Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c06b27c73 | |||
| 02b2ce8109 |
+22
-29
@@ -39,38 +39,31 @@ Implemented and gate-green; confirm each per `LOTUS_TESTING.md`, then delete the
|
|||||||
|
|
||||||
## 🧩 Element Call source-level items — now actionable via the fork
|
## 🧩 Element Call source-level items — now actionable via the fork
|
||||||
|
|
||||||
> 🔱 **[EC-FORK]** **UPDATE 2026-06-29: the fork is live.** We now own and
|
> 🔱 **[EC-FORK]** **UPDATE 2026-06-30: Phase 2 IMPLEMENTED.** We own and
|
||||||
> self-build Element Call (`LotusGuild/element-call` →
|
> self-build Element Call (`LotusGuild/element-call` →
|
||||||
> `@lotusguild/element-call-embedded`, Phase 1 done & cinny wired). A5/A6/A7
|
> `@lotusguild/element-call-embedded@0.20.1-lotus.1`, cinny wired). A5/A6/A7
|
||||||
> below are **no longer "won't fix"** — they are ordinary source changes. See
|
> below are **fixed in the fork** — they are now ⚠️ awaiting **live
|
||||||
> [`HANDOFF_ELEMENT_CALL_FORK.md`](./HANDOFF_ELEMENT_CALL_FORK.md) §10 + the Phase
|
> verification** (`LOTUS_TESTING.md` §D2), not open work. See
|
||||||
> 2 work list. (The iframe is **same-origin** / self-hosted; the old blocker was
|
> [`HANDOFF_ELEMENT_CALL_FORK.md`](./HANDOFF_ELEMENT_CALL_FORK.md) §10. Delete each
|
||||||
> that we didn't own EC's compiled source — which we now do.)
|
> row once verified live.
|
||||||
|
|
||||||
The in-call participant grid is rendered **inside EC's app**. Previously a
|
The in-call participant grid is rendered **inside EC's app** — now editable source
|
||||||
pre-built npm bundle we could only style/place around; now editable source.
|
(previously a prebuilt npm bundle we could only style around). Status of the items
|
||||||
Items from testing, with their fork-level fix path:
|
from testing:
|
||||||
|
|
||||||
- **A5 — "Focus camera":** EC supports native tile-pinning. Our bottom-bar "Focus
|
- **A5 — "Focus camera": ⚠️ FIXED in fork, awaiting verify (D2-3).** cinny now
|
||||||
camera" is a programmatic wrapper that **`.click()`s the tile** today
|
sends an `io.lotus.focus_participant` widget action that pins a participant in
|
||||||
(`CallControl.ts` `focusCameraParticipant`), and during a screenshare EC
|
EC's layout (coexisting with / overriding the screenshare spotlight); the old
|
||||||
spotlights the shared screen so a camera pin may not override it. **Fork fix:**
|
`.click()`-the-tile DOM hack in `CallControl.ts` is deleted.
|
||||||
add an `io.lotus.focus_participant` widget action that pins a participant in
|
- **A6 — avatar decorations in-call: ⚠️ FIXED in fork, awaiting verify (D2-4).**
|
||||||
EC's layout (coexisting with / overriding the screenshare spotlight); cinny
|
cinny pushes `io.lotus.decorations` (per-user APNG URLs) and the fork renders
|
||||||
sends it via the widget API and the DOM-click hack is deleted. _Status: Open —
|
them on EC's participant video-tile avatars — not just our pre-join lobby roster.
|
||||||
Actionable (Phase 2)._
|
- **A7 — mic dead after EC's "Reconnect": ⚠️ FIXED in fork, awaiting verify
|
||||||
- **A6 — avatar decorations in-call:** decorations render on **our** pre-join
|
(D2-1).** Denoise moved into EC's mic-capture/publish pipeline as a first-class
|
||||||
lobby roster (`CallMemberCard`) but not on EC's in-call video tiles. **Fork
|
LiveKit `TrackProcessor` (flag `lotusDenoiseSource=1`); EC re-runs it on every
|
||||||
fix:** render the decoration APNG inside EC's participant-tile component, fed
|
(re)publish, so reconnects keep denoise alive natively. The build-time
|
||||||
decoration slugs via widget member data. _Status: Open — Actionable (Phase 2)._
|
`getUserMedia`/`index.html` injection (the root cause) is removed. **Highest
|
||||||
- **A7 — mic dead after EC's "Reconnect":** the mid-call "Connection lost /
|
blast radius — everyone's mic; verify D2-1 carefully.**
|
||||||
Reconnect" screen is **EC's own** (our load watchdog only covers an initial
|
|
||||||
hung load). After EC reconnects, the mic isn't re-published through our denoise
|
|
||||||
`getUserMedia` shim until a clean End+rejoin. **Fork fix:** move denoise into
|
|
||||||
EC's mic-capture/publish pipeline as a first-class audio stage — EC re-runs it
|
|
||||||
on every (re)publish, so reconnects keep denoise alive natively, and the
|
|
||||||
build-time `index.html` injection is removed. _Status: Open — Actionable
|
|
||||||
(Phase 2); root cause is the `getUserMedia` monkeypatch, not EC itself._
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+111
-15
@@ -322,14 +322,104 @@ Users can set a custom background color for `@mention` chips that highlight thei
|
|||||||
|
|
||||||
## Voice / Video Call Improvements
|
## Voice / Video Call Improvements
|
||||||
|
|
||||||
> 🔱 **[EC-FORK]** Element Call is embedded as a **pre-built npm bundle** today.
|
> 🔱 **[EC-FORK] LIVE (2026-06).** Element Call is now our **self-built fork**
|
||||||
> The plan to fork & self-build it from source for true ownership — and which of
|
> (`@lotusguild/element-call-embedded@0.20.1-lotus.1`, source at
|
||||||
> the items below would move into our EC source — is in
|
> `LotusGuild/element-call`), served same-origin — no longer the upstream
|
||||||
|
> pre-built npm bundle. Several in-call behaviors below are now first-class
|
||||||
|
> source changes rather than DOM/widget hacks. Background, plan, and the Phase-2
|
||||||
|
> work list are in
|
||||||
> [`HANDOFF_ELEMENT_CALL_FORK.md`](./HANDOFF_ELEMENT_CALL_FORK.md).
|
> [`HANDOFF_ELEMENT_CALL_FORK.md`](./HANDOFF_ELEMENT_CALL_FORK.md).
|
||||||
|
|
||||||
### Element Call Upgrade
|
### Element Call — Self-Built Fork (`0.20.1-lotus.1`)
|
||||||
|
|
||||||
Upgraded embedded Element Call widget from **0.16.3** to **0.19.4**.
|
The embedded widget was upgraded **0.16.3 → 0.19.4 → 0.20.1**, then **forked**.
|
||||||
|
We self-build `LotusGuild/element-call` and publish it to our private Gitea npm
|
||||||
|
registry as `@lotusguild/element-call-embedded`; cinny consumes that instead of
|
||||||
|
`@element-hq/element-call-embedded`. The iframe prints
|
||||||
|
`Element Call embedded-v0.20.1-lotus.1` in its console (vs. `embedded-v0.20.1`
|
||||||
|
upstream) — the quickest way to confirm a deploy landed the fork.
|
||||||
|
|
||||||
|
All custom behavior lives in the fork's `src/lotus/` modules and is **additive
|
||||||
|
and dormant by default**, gated by URL flags / widget actions the host opts into,
|
||||||
|
so a stock EC config is byte-for-byte upstream behavior.
|
||||||
|
|
||||||
|
**Active (cinny drives them today):**
|
||||||
|
|
||||||
|
| # | Feature | Mechanism | Replaces (old hack) |
|
||||||
|
| --- | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| A7 | **Denoise in-source** | ML noise suppression runs inside EC as a LiveKit `TrackProcessor<Audio>` (flag `lotusDenoiseSource=1`); re-applied on every (re)publish | the build-time `getUserMedia` monkeypatch injected into `index.html` — **removed**. Fixes mic-dead-after-reconnect. |
|
||||||
|
| #2 | **Speaking / mute events** | EC emits `io.lotus.call_state` (throttled); cinny reads speaker + mute state from it (flag `lotusCallState=1`) | scraping EC's DOM for `[data-lk-speaking]` (kept only as fallback) |
|
||||||
|
| A5 | **Focus participant** | host sends `io.lotus.focus_participant` to pin a tile, coexisting with / overriding the screenshare spotlight | the `.click()`-the-tile DOM hack in `CallControl.ts` — **removed** |
|
||||||
|
| #6 | **In-call avatar decorations** | host pushes `io.lotus.decorations` (per-user APNG URLs); the fork renders them on EC's video-tile avatars | previously impossible — decorations only showed on our pre-join lobby roster |
|
||||||
|
| #5 | **Native transparent background** | flag `lotusTransparent=1` makes EC's surface transparent so the host wallpaper shows through | the injected `background:none !important` CSS |
|
||||||
|
|
||||||
|
**Now wired (cinny drives them — ⚠️ awaiting live verification):**
|
||||||
|
|
||||||
|
| # | Capability | Widget action | cinny surface |
|
||||||
|
| ----- | -------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------- |
|
||||||
|
| P5-15 | **Audio inject** | `io.lotus.inject_audio` — plays a clip into the call as a separately published track | In-Call Soundboard (uploadable clips) — see below |
|
||||||
|
| P5-31 | **Quality controls** | `io.lotus.set_quality` — sets audio/screenshare encoding bitrate/framerate | Call Quality Controls (user settings + room-admin caps) — see below |
|
||||||
|
|
||||||
|
> Both were dormant capabilities; cinny now drives them (armed via
|
||||||
|
> `lotusAudioInject=1`). The **only** EC item still open is the P5-31
|
||||||
|
> **server-side** quality guard (a `voice-limit-guard`-style sidecar reading
|
||||||
|
> `io.lotus.room_quality`) for hard enforcement across all Matrix clients — the
|
||||||
|
> client cap is best-effort.
|
||||||
|
|
||||||
|
### In-Call Soundboard (P5-15)
|
||||||
|
|
||||||
|
A soundboard button (🔔) in the call controls bar opens a popout of the user's
|
||||||
|
clips. Clicking one **injects it into the call as a real published LiveKit
|
||||||
|
track** (every participant hears it, via the fork's `io.lotus.inject_audio`) and
|
||||||
|
plays it locally for the presser (LiveKit doesn't loop your own track back).
|
||||||
|
|
||||||
|
- **User-uploadable, like custom emoji/sticker packs.** Clips are stored in the
|
||||||
|
`io.lotus.soundboard` account data event, so they **sync across all your
|
||||||
|
devices**. Upload short audio (≤ 1 MB, ≤ 40 clips) from the popout; delete
|
||||||
|
inline.
|
||||||
|
- Authenticated media can't be fetched from the widget's realm, so the host
|
||||||
|
resolves each mxc clip → an authenticated download → a same-session `blob:`
|
||||||
|
object URL and hands that to the widget.
|
||||||
|
- Gated by the **Soundboard** toggle (Settings → General → Calls) with a volume
|
||||||
|
slider. The button is hidden when disabled.
|
||||||
|
- Files: `utils/soundboardClips.ts`, `hooks/useSoundboard.ts`,
|
||||||
|
`features/call/CallSoundboard.tsx`, `plugins/call/CallControl.ts#injectAudio`.
|
||||||
|
|
||||||
|
### Call Quality Controls (P5-31)
|
||||||
|
|
||||||
|
Discord-style encoding controls applied to the local tracks via the fork's
|
||||||
|
`io.lotus.set_quality` (`RTCRtpSender.setParameters` across all simulcast
|
||||||
|
encodings, re-applied on every re-publish/reconnect).
|
||||||
|
|
||||||
|
- **User settings** (Settings → General → Calls): Microphone Bitrate,
|
||||||
|
Screenshare Bitrate, Screenshare Framerate (each defaults to **Auto**).
|
||||||
|
- **Room-admin caps**: admins set a ceiling in Room Settings → General → Voice
|
||||||
|
(`io.lotus.room_quality` state event); every Lotus client clamps its per-user
|
||||||
|
quality to `min(user setting, room cap)`.
|
||||||
|
- Applied by the `useCallQuality` hook on join and whenever settings/caps
|
||||||
|
change; `utils/callQuality.ts` builds the payload (unit-tested).
|
||||||
|
|
||||||
|
**Server-enforced call permissions (hard, ALL clients).** The same
|
||||||
|
`io.lotus.room_quality` event carries a **publish-source policy**
|
||||||
|
(`allow_screenshare`, `allow_camera`) enforced server-side by
|
||||||
|
`voice-limit-guard` (matrix repo, LXC 151): it re-signs the LiveKit JWT's
|
||||||
|
`canPublishSources`, so the SFU refuses screenshare/camera tracks for **every**
|
||||||
|
Matrix client (Element, FluffyChat, our fork) — not just Lotus. Admins toggle
|
||||||
|
these in Room Settings → Voice → **Call Permissions**; cinny also hides the
|
||||||
|
blocked buttons in the call bar. Enforcement is **live**: the JWT re-sign covers
|
||||||
|
new joins, and a background reconcile loop revokes an **in-progress**
|
||||||
|
screenshare/camera (via LiveKit `UpdateParticipant`) within ~3 s of an admin
|
||||||
|
flipping the policy — so it kills active shares mid-call, not just future ones.
|
||||||
|
|
||||||
|
- **Why numeric caps aren't server-enforced:** LiveKit is a pure SFU (forwards,
|
||||||
|
never transcodes) and has no publisher bitrate/fps field anywhere in the JWT
|
||||||
|
grant, room config, server `limit:`, or admin API; stock Element Call ignores
|
||||||
|
room metadata for publish quality. Numeric caps are therefore inherently
|
||||||
|
**cooperative** — our fork honors them, which is the design above. The
|
||||||
|
publish-source policy is the one genuine hard, cross-client lever, and it's
|
||||||
|
implemented.
|
||||||
|
- **Not yet**: screenshare resolution control (needs a `getDisplayMedia` hook in
|
||||||
|
the fork).
|
||||||
|
|
||||||
### Camera Default Off
|
### Camera Default Off
|
||||||
|
|
||||||
@@ -431,20 +521,26 @@ A comprehensive mic noise-suppression system in **Settings → General → Calls
|
|||||||
- **Support Detection:** UI now detects `AudioWorklet` / `AudioContext` support and disables ML options in unsupported environments.
|
- **Support Detection:** UI now detects `AudioWorklet` / `AudioContext` support and disables ML options in unsupported environments.
|
||||||
- **Status Reporting:** The ML shim notifies the host app via `postMessage`. If initialization fails, a system toast alerts the user of the fallback to the raw microphone.
|
- **Status Reporting:** The ML shim notifies the host app via `postMessage`. If initialization fails, a system toast alerts the user of the fallback to the raw microphone.
|
||||||
|
|
||||||
**Open-Source Model Roadmap:**
|
**Open-Source Models (all now in-source in the EC fork):**
|
||||||
| Model | Transients (Clicks) | Voice Quality | CPU Usage (WASM) |
|
| Model | Transients (Clicks) | Voice Quality | CPU Usage (WASM) | Sample rate |
|
||||||
| :--- | :--- | :--- | :--- |
|
| :--- | :--- | :--- | :--- | :--- |
|
||||||
| **RNNoise** | Poor | Moderate | < 5% |
|
| **RNNoise** (default) | Poor | Moderate | < 5% | 48 kHz |
|
||||||
| **DTLN** | Good | High | 10-20% |
|
| **Speex** | Poor | Low | < 5% | 48 kHz |
|
||||||
| **DeepFilterNet 3** | **Excellent** | **Very High** | 25-50%+ |
|
| **DTLN** | Good | High | 10-20% | 16 kHz |
|
||||||
|
| **DeepFilterNet 3** | **Excellent** | **Very High** | 25-50%+ | 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. Real-call
|
||||||
|
> **audio-quality** comparison across models is still the open verification item
|
||||||
|
> (RNNoise output is known to be weak) — see `LOTUS_TESTING.md` §D2-1.
|
||||||
|
|
||||||
### Files
|
### Files
|
||||||
|
|
||||||
- `build/lotus-denoise.js` — multi-model getUserMedia shim
|
- **EC fork** `src/lotus/lotusDenoise.ts` + `lotusDenoiseProcessor.ts` — in-source LiveKit `TrackProcessor` (RNNoise/Speex 48 kHz, DTLN 16 kHz, DeepFilterNet 48 kHz); activated by `lotusDenoiseSource=1`. (The old build-time `getUserMedia` shim `build/lotus-denoise.js` is **removed**.)
|
||||||
- `vite.config.js` — `lotusDenoise()` plugin (copies assets for RNNoise, Speex, and NoiseGate)
|
- `vite.config.js` — `lotusDenoise()` plugin (now only **copies model assets** for the fork to load; no longer injects a shim)
|
||||||
- `src/app/plugins/call/CallEmbed.ts` — advanced tier → widget URL params
|
- `src/app/plugins/call/CallEmbed.ts` — advanced tier → `lotusDenoiseSource` widget URL param
|
||||||
- `src/app/utils/lotusDenoiseUtils.ts` — support detection and model comparison metadata
|
- `src/app/utils/lotusDenoiseUtils.ts` — support detection and model comparison metadata
|
||||||
- `src/app/features/settings/general/General.tsx` — advanced settings UI + mic meter
|
- `src/app/features/settings/general/General.tsx` — advanced settings UI + mic meter
|
||||||
|
|
||||||
|
|||||||
+51
-4
@@ -267,12 +267,59 @@ Flag: `lotusTransparent=1` (native, replacing the injected `background:none !imp
|
|||||||
- [ ] Call background looks right — host wallpaper/surface shows through; **no** black box, bad
|
- [ ] 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").
|
see-through, or layout breakage (also covered loosely by §D2 "looks right").
|
||||||
|
|
||||||
### D2-6. Dormant features — confirm they do NOTHING (no regression)
|
### D2-7. In-Call Soundboard (#3 / P5-15) — 👥 2 people — **NEW**
|
||||||
|
|
||||||
EC ships the capability but cinny has **no UI** to trigger them yet:
|
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).
|
||||||
|
|
||||||
- [ ] **Soundboard audio-inject (#3)** and **quality controls (#7)** — there should be no new UI and no
|
- [ ] **Upload:** open the soundboard popout → **Upload** → pick a short audio file (mp3/ogg/wav, ≤ 1 MB).
|
||||||
effect. (Nothing to test; noted so a tester doesn't go hunting.)
|
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
|
> 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.
|
> widget-action/payload mismatch shows up there as a `io.lotus.*` rejection or a `MissingKey`/transport log.
|
||||||
|
|||||||
+65
-45
@@ -48,6 +48,9 @@ Built and gate-green; verify per [LOTUS_TESTING.md](./LOTUS_TESTING.md), then th
|
|||||||
| Desktop — proactive update notifications (Tauri) | J1 |
|
| Desktop — proactive update notifications (Tauri) | J1 |
|
||||||
| Remind Me Later | K1 |
|
| Remind Me Later | K1 |
|
||||||
| Mobile Bookmarks access | E5 |
|
| Mobile Bookmarks access | E5 |
|
||||||
|
| In-Call Soundboard (P5-15, uploadable clips → real call inject) | D2-7 |
|
||||||
|
| Call Quality Controls (P5-31, user + room-admin caps) | D2-8 |
|
||||||
|
| Call Permissions (P5-31, hard server-side screenshare/camera policy) | D2-9 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -72,32 +75,32 @@ Status: `[ ]` pending · `[~]` in progress · `[x]` completed
|
|||||||
|
|
||||||
### Confirmed facts
|
### Confirmed facts
|
||||||
|
|
||||||
| Finding | Impact |
|
| Finding | Impact |
|
||||||
| --------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
|
| -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
|
||||||
| **MSC flags ON:** `msc4140` · `msc3771` · `msc3440.stable` · `msc4133.stable` · `simplified_msc3575` · `msc4222` · `msc3266` · `msc3401_matrix_rtc` | All safe to use now |
|
| **MSC flags ON:** `msc4140` · `msc3771` · `msc3440.stable` · `msc4133.stable` · `simplified_msc3575` · `msc4222` · `msc3266` · `msc3401_matrix_rtc` | All safe to use now |
|
||||||
| **MSC flags OFF:** `msc4306` (thread subscriptions) · `msc3882` · `msc3912` · `msc4155` | These features are BLOCKED |
|
| **MSC flags OFF:** `msc4306` (thread subscriptions) · `msc3882` · `msc3912` · `msc4155` | These features are BLOCKED |
|
||||||
| **MSC3266** room summary: flag `msc3266_enabled: true` set but `GET /v1/rooms/{id}/summary` still returns 404 (M_UNRECOGNIZED) | Room Preview BLOCKED — endpoint not implemented in Synapse 1.155 |
|
| **MSC3266** room summary: flag `msc3266_enabled: true` set but `GET /v1/rooms/{id}/summary` still returns 404 (M_UNRECOGNIZED) | Room Preview BLOCKED — endpoint not implemented in Synapse 1.155 |
|
||||||
| **MSC3892** relation redaction: not in flags | Reaction Redaction feature BLOCKED |
|
| **MSC3892** relation redaction: not in flags | Reaction Redaction feature BLOCKED |
|
||||||
| **MSC4260** report user: `POST /_matrix/client/v3/users/{userId}/report` returns **200** ✅ | **Report User UNBLOCKED** — endpoint live since Synapse 1.133; ready to build |
|
| **MSC4260** report user: `POST /_matrix/client/v3/users/{userId}/report` returns **200** ✅ | **Report User UNBLOCKED** — endpoint live since Synapse 1.133; ready to build |
|
||||||
| **MSC4151** report room: HTTP 405 on GET = endpoint exists (POST only) | Report Room live ✅ |
|
| **MSC4151** report room: HTTP 405 on GET = endpoint exists (POST only) | Report Room live ✅ |
|
||||||
| `folds AvatarImage` does NOT accept children | Add frame/overlay inside `UserAvatar.tsx` itself — optional `frameName` prop |
|
| `folds AvatarImage` does NOT accept children | Add frame/overlay inside `UserAvatar.tsx` itself — optional `frameName` prop |
|
||||||
| No in-app toast system exists (was) | Built `ToastProvider` + Jotai queue; at `App.tsx:65` |
|
| No in-app toast system exists (was) | Built `ToastProvider` + Jotai queue; at `App.tsx:65` |
|
||||||
| `useUnverifiedDeviceCount()` hook exists | `src/app/hooks/useDeviceVerificationStatus.ts:65-106` |
|
| `useUnverifiedDeviceCount()` hook exists | `src/app/hooks/useDeviceVerificationStatus.ts:65-106` |
|
||||||
| Voice player: `AudioContent.tsx:44-223` | Playback rate on hidden `<audio>` at line 217 |
|
| Voice player: `AudioContent.tsx:44-223` | Playback rate on hidden `<audio>` at line 217 |
|
||||||
| `CallControl.setMicrophone(bool)` at `CallControl.ts:206-212` | For AFK auto-mute |
|
| `CallControl.setMicrophone(bool)` at `CallControl.ts:206-212` | For AFK auto-mute |
|
||||||
| `CallControl.toggleSound()` at `CallControl.ts:230-251` | Push-to-deafen — just wire a hotkey to this |
|
| `CallControl.toggleSound()` at `CallControl.ts:230-251` | Push-to-deafen — just wire a hotkey to this |
|
||||||
| matrix-js-sdk has NO arbitrary profile field methods | Use `mx.http.authedRequest()` for MSC4133 |
|
| matrix-js-sdk has NO arbitrary profile field methods | Use `mx.http.authedRequest()` for MSC4133 |
|
||||||
| Sanitizer (`sanitize.ts`) allows table, div, span, a, code, hr | LFG HTML card is safe locally; test on Element/FluffyChat |
|
| Sanitizer (`sanitize.ts`) allows table, div, span, a, code, hr | LFG HTML card is safe locally; test on Element/FluffyChat |
|
||||||
| Sanitizer STRIPS `<math>`/MathML tags | Math/LaTeX task must also modify sanitizer |
|
| Sanitizer STRIPS `<math>`/MathML tags | Math/LaTeX task must also modify sanitizer |
|
||||||
| Service worker EXISTS at `src/sw.ts` | Quick-reply task: add `notificationclick` handler |
|
| Service worker EXISTS at `src/sw.ts` | Quick-reply task: add `notificationclick` handler |
|
||||||
| `knockSupported()` utility exists at `matrix.ts:376-391` | Knock UX: only need "Request to Join" in `RoomIntro.tsx` |
|
| `knockSupported()` utility exists at `matrix.ts:376-391` | Knock UX: only need "Request to Join" in `RoomIntro.tsx` |
|
||||||
| `KeywordMessages.tsx` already has custom keyword push rules | Full push rule editor: only non-keyword rule types need new UI |
|
| `KeywordMessages.tsx` already has custom keyword push rules | Full push rule editor: only non-keyword rule types need new UI |
|
||||||
| `getMatrixToRoom()` in `matrix-to.ts` generates invite URLs | Invite link: just add QR code to room settings |
|
| `getMatrixToRoom()` in `matrix-to.ts` generates invite URLs | Invite link: just add QR code to room settings |
|
||||||
| Cindy CANNOT inject audio into EC call stream | In-call soundboard must be redesigned as local-only |
|
| ~~Cindy CANNOT inject audio into EC call stream~~ **UNBLOCKED by EC fork** — `io.lotus.inject_audio` widget action publishes a clip as a real call track | In-call soundboard CAN now mix into the call (no longer local-only); needs cinny UI to drive the action |
|
||||||
| Folds uses vanilla-extract in non-TDS, NOT CSS custom properties | Custom accent color: must create new vanilla-extract theme variant dynamically |
|
| Folds uses vanilla-extract in non-TDS, NOT CSS custom properties | Custom accent color: must create new vanilla-extract theme variant dynamically |
|
||||||
| Theme presets need ~50 CSS custom properties each | Significant design work before coding |
|
| Theme presets need ~50 CSS custom properties each | Significant design work before coding |
|
||||||
| `useCallSpeakers.ts` CSS MutationObserver polling | Visual speaking indicator: TDS ring animation on top of existing data |
|
| `useCallSpeakers.ts` CSS MutationObserver polling | Visual speaking indicator: TDS ring animation on top of existing data |
|
||||||
| MSC3489/3672 live location: BOTH false on server | Live Location BLOCKED |
|
| MSC3489/3672 live location: BOTH false on server | Live Location BLOCKED |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -266,12 +269,17 @@ Features:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### [ ] P5-15 · In-Call Soundboard
|
### [~] P5-15 · In-Call Soundboard — IMPLEMENTED (⚠️ awaiting live verification, D2-7)
|
||||||
|
|
||||||
**What:** Grid of short audio clips playable into the call audio stream via Web Audio API (AudioBufferSourceNode → MediaStreamDestinationNode → mixed with mic). Built-in clips + user-uploadable custom clips (stored as mxc://). Accessible from call controls bar.
|
**What:** Soundboard button in the call controls bar → popout grid of the user's clips; clicking one plays it **into the call** as a real published track (peers hear it) and locally (presser hears it). Clips are **user-uploadable, just like custom emojis/stickers**.
|
||||||
**[AUDIT REQUIRED]** Verify the Element Call integration exposes the mic MediaStream for mixing. This is the highest-risk part of this feature.
|
**🔱 [EC-FORK] Fork side + cinny side DONE.** The fork ships `io.lotus.inject_audio` (`LotusWidgetActions.InjectAudio`, allow-listed in `widget.ts`), armed via the `lotusAudioInject=1` flag; it publishes a clip as a separate LiveKit track — a **real** in-call soundboard mixed into the call, not local-only. cinny now drives it.
|
||||||
**🔱 [EC-FORK]** Owning the EC source (see [`HANDOFF_ELEMENT_CALL_FORK.md`](./HANDOFF_ELEMENT_CALL_FORK.md)) would unblock real audio-injection — a proper soundboard mixed into the call — which is impossible against the prebuilt bundle today.
|
**Shipped (cinny):**
|
||||||
**Complexity:** High.
|
|
||||||
|
- Clips stored in `io.lotus.soundboard` account data → **synced across devices like emoji/sticker packs** (`useSoundboard` hook; `AccountDataEvent.LotusSoundboard`).
|
||||||
|
- Upload audio (≤1 MB, ≤40 clips) → `mx.uploadContent` → mxc; play resolves mxc → authed download → `blob:` object URL (the widget can't fetch authenticated media itself) → `control.injectAudio(url, volume)` + local playback.
|
||||||
|
- `CallSoundboard.tsx` popout in the call bar (upload / play / delete), gated on the `soundboardEnabled` setting (Settings → General → Calls, + volume slider).
|
||||||
|
**Remaining:** a dedicated Settings management page (optional — upload/delete already live in the popout); a small default clip set; live verification (D2-7). Files: `utils/soundboardClips.ts`, `hooks/useSoundboard.ts`, `features/call/CallSoundboard.tsx`, `plugins/call/CallControl.ts#injectAudio`.
|
||||||
|
**Complexity:** Medium — done.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -287,26 +295,38 @@ Features:
|
|||||||
### [x] P5-30 · Advanced ML Noise Suppression (Krisp-style)
|
### [x] P5-30 · Advanced ML Noise Suppression (Krisp-style)
|
||||||
|
|
||||||
**What:** High-end background noise cancellation using a pre-trained ML model (RNNoise) running in the browser. Removes dogs, fans, and keyboard clicks from the mic stream.
|
**What:** High-end background noise cancellation using a pre-trained ML model (RNNoise) running in the browser. Removes dogs, fans, and keyboard clicks from the mic stream.
|
||||||
**Shipped:** 3-tier setting (Off / Browser-native / ML) in Settings → General → Calls. ML tier injects a same-origin pre-init shim into the vendored Element Call `index.html` that monkeypatches `getUserMedia` and routes the captured mic through an RNNoise `AudioWorklet` before LiveKit publishes — no EC fork required. See LOTUS_FEATURES.md → "Noise Suppression (Advanced Multi-Tier)".
|
**Shipped:** 3-tier setting (Off / Browser-native / ML) in Settings → General → Calls.
|
||||||
**🔱 [EC-FORK]** Once we own the EC source (see [`HANDOFF_ELEMENT_CALL_FORK.md`](./HANDOFF_ELEMENT_CALL_FORK.md)), denoise should become a first-class audio stage **inside** EC instead of an `index.html` getUserMedia monkeypatch — more robust, survives reconnects (fixes the A7 mic-after-reconnect bug), and removes the build-time injection hack.
|
**🔱 [EC-FORK] DONE — moved in-source (2026-06).** ML denoise is now a first-class audio stage **inside** the forked Element Call: a LiveKit `TrackProcessor<Audio>` activated by `lotusDenoiseSource=1` (cinny sets it when ML is selected). The old build-time `getUserMedia`/`index.html` monkeypatch is **removed**. Because EC re-runs the processor on every (re)publish, denoise now **survives reconnects and mic-device switches** — this is the A7 fix (see `LOTUS_BUGS.md` A7, `LOTUS_TESTING.md` §D2-1). The processor degrades to the raw mic rather than going silent.
|
||||||
**Key decision:** LiveKit's Krisp filter is LiveKit-Cloud-only (we self-host the SFU); EC's own RNNoise PR #3892 is unmerged. The shim is the same post-capture pipeline #3892 uses, executed from the realm we control, so it survives EC version bumps.
|
**Key decision:** LiveKit's Krisp filter is LiveKit-Cloud-only (we self-host the SFU); EC's own RNNoise PR #3892 is unmerged. Owning the fork let us implement the in-source stage directly.
|
||||||
**AEC note (resolved-as-accepted):** WebAudio capture routing can weaken browser AEC — same tradeoff as EC's upstream feature; mitigated by keeping `echoCancellation`/`autoGainControl` on the raw capture and labeling the tier "beta".
|
|
||||||
|
|
||||||
**Model Roadmap (priority order):**
|
**Models — all in-source in the fork:**
|
||||||
|
|
||||||
- [ ] **Verify DTLN** (16 kHz narrowband fix) in a real call before investing further — wired but unverified.
|
- [x] **RNNoise** (48 kHz, default) · **Speex** (48 kHz) · **DTLN** (16 kHz) · **DeepFilterNet 3** (48 kHz) — all four wired and selectable.
|
||||||
- [ ] **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.
|
- [ ] **Open verification:** real-call **audio-quality** comparison across the four models (RNNoise output is known-weak). Track under the denoise quality project, `LOTUS_TESTING.md` §D2-1 / J2.
|
||||||
- [ ] **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.
|
- [ ] **Desktop-only / HW-gated (future):** FRCRN or NVIDIA Maxine (RTX/Tensor only) — impossible in-browser; would run in the Tauri Rust backend + bridge a virtual mic into the webview. Detect capability; web falls back to RNNoise.
|
||||||
- **Excluded:** Krisp (LiveKit Cloud only); FRCRN/Maxine on web (GPU/server-bound).
|
- **Excluded:** Krisp (LiveKit Cloud only); FRCRN/Maxine on web (GPU/server-bound).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### [ ] P5-31 · Granular Voice & Screenshare Quality Controls (Discord-style)
|
### [~] P5-31 · Granular Voice & Screenshare Quality Controls — IMPLEMENTED (⚠️ awaiting live verification, D2-8)
|
||||||
|
|
||||||
**What:** Let users (or room admins via room settings) adjust audio bitrates (e.g., 64kbps to 512kbps) and screenshare quality (resolution: 720p/1080p/Source, framerate: 15/30/60fps).
|
**What:** Let users (and room admins) adjust audio bitrate and screenshare bitrate/framerate.
|
||||||
**Note:** Requires tight integration with the LiveKit SFU and custom state events for per-room quality caps.
|
**🔱 [EC-FORK] Fork side + client side DONE.** The fork ships `io.lotus.set_quality` (`LotusWidgetActions.SetQuality`) that applies audio/screenshare encoding params (`RTCRtpSender.setParameters`, all simulcast encodings, re-applied on `TrackUnmuted`/republish) inside EC. cinny now drives it.
|
||||||
**[AUDIT REQUIRED]** Must verify if current `lk-jwt-service` can be extended with custom bitrate/resolution claims or if a new sidecar (similar to `voice-limit-guard`) is needed for server-side enforcement.
|
|
||||||
**Complexity:** Extreme.
|
**Shipped (cinny):**
|
||||||
|
|
||||||
|
1. **User settings** (Settings → General → Calls): Microphone Bitrate, Screenshare Bitrate, Screenshare Framerate (`callAudioBitrate` / `screenshareBitrate` / `screenshareFramerate`).
|
||||||
|
2. **Room-admin caps**: `io.lotus.room_quality` state event (`StateEvent.LotusRoomQuality`) + `RoomQuality.tsx` in Room Settings → General → Voice (mirrors `RoomVoiceLimit`).
|
||||||
|
3. **Apply logic**: `useCallQuality` (wired in `CallEmbedProvider`'s `CallUtils`) builds `min(user setting, room cap)` and sends `io.lotus.set_quality` on join / when settings change (`utils/callQuality.ts`, unit-tested).
|
||||||
|
|
||||||
|
**Server-side enforcement (DONE — matrix repo):** extended `voice-limit-guard.py` (LXC 151) to also read `io.lotus.room_quality` and hard-enforce a **publish-source policy** for ALL clients.
|
||||||
|
|
||||||
|
- **Reality (researched, primary-source, LiveKit 1.9.11):** numeric bitrate/fps caps **cannot** be hard-enforced server-side — LiveKit is a pure SFU (forwards, never transcodes); there is NO bitrate/fps field in the JWT grant, `RoomConfiguration`, server `limit:` config, or any admin RPC, and stock Element Call ignores room metadata / custom claims for publish quality. So numeric caps stay **cooperative** (our fork honors them via `min()` → `set_quality`, already shipped).
|
||||||
|
- **What IS hard-enforced cross-client:** `VideoGrant.canPublishSources`. The guard holds the LiveKit secret, so when `io.lotus.room_quality` sets `allow_screenshare:false` / `allow_camera:false` it re-signs the issued JWT with a narrowed source list → the SFU refuses those tracks for **every** client (Element, FluffyChat, our fork). Mic always kept. Fail-open; unit-tested (`livekit/test_voice_limit_guard.py`). Admin UI: Room Settings → Voice → **Call Permissions** switches. cinny also hides the blocked buttons.
|
||||||
|
- **Live (mid-call) enforcement — DONE:** the JWT re-sign covers new joins; for participants **already in the call**, a background reconcile loop in the guard calls LiveKit `UpdateParticipant` every ~3 s to narrow `canPublishSources`, which unpublishes an in-progress screenshare/camera **server-side for all clients** and blocks re-publish (verified LiveKit 1.9.11 auto-unpublishes on permission narrowing). Only removes forbidden sources (never grants), preserves other permission flags, no-ops once compliant. So flipping a room audio-only kills live cameras/screenshares within ~one interval.
|
||||||
|
- **Not enforceable / deferred:** numeric server enforcement (impossible — see above); screenshare **resolution** control (`set_quality` covers bitrate + framerate; resolution needs a `getDisplayMedia` hook inside the fork).
|
||||||
|
|
||||||
|
**Complexity:** DONE — client (cooperative numeric caps) + server (hard publish-source policy). Only the physically-impossible numeric server enforcement is out of scope.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -540,7 +560,7 @@ Exhaustive, low-level implementation details for backlog items. Follow these pat
|
|||||||
|
|
||||||
> ⚠️ **[Gemini_Found — CORRECTED]** Gemini originally suggested using LiveKit's `LocalAudioTrack.replaceTrack()` to mix audio into the call stream. This is **not possible** from Lotus Chat's realm: Element Call runs in a **cross-origin iframe** controlled via `matrix-widget-api` (postMessage). LiveKit's JS SDK and its `LocalAudioTrack` live inside EC's sandboxed context — inaccessible from our code. This directly contradicts the confirmed constraint already listed in the Server Capabilities table: _"Cindy CANNOT inject audio into EC call stream — In-call soundboard must be redesigned as local-only."_ The soundboard must be a local-playback-only feature (output through the user's speakers, not mixed into the call audio stream).
|
> ⚠️ **[Gemini_Found — CORRECTED]** Gemini originally suggested using LiveKit's `LocalAudioTrack.replaceTrack()` to mix audio into the call stream. This is **not possible** from Lotus Chat's realm: Element Call runs in a **cross-origin iframe** controlled via `matrix-widget-api` (postMessage). LiveKit's JS SDK and its `LocalAudioTrack` live inside EC's sandboxed context — inaccessible from our code. This directly contradicts the confirmed constraint already listed in the Server Capabilities table: _"Cindy CANNOT inject audio into EC call stream — In-call soundboard must be redesigned as local-only."_ The soundboard must be a local-playback-only feature (output through the user's speakers, not mixed into the call audio stream).
|
||||||
>
|
>
|
||||||
> 🔱 **[EC-FORK — partial correction]** The "cross-origin" claim above is **outdated**: EC is now **same-origin** / self-hosted (`iframe.sandbox` has `allow-same-origin`; we read `contentDocument`). The _practical_ blocker still holds — LiveKit's `LocalAudioTrack` lives in EC's **module scope** (not on `window`), so it's unreachable from cinny even same-origin. **Owning the EC source** (see [`HANDOFF_ELEMENT_CALL_FORK.md`](./HANDOFF_ELEMENT_CALL_FORK.md)) is the path to a real call-audio-inject API, which would unblock a true in-call soundboard.
|
> 🔱 **[EC-FORK — RESOLVED]** Both the original claim and the earlier "practical blocker still holds" correction are now **outdated**. EC is same-origin **and** we own the source, so we no longer reach into EC's module scope from cinny — instead the fork **exposes the inject point itself**: the `io.lotus.inject_audio` widget action (`LotusWidgetActions.InjectAudio`) publishes a clip as a separate LiveKit track from inside EC. A **real** in-call soundboard (mixed into the call, not local-only) is therefore unblocked; only the cinny-side UI remains (see P5-15 above). The capability ships dormant today.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -144,22 +144,25 @@ The source code lives in `/root/code/cinny`. All changes should be made on the `
|
|||||||
|
|
||||||
See [LOTUS_FEATURES.md](LOTUS_FEATURES.md) for the full feature changelog and [LOTUS_TODO.md](LOTUS_TODO.md) for the work backlog.
|
See [LOTUS_FEATURES.md](LOTUS_FEATURES.md) for the full feature changelog and [LOTUS_TODO.md](LOTUS_TODO.md) for the work backlog.
|
||||||
|
|
||||||
### 🔱 Planned: Element Call fork ("Lotus Call")
|
### 🔱 Element Call fork ("Lotus Call") — LIVE
|
||||||
|
|
||||||
Voice/video channels embed **Element Call**. Today it's a **pre-built npm bundle**
|
Voice/video channels embed **Element Call**, which is now our **self-built fork**
|
||||||
(`@element-hq/element-call-embedded` 0.20.1) copied to `public/element-call/` and
|
(`@lotusguild/element-call-embedded` `0.20.1-lotus.1`, source at
|
||||||
served same-origin; we steer it via the `matrix-widget-api` plus fragile DOM
|
`LotusGuild/element-call`), published to our private Gitea npm registry and served
|
||||||
hacks. Because we don't own its compiled source, several in-call issues (avatar
|
same-origin. We no longer depend on the upstream prebuilt bundle, so in-call
|
||||||
decorations on tiles, camera focus/fullscreen during screenshare, mic recovery
|
behavior is editable source instead of fragile DOM/widget hacks.
|
||||||
after reconnect, native theming, real call-audio injection) are unfixable from
|
|
||||||
outside.
|
|
||||||
|
|
||||||
**The plan is to fork `element-hq/element-call` into a new `LotusGuild/element-call`
|
**Shipped via the fork:** denoise as an in-source LiveKit audio stage (survives
|
||||||
repo, build it from source, and host our own build** for true ownership. The full
|
reconnects), in-call speaking/mute events, focus-a-participant during screenshare,
|
||||||
self-contained plan and integration map — written for a fresh session with no
|
avatar decorations on EC video tiles, and a native transparent background.
|
||||||
prior context — is in **[`HANDOFF_ELEMENT_CALL_FORK.md`](HANDOFF_ELEMENT_CALL_FORK.md)**.
|
**Built but dormant (need cinny UI):** real call-audio injection
|
||||||
Infra/hosting notes also live in the `LotusGuild/matrix` repo README. Search the
|
(`io.lotus.inject_audio` → in-call soundboard) and quality controls
|
||||||
docs for the **`[EC-FORK]`** tag to find every related note.
|
(`io.lotus.set_quality`).
|
||||||
|
|
||||||
|
The full plan and integration map is in
|
||||||
|
**[`HANDOFF_ELEMENT_CALL_FORK.md`](HANDOFF_ELEMENT_CALL_FORK.md)**; infra/hosting +
|
||||||
|
build-pipeline notes live in the `LotusGuild/matrix` repo README. Search the docs
|
||||||
|
for the **`[EC-FORK]`** tag to find every related note.
|
||||||
|
|
||||||
### Build
|
### Build
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import { useMatrixClient } from '../hooks/useMatrixClient';
|
|||||||
import { previewRingtone, startRingtone } from '../utils/ringtones';
|
import { previewRingtone, startRingtone } from '../utils/ringtones';
|
||||||
import { useCallMembersChange, useCallSession } from '../hooks/useCall';
|
import { useCallMembersChange, useCallSession } from '../hooks/useCall';
|
||||||
import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds';
|
import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds';
|
||||||
|
import { useCallQuality } from '../hooks/useCallQuality';
|
||||||
import { useRemoteAllMuted } from '../hooks/useCallSpeakers';
|
import { useRemoteAllMuted } from '../hooks/useCallSpeakers';
|
||||||
import { useRoomAvatar, useRoomName } from '../hooks/useRoomMeta';
|
import { useRoomAvatar, useRoomName } from '../hooks/useRoomMeta';
|
||||||
import { mDirectAtom } from '../state/mDirectList';
|
import { mDirectAtom } from '../state/mDirectList';
|
||||||
@@ -584,6 +585,7 @@ function CallUtils({ embed }: { embed: CallEmbed }) {
|
|||||||
useCallMemberSoundSync(embed);
|
useCallMemberSoundSync(embed);
|
||||||
useCallJoinLeaveSounds(embed);
|
useCallJoinLeaveSounds(embed);
|
||||||
useCallThemeSync(embed);
|
useCallThemeSync(embed);
|
||||||
|
useCallQuality(embed);
|
||||||
useCallHangupEvent(
|
useCallHangupEvent(
|
||||||
embed,
|
embed,
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ import { stopPropagation } from '../../utils/keyboard';
|
|||||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
import { useCallEmbedRef } from '../../hooks/useCallEmbed';
|
import { useCallEmbedRef } from '../../hooks/useCallEmbed';
|
||||||
import { useAfkAutoMute } from '../../hooks/useAfkAutoMute';
|
import { useAfkAutoMute } from '../../hooks/useAfkAutoMute';
|
||||||
|
import { CallSoundboard } from './CallSoundboard';
|
||||||
|
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||||
|
import { StateEvent } from '../../../types/matrix/room';
|
||||||
|
import { RoomQualityContent } from '../../utils/callQuality';
|
||||||
|
|
||||||
type CallControlsProps = {
|
type CallControlsProps = {
|
||||||
callEmbed: CallEmbed;
|
callEmbed: CallEmbed;
|
||||||
@@ -88,6 +92,19 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||||||
const [pttMode] = useSetting(settingsAtom, 'pttMode');
|
const [pttMode] = useSetting(settingsAtom, 'pttMode');
|
||||||
const [pttKey] = useSetting(settingsAtom, 'pttKey');
|
const [pttKey] = useSetting(settingsAtom, 'pttKey');
|
||||||
const [deafenKey] = useSetting(settingsAtom, 'deafenKey');
|
const [deafenKey] = useSetting(settingsAtom, 'deafenKey');
|
||||||
|
const [soundboardEnabled] = useSetting(settingsAtom, 'soundboardEnabled');
|
||||||
|
|
||||||
|
// [P5-31] Hard room publish policy — hide controls the server will refuse so
|
||||||
|
// users don't click dead buttons. Absent/true = allowed.
|
||||||
|
const roomQualityEvent = useStateEvent(callEmbed.room, StateEvent.LotusRoomQuality);
|
||||||
|
const roomQuality = roomQualityEvent?.getContent<RoomQualityContent>();
|
||||||
|
const cameraAllowed = roomQuality?.allow_camera !== false;
|
||||||
|
const screenshareAllowed = roomQuality?.allow_screenshare !== false;
|
||||||
|
// Keep a forbidden control visible while its track is still live (so the user
|
||||||
|
// can stop it); otherwise hide it entirely.
|
||||||
|
const showCamera = cameraAllowed || video;
|
||||||
|
const showScreenshare = screenshareAllowed || screenshare;
|
||||||
|
const showVideoGroup = showCamera || showScreenshare || !!document.fullscreenEnabled;
|
||||||
const [pttActive, setPttActive] = useState(false);
|
const [pttActive, setPttActive] = useState(false);
|
||||||
|
|
||||||
// Track microphone via ref so the PTT effect doesn't need it as a dep (avoids listener churn)
|
// Track microphone via ref so the PTT effect doesn't need it as a dep (avoids listener churn)
|
||||||
@@ -339,24 +356,31 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||||||
onToggle={() => callEmbed.control.toggleScreenshareAudio()}
|
onToggle={() => callEmbed.control.toggleScreenshareAudio()}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
{!compact && <ControlDivider />}
|
{!compact && showVideoGroup && <ControlDivider />}
|
||||||
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
{showVideoGroup && (
|
||||||
<VideoButton enabled={video} onToggle={handleVideoToggle} />
|
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
||||||
<ScreenShareButton
|
{/* Show a forbidden control while its track is still live so the
|
||||||
enabled={screenshare}
|
user can stop it; once stopped it hides and can't be restarted. */}
|
||||||
onToggle={() =>
|
{showCamera && <VideoButton enabled={video} onToggle={handleVideoToggle} />}
|
||||||
screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
|
{showScreenshare && (
|
||||||
}
|
<ScreenShareButton
|
||||||
/>
|
enabled={screenshare}
|
||||||
{!!document.fullscreenEnabled && (
|
onToggle={() =>
|
||||||
<FullscreenButton isFullscreen={isFullscreen} onToggle={handleFullscreen} />
|
screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
|
||||||
)}
|
}
|
||||||
</Box>
|
/>
|
||||||
|
)}
|
||||||
|
{!!document.fullscreenEnabled && (
|
||||||
|
<FullscreenButton isFullscreen={isFullscreen} onToggle={handleFullscreen} />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
{!compact && <ControlDivider />}
|
{!compact && <ControlDivider />}
|
||||||
<Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}>
|
<Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}>
|
||||||
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
||||||
<ChatButton />
|
<ChatButton />
|
||||||
|
{soundboardEnabled && <CallSoundboard callEmbed={callEmbed} />}
|
||||||
<PopOut
|
<PopOut
|
||||||
anchor={cords}
|
anchor={cords}
|
||||||
position="Top"
|
position="Top"
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import React, { MouseEventHandler, useCallback, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Chip,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Icons,
|
||||||
|
Menu,
|
||||||
|
PopOut,
|
||||||
|
RectCords,
|
||||||
|
Spinner,
|
||||||
|
Text,
|
||||||
|
Tooltip,
|
||||||
|
TooltipProvider,
|
||||||
|
color,
|
||||||
|
config,
|
||||||
|
} from 'folds';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { CallEmbed } from '../../plugins/call';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { useSoundboard } from '../../hooks/useSoundboard';
|
||||||
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
|
import { settingsAtom } from '../../state/settings';
|
||||||
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
import {
|
||||||
|
SOUNDBOARD_ACCEPT,
|
||||||
|
SOUNDBOARD_MAX_CLIPS,
|
||||||
|
playClipLocally,
|
||||||
|
resolveClipObjectUrl,
|
||||||
|
} from '../../utils/soundboardClips';
|
||||||
|
|
||||||
|
type CallSoundboardProps = {
|
||||||
|
callEmbed: CallEmbed;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [P5-15] In-call soundboard: trigger user-uploaded clips into the call. Each
|
||||||
|
* clip is published to peers as a separate track by the EC fork
|
||||||
|
* (`io.lotus.inject_audio`) and also played locally for the presser's feedback.
|
||||||
|
* Clips are uploadable/managed here and synced across devices via the
|
||||||
|
* `io.lotus.soundboard` account data (like custom emoji/sticker packs).
|
||||||
|
*/
|
||||||
|
export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const { clips, addClip, removeClip } = useSoundboard();
|
||||||
|
const [soundboardVolume] = useSetting(settingsAtom, 'soundboardVolume');
|
||||||
|
|
||||||
|
const [cords, setCords] = useState<RectCords>();
|
||||||
|
const [busyId, setBusyId] = useState<string>();
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const volume = Math.max(0, Math.min(1, soundboardVolume / 100));
|
||||||
|
|
||||||
|
const handleOpen: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
|
setError(undefined);
|
||||||
|
setCords(evt.currentTarget.getBoundingClientRect());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePlay = useCallback(
|
||||||
|
async (id: string, mxc: string) => {
|
||||||
|
setBusyId(id);
|
||||||
|
setError(undefined);
|
||||||
|
try {
|
||||||
|
const objectUrl = await resolveClipObjectUrl(mx, mxc);
|
||||||
|
callEmbed.control.injectAudio(objectUrl, volume);
|
||||||
|
playClipLocally(objectUrl, volume);
|
||||||
|
} catch {
|
||||||
|
setError('Could not play that clip.');
|
||||||
|
} finally {
|
||||||
|
setBusyId(undefined);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[mx, callEmbed, volume],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFile = useCallback(
|
||||||
|
async (file: File | undefined) => {
|
||||||
|
if (!file) return;
|
||||||
|
setUploading(true);
|
||||||
|
setError(undefined);
|
||||||
|
try {
|
||||||
|
await addClip(file);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Upload failed.');
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[addClip],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept={SOUNDBOARD_ACCEPT}
|
||||||
|
hidden
|
||||||
|
onChange={(e) => {
|
||||||
|
handleFile(e.target.files?.[0]);
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<PopOut
|
||||||
|
anchor={cords}
|
||||||
|
position="Top"
|
||||||
|
align="Center"
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: () => setCords(undefined),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu style={{ maxWidth: '320px' }}>
|
||||||
|
<Box direction="Column" gap="200" style={{ padding: config.space.S200 }}>
|
||||||
|
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
|
||||||
|
<Text size="L400">Soundboard</Text>
|
||||||
|
<Chip
|
||||||
|
variant="Secondary"
|
||||||
|
radii="Pill"
|
||||||
|
disabled={uploading || clips.length >= SOUNDBOARD_MAX_CLIPS}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
before={
|
||||||
|
uploading ? <Spinner size="100" /> : <Icon size="100" src={Icons.Plus} />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text size="B300">Upload</Text>
|
||||||
|
</Chip>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{clips.length === 0 ? (
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
No clips yet. Upload a short audio clip (max 1 MB) to play it into the call.
|
||||||
|
Clips sync across your devices.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Box wrap="Wrap" gap="200">
|
||||||
|
{clips.map((clip) => (
|
||||||
|
<Box
|
||||||
|
key={clip.id}
|
||||||
|
direction="Column"
|
||||||
|
gap="100"
|
||||||
|
style={{ position: 'relative' }}
|
||||||
|
>
|
||||||
|
<Chip
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
radii="300"
|
||||||
|
disabled={busyId === clip.id}
|
||||||
|
onClick={() => handlePlay(clip.id, clip.url)}
|
||||||
|
before={
|
||||||
|
busyId === clip.id ? (
|
||||||
|
<Spinner size="100" />
|
||||||
|
) : (
|
||||||
|
<Icon size="100" src={Icons.Play} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
after={
|
||||||
|
<Icon
|
||||||
|
size="50"
|
||||||
|
src={Icons.Cross}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={(e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
removeClip(clip.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text size="B300" truncate style={{ maxWidth: '120px' }}>
|
||||||
|
{clip.name}
|
||||||
|
</Text>
|
||||||
|
</Chip>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TooltipProvider
|
||||||
|
tooltip={
|
||||||
|
<Tooltip>
|
||||||
|
<Text>Soundboard</Text>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(triggerRef) => (
|
||||||
|
<IconButton
|
||||||
|
ref={triggerRef}
|
||||||
|
variant="Surface"
|
||||||
|
fill="Soft"
|
||||||
|
radii="400"
|
||||||
|
size="400"
|
||||||
|
onClick={handleOpen}
|
||||||
|
outlined
|
||||||
|
aria-label="Soundboard"
|
||||||
|
aria-expanded={!!cords}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
>
|
||||||
|
<Icon size="400" src={Icons.BellRing} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
|
</PopOut>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Box, Switch, Text } from 'folds';
|
||||||
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
|
import { SequenceCardStyle } from '../../room-settings/styles.css';
|
||||||
|
import { SettingTile } from '../../../components/setting-tile';
|
||||||
|
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect';
|
||||||
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { useRoom } from '../../../hooks/useRoom';
|
||||||
|
import { StateEvent } from '../../../../types/matrix/room';
|
||||||
|
import { useStateEvent } from '../../../hooks/useStateEvent';
|
||||||
|
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
|
||||||
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
|
import {
|
||||||
|
AUDIO_BITRATE_OPTIONS,
|
||||||
|
RoomQualityContent,
|
||||||
|
SCREENSHARE_BITRATE_OPTIONS,
|
||||||
|
SCREENSHARE_FRAMERATE_OPTIONS,
|
||||||
|
} from '../../../utils/callQuality';
|
||||||
|
|
||||||
|
// Only the numeric cap keys are edited via `update`; the boolean policy keys
|
||||||
|
// are handled by `setAllow`.
|
||||||
|
type CapKey = 'audio_max_kbps' | 'screenshare_max_kbps' | 'screenshare_max_fps';
|
||||||
|
|
||||||
|
// String <-> numeric bridge for SettingsSelect (which needs string values).
|
||||||
|
const toValue = (n?: number): string => (typeof n === 'number' ? String(n) : 'auto');
|
||||||
|
|
||||||
|
const CAP_KEYS: (keyof RoomQualityContent)[] = [
|
||||||
|
'audio_max_kbps',
|
||||||
|
'screenshare_max_kbps',
|
||||||
|
'screenshare_max_fps',
|
||||||
|
'allow_screenshare',
|
||||||
|
'allow_camera',
|
||||||
|
];
|
||||||
|
const capsEqual = (a: RoomQualityContent, b: RoomQualityContent): boolean =>
|
||||||
|
CAP_KEYS.every((k) => a[k] === b[k]);
|
||||||
|
|
||||||
|
type RoomQualityProps = {
|
||||||
|
permissions: RoomPermissionsAPI;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* [P5-31] Room-admin quality ceiling. Writes `io.lotus.room_quality`; every
|
||||||
|
* Lotus client clamps its per-user quality to these caps. Hard enforcement for
|
||||||
|
* ALL Matrix clients is a server-side follow-up (see LOTUS_TODO.md P5-31).
|
||||||
|
*/
|
||||||
|
export function RoomQuality({ permissions }: RoomQualityProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const room = useRoom();
|
||||||
|
|
||||||
|
const canEdit = permissions.stateEvent(StateEvent.LotusRoomQuality, mx.getSafeUserId());
|
||||||
|
|
||||||
|
const event = useStateEvent(room, StateEvent.LotusRoomQuality);
|
||||||
|
const caps = useMemo<RoomQualityContent>(() => event?.getContent() ?? {}, [event]);
|
||||||
|
|
||||||
|
const [submitState, submit] = useAsyncCallback(
|
||||||
|
useCallback(
|
||||||
|
async (next: RoomQualityContent) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
await mx.sendStateEvent(room.roomId, StateEvent.LotusRoomQuality as any, next);
|
||||||
|
},
|
||||||
|
[mx, room.roomId],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const submitting = submitState.status === AsyncStatus.Loading;
|
||||||
|
|
||||||
|
// Optimistic mirror: `useStateEvent` only refreshes when the write echoes
|
||||||
|
// back via /sync (not when sendStateEvent resolves), so consecutive edits
|
||||||
|
// must build on the pending write — otherwise a second edit spreads a stale
|
||||||
|
// `caps` and silently drops the first. `effective` is what the UI shows and
|
||||||
|
// what each edit merges into; it's reconciled below once the echo lands.
|
||||||
|
const [pending, setPending] = useState<RoomQualityContent | null>(null);
|
||||||
|
const effective = pending ?? caps;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pending) return;
|
||||||
|
// Revert the optimistic view if the write failed…
|
||||||
|
if (submitState.status === AsyncStatus.Error) {
|
||||||
|
setPending(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// …or drop it once the synced state actually reflects it.
|
||||||
|
if (capsEqual(caps, pending)) setPending(null);
|
||||||
|
}, [caps, pending, submitState.status]);
|
||||||
|
|
||||||
|
const commit = (next: RoomQualityContent) => {
|
||||||
|
setPending(next);
|
||||||
|
submit(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const update = (key: CapKey, value: string) => {
|
||||||
|
const next: RoomQualityContent = { ...effective };
|
||||||
|
if (value === 'auto') delete next[key];
|
||||||
|
else next[key] = parseInt(value, 10);
|
||||||
|
commit(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setAllow = (key: 'allow_screenshare' | 'allow_camera', allowed: boolean) => {
|
||||||
|
const next: RoomQualityContent = { ...effective };
|
||||||
|
// Absent = allowed, so only persist the key when forbidding.
|
||||||
|
if (allowed) delete next[key];
|
||||||
|
else next[key] = false;
|
||||||
|
commit(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Absent/true = allowed.
|
||||||
|
const screenshareAllowed = effective.allow_screenshare !== false;
|
||||||
|
const cameraAllowed = effective.allow_camera !== false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SequenceCard
|
||||||
|
className={SequenceCardStyle}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
direction="Column"
|
||||||
|
gap="400"
|
||||||
|
>
|
||||||
|
<SettingTile
|
||||||
|
title="Call Permissions"
|
||||||
|
description={
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
Control what participants may share in this room. These are enforced on the server for
|
||||||
|
every Matrix client (Element, FluffyChat, Lotus Chat, …).
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Box direction="Column" gap="300">
|
||||||
|
<SettingTile
|
||||||
|
title="Allow Screen Sharing"
|
||||||
|
description="When off, no one can share their screen in this room."
|
||||||
|
after={
|
||||||
|
<Switch
|
||||||
|
variant="Primary"
|
||||||
|
value={screenshareAllowed}
|
||||||
|
onChange={(v) => setAllow('allow_screenshare', v)}
|
||||||
|
disabled={!canEdit || submitting}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SettingTile
|
||||||
|
title="Allow Camera"
|
||||||
|
description="When off, this is an audio-only room — no one can turn on their camera. Microphones are always allowed."
|
||||||
|
after={
|
||||||
|
<Switch
|
||||||
|
variant="Primary"
|
||||||
|
value={cameraAllowed}
|
||||||
|
onChange={(v) => setAllow('allow_camera', v)}
|
||||||
|
disabled={!canEdit || submitting}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<SettingTile
|
||||||
|
title="Call Quality Caps"
|
||||||
|
description={
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
Set a maximum microphone bitrate, screenshare bitrate, and screenshare framerate for
|
||||||
|
this room. Lotus Chat clamps each participant to these ceilings (best-effort — applies
|
||||||
|
to Lotus Chat clients). Auto = no cap.
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Box direction="Column" gap="300">
|
||||||
|
<SettingTile
|
||||||
|
title="Max Microphone Bitrate"
|
||||||
|
after={
|
||||||
|
<SettingsSelect
|
||||||
|
value={toValue(effective.audio_max_kbps)}
|
||||||
|
onChange={(v) => update('audio_max_kbps', v)}
|
||||||
|
options={AUDIO_BITRATE_OPTIONS}
|
||||||
|
disabled={!canEdit || submitting}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SettingTile
|
||||||
|
title="Max Screenshare Bitrate"
|
||||||
|
after={
|
||||||
|
<SettingsSelect
|
||||||
|
value={toValue(effective.screenshare_max_kbps)}
|
||||||
|
onChange={(v) => update('screenshare_max_kbps', v)}
|
||||||
|
options={SCREENSHARE_BITRATE_OPTIONS}
|
||||||
|
disabled={!canEdit || submitting}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SettingTile
|
||||||
|
title="Max Screenshare Framerate"
|
||||||
|
after={
|
||||||
|
<SettingsSelect
|
||||||
|
value={toValue(effective.screenshare_max_fps)}
|
||||||
|
onChange={(v) => update('screenshare_max_fps', v)}
|
||||||
|
options={SCREENSHARE_FRAMERATE_OPTIONS}
|
||||||
|
disabled={!canEdit || submitting}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</SequenceCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ export * from './RoomHistoryVisibility';
|
|||||||
export * from './RoomJoinRules';
|
export * from './RoomJoinRules';
|
||||||
export * from './RoomProfile';
|
export * from './RoomProfile';
|
||||||
export * from './RoomPublish';
|
export * from './RoomPublish';
|
||||||
|
export * from './RoomQuality';
|
||||||
export * from './RoomShareInvite';
|
export * from './RoomShareInvite';
|
||||||
export * from './RoomUpgrade';
|
export * from './RoomUpgrade';
|
||||||
export * from './RoomVoiceLimit';
|
export * from './RoomVoiceLimit';
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { keyframes } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
// Aurora Flow — a SLOW, gentle pan of layered soft aurora ribbons.
|
||||||
|
//
|
||||||
|
// The living-aurora illusion is a pure `background-position` drift: each
|
||||||
|
// comma-separated gradient layer is authored larger than the viewport
|
||||||
|
// (backgroundSize 200%–300%, see animAurora.ts) so there is slack to slide it
|
||||||
|
// around. Panning several broad blurred bands by DIFFERENT
|
||||||
|
// amounts and along DIFFERENT paths makes the ribbons appear to curl and cross
|
||||||
|
// like real northern lights — no single layer ever moves in lockstep.
|
||||||
|
//
|
||||||
|
// LAYER ORDER (must match animAurora.ts exactly — one position value per layer):
|
||||||
|
// 1. green ribbon (drifts a wide, lazy horizontal arc)
|
||||||
|
// 2. teal ribbon (drifts on a slower, offset diagonal)
|
||||||
|
// 3. violet ribbon (drifts vertically, the "curtain" fold)
|
||||||
|
// 4. sky/aqua highlight (small counter-drift for shimmer)
|
||||||
|
// 5. calm reading core (STATIC — kept at 50% 50% so the center never moves)
|
||||||
|
// 6. vignette (STATIC — kept at 50% 50% so edges never move)
|
||||||
|
//
|
||||||
|
// SEAMLESS LOOP: every animated layer starts and ends on the SAME position
|
||||||
|
// ('0%'/'100%' being identical sample points of the repeating gradient tile),
|
||||||
|
// so one period returns each band to its origin with no visible jump. The two
|
||||||
|
// static layers list their fixed position at every stop so they never pan.
|
||||||
|
//
|
||||||
|
// SLOW & GENTLE: paired with a long duration + ease-in-out in animAurora.ts, the
|
||||||
|
// motion reads as a barely-perceptible breathing drift, keeping the reading
|
||||||
|
// center calm and text crisp.
|
||||||
|
//
|
||||||
|
// getChatBg adds `willChange: 'background-position'` here and STRIPS the whole
|
||||||
|
// `animation` for prefers-reduced-motion / pause-animations, at which point the
|
||||||
|
// static `backgroundPosition` authored in animAurora.ts is what shows — already
|
||||||
|
// a finished, gorgeous aurora.
|
||||||
|
export const auroraFlow = keyframes({
|
||||||
|
'0%': {
|
||||||
|
backgroundPosition: '0% 30%, 100% 70%, 50% 0%, 20% 80%, 50% 50%, 50% 50%',
|
||||||
|
},
|
||||||
|
'25%': {
|
||||||
|
backgroundPosition: '35% 45%, 70% 55%, 55% 35%, 45% 60%, 50% 50%, 50% 50%',
|
||||||
|
},
|
||||||
|
'50%': {
|
||||||
|
backgroundPosition: '65% 60%, 40% 40%, 45% 70%, 70% 35%, 50% 50%, 50% 50%',
|
||||||
|
},
|
||||||
|
'75%': {
|
||||||
|
backgroundPosition: '35% 45%, 70% 55%, 55% 35%, 45% 60%, 50% 50%, 50% 50%',
|
||||||
|
},
|
||||||
|
'100%': {
|
||||||
|
backgroundPosition: '0% 30%, 100% 70%, 50% 0%, 20% 80%, 50% 50%, 50% 50%',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { CSSProperties } from 'react';
|
||||||
|
import { ChatBgVariants } from './types';
|
||||||
|
import { auroraFlow } from './animAurora.css';
|
||||||
|
|
||||||
|
// Aurora Flow — a premium ANIMATED aurora: soft ribbons of northern-lights color
|
||||||
|
// slowly drifting and curling over a deep, calm base.
|
||||||
|
//
|
||||||
|
// CONCEPT
|
||||||
|
// Broad, heavily-feathered gradient bands stacked over a deep midnight base, with
|
||||||
|
// a gentle vignette that darkens the edges and keeps the reading center calm.
|
||||||
|
// The distinct STATIC 'aurora' is a favorite still; this one earns its own slot
|
||||||
|
// by MOVING — see animAurora.css.ts, which slowly pans each ribbon along its own
|
||||||
|
// path via `background-position` so the curtains appear to fold and cross.
|
||||||
|
//
|
||||||
|
// LAYER ORDER (must stay in lockstep with auroraFlow's per-layer position list):
|
||||||
|
// 1. green ribbon 2. teal ribbon 3. violet ribbon 4. sky highlight
|
||||||
|
// 5. calm reading core (static) 6. vignette (static)
|
||||||
|
//
|
||||||
|
// READABILITY
|
||||||
|
// Every ribbon is a wide ellipse fading fully to transparent well before its
|
||||||
|
// edge, at low alpha (~0.05–0.13), so no band ever concentrates enough contrast
|
||||||
|
// under the message column to threaten WCAG-AA. Layer 5 lifts a soft, even wash
|
||||||
|
// through the vertical center — the reading zone — so text always sits on a calm,
|
||||||
|
// low-variance field. oklch() keeps every hue perceptually smooth and low-chroma.
|
||||||
|
//
|
||||||
|
// MOTION / SEAMLESS LOOP
|
||||||
|
// backgroundSize is >100% per animated layer, giving room to drift; the keyframe
|
||||||
|
// returns every band to its start over one long, ease-in-out period, so the loop
|
||||||
|
// is seamless and the motion barely-perceptible. willChange/animation are added
|
||||||
|
// (and stripped for reduced-motion) by getChatBg; the static positions below are
|
||||||
|
// the finished still that shows when motion is off.
|
||||||
|
|
||||||
|
const dark: CSSProperties = {
|
||||||
|
// Deep midnight blue — the polar night sky the aurora glows over.
|
||||||
|
backgroundColor: 'oklch(0.17 0.045 255)',
|
||||||
|
backgroundImage: [
|
||||||
|
// 1. Green ribbon — the signature aurora band.
|
||||||
|
'radial-gradient(ellipse 70% 45% at 50% 50%, oklch(0.7 0.14 160 / 0.13) 0%, oklch(0.7 0.14 160 / 0.05) 45%, transparent 72%)',
|
||||||
|
// 2. Teal ribbon — cool counterpart, offset.
|
||||||
|
'radial-gradient(ellipse 80% 40% at 50% 50%, oklch(0.65 0.12 200 / 0.12) 0%, oklch(0.65 0.12 200 / 0.04) 48%, transparent 74%)',
|
||||||
|
// 3. Violet ribbon — the high curtain fold.
|
||||||
|
'radial-gradient(ellipse 65% 55% at 50% 50%, oklch(0.55 0.13 300 / 0.11) 0%, oklch(0.55 0.13 300 / 0.04) 46%, transparent 70%)',
|
||||||
|
// 4. Sky/aqua highlight — subtle shimmer that counter-drifts.
|
||||||
|
'radial-gradient(ellipse 55% 35% at 50% 50%, oklch(0.72 0.1 220 / 0.09) 0%, transparent 65%)',
|
||||||
|
// 5. Calm reading core (static) — a soft even wash down the center column so
|
||||||
|
// message text always rests on a low-variance field.
|
||||||
|
'radial-gradient(ellipse 120% 60% at 50% 50%, oklch(0.2 0.04 255 / 0.5) 0%, transparent 70%)',
|
||||||
|
// 6. Vignette (static) — gently darkens the edges for luminous depth.
|
||||||
|
'radial-gradient(ellipse 130% 120% at 50% 50%, transparent 55%, oklch(0.12 0.04 260 / 0.55) 100%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: '260% 240%, 300% 260%, 240% 280%, 220% 200%, 100% 100%, 100% 100%',
|
||||||
|
backgroundPosition: '0% 30%, 100% 70%, 50% 0%, 20% 80%, 50% 50%, 50% 50%',
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
animation: `${auroraFlow} 60s ease-in-out infinite`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const light: CSSProperties = {
|
||||||
|
// Pale cool base — a soft pre-dawn sky the pastel aurora dreams over.
|
||||||
|
backgroundColor: 'oklch(0.97 0.012 240)',
|
||||||
|
backgroundImage: [
|
||||||
|
// 1. Mint ribbon.
|
||||||
|
'radial-gradient(ellipse 70% 45% at 50% 50%, oklch(0.85 0.08 160 / 0.5) 0%, oklch(0.85 0.08 160 / 0.16) 45%, transparent 72%)',
|
||||||
|
// 2. Sky ribbon.
|
||||||
|
'radial-gradient(ellipse 80% 40% at 50% 50%, oklch(0.83 0.07 220 / 0.48) 0%, oklch(0.83 0.07 220 / 0.14) 48%, transparent 74%)',
|
||||||
|
// 3. Lilac ribbon — the high curtain fold.
|
||||||
|
'radial-gradient(ellipse 65% 55% at 50% 50%, oklch(0.82 0.07 300 / 0.42) 0%, oklch(0.82 0.07 300 / 0.12) 46%, transparent 70%)',
|
||||||
|
// 4. Aqua highlight — subtle shimmer that counter-drifts.
|
||||||
|
'radial-gradient(ellipse 55% 35% at 50% 50%, oklch(0.88 0.06 200 / 0.34) 0%, transparent 65%)',
|
||||||
|
// 5. Calm reading core (static) — a bright even wash down the center column
|
||||||
|
// so dark message text always rests on a light, low-variance field.
|
||||||
|
'radial-gradient(ellipse 120% 60% at 50% 50%, oklch(0.99 0.005 240 / 0.6) 0%, transparent 70%)',
|
||||||
|
// 6. Vignette (static) — a whisper of cool shade at the edges for depth.
|
||||||
|
'radial-gradient(ellipse 130% 120% at 50% 50%, transparent 55%, oklch(0.9 0.02 250 / 0.45) 100%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: '260% 240%, 300% 260%, 240% 280%, 220% 200%, 100% 100%, 100% 100%',
|
||||||
|
backgroundPosition: '0% 30%, 100% 70%, 50% 0%, 20% 80%, 50% 50%, 50% 50%',
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
animation: `${auroraFlow} 60s ease-in-out infinite`,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const animAurora: ChatBgVariants = { dark, light };
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { keyframes } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
// Fireflies — a slow, gentle PAN of sparse glowing motes across a warm summer
|
||||||
|
// dusk. The scene in animFireflies.ts stacks these background layers:
|
||||||
|
// 1. large bright motes — tile 227x227, brightest core+halo, drifts FASTEST
|
||||||
|
// 2. medium motes — tile 293x293, dimmer, medium drift
|
||||||
|
// 3. tiny far sparks — tile 179x179, faintest, drifts SLOWEST (small step)
|
||||||
|
// 4. center vignette (100% 100%) — STATIC
|
||||||
|
// 5. warm dusk wash A (100% 100%) — STATIC
|
||||||
|
// 6. warm dusk wash B (100% 100%) — STATIC
|
||||||
|
//
|
||||||
|
// Seamless drift: the single `animation` shorthand shares ONE duration across all
|
||||||
|
// layers, so the differing apparent speeds come purely from how FAR each layer
|
||||||
|
// travels. For a jump-free loop every mote layer must translate by an EXACT
|
||||||
|
// integer multiple of its own tile period in BOTH axes, so the mote re-entering
|
||||||
|
// at the wrap is identical to the one that left. Each layer moves exactly one
|
||||||
|
// full tile:
|
||||||
|
// large : -227 / -227 (1 x 227)
|
||||||
|
// medium: -293 / -293 (1 x 293) — bigger tile, same 1-tile move => SLOWER look
|
||||||
|
// far : -179 / -179 (1 x 179) — smallest tile, damped by low opacity so it
|
||||||
|
// reads as the calm distant layer
|
||||||
|
// Because tile sizes differ, one shared 1-tile translation yields three distinct
|
||||||
|
// apparent speeds — the wandering-firefly parallax — while every layer lands back
|
||||||
|
// on an identical phase at 100% for a perfectly seamless repeat.
|
||||||
|
//
|
||||||
|
// The diagonal component (both x and y shift) makes motes feel like they wander
|
||||||
|
// through the meadow rather than slide flatly. The three static layers (vignette
|
||||||
|
// and the two dusk washes) are pinned at '0 0' every frame so the warm ambient
|
||||||
|
// glow and the calm reading center never move under the text.
|
||||||
|
//
|
||||||
|
// The '0%' frame MUST match the static backgroundPosition authored in
|
||||||
|
// animFireflies.ts, so when getChatBg STRIPS this animation for
|
||||||
|
// prefers-reduced-motion the finished scene of glowing motes shows without a jump.
|
||||||
|
export const firefliesDrift = keyframes({
|
||||||
|
'0%': {
|
||||||
|
// large, medium, far, vignette, wash A, wash B
|
||||||
|
backgroundPosition: '0 0, 83px 47px, 131px 101px, 0 0, 0 0, 0 0',
|
||||||
|
},
|
||||||
|
'100%': {
|
||||||
|
// large: 0-227 / 0-227
|
||||||
|
// medium: 83-293 / 47-293
|
||||||
|
// far: 131-179 / 101-179
|
||||||
|
backgroundPosition: '-227px -227px, -210px -246px, -48px -78px, 0 0, 0 0, 0 0',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { ChatBgVariants } from './types';
|
||||||
|
import { firefliesDrift } from './animFireflies.css';
|
||||||
|
|
||||||
|
// Fireflies — a warm summer-dusk meadow. A few soft golden-green motes drift over
|
||||||
|
// a deep base, each mote a bright core melting into a warm halo. Sparse by design
|
||||||
|
// so the reading column stays clear; the motion is a slow, gentle background-
|
||||||
|
// position PAN (see animFireflies.css.ts) that reads as fireflies wandering.
|
||||||
|
//
|
||||||
|
// Layer stacking order (topmost first — CSS paints image #1 on top):
|
||||||
|
// 1. large bright motes — crisp warm core -> warm halo, sparse, largest step
|
||||||
|
// 2. medium motes — dimmer, smaller, more of them
|
||||||
|
// 3. tiny far sparks — faintest, smallest tile, calm distant layer
|
||||||
|
// 4. center vignette — keeps the reading center the calmest area
|
||||||
|
// 5. warm dusk wash A — ambient glow, upper
|
||||||
|
// 6. warm dusk wash B — ambient glow, lower
|
||||||
|
// Mote tiles use coprime-ish sizes (227/293/179) so their repeats never line up
|
||||||
|
// and the field reads as scattered, not gridded.
|
||||||
|
//
|
||||||
|
// getChatBg STRIPS the `animation` for prefers-reduced-motion / pause, so the
|
||||||
|
// authored backgroundPosition already composes a finished, gorgeous still scene
|
||||||
|
// of glowing motes on its own — the animation only sets them gently adrift.
|
||||||
|
export const animFireflies: ChatBgVariants = {
|
||||||
|
// Dark: warm gold-green glows on a deep forest-navy base with a soft vignette.
|
||||||
|
// Cores sit near oklch(0.85 0.13 110); halos fall to a warm amber-green. All
|
||||||
|
// opacities are kept low so message text stays crisp (WCAG-AA) over the field.
|
||||||
|
dark: {
|
||||||
|
backgroundColor: 'oklch(0.17 0.035 175)',
|
||||||
|
backgroundImage: [
|
||||||
|
// 1. large bright motes — golden-green core fading through a warm halo
|
||||||
|
'radial-gradient(circle at center, oklch(0.85 0.13 110 / 0.55) 1.4px, oklch(0.72 0.14 95 / 0.16) 3px, transparent 6px)',
|
||||||
|
// 2. medium motes — a touch cooler-green, dimmer, more numerous
|
||||||
|
'radial-gradient(circle at center, oklch(0.82 0.13 128 / 0.40) 1.1px, oklch(0.70 0.12 110 / 0.12) 2.4px, transparent 5px)',
|
||||||
|
// 3. tiny far sparks — faint warm pinpoints, the calm distant layer
|
||||||
|
'radial-gradient(circle at center, oklch(0.88 0.11 100 / 0.28) 0.8px, transparent 2.4px)',
|
||||||
|
// 4. center vignette — darkens the edges, keeps reading center calmest
|
||||||
|
'radial-gradient(ellipse 125% 95% at 50% 44%, transparent 40%, oklch(0.10 0.03 175 / 0.55) 100%)',
|
||||||
|
// 5. warm dusk wash A — a low amber-green glow drifting in from upper-right
|
||||||
|
'radial-gradient(ellipse 140% 120% at 80% 10%, oklch(0.30 0.07 120 / 0.45) 0%, transparent 58%)',
|
||||||
|
// 6. warm dusk wash B — deep teal-navy pooling into the lower-left
|
||||||
|
'radial-gradient(ellipse 135% 115% at 16% 94%, oklch(0.22 0.05 190 / 0.50) 0%, transparent 60%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: [
|
||||||
|
'227px 227px', // large motes
|
||||||
|
'293px 293px', // medium motes
|
||||||
|
'179px 179px', // far sparks
|
||||||
|
'100% 100%', // vignette
|
||||||
|
'100% 100%', // wash A
|
||||||
|
'100% 100%', // wash B
|
||||||
|
].join(','),
|
||||||
|
backgroundPosition: [
|
||||||
|
'0 0', // large (matches firefliesDrift 0%)
|
||||||
|
'83px 47px', // medium (offset breaks alignment)
|
||||||
|
'131px 101px', // far (offset again)
|
||||||
|
'0 0', // vignette (static)
|
||||||
|
'0 0', // wash A (static)
|
||||||
|
'0 0', // wash B (static)
|
||||||
|
].join(','),
|
||||||
|
animation: `${firefliesDrift} 44s linear infinite`,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Light: a cozy warm dim-dusk. No harsh dots on white — soft amber motes with
|
||||||
|
// gentle halos float on a warm blush->honey gradient. Contrast stays low so the
|
||||||
|
// reading area is comfortable and text remains crisp (WCAG-AA).
|
||||||
|
light: {
|
||||||
|
backgroundColor: 'oklch(0.955 0.02 85)',
|
||||||
|
backgroundImage: [
|
||||||
|
// 1. large amber motes — warm honey core into a soft amber halo
|
||||||
|
'radial-gradient(circle at center, oklch(0.80 0.11 80 / 0.30) 1.4px, oklch(0.85 0.09 70 / 0.12) 3px, transparent 6px)',
|
||||||
|
// 2. medium motes — slightly greener-gold, softer
|
||||||
|
'radial-gradient(circle at center, oklch(0.78 0.10 95 / 0.22) 1.1px, oklch(0.86 0.08 85 / 0.10) 2.4px, transparent 5px)',
|
||||||
|
// 3. tiny far sparks — faint warm pinpoints for texture, never noise
|
||||||
|
'radial-gradient(circle at center, oklch(0.75 0.10 75 / 0.16) 0.8px, transparent 2.4px)',
|
||||||
|
// 4. center vignette — brightens the calm reading center a touch
|
||||||
|
'radial-gradient(ellipse 125% 95% at 50% 44%, oklch(1 0 0 / 0.40) 30%, transparent 100%)',
|
||||||
|
// 5. warm dusk wash A — honey glow from the upper-right
|
||||||
|
'radial-gradient(ellipse 140% 120% at 80% 8%, oklch(0.92 0.06 85 / 0.55) 0%, transparent 60%)',
|
||||||
|
// 6. warm dusk wash B — soft rose blush pooling lower-left
|
||||||
|
'radial-gradient(ellipse 135% 115% at 15% 95%, oklch(0.93 0.05 40 / 0.45) 0%, transparent 62%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: [
|
||||||
|
'227px 227px', // large motes
|
||||||
|
'293px 293px', // medium motes
|
||||||
|
'179px 179px', // far sparks
|
||||||
|
'100% 100%', // vignette
|
||||||
|
'100% 100%', // wash A
|
||||||
|
'100% 100%', // wash B
|
||||||
|
].join(','),
|
||||||
|
backgroundPosition: [
|
||||||
|
'0 0', // large (matches firefliesDrift 0%)
|
||||||
|
'83px 47px', // medium
|
||||||
|
'131px 101px', // far
|
||||||
|
'0 0', // vignette (static)
|
||||||
|
'0 0', // wash A (static)
|
||||||
|
'0 0', // wash B (static)
|
||||||
|
].join(','),
|
||||||
|
animation: `${firefliesDrift} 44s linear infinite`,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { keyframes } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
// Grid Pulse — a slow "energy" glow that sweeps across a static tech grid.
|
||||||
|
//
|
||||||
|
// The motif is a crisp thin grid that pulses. Rather than scaling the grid
|
||||||
|
// (which shifts every line and reads as a jitter behind text), we keep the grid
|
||||||
|
// perfectly still and PAN a single soft radial "bloom" layer diagonally across
|
||||||
|
// it. As the bloom drifts, the grid lines it passes over appear to brighten and
|
||||||
|
// then settle — a calm travelling pulse, never a flash.
|
||||||
|
//
|
||||||
|
// Layer mapping (see animPulse.ts — one background-position value per layer):
|
||||||
|
// 0. grid core lines (vertical) — STATIC ('0 0')
|
||||||
|
// 1. grid core lines (horizontal) — STATIC ('0 0')
|
||||||
|
// 2. grid fine sub-lines (V) — STATIC ('0 0')
|
||||||
|
// 3. grid fine sub-lines (H) — STATIC ('0 0')
|
||||||
|
// 4. TRAVELLING BLOOM — panned here (the only moving layer)
|
||||||
|
// 5. base wash / centre glow — STATIC ('0 0')
|
||||||
|
// 6. vignette — STATIC ('0 0')
|
||||||
|
//
|
||||||
|
// Seamless loop: the bloom layer is authored to tile (its backgroundSize in
|
||||||
|
// animPulse.ts is 480px — an exact 4x multiple of the 120px grid module, and
|
||||||
|
// 8x of the 60px sub-grid). Panning it by EXACTLY one bloom-tile (480px on both
|
||||||
|
// axes) returns every pixel to an identical neighbouring tile, so the wrap at
|
||||||
|
// 100% is invisible. Diagonal travel (both axes move together) makes the sweep
|
||||||
|
// feel organic while still landing on a whole-tile offset.
|
||||||
|
//
|
||||||
|
// getChatBg adds `willChange: 'background-position'` for the animated case, so a
|
||||||
|
// background-position pulse is exactly what the compositor is hinted for. It
|
||||||
|
// STRIPS this whole `animation` for prefers-reduced-motion / pause-animations,
|
||||||
|
// at which point the static bloom position authored in animPulse.ts is what
|
||||||
|
// shows — a finished, gently glowing grid.
|
||||||
|
export const gridPulse = keyframes({
|
||||||
|
'0%': {
|
||||||
|
backgroundPosition: '0 0, 0 0, 0 0, 0 0, 0px 0px, 0 0, 0 0',
|
||||||
|
},
|
||||||
|
'100%': {
|
||||||
|
backgroundPosition: '0 0, 0 0, 0 0, 0 0, 480px 480px, 0 0, 0 0',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { ChatBgVariants } from './types';
|
||||||
|
import { gridPulse } from './animPulse.css';
|
||||||
|
|
||||||
|
// Grid Pulse (anim-pulse) — a refined sci-fi grid with a slow energy pulse.
|
||||||
|
//
|
||||||
|
// Concept: a crisp thin tech grid over which a single soft radial glow drifts
|
||||||
|
// diagonally, so the lines it crosses seem to charge and settle — a hypnotic
|
||||||
|
// travelling pulse rather than a strobing brightness flash. Three ingredients,
|
||||||
|
// exactly per the quality bar:
|
||||||
|
// 1. a crisp thin grid — two hairline linear layers (V + H) at a 120px module
|
||||||
|
// plus a fainter 60px sub-grid, so the mesh reads as fine machined lattice;
|
||||||
|
// 2. a soft bloom layer — one wide, very-low-opacity radial that TRAVELS across
|
||||||
|
// the grid (the pulse), authored to tile so the loop is seamless;
|
||||||
|
// 3. a radial vignette — keeps the reading centre calm (dark theme darkens it,
|
||||||
|
// light theme brightens it) so text always sits on the quietest region.
|
||||||
|
//
|
||||||
|
// Animation approach & why it's subtle: only ONE layer moves — the bloom — and
|
||||||
|
// it moves by pure background-position (the property getChatBg hints via
|
||||||
|
// willChange). No line ever shifts, no global brightness flicker, so text never
|
||||||
|
// wobbles. The glow itself is barely-there (opacity well under the neon bloom),
|
||||||
|
// so the "pulse" is felt as a slow wash of light passing behind the words. 22s
|
||||||
|
// per cycle makes it meditative, not busy.
|
||||||
|
//
|
||||||
|
// Seamless loop: the bloom's backgroundSize is 480px — an exact 4x multiple of
|
||||||
|
// the 120px grid module (and 8x of the 60px sub-grid). The keyframe pans it by
|
||||||
|
// exactly one 480px tile on both axes, so it wraps onto an identical tile with
|
||||||
|
// no visible seam (see animPulse.css.ts).
|
||||||
|
//
|
||||||
|
// Reduced-motion fallback: getChatBg strips `animation`, leaving the bloom at
|
||||||
|
// its authored static position — parked slightly above-centre so the finished
|
||||||
|
// frame reads as a deliberately-lit, gently glowing grid rather than a frozen
|
||||||
|
// mid-sweep. The grid, wash and vignette are all static regardless, so the
|
||||||
|
// still image is already a complete, premium background.
|
||||||
|
//
|
||||||
|
// Dark vs light: dark is a cool cyan lattice glowing on deep blue-black with a
|
||||||
|
// dim bloom and a centre-darkening vignette. Light is a soft slate-blue lattice
|
||||||
|
// on pale cool-white with a whisper-faint bloom and a centre-BRIGHTENING
|
||||||
|
// vignette, so the reading column lifts toward white. Both keep line + glow
|
||||||
|
// opacity low for WCAG-AA legibility in either app theme.
|
||||||
|
|
||||||
|
export const animPulse: ChatBgVariants = {
|
||||||
|
// Dark: cyan grid on deep blue-black, a dim energy bloom sweeping through.
|
||||||
|
dark: {
|
||||||
|
backgroundColor: 'oklch(0.16 0.03 240)',
|
||||||
|
backgroundImage: [
|
||||||
|
// 0. grid core — vertical hairlines (cool cyan)
|
||||||
|
'linear-gradient(90deg, oklch(0.75 0.11 200 / 0.14) 0 1px, transparent 1px)',
|
||||||
|
// 1. grid core — horizontal hairlines
|
||||||
|
'linear-gradient(0deg, oklch(0.75 0.11 200 / 0.14) 0 1px, transparent 1px)',
|
||||||
|
// 2. fine sub-grid — vertical (fainter, half module)
|
||||||
|
'linear-gradient(90deg, oklch(0.75 0.11 200 / 0.05) 0 1px, transparent 1px)',
|
||||||
|
// 3. fine sub-grid — horizontal
|
||||||
|
'linear-gradient(0deg, oklch(0.75 0.11 200 / 0.05) 0 1px, transparent 1px)',
|
||||||
|
// 4. TRAVELLING BLOOM — the pulse: a wide soft cyan glow that drifts
|
||||||
|
'radial-gradient(circle at 50% 50%, oklch(0.8 0.12 200 / 0.16) 0%, oklch(0.75 0.11 205 / 0.06) 26%, transparent 55%)',
|
||||||
|
// 5. base wash — a faint steady centre glow so the grid never looks flat
|
||||||
|
'radial-gradient(ellipse 120% 100% at 50% 42%, oklch(0.42 0.07 235 / 0.28) 0%, transparent 62%)',
|
||||||
|
// 6. vignette — darken the edges, keep the reading centre calm & dark
|
||||||
|
'radial-gradient(ellipse 130% 100% at 50% 46%, transparent 34%, oklch(0.11 0.02 245 / 0.72) 100%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: [
|
||||||
|
'120px 120px', // grid core V
|
||||||
|
'120px 120px', // grid core H
|
||||||
|
'60px 60px', // sub-grid V (exact 1/2 divisor — re-registers)
|
||||||
|
'60px 60px', // sub-grid H
|
||||||
|
'480px 480px', // bloom (4x module — pans one whole tile, seamless)
|
||||||
|
'100% 100%', // base wash
|
||||||
|
'100% 100%', // vignette
|
||||||
|
].join(','),
|
||||||
|
backgroundPosition: [
|
||||||
|
'0 0', // grid core V
|
||||||
|
'0 0', // grid core H
|
||||||
|
'0 0', // sub-grid V
|
||||||
|
'0 0', // sub-grid H
|
||||||
|
'120px 40px', // bloom static (reduced-motion) — parked above-centre
|
||||||
|
'0 0', // base wash
|
||||||
|
'0 0', // vignette
|
||||||
|
].join(','),
|
||||||
|
animation: `${gridPulse} 22s ease-in-out infinite`,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Light: soft slate-blue grid on pale cool-white, a gentle luminance breathe.
|
||||||
|
light: {
|
||||||
|
backgroundColor: 'oklch(0.975 0.006 235)',
|
||||||
|
backgroundImage: [
|
||||||
|
// 0. grid core — vertical hairlines (soft slate-blue)
|
||||||
|
'linear-gradient(90deg, oklch(0.55 0.08 245 / 0.15) 0 1px, transparent 1px)',
|
||||||
|
// 1. grid core — horizontal hairlines
|
||||||
|
'linear-gradient(0deg, oklch(0.55 0.08 245 / 0.15) 0 1px, transparent 1px)',
|
||||||
|
// 2. fine sub-grid — vertical (fainter, half module)
|
||||||
|
'linear-gradient(90deg, oklch(0.55 0.08 245 / 0.055) 0 1px, transparent 1px)',
|
||||||
|
// 3. fine sub-grid — horizontal
|
||||||
|
'linear-gradient(0deg, oklch(0.55 0.08 245 / 0.055) 0 1px, transparent 1px)',
|
||||||
|
// 4. TRAVELLING BLOOM — a whisper of slate-blue light drifting through
|
||||||
|
'radial-gradient(circle at 50% 50%, oklch(0.6 0.09 240 / 0.09) 0%, oklch(0.62 0.08 245 / 0.035) 26%, transparent 55%)',
|
||||||
|
// 5. base wash — the faintest cool tint so the grid sits on soft light
|
||||||
|
'radial-gradient(ellipse 120% 100% at 50% 42%, oklch(0.86 0.03 235 / 0.30) 0%, transparent 62%)',
|
||||||
|
// 6. vignette — brighten the calm reading centre toward white for legibility
|
||||||
|
'radial-gradient(ellipse 130% 100% at 50% 46%, oklch(1 0 0 / 0.5) 30%, transparent 100%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: [
|
||||||
|
'120px 120px', // grid core V
|
||||||
|
'120px 120px', // grid core H
|
||||||
|
'60px 60px', // sub-grid V
|
||||||
|
'60px 60px', // sub-grid H
|
||||||
|
'480px 480px', // bloom (4x module — seamless one-tile pan)
|
||||||
|
'100% 100%', // base wash
|
||||||
|
'100% 100%', // vignette
|
||||||
|
].join(','),
|
||||||
|
backgroundPosition: [
|
||||||
|
'0 0', // grid core V
|
||||||
|
'0 0', // grid core H
|
||||||
|
'0 0', // sub-grid V
|
||||||
|
'0 0', // sub-grid H
|
||||||
|
'120px 40px', // bloom static (reduced-motion) — parked above-centre
|
||||||
|
'0 0', // base wash
|
||||||
|
'0 0', // vignette
|
||||||
|
].join(','),
|
||||||
|
animation: `${gridPulse} 22s ease-in-out infinite`,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { keyframes } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
// Digital Rain — a slow vertical PAN of the streak columns.
|
||||||
|
//
|
||||||
|
// The streak SVG tile is authored 200px tall (see animRain.ts, backgroundSize
|
||||||
|
// height = 200px). The falling illusion is a pure background-position translate
|
||||||
|
// downward by EXACTLY one tile height (200px) over the cycle, so the loop is
|
||||||
|
// perfectly seamless — the pixel at y re-enters where the pixel at y-200 was,
|
||||||
|
// which is identical because the tile repeats.
|
||||||
|
//
|
||||||
|
// Only the first background layer (the streak SVG) is panned; every subsequent
|
||||||
|
// comma-separated layer is kept at its authored position ('0 0') so the base
|
||||||
|
// gradients / vignette stay put while the rain falls over them. Listing a value
|
||||||
|
// per layer is required — a single value would pan ALL layers.
|
||||||
|
//
|
||||||
|
// getChatBg adds `willChange: 'background-position'` for the animated case, and
|
||||||
|
// STRIPS this whole `animation` for reduced-motion, at which point the static
|
||||||
|
// backgroundPosition authored in animRain.ts is what shows.
|
||||||
|
export const rainFall = keyframes({
|
||||||
|
'0%': { backgroundPosition: '0 0, 0 0, 0 0, 0 0' },
|
||||||
|
'100%': { backgroundPosition: '0 200px, 0 0, 0 0, 0 0' },
|
||||||
|
});
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { ChatBgVariants } from './types';
|
||||||
|
import { rainFall } from './animRain.css';
|
||||||
|
|
||||||
|
// anim-rain — "Digital Rain" — a premium take on the Matrix code-rain motif.
|
||||||
|
//
|
||||||
|
// Concept: sparse vertical columns of falling glyph-streaks. Each streak is a
|
||||||
|
// soft vertical gradient that fades from a brighter LEADING glyph (the drop's
|
||||||
|
// head) up into a dim trailing tail, punctuated by a scatter of faint monospace
|
||||||
|
// glyph marks so it reads as CODE rather than plain stripes. It floats over a
|
||||||
|
// near-black base carrying a subtle green phosphor cast and a gentle vignette.
|
||||||
|
// Columns are deliberately sparse (only a handful across the 260px-wide tile)
|
||||||
|
// so the reading area breathes and text always wins the contrast fight.
|
||||||
|
//
|
||||||
|
// SEAMLESS TILING + PAN — the streak SVG tile is 260×200. Its content is
|
||||||
|
// authored to wrap top↔bottom: each streak's gradient and glyphs are placed so
|
||||||
|
// the tile is vertically continuous, and the animation (see animRain.css.ts)
|
||||||
|
// pans this first layer down by EXACTLY one tile height (200px) per cycle, so
|
||||||
|
// the "fall" loops with no seam. The base / vignette layers are 100% 100% and
|
||||||
|
// stay fixed (the keyframe holds them at '0 0').
|
||||||
|
//
|
||||||
|
// ANIMATION-STRIP SAFETY — getChatBg removes `animation` for reduced-motion /
|
||||||
|
// pause-animations users, so the non-animation properties below already read as
|
||||||
|
// a finished, gorgeous STATIC rain: a frozen frame of streaks over the base.
|
||||||
|
//
|
||||||
|
// CSP / Tauri-safe: inline SVG via encodeURIComponent (NOT base64). oklch used
|
||||||
|
// throughout; alphas kept low so both themes stay WCAG-AA-friendly for text.
|
||||||
|
|
||||||
|
// One vertical streak-column, colour-parameterised. Placed at x within a
|
||||||
|
// 260-wide tile. `head` is the bright leading-glyph colour, `tail` the dim
|
||||||
|
// trailing colour, `glyph` the colour of the riding monospace glyph ticks.
|
||||||
|
const streak = (
|
||||||
|
x: number,
|
||||||
|
headY: number, // y of the leading glyph (drop head)
|
||||||
|
len: number, // trailing tail length upward
|
||||||
|
head: string,
|
||||||
|
tail: string,
|
||||||
|
glyph: string,
|
||||||
|
): string => {
|
||||||
|
const topY = headY - len;
|
||||||
|
const id = `g${x}_${headY}`; // unique even when two columns share an x
|
||||||
|
// Vertical fade: transparent at the tail top → tail colour → bright head.
|
||||||
|
const grad = `
|
||||||
|
<linearGradient id='${id}' x1='0' y1='${topY}' x2='0' y2='${headY}' gradientUnits='userSpaceOnUse'>
|
||||||
|
<stop offset='0' stop-color='${tail}' stop-opacity='0'/>
|
||||||
|
<stop offset='0.55' stop-color='${tail}'/>
|
||||||
|
<stop offset='1' stop-color='${head}'/>
|
||||||
|
</linearGradient>`;
|
||||||
|
// The streak body is a soft, slightly-blurred vertical bar.
|
||||||
|
const bar = `<rect x='${x - 3}' y='${topY}' width='6' height='${len}' rx='3' fill='url(#${id})'/>`;
|
||||||
|
// A few monospace glyph ticks riding the column (short horizontal dashes).
|
||||||
|
const ticks = [0.22, 0.45, 0.68, 0.86]
|
||||||
|
.map((f, i) => {
|
||||||
|
const gy = Math.round(topY + len * f);
|
||||||
|
const gw = i % 2 === 0 ? 5 : 3;
|
||||||
|
const op = i === 3 ? '0.9' : '0.5';
|
||||||
|
return `<rect x='${x - gw / 2}' y='${gy}' width='${gw}' height='1.4' rx='0.7' fill='${glyph}' fill-opacity='${op}'/>`;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
// The leading glyph: a brighter small square cap at the head.
|
||||||
|
const cap = `<rect x='${x - 2.5}' y='${headY - 3}' width='5' height='5' rx='1' fill='${head}'/>`;
|
||||||
|
return grad + bar + ticks + cap;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Full 260×200 tile. Columns are wrapped vertically: a column whose head sits
|
||||||
|
// low in the tile has its tail running off the top, and a companion column
|
||||||
|
// re-enters that space, so panning by one tile height reads as continuous fall.
|
||||||
|
const tile = (head: string, tail: string, glyph: string): string => {
|
||||||
|
const cols = [
|
||||||
|
streak(24, 150, 140, head, tail, glyph),
|
||||||
|
streak(78, 60, 120, head, tail, glyph),
|
||||||
|
streak(122, 196, 160, head, tail, glyph), // head near bottom → tail wraps up
|
||||||
|
streak(122, 40, 160, head, tail, glyph), // partner near top completes the wrap
|
||||||
|
streak(178, 110, 100, head, tail, glyph),
|
||||||
|
streak(232, 176, 130, head, tail, glyph),
|
||||||
|
].join('');
|
||||||
|
const svg = `<svg xmlns='http://www.w3.org/2000/svg' width='260' height='200' viewBox='0 0 260 200'><defs></defs>${cols}</svg>`;
|
||||||
|
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const animRain: ChatBgVariants = {
|
||||||
|
// Dark: phosphor-green streaks on deep near-black with a faint green cast.
|
||||||
|
dark: {
|
||||||
|
backgroundColor: 'oklch(0.16 0.02 150)',
|
||||||
|
backgroundImage: [
|
||||||
|
// 1) the falling streak columns (this is the panned layer)
|
||||||
|
tile(
|
||||||
|
'oklch(0.75 0.14 150 / 0.5)', // head — bright phosphor glyph
|
||||||
|
'oklch(0.68 0.12 150 / 0.28)', // tail — dim phosphor
|
||||||
|
'oklch(0.82 0.1 150 / 0.5)', // glyph ticks — brightest
|
||||||
|
),
|
||||||
|
// 2) soft top-down phosphor haze so the rain has atmosphere
|
||||||
|
'linear-gradient(180deg, oklch(0.24 0.04 150 / 0.55) 0%, transparent 40%)',
|
||||||
|
// 3) subtle green cast pooling toward the bottom
|
||||||
|
'radial-gradient(120% 90% at 50% 100%, oklch(0.28 0.05 150 / 0.45) 0%, transparent 60%)',
|
||||||
|
// 4) vignette — quiet the corners so the reading column stays clean
|
||||||
|
'radial-gradient(140% 140% at 50% 45%, transparent 60%, oklch(0.1 0.02 150 / 0.6) 100%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: ['260px 200px', '100% 100%', '100% 100%', '100% 100%'].join(','),
|
||||||
|
backgroundPosition: ['0 0', '0 0', '0 0', '0 0'].join(','),
|
||||||
|
animation: `${rainFall} 12s linear infinite`,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Light: soft teal-grey streaks on a pale cool base — elegant, never neon.
|
||||||
|
light: {
|
||||||
|
backgroundColor: 'oklch(0.97 0.008 165)',
|
||||||
|
backgroundImage: [
|
||||||
|
tile(
|
||||||
|
'oklch(0.55 0.07 165 / 0.4)', // head — soft teal-grey drop
|
||||||
|
'oklch(0.62 0.05 165 / 0.22)', // tail — faint teal-grey
|
||||||
|
'oklch(0.5 0.06 165 / 0.42)', // glyph ticks
|
||||||
|
),
|
||||||
|
// gentle cool wash from the top
|
||||||
|
'linear-gradient(180deg, oklch(0.94 0.015 175 / 0.6) 0%, transparent 42%)',
|
||||||
|
// faint teal pooling at the bottom edge
|
||||||
|
'radial-gradient(120% 90% at 50% 100%, oklch(0.9 0.02 170 / 0.5) 0%, transparent 60%)',
|
||||||
|
// soft vignette in cool grey
|
||||||
|
'radial-gradient(140% 140% at 50% 45%, transparent 62%, oklch(0.88 0.02 165 / 0.5) 100%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: ['260px 200px', '100% 100%', '100% 100%', '100% 100%'].join(','),
|
||||||
|
backgroundPosition: ['0 0', '0 0', '0 0', '0 0'].join(','),
|
||||||
|
animation: `${rainFall} 12s linear infinite`,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { keyframes } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
// Star Drift — a slow, serene PAN of a deep-space starfield with real parallax.
|
||||||
|
//
|
||||||
|
// The starfield in animStars.ts stacks six background layers:
|
||||||
|
// 1. near stars — tile 137x137, brighter, drifts FASTEST
|
||||||
|
// 2. mid stars — tile 191x191, medium
|
||||||
|
// 3. far dust — tile 233x233, dimmest, drifts SLOWEST
|
||||||
|
// 4. center vignette (100% 100%) — STATIC
|
||||||
|
// 5. nebula wash A (100% 100%) — STATIC
|
||||||
|
// 6. nebula wash B (100% 100%) — STATIC
|
||||||
|
//
|
||||||
|
// Seamless parallax: the single `animation` shorthand shares ONE duration across
|
||||||
|
// all layers, so speed differences are produced purely by how FAR each layer
|
||||||
|
// travels in the keyframe. For a perfectly seamless loop each star layer must
|
||||||
|
// translate by an EXACT integer multiple of its own tile period, so the pixel
|
||||||
|
// re-entering at the wrap is identical to the one that left. We move:
|
||||||
|
// near : -274px = 2 x 137 (two tiles -> fastest apparent drift)
|
||||||
|
// mid : -191px = 1 x 191 (one tile -> medium)
|
||||||
|
// far : -233px = 1 x 233 (one tile, but larger tile => slowest apparent)
|
||||||
|
// so near/mid/far read as three depths sliding past each other, yet every layer
|
||||||
|
// lands back on an identical phase at 100% for a jump-free repeat.
|
||||||
|
//
|
||||||
|
// A diagonal component (both x and y shift) makes the drift feel like gentle
|
||||||
|
// motion through space rather than a flat slide. The static layers are pinned at
|
||||||
|
// '0 0' every frame so the vignette and nebula never move under the text.
|
||||||
|
//
|
||||||
|
// The start frame ('0%') MUST match the static backgroundPosition authored in
|
||||||
|
// animStars.ts, so that when getChatBg STRIPS this animation for
|
||||||
|
// prefers-reduced-motion the finished starfield shows without a jump.
|
||||||
|
export const starDrift = keyframes({
|
||||||
|
'0%': {
|
||||||
|
backgroundPosition: '0 0, 61px 43px, 113px 97px, 0 0, 0 0, 0 0',
|
||||||
|
},
|
||||||
|
'100%': {
|
||||||
|
// near: -274/-274 (2 tiles), mid: 61-191/43-191, far: 113-233/97-233
|
||||||
|
backgroundPosition: '-274px -274px, -130px -148px, -120px -136px, 0 0, 0 0, 0 0',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { ChatBgVariants } from './types';
|
||||||
|
import { starDrift } from './animStars.css';
|
||||||
|
|
||||||
|
// animStars ("Star Drift") — a serene deep-space field slowly drifting, with
|
||||||
|
// genuine parallax between a near (brighter, faster) and a far (dim, slower)
|
||||||
|
// star layer, floated on a faint nebula wash and calmed by a center vignette.
|
||||||
|
//
|
||||||
|
// Concept: three tiling star layers at coprime-ish tile sizes (137/191/233 dark,
|
||||||
|
// 149/199/251 light) so their combined repeat is astronomically large and no
|
||||||
|
// seam is ever perceivable. The near layer is crisp and sparse; the far "dust"
|
||||||
|
// layer is dim and dense — the layer that gives depth. Beneath the stars sit a
|
||||||
|
// deep-blue -> violet nebula (two soft ellipses) and a center vignette that keeps
|
||||||
|
// the reading column the calmest, lowest-contrast area of the whole canvas.
|
||||||
|
//
|
||||||
|
// Layer stacking order (CSS paints image #1 on TOP):
|
||||||
|
// 1. near stars — brighter, largest visible drift (tile 137 / 149)
|
||||||
|
// 2. mid stars — softer, medium (tile 191 / 199)
|
||||||
|
// 3. far dust — dimmest, slowest, most-repeated (tile 233 / 251)
|
||||||
|
// 4. center vignette (100% 100%, static)
|
||||||
|
// 5. nebula wash A (100% 100%, static)
|
||||||
|
// 6. nebula wash B (100% 100%, static)
|
||||||
|
//
|
||||||
|
// Animation: `starDrift` (see animStars.css.ts) is a SLOW background-position PAN
|
||||||
|
// that translates each star layer by an exact integer number of its own tiles,
|
||||||
|
// so the loop is seamless AND the three layers drift at different apparent
|
||||||
|
// speeds (parallax). getChatBg adds willChange/contain for the animated case and
|
||||||
|
// STRIPS the `animation` for prefers-reduced-motion — at which point the static
|
||||||
|
// backgroundPosition below (identical to the keyframe's 0% frame) shows as a
|
||||||
|
// fully finished starfield on its own.
|
||||||
|
//
|
||||||
|
// Density is kept modest toward the center by the vignette + conservative dot
|
||||||
|
// sizes, and every star opacity stays low so text over the field always clears
|
||||||
|
// WCAG-AA in both themes.
|
||||||
|
|
||||||
|
export const animStars: ChatBgVariants = {
|
||||||
|
// Dark: cool white + faint blue stars on a near-black cosmos, lifted onto a
|
||||||
|
// deep-blue -> violet nebula with a soft vignette darkening the calm center.
|
||||||
|
dark: {
|
||||||
|
backgroundColor: 'oklch(0.15 0.03 275)',
|
||||||
|
backgroundImage: [
|
||||||
|
// 1. near stars — crisp cool-white, sparse, the "fast" parallax layer
|
||||||
|
'radial-gradient(circle at center, oklch(0.98 0.012 255 / 0.85) 0.6px, transparent 1.5px)',
|
||||||
|
// 2. mid stars — softer, a touch blue, more of them
|
||||||
|
'radial-gradient(circle at center, oklch(0.90 0.03 260 / 0.52) 0.6px, transparent 1.3px)',
|
||||||
|
// 3. far dust — faint blue haze, the slow depth layer (most repeats)
|
||||||
|
'radial-gradient(circle at center, oklch(0.78 0.06 255 / 0.28) 0.5px, transparent 1.1px)',
|
||||||
|
// 4. center vignette — keeps the reading column calmest / lowest-contrast
|
||||||
|
'radial-gradient(ellipse 120% 90% at 50% 42%, transparent 40%, oklch(0.09 0.03 270 / 0.58) 100%)',
|
||||||
|
// 5. nebula wash A — deep violet high-right
|
||||||
|
'radial-gradient(ellipse 140% 120% at 78% 10%, oklch(0.26 0.09 285 / 0.55) 0%, transparent 55%)',
|
||||||
|
// 6. nebula wash B — deep blue low-left
|
||||||
|
'radial-gradient(ellipse 130% 110% at 16% 94%, oklch(0.21 0.07 250 / 0.50) 0%, transparent 58%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: [
|
||||||
|
'137px 137px', // near stars
|
||||||
|
'191px 191px', // mid stars
|
||||||
|
'233px 233px', // far dust
|
||||||
|
'100% 100%', // vignette
|
||||||
|
'100% 100%', // nebula A
|
||||||
|
'100% 100%', // nebula B
|
||||||
|
].join(','),
|
||||||
|
// Must equal starDrift's 0% frame so reduced-motion shows this exact field.
|
||||||
|
backgroundPosition: [
|
||||||
|
'0 0', // near
|
||||||
|
'61px 43px', // mid (offset breaks tile alignment)
|
||||||
|
'113px 97px', // far (offset again)
|
||||||
|
'0 0', // vignette
|
||||||
|
'0 0', // nebula A
|
||||||
|
'0 0', // nebula B
|
||||||
|
].join(','),
|
||||||
|
animation: `${starDrift} 90s linear infinite`,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Light: an airy pre-dawn sky. No literal white-on-white stars — instead very
|
||||||
|
// soft pale sparkles plus the merest cool speckles, floated on a gentle cool
|
||||||
|
// gradient. Reads as elegant atmosphere, never as noise over text.
|
||||||
|
light: {
|
||||||
|
backgroundColor: 'oklch(0.965 0.008 255)',
|
||||||
|
backgroundImage: [
|
||||||
|
// 1. near sparkles — a hair brighter/warmer than the sky
|
||||||
|
'radial-gradient(circle at center, oklch(0.995 0.015 90 / 0.50) 0.6px, transparent 1.5px)',
|
||||||
|
// 2. mid cool speckles — faintest hint of darkness for texture/contrast
|
||||||
|
'radial-gradient(circle at center, oklch(0.60 0.05 260 / 0.15) 0.5px, transparent 1.2px)',
|
||||||
|
// 3. far dust — very soft cool haze, the slow depth layer
|
||||||
|
'radial-gradient(circle at center, oklch(0.70 0.04 255 / 0.11) 0.5px, transparent 1.1px)',
|
||||||
|
// 4. center vignette — subtly brightens the calm reading center
|
||||||
|
'radial-gradient(ellipse 120% 90% at 50% 44%, oklch(1 0 0 / 0.45) 30%, transparent 100%)',
|
||||||
|
// 5. pre-dawn wash A — cool blue high-right
|
||||||
|
'radial-gradient(ellipse 150% 120% at 80% 6%, oklch(0.90 0.05 255 / 0.60) 0%, transparent 60%)',
|
||||||
|
// 6. pre-dawn wash B — warm blush low-left
|
||||||
|
'radial-gradient(ellipse 140% 120% at 14% 96%, oklch(0.93 0.04 40 / 0.42) 0%, transparent 62%)',
|
||||||
|
].join(','),
|
||||||
|
// Same tile sizes as dark (137/191/233). The shared starDrift keyframe pans
|
||||||
|
// each layer by an exact integer multiple of ITS tile (near 2x137, mid 1x191,
|
||||||
|
// far 1x233); reusing these tiles here guarantees the loop wraps seamlessly in
|
||||||
|
// light mode too, since one keyframe drives both themes. Coprime-ish sizes keep
|
||||||
|
// the combined repeat astronomically large so no seam is ever perceivable.
|
||||||
|
backgroundSize: [
|
||||||
|
'137px 137px', // near sparkles
|
||||||
|
'191px 191px', // mid speckles
|
||||||
|
'233px 233px', // far dust
|
||||||
|
'100% 100%', // vignette
|
||||||
|
'100% 100%', // wash A
|
||||||
|
'100% 100%', // wash B
|
||||||
|
].join(','),
|
||||||
|
// Positions mirror the keyframe 0% frame (== reduced-motion static field).
|
||||||
|
backgroundPosition: [
|
||||||
|
'0 0', // near
|
||||||
|
'61px 43px', // mid
|
||||||
|
'113px 97px', // far
|
||||||
|
'0 0', // vignette
|
||||||
|
'0 0', // wash A
|
||||||
|
'0 0', // wash B
|
||||||
|
].join(','),
|
||||||
|
animation: `${starDrift} 100s linear infinite`,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { ChatBgVariants } from './types';
|
||||||
|
|
||||||
|
// blueprint — an engineering / architectural drafting sheet.
|
||||||
|
//
|
||||||
|
// Layers (painted top-to-bottom):
|
||||||
|
// 1. SVG draftsman tick-marks + a centred crosshair accent (96px tile — lands
|
||||||
|
// exactly on the major grid; corner quarter-arms tile into a full "+" on
|
||||||
|
// every major intersection).
|
||||||
|
// 2. Major grid lines (heavier) — 96px.
|
||||||
|
// 3. Minor grid lines (fine, fainter) — 16px (96 = 6 × 16, so it nests
|
||||||
|
// seamlessly inside the major grid with no beat/moiré).
|
||||||
|
// 4. A soft radial vignette + a gentle sheet-glow so the surface reads like a
|
||||||
|
// real drafting sheet with subtle dimension rather than a flat tile.
|
||||||
|
//
|
||||||
|
// Everything is kept at low alpha (~0.03–0.16) so the motif is felt, not read:
|
||||||
|
// crisp message text sits comfortably above it in both themes (WCAG-AA safe).
|
||||||
|
|
||||||
|
const DARK_TICKS =
|
||||||
|
'url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2296%22%20height%3D%2296%22%3E%3Cg%20stroke%3D%22oklch%280.72%200.11%20230%20%2F%200.32%29%22%20stroke-width%3D%221%22%3E%3Cpath%20d%3D%22M0%200%20H7%20M0%200%20V7%22%2F%3E%3Cpath%20d%3D%22M96%200%20H89%20M96%200%20V7%22%2F%3E%3Cpath%20d%3D%22M0%2096%20H7%20M0%2096%20V89%22%2F%3E%3Cpath%20d%3D%22M96%2096%20H89%20M96%2096%20V89%22%2F%3E%3C%2Fg%3E%3Cg%20stroke%3D%22oklch%280.72%200.11%20230%20%2F%200.18%29%22%20stroke-width%3D%221%22%3E%3Cpath%20d%3D%22M48%2044%20V52%20M44%2048%20H52%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E")';
|
||||||
|
|
||||||
|
const LIGHT_TICKS =
|
||||||
|
'url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2296%22%20height%3D%2296%22%3E%3Cg%20stroke%3D%22oklch%280.48%200.13%20250%20%2F%200.38%29%22%20stroke-width%3D%221%22%3E%3Cpath%20d%3D%22M0%200%20H7%20M0%200%20V7%22%2F%3E%3Cpath%20d%3D%22M96%200%20H89%20M96%200%20V7%22%2F%3E%3Cpath%20d%3D%22M0%2096%20H7%20M0%2096%20V89%22%2F%3E%3Cpath%20d%3D%22M96%2096%20H89%20M96%2096%20V89%22%2F%3E%3C%2Fg%3E%3Cg%20stroke%3D%22oklch%280.48%200.13%20250%20%2F%200.22%29%22%20stroke-width%3D%221%22%3E%3Cpath%20d%3D%22M48%2044%20V52%20M44%2048%20H52%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E")';
|
||||||
|
|
||||||
|
export const blueprint: ChatBgVariants = {
|
||||||
|
// Cyan-blue lines on a deep navy sheet.
|
||||||
|
dark: {
|
||||||
|
backgroundColor: 'oklch(0.22 0.05 250)',
|
||||||
|
backgroundImage: [
|
||||||
|
// 1. draftsman ticks + centre crosshair
|
||||||
|
DARK_TICKS,
|
||||||
|
// 4a. sheet-glow: a faint cooler highlight drifting off the top-left,
|
||||||
|
// giving the flat navy some dimension.
|
||||||
|
'radial-gradient(120% 120% at 18% 8%, oklch(0.30 0.06 245 / 0.55) 0%, transparent 55%)',
|
||||||
|
// 4b. vignette: gently darkens the corners like a drafting sheet edge.
|
||||||
|
'radial-gradient(140% 140% at 50% 42%, transparent 58%, oklch(0.14 0.04 255 / 0.5) 100%)',
|
||||||
|
// 2. major grid (heavier)
|
||||||
|
'linear-gradient(oklch(0.72 0.12 230 / 0.13) 1px, transparent 1px)',
|
||||||
|
'linear-gradient(90deg, oklch(0.72 0.12 230 / 0.13) 1px, transparent 1px)',
|
||||||
|
// 3. minor grid (fine, fainter)
|
||||||
|
'linear-gradient(oklch(0.72 0.12 230 / 0.05) 1px, transparent 1px)',
|
||||||
|
'linear-gradient(90deg, oklch(0.72 0.12 230 / 0.05) 1px, transparent 1px)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: [
|
||||||
|
'96px 96px', // ticks
|
||||||
|
'100% 100%', // sheet-glow
|
||||||
|
'100% 100%', // vignette
|
||||||
|
'96px 96px', // major V
|
||||||
|
'96px 96px', // major H
|
||||||
|
'16px 16px', // minor V
|
||||||
|
'16px 16px', // minor H
|
||||||
|
].join(','),
|
||||||
|
// All layers share the default top-left (0 0) origin so the tick tile, the
|
||||||
|
// 96px major grid and the 16px minor grid stay phase-locked (96 = 6 × 16) —
|
||||||
|
// no drift, no visible seams. (A per-layer `center` would let the differently
|
||||||
|
// sized tiles center independently and fall out of alignment.)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Blue lines on a cool paper-white sheet.
|
||||||
|
light: {
|
||||||
|
backgroundColor: 'oklch(0.97 0.01 240)',
|
||||||
|
backgroundImage: [
|
||||||
|
LIGHT_TICKS,
|
||||||
|
// sheet-glow: a hint of brighter paper toward the top-left.
|
||||||
|
'radial-gradient(120% 120% at 18% 8%, oklch(0.99 0.008 240 / 0.7) 0%, transparent 55%)',
|
||||||
|
// vignette: soft cool shading into the corners.
|
||||||
|
'radial-gradient(140% 140% at 50% 42%, transparent 60%, oklch(0.90 0.02 245 / 0.55) 100%)',
|
||||||
|
// major grid (heavier)
|
||||||
|
'linear-gradient(oklch(0.48 0.13 250 / 0.15) 1px, transparent 1px)',
|
||||||
|
'linear-gradient(90deg, oklch(0.48 0.13 250 / 0.15) 1px, transparent 1px)',
|
||||||
|
// minor grid (fine, fainter)
|
||||||
|
'linear-gradient(oklch(0.48 0.13 250 / 0.06) 1px, transparent 1px)',
|
||||||
|
'linear-gradient(90deg, oklch(0.48 0.13 250 / 0.06) 1px, transparent 1px)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: [
|
||||||
|
'96px 96px',
|
||||||
|
'100% 100%',
|
||||||
|
'100% 100%',
|
||||||
|
'96px 96px',
|
||||||
|
'96px 96px',
|
||||||
|
'16px 16px',
|
||||||
|
'16px 16px',
|
||||||
|
].join(','),
|
||||||
|
// Shared top-left origin keeps the tick tile and both grids phase-locked.
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { CSSProperties } from 'react';
|
||||||
|
import { ChatBgVariants } from './types';
|
||||||
|
|
||||||
|
// chevron — refined woven-upholstery zigzag.
|
||||||
|
//
|
||||||
|
// The motif is a continuous, crisp chevron built to read as *textured fabric*
|
||||||
|
// rather than flat stripes. The zigzag threads themselves are drawn with a
|
||||||
|
// tiny inline-SVG tile (guaranteed geometrically seamless — the "V" path exits
|
||||||
|
// each tile edge exactly where the next tile's path enters, both horizontally
|
||||||
|
// and vertically). Over that, layered CSS gradients add the premium feel:
|
||||||
|
// • a soft light→shade sweep across the weave gives each band an embossed,
|
||||||
|
// woven cross-section (catches light on one diagonal face, shade on the
|
||||||
|
// other);
|
||||||
|
// • a faint two-tone wash alternates the tint of successive chevron rows for
|
||||||
|
// an interlocked-yarn look;
|
||||||
|
// • a gentle centre lift + corner vignette settle the field so text always
|
||||||
|
// sits over the calmer middle.
|
||||||
|
//
|
||||||
|
// SEAMLESS TILING
|
||||||
|
// The SVG is a WxH tile whose path is one full zigzag wave: it starts at the
|
||||||
|
// left edge, dips to the vertex, rises to the right edge at the SAME y it
|
||||||
|
// started — so horizontally each tile's end meets the next tile's start with no
|
||||||
|
// step. Two stacked strokes (offset by H) fill the vertical repeat, and the
|
||||||
|
// tile height equals the row pitch, so vertical stacking is seamless too. The
|
||||||
|
// gradient overlays are non-repeating (100% 100%) or share the SVG's tile
|
||||||
|
// width, so none of them introduce a seam.
|
||||||
|
//
|
||||||
|
// Everything sits at low alpha (~0.03–0.11) so the pattern is felt, not read:
|
||||||
|
// crisp message text stays comfortably WCAG-AA in both themes.
|
||||||
|
|
||||||
|
// One zigzag wave, 40px wide × 20px tall. Path enters at (0,4), dips to the
|
||||||
|
// vertex at (20,16), climbs back to (40,4) — identical entry/exit y => seamless
|
||||||
|
// horizontal repeat. A second copy shifted +10 in y keeps a soft double thread.
|
||||||
|
const svg = (stroke: string, faint: string) =>
|
||||||
|
'url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20' +
|
||||||
|
'width%3D%2240%22%20height%3D%2220%22%3E' +
|
||||||
|
`%3Cpath%20d%3D%22M0%204%20L20%2016%20L40%204%22%20fill%3D%22none%22%20stroke%3D%22${stroke}%22%20stroke-width%3D%223%22%2F%3E` +
|
||||||
|
`%3Cpath%20d%3D%22M0%2014%20L20%2026%20L40%2014%20M0%20-6%20L20%206%20L40%20-6%22%20fill%3D%22none%22%20stroke%3D%22${faint}%22%20stroke-width%3D%222%22%2F%3E` +
|
||||||
|
'%3C%2Fsvg%3E")';
|
||||||
|
|
||||||
|
const dark: CSSProperties = {
|
||||||
|
backgroundColor: 'oklch(0.20 0.022 260)',
|
||||||
|
backgroundImage: [
|
||||||
|
// 1. The zigzag threads — muted indigo/slate, main + fainter under-thread.
|
||||||
|
svg('oklch(0.55 0.05 265 %2F 0.16)', 'oklch(0.50 0.045 262 %2F 0.07)'),
|
||||||
|
// 2. Woven emboss — a soft diagonal light→shade sweep across the weave so
|
||||||
|
// the bands catch light on one face and fall to shade on the other.
|
||||||
|
'linear-gradient(135deg, oklch(0.62 0.05 265 / 0.05) 0%, transparent 45%, transparent 55%, oklch(0.14 0.02 260 / 0.06) 100%)',
|
||||||
|
// 3. Two-tone weft — a whisper shade on alternate chevron rows.
|
||||||
|
'repeating-linear-gradient(0deg, oklch(0.50 0.04 258 / 0.035) 0px, oklch(0.50 0.04 258 / 0.035) 20px, transparent 20px, transparent 40px)',
|
||||||
|
// 4. Tonal wash — cool centre lift for gentle depth.
|
||||||
|
'radial-gradient(ellipse 90% 75% at 50% 42%, oklch(0.26 0.03 262 / 0.40) 0%, transparent 60%)',
|
||||||
|
// 5. Vignette — feather corners into deeper charcoal-blue.
|
||||||
|
'radial-gradient(ellipse 120% 130% at 50% 45%, transparent 60%, oklch(0.15 0.02 260 / 0.55) 100%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: '40px 20px, 100% 100%, 40px 40px, 100% 100%, 100% 100%',
|
||||||
|
};
|
||||||
|
|
||||||
|
const light: CSSProperties = {
|
||||||
|
backgroundColor: 'oklch(0.965 0.006 85)',
|
||||||
|
backgroundImage: [
|
||||||
|
// 1. The zigzag threads — soft dusty-blue, main + fainter under-thread.
|
||||||
|
svg('oklch(0.55 0.05 255 %2F 0.14)', 'oklch(0.52 0.045 255 %2F 0.06)'),
|
||||||
|
// 2. Woven emboss — diagonal light→shade sweep for a knit-fabric surface.
|
||||||
|
'linear-gradient(135deg, oklch(0.99 0.008 85 / 0.06) 0%, transparent 45%, transparent 55%, oklch(0.55 0.05 255 / 0.05) 100%)',
|
||||||
|
// 3. Two-tone weft — faint alternating-row shade.
|
||||||
|
'repeating-linear-gradient(0deg, oklch(0.52 0.04 255 / 0.03) 0px, oklch(0.52 0.04 255 / 0.03) 20px, transparent 20px, transparent 40px)',
|
||||||
|
// 4. Tonal wash — warm paper highlight through the reading centre.
|
||||||
|
'radial-gradient(ellipse 90% 75% at 50% 42%, oklch(0.99 0.008 85 / 0.55) 0%, transparent 60%)',
|
||||||
|
// 5. Vignette — settle corners into a slightly deeper dusty tone.
|
||||||
|
'radial-gradient(ellipse 120% 130% at 50% 45%, transparent 60%, oklch(0.91 0.012 250 / 0.40) 100%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: '40px 20px, 100% 100%, 40px 40px, 100% 100%, 100% 100%',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const chevron: ChatBgVariants = { dark, light };
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { ChatBgVariants } from './types';
|
||||||
|
|
||||||
|
// circuit — an elegant printed-circuit board.
|
||||||
|
//
|
||||||
|
// Concept: thin right-angle copper traces route between small pads / vias and
|
||||||
|
// the occasional solder-junction dot, over a deep board base. It reads as an
|
||||||
|
// authentic PCB rather than a plain grid: the routing turns corners, dead-ends
|
||||||
|
// at through-hole pads, and picks up faint via-glows — but stays sparse, with
|
||||||
|
// generous negative space so message text always wins the contrast fight.
|
||||||
|
//
|
||||||
|
// The trace network is a single inline SVG data-URI (encodeURIComponent, NOT
|
||||||
|
// base64 — CSP / Tauri-safe) so the geometry can be real right-angle routing
|
||||||
|
// instead of gradient fakery. It is layered over a subtle board-base gradient.
|
||||||
|
//
|
||||||
|
// SEAMLESS TILING — the 120×120 tile is authored so every trace that leaves an
|
||||||
|
// edge re-enters at the identical coordinate on the OPPOSITE edge, so the copper
|
||||||
|
// runs continuously across tile boundaries with no visible seam:
|
||||||
|
// • horizontal runs cross the left/right edges at y = 30 and y = 90
|
||||||
|
// • vertical runs cross the top/bottom edges at x = 40 and x = 88
|
||||||
|
// backgroundSize is set to the tile size (120px) so those crossings line up
|
||||||
|
// exactly on repeat.
|
||||||
|
//
|
||||||
|
// Two hand-tuned SVGs (dark / light) differ only in stroke/fill colour + alpha.
|
||||||
|
// Alphas stay low (≈0.05–0.5 on the accents, traces ~0.1–0.16) so the pattern is
|
||||||
|
// felt, not read — crisp text sits comfortably above it in both themes.
|
||||||
|
|
||||||
|
// Shared geometry, colour-parameterised so the two themes stay pixel-identical
|
||||||
|
// in layout and only diverge in palette.
|
||||||
|
const tile = (
|
||||||
|
trace: string, // trace stroke colour
|
||||||
|
traceW: string, // trace stroke-width
|
||||||
|
pad: string, // pad ring colour
|
||||||
|
padFill: string, // pad centre / board-coloured hole
|
||||||
|
via: string, // via glow colour
|
||||||
|
junction: string, // filled junction-dot colour
|
||||||
|
): string => {
|
||||||
|
const svg = `<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120' viewBox='0 0 120 120'>
|
||||||
|
<g fill='none' stroke='${trace}' stroke-width='${traceW}' stroke-linecap='round' stroke-linejoin='round'>
|
||||||
|
<path d='M0 30 H26 V58 H60'/>
|
||||||
|
<path d='M60 58 V90 H120'/>
|
||||||
|
<path d='M0 90 H40 V120'/>
|
||||||
|
<path d='M40 0 V22 H88'/>
|
||||||
|
<path d='M88 0 V44 H104'/>
|
||||||
|
<path d='M104 44 V90 H120'/>
|
||||||
|
<path d='M88 120 V90'/>
|
||||||
|
<path d='M60 30 H120'/>
|
||||||
|
<path d='M60 30 V58'/>
|
||||||
|
<path d='M26 58 V90'/>
|
||||||
|
</g>
|
||||||
|
<g fill='none' stroke='${pad}' stroke-width='${traceW}'>
|
||||||
|
<circle cx='26' cy='58' r='3.4'/>
|
||||||
|
<circle cx='40' cy='90' r='3.4'/>
|
||||||
|
<circle cx='88' cy='22' r='3.4'/>
|
||||||
|
<circle cx='104' cy='44' r='3.4'/>
|
||||||
|
</g>
|
||||||
|
<g fill='${padFill}'>
|
||||||
|
<circle cx='26' cy='58' r='1.3'/>
|
||||||
|
<circle cx='40' cy='90' r='1.3'/>
|
||||||
|
<circle cx='88' cy='22' r='1.3'/>
|
||||||
|
<circle cx='104' cy='44' r='1.3'/>
|
||||||
|
</g>
|
||||||
|
<g fill='${junction}'>
|
||||||
|
<circle cx='60' cy='58' r='2'/>
|
||||||
|
<circle cx='60' cy='30' r='2'/>
|
||||||
|
<circle cx='104' cy='90' r='2'/>
|
||||||
|
</g>
|
||||||
|
<g fill='${via}'>
|
||||||
|
<circle cx='26' cy='58' r='7'/>
|
||||||
|
<circle cx='88' cy='22' r='7'/>
|
||||||
|
<circle cx='104' cy='44' r='7'/>
|
||||||
|
</g>
|
||||||
|
</svg>`;
|
||||||
|
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const circuit: ChatBgVariants = {
|
||||||
|
// Faint teal/green copper with dim cyan via-glows on a near-black board.
|
||||||
|
dark: {
|
||||||
|
backgroundColor: 'oklch(0.17 0.02 165)',
|
||||||
|
backgroundImage: [
|
||||||
|
tile(
|
||||||
|
'oklch(0.7 0.1 165 / 0.16)', // traces — faint teal-green copper
|
||||||
|
'1',
|
||||||
|
'oklch(0.72 0.11 175 / 0.32)', // pad rings — slightly brighter
|
||||||
|
'oklch(0.17 0.02 165)', // pad holes — board colour (drilled look)
|
||||||
|
'oklch(0.78 0.13 200 / 0.14)', // via glow — dim cyan halo
|
||||||
|
'oklch(0.74 0.12 170 / 0.4)', // junction dots — solid copper
|
||||||
|
),
|
||||||
|
// board-base: a gentle diagonal sheen so the flat near-black gains depth.
|
||||||
|
'radial-gradient(130% 130% at 20% 12%, oklch(0.22 0.03 170 / 0.6) 0%, transparent 58%)',
|
||||||
|
// vignette: barely darkens the corners like a laminated board edge.
|
||||||
|
'radial-gradient(140% 140% at 50% 45%, transparent 62%, oklch(0.12 0.02 165 / 0.55) 100%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: ['120px 120px', '100% 100%', '100% 100%'].join(','),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Soft green-grey traces on a pale board.
|
||||||
|
light: {
|
||||||
|
backgroundColor: 'oklch(0.96 0.012 160)',
|
||||||
|
backgroundImage: [
|
||||||
|
tile(
|
||||||
|
'oklch(0.55 0.07 165 / 0.24)', // traces — soft green-grey copper
|
||||||
|
'1',
|
||||||
|
'oklch(0.5 0.08 170 / 0.4)', // pad rings
|
||||||
|
'oklch(0.96 0.012 160)', // pad holes — board colour
|
||||||
|
'oklch(0.6 0.09 200 / 0.1)', // via glow — faint cool halo
|
||||||
|
'oklch(0.5 0.08 165 / 0.42)', // junction dots
|
||||||
|
),
|
||||||
|
// board-base: a hint of brighter laminate toward the top-left.
|
||||||
|
'radial-gradient(130% 130% at 20% 12%, oklch(0.99 0.008 160 / 0.7) 0%, transparent 58%)',
|
||||||
|
// vignette: soft green-grey shading into the corners.
|
||||||
|
'radial-gradient(140% 140% at 50% 45%, transparent 62%, oklch(0.9 0.02 160 / 0.5) 100%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: ['120px 120px', '100% 100%', '100% 100%'].join(','),
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { CSSProperties } from 'react';
|
||||||
|
import { ChatBgVariants } from './types';
|
||||||
|
|
||||||
|
// crosshatch — fine pen-and-ink engraving, like a banknote guilloché.
|
||||||
|
// Three hatch directions (right-leaning, left-leaning, near-horizontal cross)
|
||||||
|
// are layered at low opacity so the eye reads a woven ink texture rather than
|
||||||
|
// discrete stripes. Each direction uses a slightly different pitch so the
|
||||||
|
// combined pattern never lines up into a coarse moire, and a barely-there
|
||||||
|
// diagonal tonal gradient lends etched depth.
|
||||||
|
//
|
||||||
|
// Seamless tiling: each hatch is a `repeating-linear-gradient`, which repeats
|
||||||
|
// infinitely by definition, so the layers are left at `backgroundSize: auto`
|
||||||
|
// and tile with no visible seam at any element size (constraining a diagonal
|
||||||
|
// repeat to a small square would clip it mid-period and create a seam). The
|
||||||
|
// tonal wash is a single non-repeating gradient stretched to `cover`.
|
||||||
|
//
|
||||||
|
// Opacities are kept in the 0.02–0.05 range so the texture is felt, not read —
|
||||||
|
// crisp message text sits comfortably above it in both themes (WCAG-AA safe).
|
||||||
|
|
||||||
|
const dark: CSSProperties = {
|
||||||
|
// near-black base with a whisper of cool blue so silver ink reads as engraving
|
||||||
|
backgroundColor: 'oklch(0.16 0.01 255)',
|
||||||
|
backgroundImage: [
|
||||||
|
// faint tonal gradient — top-left slightly lifted for etched depth
|
||||||
|
'linear-gradient(135deg, oklch(0.20 0.012 255 / 0.5) 0%, oklch(0.15 0.01 255 / 0) 55%, oklch(0.14 0.008 260 / 0.45) 100%)',
|
||||||
|
// primary hatch, right-leaning fine lines (cool silver ink), ~9px pitch
|
||||||
|
'repeating-linear-gradient(45deg, oklch(0.75 0.02 250 / 0.05) 0, oklch(0.75 0.02 250 / 0.05) 0.75px, transparent 0.75px, transparent 9px)',
|
||||||
|
// secondary hatch, left-leaning — the cross of the crosshatch
|
||||||
|
'repeating-linear-gradient(135deg, oklch(0.75 0.02 250 / 0.045) 0, oklch(0.75 0.02 250 / 0.045) 0.75px, transparent 0.75px, transparent 9px)',
|
||||||
|
// tertiary hatch, right-leaning at a denser pitch for engraved richness
|
||||||
|
'repeating-linear-gradient(45deg, oklch(0.78 0.018 250 / 0.02) 0, oklch(0.78 0.018 250 / 0.02) 0.5px, transparent 0.5px, transparent 4.5px)',
|
||||||
|
// quaternary near-horizontal fill line, very faint, weaves the mesh together
|
||||||
|
'repeating-linear-gradient(20deg, oklch(0.72 0.015 255 / 0.018) 0, oklch(0.72 0.015 255 / 0.018) 0.5px, transparent 0.5px, transparent 13px)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: 'cover, auto, auto, auto, auto',
|
||||||
|
};
|
||||||
|
|
||||||
|
const light: CSSProperties = {
|
||||||
|
// warm paper base — graphite ink on cream stock
|
||||||
|
backgroundColor: 'oklch(0.975 0.006 85)',
|
||||||
|
backgroundImage: [
|
||||||
|
// faint tonal wash — soft warm depth for aged-paper feel
|
||||||
|
'linear-gradient(135deg, oklch(0.94 0.008 85 / 0.55) 0%, oklch(0.98 0.005 85 / 0) 55%, oklch(0.93 0.01 80 / 0.5) 100%)',
|
||||||
|
// primary hatch, right-leaning graphite lines, ~9px pitch
|
||||||
|
'repeating-linear-gradient(45deg, oklch(0.42 0.01 265 / 0.055) 0, oklch(0.42 0.01 265 / 0.055) 0.75px, transparent 0.75px, transparent 9px)',
|
||||||
|
// secondary hatch, left-leaning — the cross
|
||||||
|
'repeating-linear-gradient(135deg, oklch(0.42 0.01 265 / 0.05) 0, oklch(0.42 0.01 265 / 0.05) 0.75px, transparent 0.75px, transparent 9px)',
|
||||||
|
// tertiary denser right-leaning hatch for engraved fineness
|
||||||
|
'repeating-linear-gradient(45deg, oklch(0.40 0.012 265 / 0.025) 0, oklch(0.40 0.012 265 / 0.025) 0.5px, transparent 0.5px, transparent 4.5px)',
|
||||||
|
// quaternary near-horizontal weave line, barely-there
|
||||||
|
'repeating-linear-gradient(20deg, oklch(0.45 0.01 260 / 0.022) 0, oklch(0.45 0.01 260 / 0.022) 0.5px, transparent 0.5px, transparent 13px)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: 'cover, auto, auto, auto, auto',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const crosshatch: ChatBgVariants = { dark, light };
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { ChatBgVariants } from './types';
|
||||||
|
|
||||||
|
// Herringbone — a refined, tactile broken-zigzag weave (the classic parquet / tweed
|
||||||
|
// motif) rather than a flat hairline grid. Each plank is drawn twice in a compact SVG
|
||||||
|
// data-URI tile: a lit "thread" and a 0.6px-offset shadow companion, so every plank
|
||||||
|
// reads as a beveled, three-dimensional strand of fabric instead of a line.
|
||||||
|
//
|
||||||
|
// SEAMLESS TILING: planks live on a 12px lattice and their orientation follows the true
|
||||||
|
// herringbone rule orient(cx, cy) = '/' when (cx - cy) mod 4 in {0, 1}, else '\\'.
|
||||||
|
// That rule is exactly periodic every 4 cells in BOTH axes, so the 48x48px tile repeats
|
||||||
|
// with no seam at any scroll offset; segment endpoints all land on lattice corners, so
|
||||||
|
// the broken V's interlock perfectly across tile edges.
|
||||||
|
//
|
||||||
|
// DEPTH: beneath the weave sit two very low-contrast oklch layers — a diagonal two-tone
|
||||||
|
// wash that gives the fabric a faint lit/shadowed side, plus a soft vignette that lets
|
||||||
|
// the centre (where text lives) stay calmest. Everything is kept in the "felt, not read"
|
||||||
|
// opacity band so WCAG-AA body text sits comfortably on top in both themes.
|
||||||
|
|
||||||
|
const WEAVE_DARK =
|
||||||
|
'data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2248%22%20height%3D%2248%22%20viewBox%3D%220%200%2048%2048%22%3E%3Cpath%20d%3D%22M-11.4%200.6L0.6%20-11.4M0.6%200.6L12.6%20-11.4M12.6%20-11.4L24.6%200.6M24.6%20-11.4L36.6%200.6M36.6%200.6L48.6%20-11.4M48.6%200.6L60.6%20-11.4M-11.4%200.6L0.6%2012.6M0.6%2012.6L12.6%200.6M12.6%2012.6L24.6%200.6M24.6%200.6L36.6%2012.6M36.6%200.6L48.6%2012.6M48.6%2012.6L60.6%200.6M-11.4%2012.6L0.6%2024.6M0.6%2012.6L12.6%2024.6M12.6%2024.6L24.6%2012.6M24.6%2024.6L36.6%2012.6M36.6%2012.6L48.6%2024.6M48.6%2012.6L60.6%2024.6M-11.4%2036.6L0.6%2024.6M0.6%2024.6L12.6%2036.6M12.6%2024.6L24.6%2036.6M24.6%2036.6L36.6%2024.6M36.6%2036.6L48.6%2024.6M48.6%2024.6L60.6%2036.6M-11.4%2048.6L0.6%2036.6M0.6%2048.6L12.6%2036.6M12.6%2036.6L24.6%2048.6M24.6%2036.6L36.6%2048.6M36.6%2048.6L48.6%2036.6M48.6%2048.6L60.6%2036.6M-11.4%2048.6L0.6%2060.6M0.6%2060.6L12.6%2048.6M12.6%2060.6L24.6%2048.6M24.6%2048.6L36.6%2060.6M36.6%2048.6L48.6%2060.6M48.6%2060.6L60.6%2048.6%22%20fill%3D%22none%22%20stroke%3D%22rgb%2810%2C8%2C6%29%22%20stroke-width%3D%221.1%22%20stroke-opacity%3D%220.085%22%20stroke-linecap%3D%22round%22%2F%3E%3Cpath%20d%3D%22M-12%200L0%20-12M0%200L12%20-12M12%20-12L24%200M24%20-12L36%200M36%200L48%20-12M48%200L60%20-12M-12%200L0%2012M0%2012L12%200M12%2012L24%200M24%200L36%2012M36%200L48%2012M48%2012L60%200M-12%2012L0%2024M0%2012L12%2024M12%2024L24%2012M24%2024L36%2012M36%2012L48%2024M48%2012L60%2024M-12%2036L0%2024M0%2024L12%2036M12%2024L24%2036M24%2036L36%2024M36%2036L48%2024M48%2024L60%2036M-12%2048L0%2036M0%2048L12%2036M12%2036L24%2048M24%2036L36%2048M36%2048L48%2036M48%2048L60%2036M-12%2048L0%2060M0%2060L12%2048M12%2060L24%2048M24%2048L36%2060M36%2048L48%2060M48%2060L60%2048%22%20fill%3D%22none%22%20stroke%3D%22rgb%28210%2C199%2C180%29%22%20stroke-width%3D%221.1%22%20stroke-opacity%3D%220.111%22%20stroke-linecap%3D%22round%22%2F%3E%3C%2Fsvg%3E';
|
||||||
|
const WEAVE_LIGHT =
|
||||||
|
'data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2248%22%20height%3D%2248%22%20viewBox%3D%220%200%2048%2048%22%3E%3Cpath%20d%3D%22M-11.4%200.6L0.6%20-11.4M0.6%200.6L12.6%20-11.4M12.6%20-11.4L24.6%200.6M24.6%20-11.4L36.6%200.6M36.6%200.6L48.6%20-11.4M48.6%200.6L60.6%20-11.4M-11.4%200.6L0.6%2012.6M0.6%2012.6L12.6%200.6M12.6%2012.6L24.6%200.6M24.6%200.6L36.6%2012.6M36.6%200.6L48.6%2012.6M48.6%2012.6L60.6%200.6M-11.4%2012.6L0.6%2024.6M0.6%2012.6L12.6%2024.6M12.6%2024.6L24.6%2012.6M24.6%2024.6L36.6%2012.6M36.6%2012.6L48.6%2024.6M48.6%2012.6L60.6%2024.6M-11.4%2036.6L0.6%2024.6M0.6%2024.6L12.6%2036.6M12.6%2024.6L24.6%2036.6M24.6%2036.6L36.6%2024.6M36.6%2036.6L48.6%2024.6M48.6%2024.6L60.6%2036.6M-11.4%2048.6L0.6%2036.6M0.6%2048.6L12.6%2036.6M12.6%2036.6L24.6%2048.6M24.6%2036.6L36.6%2048.6M36.6%2048.6L48.6%2036.6M48.6%2048.6L60.6%2036.6M-11.4%2048.6L0.6%2060.6M0.6%2060.6L12.6%2048.6M12.6%2060.6L24.6%2048.6M24.6%2048.6L36.6%2060.6M36.6%2048.6L48.6%2060.6M48.6%2060.6L60.6%2048.6%22%20fill%3D%22none%22%20stroke%3D%22rgb%28126%2C116%2C98%29%22%20stroke-width%3D%221.1%22%20stroke-opacity%3D%220.075%22%20stroke-linecap%3D%22round%22%2F%3E%3Cpath%20d%3D%22M-12%200L0%20-12M0%200L12%20-12M12%20-12L24%200M24%20-12L36%200M36%200L48%20-12M48%200L60%20-12M-12%200L0%2012M0%2012L12%200M12%2012L24%200M24%200L36%2012M36%200L48%2012M48%2012L60%200M-12%2012L0%2024M0%2012L12%2024M12%2024L24%2012M24%2024L36%2012M36%2012L48%2024M48%2012L60%2024M-12%2036L0%2024M0%2024L12%2036M12%2024L24%2036M24%2036L36%2024M36%2036L48%2024M48%2024L60%2036M-12%2048L0%2036M0%2048L12%2036M12%2036L24%2048M24%2036L36%2048M36%2048L48%2036M48%2048L60%2036M-12%2048L0%2060M0%2060L12%2048M12%2060L24%2048M24%2048L36%2060M36%2048L48%2060M48%2060L60%2048%22%20fill%3D%22none%22%20stroke%3D%22rgb%28255%2C253%2C247%29%22%20stroke-width%3D%221.1%22%20stroke-opacity%3D%220.098%22%20stroke-linecap%3D%22round%22%2F%3E%3C%2Fsvg%3E';
|
||||||
|
|
||||||
|
export const herringbone: ChatBgVariants = {
|
||||||
|
// Warm taupe threads (~oklch(0.79 0.02 75)) over a charcoal base. The two-tone wash
|
||||||
|
// runs cool-charcoal -> slightly warmer charcoal across the diagonal so the weave has
|
||||||
|
// a gentle light side; the vignette darkens the far corners a touch for depth.
|
||||||
|
dark: {
|
||||||
|
backgroundColor: '#14120f',
|
||||||
|
backgroundImage: [
|
||||||
|
`url("${WEAVE_DARK}")`,
|
||||||
|
'linear-gradient(135deg, oklch(0.26 0.012 70 / 0.5) 0%, oklch(0.2 0.008 60 / 0.5) 100%)',
|
||||||
|
'radial-gradient(120% 120% at 50% 40%, oklch(0.24 0.01 65 / 0) 55%, oklch(0.12 0.006 55 / 0.45) 100%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: '48px 48px, 100% 100%, 100% 100%',
|
||||||
|
backgroundRepeat: 'repeat, no-repeat, no-repeat',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Greige threads (shadow ~oklch(0.6 0.015 75)) with a warm-white highlight over a warm
|
||||||
|
// off-white base. The wash tilts warm-white -> faint greige across the diagonal for the
|
||||||
|
// lit/shadow side; a whisper-soft vignette keeps corners from going flat.
|
||||||
|
light: {
|
||||||
|
backgroundColor: '#f6f3ec',
|
||||||
|
backgroundImage: [
|
||||||
|
`url("${WEAVE_LIGHT}")`,
|
||||||
|
'linear-gradient(135deg, oklch(0.99 0.006 85 / 0.6) 0%, oklch(0.93 0.01 80 / 0.6) 100%)',
|
||||||
|
'radial-gradient(120% 120% at 50% 40%, oklch(0.98 0.006 85 / 0) 58%, oklch(0.87 0.012 78 / 0.4) 100%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: '48px 48px, 100% 100%, 100% 100%',
|
||||||
|
backgroundRepeat: 'repeat, no-repeat, no-repeat',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { CSSProperties } from 'react';
|
||||||
|
import { ChatBgVariants } from './types';
|
||||||
|
|
||||||
|
// hexgrid — a refined sci-fi HUD honeycomb lattice.
|
||||||
|
//
|
||||||
|
// The motif is a crisp pointy-top hexagon honeycomb, drawn as thin interlocking
|
||||||
|
// outlines like the readout of a sci-fi interface. It is layered over a soft
|
||||||
|
// depth sheen: a faint central glow lifts the middle of the field and a gentle
|
||||||
|
// vignette settles the corners, so the lattice reads as a lit HUD surface with
|
||||||
|
// dimension rather than a flat repeating tile. Everything is kept at low alpha
|
||||||
|
// (hex lines ~0.14–0.16, washes well under legibility thresholds) so the motif
|
||||||
|
// is *felt, not read* — crisp message text stays comfortably WCAG-AA in both
|
||||||
|
// themes.
|
||||||
|
//
|
||||||
|
// SEAMLESS TILING
|
||||||
|
// The hex outlines live in a single inline-SVG data-URI tile of exactly
|
||||||
|
// √3·s × 3·s = 34.641 × 60 (side length s = 20). That is the natural repeat cell
|
||||||
|
// of a pointy-top honeycomb: one full central hexagon plus the six neighbours
|
||||||
|
// whose bodies straddle the tile edges. Because each straddling hexagon is drawn
|
||||||
|
// in full, the half that spills past one edge is completed pixel-for-pixel by the
|
||||||
|
// matching half re-entering from the opposite edge on the next repeat — the six
|
||||||
|
// vertical side edges land exactly on x = 0 and x = 34.641, the slanted edges
|
||||||
|
// meet across y = 0 / y = 60, so the lattice interlocks with no seam and no
|
||||||
|
// moiré. `backgroundSize: 34.641px 60px` locks the tile to that period; the glow
|
||||||
|
// and vignette are single non-repeating layers sized to 100%.
|
||||||
|
//
|
||||||
|
// DARK vs LIGHT
|
||||||
|
// Dark: cool cyan hex lines (oklch 0.72 0.1 200) on a deep blue-black base, with
|
||||||
|
// a soft cyan-tinted central glow — the classic "cold HUD" look.
|
||||||
|
// Light: soft slate-blue hexes (oklch 0.55 0.07 250) on a pale cool-white sheet,
|
||||||
|
// with a bright paper highlight at centre. Each alpha/lightness is tuned
|
||||||
|
// independently so both feel equally quiet against their own base.
|
||||||
|
|
||||||
|
// One seamless honeycomb tile (√3·20 × 3·20). Colour is injected per-theme.
|
||||||
|
const hexTile = (stroke: string): string =>
|
||||||
|
`url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2234.641%22%20height%3D%2260%22%3E%3Cpath%20d%3D%22M17.32%2010L0%2020L0%2040L17.32%2050L34.64%2040L34.64%2020Z%20M0%20-20L-17.32%20-10L-17.32%2010L0%2020L17.32%2010L17.32%20-10Z%20M34.64%20-20L17.32%20-10L17.32%2010L34.64%2020L51.96%2010L51.96%20-10Z%20M0%2040L-17.32%2050L-17.32%2070L0%2080L17.32%2070L17.32%2050Z%20M34.64%2040L17.32%2050L17.32%2070L34.64%2080L51.96%2070L51.96%2050Z%20M17.32%20-50L0%20-40L0%20-20L17.32%20-10L34.64%20-20L34.64%20-40Z%20M17.32%2070L0%2080L0%20100L17.32%20110L34.64%20100L34.64%2080Z%22%20fill%3D%22none%22%20stroke%3D%22${encodeURIComponent(
|
||||||
|
stroke,
|
||||||
|
)}%22%20stroke-width%3D%220.9%22%20stroke-linejoin%3D%22round%22%2F%3E%3C%2Fsvg%3E")`;
|
||||||
|
|
||||||
|
const dark: CSSProperties = {
|
||||||
|
backgroundColor: 'oklch(0.19 0.03 245)',
|
||||||
|
backgroundImage: [
|
||||||
|
// 1. the honeycomb lattice — cool cyan hex outlines.
|
||||||
|
hexTile('oklch(0.72 0.1 200 / 0.14)'),
|
||||||
|
// 2. central glow — a soft cyan lift so the field looks lit from within.
|
||||||
|
'radial-gradient(120% 90% at 50% 42%, oklch(0.30 0.05 210 / 0.55) 0%, transparent 60%)',
|
||||||
|
// 3. vignette — settles the corners into the deep base for depth.
|
||||||
|
'radial-gradient(130% 130% at 50% 45%, transparent 55%, oklch(0.13 0.02 240 / 0.55) 100%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: '34.641px 60px, 100% 100%, 100% 100%',
|
||||||
|
};
|
||||||
|
|
||||||
|
const light: CSSProperties = {
|
||||||
|
backgroundColor: 'oklch(0.965 0.008 240)',
|
||||||
|
backgroundImage: [
|
||||||
|
// 1. the honeycomb lattice — soft slate-blue hex outlines.
|
||||||
|
hexTile('oklch(0.55 0.07 250 / 0.16)'),
|
||||||
|
// 2. central highlight — a hint of brighter paper toward the middle.
|
||||||
|
'radial-gradient(120% 90% at 50% 40%, oklch(0.99 0.005 240 / 0.7) 0%, transparent 60%)',
|
||||||
|
// 3. vignette — feather the edges into a slightly cooler paper.
|
||||||
|
'radial-gradient(130% 130% at 50% 45%, transparent 58%, oklch(0.90 0.015 245 / 0.5) 100%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: '34.641px 60px, 100% 100%, 100% 100%',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const hexgrid: ChatBgVariants = { dark, light };
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { ChatBgVariants } from './types';
|
||||||
|
|
||||||
|
// neon — a synthwave neon grid with real bloom, kept restrained for readability.
|
||||||
|
//
|
||||||
|
// Concept: a retro-futuristic magenta/cyan grid that *glows* rather than shouts.
|
||||||
|
// The glow is built the way a real neon tube reads: a crisp hairline of light
|
||||||
|
// sitting inside a much wider, softer halo of the same hue. We achieve this per
|
||||||
|
// axis by stacking TWO linear-gradient layers that share the identical tile size
|
||||||
|
// (so their lines land on exactly the same pixel column/row across every repeat):
|
||||||
|
// - a wide "bloom" line: a fat, very-low-opacity band with a soft gradient
|
||||||
|
// falloff on both sides (transparent -> colour -> transparent), reading as
|
||||||
|
// out-of-focus glow;
|
||||||
|
// - a crisp "core" line: a 1px bright hairline centred in that bloom.
|
||||||
|
// A dark radial vignette then pulls the whole grid back toward the edges and
|
||||||
|
// keeps the reading column — the calm centre — darkest and highest-contrast, so
|
||||||
|
// text stays crisp. Pure CSS: only linear + radial gradients, no assets.
|
||||||
|
//
|
||||||
|
// Seamless tiling: every grid layer uses the SAME backgroundSize per axis
|
||||||
|
// (magenta and cyan share one 88px module in dark; the fine cyan sub-grid is an
|
||||||
|
// exact 1/2 divisor at 44px so it re-registers). Because the bloom and core for
|
||||||
|
// an axis share a size and a 0/0 position, their lines are always co-registered
|
||||||
|
// and no seam is possible. Vignette/wash layers are 100% 100% and never tile.
|
||||||
|
|
||||||
|
export const neon: ChatBgVariants = {
|
||||||
|
// Dark: magenta + cyan tubes glowing over near-black, bloom kept low so the
|
||||||
|
// lines are felt, not read. Vignette darkens the centre for legibility.
|
||||||
|
dark: {
|
||||||
|
backgroundColor: 'oklch(0.135 0.02 285)',
|
||||||
|
backgroundImage: [
|
||||||
|
// 1. magenta core hairlines — crisp, bright, thin (vertical + horizontal)
|
||||||
|
'linear-gradient(90deg, oklch(0.68 0.21 350 / 0.34) 0 1px, transparent 1px)',
|
||||||
|
'linear-gradient(0deg, oklch(0.68 0.21 350 / 0.34) 0 1px, transparent 1px)',
|
||||||
|
// 2. magenta bloom — a wide soft halo hugging the same lines
|
||||||
|
'linear-gradient(90deg, transparent 0, oklch(0.66 0.2 350 / 0.11) 3px, transparent 7px)',
|
||||||
|
'linear-gradient(0deg, transparent 0, oklch(0.66 0.2 350 / 0.11) 3px, transparent 7px)',
|
||||||
|
// 3. cyan core hairlines on the offset half-grid — the cross accent
|
||||||
|
'linear-gradient(90deg, oklch(0.82 0.13 200 / 0.20) 0 1px, transparent 1px)',
|
||||||
|
'linear-gradient(0deg, oklch(0.82 0.13 200 / 0.20) 0 1px, transparent 1px)',
|
||||||
|
// 4. cyan bloom — soft cool halo on the same half-grid lines
|
||||||
|
'linear-gradient(90deg, transparent 0, oklch(0.80 0.12 200 / 0.07) 2px, transparent 5px)',
|
||||||
|
'linear-gradient(0deg, transparent 0, oklch(0.80 0.12 200 / 0.07) 2px, transparent 5px)',
|
||||||
|
// 5. vignette — recede the grid, keep the reading centre calm & dark
|
||||||
|
'radial-gradient(ellipse 125% 95% at 50% 44%, transparent 34%, oklch(0.10 0.02 285 / 0.72) 100%)',
|
||||||
|
// 6. horizon wash — a faint magenta->cyan synthwave glow low on the canvas
|
||||||
|
'radial-gradient(ellipse 150% 90% at 50% 108%, oklch(0.4 0.14 340 / 0.30) 0%, transparent 60%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: [
|
||||||
|
'88px 88px', // magenta core V
|
||||||
|
'88px 88px', // magenta core H
|
||||||
|
'88px 88px', // magenta bloom V
|
||||||
|
'88px 88px', // magenta bloom H
|
||||||
|
'44px 44px', // cyan core V (exact 1/2 divisor — re-registers)
|
||||||
|
'44px 44px', // cyan core H
|
||||||
|
'44px 44px', // cyan bloom V
|
||||||
|
'44px 44px', // cyan bloom H
|
||||||
|
'100% 100%', // vignette
|
||||||
|
'100% 100%', // horizon wash
|
||||||
|
].join(','),
|
||||||
|
backgroundPosition: [
|
||||||
|
'0 0', // magenta core V
|
||||||
|
'0 0', // magenta core H
|
||||||
|
'-3px 0', // magenta bloom V — centre the 7px halo on the 1px core
|
||||||
|
'0 -3px', // magenta bloom H
|
||||||
|
'22px 22px', // cyan core V — sit the fine grid between magenta lines
|
||||||
|
'22px 22px', // cyan core H
|
||||||
|
'20px 22px', // cyan bloom V — centre the 5px halo on the cyan core
|
||||||
|
'22px 20px', // cyan bloom H
|
||||||
|
'0 0', // vignette
|
||||||
|
'0 0', // horizon wash
|
||||||
|
].join(','),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Light: "neon" reinterpreted as a soft luminous violet/teal grid on a pale
|
||||||
|
// cool-white base — no glow-on-black, just gentle coloured light. Bloom is even
|
||||||
|
// lighter here; a subtle centre-brightening vignette lifts the reading column.
|
||||||
|
light: {
|
||||||
|
backgroundColor: 'oklch(0.972 0.006 275)',
|
||||||
|
backgroundImage: [
|
||||||
|
// 1. violet core hairlines — soft but defined
|
||||||
|
'linear-gradient(90deg, oklch(0.55 0.17 330 / 0.16) 0 1px, transparent 1px)',
|
||||||
|
'linear-gradient(0deg, oklch(0.55 0.17 330 / 0.16) 0 1px, transparent 1px)',
|
||||||
|
// 2. violet bloom — the merest wide halo for luminosity
|
||||||
|
'linear-gradient(90deg, transparent 0, oklch(0.6 0.16 330 / 0.06) 3px, transparent 7px)',
|
||||||
|
'linear-gradient(0deg, transparent 0, oklch(0.6 0.16 330 / 0.06) 3px, transparent 7px)',
|
||||||
|
// 3. teal core hairlines on the offset half-grid — cool accent
|
||||||
|
'linear-gradient(90deg, oklch(0.58 0.11 200 / 0.11) 0 1px, transparent 1px)',
|
||||||
|
'linear-gradient(0deg, oklch(0.58 0.11 200 / 0.11) 0 1px, transparent 1px)',
|
||||||
|
// 4. teal bloom — faint cool halo on the same half-grid lines
|
||||||
|
'linear-gradient(90deg, transparent 0, oklch(0.62 0.1 200 / 0.045) 2px, transparent 5px)',
|
||||||
|
'linear-gradient(0deg, transparent 0, oklch(0.62 0.1 200 / 0.045) 2px, transparent 5px)',
|
||||||
|
// 5. vignette — brighten the calm reading centre for max legibility
|
||||||
|
'radial-gradient(ellipse 125% 95% at 50% 44%, oklch(1 0 0 / 0.50) 30%, transparent 100%)',
|
||||||
|
// 6. horizon wash — a whisper of violet->teal light low on the canvas
|
||||||
|
'radial-gradient(ellipse 150% 90% at 50% 108%, oklch(0.8 0.09 320 / 0.28) 0%, transparent 60%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: [
|
||||||
|
'88px 88px', // violet core V
|
||||||
|
'88px 88px', // violet core H
|
||||||
|
'88px 88px', // violet bloom V
|
||||||
|
'88px 88px', // violet bloom H
|
||||||
|
'44px 44px', // teal core V
|
||||||
|
'44px 44px', // teal core H
|
||||||
|
'44px 44px', // teal bloom V
|
||||||
|
'44px 44px', // teal bloom H
|
||||||
|
'100% 100%', // vignette
|
||||||
|
'100% 100%', // horizon wash
|
||||||
|
].join(','),
|
||||||
|
backgroundPosition: [
|
||||||
|
'0 0', // violet core V
|
||||||
|
'0 0', // violet core H
|
||||||
|
'-3px 0', // violet bloom V
|
||||||
|
'0 -3px', // violet bloom H
|
||||||
|
'22px 22px', // teal core V
|
||||||
|
'22px 22px', // teal core H
|
||||||
|
'20px 22px', // teal bloom V
|
||||||
|
'22px 20px', // teal bloom H
|
||||||
|
'0 0', // vignette
|
||||||
|
'0 0', // horizon wash
|
||||||
|
].join(','),
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { CSSProperties } from 'react';
|
||||||
|
import { ChatBgVariants } from './types';
|
||||||
|
|
||||||
|
// plaid — an authentic woven tartan, muted to a heather-wool hush.
|
||||||
|
//
|
||||||
|
// Real tartan is not a grid of lines: it is a *sett* — a repeating sequence of
|
||||||
|
// coloured bands of different widths — thrown in BOTH warp (vertical) and weft
|
||||||
|
// (horizontal) directions with the SAME sequence. Where a warp band crosses a
|
||||||
|
// weft band of the same colour the yarn density doubles and the colour visibly
|
||||||
|
// deepens; that reinforced overlap at every crossing is exactly what makes cloth
|
||||||
|
// read as woven rather than printed. We reproduce that physically with
|
||||||
|
// semi-transparent bands: a vertical band at alpha a and a horizontal band at
|
||||||
|
// alpha a stack to ~2a where they cross (over transparent to 1x elsewhere), so
|
||||||
|
// the crossings darken on their own with no extra layer.
|
||||||
|
//
|
||||||
|
// THE SETT (band widths across one tile)
|
||||||
|
// We use a few closely-related widths for a wool-flannel rhythm rather than a
|
||||||
|
// clean check: a wide ground band, a medium companion, and a thin accent
|
||||||
|
// over-stripe of a warmer hue (the classic single guard line). The identical
|
||||||
|
// sequence in warp and weft yields the tartan lattice. A faint diagonal twill
|
||||||
|
// hatch sits on top at very low alpha to suggest the 2/2 twill thread angle of
|
||||||
|
// woven wool. A soft central wash lifts the reading zone and a gentle vignette
|
||||||
|
// settles the corners.
|
||||||
|
//
|
||||||
|
// SEAMLESS TILING
|
||||||
|
// Every band layer is a `repeating-linear-gradient` whose stop sequence is
|
||||||
|
// expressed in px and whose period divides the tile exactly (dark tile 96px:
|
||||||
|
// wide=48, medium=24, accent=96; light tile 88px similarly). Warp layers repeat
|
||||||
|
// at 0deg-across (90deg gradient) and weft at 0deg, sharing one square
|
||||||
|
// `backgroundSize`, so the sett closes on itself with no seam in either axis.
|
||||||
|
// The twill hatch is a repeating-linear-gradient on a small square tile that
|
||||||
|
// divides the main tile. Wash and vignette are single non-repeating gradients
|
||||||
|
// at 100% 100%, so they never seam.
|
||||||
|
|
||||||
|
const dark: CSSProperties = {
|
||||||
|
// Deep muted forest-charcoal ground.
|
||||||
|
backgroundColor: 'oklch(0.19 0.018 155)',
|
||||||
|
backgroundImage: [
|
||||||
|
// Twill hatch — whisper-faint diagonal thread angle of the weave itself.
|
||||||
|
'repeating-linear-gradient(45deg,' +
|
||||||
|
' oklch(0.55 0.03 155 / 0.03) 0px, oklch(0.55 0.03 155 / 0.03) 1px,' +
|
||||||
|
' transparent 1px, transparent 4px)',
|
||||||
|
|
||||||
|
// WEFT (horizontal bands) --------------------------------------------
|
||||||
|
// Wide muted-forest ground band.
|
||||||
|
'repeating-linear-gradient(0deg,' +
|
||||||
|
' oklch(0.45 0.05 150 / 0.14) 0px, oklch(0.45 0.05 150 / 0.14) 22px,' +
|
||||||
|
' transparent 22px, transparent 48px)',
|
||||||
|
// Medium companion band (cooler, offset into the ground gap).
|
||||||
|
'repeating-linear-gradient(0deg,' +
|
||||||
|
' transparent 0px, transparent 60px,' +
|
||||||
|
' oklch(0.42 0.035 175 / 0.11) 60px, oklch(0.42 0.035 175 / 0.11) 72px,' +
|
||||||
|
' transparent 72px, transparent 96px)',
|
||||||
|
// Thin warm amber guard line — the single accent over-stripe.
|
||||||
|
'repeating-linear-gradient(0deg,' +
|
||||||
|
' transparent 0px, transparent 36px,' +
|
||||||
|
' oklch(0.60 0.08 40 / 0.13) 36px, oklch(0.60 0.08 40 / 0.13) 38px,' +
|
||||||
|
' transparent 38px, transparent 96px)',
|
||||||
|
|
||||||
|
// WARP (vertical bands, identical sett) -------------------------------
|
||||||
|
'repeating-linear-gradient(90deg,' +
|
||||||
|
' oklch(0.45 0.05 150 / 0.14) 0px, oklch(0.45 0.05 150 / 0.14) 22px,' +
|
||||||
|
' transparent 22px, transparent 48px)',
|
||||||
|
'repeating-linear-gradient(90deg,' +
|
||||||
|
' transparent 0px, transparent 60px,' +
|
||||||
|
' oklch(0.42 0.035 175 / 0.11) 60px, oklch(0.42 0.035 175 / 0.11) 72px,' +
|
||||||
|
' transparent 72px, transparent 96px)',
|
||||||
|
'repeating-linear-gradient(90deg,' +
|
||||||
|
' transparent 0px, transparent 36px,' +
|
||||||
|
' oklch(0.60 0.08 40 / 0.13) 36px, oklch(0.60 0.08 40 / 0.13) 38px,' +
|
||||||
|
' transparent 38px, transparent 96px)',
|
||||||
|
|
||||||
|
// Tonal wash — soft warm-green lift through the reading centre.
|
||||||
|
'radial-gradient(ellipse 92% 78% at 50% 42%, oklch(0.27 0.03 150 / 0.38) 0%, transparent 62%)',
|
||||||
|
// Vignette — feather the corners into deeper forest-charcoal.
|
||||||
|
'radial-gradient(ellipse 122% 132% at 50% 45%, transparent 58%, oklch(0.14 0.016 155 / 0.55) 100%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize:
|
||||||
|
'8px 8px,' + // twill (multiple of 4px hatch period → seamless)
|
||||||
|
'96px 96px, 96px 96px, 96px 96px,' + // weft: wide, medium, accent
|
||||||
|
'96px 96px, 96px 96px, 96px 96px,' + // warp: wide, medium, accent
|
||||||
|
'100% 100%, 100% 100%', // wash, vignette
|
||||||
|
};
|
||||||
|
|
||||||
|
const light: CSSProperties = {
|
||||||
|
// Warm off-white paper ground.
|
||||||
|
backgroundColor: 'oklch(0.965 0.007 85)',
|
||||||
|
backgroundImage: [
|
||||||
|
// Twill hatch — faint diagonal weave angle on paper.
|
||||||
|
'repeating-linear-gradient(45deg,' +
|
||||||
|
' oklch(0.45 0.03 250 / 0.025) 0px, oklch(0.45 0.03 250 / 0.025) 1px,' +
|
||||||
|
' transparent 1px, transparent 4px)',
|
||||||
|
|
||||||
|
// WEFT (horizontal bands) --------------------------------------------
|
||||||
|
// Wide dusty-blue ground band.
|
||||||
|
'repeating-linear-gradient(0deg,' +
|
||||||
|
' oklch(0.60 0.045 245 / 0.12) 0px, oklch(0.60 0.045 245 / 0.12) 20px,' +
|
||||||
|
' transparent 20px, transparent 44px)',
|
||||||
|
// Medium greige companion band.
|
||||||
|
'repeating-linear-gradient(0deg,' +
|
||||||
|
' transparent 0px, transparent 55px,' +
|
||||||
|
' oklch(0.62 0.018 90 / 0.11) 55px, oklch(0.62 0.018 90 / 0.11) 66px,' +
|
||||||
|
' transparent 66px, transparent 88px)',
|
||||||
|
// Thin warm sand guard line — the single accent over-stripe.
|
||||||
|
'repeating-linear-gradient(0deg,' +
|
||||||
|
' transparent 0px, transparent 33px,' +
|
||||||
|
' oklch(0.68 0.06 55 / 0.12) 33px, oklch(0.68 0.06 55 / 0.12) 35px,' +
|
||||||
|
' transparent 35px, transparent 88px)',
|
||||||
|
|
||||||
|
// WARP (vertical bands, identical sett) -------------------------------
|
||||||
|
'repeating-linear-gradient(90deg,' +
|
||||||
|
' oklch(0.60 0.045 245 / 0.12) 0px, oklch(0.60 0.045 245 / 0.12) 20px,' +
|
||||||
|
' transparent 20px, transparent 44px)',
|
||||||
|
'repeating-linear-gradient(90deg,' +
|
||||||
|
' transparent 0px, transparent 55px,' +
|
||||||
|
' oklch(0.62 0.018 90 / 0.11) 55px, oklch(0.62 0.018 90 / 0.11) 66px,' +
|
||||||
|
' transparent 66px, transparent 88px)',
|
||||||
|
'repeating-linear-gradient(90deg,' +
|
||||||
|
' transparent 0px, transparent 33px,' +
|
||||||
|
' oklch(0.68 0.06 55 / 0.12) 33px, oklch(0.68 0.06 55 / 0.12) 35px,' +
|
||||||
|
' transparent 35px, transparent 88px)',
|
||||||
|
|
||||||
|
// Tonal wash — warm paper highlight through the reading centre.
|
||||||
|
'radial-gradient(ellipse 92% 78% at 50% 42%, oklch(0.99 0.008 85 / 0.55) 0%, transparent 62%)',
|
||||||
|
// Vignette — settle the corners into a slightly deeper dusty tone.
|
||||||
|
'radial-gradient(ellipse 122% 132% at 50% 45%, transparent 58%, oklch(0.90 0.014 245 / 0.40) 100%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize:
|
||||||
|
'8px 8px,' + // twill (multiple of 4px hatch period → seamless)
|
||||||
|
'88px 88px, 88px 88px, 88px 88px,' + // weft: wide, medium, accent
|
||||||
|
'88px 88px, 88px 88px, 88px 88px,' + // warp: wide, medium, accent
|
||||||
|
'100% 100%, 100% 100%', // wash, vignette
|
||||||
|
};
|
||||||
|
|
||||||
|
export const plaid: ChatBgVariants = { dark, light };
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { CSSProperties } from 'react';
|
||||||
|
import { ChatBgVariants } from './types';
|
||||||
|
|
||||||
|
// polka — a grown-up polka dot: embossed leather / fine letterpress stationery,
|
||||||
|
// not childish spots. Each dot is not a flat circle but a soft radial "bump":
|
||||||
|
// an off-centre highlight fading into a faint recessed shadow, so it reads as a
|
||||||
|
// gently raised (or debossed) node catching a single top-left light. Two subtly
|
||||||
|
// different dot sizes are staggered on a half-tile offset for a refined,
|
||||||
|
// hand-set rhythm, and a large single vignette gradient adds quiet depth toward
|
||||||
|
// the edges.
|
||||||
|
//
|
||||||
|
// SEAMLESS TILING: both dot layers repeat on the SAME 44px cell (backgroundSize
|
||||||
|
// 44px 44px). The larger "primary" dots sit at 0 0; the smaller "secondary"
|
||||||
|
// dots are shifted by exactly half a tile (22px 22px) so they fall in the gaps
|
||||||
|
// of the primary lattice — a true staggered brick layout that wraps with no
|
||||||
|
// seam. Each radial gradient's highlight/shadow rings are fully enclosed well
|
||||||
|
// inside its cell, so nothing is clipped at a tile boundary. The vignette is a
|
||||||
|
// single non-repeating gradient covering the whole element ('cover').
|
||||||
|
//
|
||||||
|
// SUBTLETY: dot opacities live in the 0.03–0.10 range and every dot fades to
|
||||||
|
// transparent over a soft edge (no hard rim), so the surface is felt as tactile
|
||||||
|
// grain rather than read as dots. Crisp message text sits comfortably above it
|
||||||
|
// in both themes (WCAG-AA safe).
|
||||||
|
|
||||||
|
const dark: CSSProperties = {
|
||||||
|
// deep espresso base — warm, near-black brown
|
||||||
|
backgroundColor: 'oklch(0.19 0.018 65)',
|
||||||
|
backgroundImage: [
|
||||||
|
// vignette — corners settle darker so the field feels like supple leather
|
||||||
|
'radial-gradient(120% 120% at 50% 40%, oklch(0.22 0.02 65 / 0.5) 0%, oklch(0.19 0.018 65 / 0) 55%, oklch(0.15 0.015 60 / 0.55) 100%)',
|
||||||
|
// PRIMARY dot — larger raised pearl. Top-left warm highlight, then the body,
|
||||||
|
// then a whisper of shadow at the lower-right rim for embossed dimension.
|
||||||
|
'radial-gradient(circle at 42% 40%, oklch(0.82 0.02 80 / 0.10) 0%, oklch(0.80 0.02 80 / 0.075) 22%, oklch(0.55 0.02 70 / 0.045) 44%, oklch(0.12 0.01 60 / 0.05) 62%, transparent 72%)',
|
||||||
|
// SECONDARY dot — smaller, staggered into the gaps, fainter for depth layering
|
||||||
|
'radial-gradient(circle at 42% 40%, oklch(0.82 0.02 80 / 0.075) 0%, oklch(0.78 0.02 80 / 0.05) 26%, oklch(0.50 0.02 70 / 0.03) 52%, oklch(0.12 0.01 60 / 0.04) 70%, transparent 82%)',
|
||||||
|
].join(','),
|
||||||
|
// primary dots ~9px, secondary ~6px, both on the same 44px lattice
|
||||||
|
backgroundSize: 'cover, 44px 44px, 44px 44px',
|
||||||
|
// secondary offset by half a tile => staggered brick lattice
|
||||||
|
backgroundPosition: 'center, 0 0, 22px 22px',
|
||||||
|
};
|
||||||
|
|
||||||
|
const light: CSSProperties = {
|
||||||
|
// cream stationery base — warm off-white paper stock
|
||||||
|
backgroundColor: 'oklch(0.975 0.008 85)',
|
||||||
|
backgroundImage: [
|
||||||
|
// vignette — a gentle warm settling toward the edges, like heavy cotton paper
|
||||||
|
'radial-gradient(120% 120% at 50% 40%, oklch(0.99 0.006 85 / 0.5) 0%, oklch(0.975 0.008 85 / 0) 55%, oklch(0.945 0.012 80 / 0.55) 100%)',
|
||||||
|
// PRIMARY dot — soft taupe deboss. Faint paper highlight at top-left, taupe
|
||||||
|
// body, then a soft shadow lower-right so each dot reads pressed into the sheet.
|
||||||
|
'radial-gradient(circle at 42% 40%, oklch(0.99 0.004 85 / 0.35) 0%, oklch(0.72 0.02 70 / 0.075) 30%, oklch(0.60 0.025 65 / 0.09) 50%, oklch(0.55 0.025 60 / 0.05) 66%, transparent 76%)',
|
||||||
|
// SECONDARY dot — smaller, staggered, lighter for a two-tier hand-set rhythm
|
||||||
|
'radial-gradient(circle at 42% 40%, oklch(0.99 0.004 85 / 0.28) 0%, oklch(0.74 0.02 70 / 0.05) 34%, oklch(0.62 0.025 65 / 0.06) 56%, oklch(0.56 0.025 60 / 0.035) 72%, transparent 84%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: 'cover, 44px 44px, 44px 44px',
|
||||||
|
backgroundPosition: 'center, 0 0, 22px 22px',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const polka: ChatBgVariants = { dark, light };
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { ChatBgVariants } from './types';
|
||||||
|
|
||||||
|
// stars — a deep-space starfield with subtle depth.
|
||||||
|
//
|
||||||
|
// Concept: three parallax layers of stars at different tile sizes and offsets
|
||||||
|
// (so the repeat never lines up and reads as a genuine random field), lifted
|
||||||
|
// onto a faint deep-blue->violet nebula wash for depth, and finished with a
|
||||||
|
// gentle center vignette that keeps the reading column the calmest area of the
|
||||||
|
// canvas. Every layer is a stacked radial-gradient — pure CSS, no assets.
|
||||||
|
//
|
||||||
|
// Layer stacking order (topmost first, as CSS paints image #1 on top):
|
||||||
|
// 1. bright near stars (crisp, sparse, largest tile)
|
||||||
|
// 2. mid stars (dimmer, medium tile)
|
||||||
|
// 3. faint blue far stars (haze, smallest tile — most repeats, least visible)
|
||||||
|
// 4. calming center vignette
|
||||||
|
// 5. nebula wash (deep blue -> violet)
|
||||||
|
// The three star tiles use coprime-ish sizes (137/191/233 dark) so their least
|
||||||
|
// common repeat is enormous and no seam is perceivable.
|
||||||
|
|
||||||
|
export const stars: ChatBgVariants = {
|
||||||
|
// Dark: bright/dim white + faint blue stars on a near-black cosmos, with a
|
||||||
|
// deep-blue->violet nebula and a soft vignette that darkens the calm center.
|
||||||
|
dark: {
|
||||||
|
backgroundColor: 'oklch(0.16 0.03 275)',
|
||||||
|
backgroundImage: [
|
||||||
|
// 1. bright near stars — crisp cool-white, sparse
|
||||||
|
'radial-gradient(circle at center, oklch(0.98 0.01 260 / 0.85) 0.6px, transparent 1.4px)',
|
||||||
|
// 2. mid stars — softer, more of them
|
||||||
|
'radial-gradient(circle at center, oklch(0.92 0.02 265 / 0.55) 0.6px, transparent 1.3px)',
|
||||||
|
// 3. faint blue far dust — the parallax haze
|
||||||
|
'radial-gradient(circle at center, oklch(0.80 0.06 255 / 0.30) 0.5px, transparent 1.1px)',
|
||||||
|
// 4. center vignette — keeps the reading column calmest
|
||||||
|
'radial-gradient(ellipse 120% 90% at 50% 42%, transparent 42%, oklch(0.10 0.03 270 / 0.55) 100%)',
|
||||||
|
// 5. nebula wash — deep blue -> violet drift
|
||||||
|
'radial-gradient(ellipse 140% 120% at 78% 12%, oklch(0.25 0.08 280 / 0.55) 0%, transparent 55%)',
|
||||||
|
'radial-gradient(ellipse 130% 110% at 18% 92%, oklch(0.20 0.06 250 / 0.50) 0%, transparent 58%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: [
|
||||||
|
'137px 137px', // near stars
|
||||||
|
'191px 191px', // mid stars
|
||||||
|
'233px 233px', // far dust
|
||||||
|
'100% 100%', // vignette
|
||||||
|
'100% 100%', // nebula A
|
||||||
|
'100% 100%', // nebula B
|
||||||
|
].join(','),
|
||||||
|
backgroundPosition: [
|
||||||
|
'0 0', // near
|
||||||
|
'61px 43px', // mid (offset breaks alignment)
|
||||||
|
'113px 97px', // far (offset again)
|
||||||
|
'0 0', // vignette
|
||||||
|
'0 0', // nebula A
|
||||||
|
'0 0', // nebula B
|
||||||
|
].join(','),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Light: an airy pre-dawn sky. No literal white stars on white — instead very
|
||||||
|
// soft pale sparkles paired with the faintest cool-grey speckles, floated on a
|
||||||
|
// gentle cool gradient. Reads as elegant atmosphere, never as noise over text.
|
||||||
|
light: {
|
||||||
|
backgroundColor: 'oklch(0.965 0.008 255)',
|
||||||
|
backgroundImage: [
|
||||||
|
// 1. pale warm pre-dawn sparkles — a hair brighter than the sky
|
||||||
|
'radial-gradient(circle at center, oklch(0.995 0.015 90 / 0.55) 0.6px, transparent 1.4px)',
|
||||||
|
// 2. tiny cool speckles — the merest hint of darkness for texture/contrast
|
||||||
|
'radial-gradient(circle at center, oklch(0.62 0.05 260 / 0.16) 0.5px, transparent 1.2px)',
|
||||||
|
// 3. faint far dust — very soft, most-repeated layer
|
||||||
|
'radial-gradient(circle at center, oklch(0.70 0.04 255 / 0.12) 0.5px, transparent 1.1px)',
|
||||||
|
// 4. center vignette — brightens the calm reading center slightly
|
||||||
|
'radial-gradient(ellipse 120% 90% at 50% 44%, oklch(1 0 0 / 0.45) 30%, transparent 100%)',
|
||||||
|
// 5. pre-dawn wash — cool blue high, warm blush low
|
||||||
|
'radial-gradient(ellipse 150% 120% at 80% 8%, oklch(0.90 0.05 255 / 0.60) 0%, transparent 60%)',
|
||||||
|
'radial-gradient(ellipse 140% 120% at 15% 95%, oklch(0.93 0.04 40 / 0.45) 0%, transparent 62%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: [
|
||||||
|
'149px 149px', // sparkles
|
||||||
|
'199px 199px', // speckles
|
||||||
|
'251px 251px', // far dust
|
||||||
|
'100% 100%', // vignette
|
||||||
|
'100% 100%', // wash A
|
||||||
|
'100% 100%', // wash B
|
||||||
|
].join(','),
|
||||||
|
backgroundPosition: [
|
||||||
|
'0 0', // sparkles
|
||||||
|
'71px 53px', // speckles
|
||||||
|
'127px 109px', // far dust
|
||||||
|
'0 0', // vignette
|
||||||
|
'0 0', // wash A
|
||||||
|
'0 0', // wash B
|
||||||
|
].join(','),
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { ChatBgVariants } from './types';
|
||||||
|
|
||||||
|
// tactical — a military tactical display / recon coordinate grid (MGRS-style).
|
||||||
|
//
|
||||||
|
// The motif is a fine grid nested inside bold sector squares, with a reticle
|
||||||
|
// crosshair (arms + ring) at every sector intersection, small stencil corner
|
||||||
|
// brackets inside each sector, and coordinate tick-marks along the sector edges
|
||||||
|
// — a convincing mil-spec map overlay rather than a plain dot grid.
|
||||||
|
//
|
||||||
|
// Layers (painted top-to-bottom):
|
||||||
|
// 1. SVG reticle/stencil tile (128px). Corner arms + quarter-ring arcs radiate
|
||||||
|
// from each of the four tile corners, so four neighbouring tiles combine
|
||||||
|
// into ONE full crosshair "+" with a full ring at every sector intersection.
|
||||||
|
// The tile also carries L-shaped stencil brackets, edge coordinate ticks and
|
||||||
|
// a micro centre reticle. Because every mark is anchored to the 128px tile
|
||||||
|
// lattice, it stays phase-locked to the grids below — no seams, no drift.
|
||||||
|
// 2. Sector lines (heavier) — 128px.
|
||||||
|
// 3. Fine recon grid (fainter) — 16px (128 = 8 × 16, so it nests
|
||||||
|
// exactly inside every sector with no beat/moiré).
|
||||||
|
// 4. A soft scan vignette that keeps the CENTRE calm and clear for text while
|
||||||
|
// letting the grid fall away slightly toward the edges — dimension without
|
||||||
|
// contrast.
|
||||||
|
//
|
||||||
|
// All strokes sit at low alpha (~0.03–0.30 on 1px marks) so the display is felt,
|
||||||
|
// not read: crisp message text stays comfortably WCAG-AA legible in both themes.
|
||||||
|
// A single shared top-left (0 0) origin keeps the reticle tile, the 128px sector
|
||||||
|
// grid and the 16px fine grid all in phase.
|
||||||
|
|
||||||
|
const DARK_RETICLE =
|
||||||
|
'url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22128%22%20height%3D%22128%22%3E%3Cg%20stroke%3D%22oklch%280.72%200.13%2085%20%2F%200.30%29%22%20stroke-width%3D%221%22%20fill%3D%22none%22%3E%3Cpath%20d%3D%22M0%200%20H14%20M0%200%20V14%22%2F%3E%3Cpath%20d%3D%22M128%200%20H114%20M128%200%20V14%22%2F%3E%3Cpath%20d%3D%22M0%20128%20H14%20M0%20128%20V114%22%2F%3E%3Cpath%20d%3D%22M128%20128%20H114%20M128%20128%20V114%22%2F%3E%3Ccircle%20cx%3D%220%22%20cy%3D%220%22%20r%3D%226%22%2F%3E%3Ccircle%20cx%3D%22128%22%20cy%3D%220%22%20r%3D%226%22%2F%3E%3Ccircle%20cx%3D%220%22%20cy%3D%22128%22%20r%3D%226%22%2F%3E%3Ccircle%20cx%3D%22128%22%20cy%3D%22128%22%20r%3D%226%22%2F%3E%3C%2Fg%3E%3Cg%20stroke%3D%22oklch%280.72%200.10%2095%20%2F%200.22%29%22%20stroke-width%3D%221%22%20fill%3D%22none%22%3E%3Cpath%20d%3D%22M10%2020%20V10%20H20%22%2F%3E%3Cpath%20d%3D%22M118%2020%20V10%20H108%22%2F%3E%3Cpath%20d%3D%22M10%20108%20V118%20H20%22%2F%3E%3Cpath%20d%3D%22M118%20108%20V118%20H108%22%2F%3E%3C%2Fg%3E%3Cg%20stroke%3D%22oklch%280.72%200.10%2095%20%2F%200.22%29%22%20stroke-width%3D%221%22%20fill%3D%22none%22%3E%3Cpath%20d%3D%22M64%200%20V6%20M64%20128%20V122%20M0%2064%20H6%20M128%2064%20H122%22%2F%3E%3C%2Fg%3E%3Cg%20stroke%3D%22oklch%280.72%200.13%2085%20%2F%200.30%29%22%20stroke-width%3D%221%22%20fill%3D%22none%22%3E%3Cpath%20d%3D%22M64%2058%20V70%20M58%2064%20H70%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E")';
|
||||||
|
|
||||||
|
const LIGHT_RETICLE =
|
||||||
|
'url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22128%22%20height%3D%22128%22%3E%3Cg%20stroke%3D%22oklch%280.45%200.07%20120%20%2F%200.40%29%22%20stroke-width%3D%221%22%20fill%3D%22none%22%3E%3Cpath%20d%3D%22M0%200%20H14%20M0%200%20V14%22%2F%3E%3Cpath%20d%3D%22M128%200%20H114%20M128%200%20V14%22%2F%3E%3Cpath%20d%3D%22M0%20128%20H14%20M0%20128%20V114%22%2F%3E%3Cpath%20d%3D%22M128%20128%20H114%20M128%20128%20V114%22%2F%3E%3Ccircle%20cx%3D%220%22%20cy%3D%220%22%20r%3D%226%22%2F%3E%3Ccircle%20cx%3D%22128%22%20cy%3D%220%22%20r%3D%226%22%2F%3E%3Ccircle%20cx%3D%220%22%20cy%3D%22128%22%20r%3D%226%22%2F%3E%3Ccircle%20cx%3D%22128%22%20cy%3D%22128%22%20r%3D%226%22%2F%3E%3C%2Fg%3E%3Cg%20stroke%3D%22oklch%280.42%200.05%20130%20%2F%200.28%29%22%20stroke-width%3D%221%22%20fill%3D%22none%22%3E%3Cpath%20d%3D%22M10%2020%20V10%20H20%22%2F%3E%3Cpath%20d%3D%22M118%2020%20V10%20H108%22%2F%3E%3Cpath%20d%3D%22M10%20108%20V118%20H20%22%2F%3E%3Cpath%20d%3D%22M118%20108%20V118%20H108%22%2F%3E%3C%2Fg%3E%3Cg%20stroke%3D%22oklch%280.42%200.05%20130%20%2F%200.28%29%22%20stroke-width%3D%221%22%20fill%3D%22none%22%3E%3Cpath%20d%3D%22M64%200%20V6%20M64%20128%20V122%20M0%2064%20H6%20M128%2064%20H122%22%2F%3E%3C%2Fg%3E%3Cg%20stroke%3D%22oklch%280.45%200.07%20120%20%2F%200.40%29%22%20stroke-width%3D%221%22%20fill%3D%22none%22%3E%3Cpath%20d%3D%22M64%2058%20V70%20M58%2064%20H70%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E")';
|
||||||
|
|
||||||
|
export const tactical: ChatBgVariants = {
|
||||||
|
// Phosphor amber/olive lines glowing on a near-black recon display.
|
||||||
|
dark: {
|
||||||
|
backgroundColor: 'oklch(0.17 0.012 95)',
|
||||||
|
backgroundImage: [
|
||||||
|
// 1. reticles + stencil brackets + coordinate ticks
|
||||||
|
DARK_RETICLE,
|
||||||
|
// 4. scan vignette: keeps the centre calm, eases grid contrast at edges.
|
||||||
|
'radial-gradient(135% 120% at 50% 46%, transparent 52%, oklch(0.11 0.01 100 / 0.55) 100%)',
|
||||||
|
// a faint phosphor bloom drifting off the top so the black isn't dead flat.
|
||||||
|
'radial-gradient(120% 90% at 50% 0%, oklch(0.24 0.03 90 / 0.45) 0%, transparent 60%)',
|
||||||
|
// 2. sector lines (heavier)
|
||||||
|
'linear-gradient(oklch(0.72 0.13 85 / 0.11) 1px, transparent 1px)',
|
||||||
|
'linear-gradient(90deg, oklch(0.72 0.13 85 / 0.11) 1px, transparent 1px)',
|
||||||
|
// 3. fine recon grid (fainter)
|
||||||
|
'linear-gradient(oklch(0.72 0.11 90 / 0.045) 1px, transparent 1px)',
|
||||||
|
'linear-gradient(90deg, oklch(0.72 0.11 90 / 0.045) 1px, transparent 1px)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: [
|
||||||
|
'128px 128px', // reticle tile
|
||||||
|
'100% 100%', // vignette
|
||||||
|
'100% 100%', // phosphor bloom
|
||||||
|
'128px 128px', // sector V
|
||||||
|
'128px 128px', // sector H
|
||||||
|
'16px 16px', // fine V
|
||||||
|
'16px 16px', // fine H
|
||||||
|
].join(','),
|
||||||
|
// Shared top-left origin: reticle tile + 128px sector grid + 16px fine grid
|
||||||
|
// (128 = 8 × 16) stay phase-locked, so corner arms land on sector crossings.
|
||||||
|
},
|
||||||
|
|
||||||
|
// Olive-graphite recon grid printed on cool tactical paper.
|
||||||
|
light: {
|
||||||
|
backgroundColor: 'oklch(0.95 0.008 120)',
|
||||||
|
backgroundImage: [
|
||||||
|
LIGHT_RETICLE,
|
||||||
|
// scan vignette: gentle cool shading into the corners, calm centre.
|
||||||
|
'radial-gradient(135% 120% at 50% 46%, transparent 56%, oklch(0.86 0.02 125 / 0.5) 100%)',
|
||||||
|
// paper sheen toward the top so the surface reads like a printed sheet.
|
||||||
|
'radial-gradient(120% 90% at 50% 0%, oklch(0.98 0.006 120 / 0.7) 0%, transparent 60%)',
|
||||||
|
// sector lines (heavier)
|
||||||
|
'linear-gradient(oklch(0.45 0.07 120 / 0.14) 1px, transparent 1px)',
|
||||||
|
'linear-gradient(90deg, oklch(0.45 0.07 120 / 0.14) 1px, transparent 1px)',
|
||||||
|
// fine recon grid (fainter)
|
||||||
|
'linear-gradient(oklch(0.45 0.06 125 / 0.055) 1px, transparent 1px)',
|
||||||
|
'linear-gradient(90deg, oklch(0.45 0.06 125 / 0.055) 1px, transparent 1px)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: [
|
||||||
|
'128px 128px',
|
||||||
|
'100% 100%',
|
||||||
|
'100% 100%',
|
||||||
|
'128px 128px',
|
||||||
|
'128px 128px',
|
||||||
|
'16px 16px',
|
||||||
|
'16px 16px',
|
||||||
|
].join(','),
|
||||||
|
// Shared top-left origin keeps the reticle tile and both grids phase-locked.
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { CSSProperties } from 'react';
|
||||||
|
import { ChatBgVariants } from './types';
|
||||||
|
|
||||||
|
// topographic — an elegant contour / elevation map.
|
||||||
|
//
|
||||||
|
// The motif is a delicate cartographic contour survey: nested rings suggest two
|
||||||
|
// gentle "peaks" and a shallow "valley", drawn with occasional heavier "index
|
||||||
|
// contour" lines for authenticity, all floating over a soft tonal wash. It is
|
||||||
|
// tuned to be *felt, not read* — line opacities sit well under legibility
|
||||||
|
// thresholds so crisp message text stays comfortably WCAG-AA in both themes.
|
||||||
|
//
|
||||||
|
// SEAMLESS TILING
|
||||||
|
// Each contour system is a `repeating-radial-gradient` whose ring period P is a
|
||||||
|
// clean divisor of its `backgroundSize` tile. A repeating-radial-gradient tiles
|
||||||
|
// seamlessly only when the tile edge falls on a whole number of ring periods, so
|
||||||
|
// every layer below uses tile = N * P. Peak A's fine (32px) and index (128px)
|
||||||
|
// layers share one 256px tile (256 = 8*32 = 2*128) AND one center, so the heavy
|
||||||
|
// index lines land exactly on every 4th fine ring — a true index contour, never
|
||||||
|
// drifting out of register. Peak B tiles 288 = 12*24; the valley tiles 384 =
|
||||||
|
// 8*48. The tonal washes/vignette are single non-repeating gradients sized to
|
||||||
|
// the same tiles, so nothing shows a visible seam.
|
||||||
|
|
||||||
|
const dark: CSSProperties = {
|
||||||
|
backgroundColor: 'oklch(0.205 0.018 235)',
|
||||||
|
backgroundImage: [
|
||||||
|
// Peak A — fine contour lines (soft teal), 32px period.
|
||||||
|
'repeating-radial-gradient(circle at 27% 34%, transparent 0, transparent 26px,' +
|
||||||
|
' oklch(0.62 0.055 190 / 0.09) 27px, oklch(0.62 0.055 190 / 0.09) 28px, transparent 29px, transparent 32px)',
|
||||||
|
// Peak A — index (heavier) contour every 4th ring, 128px period.
|
||||||
|
'repeating-radial-gradient(circle at 27% 34%, transparent 0, transparent 122px,' +
|
||||||
|
' oklch(0.66 0.06 190 / 0.10) 123px, oklch(0.66 0.06 190 / 0.10) 125px, transparent 126px, transparent 128px)',
|
||||||
|
// Peak B — fine contour lines (cooler sage-teal), 24px period.
|
||||||
|
'repeating-radial-gradient(circle at 78% 72%, transparent 0, transparent 19px,' +
|
||||||
|
' oklch(0.60 0.05 200 / 0.07) 20px, oklch(0.60 0.05 200 / 0.07) 21px, transparent 22px, transparent 24px)',
|
||||||
|
// Valley — broad shallow rings (very faint), 48px period.
|
||||||
|
'repeating-radial-gradient(circle at 52% 8%, transparent 0, transparent 42px,' +
|
||||||
|
' oklch(0.58 0.045 195 / 0.05) 43px, oklch(0.58 0.045 195 / 0.05) 44px, transparent 45px, transparent 48px)',
|
||||||
|
// Tonal wash — lifts the "peaks", sinks the corners for depth.
|
||||||
|
'radial-gradient(circle at 27% 34%, oklch(0.26 0.03 200 / 0.55) 0%, transparent 46%)',
|
||||||
|
'radial-gradient(circle at 78% 72%, oklch(0.24 0.028 205 / 0.45) 0%, transparent 44%)',
|
||||||
|
// Vignette — soft edge darkening keeps the field calm behind text.
|
||||||
|
'radial-gradient(ellipse 120% 130% at 50% 42%, transparent 58%, oklch(0.15 0.02 235 / 0.55) 100%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize:
|
||||||
|
'256px 256px, 256px 256px, 288px 288px, 384px 384px, 100% 100%, 100% 100%, 100% 100%',
|
||||||
|
};
|
||||||
|
|
||||||
|
const light: CSSProperties = {
|
||||||
|
backgroundColor: 'oklch(0.965 0.008 85)',
|
||||||
|
backgroundImage: [
|
||||||
|
// Peak A — fine contour lines (warm graphite/sand), 32px period.
|
||||||
|
'repeating-radial-gradient(circle at 27% 34%, transparent 0, transparent 26px,' +
|
||||||
|
' oklch(0.45 0.03 70 / 0.08) 27px, oklch(0.45 0.03 70 / 0.08) 28px, transparent 29px, transparent 32px)',
|
||||||
|
// Peak A — index (heavier) contour every 4th ring, 128px period.
|
||||||
|
'repeating-radial-gradient(circle at 27% 34%, transparent 0, transparent 122px,' +
|
||||||
|
' oklch(0.40 0.035 68 / 0.10) 123px, oklch(0.40 0.035 68 / 0.10) 125px, transparent 126px, transparent 128px)',
|
||||||
|
// Peak B — fine contour lines (soft sage-graphite), 24px period.
|
||||||
|
'repeating-radial-gradient(circle at 78% 72%, transparent 0, transparent 19px,' +
|
||||||
|
' oklch(0.47 0.028 120 / 0.06) 20px, oklch(0.47 0.028 120 / 0.06) 21px, transparent 22px, transparent 24px)',
|
||||||
|
// Valley — broad shallow rings (very faint), 48px period.
|
||||||
|
'repeating-radial-gradient(circle at 52% 8%, transparent 0, transparent 42px,' +
|
||||||
|
' oklch(0.46 0.025 75 / 0.045) 43px, oklch(0.46 0.025 75 / 0.045) 44px, transparent 45px, transparent 48px)',
|
||||||
|
// Tonal wash — warm paper highlights over the "peaks".
|
||||||
|
'radial-gradient(circle at 27% 34%, oklch(0.985 0.012 85 / 0.60) 0%, transparent 46%)',
|
||||||
|
'radial-gradient(circle at 78% 72%, oklch(0.945 0.014 95 / 0.45) 0%, transparent 44%)',
|
||||||
|
// Vignette — feather the edges to a slightly deeper sand for depth.
|
||||||
|
'radial-gradient(ellipse 120% 130% at 50% 42%, transparent 58%, oklch(0.90 0.016 80 / 0.45) 100%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize:
|
||||||
|
'256px 256px, 256px 256px, 288px 288px, 384px 384px, 100% 100%, 100% 100%, 100% 100%',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const topographic: ChatBgVariants = { dark, light };
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { CSSProperties } from 'react';
|
||||||
|
import { ChatBgVariants } from './types';
|
||||||
|
|
||||||
|
// triangles — elegant low-poly / faceted-crystal mesh.
|
||||||
|
//
|
||||||
|
// The motif stays true to its name — a triangular tessellation — but is rebuilt
|
||||||
|
// to read as a *faceted crystalline surface* rather than the old flat isometric
|
||||||
|
// lines. Neighbouring triangular facets carry barely-there tonal shifts (one
|
||||||
|
// face catches a whisper of light, the adjacent one falls into a whisper of
|
||||||
|
// shade) so the plane looks gently faceted and dimensional, like brushed slate
|
||||||
|
// or cut glass seen at a shallow angle. A hairline "mesh glint" traces the facet
|
||||||
|
// edges so the crystalline structure is felt, never read. A soft tonal wash and
|
||||||
|
// a feathered vignette give the whole field quiet architectural depth.
|
||||||
|
//
|
||||||
|
// FACET SHADING
|
||||||
|
// An isometric triangle grid is three families of parallel lines at 0deg, 60deg
|
||||||
|
// and 120deg. Each `linear-gradient` below is a *hard-edged* two-band ramp along
|
||||||
|
// one of those axes: a faint tonal band followed by transparent, repeating
|
||||||
|
// across the tile. Overlapping the three axes partitions the plane into small
|
||||||
|
// triangular cells; because each axis contributes its shade to a different set
|
||||||
|
// of cells, up-pointing and down-pointing facets end up carrying subtly
|
||||||
|
// different summed tones — the alternating light/shadow facet look. A separate
|
||||||
|
// hairline layer per axis draws the thin edge glint at the facet borders.
|
||||||
|
//
|
||||||
|
// SEAMLESS TILING
|
||||||
|
// Equilateral geometry needs the tile height to be the width times sqrt(3). We
|
||||||
|
// use a 48x83px tile (48 * 1.732 = 83.1, rounded to 83) so the 60deg/120deg
|
||||||
|
// ramps close exactly on the tile box, and the horizontal edge family repeats on
|
||||||
|
// half-height (48x41.5 -> the 0deg hairline is sized to the full tile so its
|
||||||
|
// bands land on tile edges). Every facet-shade and edge layer shares this tile
|
||||||
|
// (or an exact multiple), and the 60/120 layers meet at the tile's mid columns,
|
||||||
|
// so triangles interlock across every seam with no drift. Wash and vignette are
|
||||||
|
// single non-repeating gradients at 100% 100%, so they never seam.
|
||||||
|
|
||||||
|
const dark: CSSProperties = {
|
||||||
|
// Deep navy base — the crystal sits on cool night stone.
|
||||||
|
backgroundColor: 'oklch(0.19 0.028 258)',
|
||||||
|
backgroundImage: [
|
||||||
|
// --- Facet shading: three cool-slate tonal ramps, one per triangle axis.
|
||||||
|
// Ascending-diagonal facets — a soft light band on one face family.
|
||||||
|
'linear-gradient(60deg,' +
|
||||||
|
' oklch(0.46 0.03 250 / 0.07) 0%, oklch(0.46 0.03 250 / 0.07) 50%,' +
|
||||||
|
' transparent 50%, transparent 100%)',
|
||||||
|
// Descending-diagonal facets — the shade family, closing the triangles.
|
||||||
|
'linear-gradient(120deg,' +
|
||||||
|
' oklch(0.34 0.03 255 / 0.06) 0%, oklch(0.34 0.03 255 / 0.06) 50%,' +
|
||||||
|
' transparent 50%, transparent 100%)',
|
||||||
|
// Horizontal facets — a third, fainter slate band so cells read three-sided.
|
||||||
|
'linear-gradient(0deg,' +
|
||||||
|
' oklch(0.42 0.028 248 / 0.045) 0%, oklch(0.42 0.028 248 / 0.045) 50%,' +
|
||||||
|
' transparent 50%, transparent 100%)',
|
||||||
|
// --- Mesh glint: hairline edges tracing the crystalline facet borders.
|
||||||
|
'linear-gradient(60deg, transparent 0, transparent calc(50% - 0.5px),' +
|
||||||
|
' oklch(0.62 0.035 250 / 0.10) calc(50% - 0.5px), oklch(0.62 0.035 250 / 0.10) 50%,' +
|
||||||
|
' transparent 50%)',
|
||||||
|
'linear-gradient(120deg, transparent 0, transparent calc(50% - 0.5px),' +
|
||||||
|
' oklch(0.62 0.035 250 / 0.10) calc(50% - 0.5px), oklch(0.62 0.035 250 / 0.10) 50%,' +
|
||||||
|
' transparent 50%)',
|
||||||
|
// --- Tonal wash — a gentle cool lift through the reading centre for depth.
|
||||||
|
'radial-gradient(ellipse 95% 80% at 50% 40%, oklch(0.28 0.03 255 / 0.45) 0%, transparent 62%)',
|
||||||
|
// --- Vignette — feather the corners into deeper navy.
|
||||||
|
'radial-gradient(ellipse 125% 130% at 50% 45%, transparent 58%, oklch(0.13 0.022 258 / 0.55) 100%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: '48px 83px, 48px 83px, 48px 83px, 48px 83px, 48px 83px, 100% 100%, 100% 100%',
|
||||||
|
// Offset the 120deg (shade) and its glint by half a tile so up/down facets
|
||||||
|
// interlock — this is what alternates the light/shadow triangles.
|
||||||
|
backgroundPosition: '0 0, 24px 0, 0 0, 0 0, 24px 0, 0 0, 0 0',
|
||||||
|
};
|
||||||
|
|
||||||
|
const light: CSSProperties = {
|
||||||
|
// Pale ice-white base — cut glass on frosted paper.
|
||||||
|
backgroundColor: 'oklch(0.975 0.004 250)',
|
||||||
|
backgroundImage: [
|
||||||
|
// --- Facet shading: soft cool-grey tonal ramps, one per triangle axis.
|
||||||
|
// Ascending-diagonal facets — a barely-there shade on one face family.
|
||||||
|
'linear-gradient(60deg,' +
|
||||||
|
' oklch(0.66 0.022 252 / 0.09) 0%, oklch(0.66 0.022 252 / 0.09) 50%,' +
|
||||||
|
' transparent 50%, transparent 100%)',
|
||||||
|
// Descending-diagonal facets — a hair darker, closing the triangles.
|
||||||
|
'linear-gradient(120deg,' +
|
||||||
|
' oklch(0.58 0.024 255 / 0.08) 0%, oklch(0.58 0.024 255 / 0.08) 50%,' +
|
||||||
|
' transparent 50%, transparent 100%)',
|
||||||
|
// Horizontal facets — the third, faintest cool-grey band.
|
||||||
|
'linear-gradient(0deg,' +
|
||||||
|
' oklch(0.62 0.02 250 / 0.055) 0%, oklch(0.62 0.02 250 / 0.055) 50%,' +
|
||||||
|
' transparent 50%, transparent 100%)',
|
||||||
|
// --- Mesh glint: crisp hairline facet edges in cool slate.
|
||||||
|
'linear-gradient(60deg, transparent 0, transparent calc(50% - 0.5px),' +
|
||||||
|
' oklch(0.50 0.03 255 / 0.11) calc(50% - 0.5px), oklch(0.50 0.03 255 / 0.11) 50%,' +
|
||||||
|
' transparent 50%)',
|
||||||
|
'linear-gradient(120deg, transparent 0, transparent calc(50% - 0.5px),' +
|
||||||
|
' oklch(0.50 0.03 255 / 0.11) calc(50% - 0.5px), oklch(0.50 0.03 255 / 0.11) 50%,' +
|
||||||
|
' transparent 50%)',
|
||||||
|
// --- Tonal wash — a clean white highlight through the reading centre.
|
||||||
|
'radial-gradient(ellipse 95% 80% at 50% 40%, oklch(0.995 0.003 250 / 0.60) 0%, transparent 62%)',
|
||||||
|
// --- Vignette — settle the corners into a faint cool grey.
|
||||||
|
'radial-gradient(ellipse 125% 130% at 50% 45%, transparent 58%, oklch(0.90 0.012 252 / 0.42) 100%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: '48px 83px, 48px 83px, 48px 83px, 48px 83px, 48px 83px, 100% 100%, 100% 100%',
|
||||||
|
backgroundPosition: '0 0, 24px 0, 0 0, 0 0, 24px 0, 0 0, 0 0',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const triangles: ChatBgVariants = { dark, light };
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { CSSProperties } from 'react';
|
||||||
|
|
||||||
|
// A chat background provides an independently-tuned CSSProperties per app theme:
|
||||||
|
// the `dark` variant is a subtle light-ish pattern on a dark base, the `light`
|
||||||
|
// variant a subtle dark-ish pattern on a light base. Each sits DIRECTLY behind
|
||||||
|
// the chat message list, so both must stay subtle enough to preserve WCAG-AA
|
||||||
|
// text legibility. Animated backgrounds include an `animation`; getChatBg strips
|
||||||
|
// it for prefers-reduced-motion / pause-animations, so the remaining properties
|
||||||
|
// must already read as a finished static background on their own.
|
||||||
|
export type ChatBgVariants = {
|
||||||
|
dark: CSSProperties;
|
||||||
|
light: CSSProperties;
|
||||||
|
};
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { CSSProperties } from 'react';
|
||||||
|
import { ChatBgVariants } from './types';
|
||||||
|
|
||||||
|
// waves — a serene, rhythmic ocean swell / sound-wave contour.
|
||||||
|
//
|
||||||
|
// The motif is three stacked sine contours — layered swell at slightly varied
|
||||||
|
// amplitude, weight and opacity — floating over a soft vertical depth wash so
|
||||||
|
// the field reads like gentle water or sculpted sand. It is tuned to be *felt,
|
||||||
|
// not read*: every stroke sits well under legibility thresholds so crisp
|
||||||
|
// message text stays comfortably WCAG-AA in both themes.
|
||||||
|
//
|
||||||
|
// TRUE SINE CURVES VIA INLINE SVG
|
||||||
|
// Gradients can't draw a real sine, so each wave is a polyline sampling of
|
||||||
|
// y = yc - amp*sin(2*pi*N*x/W), rendered as an inline SVG data-URI (fully
|
||||||
|
// URL-encoded, so it is CSP/Tauri-safe and needs no external asset). oklch()
|
||||||
|
// stroke colors give perceptually even, low-chroma lines.
|
||||||
|
//
|
||||||
|
// SEAMLESS TILING
|
||||||
|
// The SVG tile is 240x120 with EXACTLY N=2 whole periods across its 240px
|
||||||
|
// width, so the first and last sample of every wave share the same y — the
|
||||||
|
// horizontal repeat has no seam. All three contours live within y = 24..106,
|
||||||
|
// clear of the 0/120 tile edges, so the vertical repeat is seam-free too. To
|
||||||
|
// avoid a rigid stacked look, the same tile is layered a second time shifted by
|
||||||
|
// half a tile (120px x, 60px y) at lower opacity, weaving the rows into a
|
||||||
|
// continuous drifting swell. backgroundSize = 240px 120px keeps the SVG at its
|
||||||
|
// authored scale; the depth wash is a single 100% gradient sized to match.
|
||||||
|
|
||||||
|
const waveTileDark =
|
||||||
|
'url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22240%22%20height%3D%22120%22%20viewBox%3D%220%200%20240%20120%22%3E%3Cg%20fill%3D%22none%22%20stroke-linecap%3D%22round%22%3E%3Cpath%20d%3D%22M0%2034%20L5%2031.41%20L10%2029%20L15%2026.93%20L20%2025.34%20L25%2024.34%20L30%2024%20L35%2024.34%20L40%2025.34%20L45%2026.93%20L50%2029%20L55%2031.41%20L60%2034%20L65%2036.59%20L70%2039%20L75%2041.07%20L80%2042.66%20L85%2043.66%20L90%2044%20L95%2043.66%20L100%2042.66%20L105%2041.07%20L110%2039%20L115%2036.59%20L120%2034%20L125%2031.41%20L130%2029%20L135%2026.93%20L140%2025.34%20L145%2024.34%20L150%2024%20L155%2024.34%20L160%2025.34%20L165%2026.93%20L170%2029%20L175%2031.41%20L180%2034%20L185%2036.59%20L190%2039%20L195%2041.07%20L200%2042.66%20L205%2043.66%20L210%2044%20L215%2043.66%20L220%2042.66%20L225%2041.07%20L230%2039%20L235%2036.59%20L240%2034%22%20stroke%3D%22oklch(0.65%200.08%20200%20%2F%200.16)%22%20stroke-width%3D%221.5%22%2F%3E%3Cpath%20d%3D%22M0%2064%20L5%2062.19%20L10%2060.5%20L15%2059.05%20L20%2057.94%20L25%2057.24%20L30%2057%20L35%2057.24%20L40%2057.94%20L45%2059.05%20L50%2060.5%20L55%2062.19%20L60%2064%20L65%2065.81%20L70%2067.5%20L75%2068.95%20L80%2070.06%20L85%2070.76%20L90%2071%20L95%2070.76%20L100%2070.06%20L105%2068.95%20L110%2067.5%20L115%2065.81%20L120%2064%20L125%2062.19%20L130%2060.5%20L135%2059.05%20L140%2057.94%20L145%2057.24%20L150%2057%20L155%2057.24%20L160%2057.94%20L165%2059.05%20L170%2060.5%20L175%2062.19%20L180%2064%20L185%2065.81%20L190%2067.5%20L195%2068.95%20L200%2070.06%20L205%2070.76%20L210%2071%20L215%2070.76%20L220%2070.06%20L225%2068.95%20L230%2067.5%20L235%2065.81%20L240%2064%22%20stroke%3D%22oklch(0.68%200.07%20195%20%2F%200.11)%22%20stroke-width%3D%221.2%22%2F%3E%3Cpath%20d%3D%22M0%2094%20L5%2090.89%20L10%2088%20L15%2085.51%20L20%2083.61%20L25%2082.41%20L30%2082%20L35%2082.41%20L40%2083.61%20L45%2085.51%20L50%2088%20L55%2090.89%20L60%2094%20L65%2097.11%20L70%20100%20L75%20102.49%20L80%20104.39%20L85%20105.59%20L90%20106%20L95%20105.59%20L100%20104.39%20L105%20102.49%20L110%20100%20L115%2097.11%20L120%2094%20L125%2090.89%20L130%2088%20L135%2085.51%20L140%2083.61%20L145%2082.41%20L150%2082%20L155%2082.41%20L160%2083.61%20L165%2085.51%20L170%2088%20L175%2090.89%20L180%2094%20L185%2097.11%20L190%20100%20L195%20102.49%20L200%20104.39%20L205%20105.59%20L210%20106%20L215%20105.59%20L220%20104.39%20L225%20102.49%20L230%20100%20L235%2097.11%20L240%2094%22%20stroke%3D%22oklch(0.62%200.075%20205%20%2F%200.14)%22%20stroke-width%3D%221.6%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E")';
|
||||||
|
|
||||||
|
const waveTileLight =
|
||||||
|
'url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22240%22%20height%3D%22120%22%20viewBox%3D%220%200%20240%20120%22%3E%3Cg%20fill%3D%22none%22%20stroke-linecap%3D%22round%22%3E%3Cpath%20d%3D%22M0%2034%20L5%2031.41%20L10%2029%20L15%2026.93%20L20%2025.34%20L25%2024.34%20L30%2024%20L35%2024.34%20L40%2025.34%20L45%2026.93%20L50%2029%20L55%2031.41%20L60%2034%20L65%2036.59%20L70%2039%20L75%2041.07%20L80%2042.66%20L85%2043.66%20L90%2044%20L95%2043.66%20L100%2042.66%20L105%2041.07%20L110%2039%20L115%2036.59%20L120%2034%20L125%2031.41%20L130%2029%20L135%2026.93%20L140%2025.34%20L145%2024.34%20L150%2024%20L155%2024.34%20L160%2025.34%20L165%2026.93%20L170%2029%20L175%2031.41%20L180%2034%20L185%2036.59%20L190%2039%20L195%2041.07%20L200%2042.66%20L205%2043.66%20L210%2044%20L215%2043.66%20L220%2042.66%20L225%2041.07%20L230%2039%20L235%2036.59%20L240%2034%22%20stroke%3D%22oklch(0.62%200.045%20235%20%2F%200.16)%22%20stroke-width%3D%221.5%22%2F%3E%3Cpath%20d%3D%22M0%2064%20L5%2062.19%20L10%2060.5%20L15%2059.05%20L20%2057.94%20L25%2057.24%20L30%2057%20L35%2057.24%20L40%2057.94%20L45%2059.05%20L50%2060.5%20L55%2062.19%20L60%2064%20L65%2065.81%20L70%2067.5%20L75%2068.95%20L80%2070.06%20L85%2070.76%20L90%2071%20L95%2070.76%20L100%2070.06%20L105%2068.95%20L110%2067.5%20L115%2065.81%20L120%2064%20L125%2062.19%20L130%2060.5%20L135%2059.05%20L140%2057.94%20L145%2057.24%20L150%2057%20L155%2057.24%20L160%2057.94%20L165%2059.05%20L170%2060.5%20L175%2062.19%20L180%2064%20L185%2065.81%20L190%2067.5%20L195%2068.95%20L200%2070.06%20L205%2070.76%20L210%2071%20L215%2070.76%20L220%2070.06%20L225%2068.95%20L230%2067.5%20L235%2065.81%20L240%2064%22%20stroke%3D%22oklch(0.66%200.04%20240%20%2F%200.11)%22%20stroke-width%3D%221.2%22%2F%3E%3Cpath%20d%3D%22M0%2094%20L5%2090.89%20L10%2088%20L15%2085.51%20L20%2083.61%20L25%2082.41%20L30%2082%20L35%2082.41%20L40%2083.61%20L45%2085.51%20L50%2088%20L55%2090.89%20L60%2094%20L65%2097.11%20L70%20100%20L75%20102.49%20L80%20104.39%20L85%20105.59%20L90%20106%20L95%20105.59%20L100%20104.39%20L105%20102.49%20L110%20100%20L115%2097.11%20L120%2094%20L125%2090.89%20L130%2088%20L135%2085.51%20L140%2083.61%20L145%2082.41%20L150%2082%20L155%2082.41%20L160%2083.61%20L165%2085.51%20L170%2088%20L175%2090.89%20L180%2094%20L185%2097.11%20L190%20100%20L195%20102.49%20L200%20104.39%20L205%20105.59%20L210%20106%20L215%20105.59%20L220%20104.39%20L225%20102.49%20L230%20100%20L235%2097.11%20L240%2094%22%20stroke%3D%22oklch(0.60%200.05%20230%20%2F%200.14)%22%20stroke-width%3D%221.6%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E")';
|
||||||
|
|
||||||
|
const dark: CSSProperties = {
|
||||||
|
// Deep ink-blue base — the "water" the swell floats on.
|
||||||
|
backgroundColor: 'oklch(0.19 0.03 245)',
|
||||||
|
backgroundImage: [
|
||||||
|
// Primary swell — teal/aqua sine contours.
|
||||||
|
waveTileDark,
|
||||||
|
// Offset echo — same tile shifted half a period, dimmed, to weave rows.
|
||||||
|
waveTileDark,
|
||||||
|
// Depth wash — subtle lift toward the top, sink toward the bottom.
|
||||||
|
'linear-gradient(180deg, oklch(0.24 0.04 240 / 0.5) 0%, oklch(0.16 0.025 250 / 0.5) 100%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: '240px 120px, 240px 120px, 100% 100%',
|
||||||
|
backgroundPosition: '0 0, 120px 60px, 0 0',
|
||||||
|
// Dim the offset echo layer relative to the primary swell.
|
||||||
|
backgroundBlendMode: 'normal, soft-light, normal',
|
||||||
|
};
|
||||||
|
|
||||||
|
const light: CSSProperties = {
|
||||||
|
// Soft warm white base — like sunlit paper or pale sand.
|
||||||
|
backgroundColor: 'oklch(0.975 0.006 240)',
|
||||||
|
backgroundImage: [
|
||||||
|
// Primary swell — pale blue-grey sine contours.
|
||||||
|
waveTileLight,
|
||||||
|
// Offset echo — same tile shifted half a period, dimmed, to weave rows.
|
||||||
|
waveTileLight,
|
||||||
|
// Depth wash — faint cool tint feathering toward the bottom for calm depth.
|
||||||
|
'linear-gradient(180deg, oklch(0.99 0.004 240 / 0.5) 0%, oklch(0.95 0.01 245 / 0.5) 100%)',
|
||||||
|
].join(','),
|
||||||
|
backgroundSize: '240px 120px, 240px 120px, 100% 100%',
|
||||||
|
backgroundPosition: '0 0, 120px 60px, 0 0',
|
||||||
|
// Dim the offset echo layer relative to the primary swell.
|
||||||
|
backgroundBlendMode: 'normal, multiply, normal',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const waves: ChatBgVariants = { dark, light };
|
||||||
@@ -1,12 +1,24 @@
|
|||||||
import { CSSProperties } from 'react';
|
import { CSSProperties } from 'react';
|
||||||
import { ChatBackground } from '../../state/settings';
|
import { ChatBackground } from '../../state/settings';
|
||||||
import {
|
import { blueprint } from './backgrounds/blueprint';
|
||||||
animRainKeyframe,
|
import { stars } from './backgrounds/stars';
|
||||||
animStarsDriftKeyframe,
|
import { topographic } from './backgrounds/topographic';
|
||||||
animGridPulseKeyframe,
|
import { herringbone } from './backgrounds/herringbone';
|
||||||
animAuroraKeyframe,
|
import { crosshatch } from './backgrounds/crosshatch';
|
||||||
animFirefliesKeyframe,
|
import { chevron } from './backgrounds/chevron';
|
||||||
} from '../../styles/Animations.css';
|
import { polka } from './backgrounds/polka';
|
||||||
|
import { triangles } from './backgrounds/triangles';
|
||||||
|
import { plaid } from './backgrounds/plaid';
|
||||||
|
import { tactical } from './backgrounds/tactical';
|
||||||
|
import { circuit } from './backgrounds/circuit';
|
||||||
|
import { hexgrid } from './backgrounds/hexgrid';
|
||||||
|
import { waves } from './backgrounds/waves';
|
||||||
|
import { neon } from './backgrounds/neon';
|
||||||
|
import { animRain } from './backgrounds/animRain';
|
||||||
|
import { animStars } from './backgrounds/animStars';
|
||||||
|
import { animPulse } from './backgrounds/animPulse';
|
||||||
|
import { animAurora } from './backgrounds/animAurora';
|
||||||
|
import { animFireflies } from './backgrounds/animFireflies';
|
||||||
|
|
||||||
export const BG_OPTIONS: { value: ChatBackground; label: string }[] = [
|
export const BG_OPTIONS: { value: ChatBackground; label: string }[] = [
|
||||||
{ value: 'none', label: 'None' },
|
{ value: 'none', label: 'None' },
|
||||||
@@ -33,20 +45,14 @@ export const BG_OPTIONS: { value: ChatBackground; label: string }[] = [
|
|||||||
{ value: 'anim-fireflies', label: 'Fireflies' },
|
{ value: 'anim-fireflies', label: 'Fireflies' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// `none`, `carbon` and `aurora` stay inline: carbon + aurora are the kept user
|
||||||
|
// favorites, none is the empty layer. Every other background is a premium
|
||||||
|
// per-pattern module under ./backgrounds/ (each exposes a `dark` + `light`
|
||||||
|
// variant). Keeping the whole record here lets getChatBg stay the single entry
|
||||||
|
// point and preserves the Record<ChatBackground, ...> exhaustiveness check.
|
||||||
const DARK: Record<ChatBackground, CSSProperties> = {
|
const DARK: Record<ChatBackground, CSSProperties> = {
|
||||||
none: {},
|
none: {},
|
||||||
|
|
||||||
blueprint: {
|
|
||||||
backgroundColor: '#0a1628',
|
|
||||||
backgroundImage: [
|
|
||||||
'linear-gradient(rgba(100,149,237,0.14) 1px, transparent 1px)',
|
|
||||||
'linear-gradient(90deg, rgba(100,149,237,0.14) 1px, transparent 1px)',
|
|
||||||
'linear-gradient(rgba(100,149,237,0.05) 1px, transparent 1px)',
|
|
||||||
'linear-gradient(90deg, rgba(100,149,237,0.05) 1px, transparent 1px)',
|
|
||||||
].join(','),
|
|
||||||
backgroundSize: '80px 80px, 80px 80px, 16px 16px, 16px 16px',
|
|
||||||
},
|
|
||||||
|
|
||||||
carbon: {
|
carbon: {
|
||||||
backgroundColor: '#0e0e0e',
|
backgroundColor: '#0e0e0e',
|
||||||
backgroundImage: [
|
backgroundImage: [
|
||||||
@@ -55,138 +61,6 @@ const DARK: Record<ChatBackground, CSSProperties> = {
|
|||||||
].join(','),
|
].join(','),
|
||||||
backgroundSize: '8px 8px',
|
backgroundSize: '8px 8px',
|
||||||
},
|
},
|
||||||
|
|
||||||
stars: {
|
|
||||||
backgroundColor: '#050510',
|
|
||||||
backgroundImage: [
|
|
||||||
'radial-gradient(circle, rgba(255,255,255,0.85) 1px, transparent 1px)',
|
|
||||||
'radial-gradient(circle, rgba(255,255,255,0.55) 1px, transparent 1px)',
|
|
||||||
'radial-gradient(circle, rgba(200,200,255,0.3) 1px, transparent 1px)',
|
|
||||||
].join(','),
|
|
||||||
backgroundSize: '130px 130px, 190px 190px, 260px 260px',
|
|
||||||
backgroundPosition: '0 0, 65px 32px, 32px 97px',
|
|
||||||
},
|
|
||||||
|
|
||||||
topographic: {
|
|
||||||
backgroundColor: '#0f0f17',
|
|
||||||
backgroundImage: [
|
|
||||||
'repeating-radial-gradient(circle at 20% 20%, transparent 0, transparent 30px, rgba(152,0,0,0.07) 31px, transparent 32px)',
|
|
||||||
'repeating-radial-gradient(circle at 80% 80%, transparent 0, transparent 25px, rgba(100,100,200,0.06) 26px, transparent 27px)',
|
|
||||||
'repeating-radial-gradient(circle at 50% 10%, transparent 0, transparent 45px, rgba(152,0,0,0.04) 46px, transparent 47px)',
|
|
||||||
].join(','),
|
|
||||||
},
|
|
||||||
|
|
||||||
herringbone: {
|
|
||||||
backgroundColor: '#111118',
|
|
||||||
backgroundImage: [
|
|
||||||
'repeating-linear-gradient(60deg, rgba(180,160,210,0.08) 0, rgba(180,160,210,0.08) 1px, transparent 0, transparent 50%)',
|
|
||||||
'repeating-linear-gradient(120deg, rgba(180,160,210,0.08) 0, rgba(180,160,210,0.08) 1px, transparent 0, transparent 50%)',
|
|
||||||
].join(','),
|
|
||||||
backgroundSize: '20px 36px',
|
|
||||||
},
|
|
||||||
|
|
||||||
crosshatch: {
|
|
||||||
backgroundColor: '#0f0f0f',
|
|
||||||
backgroundImage: [
|
|
||||||
'linear-gradient(rgba(255,255,255,0.06) 1px, transparent 1px)',
|
|
||||||
'linear-gradient(90deg, rgba(255,255,255,0.06) 1px, transparent 1px)',
|
|
||||||
'linear-gradient(rgba(255,255,255,0.022) 1px, transparent 1px)',
|
|
||||||
'linear-gradient(90deg, rgba(255,255,255,0.022) 1px, transparent 1px)',
|
|
||||||
].join(','),
|
|
||||||
backgroundSize: '60px 60px, 60px 60px, 12px 12px, 12px 12px',
|
|
||||||
},
|
|
||||||
|
|
||||||
// Interlocking zigzag stripes
|
|
||||||
chevron: {
|
|
||||||
backgroundColor: '#0f0f17',
|
|
||||||
backgroundImage: [
|
|
||||||
'linear-gradient(135deg, rgba(180,160,210,0.1) 25%, transparent 25%)',
|
|
||||||
'linear-gradient(225deg, rgba(180,160,210,0.1) 25%, transparent 25%)',
|
|
||||||
'linear-gradient(315deg, rgba(180,160,210,0.1) 25%, transparent 25%)',
|
|
||||||
'linear-gradient(45deg, rgba(180,160,210,0.1) 25%, transparent 25%)',
|
|
||||||
].join(','),
|
|
||||||
backgroundSize: '20px 20px',
|
|
||||||
},
|
|
||||||
|
|
||||||
// Even dot grid
|
|
||||||
polka: {
|
|
||||||
backgroundColor: '#0e0e14',
|
|
||||||
backgroundImage: 'radial-gradient(circle, rgba(255,255,255,0.2) 2px, transparent 2px)',
|
|
||||||
backgroundSize: '28px 28px',
|
|
||||||
},
|
|
||||||
|
|
||||||
// Isometric triangle grid
|
|
||||||
triangles: {
|
|
||||||
backgroundColor: '#111118',
|
|
||||||
backgroundImage: [
|
|
||||||
'linear-gradient(60deg, rgba(100,149,237,0.09) 25%, transparent 25%, transparent 75%, rgba(100,149,237,0.09) 75%)',
|
|
||||||
'linear-gradient(120deg, rgba(100,149,237,0.09) 25%, transparent 25%, transparent 75%, rgba(100,149,237,0.09) 75%)',
|
|
||||||
].join(','),
|
|
||||||
backgroundSize: '40px 70px',
|
|
||||||
backgroundPosition: '0 0, 20px 35px',
|
|
||||||
},
|
|
||||||
|
|
||||||
// Tartan-inspired crossing lines with accent colour
|
|
||||||
plaid: {
|
|
||||||
backgroundColor: '#0a1020',
|
|
||||||
backgroundImage: [
|
|
||||||
'repeating-linear-gradient(0deg, transparent, transparent 39px, rgba(100,149,237,0.13) 39px, rgba(100,149,237,0.13) 40px)',
|
|
||||||
'repeating-linear-gradient(90deg, transparent, transparent 39px, rgba(100,149,237,0.13) 39px, rgba(100,149,237,0.13) 40px)',
|
|
||||||
'repeating-linear-gradient(0deg, transparent, transparent 7px, rgba(152,0,0,0.08) 7px, rgba(152,0,0,0.08) 8px)',
|
|
||||||
'repeating-linear-gradient(90deg, transparent, transparent 7px, rgba(152,0,0,0.08) 7px, rgba(152,0,0,0.08) 8px)',
|
|
||||||
].join(','),
|
|
||||||
},
|
|
||||||
|
|
||||||
// LotusGuild TDS exact dot-grid
|
|
||||||
tactical: {
|
|
||||||
backgroundColor: '#030508',
|
|
||||||
backgroundImage: 'radial-gradient(circle, rgba(0,212,255,0.055) 1px, transparent 1px)',
|
|
||||||
backgroundSize: '28px 28px',
|
|
||||||
},
|
|
||||||
|
|
||||||
// Circuit board — green grid with node dots
|
|
||||||
circuit: {
|
|
||||||
backgroundColor: '#040a04',
|
|
||||||
backgroundImage: [
|
|
||||||
'linear-gradient(rgba(0,255,136,0.045) 1px, transparent 1px)',
|
|
||||||
'linear-gradient(90deg, rgba(0,255,136,0.045) 1px, transparent 1px)',
|
|
||||||
'radial-gradient(circle, rgba(0,255,136,0.20) 1.5px, transparent 1.5px)',
|
|
||||||
].join(','),
|
|
||||||
backgroundSize: '40px 40px, 40px 40px, 40px 40px',
|
|
||||||
backgroundPosition: '0 0, 0 0, 20px 20px',
|
|
||||||
},
|
|
||||||
|
|
||||||
// True pointy-top hexagonal grid via SVG data URI
|
|
||||||
hexgrid: {
|
|
||||||
backgroundColor: '#060c14',
|
|
||||||
backgroundImage:
|
|
||||||
'url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2229%22%20height%3D%2250%22%3E%3Cpath%20d%3D%22M14.5%200L29%208L29%2025L14.5%2033L0%2025L0%208Z%20M14.5%2033L29%2041V50%20M14.5%2033L0%2041V50%22%20fill%3D%22none%22%20stroke%3D%22rgba%280%2C212%2C255%2C0.13%29%22%20stroke-width%3D%220.8%22/%3E%3C/svg%3E")',
|
|
||||||
backgroundSize: '29px 50px',
|
|
||||||
},
|
|
||||||
|
|
||||||
// Flowing sine-wave lines
|
|
||||||
waves: {
|
|
||||||
backgroundColor: '#080c18',
|
|
||||||
backgroundImage: [
|
|
||||||
'repeating-radial-gradient(ellipse at 0% 50%, transparent 0, transparent 18px, rgba(80,130,255,0.07) 19px, transparent 20px)',
|
|
||||||
'repeating-radial-gradient(ellipse at 100% 50%, transparent 0, transparent 28px, rgba(80,130,255,0.05) 29px, transparent 30px)',
|
|
||||||
'repeating-radial-gradient(ellipse at 50% 0%, transparent 0, transparent 22px, rgba(100,60,200,0.06) 23px, transparent 24px)',
|
|
||||||
].join(','),
|
|
||||||
},
|
|
||||||
|
|
||||||
// Neon cyberpunk grid — orange/cyan TDS colors
|
|
||||||
neon: {
|
|
||||||
backgroundColor: '#020408',
|
|
||||||
backgroundImage: [
|
|
||||||
'linear-gradient(rgba(255,107,0,0.10) 1px, transparent 1px)',
|
|
||||||
'linear-gradient(90deg, rgba(255,107,0,0.10) 1px, transparent 1px)',
|
|
||||||
'linear-gradient(rgba(0,212,255,0.05) 1px, transparent 1px)',
|
|
||||||
'linear-gradient(90deg, rgba(0,212,255,0.05) 1px, transparent 1px)',
|
|
||||||
].join(','),
|
|
||||||
backgroundSize: '60px 60px, 60px 60px, 12px 12px, 12px 12px',
|
|
||||||
},
|
|
||||||
|
|
||||||
// Aurora borealis — flowing gradient bands
|
|
||||||
aurora: {
|
aurora: {
|
||||||
backgroundColor: '#030810',
|
backgroundColor: '#030810',
|
||||||
backgroundImage: [
|
backgroundImage: [
|
||||||
@@ -197,86 +71,30 @@ const DARK: Record<ChatBackground, CSSProperties> = {
|
|||||||
].join(','),
|
].join(','),
|
||||||
},
|
},
|
||||||
|
|
||||||
// Animated: Matrix digital rain — scrolling stripe columns + phosphor glow flicker
|
blueprint: blueprint.dark,
|
||||||
'anim-rain': {
|
stars: stars.dark,
|
||||||
backgroundColor: '#010804',
|
topographic: topographic.dark,
|
||||||
backgroundImage: [
|
herringbone: herringbone.dark,
|
||||||
'repeating-linear-gradient(180deg, rgba(0,255,136,0.16) 0px, rgba(0,255,136,0.16) 1px, transparent 1px, transparent 20px)',
|
crosshatch: crosshatch.dark,
|
||||||
'repeating-linear-gradient(180deg, rgba(0,255,136,0.07) 0px, rgba(0,255,136,0.07) 1px, transparent 1px, transparent 8px)',
|
chevron: chevron.dark,
|
||||||
].join(','),
|
polka: polka.dark,
|
||||||
backgroundSize: '40px 200px, 12px 200px',
|
triangles: triangles.dark,
|
||||||
backgroundPosition: '0 0, 0 0',
|
plaid: plaid.dark,
|
||||||
animation: `${animRainKeyframe} 8s linear infinite`,
|
tactical: tactical.dark,
|
||||||
},
|
circuit: circuit.dark,
|
||||||
|
hexgrid: hexgrid.dark,
|
||||||
// Animated: drifting star field — three seamlessly-tiling layers at different speeds
|
waves: waves.dark,
|
||||||
'anim-stars': {
|
neon: neon.dark,
|
||||||
backgroundColor: '#050510',
|
'anim-rain': animRain.dark,
|
||||||
backgroundImage: [
|
'anim-stars': animStars.dark,
|
||||||
'radial-gradient(circle, rgba(255,255,255,0.85) 1px, transparent 1px)',
|
'anim-pulse': animPulse.dark,
|
||||||
'radial-gradient(circle, rgba(200,220,255,0.55) 1px, transparent 1px)',
|
'anim-aurora': animAurora.dark,
|
||||||
'radial-gradient(circle, rgba(180,200,255,0.3) 1px, transparent 1px)',
|
'anim-fireflies': animFireflies.dark,
|
||||||
].join(','),
|
|
||||||
backgroundSize: '130px 130px, 190px 190px, 260px 260px',
|
|
||||||
backgroundPosition: '0 0, 65px 32px, 32px 97px',
|
|
||||||
animation: `${animStarsDriftKeyframe} 30s linear infinite`,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Animated: neon grid pulse — size breathe + independent brightness oscillation
|
|
||||||
'anim-pulse': {
|
|
||||||
backgroundColor: '#030508',
|
|
||||||
backgroundImage: [
|
|
||||||
'linear-gradient(rgba(255,107,0,0.12) 1px, transparent 1px)',
|
|
||||||
'linear-gradient(90deg, rgba(255,107,0,0.12) 1px, transparent 1px)',
|
|
||||||
'linear-gradient(rgba(0,212,255,0.06) 1px, transparent 1px)',
|
|
||||||
'linear-gradient(90deg, rgba(0,212,255,0.06) 1px, transparent 1px)',
|
|
||||||
].join(','),
|
|
||||||
backgroundSize: '60px 60px, 60px 60px, 12px 12px, 12px 12px',
|
|
||||||
animation: `${animGridPulseKeyframe} 4s ease-in-out infinite`,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Animated: aurora borealis — four bands each travel an independent path
|
|
||||||
'anim-aurora': {
|
|
||||||
backgroundColor: '#020a10',
|
|
||||||
backgroundImage: [
|
|
||||||
'radial-gradient(ellipse 80% 60% at 20% 30%, rgba(0,255,136,0.12) 0%, transparent 70%)',
|
|
||||||
'radial-gradient(ellipse 70% 50% at 80% 70%, rgba(0,100,255,0.12) 0%, transparent 70%)',
|
|
||||||
'radial-gradient(ellipse 90% 70% at 50% 10%, rgba(191,95,255,0.09) 0%, transparent 65%)',
|
|
||||||
'radial-gradient(ellipse 75% 55% at 60% 90%, rgba(0,212,255,0.09) 0%, transparent 65%)',
|
|
||||||
].join(','),
|
|
||||||
backgroundSize: '200% 200%, 250% 250%, 300% 300%, 220% 220%',
|
|
||||||
backgroundPosition: '0% 0%, 100% 0%, 50% 100%, 0% 50%',
|
|
||||||
animation: `${animAuroraKeyframe} 28s ease-in-out infinite`,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Animated: fireflies — drift + brightness glow + opacity blink at prime periods
|
|
||||||
'anim-fireflies': {
|
|
||||||
backgroundColor: '#030508',
|
|
||||||
backgroundImage: [
|
|
||||||
'radial-gradient(circle, rgba(255,220,50,0.7) 1.5px, rgba(255,160,0,0.18) 3px, transparent 4px)',
|
|
||||||
'radial-gradient(circle, rgba(255,200,30,0.55) 1px, rgba(255,140,0,0.14) 2.5px, transparent 3.5px)',
|
|
||||||
'radial-gradient(circle, rgba(255,240,100,0.4) 1px, transparent 2px)',
|
|
||||||
].join(','),
|
|
||||||
backgroundSize: '200px 200px, 280px 280px, 160px 160px',
|
|
||||||
backgroundPosition: '0 0, 120px 80px, 60px 140px',
|
|
||||||
animation: `${animFirefliesKeyframe} 30s linear infinite`,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const LIGHT: Record<ChatBackground, CSSProperties> = {
|
const LIGHT: Record<ChatBackground, CSSProperties> = {
|
||||||
none: {},
|
none: {},
|
||||||
|
|
||||||
blueprint: {
|
|
||||||
backgroundColor: '#eef3ff',
|
|
||||||
backgroundImage: [
|
|
||||||
'linear-gradient(rgba(50,100,220,0.16) 1px, transparent 1px)',
|
|
||||||
'linear-gradient(90deg, rgba(50,100,220,0.16) 1px, transparent 1px)',
|
|
||||||
'linear-gradient(rgba(50,100,220,0.06) 1px, transparent 1px)',
|
|
||||||
'linear-gradient(90deg, rgba(50,100,220,0.06) 1px, transparent 1px)',
|
|
||||||
].join(','),
|
|
||||||
backgroundSize: '80px 80px, 80px 80px, 16px 16px, 16px 16px',
|
|
||||||
},
|
|
||||||
|
|
||||||
carbon: {
|
carbon: {
|
||||||
backgroundColor: '#efefef',
|
backgroundColor: '#efefef',
|
||||||
backgroundImage: [
|
backgroundImage: [
|
||||||
@@ -285,129 +103,6 @@ const LIGHT: Record<ChatBackground, CSSProperties> = {
|
|||||||
].join(','),
|
].join(','),
|
||||||
backgroundSize: '8px 8px',
|
backgroundSize: '8px 8px',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Stars is intentionally always dark — it's a night-sky theme
|
|
||||||
stars: {
|
|
||||||
backgroundColor: '#050510',
|
|
||||||
backgroundImage: [
|
|
||||||
'radial-gradient(circle, rgba(255,255,255,0.85) 1px, transparent 1px)',
|
|
||||||
'radial-gradient(circle, rgba(255,255,255,0.55) 1px, transparent 1px)',
|
|
||||||
'radial-gradient(circle, rgba(200,200,255,0.3) 1px, transparent 1px)',
|
|
||||||
].join(','),
|
|
||||||
backgroundSize: '130px 130px, 190px 190px, 260px 260px',
|
|
||||||
backgroundPosition: '0 0, 65px 32px, 32px 97px',
|
|
||||||
},
|
|
||||||
|
|
||||||
topographic: {
|
|
||||||
backgroundColor: '#faf8f5',
|
|
||||||
backgroundImage: [
|
|
||||||
'repeating-radial-gradient(circle at 20% 20%, transparent 0, transparent 30px, rgba(100,60,60,0.09) 31px, transparent 32px)',
|
|
||||||
'repeating-radial-gradient(circle at 80% 80%, transparent 0, transparent 25px, rgba(60,60,130,0.07) 26px, transparent 27px)',
|
|
||||||
'repeating-radial-gradient(circle at 50% 10%, transparent 0, transparent 45px, rgba(100,60,60,0.05) 46px, transparent 47px)',
|
|
||||||
].join(','),
|
|
||||||
},
|
|
||||||
|
|
||||||
herringbone: {
|
|
||||||
backgroundColor: '#f9f9f9',
|
|
||||||
backgroundImage: [
|
|
||||||
'repeating-linear-gradient(60deg, rgba(80,70,110,0.09) 0, rgba(80,70,110,0.09) 1px, transparent 0, transparent 50%)',
|
|
||||||
'repeating-linear-gradient(120deg, rgba(80,70,110,0.09) 0, rgba(80,70,110,0.09) 1px, transparent 0, transparent 50%)',
|
|
||||||
].join(','),
|
|
||||||
backgroundSize: '20px 36px',
|
|
||||||
},
|
|
||||||
|
|
||||||
crosshatch: {
|
|
||||||
backgroundColor: '#ffffff',
|
|
||||||
backgroundImage: [
|
|
||||||
'linear-gradient(rgba(0,0,0,0.07) 1px, transparent 1px)',
|
|
||||||
'linear-gradient(90deg, rgba(0,0,0,0.07) 1px, transparent 1px)',
|
|
||||||
'linear-gradient(rgba(0,0,0,0.025) 1px, transparent 1px)',
|
|
||||||
'linear-gradient(90deg, rgba(0,0,0,0.025) 1px, transparent 1px)',
|
|
||||||
].join(','),
|
|
||||||
backgroundSize: '60px 60px, 60px 60px, 12px 12px, 12px 12px',
|
|
||||||
},
|
|
||||||
|
|
||||||
chevron: {
|
|
||||||
backgroundColor: '#f9f8ff',
|
|
||||||
backgroundImage: [
|
|
||||||
'linear-gradient(135deg, rgba(80,60,130,0.1) 25%, transparent 25%)',
|
|
||||||
'linear-gradient(225deg, rgba(80,60,130,0.1) 25%, transparent 25%)',
|
|
||||||
'linear-gradient(315deg, rgba(80,60,130,0.1) 25%, transparent 25%)',
|
|
||||||
'linear-gradient(45deg, rgba(80,60,130,0.1) 25%, transparent 25%)',
|
|
||||||
].join(','),
|
|
||||||
backgroundSize: '20px 20px',
|
|
||||||
},
|
|
||||||
|
|
||||||
polka: {
|
|
||||||
backgroundColor: '#fafafa',
|
|
||||||
backgroundImage: 'radial-gradient(circle, rgba(0,0,0,0.18) 2px, transparent 2px)',
|
|
||||||
backgroundSize: '28px 28px',
|
|
||||||
},
|
|
||||||
|
|
||||||
triangles: {
|
|
||||||
backgroundColor: '#f4f7ff',
|
|
||||||
backgroundImage: [
|
|
||||||
'linear-gradient(60deg, rgba(50,100,220,0.1) 25%, transparent 25%, transparent 75%, rgba(50,100,220,0.1) 75%)',
|
|
||||||
'linear-gradient(120deg, rgba(50,100,220,0.1) 25%, transparent 25%, transparent 75%, rgba(50,100,220,0.1) 75%)',
|
|
||||||
].join(','),
|
|
||||||
backgroundSize: '40px 70px',
|
|
||||||
backgroundPosition: '0 0, 20px 35px',
|
|
||||||
},
|
|
||||||
|
|
||||||
plaid: {
|
|
||||||
backgroundColor: '#f5f0ff',
|
|
||||||
backgroundImage: [
|
|
||||||
'repeating-linear-gradient(0deg, transparent, transparent 39px, rgba(100,50,180,0.15) 39px, rgba(100,50,180,0.15) 40px)',
|
|
||||||
'repeating-linear-gradient(90deg, transparent, transparent 39px, rgba(100,50,180,0.15) 39px, rgba(100,50,180,0.15) 40px)',
|
|
||||||
'repeating-linear-gradient(0deg, transparent, transparent 7px, rgba(200,0,0,0.09) 7px, rgba(200,0,0,0.09) 8px)',
|
|
||||||
'repeating-linear-gradient(90deg, transparent, transparent 7px, rgba(200,0,0,0.09) 7px, rgba(200,0,0,0.09) 8px)',
|
|
||||||
].join(','),
|
|
||||||
},
|
|
||||||
|
|
||||||
tactical: {
|
|
||||||
backgroundColor: '#f0f4fa',
|
|
||||||
backgroundImage: 'radial-gradient(circle, rgba(0,100,200,0.08) 1px, transparent 1px)',
|
|
||||||
backgroundSize: '28px 28px',
|
|
||||||
},
|
|
||||||
|
|
||||||
circuit: {
|
|
||||||
backgroundColor: '#f0f8f0',
|
|
||||||
backgroundImage: [
|
|
||||||
'linear-gradient(rgba(0,160,80,0.06) 1px, transparent 1px)',
|
|
||||||
'linear-gradient(90deg, rgba(0,160,80,0.06) 1px, transparent 1px)',
|
|
||||||
'radial-gradient(circle, rgba(0,160,80,0.22) 1.5px, transparent 1.5px)',
|
|
||||||
].join(','),
|
|
||||||
backgroundSize: '40px 40px, 40px 40px, 40px 40px',
|
|
||||||
backgroundPosition: '0 0, 0 0, 20px 20px',
|
|
||||||
},
|
|
||||||
|
|
||||||
hexgrid: {
|
|
||||||
backgroundColor: '#f4f8ff',
|
|
||||||
backgroundImage:
|
|
||||||
'url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2229%22%20height%3D%2250%22%3E%3Cpath%20d%3D%22M14.5%200L29%208L29%2025L14.5%2033L0%2025L0%208Z%20M14.5%2033L29%2041V50%20M14.5%2033L0%2041V50%22%20fill%3D%22none%22%20stroke%3D%22rgba%2850%2C100%2C220%2C0.11%29%22%20stroke-width%3D%220.8%22/%3E%3C/svg%3E")',
|
|
||||||
backgroundSize: '29px 50px',
|
|
||||||
},
|
|
||||||
|
|
||||||
waves: {
|
|
||||||
backgroundColor: '#eef3ff',
|
|
||||||
backgroundImage: [
|
|
||||||
'repeating-radial-gradient(ellipse at 0% 50%, transparent 0, transparent 18px, rgba(50,100,220,0.09) 19px, transparent 20px)',
|
|
||||||
'repeating-radial-gradient(ellipse at 100% 50%, transparent 0, transparent 28px, rgba(50,100,220,0.07) 29px, transparent 30px)',
|
|
||||||
'repeating-radial-gradient(ellipse at 50% 0%, transparent 0, transparent 22px, rgba(80,40,180,0.07) 23px, transparent 24px)',
|
|
||||||
].join(','),
|
|
||||||
},
|
|
||||||
|
|
||||||
neon: {
|
|
||||||
backgroundColor: '#fafafa',
|
|
||||||
backgroundImage: [
|
|
||||||
'linear-gradient(rgba(196,78,0,0.12) 1px, transparent 1px)',
|
|
||||||
'linear-gradient(90deg, rgba(196,78,0,0.12) 1px, transparent 1px)',
|
|
||||||
'linear-gradient(rgba(0,98,184,0.06) 1px, transparent 1px)',
|
|
||||||
'linear-gradient(90deg, rgba(0,98,184,0.06) 1px, transparent 1px)',
|
|
||||||
].join(','),
|
|
||||||
backgroundSize: '60px 60px, 60px 60px, 12px 12px, 12px 12px',
|
|
||||||
},
|
|
||||||
|
|
||||||
aurora: {
|
aurora: {
|
||||||
backgroundColor: '#f4faf8',
|
backgroundColor: '#f4faf8',
|
||||||
backgroundImage: [
|
backgroundImage: [
|
||||||
@@ -418,67 +113,25 @@ const LIGHT: Record<ChatBackground, CSSProperties> = {
|
|||||||
].join(','),
|
].join(','),
|
||||||
},
|
},
|
||||||
|
|
||||||
// Animated light variants
|
blueprint: blueprint.light,
|
||||||
|
stars: stars.light,
|
||||||
'anim-rain': {
|
topographic: topographic.light,
|
||||||
backgroundColor: '#f0fff4',
|
herringbone: herringbone.light,
|
||||||
backgroundImage: [
|
crosshatch: crosshatch.light,
|
||||||
'repeating-linear-gradient(180deg, rgba(0,160,80,0.16) 0px, rgba(0,160,80,0.16) 1px, transparent 1px, transparent 20px)',
|
chevron: chevron.light,
|
||||||
'repeating-linear-gradient(180deg, rgba(0,160,80,0.07) 0px, rgba(0,160,80,0.07) 1px, transparent 1px, transparent 8px)',
|
polka: polka.light,
|
||||||
].join(','),
|
triangles: triangles.light,
|
||||||
backgroundSize: '40px 200px, 12px 200px',
|
plaid: plaid.light,
|
||||||
backgroundPosition: '0 0, 0 0',
|
tactical: tactical.light,
|
||||||
animation: `${animRainKeyframe} 8s linear infinite`,
|
circuit: circuit.light,
|
||||||
},
|
hexgrid: hexgrid.light,
|
||||||
|
waves: waves.light,
|
||||||
'anim-stars': {
|
neon: neon.light,
|
||||||
backgroundColor: '#f5f5ff',
|
'anim-rain': animRain.light,
|
||||||
backgroundImage: [
|
'anim-stars': animStars.light,
|
||||||
'radial-gradient(circle, rgba(60,60,160,0.50) 1px, transparent 1px)',
|
'anim-pulse': animPulse.light,
|
||||||
'radial-gradient(circle, rgba(80,80,180,0.35) 1px, transparent 1px)',
|
'anim-aurora': animAurora.light,
|
||||||
'radial-gradient(circle, rgba(100,100,200,0.20) 1px, transparent 1px)',
|
'anim-fireflies': animFireflies.light,
|
||||||
].join(','),
|
|
||||||
backgroundSize: '130px 130px, 190px 190px, 260px 260px',
|
|
||||||
backgroundPosition: '0 0, 65px 32px, 32px 97px',
|
|
||||||
animation: `${animStarsDriftKeyframe} 30s linear infinite`,
|
|
||||||
},
|
|
||||||
|
|
||||||
'anim-pulse': {
|
|
||||||
backgroundColor: '#ffffff',
|
|
||||||
backgroundImage: [
|
|
||||||
'linear-gradient(rgba(0,98,184,0.14) 1px, transparent 1px)',
|
|
||||||
'linear-gradient(90deg, rgba(0,98,184,0.14) 1px, transparent 1px)',
|
|
||||||
'linear-gradient(rgba(0,98,184,0.06) 1px, transparent 1px)',
|
|
||||||
'linear-gradient(90deg, rgba(0,98,184,0.06) 1px, transparent 1px)',
|
|
||||||
].join(','),
|
|
||||||
backgroundSize: '60px 60px, 60px 60px, 12px 12px, 12px 12px',
|
|
||||||
animation: `${animGridPulseKeyframe} 4s ease-in-out infinite`,
|
|
||||||
},
|
|
||||||
|
|
||||||
'anim-aurora': {
|
|
||||||
backgroundColor: '#f0f8f4',
|
|
||||||
backgroundImage: [
|
|
||||||
'radial-gradient(ellipse 80% 60% at 20% 30%, rgba(0,160,80,0.13) 0%, transparent 70%)',
|
|
||||||
'radial-gradient(ellipse 70% 50% at 80% 70%, rgba(0,80,200,0.13) 0%, transparent 70%)',
|
|
||||||
'radial-gradient(ellipse 90% 70% at 50% 10%, rgba(140,60,220,0.10) 0%, transparent 65%)',
|
|
||||||
'radial-gradient(ellipse 75% 55% at 60% 90%, rgba(0,160,200,0.10) 0%, transparent 65%)',
|
|
||||||
].join(','),
|
|
||||||
backgroundSize: '200% 200%, 250% 250%, 300% 300%, 220% 220%',
|
|
||||||
backgroundPosition: '0% 0%, 100% 0%, 50% 100%, 0% 50%',
|
|
||||||
animation: `${animAuroraKeyframe} 28s ease-in-out infinite`,
|
|
||||||
},
|
|
||||||
|
|
||||||
'anim-fireflies': {
|
|
||||||
backgroundColor: '#fffdf0',
|
|
||||||
backgroundImage: [
|
|
||||||
'radial-gradient(circle, rgba(180,120,0,0.70) 1.5px, rgba(160,90,0,0.18) 3px, transparent 4px)',
|
|
||||||
'radial-gradient(circle, rgba(160,100,0,0.55) 1px, rgba(140,80,0,0.14) 2.5px, transparent 3.5px)',
|
|
||||||
'radial-gradient(circle, rgba(200,140,0,0.40) 1px, transparent 2px)',
|
|
||||||
].join(','),
|
|
||||||
backgroundSize: '200px 200px, 280px 280px, 160px 160px',
|
|
||||||
backgroundPosition: '0 0, 120px 80px, 60px 140px',
|
|
||||||
animation: `${animFirefliesKeyframe} 30s linear infinite`,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getChatBg = (
|
export const getChatBg = (
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
RoomLocalAddresses,
|
RoomLocalAddresses,
|
||||||
RoomPublishedAddresses,
|
RoomPublishedAddresses,
|
||||||
RoomPublish,
|
RoomPublish,
|
||||||
|
RoomQuality,
|
||||||
RoomShareInvite,
|
RoomShareInvite,
|
||||||
RoomUpgrade,
|
RoomUpgrade,
|
||||||
RoomVoiceLimit,
|
RoomVoiceLimit,
|
||||||
@@ -58,6 +59,7 @@ export function General({ requestClose }: GeneralProps) {
|
|||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Text size="L400">Voice</Text>
|
<Text size="L400">Voice</Text>
|
||||||
<RoomVoiceLimit permissions={permissions} />
|
<RoomVoiceLimit permissions={permissions} />
|
||||||
|
<RoomQuality permissions={permissions} />
|
||||||
</Box>
|
</Box>
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Text size="L400">Addresses</Text>
|
<Text size="L400">Addresses</Text>
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import {
|
|||||||
} from '../../../utils/lotusDenoiseUtils';
|
} from '../../../utils/lotusDenoiseUtils';
|
||||||
import { useSetting } from '../../../state/hooks/settings';
|
import { useSetting } from '../../../state/hooks/settings';
|
||||||
import {
|
import {
|
||||||
|
CallAudioBitrate,
|
||||||
ChatBackground,
|
ChatBackground,
|
||||||
ComposerToolbarSettings,
|
ComposerToolbarSettings,
|
||||||
DateFormat,
|
DateFormat,
|
||||||
@@ -53,9 +54,16 @@ import {
|
|||||||
MessageSpacing,
|
MessageSpacing,
|
||||||
NoiseSuppressionMode,
|
NoiseSuppressionMode,
|
||||||
RingtoneId,
|
RingtoneId,
|
||||||
|
ScreenshareBitrate,
|
||||||
|
ScreenshareFramerate,
|
||||||
Settings,
|
Settings,
|
||||||
settingsAtom,
|
settingsAtom,
|
||||||
} from '../../../state/settings';
|
} from '../../../state/settings';
|
||||||
|
import {
|
||||||
|
AUDIO_BITRATE_OPTIONS,
|
||||||
|
SCREENSHARE_BITRATE_OPTIONS,
|
||||||
|
SCREENSHARE_FRAMERATE_OPTIONS,
|
||||||
|
} from '../../../utils/callQuality';
|
||||||
import { SeasonalPreview, SeasonTheme } from '../../../components/seasonal/SeasonalEffect';
|
import { SeasonalPreview, SeasonTheme } from '../../../components/seasonal/SeasonalEffect';
|
||||||
import { SEASON_DATE_RANGES } from '../../../components/seasonal/seasonSchedule';
|
import { SEASON_DATE_RANGES } from '../../../components/seasonal/seasonSchedule';
|
||||||
import { SettingTile } from '../../../components/setting-tile';
|
import { SettingTile } from '../../../components/setting-tile';
|
||||||
@@ -1221,6 +1229,18 @@ function Calls() {
|
|||||||
const [ringtoneVolume, setRingtoneVolume] = useSetting(settingsAtom, 'ringtoneVolume');
|
const [ringtoneVolume, setRingtoneVolume] = useSetting(settingsAtom, 'ringtoneVolume');
|
||||||
const [ringtoneId, setRingtoneId] = useSetting(settingsAtom, 'ringtoneId');
|
const [ringtoneId, setRingtoneId] = useSetting(settingsAtom, 'ringtoneId');
|
||||||
|
|
||||||
|
const [callAudioBitrate, setCallAudioBitrate] = useSetting(settingsAtom, 'callAudioBitrate');
|
||||||
|
const [screenshareBitrate, setScreenshareBitrate] = useSetting(
|
||||||
|
settingsAtom,
|
||||||
|
'screenshareBitrate',
|
||||||
|
);
|
||||||
|
const [screenshareFramerate, setScreenshareFramerate] = useSetting(
|
||||||
|
settingsAtom,
|
||||||
|
'screenshareFramerate',
|
||||||
|
);
|
||||||
|
const [soundboardEnabled, setSoundboardEnabled] = useSetting(settingsAtom, 'soundboardEnabled');
|
||||||
|
const [soundboardVolume, setSoundboardVolume] = useSetting(settingsAtom, 'soundboardVolume');
|
||||||
|
|
||||||
const handleJoinLeaveSoundChange = (value: 'off' | 'chime' | 'soft' | 'retro') => {
|
const handleJoinLeaveSoundChange = (value: 'off' | 'chime' | 'soft' | 'retro') => {
|
||||||
setCallJoinLeaveSound(value);
|
setCallJoinLeaveSound(value);
|
||||||
if (value !== 'off') playCallJoinSound(value);
|
if (value !== 'off') playCallJoinSound(value);
|
||||||
@@ -1616,6 +1636,80 @@ function Calls() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
|
|
||||||
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
|
<SettingTile
|
||||||
|
title="Microphone Bitrate"
|
||||||
|
description="Cap the audio bitrate your mic sends in calls. Lower saves bandwidth; higher is clearer. Auto lets Element Call decide."
|
||||||
|
after={
|
||||||
|
<SettingsSelect<CallAudioBitrate>
|
||||||
|
value={callAudioBitrate}
|
||||||
|
onChange={setCallAudioBitrate}
|
||||||
|
options={AUDIO_BITRATE_OPTIONS}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SequenceCard>
|
||||||
|
|
||||||
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
|
<SettingTile
|
||||||
|
title="Screenshare Bitrate"
|
||||||
|
description="Cap the bitrate used when you share your screen. Lower is smoother on poor connections; higher is sharper."
|
||||||
|
after={
|
||||||
|
<SettingsSelect<ScreenshareBitrate>
|
||||||
|
value={screenshareBitrate}
|
||||||
|
onChange={setScreenshareBitrate}
|
||||||
|
options={SCREENSHARE_BITRATE_OPTIONS}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SequenceCard>
|
||||||
|
|
||||||
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
|
<SettingTile
|
||||||
|
title="Screenshare Framerate"
|
||||||
|
description="Cap the frames-per-second of your screenshare. 60 fps suits motion/gaming; 15 fps suits slides and saves bandwidth."
|
||||||
|
after={
|
||||||
|
<SettingsSelect<ScreenshareFramerate>
|
||||||
|
value={screenshareFramerate}
|
||||||
|
onChange={setScreenshareFramerate}
|
||||||
|
options={SCREENSHARE_FRAMERATE_OPTIONS}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SequenceCard>
|
||||||
|
|
||||||
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
|
<SettingTile
|
||||||
|
title="Soundboard"
|
||||||
|
description="Show a soundboard button in the call bar. Upload short audio clips (like custom emojis) to play them into the call. Clips sync across your devices."
|
||||||
|
after={
|
||||||
|
<Switch variant="Primary" value={soundboardEnabled} onChange={setSoundboardEnabled} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{soundboardEnabled && (
|
||||||
|
<SettingTile
|
||||||
|
title="Soundboard Volume"
|
||||||
|
after={
|
||||||
|
<Box alignItems="Center" gap="200" style={{ minWidth: toRem(180) }}>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={5}
|
||||||
|
value={soundboardVolume}
|
||||||
|
onChange={(e) => setSoundboardVolume(parseInt(e.target.value, 10))}
|
||||||
|
style={{ flexGrow: 1 }}
|
||||||
|
aria-label="Soundboard volume"
|
||||||
|
/>
|
||||||
|
<Text size="T200" style={{ minWidth: toRem(36), textAlign: 'right' }}>
|
||||||
|
{soundboardVolume}%
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SequenceCard>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { CallEmbed } from '../plugins/call';
|
||||||
|
import { settingsAtom } from '../state/settings';
|
||||||
|
import { useStateEvent } from './useStateEvent';
|
||||||
|
import { StateEvent } from '../../types/matrix/room';
|
||||||
|
import { buildQualityPayload, RoomQualityContent } from '../utils/callQuality';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [P5-31] Apply the user's call quality settings (clamped by any room-level
|
||||||
|
* cap) to the Element Call fork via the `io.lotus.set_quality` widget action.
|
||||||
|
*
|
||||||
|
* The fork stores the settings and re-applies them on every (re)publish and
|
||||||
|
* reconnect, so we only need to (re)send when the payload changes or the widget
|
||||||
|
* becomes ready — no need to poll the track lifecycle here.
|
||||||
|
*/
|
||||||
|
export function useCallQuality(embed: CallEmbed): void {
|
||||||
|
const { callAudioBitrate, screenshareBitrate, screenshareFramerate } = useAtomValue(settingsAtom);
|
||||||
|
|
||||||
|
const roomQualityEvent = useStateEvent(embed.room, StateEvent.LotusRoomQuality);
|
||||||
|
const roomCaps = roomQualityEvent?.getContent<RoomQualityContent>();
|
||||||
|
|
||||||
|
// Depend on the primitive cap values (not the event object) so re-renders
|
||||||
|
// don't resend needlessly.
|
||||||
|
const audioCap = roomCaps?.audio_max_kbps;
|
||||||
|
const ssCap = roomCaps?.screenshare_max_kbps;
|
||||||
|
const fpsCap = roomCaps?.screenshare_max_fps;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const payload = buildQualityPayload(
|
||||||
|
{ callAudioBitrate, screenshareBitrate, screenshareFramerate },
|
||||||
|
{ audio_max_kbps: audioCap, screenshare_max_kbps: ssCap, screenshare_max_fps: fpsCap },
|
||||||
|
);
|
||||||
|
const send = (): void => embed.control.setQuality(payload);
|
||||||
|
// Send now (settings are sticky fork-side even if tracks aren't up yet) and
|
||||||
|
// again once the widget signals ready, in case the transport wasn't up.
|
||||||
|
send();
|
||||||
|
const off = embed.onReady(send);
|
||||||
|
return off;
|
||||||
|
}, [embed, callAudioBitrate, screenshareBitrate, screenshareFramerate, audioCap, ssCap, fpsCap]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
|
import { useAccountDataCallback } from './useAccountDataCallback';
|
||||||
|
import { AccountDataEvent } from '../../types/matrix/accountData';
|
||||||
|
import {
|
||||||
|
SoundboardClip,
|
||||||
|
SoundboardContent,
|
||||||
|
SOUNDBOARD_MAX_CLIP_BYTES,
|
||||||
|
SOUNDBOARD_MAX_CLIPS,
|
||||||
|
SOUNDBOARD_NAME_MAX,
|
||||||
|
readSoundboardClips,
|
||||||
|
} from '../utils/soundboardClips';
|
||||||
|
|
||||||
|
const KEY = AccountDataEvent.LotusSoundboard;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [P5-15] Read/write the user's personal soundboard, stored in the
|
||||||
|
* `io.lotus.soundboard` account data event (synced across devices like custom
|
||||||
|
* emoji/sticker packs). Uploading writes the audio to the media repo and
|
||||||
|
* appends an mxc reference.
|
||||||
|
*/
|
||||||
|
export function useSoundboard(): {
|
||||||
|
clips: SoundboardClip[];
|
||||||
|
addClip: (file: File, name?: string) => Promise<void>;
|
||||||
|
removeClip: (id: string) => Promise<void>;
|
||||||
|
renameClip: (id: string, name: string) => Promise<void>;
|
||||||
|
} {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const [clips, setClips] = useState<SoundboardClip[]>(() => readSoundboardClips(mx));
|
||||||
|
|
||||||
|
useAccountDataCallback(
|
||||||
|
mx,
|
||||||
|
useCallback((evt) => {
|
||||||
|
if (evt.getType() === KEY) {
|
||||||
|
const content = evt.getContent<SoundboardContent>();
|
||||||
|
setClips(Array.isArray(content?.clips) ? content.clips : []);
|
||||||
|
}
|
||||||
|
}, []),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setClips(readSoundboardClips(mx));
|
||||||
|
}, [mx]);
|
||||||
|
|
||||||
|
const persist = useCallback(
|
||||||
|
async (next: SoundboardClip[]) => {
|
||||||
|
const content: SoundboardContent = { clips: next };
|
||||||
|
await (
|
||||||
|
mx as unknown as { setAccountData: (t: string, c: unknown) => Promise<void> }
|
||||||
|
).setAccountData(KEY, content);
|
||||||
|
},
|
||||||
|
[mx],
|
||||||
|
);
|
||||||
|
|
||||||
|
const addClip = useCallback(
|
||||||
|
async (file: File, name?: string) => {
|
||||||
|
const current = readSoundboardClips(mx);
|
||||||
|
if (current.length >= SOUNDBOARD_MAX_CLIPS) {
|
||||||
|
throw new Error(`Soundboard is full (max ${SOUNDBOARD_MAX_CLIPS} clips).`);
|
||||||
|
}
|
||||||
|
if (file.size > SOUNDBOARD_MAX_CLIP_BYTES) {
|
||||||
|
throw new Error('Clip is too large (max 1 MB).');
|
||||||
|
}
|
||||||
|
const res = await mx.uploadContent(file, { type: file.type || 'audio/mpeg' });
|
||||||
|
const mxc = res.content_uri;
|
||||||
|
if (!mxc) throw new Error('Upload failed.');
|
||||||
|
const label = (name ?? file.name.replace(/\.[^/.]+$/, ''))
|
||||||
|
.trim()
|
||||||
|
.slice(0, SOUNDBOARD_NAME_MAX);
|
||||||
|
const clip: SoundboardClip = {
|
||||||
|
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
name: label || 'Clip',
|
||||||
|
url: mxc,
|
||||||
|
mimetype: file.type || undefined,
|
||||||
|
size: file.size,
|
||||||
|
};
|
||||||
|
await persist([...current, clip]);
|
||||||
|
},
|
||||||
|
[mx, persist],
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeClip = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
const next = readSoundboardClips(mx).filter((c) => c.id !== id);
|
||||||
|
await persist(next);
|
||||||
|
},
|
||||||
|
[mx, persist],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renameClip = useCallback(
|
||||||
|
async (id: string, name: string) => {
|
||||||
|
const trimmed = name.trim().slice(0, SOUNDBOARD_NAME_MAX);
|
||||||
|
if (!trimmed) return;
|
||||||
|
const next = readSoundboardClips(mx).map((c) => (c.id === id ? { ...c, name: trimmed } : c));
|
||||||
|
await persist(next);
|
||||||
|
},
|
||||||
|
[mx, persist],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { clips, addClip, removeClip, renameClip };
|
||||||
|
}
|
||||||
@@ -7,6 +7,17 @@ export enum CallControlEvent {
|
|||||||
StateUpdate = 'state_update',
|
StateUpdate = 'state_update',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [lotus #7 / P5-31] Payload for the fork's `io.lotus.set_quality` action.
|
||||||
|
* All fields optional; `null` clears that cap. Bits/sec for bitrates, fps for
|
||||||
|
* framerate.
|
||||||
|
*/
|
||||||
|
export type LotusQualityPayload = {
|
||||||
|
audioMaxBitrate?: number | null;
|
||||||
|
screenshareMaxBitrate?: number | null;
|
||||||
|
screenshareMaxFramerate?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
export class CallControl extends EventEmitter implements CallControlState {
|
export class CallControl extends EventEmitter implements CallControlState {
|
||||||
private state: CallControlState;
|
private state: CallControlState;
|
||||||
|
|
||||||
@@ -358,6 +369,33 @@ export class CallControl extends EventEmitter implements CallControlState {
|
|||||||
this.call.transport.send('io.lotus.focus_participant', { userId: null }).catch(() => undefined);
|
this.call.transport.send('io.lotus.focus_participant', { userId: null }).catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [lotus #3 / P5-15] Inject a soundboard clip into the call so other
|
||||||
|
* participants hear it. The fork publishes it as a separate LiveKit audio
|
||||||
|
* track (`io.lotus.inject_audio`) rather than splicing the mic. `url` must be
|
||||||
|
* an https/blob URL the widget can fetch WITHOUT credentials — the host
|
||||||
|
* resolves an mxc clip to a `blob:` object URL first (authenticated media
|
||||||
|
* can't be fetched cross-realm by the widget). `volume` is 0–1.
|
||||||
|
*
|
||||||
|
* The local user does not hear their own published track, so callers should
|
||||||
|
* also play the clip locally for feedback.
|
||||||
|
*/
|
||||||
|
public injectAudio(url: string, volume = 1): void {
|
||||||
|
this.call.transport.send('io.lotus.inject_audio', { url, volume }).catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [lotus #7 / P5-31] Apply audio/screenshare encoding limits to the local
|
||||||
|
* published tracks (the fork's `io.lotus.set_quality` action, via
|
||||||
|
* `RTCRtpSender.setParameters` — no republish). Bitrates are bits/sec,
|
||||||
|
* framerate is fps. A field set to `null` clears that cap. Settings are
|
||||||
|
* sticky fork-side (re-applied on every re-publish / reconnect). Values are
|
||||||
|
* clamped fork-side, so out-of-range input can't brick the encoder.
|
||||||
|
*/
|
||||||
|
public setQuality(settings: LotusQualityPayload): void {
|
||||||
|
this.call.transport.send('io.lotus.set_quality', settings).catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
public dispose() {
|
public dispose() {
|
||||||
this.bodyMutationObserver.disconnect();
|
this.bodyMutationObserver.disconnect();
|
||||||
this.controlMutationObserver.disconnect();
|
this.controlMutationObserver.disconnect();
|
||||||
|
|||||||
@@ -179,6 +179,11 @@ export class CallEmbed {
|
|||||||
// - transparent background so the room wallpaper shows through natively
|
// - transparent background so the room wallpaper shows through natively
|
||||||
lotusCallState: 'true',
|
lotusCallState: 'true',
|
||||||
lotusTransparent: 'true',
|
lotusTransparent: 'true',
|
||||||
|
// [lotus #3 / P5-15] Arm the fork's audio-inject handler so the in-call
|
||||||
|
// soundboard can publish clips into the call. Dormant until the host
|
||||||
|
// sends io.lotus.inject_audio (only on an explicit user click), so
|
||||||
|
// arming it for every call is safe.
|
||||||
|
lotusAudioInject: 'true',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (denoiseMode === 'ml') {
|
if (denoiseMode === 'ml') {
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ export type DenoiseModelId = 'rnnoise' | 'speex' | 'dtln' | 'deepfilternet';
|
|||||||
// 'soft' / 'retro' are synthesized in-browser (see utils/ringtones.ts);
|
// 'soft' / 'retro' are synthesized in-browser (see utils/ringtones.ts);
|
||||||
// 'none' is silent (visual-only incoming-call UI).
|
// 'none' is silent (visual-only incoming-call UI).
|
||||||
export type RingtoneId = 'classic' | 'chime' | 'soft' | 'retro' | 'none';
|
export type RingtoneId = 'classic' | 'chime' | 'soft' | 'retro' | 'none';
|
||||||
|
// [P5-31] Granular call quality caps. 'auto' = don't cap (the EC fork keeps its
|
||||||
|
// default encoding). Numbers are kbps (audio/screenshare bitrate) or fps
|
||||||
|
// (screenshare framerate); converted to the fork's bits/sec + fps payload in
|
||||||
|
// utils/callQuality.ts and applied via the io.lotus.set_quality widget action.
|
||||||
|
export type CallAudioBitrate = 'auto' | '32' | '64' | '96' | '128' | '256';
|
||||||
|
export type ScreenshareBitrate = 'auto' | '500' | '1500' | '3000' | '8000';
|
||||||
|
export type ScreenshareFramerate = 'auto' | '15' | '30' | '60';
|
||||||
export type ChatBackground =
|
export type ChatBackground =
|
||||||
| 'none'
|
| 'none'
|
||||||
| 'blueprint'
|
| 'blueprint'
|
||||||
@@ -156,6 +163,14 @@ export interface Settings {
|
|||||||
ringtoneId: RingtoneId;
|
ringtoneId: RingtoneId;
|
||||||
ringtoneVolume: number; // 0–100
|
ringtoneVolume: number; // 0–100
|
||||||
|
|
||||||
|
// [P5-31] Call quality controls
|
||||||
|
callAudioBitrate: CallAudioBitrate;
|
||||||
|
screenshareBitrate: ScreenshareBitrate;
|
||||||
|
screenshareFramerate: ScreenshareFramerate;
|
||||||
|
// [P5-15] In-call soundboard
|
||||||
|
soundboardEnabled: boolean;
|
||||||
|
soundboardVolume: number; // 0–100
|
||||||
|
|
||||||
seasonalThemeOverride:
|
seasonalThemeOverride:
|
||||||
| 'auto'
|
| 'auto'
|
||||||
| 'off'
|
| 'off'
|
||||||
@@ -253,6 +268,12 @@ const defaultSettings: Settings = {
|
|||||||
ringtoneId: 'classic',
|
ringtoneId: 'classic',
|
||||||
ringtoneVolume: 70,
|
ringtoneVolume: 70,
|
||||||
|
|
||||||
|
callAudioBitrate: 'auto',
|
||||||
|
screenshareBitrate: 'auto',
|
||||||
|
screenshareFramerate: 'auto',
|
||||||
|
soundboardEnabled: true,
|
||||||
|
soundboardVolume: 80,
|
||||||
|
|
||||||
seasonalThemeOverride: 'auto',
|
seasonalThemeOverride: 'auto',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { buildQualityPayload } from './callQuality';
|
||||||
|
|
||||||
|
describe('buildQualityPayload', () => {
|
||||||
|
it("sends null for every 'auto' field so a prior cap is reset", () => {
|
||||||
|
const payload = buildQualityPayload({
|
||||||
|
callAudioBitrate: 'auto',
|
||||||
|
screenshareBitrate: 'auto',
|
||||||
|
screenshareFramerate: 'auto',
|
||||||
|
});
|
||||||
|
assert.deepEqual(payload, {
|
||||||
|
audioMaxBitrate: null,
|
||||||
|
screenshareMaxBitrate: null,
|
||||||
|
screenshareMaxFramerate: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts kbps user settings to bits/sec and passes fps through', () => {
|
||||||
|
const payload = buildQualityPayload({
|
||||||
|
callAudioBitrate: '64',
|
||||||
|
screenshareBitrate: '1500',
|
||||||
|
screenshareFramerate: '30',
|
||||||
|
});
|
||||||
|
assert.equal(payload.audioMaxBitrate, 64_000);
|
||||||
|
assert.equal(payload.screenshareMaxBitrate, 1_500_000);
|
||||||
|
assert.equal(payload.screenshareMaxFramerate, 30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps the user setting down to the room cap (lower wins)', () => {
|
||||||
|
const payload = buildQualityPayload(
|
||||||
|
{ callAudioBitrate: '256', screenshareBitrate: '8000', screenshareFramerate: '60' },
|
||||||
|
{ audio_max_kbps: 64, screenshare_max_kbps: 1500, screenshare_max_fps: 30 },
|
||||||
|
);
|
||||||
|
assert.equal(payload.audioMaxBitrate, 64_000);
|
||||||
|
assert.equal(payload.screenshareMaxBitrate, 1_500_000);
|
||||||
|
assert.equal(payload.screenshareMaxFramerate, 30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not raise a user setting that is already below the room cap', () => {
|
||||||
|
const payload = buildQualityPayload(
|
||||||
|
{ callAudioBitrate: '32', screenshareBitrate: 'auto', screenshareFramerate: '15' },
|
||||||
|
{ audio_max_kbps: 128, screenshare_max_kbps: 3000, screenshare_max_fps: 60 },
|
||||||
|
);
|
||||||
|
assert.equal(payload.audioMaxBitrate, 32_000);
|
||||||
|
// user 'auto' but room caps screenshare bitrate -> room cap applies
|
||||||
|
assert.equal(payload.screenshareMaxBitrate, 3_000_000);
|
||||||
|
assert.equal(payload.screenshareMaxFramerate, 15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies a room cap even when the user left the field on auto', () => {
|
||||||
|
const payload = buildQualityPayload(
|
||||||
|
{ callAudioBitrate: 'auto', screenshareBitrate: 'auto', screenshareFramerate: 'auto' },
|
||||||
|
{ audio_max_kbps: 96 },
|
||||||
|
);
|
||||||
|
assert.equal(payload.audioMaxBitrate, 96_000);
|
||||||
|
assert.equal(payload.screenshareMaxBitrate, null);
|
||||||
|
assert.equal(payload.screenshareMaxFramerate, null);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { LotusQualityPayload } from '../plugins/call/CallControl';
|
||||||
|
import { CallAudioBitrate, ScreenshareBitrate, ScreenshareFramerate } from '../state/settings';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [P5-31] Room-level quality caps, stored in the `io.lotus.room_quality` state
|
||||||
|
* event. Admins set a ceiling every client must stay under. Values mirror the
|
||||||
|
* user-setting units (kbps / fps); `undefined`/absent = no cap.
|
||||||
|
*
|
||||||
|
* NOTE: the client applies these as a best-effort UX cap. Hard enforcement for
|
||||||
|
* ALL Matrix clients is a server-side follow-up (a `voice-limit-guard`-style
|
||||||
|
* sidecar on LXC 151 that reads this event) — see LOTUS_TODO.md P5-31.
|
||||||
|
*/
|
||||||
|
export type RoomQualityContent = {
|
||||||
|
// Numeric caps: client-cooperative only (our fork honors them; the SFU cannot
|
||||||
|
// enforce publisher bitrate/fps — LiveKit forwards, never transcodes).
|
||||||
|
audio_max_kbps?: number;
|
||||||
|
screenshare_max_kbps?: number;
|
||||||
|
screenshare_max_fps?: number;
|
||||||
|
// Publish-source policy: HARD-enforced server-side for ALL clients by the
|
||||||
|
// voice-limit-guard (it re-signs the LiveKit JWT's canPublishSources).
|
||||||
|
// Absent/true = allowed; only an explicit false forbids.
|
||||||
|
allow_screenshare?: boolean;
|
||||||
|
allow_camera?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Selectable options (kbps / fps), shared by the settings UI and the room-admin
|
||||||
|
// UI so they stay in sync. Values are strings so they satisfy SettingsSelect's
|
||||||
|
// `T extends string` constraint; parsed to numbers in buildQualityPayload.
|
||||||
|
export const AUDIO_BITRATE_OPTIONS: { value: CallAudioBitrate; label: string }[] = [
|
||||||
|
{ value: 'auto', label: 'Auto' },
|
||||||
|
{ value: '32', label: '32 kbps' },
|
||||||
|
{ value: '64', label: '64 kbps' },
|
||||||
|
{ value: '96', label: '96 kbps' },
|
||||||
|
{ value: '128', label: '128 kbps' },
|
||||||
|
{ value: '256', label: '256 kbps' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const SCREENSHARE_BITRATE_OPTIONS: { value: ScreenshareBitrate; label: string }[] = [
|
||||||
|
{ value: 'auto', label: 'Auto' },
|
||||||
|
{ value: '500', label: '0.5 Mbps' },
|
||||||
|
{ value: '1500', label: '1.5 Mbps' },
|
||||||
|
{ value: '3000', label: '3 Mbps' },
|
||||||
|
{ value: '8000', label: '8 Mbps' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const SCREENSHARE_FRAMERATE_OPTIONS: { value: ScreenshareFramerate; label: string }[] = [
|
||||||
|
{ value: 'auto', label: 'Auto' },
|
||||||
|
{ value: '15', label: '15 fps' },
|
||||||
|
{ value: '30', label: '30 fps' },
|
||||||
|
{ value: '60', label: '60 fps' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Lower of two caps, treating `undefined` as "no cap on that side". */
|
||||||
|
const minCap = (a: number | undefined, b: number | undefined): number | undefined => {
|
||||||
|
if (a === undefined) return b;
|
||||||
|
if (b === undefined) return a;
|
||||||
|
return Math.min(a, b);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Parse a setting value ('auto' | numeric string) to a number or undefined. */
|
||||||
|
const num = (v: string): number | undefined => {
|
||||||
|
if (v === 'auto') return undefined;
|
||||||
|
const n = parseInt(v, 10);
|
||||||
|
return Number.isFinite(n) ? n : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
type QualitySettings = {
|
||||||
|
callAudioBitrate: CallAudioBitrate;
|
||||||
|
screenshareBitrate: ScreenshareBitrate;
|
||||||
|
screenshareFramerate: ScreenshareFramerate;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the `io.lotus.set_quality` payload from the user's settings, clamped by
|
||||||
|
* any room-level cap. Every field is always present so clearing a setting back
|
||||||
|
* to 'auto' sends an explicit `null` that resets the fork-side cap (otherwise a
|
||||||
|
* previously-applied cap would stick for the rest of the call).
|
||||||
|
*/
|
||||||
|
export const buildQualityPayload = (
|
||||||
|
settings: QualitySettings,
|
||||||
|
roomCaps?: RoomQualityContent,
|
||||||
|
): LotusQualityPayload => {
|
||||||
|
const userAudio = num(settings.callAudioBitrate);
|
||||||
|
const userSsBitrate = num(settings.screenshareBitrate);
|
||||||
|
const userSsFps = num(settings.screenshareFramerate);
|
||||||
|
|
||||||
|
const audioKbps = minCap(userAudio, roomCaps?.audio_max_kbps);
|
||||||
|
const ssKbps = minCap(userSsBitrate, roomCaps?.screenshare_max_kbps);
|
||||||
|
const ssFps = minCap(userSsFps, roomCaps?.screenshare_max_fps);
|
||||||
|
|
||||||
|
return {
|
||||||
|
audioMaxBitrate: audioKbps === undefined ? null : audioKbps * 1000,
|
||||||
|
screenshareMaxBitrate: ssKbps === undefined ? null : ssKbps * 1000,
|
||||||
|
screenshareMaxFramerate: ssFps === undefined ? null : ssFps,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { MatrixClient } from 'matrix-js-sdk';
|
||||||
|
import { downloadMedia, mxcUrlToHttp } from './matrix';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [P5-15] A user-uploaded soundboard clip. Stored (as a list) in the
|
||||||
|
* `io.lotus.soundboard` account data event, so clips sync across a user's
|
||||||
|
* devices exactly like custom emoji / sticker packs.
|
||||||
|
*/
|
||||||
|
export type SoundboardClip = {
|
||||||
|
/** Stable local id (not shared with peers). */
|
||||||
|
id: string;
|
||||||
|
/** Display name / shortcode shown on the tile. */
|
||||||
|
name: string;
|
||||||
|
/** mxc:// URI of the uploaded audio. */
|
||||||
|
url: string;
|
||||||
|
mimetype?: string;
|
||||||
|
size?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SoundboardContent = {
|
||||||
|
clips?: SoundboardClip[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SOUNDBOARD_NAME_MAX = 24;
|
||||||
|
/** Keep clips short: they publish to every peer and hold a track open. */
|
||||||
|
export const SOUNDBOARD_MAX_CLIP_BYTES = 1024 * 1024; // 1 MB
|
||||||
|
export const SOUNDBOARD_MAX_CLIPS = 40;
|
||||||
|
export const SOUNDBOARD_ACCEPT = 'audio/mpeg,audio/ogg,audio/wav,audio/webm,audio/mp4,audio/aac';
|
||||||
|
|
||||||
|
// Cache resolved object URLs per mxc so re-triggering a clip doesn't re-download
|
||||||
|
// it. Object URLs live for the page session; the set is tiny (<= MAX_CLIPS).
|
||||||
|
const objectUrlCache = new Map<string, string>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve an mxc clip to a `blob:` object URL the Element Call widget can fetch
|
||||||
|
* without credentials. Authenticated media (MSC3916) can't be fetched from the
|
||||||
|
* widget's realm, so the host downloads it (auth handled by the service worker)
|
||||||
|
* and hands the widget a same-session blob URL instead.
|
||||||
|
*/
|
||||||
|
export const resolveClipObjectUrl = async (mx: MatrixClient, mxcUrl: string): Promise<string> => {
|
||||||
|
const cached = objectUrlCache.get(mxcUrl);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const httpUrl = mxcUrlToHttp(mx, mxcUrl, true);
|
||||||
|
if (!httpUrl) throw new Error('invalid mxc url');
|
||||||
|
const blob = await downloadMedia(httpUrl);
|
||||||
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
objectUrlCache.set(mxcUrl, objectUrl);
|
||||||
|
return objectUrl;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play a resolved clip locally so the person who pressed it gets immediate
|
||||||
|
* feedback — LiveKit doesn't loop a participant's own published track back to
|
||||||
|
* them, so without this the presser would hear nothing. `volume` is 0–1.
|
||||||
|
*/
|
||||||
|
export const playClipLocally = (objectUrl: string, volume: number): void => {
|
||||||
|
try {
|
||||||
|
const audio = new Audio(objectUrl);
|
||||||
|
audio.volume = Math.max(0, Math.min(1, volume));
|
||||||
|
audio.play().catch(() => undefined);
|
||||||
|
} catch {
|
||||||
|
/* best effort */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const readSoundboardClips = (mx: MatrixClient): SoundboardClip[] => {
|
||||||
|
const content = mx.getAccountData('io.lotus.soundboard' as never)?.getContent() as
|
||||||
|
| SoundboardContent
|
||||||
|
| undefined;
|
||||||
|
return Array.isArray(content?.clips) ? content.clips : [];
|
||||||
|
};
|
||||||
@@ -10,6 +10,10 @@ export enum AccountDataEvent {
|
|||||||
PoniesUserEmotes = 'im.ponies.user_emotes',
|
PoniesUserEmotes = 'im.ponies.user_emotes',
|
||||||
PoniesEmoteRooms = 'im.ponies.emote_rooms',
|
PoniesEmoteRooms = 'im.ponies.emote_rooms',
|
||||||
|
|
||||||
|
// [P5-15] Personal, uploadable in-call soundboard clips (synced across
|
||||||
|
// devices like custom emoji/sticker packs).
|
||||||
|
LotusSoundboard = 'io.lotus.soundboard',
|
||||||
|
|
||||||
SecretStorageDefaultKey = 'm.secret_storage.default_key',
|
SecretStorageDefaultKey = 'm.secret_storage.default_key',
|
||||||
|
|
||||||
CrossSigningMaster = 'm.cross_signing.master',
|
CrossSigningMaster = 'm.cross_signing.master',
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export enum StateEvent {
|
|||||||
PoniesRoomEmotes = 'im.ponies.room_emotes',
|
PoniesRoomEmotes = 'im.ponies.room_emotes',
|
||||||
PowerLevelTags = 'in.cinny.room.power_level_tags',
|
PowerLevelTags = 'in.cinny.room.power_level_tags',
|
||||||
LotusVoiceLimit = 'io.lotus.voice_limit',
|
LotusVoiceLimit = 'io.lotus.voice_limit',
|
||||||
|
LotusRoomQuality = 'io.lotus.room_quality',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum MessageEvent {
|
export enum MessageEvent {
|
||||||
|
|||||||
Reference in New Issue
Block a user