Compare commits
151 Commits
fc8eb70617
...
lotus
| Author | SHA1 | Date | |
|---|---|---|---|
| abd0753148 | |||
| 8192da5a12 | |||
| 6dc478e989 | |||
| 049472e25f | |||
| 81904372bc | |||
| c82ab5c7f5 | |||
| ebcd8ec926 | |||
| 4ff07ea2bd | |||
| 804caa5130 | |||
| 625f0c2386 | |||
| 4d7a05c0f1 | |||
| b5e7bcc0b8 | |||
| bca371ad38 | |||
| 899a14c119 | |||
| 6728a1274d | |||
| 21dda93d1b | |||
| 4380041014 | |||
| 8729ccfcf5 | |||
| 8ab1ec254b | |||
| 23f715857c | |||
| f589182709 | |||
| ef573376ac | |||
| 34d9272790 | |||
| 96f7187031 | |||
| 664dcd4cd8 | |||
| 7f960b026b | |||
| 992d2b83b3 | |||
| a9505ca5b2 | |||
| dca51a41ef | |||
| 579449acc3 | |||
| 34592d9144 | |||
| 0adce52d37 | |||
| 501d493ca4 | |||
| ffb934fce6 | |||
| 440c1fe948 | |||
| aa62df9c75 | |||
| 15ac538a4b | |||
| 39cfc23ebe | |||
| 7a8cadc6ec | |||
| 91bd360125 | |||
| 7da960ac8c | |||
| ed51c39fe7 | |||
| c1efa7b94e | |||
| e31b84c08e | |||
| 258e3ec620 | |||
| 3336abb66f | |||
| a184ee0221 | |||
| 4509a2b6d3 | |||
| 7e38baa7b6 | |||
| aab7e5ae20 | |||
| a0fcdf74da | |||
| ebc782b16c | |||
| 7939dc92d4 | |||
| 7c06b27c73 | |||
| 02b2ce8109 | |||
| 26f998d243 | |||
| f816049fdf | |||
| eafa353364 | |||
| 353bb59393 | |||
| 1daa8aa9b1 | |||
| 5af024f7e7 | |||
| 84ce9843ff | |||
| efcee88f05 | |||
| 0b307037e0 | |||
| 67bd05fc96 | |||
| dd6b0bccb3 | |||
| a50d3e7ca7 | |||
| d3d2f9a448 | |||
| 98ad5674a8 | |||
| 30d0331174 | |||
| 24662fa994 | |||
| 230ef8ed7c | |||
| 160c09e525 | |||
| 589d45e0a0 | |||
| acd355bb5a | |||
| 6e59395fb8 | |||
| 9f4516c6a8 | |||
| 0bd2273bee | |||
| d37fa1584c | |||
| e17cb09269 | |||
| 4d55e45962 | |||
| e3532064b5 | |||
| 1e37b20c6a | |||
| 4f03775e04 | |||
| 9678b02aba | |||
| a926487f5e | |||
| ae1d30bc5a | |||
| a7d145aa70 | |||
| 472d4ba008 | |||
| 2a0478cad8 | |||
| cee0c591e2 | |||
| 68b6ffffd7 | |||
| 9bc8c4b47f | |||
| e80ebd35cb | |||
| 36343baecc | |||
| 89cf171efc | |||
| 149ec8e4e4 | |||
| d1cd963e4b | |||
| 5ef0a1fd3e | |||
| 6ace96f2cf | |||
| 2d71f2ce30 | |||
| 2c3dba55e6 | |||
| c7a04dcc70 | |||
| 4b14c15518 | |||
| c68ef346bf | |||
| c5d7fcc303 | |||
| 9bf56d5748 | |||
| d5ce56930b | |||
| 349194e7e5 | |||
| 24d6460e4c | |||
| 127e783f66 | |||
| 198fd12bb2 | |||
| 34d5209165 | |||
| 9684ab75bb | |||
| 0a6b035a67 | |||
| cbfd3e5632 | |||
| 3faf0866a0 | |||
| bab3a160c2 | |||
| 1778cd0009 | |||
| 5204766276 | |||
| 6218012d3f | |||
| ccb0c1d18e | |||
| 65e24bd446 | |||
| de6cecaffc | |||
| da545ba9b9 | |||
| 3c4842df1e | |||
| 1ee0f0b57a | |||
| 4fbbd9680b | |||
| 259a5a2b3e | |||
| 8d62be9eff | |||
| 63139350e4 | |||
| 33b33e685a | |||
| a8038bb534 | |||
| 4d0e34c4cf | |||
| 70ffd252bd | |||
| 51d468fbcc | |||
| 1c84556600 | |||
| 34997bcbd1 | |||
| 78cb2acd6c | |||
| ce8a03ab16 | |||
| 19feca4964 | |||
| adbda094e7 | |||
| 7013da70bc | |||
| 49d9410e3a | |||
| 84a2e7a93e | |||
| 950b8a8128 | |||
| af58f7a32c | |||
| 91c6f2f091 | |||
| 31cf353463 | |||
| 8912423aeb | |||
| bc85cd4984 |
@@ -1,2 +1 @@
|
|||||||
VITE_SENTRY_DSN=https://264a5e95c5d31fe080a2e92fb008294d@o4511430568378368.ingest.us.sentry.io/4511430571982849
|
|
||||||
VITE_APP_VERSION=lotus
|
VITE_APP_VERSION=lotus
|
||||||
|
|||||||
+24
-2
@@ -21,16 +21,38 @@ jobs:
|
|||||||
cache: npm
|
cache: npm
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
# Harden against transient registry network failures (ECONNRESET etc.):
|
||||||
|
# raise npm's built-in fetch retries/timeouts and retry `npm ci` up to
|
||||||
|
# 3 times with backoff before failing the build.
|
||||||
|
run: |
|
||||||
|
npm config set fetch-retries 5
|
||||||
|
npm config set fetch-retry-mintimeout 20000
|
||||||
|
npm config set fetch-retry-maxtimeout 120000
|
||||||
|
npm config set fetch-timeout 600000
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
echo "npm ci attempt $attempt…"
|
||||||
|
npm ci && break
|
||||||
|
if [ "$attempt" = "3" ]; then
|
||||||
|
echo "npm ci failed after 3 attempts" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "npm ci failed; retrying in $((attempt * 15))s…" >&2
|
||||||
|
sleep $((attempt * 15))
|
||||||
|
done
|
||||||
|
|
||||||
# ── Critical gate — if this fails, nothing deploys ──────────────────
|
# ── Critical gate — if this fails, nothing deploys ──────────────────
|
||||||
- name: Build
|
- name: Build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
|
||||||
VITE_APP_VERSION: ${{ github.sha }}
|
VITE_APP_VERSION: ${{ github.sha }}
|
||||||
|
|
||||||
|
# Unit tests are a hard gate too — deterministic pure-logic tests on Node's
|
||||||
|
# built-in runner via tsx (no vitest — Vite 8 is ahead of vitest's range).
|
||||||
|
# A failure blocks the deploy.
|
||||||
|
- name: Unit tests
|
||||||
|
run: npm test
|
||||||
|
|
||||||
# ── Quality checks (informational — pre-existing issues exist) ───────
|
# ── Quality checks (informational — pre-existing issues exist) ───────
|
||||||
- name: TypeScript
|
- name: TypeScript
|
||||||
run: npm run typecheck
|
run: npm run typecheck
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
legacy-peer-deps=true
|
legacy-peer-deps=true
|
||||||
save-exact=true
|
save-exact=true
|
||||||
|
@lotusguild:registry=https://code.lotusguild.org/api/packages/LotusGuild/npm/
|
||||||
-490
@@ -1,490 +0,0 @@
|
|||||||
# Lotus Chat — Bug Report & Technical Audit
|
|
||||||
|
|
||||||
**Date:** June 2026
|
|
||||||
|
|
||||||
This document tracks identified bugs, edge cases, and architectural discrepancies found during the audit of the Lotus Chat codebase. Recommended fixes are provided for each item.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚩 Critical & UI Bugs
|
|
||||||
|
|
||||||
### 12. PiP Mute Icon Misidentifies Whose Mic Is Muted
|
|
||||||
|
|
||||||
- **File:** `cinny/src/app/components/CallEmbedProvider.tsx`
|
|
||||||
- **Status:** **FIXED ⚠️ UNTESTED** — needs verification in a live call with at least one other participant who mutes/unmutes
|
|
||||||
- **Issue:** The muted-mic badge in the Picture-in-Picture window used `useRemoteAllMuted` (fires when ANY remote participant is muted) and rendered in the bottom-left corner — the conventional position for "YOUR" mic status. Users read it as their own mic being muted.
|
|
||||||
- **Root Cause:** `PipMuteOverlay` was triggering on remote-mute events while displaying in a position that implies local-user status.
|
|
||||||
- **Fix Applied:**
|
|
||||||
- **Bottom-left badge** now shows only when the LOCAL user's mic is muted (checked via `!controlState.microphone` from `useCallControlState`). Includes "You" label to make it unambiguous. Uses `color.Critical.Main`.
|
|
||||||
- **Top-right badge** (new) shows "All muted" in `color.Warning.Main` when all remote participants are muted — positioned and labeled so it's clearly about other people, not the local user.
|
|
||||||
- Both badges use `aria-label` / `title` for accessibility.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 1. No Camera Focus During Screenshare
|
|
||||||
|
|
||||||
- **File:** `cinny/src/app/plugins/call/CallControl.ts`, `cinny/src/app/features/call-status/MemberGlance.tsx`
|
|
||||||
- **Status:** **FIXED ⚠️ UNTESTED** — needs verification in a live call with an active screenshare + a participant on camera
|
|
||||||
- **Issue:** Automatic screenshare spotlighting forces primary display override, preventing users from manually focusing on camera feeds.
|
|
||||||
- **Root Cause:** Before this feature there was no UI path to manually pick a camera to focus, so EC's auto-spotlight (which prioritizes an active screenshare) always won.
|
|
||||||
- **Fix Applied:** `CallControl.focusCameraParticipant(userId)` switches EC to spotlight mode and clicks that participant's `[data-testid="videoTile"]` inside the EC iframe — in Element Call, clicking a tile in spotlight **pins** it, so the user's explicit selection takes precedence over the auto-pinned screenshare. Exposed via a "Focus camera" item in the `MemberGlance` participant menu (avatar → menu). Falls back to a plain spotlight toggle if the tile isn't rendered (e.g. camera off).
|
|
||||||
- **Architectural note:** EC owns the grid/spotlight renderer inside its iframe; our control is DOM-level tile clicks. The pin persists until changed, so a one-shot focus is sufficient. A continuously-enforced "sticky" focus that re-pins on every EC spotlight change was deliberately **not** built — it would require fighting EC's internal state on each mutation and risks flicker.
|
|
||||||
|
|
||||||
### 2. Chat Background Animation Flickering
|
|
||||||
|
|
||||||
- **File:** `cinny/src/app/features/lotus/chatBackground.ts`
|
|
||||||
- **Status:** **FIXED ⚠️ UNTESTED** — needs verification on a real device with an animated background active
|
|
||||||
- **Issue:** Animated background properties cause visible flickering on message text and the composer area, particularly on browsers/GPUs susceptible to repaint-induced artifacts.
|
|
||||||
- **Root Cause:** Animation triggers excessive repaints or layout recalculations on descendant elements, likely due to animating non-GPU accelerated properties on parent containers without proper rendering context isolation.
|
|
||||||
- **Fix Applied:** `getChatBg()` now injects `willChange: 'background-position'` and `contain: 'paint'` for any animated variant. This promotes the element to its own compositor layer and isolates repaints from descendants. Background-position animation is already GPU-hinted on modern browsers; `contain: paint` prevents descendant elements from being invalidated during each frame.
|
|
||||||
|
|
||||||
### 3. Avatar Decorations in Element Call
|
|
||||||
|
|
||||||
- **File:** `cinny/src/app/features/call/CallMemberCard.tsx`
|
|
||||||
- **Status:** **FIXED ⚠️ UNTESTED** — needs verification in a live call with a participant who has a decoration set
|
|
||||||
- **Issue:** Avatar decorations are failing to render within the call/room interface member lists.
|
|
||||||
- **Root Cause:** Member lists and the people drawer already wrapped avatars in `<AvatarDecoration userId={...}>`, but the call participant tile (`CallMemberCard`) rendered a bare `<UserAvatar>` with no decoration wrapper — so decorations were absent specifically on call tiles. (Note: avatars rendered _inside_ the Element Call iframe are EC-rendered and out of our control; this fix covers our own participant roster / prescreen.)
|
|
||||||
- **Fix Applied:** Wrapped the call-tile avatar in `<AvatarDecoration userId={userId}>` (commit `0394fce9`), matching the member-list pattern.
|
|
||||||
|
|
||||||
### 4. DM and Group Message Calls
|
|
||||||
|
|
||||||
- **File:** `cinny/src/app/components/CallEmbedProvider.tsx`
|
|
||||||
- **Status:** **FIXED ⚠️ UNTESTED** — needs live-call verification: (a) ring/preview per selected ringtone & volume; (b) the corner banner appearing (with a single ping, not a loop) when a second call arrives while already in a call.
|
|
||||||
- **Issue:** Incoming call ringtone is hardcoded, lacks volume control, and is suppressed if the user is already in an active call.
|
|
||||||
- **Root Cause:** Ringing logic is tightly coupled to `RTCNotification` events in `CallEmbedProvider.tsx`, using a hardcoded audio file path. It lacks an abstraction for sound management or user-configurable settings for ringtones/volumes.
|
|
||||||
- **Fix Applied:**
|
|
||||||
- `ringtoneVolume` setting (0–100, default 70); applied to the ring. Slider in Settings → General → Calls.
|
|
||||||
- **(a) Ringtone selection** (`4a875884`): `ringtoneId` setting (`classic | chime | soft | retro | none`). New `utils/ringtones.ts` synthesizes the three styles in-browser (WebAudio, mirroring `callSounds.ts`) — no new binary assets; `classic` keeps `call.ogg`; `none` is silent/visual-only. `startRingtone()` loops until stopped; `previewRingtone()` powers the on-select preview in Settings. Persisted id is whitelisted in `getSettings`.
|
|
||||||
- **(b) Active-call notification** (`c67aed01`): when already joined to a _different_ call, a compact, non-intrusive `IncomingCallBanner` (caller avatar + name + Answer/Reject, top-right) replaces the full-screen `IncomingCall` overlay and plays a **single soft ping** (one-shot ringtone) instead of the looping ring — so it never takes over the screen or talks over the active call. Full overlay still shows when in no call; being in the ringing room's own call still shows nothing.
|
|
||||||
|
|
||||||
### 5. Seasonal Themes and Chat Backgrounds Design
|
|
||||||
|
|
||||||
- **File:** `cinny/src/app/hooks/useTheme.ts`, `cinny/src/app/features/lotus/chatBackground.ts`
|
|
||||||
- **Status:** **OPEN**
|
|
||||||
- **Issue:** Basic CSS or random moving lines are insufficient for high-fidelity wallpaper/theming. They lack professional design theory, coherence, and aesthetic depth.
|
|
||||||
- **Root Cause:** Current implementation relies on basic CSS, lacks advanced design theory, and does not leverage modern, performant CSS wallpaper techniques.
|
|
||||||
- **Proposed Fix (Extreme Depth Redesign):**
|
|
||||||
- **Research-Backed Implementation:** Implement advanced design techniques (layered `oklch` gradients, `backdrop-filter` for refractive "liquid glass" effects, GPU-accelerated `transform` animations) to create living, breathing backgrounds.
|
|
||||||
- **Performance Optimization:** Ensure all animations strictly use compositor-thread properties (`transform`, `opacity`) and apply `contain: paint` / `will-change: transform` to prevent layout thrashing/flickering.
|
|
||||||
- **Design Resources (Examples/Inspiration):**
|
|
||||||
- [Uiverse.io Patterns](https://uiverse.io/patterns)
|
|
||||||
- [MagicPattern CSS Backgrounds](https://www.magicpattern.design/tools/css-backgrounds)
|
|
||||||
- [Prismic Blog: CSS Background Effects](https://prismic.io/blog/css-background-effects)
|
|
||||||
- [CSS-Pattern.com](https://css-pattern.com) (Pure CSS pattern library)
|
|
||||||
- [BGJar](https://bgjar.com) (Performance-focused generators)
|
|
||||||
- **Goal:** Treat each theme/background as a week-long development sprint to ensure professional polish, WCAG AA contrast compliance for overlaying UI, and seamless integration with the Lotus TDS.
|
|
||||||
|
|
||||||
### 6. Exclusive Background vs. Seasonal Choice
|
|
||||||
|
|
||||||
- **File:** `cinny/src/app/state/settings.ts`
|
|
||||||
- **Status:** **FIXED ⚠️ UNTESTED** — needs verification: (a) pick a background, confirm seasonal theme auto-clears; (b) pick a seasonal theme, confirm background auto-clears; (c) set both via old localStorage data and reload, confirm SeasonalEffect guard suppresses the overlay
|
|
||||||
- **Issue:** Concurrent application of both Chat Backgrounds and Seasonal Themes causes visual clutter and high GPU usage.
|
|
||||||
- **Root Cause:** These are currently handled as independent settings in the `settingsAtom` and applied simultaneously without mutual exclusion.
|
|
||||||
- **Fix Applied:** Mutual exclusion enforced at two layers: (1) `General.tsx` — ChatBgGrid clears seasonalThemeOverride→'off' when any non-'none' background is picked; SeasonalBgGrid clears chatBackground→'none' when any real seasonal theme is selected. (2) `SeasonalEffect.tsx` — runtime guard returns null if `chatBackground !== 'none'`, protecting against legacy persisted state.
|
|
||||||
|
|
||||||
### 7. Tiny Touch Targets in Composer Toolbar
|
|
||||||
|
|
||||||
- **File:** `cinny/src/app/features/room/RoomInput.tsx`
|
|
||||||
- **Status:** **FIXED ⚠️ UNTESTED** — needs verification on a real mobile device: open composer, confirm all toolbar buttons are tappable without mis-taps
|
|
||||||
- **Issue:** Toolbar buttons have hit areas smaller than the WCAG-recommended 44x44px for touch, hindering mobile accessibility.
|
|
||||||
- **Fix Applied:** Added `touchTarget = { minWidth: '44px', minHeight: '44px' }` computed from `mobileOrTablet()` and applied as `style={touchTarget}` to all 8 composer toolbar `IconButton` elements (attach, format, sticker, emoji, GIF, location, poll, schedule, send).
|
|
||||||
|
|
||||||
### 8. Horizontal Overflow in Room Settings
|
|
||||||
|
|
||||||
- **File:** `cinny/src/app/components/page/style.css.ts`
|
|
||||||
- **Status:** **FIXED ⚠️ UNTESTED** — needs verification: open Room Settings on a narrow mobile screen, confirm nav panel fills full width and no horizontal scrollbar appears
|
|
||||||
- **Issue:** Wide tables and input elements in room settings cause horizontal overflow on mobile viewports.
|
|
||||||
- **Fix Applied:** Added `@media (max-width: 750px) { width: '100%' }` to both `'400'` and `'300'` size variants of the `PageNav` vanilla-extract recipe in `style.css.ts`.
|
|
||||||
|
|
||||||
### 9. Modal Float-Style Responsiveness
|
|
||||||
|
|
||||||
- **File:** Multiple modal files
|
|
||||||
- **Status:** **FIXED ⚠️ UNTESTED** — needs verification by opening each modal on a real mobile device
|
|
||||||
- **Issue:** Modals appear as floating boxes on mobile, creating navigation and readability challenges.
|
|
||||||
- **Fix Applied:** Created `useModalStyle(desktopMaxWidth)` hook (`src/app/hooks/useModalStyle.ts`) that returns fullscreen styles on mobile (no border-radius, no max-width, `height: 100%`) and desktop box styles otherwise. Applied to all 22+ modal files: `LeaveRoomPrompt`, `LeaveSpacePrompt`, `ReportRoomModal`, `ReportUserModal`, `DeviceVerification`, `InviteUserPrompt`, `LogoutDialog`, `DeviceVerificationSetup`, `DeviceVerificationReset`, `JoinAddressPrompt`, `JumpToTime`, `EditHistoryModal`, `ForwardMessageDialog`, `RemindMeDialog`, `CreateRoomModal`, `CreateSpaceModal`, `ScheduleMessageModal`, `PollCreator`, `AddExistingModal`, `RoomEncryption`, `RoomUpgrade`, `Modal500`, `ReadReceiptAvatars`, `RoomTopicViewer`.
|
|
||||||
- **Note:** `UIAFlowOverlay` already fullscreen via `<Overlay>` — no change needed. `JoinRulesSwitcher`/`RoomNotificationSwitcher` are dropdowns, not modals.
|
|
||||||
|
|
||||||
### 10. Composer Keyboard Obscurity
|
|
||||||
|
|
||||||
- **File:** `src/index.css`
|
|
||||||
- **Status:** **FIXED ⚠️ UNTESTED** — needs verification on iOS Safari specifically (the worst offender); on Android Chrome `100dvh` has been standard since Chrome 108
|
|
||||||
- **Issue:** The chat composer is often partially or fully obscured by the virtual keyboard on mobile.
|
|
||||||
- **Fix Applied:** Added `height: 100dvh` (dynamic viewport height) to `html` alongside the existing `height: 100%` fallback. `dvh` updates when the software keyboard appears, ensuring the layout shrinks correctly and the composer stays visible.
|
|
||||||
|
|
||||||
### 11. Inline Jotai atom creation
|
|
||||||
|
|
||||||
- **File:** `cinny/src/app/hooks/useSpaceHierarchy.ts`
|
|
||||||
- **Status:** **FALSE POSITIVE — CLOSED**
|
|
||||||
- **Issue:** Inline Jotai atom creation in a hook risks re-rendering components unnecessarily.
|
|
||||||
- **Resolution:** `useState(() => atom(...))` IS the correct Jotai pattern for local stable atom references. The factory function form of `useState` ensures the atom is created only once per component mount. No change warranted.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📦 Barrel File Audit
|
|
||||||
|
|
||||||
| File Path | Note | Status |
|
|
||||||
| :------------------------------------------ | :------------------------- | :----- |
|
|
||||||
| `cinny/src/app/plugins/call/index.ts` | Extensive `export *` usage | OPEN |
|
|
||||||
| `cinny/src/app/plugins/text-area/index.ts` | Extensive `export *` usage | OPEN |
|
|
||||||
| `cinny/src/app/components/message/index.ts` | Extensive `export *` usage | OPEN |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔍 Technical & Performance Refinements
|
|
||||||
|
|
||||||
| Category | Issue Description | File Path | Status |
|
|
||||||
| :---------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------- | :---------------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| State Sync | Fire-and-forget network call to set offline presence during `pagehide` event may not complete reliably, potentially causing UI drift in presence status. | `cinny/src/app/hooks/usePresenceUpdater.ts` | FIXED (`d2946c00`) — unload path now uses `fetch({ keepalive: true })` so the request survives page teardown (`sendBeacon` was unusable here: it can't set the auth header). |
|
|
||||||
| State Sync | Fire-and-forget network call `setPresence().catch(...)` suppresses errors, meaning the app may falsely assume presence update success. | `cinny/src/app/hooks/usePresenceUpdater.ts` | FIXED (`d2946c00`) — errors are now surfaced via `warnPresenceFailure` (redacted logging) instead of being silently swallowed. |
|
|
||||||
| Memory Leak | Decrypted Media Memory Leak (Gallery & Lightbox) due to missing virtualization and blob revocation. | `cinny/src/app/features/room/MediaGallery.tsx` | PARTIALLY FIXED ⚠️ UNTESTED — Blob revocation was already correct; added `enabled` param to `useDecryptedMediaUrl` and `useNearViewport(300px)` to each `GalleryTile` to gate decryption until near-viewport, reducing burst on pagination. True virtualization (windowing) deferred — requires significant refactor. |
|
|
||||||
| Data Persistence | Scheduled Messages are ephemeral (lost on refresh) due to fragile `localStorage` parsing. | `cinny/src/app/state/scheduledMessages.ts` | FIXED — now uses `atomWithStorage` + `createJSONStorage` (Jotai's built-in persistence with error-safe JSON parsing) |
|
|
||||||
| Memory Leak | Potential memory leak due to uncleaned `handleMouseMove` listener in `usePan`. | `cinny/src/app/hooks/usePan.ts` | FALSE POSITIVE — `usePan` already uses `attachedRef` to track listeners and cleans them up in an unmount `useEffect`. No change needed. |
|
|
||||||
| Asset Optimization | Large unoptimized media asset (213KB) found in `public/res`. | `public/res/Lotus.png` | OPEN |
|
|
||||||
| Data Persistence | Non-atomic `localStorage` updates in session management can lead to inconsistent state. | `cinny/src/app/state/sessions.ts` | OPEN |
|
|
||||||
| Data Persistence | Lack of cross-tab synchronization for `localStorage` updates in session management risks race conditions. | `cinny/src/app/state/sessions.ts` | OPEN |
|
|
||||||
| Network Resilience | `uploadContent` lacks retry logic, failing immediately upon network error. | `cinny/src/app/utils/matrix.ts` | FIXED (`d2946c00`) — bounded retry (`UPLOAD_MAX_RETRY_COUNT=3`) gated by `isRetryableUploadError` (transient/network/5xx/429 only, not 4xx), reusing the `rateLimitedActions` capped-exponential backoff. |
|
|
||||||
| Network Resilience | `rateLimitedActions` uses basic retry logic without exponential backoff, which may exacerbate 429 issues. | `cinny/src/app/utils/matrix.ts` | FIXED — fallback delay now uses capped exponential backoff (`min(1000 * 2^retryCount, 30_000)ms`) when server doesn't send `Retry-After`; server header still takes precedence via `getRetryAfterMs()`. |
|
|
||||||
| Matrix Event Robustness | `useMatrixEventRenderer` handles unknown events gracefully by returning `null`, which may hide potentially important unrendered data. | `cinny/src/app/hooks/useMatrixEventRenderer.ts` | FALSE POSITIVE — returning `null` for unrendered types is the intended contract. Callers opt into rendering unknowns via the `renderStateEvent` / `renderEvent` fallback params; `null` only results when the caller deliberately supplies no fallback. No change warranted. |
|
|
||||||
| Data Contract | `MatrixError` instantiation with `UploadResponse` might be brittle. | `cinny/src/app/utils/matrix.ts` | FIXED (`d2946c00`) — replaced the brittle direct construction with `matrixErrorFromUploadResponse` / `matrixErrorFromUnknown` guards that validate shape before building a `MatrixError`. |
|
|
||||||
| Type Safety | `addRoomIdToMDirect` uses `as any` cast for `AccountDataEvent.Direct`, bypassing type contract validation. | `cinny/src/app/utils/matrix.ts` | FIXED (`d2946c00`) — `addRoomIdToMDirect` / `removeRoomIdFromMDirect` now use `EventType.Direct` + a typed `MDirectContent`, dropping the `as any` cast. |
|
|
||||||
| Robustness | `rateLimitedActions` relies on `MatrixError.httpStatus` which might not exist on all error variants. | `cinny/src/app/utils/matrix.ts` | FALSE POSITIVE — `MatrixError.httpStatus` is defined as `readonly httpStatus?: number` in `matrix-js-sdk/lib/http-api/errors.d.ts`. It is optional (not on all instances) but the `?.` optional chain already guards against undefined. No change needed. |
|
|
||||||
| Type Contract | Custom types in `cinny/src/types/matrix` mirror SDK types instead of using them, risking drift and contract mismatches. | `cinny/src/types/matrix/` | OPEN |
|
|
||||||
|
|
||||||
## 🏗️ Architectural & Hygiene Audit
|
|
||||||
|
|
||||||
| Category | Issue Description | File Path | Status |
|
|
||||||
| :------- | :--------------------------------------------------------------- | :-------- | :----- |
|
|
||||||
| Hygiene | No stale development notes or TypeScript strictness issues found | N/A | OPEN |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🏗️ TDS Compliance & Styling Issues
|
|
||||||
|
|
||||||
| Issue Description | File Path |
|
|
||||||
| :------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| Hardcoded inline style `cursor: 'pointer'` | `cinny/src/app/plugins/react-custom-html-parser.tsx` |
|
|
||||||
| Hardcoded color `#00D4FF`, `#FFB300` ✅ **VERIFIED COMPLIANT** | `cinny/src/app/components/event-readers/EventReaders.tsx` |
|
|
||||||
| Hardcoded color `#EE1D52`, `#9146ff`, `#ff4500`, `#cb3837`, `#f48024` ⚠️ **BRAND EXCEPTION** | `cinny/src/app/components/url-preview/UrlPreviewCard.tsx` + `UrlPreview.css.tsx` — official third-party brand colors in SVG logos and site badge backgrounds; cannot convert to CSS variables without inventing new tokens (violates TDS rule 3) |
|
|
||||||
| Massive number of hardcoded `backgroundColor` values ⚠️ **PATTERN CONTENT EXCEPTION** | `cinny/src/app/features/lotus/chatBackground.ts` — each background's base color is aesthetic content that defines the pattern identity; converting requires inventing 40+ CSS variables (violates TDS rule 3) or using CSS4 `relative-color-syntax` in inline styles (insufficient browser support); these are visual content, not UI chrome |
|
|
||||||
| Hardcoded colors `#00FF88`, `#FF6B00` ✅ **VERIFIED COMPLIANT** | `cinny/src/app/features/call/CallControls.tsx` |
|
|
||||||
| Hardcoded fallback hexes in toast colors ✅ **FIXED** | `cinny/src/app/features/toast/LotusToastContainer.tsx` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🌐 Localization, Accessibility & Performance
|
|
||||||
|
|
||||||
| Category | Issue Description | File Path | Status |
|
|
||||||
| :------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| Localization | Hardcoded UI string: "Chat Room" | `src/app/components/create-room/CreateRoomTypeSelector.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
|
|
||||||
| Localization | Hardcoded UI string: "Messages, photos, and videos." | `src/app/components/create-room/CreateRoomTypeSelector.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
|
|
||||||
| Localization | Hardcoded UI string: "Voice Room" | `src/app/components/create-room/CreateRoomTypeSelector.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
|
|
||||||
| Localization | Hardcoded UI string: "Live audio and video conversations." | `src/app/components/create-room/CreateRoomTypeSelector.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
|
|
||||||
| Localization | Hardcoded UI string: "Download" | `src/app/components/image-viewer/ImageViewer.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
|
|
||||||
| Localization | Hardcoded UI string: "Open Location" | `src/app/components/message/MsgTypeRenderers.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
|
|
||||||
| Localization | Hardcoded UI string: "Thread" | `src/app/components/message/Reply.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
|
|
||||||
| Localization | Hardcoded UI string: "View" | `src/app/components/message/content/ImageContent.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
|
|
||||||
| Localization | Hardcoded UI string: "Spoiler" | `src/app/components/message/content/ImageContent.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
|
|
||||||
| Localization | Hardcoded UI string: "Retry" | `src/app/components/message/content/ImageContent.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
|
|
||||||
| Localization | Hardcoded UI string: "Close" | `src/app/components/DeviceVerification.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
|
|
||||||
| Localization | Hardcoded UI string: "Accept" | `src/app/components/DeviceVerification.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
|
|
||||||
| Localization | Hardcoded UI string: "They Match" | `src/app/components/DeviceVerification.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
|
|
||||||
| Localization | Hardcoded UI string: "Okay" | `src/app/components/DeviceVerification.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
|
|
||||||
| Localization | Hardcoded UI string: "Join Server" | `src/app/components/url-preview/UrlPreviewCard.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
|
|
||||||
| Localization | Hardcoded UI string: "Invite" | `src/app/components/invite-user-prompt/InviteUserPrompt.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
|
|
||||||
| Localization | Hardcoded UI string: "Files" | `src/app/components/upload-board/UploadBoard.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
|
|
||||||
| Localization | Hardcoded UI string: "Send" | `src/app/components/upload-board/UploadBoard.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
|
|
||||||
| Localization | Hardcoded UI string: "Upload Failed" | `src/app/components/upload-board/UploadBoard.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
|
|
||||||
| Localization | Hardcoded UI string: "Password" | `src/app/components/uia-stages/PasswordStage.tsx` | FIXED (`1a5896ef`) — wrapped in `t()` + key added to `en.json` |
|
|
||||||
| Bundle Size | Large unoptimized media asset (213KB) | `public/res/Lotus.png` | OPEN |
|
|
||||||
| Matrix Logic | Inefficient repeated `mx.getRoom()` calls in component render loops | `src/app/features/lobby/Lobby.tsx` | FALSE POSITIVE — `Lobby` already routes its render loop through the memoized `useGetRoom(allJoinedRooms)`. The two remaining `mx.getRoom()` calls are inside drag/drop event handlers (not render loops) and are O(1) SDK map lookups. No change warranted. |
|
|
||||||
| Matrix Logic | Inefficient repeated `mx.getRoom()` calls in component render loops | `src/app/components/emoji-board/EmojiBoard.tsx` | FIXED (`b7e1f89c`) — pack-label `mx.getRoom()` lookups in `EmojiSidebar`/`StickerSidebar` hoisted into a `useMemo`'d `Map` built once per pack list. |
|
|
||||||
| Performance | Numerous event handlers (e.g., handleUserClick, handleReplyClick) lack `useCallback`, leading to unnecessary re-renders of message components. | `cinny/src/app/features/room/RoomTimeline.tsx` | FIXED (`b7e1f89c`) — `handleJumpToLatest`/`handleJumpToUnread`/`handleMarkAsRead` wrapped in `useCallback`. |
|
|
||||||
| Performance | The `submit` function and file handling callbacks (e.g., handleSendUpload) are re-created on every render, causing re-renders of the editor and toolbar components. | `cinny/src/app/features/room/RoomInput.tsx` | FIXED (`b7e1f89c`) — `handleCancelUpload`/`handleSendUpload`/`handleShareLocation`/`handleEmoticonSelect`/`handleStickerSelect` wrapped in `useCallback`. |
|
|
||||||
| Accessibility | `button` for edit history lacks `aria-label` | `cinny/src/app/components/message/content/FallbackContent.tsx` | FIXED ⚠️ UNTESTED — added `aria-label="View edit history"` |
|
|
||||||
| Accessibility | `button` for reaction lacks `aria-label` | `cinny/src/app/components/message/Reaction.tsx` | **FIXED ⚠️ UNTESTED** — `Reaction` component now computes `aria-label="{shortcode} reaction, N people"` internally using `getShortcodeFor`; custom (mxc://) emoji falls back to "custom emoji reaction". |
|
|
||||||
| Accessibility | `button` for ThreadIndicator lacks `aria-label` | `cinny/src/app/components/message/Reply.tsx` | FIXED ⚠️ UNTESTED — added `aria-label="View thread"` |
|
|
||||||
| Accessibility | `button` for ReplyLayout lacks `aria-label` | `cinny/src/app/components/message/Reply.tsx` | FIXED ⚠️ UNTESTED — added `aria-label="Jump to original message"` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 Infrastructure, DevEx & Type Safety
|
|
||||||
|
|
||||||
| Category | Issue Description | File Path | Status |
|
|
||||||
| :------------- | :----------------------------------------------------------------------------------------------------------- | :--------------------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| Dependencies | `lodash` pinned to non-existent version `4.18.1` | `cinny/package.json` | OPEN |
|
|
||||||
| Dependencies | Various pinned versions of `@atlaskit`, `matrix-js-sdk` | `cinny/package.json` | OPEN |
|
|
||||||
| Dependencies | `matrix-js-sdk` pinned to Release Candidate (`41.6.0-rc.0`) | `cinny/package.json` | OPEN |
|
|
||||||
| Dependencies | Unstable/experimental versions for build tools (`vite` 8.0.14, `typescript` 6.0.3, `eslint` 9.39.4) | `cinny/package.json` | OPEN |
|
|
||||||
| CI/CD | `package-manager-cache` set to `false` | `cinny/.github/workflows/build-pull-request.yml` | OPEN |
|
|
||||||
| CI/CD | Inefficient sequential execution in deployment | `cinny/.github/workflows/prod-deploy.yml` | OPEN |
|
|
||||||
| CI/CD | Aggressive 1-minute timeout for Netlify deploy | `cinny/.github/workflows/prod-deploy.yml` | OPEN |
|
|
||||||
| DevEx | Stale upstream bug tracker link/donations/CLA | `cinny/CONTRIBUTING.md` | OPEN |
|
|
||||||
| DevEx | Alignment issue between README and CONTRIBUTING | `cinny/README.md` | OPEN |
|
|
||||||
| Testing | No evident automated testing configuration/files | `cinny/src/` | OPEN |
|
|
||||||
| Type Safety | Extensive use of `as any` type assertions | `cinny/src/` | OPEN |
|
|
||||||
| Security | Hardcoded public CDN URL; consider moving to environment variable | /root/code/cinny/scripts/syncDecorations.mjs | OPEN |
|
|
||||||
| Architecture | Modifying node_modules directly is brittle; use patch-package instead | /root/code/cinny/scripts/patch-folds.mjs | OPEN |
|
|
||||||
| Robustness | Missing security headers (HSTS, CSP, etc.) and inefficient asset serving using rewrites instead of try_files | /root/code/cinny/contrib/nginx/cinny.domain.tld.conf | OPEN |
|
|
||||||
| Robustness | Incomplete documentation/placeholder path in Caddyfile | /root/code/cinny/contrib/caddy/caddyfile | OPEN |
|
|
||||||
| Matrix SDK | Inefficient listener management (`setMaxListeners: 150`) and incomplete SDK state transition handling. | `src/client/initMatrix.ts` | OPEN |
|
|
||||||
| PWA Robustness | Service worker lacks caching strategy for application assets, resulting in no offline capability. | `cinny/src/sw.ts` | OPEN |
|
|
||||||
| PWA Integrity | `manifest: false` in `vite.config.js` might prevent correct PWA installation if not handled externally. | `cinny/vite.config.js` | OPEN |
|
|
||||||
| PII Leakage | Potential PII exposure via console.error (parameter e likely contains event data). | `cinny/src/app/plugins/call/CallEmbed.ts` | VERIFIED COMPLIANT — reviewed during the logging pass (`203568c9`); the existing log path already records only `e.message`, not raw event payloads. No change needed. |
|
|
||||||
| PII Leakage | Potential PII exposure via console.warn (parameter imgError/videoError/thumbError object). | `cinny/src/app/features/room/msgContent.ts` | FIXED (`203568c9`) — media-error warnings now log only `error.name` + `error.message`, never the raw error/event object. |
|
|
||||||
| PII Leakage | Potential PII exposure via console.error (parameter e likely contains event data). | `cinny/src/app/features/room/RoomInput.tsx` | VERIFIED COMPLIANT — reviewed during the logging pass (`203568c9`); the existing log path already records only `e.message`. No change needed. |
|
|
||||||
|
|
||||||
## 🏗️ Architectural & Resilience Audit
|
|
||||||
|
|
||||||
| Category | Issue Description | File Path | Status |
|
|
||||||
| :----------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| Element Call Integration | Lacks robust iframe failure monitoring beyond initial 'preparing' event; can result in a permanently hung 'Loading...' state with no user-visible error or recovery path. | `src/app/plugins/call/CallEmbed.ts` | FIXED (`0394fce9`) — added a `CALL_LOAD_WATCHDOG_MS` (25s) timeout that settles on ready/capabilities/joined and fails on iframe error/timeout, exposing a `loadFailed` getter + `onLoadError(cb)`. `CallView` renders a `CallLoadErrorMessage` overlay (Retry/Leave) instead of a permanent spinner. ⚠️ UNTESTED — needs a live call. |
|
|
||||||
| Component Resilience | `RoomTimeline` has no `ErrorBoundary` wrapper — a single malformed event crashing the renderer takes down the entire timeline with no fallback UI. | `src/app/features/room/RoomTimeline.tsx` | FALSE POSITIVE — `RoomView.tsx` (lines 113–137) already wraps `<RoomTimeline>` in a react-error-boundary `ErrorBoundary` with a "Timeline unavailable" fallback. A wave-1 agent's redundant nested boundary was reverted. No change needed. |
|
|
||||||
| Component Resilience | `RoomInput` has no `ErrorBoundary` wrapper — a crash in the composer leaves users unable to send messages. | `src/app/features/room/RoomInput.tsx` | FALSE POSITIVE — `RoomView.tsx` (lines 151–171) already wraps `<RoomInput>` in an `ErrorBoundary` with a "Message composer encountered an error" `RoomInputPlaceholder` fallback. No change needed. |
|
|
||||||
| Fallback Logic | No explicit empty/error fallback for Matrix SDK data calls in `RoomTimeline`; relies purely on SDK internal error propagation, meaning silent failures show a blank timeline. | `src/app/features/room/RoomTimeline.tsx` | ADDRESSED — the `RoomView` `ErrorBoundary` (above) provides the explicit render-error fallback; a thrown SDK/render error now surfaces "Timeline unavailable" rather than a blank timeline. |
|
|
||||||
| Dependency | Potential for complex dependency chains due to deep nesting in `src/app/features/` and `src/app/hooks/`. | `src/app/` | OPEN |
|
|
||||||
| Hydration/Race Condition | The SyncState listener registered by useSyncState may miss the initial 'PREPARED' event if the client initializes synchronously from IndexedDB before the effect runs, leading to an infinite loading state. | `cinny/src/app/pages/client/ClientRoot.tsx` | OPEN |
|
|
||||||
| Structure | High number of small, highly coupled utility hooks (`src/app/hooks/`) may obscure dependency graphs. | `src/app/hooks/` | OPEN |
|
|
||||||
| Dead Code | Potential for unused CSS modules or UI components in `src/app/features/`. | `src/app/` | OPEN |
|
|
||||||
| Security | Sensitive session data (access tokens, device ID) stored in `localStorage` is vulnerable to XSS. | `src/app/state/sessions.ts` | OPEN |
|
|
||||||
| Privacy | Sensitive user status messages and expiry timestamps are persisted in `localStorage`. | `src/app/features/settings/account/Profile.tsx` | OPEN |
|
|
||||||
| Privacy | Unsent composer drafts stored in `localStorage` without encryption could leak info on shared devices. | `src/app/features/room/RoomInput.tsx` | OPEN |
|
|
||||||
| Persistence | Scheduled messages relying on fragile `localStorage` parsing are prone to data loss on session expiry or error. | `src/app/state/scheduledMessages.ts` | OPEN |
|
|
||||||
| Bundle Bloat | Inefficient `lodash` import; risks including entire library instead of necessary utilities. | `cinny/package.json` | OPEN |
|
|
||||||
| Bundle Bloat | Large `matrix-js-sdk` (RC version) dependency; high potential for tree-shaking overhead. | `cinny/package.json` | OPEN |
|
|
||||||
| Build-Time Overhead | `lotusDenoise` plugin performs heavy, sequential `fs` operations during `closeBundle`, significantly slowing build times. | `cinny/vite.config.js` | OPEN |
|
|
||||||
| Build-Time Overhead | Complex manual `viteStaticCopy` configuration requiring multiple renames and path manipulations; risks redundant processing. | `cinny/vite.config.js` | OPEN |
|
|
||||||
| Architectural Debt | Redundant style variant logic in `SpacingVariant` could be simplified. | `cinny/src/app/components/message/layout/layout.css.ts` | OPEN |
|
|
||||||
| Overhead Analysis | Potential CSS bloat from `DropTarget` composition across multiple recipes (`SidebarItem`, `SidebarFolder`). | `cinny/src/app/components/sidebar/Sidebar.css.ts` | OPEN |
|
|
||||||
|
|
||||||
## 🏗️ Git Workflow & History Audit
|
|
||||||
|
|
||||||
| Category | Issue Description | File Path | Status |
|
|
||||||
| :------- | :------------------------------------------------------------------------------------------------------ | :---------- | :----- |
|
|
||||||
| Workflow | Monolithic "Fix all bugs" commits (e.g., `10f6544e`, `aa48c9ef`) make `git bisect` difficult. | Git History | OPEN |
|
|
||||||
| Workflow | Inconsistent commit message prefixes (e.g., `fix`, `feat`, `docs`, `assets`). | Git History | OPEN |
|
|
||||||
| Workflow | Use of `fix` or `feat` for large-scale changes affecting multiple disparate systems (e.g., `938ead79`). | Git History | OPEN |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎨 Native UI/UX Consistency — Lotus vs. Cinny Baseline
|
|
||||||
|
|
||||||
> Audit of every Lotus-custom UI feature against Cinny's native folds design-system conventions. "Native pattern" means the `folds` component library, vanilla-extract tokens (`color.*`, `config.radii.*`, `config.space.*`), and established Cinny component patterns. 52 findings, organized by severity.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 🔴 Major — Broken Styling / Functional Regressions
|
|
||||||
|
|
||||||
#### N1. `ProfileDecoration` Save Button — Undefined `--accent-cyan` Variable (border invisible on all non-TDS themes)
|
|
||||||
|
|
||||||
- **File:** `src/app/features/settings/account/ProfileDecoration.tsx`, lines 191–213
|
|
||||||
- **Status:** **FIXED** — replaced raw `<button>` with `<Button size="400" variant="Success" fill="Solid" radii="300">`, removed undefined `--accent-cyan` reference
|
|
||||||
- **Issue:** The save button is a raw `<button>` with `border: '1px solid var(--accent-cyan)'` and `color: 'var(--accent-cyan)'`. The variable `--accent-cyan` (without the `--lt-` prefix) is never defined in any theme file — the correct prefixed form is `--lt-accent-cyan`. On all non-TDS themes the border is **invisible** and the text has no color.
|
|
||||||
- **Root Cause:** Missing `--lt-` prefix. Additionally, the raw `<button>` should be a folds `<Button>` to match every other save button in the same `Profile.tsx` settings panel (e.g., `ProfileDisplayName` save at `Profile.tsx:303`).
|
|
||||||
- **Fix:** Replace raw `<button>` with `<Button size="400" variant="Success" fill="Solid" radii="300">`. Remove the `--accent-cyan` reference.
|
|
||||||
|
|
||||||
#### N2. `UserPrivateNotes` Textarea — Undefined `--border-interactive` Variable (border invisible on all themes)
|
|
||||||
|
|
||||||
- **File:** `src/app/components/user-profile/UserRoomProfile.tsx`, lines 246–265
|
|
||||||
- **Status:** **FIXED** — replaced undefined CSS vars with `color.SurfaceVariant.ContainerLine`, `config.radii.R300`, `config.space.S200/S300`
|
|
||||||
- **Issue:** The notes textarea sets `border: '1px solid var(--border-interactive)'`. This variable is never defined anywhere in the codebase — the correct equivalents are `--bg-surface-border` (`src/index.css`) or `color.SurfaceVariant.ContainerLine` (folds token). The border is **invisible on all themes**.
|
|
||||||
- **Root Cause:** Invented CSS variable name. Also uses raw pixel sizing (`borderRadius: '6px'`, `padding: '8px 10px'`, `fontSize: '14px'`) instead of folds tokens.
|
|
||||||
- **Fix:** Replace inline style with `border: \`1px solid ${color.SurfaceVariant.ContainerLine}\``, `borderRadius: config.radii.R300`, `padding: config.space.S200`.
|
|
||||||
|
|
||||||
#### N3. `LotusToastContainer` — Z-Index Places Toasts Below Night Light Overlay and All Modals
|
|
||||||
|
|
||||||
- **File:** `src/app/features/toast/LotusToastContainer.tsx`, lines 184–211; `src/app/pages/App.tsx`
|
|
||||||
- **Status:** **FIXED** — raised toast `zIndex` from `9997` to `10001` (above Night Light at 9998 and modals at 9999)
|
|
||||||
- **Issue:** The toast container uses hardcoded `zIndex: 9997`. The Night Light overlay is at `z-index: 9998`. The folds `Overlay`/`Dialog` components used for all modals resolve to `z-index: 9999`. Result: (a) toasts render **under** the Night Light tint and take on the warm orange filter; (b) any open modal covers toasts entirely, making notifications invisible.
|
|
||||||
- **Root Cause:** The toast container does not use the `folds` `OverlayContainerProvider` portal that manages z-index correctly — it is a plain `position: fixed` div injected directly in `App.tsx`.
|
|
||||||
- **Fix:** Either route the toast portal through `OverlayContainerProvider` (matching how all other floating UI works), or raise `zIndex` above all overlay layers (10001+). Also audit Night Light's z-index (9998) relative to toasts.
|
|
||||||
|
|
||||||
#### N4. `PollContent` Vote Buttons — Entirely Outside the Folds Design System
|
|
||||||
|
|
||||||
- **File:** `src/app/components/message/content/PollContent.tsx`
|
|
||||||
- **Status:** **FIXED ⚠️ UNTESTED** (`caf6318a`) — needs verification: create a poll, then view/vote on it under a **non-TDS theme** (e.g. default Cinny dark/light) and confirm borders, selected state, and progress fill are all visible.
|
|
||||||
- **Issue:** Each poll answer is a native `<button>` with ~15 hardcoded inline style properties using undefined CSS variables (`--accent-cyan`, `--accent-cyan-dim`, `--accent-cyan-border`, `--border-color`). Checkbox/radio indicators, percentage spans, and the poll label used raw pixel/rem font sizes (`0.68rem`, `0.78rem`, `0.88rem`) and hardcoded `rgba()`/`#fff`. None of those vars exist outside TDS mode — the component rendered unstyled (invisible borders / no selected/progress state) on every non-TDS theme.
|
|
||||||
- **Root Cause:** Custom implementation that bypassed folds tokens entirely.
|
|
||||||
- **Fix Applied:** Kept the `<button>` structure (the progress-bar-behind-text affordance has no folds `Button` equivalent) but made every value theme-reactive: `color.Primary.*` for selected/indicator state, `color.SurfaceVariant.*` for the resting surface + progress fill, `config.*` for radii/spacing/border-width, and folds `<Text>` for the option label, percentage, and section label (dropping the raw rem sizes and `opacity` hacks). The duplicate checkbox/radio indicator spans were merged into one.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 🟠 Moderate — Interaction Pattern or Visual Deviations
|
|
||||||
|
|
||||||
| # | Area | File | Lines | Issue | Native Pattern |
|
|
||||||
| :-- | :------------------------- | :---------------------------------------- | :---------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| N5 | Read Receipts | `ReadReceiptAvatars.tsx` | 62–137 | Trigger button is raw `<button>` with `onMouseEnter`/`onMouseLeave` JS style mutation for hover state — **FIXED**: hover/focus emphasis moved to co-located `ReadReceiptAvatars.css.ts` (`:hover`/`:focus-visible`), no JS `.style` mutation | All interactive elements use `useHover` from `react-aria` and folds variant system for hover; direct `.style` mutation used nowhere else on buttons |
|
|
||||||
| N6 | Read Receipts | `ReadReceiptAvatars.tsx` & `Message.tsx` | 32–56 / 268–283 | Two code paths open `EventReaders`: avatar-pill path uses `useModalStyle(360)` for mobile fullscreen; context-menu path (`MessageReadReceiptItem`) does not — on mobile the context menu path opens a fixed-size non-fullscreen modal for the same content | All modals that share a layout variant use `useModalStyle` consistently; `MessageReadReceiptItem` was not updated when `useModalStyle` was added |
|
|
||||||
| N7 | Delivery Status | `Message.tsx` | 89–148 | `DeliveryStatus` renders Unicode glyphs (`⟳ ✓ ✕`) in a `<span>` with `fontSize: '10px'` instead of folds `<Icon>` components — **FIXED**: replaced with `Icons.Check/Cross/Send` via `<Icon size="100">` | `Icons.Check`, `Icons.Cross`, etc. are used for all other status glyphs; folds `Text` size tokens for all supplementary text |
|
|
||||||
| N8 | GIF Picker | `GifPicker.tsx` | 83–124 | GIF picker container uses fully bespoke inline styles (`borderRadius: '12px'`, `boxShadow: '0 8px 32px rgba(0,0,0,0.4)'`, raw `rgba` border) — two separate style sets for TDS and non-TDS paths — **FIXED**: non-TDS path now uses folds tokens (`color.Surface.Container`, `config.radii.R400`, `color.Surface.ContainerLine`, `color.Other.Shadow`), dropping the undefined `var(--bg-surface)`; the TDS branch keeps its `--lt-*` glow chrome (valid TDS styling) | `EmojiBoard` has no caller-applied container styling; folds components handle their own surface internally via design tokens |
|
|
||||||
| N9 | GIF Button | `RoomInput.tsx` | 1076–1087 | GIF toolbar button renders `<Text size="T200">` with hand-rolled `fontWeight`/`fontSize`/`letterSpacing` instead of `<Icon>` — **WON'T FIX (deliberate)**: folds has no GIF icon, and "GIF" is a widely-recognized text affordance (Slack/Discord/Element all use a text label). Converting to an arbitrary icon would be less clear, not more. | All 8 other toolbar buttons (`Smile`, `Sticker`, `Location`, `Poll`, etc.) use `<Icon src={...} />` exclusively |
|
|
||||||
| N10 | Send Animation | `Message.tsx` + `Animations.css.ts` | 979–998 / 60–71 | `MsgAppearClass` and `MentionHighlightPulse` both animate `transform: scale` on the same `MessageBase` DOM node — on self-sent mention messages both classes apply simultaneously and fight over the `transform` property — **FIXED**: `mentionPulseKeyframes` now animates only `box-shadow` (dropped the imperceptible `scale(1.003)`), so the appear-scale and the mention glow no longer contend for `transform` | Pre-existing `highlightAnime` only animates `backgroundColor`; no prior `transform` animation on `MessageBase` |
|
|
||||||
| N11 | AvatarDecoration | `AvatarDecoration.tsx` | 5 / 38–41 | Fixed 8px inset on all sides regardless of avatar size — at folds size `"200"` (~32px) the decoration bleeds 50% of the avatar diameter, clipping against `overflow: hidden` parent containers in member lists. **Inset issue still OPEN.** _Related regression fixed in `useAvatarDecoration.ts`_: the decoration fetch cached **all** failures (including transient 429/5xx) as "no decoration" permanently for the session, so a single rate-limited burst (member list / timeline mount many avatars at once) would make decorations vanish until a full reload. Now only a genuine 404 is cached; transient errors retry on the next mount. | Folds `Avatar` and `PresenceRingAvatar` do not emit overflow outside their bounding box |
|
|
||||||
| N12 | MediaGallery Drawer | `MediaGallery.tsx` | 651–661 | Drawer uses `position: 'fixed'` with hardcoded `width: '320px'` as inline styles on a `<Box>` — **FIXED**: moved positioning/width into co-located `MediaGallery.css.ts` using `toRem(320)` + a `max-width: 750px` full-screen media query (mirrors `MembersDrawer`); border/header now use `config.borderWidth`/`config.space` tokens. Added Escape-to-close on the panel (previously only the lightbox handled Escape). **Full chrome redesign (round 2)** to match native conventions: panel + header switched from `Surface` to `Background` variant (matching `MembersDrawer`/Saved Messages); header now `Text size="H4"` + plain close `IconButton` (dropped the bespoke tooltip-wrapped button); tabs moved to a bordered toolbar strip with the `variant={active?'Primary':'Secondary'} fill={active?'Solid':'Soft'}` pattern from `PolicyListViewer` and now show per-tab counts; the centered "lines + label" month divider replaced with a left-aligned group label (Cinny group-label pattern); thumbnail tiles moved hover/focus styling to CSS `:hover`/`:focus-visible` (no JS hover state) and into `MediaGallery.css.ts`; file rows + grid tokenized. **Docking fix (round 3)** — the core of the finding: the gallery was a `position: fixed` overlay floating over the timeline, mounted from `RoomViewHeader`. It is now a **docked flex sibling** in the room layout row, exactly like `MembersDrawer`: open state lifted to a `mediaGalleryAtom` (mirrors `bookmarksPanelAtom`), rendered in `Room.tsx` with a vertical `Line` separator on desktop and `key={room.roomId}` to reset per room; the CSS is static-width on desktop and only `position: fixed; inset: 0` full-screen on mobile (identical strategy to `MembersDrawer.css`). It now shares the row with the timeline instead of overlapping it. | `MembersDrawer` uses a vanilla-extract class with `width: toRem(266)` and is placed by the layout system, not `position: fixed`. 54px width discrepancy also breaks visual rhythm if both panels could be open |
|
|
||||||
| N13 | ScheduledMessagesTray | `ScheduledMessagesTray.tsx` | 108–126 | Collapsible tray header is `<Box as="button">` with `cursor: 'pointer'` inline style and no folds variant — no hover state, no focus ring — **FIXED**: replaced with folds `<Button variant="Secondary" fill="None" radii="0">` using `before`/`after` icon props (gains design-system hover/focus) | All clickable header/toggle elements in the room view use folds `<Button>` or `<IconButton>` with explicit variants for hover/focus; `<Box as="button">` with no variant is used nowhere else |
|
|
||||||
| N14 | ForwardMessageDialog | `ForwardMessageDialog.tsx` | 137–154 | Dialog uses `<Modal>` but has no `<Header>` component and no close `<IconButton>` — only way to close is clicking outside — **FIXED**: added a folds `<Header variant="Surface" size="500">` with the title + close `<IconButton radii="300">`, matching every other modal | Every other modal using `<Modal>` or `<Box role="dialog">` includes a `<Header>` with a close `<IconButton>` in the top-right (EditHistoryModal, LeaveRoomPrompt, ScheduleMessageModal, RemindMeDialog, etc.) |
|
|
||||||
| N15 | ScheduleMessageModal | `ScheduleMessageModal.tsx` | 180–193 | Modal root is `<Box as="form" role="dialog">` with manually assembled `borderRadius: config.radii.R400`/`boxShadow` — **FIXED**: shell is now `<Dialog as="form" variant="Surface">`; removed inline surface styles | `ForwardMessageDialog` uses folds `<Modal size="400">` with `R500` radius; the R400 vs R500 mismatch is visible when both dialogs appear in the same session |
|
|
||||||
| N16 | Presence Picker | `SettingsTab.tsx` | 118–144 | Presence trigger dot is raw `<button>` with `position: absolute; bottom: 2; right: 2` inline and no folds focus ring; no tooltip — **FIXED**: wrapped the trigger in a folds `TooltipProvider` (shows "Status: …"); replaced the undefined `var(--bg-surface)` with `color.Background.Container`. Kept the absolute-positioned `<button>` (it overlays the avatar corner; a full `IconButton` would be too large for the dot). | Every other sidebar icon button uses folds `IconButton` with `SidebarItemTooltip` and `TooltipProvider` |
|
|
||||||
| N17 | Presence Picker | `SettingsTab.tsx` | 80–86 | `PresencePicker` `FocusTrap` missing `escapeDeactivates: stopPropagation` and `isKeyForward`/`isKeyBackward` — **FIXED**: added all three options, matching the theme selector / sort menus | Every other `PopOut`+`FocusTrap`+`Menu` combo supplies both (theme selector `General.tsx:143–160`, `SettingsSelect`, sort menus) — without it Escape bubbles past the trap and arrow-key navigation is absent |
|
|
||||||
| N18 | Profile Selects | `Profile.tsx` | 547–575 / 816–848 | `ProfileStatus` auto-clear and `ProfileTimezone` selectors are native `<select>` elements with hardcoded `colorScheme: 'dark'` — will render in dark mode on light themes | General.tsx uses folds `SettingsSelect<T>` (`Button`+`PopOut`+`Menu`) for all dropdowns; `colorScheme: 'dark'` breaks light/custom theme appearance |
|
|
||||||
| N19 | Presence Labels | `useUserPresence.ts` vs `SettingsTab.tsx` | 55–62 / 36–42 | `PresenceBadge` tooltip shows "Active / Busy / Away"; `PresencePicker` options read "Online / Idle / Do Not Disturb / Invisible" — a DND user shows tooltip "Busy", not "Do Not Disturb" — **FIXED**: aligned `usePresenceLabel` reader vocabulary to the setter (online→"Online", unavailable→"Idle", offline→"Offline") | Within the same Lotus feature set the user-facing vocabulary is inconsistent between the setter UI and the reader tooltip |
|
|
||||||
| N20 | Notification Presets | `Notifications.tsx` | 57–107 | Gaming/Work/Sleep preset buttons are bare `<button>` elements with Lotus-specific CSS vars (`--border-interactive-normal`, `--bg-surface-low`) not defined in all themes — **FIXED**: converted to folds `<Button variant="Secondary" fill="Soft" radii="300">` (auto height) wrapping the emoji/label/description column; undefined vars removed | Grouped preset/action buttons elsewhere use folds `Chip variant="Primary/Secondary" outlined radii="Pill"` (e.g., Composer Toolbar toggles in `General.tsx:1100–1113`) |
|
|
||||||
| N21 | Notification Sound Selects | `SystemNotification.tsx` | 111–305 | Message sound, invite sound, and quiet-hours time pickers are bare `<select>`/`<input type="time">` with `colorScheme: 'dark'` workaround | All other dropdowns in settings use the `Button`+`PopOut`+`Menu`+`MenuItem` folds pattern; the native select renders OS-styled on all platforms |
|
|
||||||
| N22 | DM Preview Virtualizer | `RoomNavItem.tsx` / `Direct.tsx` | 608–627 / 232 | DM preview adds a second text row to each DM item, making it taller than 38px, but `useVirtualizer` in `Direct.tsx` still uses `estimateSize: () => 38` — causes layout jump/overlap on initial render — **FIXED**: bumped `estimateSize` to 52 (the two-line DM-row height) so the initial estimate matches the common case; `measureElement` still corrects each row exactly | Non-DM rooms in Home.tsx also estimate 38px; DM items with a preview are now a different height, creating two visual densities in the same nav column |
|
|
||||||
| N23 | RoomServerACL | `RoomServerACL.tsx` | 100–115 / 298–309 | Server-name text input is a raw `<input type="text">` with inline style object; "Allow IP literal addresses" is a raw `<input type="checkbox">` with `style={{ width: 16, height: 16 }}` — **FIXED**: text input → folds `<Input variant={error?'Critical':'Secondary'}>`; checkbox → folds `<Checkbox variant="Primary">` | All other text/boolean controls in room settings use folds `Input` and `Checkbox` components (`RoomAddress.tsx:163`, `RoomAddress.tsx:330`) |
|
|
||||||
| N24 | PolicyListViewer | `PolicyListViewer.tsx` | 245–264 | Room-ID add input is a raw `<input type="text">` with manually replicated folds token values — **FIXED**: replaced with folds `<Input variant={error?'Critical':'Secondary'} size="400" radii="300">` | Native pattern: folds `<Input variant="Secondary" size="300" radii="300">` — no inline style needed |
|
|
||||||
| N25 | ExportRoomHistory Inputs | `ExportRoomHistory.tsx` | 258–292 | Both date range pickers are raw `<input type="date">` with inline styles — **FIXED**: replaced with folds `<Input type="date" variant="Secondary" size="400" radii="300">` | Native pattern: folds `Input` component; `<input type="date">` renders OS-native date picker, unstyled relative to the rest of the settings panel |
|
|
||||||
| N26 | RoomShareInvite QR | `RoomShareInvite.tsx` | 66–73 | QR code `<img>` has no `onError` handler and no loading state — broken-image placeholder shown when the external API is unreachable — **FIXED**: added `loading="lazy"` + `onError` that swaps to a folds "QR code unavailable" placeholder card | Cinny avatar components and MediaGallery use `onError` handlers; this is the only settings element making a request to a third-party server with no graceful degradation |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 🟡 Minor — Cosmetic / Token Discipline
|
|
||||||
|
|
||||||
| # | Area | File | Lines | Issue | Native Pattern |
|
|
||||||
| :------ | :--------------------------------- | :------------------------------------- | :---------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| N27 | GIF Picker | `GifPicker.tsx` | 103–110 | `FocusTrap` omits `returnFocusOnDeactivate: false` — focus returns to GIF button on dismiss instead of staying in the editor — **FIXED**: added `returnFocusOnDeactivate: false` (matches EmojiBoard) | `EmojiBoard` in `RoomInput.tsx:978` explicitly sets `returnFocusOnDeactivate={false}`; GIF picker dismiss behaviour is inconsistent with emoji picker |
|
|
||||||
| N28 | Character Counter | `RoomInput.tsx` | 1159–1174 | Composer character counter rendered with `color: 'var(--tc-surface-low)'` and raw pixel padding — a CSS variable not used anywhere else in the codebase — **FIXED**: removed undefined var and raw opacity; now `<Text priority="300">` with `config.space.S100` padding | Use `color.*` folds tokens or `priority="300"` on a `Text` component |
|
|
||||||
| N29 | PollCreator Modal | `PollCreator.tsx` | 103–116 | Modal root is `<Box as="form" role="dialog" aria-modal="true">` with manually assembled surface styles instead of folds `<Dialog variant="Surface">` — **FIXED**: shell is now `<Dialog as="form" variant="Surface">`; removed inline surface styles | `MessageDeleteItem` and `MessageReportItem` in `Message.tsx:506,635` use `<Dialog variant="Surface">` inside `OverlayCenter > FocusTrap` |
|
|
||||||
| N30 | Playback Speed Chip | `AudioContent.tsx` | 163–189 | Speed chip uses `variant="SurfaceVariant" radii="Pill"` while adjacent Play/Pause chip uses `variant="Secondary" radii="300"` — mismatched shape and variant within the same `leftControl` row — **FIXED**: changed speed chip to `variant="Secondary" radii="300"` | Controls grouped in the same row should share variant and radii |
|
|
||||||
| N31 | Collapsible Message Toggle | `MsgTypeRenderers.tsx` | 97–105 | "Read more ↓" / "Show less ↑" uses `<Button size="300" variant="Secondary" fill="None">` — visually a padded form button — **FIXED**: replaced with the native flush inline-button pattern (`background:none;border:none;padding:0`) + `<Text size="T200">` tinted `color.Primary.Main`, matching `(edited)` in FallbackContent | Inline text toggles in message content (e.g. `(edited)` in `FallbackContent.tsx:74`) use bare `<button>` with `background: none; border: none; padding: 0` to stay flush with text |
|
|
||||||
| N32 | ReadReceiptAvatars Pill | `ReadReceiptAvatars.tsx` | 95–103 | Pill border is `'1px solid rgba(0,212,255,0.30)'` hardcoded raw rgba string; `borderRadius: '999px'` not a folds radii token; padding in raw pixels — **FIXED**: replaced with `config.borderWidth.B300`, `config.radii.Pill`, `config.space.S100/S200` | Use `color.*` folds tokens and `config.radii.Pill` / `config.space.S*` |
|
|
||||||
| ~~N33~~ | ~~ReadReceiptAvatars Class~~ | ~~`ReadReceiptAvatars.tsx`~~ | ~~67~~ | ~~`className="receipt-pill-btn"` references a class never defined~~ — **FIXED**: removed dead className | All custom CSS goes through co-located vanilla-extract `*.css.ts` files |
|
|
||||||
| N34 | EventReaders Header Size | `EventReaders.tsx` | 70 | `Header size="600"` (56px tall) while all peer message-action modals use `size="500"` (48px) — **FIXED**: changed to `size="500"` | `EditHistoryModal`, `LeaveRoomPrompt`, `MessageDeleteItem`, `MessageReportItem` all use `size="500"`; `size="600"` is reserved for full-page panel headers |
|
|
||||||
| N35 | EventReaders Close Button | `EventReaders.tsx` | 96 | Close `IconButton` missing explicit `radii="300"` prop — **FIXED**: added `radii="300"` | Every peer modal close button explicitly sets `radii="300"` (EditHistoryModal:184, LeaveRoomPrompt:75, MessageDeleteItem:517) |
|
|
||||||
| N36 | EventReaders Header Border | `EventReaders.tsx` | 72–77 | Lotus-mode header sets `borderBottom: '1px solid var(--lt-border-color)'` as a CSS shorthand string — **FIXED**: changed to `borderBottomWidth: config.borderWidth.B300` | Native modals use `borderBottomWidth: config.borderWidth.B300` to avoid overriding the border-color set by the folds variant system |
|
|
||||||
| N37 | EventReaders Timestamp | `EventReaders.tsx` | 143–151 | Lotus path sets `fontSize: '0.72rem'` inline — a raw relative unit between folds `T200` and `T100` scale steps — **FIXED**: removed raw `fontSize`, added `priority="300"` | Use folds `Text size="T200" priority="300"` for subdued secondary text |
|
|
||||||
| N38 | BookmarksPanel Header | `BookmarksPanel.tsx` | 155–196 | Header uses `variant="Surface"` and close button uses `size="300" radii="300"`; also has a SurfaceVariant search bar strip with no equivalent in any native drawer — **FIXED (full redesign)**: rebuilt the whole "Saved Messages" panel to match the canonical `MembersDrawer` — co-located `BookmarksPanel.css.ts` (`toRem(266)` + `max-width:750px` full-screen media query, replacing the old `position:absolute; zIndex:100` mobile "modal" that had no backdrop/escape), `variant="Background"` header, room **avatars** on each item (was a generic hash icon), `priority` tokens replacing all raw `opacity` hacks, the `borderLeft:3px` accent removed, and Escape-to-close added. | `MembersDrawer` header uses `variant="Background"` and default-size close button; the extra search+count strip creates a structurally different component family |
|
|
||||||
| N39 | Forward Menu Icon | `Message.tsx` | 1150 | Forward context menu item's `after` icon has no `size="100"` prop — **FIXED**: added `size="100"` to the `ArrowRight` icon | Every other after-icon in the same menu block explicitly uses `size="100"` (Reply, Reaction, Edit, Remind Me, Bookmark); missing size causes the Forward icon to render larger |
|
|
||||||
| N40 | ProfileDecoration Remove Button | `ProfileDecoration.tsx` | 185 | "Remove" link is a raw `<button>` with `background: 'none'; color: 'var(--tc-surface-low-contrast)'` — an undefined CSS variable — **FIXED**: replaced with `<Button variant="Critical" fill="None" size="300" radii="300">` | Use folds `<Button variant="Critical" fill="None">` or a `Text`-styled inline link |
|
|
||||||
| N41 | PresenceBadge / UserNotes Saving | `UserRoomProfile.tsx` | 240–244 | "Saving…" indicator is `<Text opacity={0.5}>` without a spinner — **FIXED**: now shows a folds `<Spinner variant="Success" fill="Solid" size="100">` beside the "Saving…" text | Every other save operation in `Profile.tsx` shows a folds `<Spinner variant="Success" fill="Solid" size="300">` alongside the save button |
|
|
||||||
| N42 | Character Counter Convention | `UserRoomProfile.tsx` vs `Profile.tsx` | 243 / 479–490 | `UserPrivateNotes` shows remaining count `"N left"`, appears only under 100; `ProfileStatus` shows `"current / 64"` always with color progression | Two Lotus features in the same settings flow use different counter conventions; neither matches a pre-existing Cinny pattern |
|
|
||||||
| N43 | Night Light Slider | `General.tsx` | 554–565 | Night Light intensity slider is a raw `<input type="range">` with no `accentColor` token — renders in browser-default blue on all themes — **FIXED**: added `accentColor: color.Primary.Main`; the intensity label `opacity` hack also replaced with `priority="300"` | The Gate Threshold slider at `General.tsx:1456` at minimum sets `accentColor: 'var(--accent-orange)'`; the Night Light slider does neither |
|
|
||||||
| N44 | Mention Highlight & Boot Button | `General.tsx` | 597–677 | `<input type="color">` for mention highlight uses raw pixel dimensions (`width: '36px'`, `height: '28px'`, `borderRadius: '4px'`); Reset and Boot buttons are bare `<button>` with Lotus CSS vars — **PARTIALLY FIXED**: the mention-highlight Reset button (renders on all themes) is now a folds `<Button variant="Secondary" fill="Soft">`, removing the undefined `--border-interactive-normal` var. The Boot button is **deliberately kept** as-is: it only renders when `lotusTerminal` is active, i.e. exactly when the `--accent-orange*` TDS vars are defined. The `<input type="color">` itself is tracked separately as N69. | Adjacent settings controls use folds `IconButton`/`Button`; there is no other `<input type="color">` in the Cinny settings UI |
|
|
||||||
| N45 | SettingsSelect vs SelectTheme | `General.tsx` | 126 vs 197 | `SettingsSelect` trigger uses `variant="Secondary"` while `SelectTheme` uses `variant="Primary" outlined fill="Soft"` for the same `Button`+`PopOut` dropdown pattern — adjacent rows in the same Appearance section have different visual weight — **FIXED**: `SelectTheme` trigger changed to `variant="Secondary"` to match `SettingsSelect` | Dropdown triggers should share the same variant within the same settings section |
|
|
||||||
| N46 | RoomInsights SectionHeader | `RoomInsights.tsx` | 24–37 | `SectionHeader` adds `textTransform: 'uppercase'`, `letterSpacing: '0.06em'`, `opacity: 0.6` to `Text size="L400"` — **FIXED**: simplified to `<Text size="L400" priority="300">` | Every other settings panel uses bare `<Text size="L400">Label</Text>` with no transforms (`General.tsx:52–72`, `ExportRoomHistory.tsx:220,246`) |
|
|
||||||
| N47 | RoomInsights Chart Radii | `RoomInsights.tsx` | 350–356 / 415–436 | Bar chart uses `borderRadius: 3` and histogram bars use `borderRadius: '2px 2px 0 0'` as raw pixel integers — **FIXED**: replaced with `config.radii.R300` | All other rounded corners use `config.radii.*` tokens |
|
|
||||||
| N48 | RoomInsights Font Size | `RoomInsights.tsx` | 448 | Hour-axis labels set `style={{ fontSize: 9 }}` as a raw pixel integer — overrides the folds `Text size="T200"` applied on the same element — **FIXED**: removed raw `style={{ fontSize: 9 }}` | Use only folds `Text` size props; never override with raw `fontSize` |
|
|
||||||
| N49 | RoomInsights Emoji Icons | `RoomInsights.tsx` | 41–65 / 292–295 | `StatTile` uses literal Unicode emoji (`🖼️ 🎬 🎵 📎`) in `<Text size="H4">` as icons — **FIXED**: `StatTile` now takes an `icon: IconSrc` and renders `<Icon>` using `Icons.Photo/VideoCamera/Headphone/File` | All other iconographic elements use `<Icon src={Icons.*} />` from folds — emoji rendering varies between Windows/macOS/Linux and cannot be tinted by the theme |
|
|
||||||
| N50 | RoomInsights Warning Banner | `RoomInsights.tsx` | 168–192 | Disclaimer banner uses raw `<Box style={{ border: color.Warning.Main, background: color.Warning.Container }}>` — **FIXED**: replaced with `<SequenceCard variant="SurfaceVariant">` with `<Icon>` colored via `color.Warning.Main` | Settings panel informational cards use `<SequenceCard variant="SurfaceVariant">` throughout RoomServerACL, ExportRoomHistory, PolicyListViewer |
|
|
||||||
| N51 | ExportRoomHistory Progress | `ExportRoomHistory.tsx` | 311–314 | Export progress shows as a plain `Text` string ("Exporting… N messages") — **WON'T FIX (deliberate)**: unlike `BackupRestore` (which has a known total to drive a determinate `ProgressBar`), export has no known total — it counts messages as they stream. The operation already shows a folds `Spinner` in the button plus a live count, which is the correct affordance for an indeterminate task. | `BackupRestore.tsx:72,90` uses a folds `<ProgressBar variant="Secondary" size="300">` for the same kind of long async operation |
|
|
||||||
| N52 | MessageQuickReactions Empty Return | `Message.tsx` | 160 | `if (recentEmojis.length === 0) return <span />;` — injects an invisible DOM node into the hover action bar flex container — **FIXED**: changed to `return null` | Universal convention for empty renders in Cinny is `return null`; 144+ instances across the codebase; the empty `<span>` can affect flex spacing |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Round 2 — Additional Feature Areas
|
|
||||||
|
|
||||||
#### 🔴 Additional Major Findings
|
|
||||||
|
|
||||||
**N53 — PTT Badge (Lotus Terminal path): Raw `<div>` tree with `--lt-*` CSS vars instead of folds `<Chip>`**
|
|
||||||
|
|
||||||
- **File:** `src/app/features/call/CallControls.tsx`
|
|
||||||
- **Status:** **FIXED** (`50076962`) — removed the `lotusTerminal` branch entirely; the PTT badge is now the single folds `<Chip variant={pttActive ? 'Success' : 'Warning'} fill="Soft" radii="400" outlined>` path for all themes (TDS styling still flows through the CSS-variable layer over the Chip). Dropped the now-unused `lotusTerminal` read. Build-verified; visual parity to confirm only if you specifically used the terminal-mode PTT look.
|
|
||||||
- **Issue:** When `lotusTerminal` is true the PTT badge renders as a bare `<Box>` with inline styles referencing `--lt-accent-green-dim`, `--lt-accent-green-border`, `--lt-accent-green` — variables absent outside TDS mode — hardcoded rem padding, `borderRadius: '99px'` (non-token), a raw monospace `fontFamily` string, non-token `letterSpacing`, and a raw `animation:` CSS string for the live-pulse dot. The live `●` dot is a raw `<span>` with inline style.
|
|
||||||
- **Root Cause:** Two entirely separate component trees for the same badge depending on a theme boolean. The non-terminal path (lines 284–301) uses the correct `<Chip variant="Success"|"Warning" fill="Soft" radii="400" outlined>`.
|
|
||||||
- **Fix:** Remove the terminal branch. The standard `<Chip>` path already exists and TDS theming can be applied via the CSS variable layer without a separate component tree.
|
|
||||||
|
|
||||||
**N54 — PiP Mute Overlay Badges: Raw `<div>` instead of folds `<Badge>`/`<Chip>`**
|
|
||||||
|
|
||||||
- **File:** `src/app/components/CallEmbedProvider.tsx`, lines 438–477
|
|
||||||
- **Status:** **FIXED** — replaced hardcoded `borderRadius`/`padding`/`fontSize` with `config.radii.R300`, `config.space.S100/S200` tokens; replaced raw `<span>` text with folds `<Text size="T200">`; color now applied to the `Icon`/`Text` via `color.Critical/Warning.Main`. The dark translucent scrim (`rgba(0,0,0,0.65)`) is **deliberately retained**: these badges overlay arbitrary video, where a theme `Chip`/`Badge` surface token would not guarantee legibility. They are also non-interactive (`pointerEvents: 'none'`), so an interactive `Chip` (a `<button>`) is semantically wrong.
|
|
||||||
- **Issue:** Both the "You muted" (bottom-left) and "All muted" (top-right) PiP badges are raw `<div>` elements with hardcoded `background: 'rgba(0,0,0,0.65)'`, `backdropFilter: 'blur(4px)'`, `borderRadius: '6px'`, `padding: '3px 7px'`, `fontSize: '12px'`. Color is set as `color: color.Critical.Main` directly on the wrapper `<div>`, not via a folds `variant` prop. Text is `<span style={{ fontSize: '11px', fontWeight: 600 }}>`.
|
|
||||||
- **Root Cause:** `CallView.tsx` line 127 uses `<Badge variant="Critical" fill="Solid" size="400">` in the same file for the "N Live" indicator — the native pattern exists and is unused here.
|
|
||||||
|
|
||||||
**N55 — Chat Background / Seasonal Theme Selected State Uses `color.Critical.Main` (Error Red)**
|
|
||||||
|
|
||||||
- **File:** `src/app/features/settings/general/General.tsx`, lines 1660–1661 and 1726–1728
|
|
||||||
- **Status:** **FIXED** — replaced all 4 instances of `color.Critical.Main` with `color.Primary.Main` in `General.tsx`
|
|
||||||
- **Issue:** The selected-state border for both `ChatBgGrid` and `SeasonalBgGrid` is `border: \`2px solid ${color.Critical.Main}\``and the label color is also`color.Critical.Main`. `color.Critical.Main` is the semantic token for **destructive/error states** — it is used for "Leave Room", "Delete Message", "Report Room" in the same file. A normal selection indicator rendered in error red is semantically wrong and visually alarming.
|
|
||||||
- **Root Cause:** Wrong semantic token for an active/selected state.
|
|
||||||
- **Fix:** Replace `color.Critical.Main` with `color.Primary.Main` (or `color.Success.Main` to match how other settings selections are styled) for both the border and label color.
|
|
||||||
|
|
||||||
**N56 — Report Modal Category Dropdown: Native `<select>` Instead of folds `Chip`+`PopOut`+`Menu`**
|
|
||||||
|
|
||||||
- **File:** `src/app/features/room/ReportRoomModal.tsx` lines 138–163; `src/app/features/room/ReportUserModal.tsx` lines 144–169
|
|
||||||
- **Status:** **FIXED** — extracted a shared `ReportCategorySelect` component (`src/app/features/room/ReportCategorySelect.tsx`) using the folds `Button` trigger + `PopOut` + `FocusTrap` + `Menu` + `MenuItem` pattern (with `escapeDeactivates`/arrow-key nav, matching `OrderButton`); both modals now use it instead of the native `<select>`.
|
|
||||||
- **Issue:** Both report modals render the "Category" field as `<Box as="select">` with hand-rolled inline styles (padding, border, background, color, fontSize, fontFamily). No other selector in the message-action modal context uses `<select>` — the established pattern for all dropdowns in both message modals and search filters is `Chip onClick → setMenuAnchor → PopOut → FocusTrap → Menu → MenuItem` (reference: `OrderButton` in `SearchFilters.tsx` lines 63–114).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 🟠 Additional Moderate Findings
|
|
||||||
|
|
||||||
| # | Area | File | Lines | Issue | Native Pattern |
|
|
||||||
| :-- | :--------------------------------------------------------------------------- | :-------------------------------------------- | :-------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
||||||
| N57 | PiP Fullscreen Button | `CallEmbedProvider.tsx` | 929–951 | PiP fullscreen toggle is a raw `<button>` with `background: 'rgba(0,0,0,0.65)'`, `color: '#fff'`, `fontSize: '13px'`, Unicode ⛶/⊡ glyph — no focus ring, no tooltip — **FIXED (token discipline)**: `borderRadius`/`padding`/gap replaced with `config.radii.R300` + `config.space.*` tokens (also on the "Return to call" label). The dark scrim and `#fff` text are **deliberately kept** for legibility over arbitrary video; the glyph stays because folds has no fullscreen icon. `aria-label`/`title` tooltip already present. | `Controls.tsx` fullscreen button uses `<IconButton variant="Surface" fill="Soft" radii="400" size="400" outlined>` with `<TooltipProvider>`; hardcoded `#fff` fails on light themes |
|
|
||||||
| N58 | Screenshare Confirm Popup | `CallControls.tsx` | 303–360 | "Share your screen?" popup is a raw `<Box>` with `--bg-surface`/`--bg-surface-border` vars (undefined outside TDS), `borderRadius: '0.75rem'`, `boxShadow: '0 8px 32px rgba(...)'`, no `FocusTrap` | Cinny's confirmation dialogs use folds `<Menu>` + `<FocusTrap>` + `<PopOut>`; the non-FocusTrap popup is not keyboard-accessible |
|
|
||||||
| N59 | ML Noise Suppression Panel | `General.tsx` | 1303–1487 | Sub-panel uses `var(--border-color)`, `var(--bg-card)`, `var(--bg-input)` (undefined in folds default theme), raw `<details>`/`<summary>` (UA-styled), `accentColor: 'var(--accent-orange)'` (TDS-only) | All other settings sub-sections use `<SettingTile>` rows inside `<SequenceCard>`; no other settings component uses `<details>` |
|
|
||||||
| N60 | Knock Badge on Members Button | `RoomViewHeader.tsx` | 744–782 | Knock count badge wrapped in extra `<div style={{ position: 'relative' }}>` with hardcoded `fontSize: '9px'`, `minWidth: '14px'`, `height: '14px'`, `padding: '0 3px'` overriding folds `size="200"` — **FIXED**: removed wrapper div, put `position: 'relative'` directly on the `IconButton`, `<Badge size="400">` with `toRem(3)` insets and `<Text size="L400">` — now matches the Pinned Messages badge pattern exactly | Pinned Messages badge (same header, lines 651–677) uses `position: 'relative'` directly on `<IconButton>` + `toRem()` for inset; no extra wrapper div |
|
|
||||||
| N61 | Knock Member Rows | `MembersDrawer.tsx` | 441–487 | Knock requester rows use raw `<Box>` with manually duplicated padding; no `<MenuItem>` wrapper → no hover/focus/active states — **WON'T FIX (deliberate)**: unlike a `MemberItem` (a clickable navigation row), a knock row contains two action buttons (Approve / Deny) and is **not itself clickable**. Wrapping it in `<MenuItem>` (a `<button>`) would nest interactive controls inside a button — invalid HTML/ARIA. The row has no interactive state to express. | Every joined/invited member uses `<MemberItem>` which wraps `<MenuItem variant="Background" radii="400">` with baked-in spacing and all interactive states |
|
|
||||||
| N62 | Unverified Device Banner | `RoomInput.tsx` | 860–883 | Warning callout above composer uses inline `background: color.Warning.Container`, `borderLeft: '3px solid color.Warning.Main'` — a custom left-border accent pattern not present anywhere else in the folds system — **FIXED**: replaced the `borderLeft: '3px'` accent with a standard full `border` using `color.Warning.ContainerLine` + `config.borderWidth.B300`; removed the `opacity` hacks (folds `OnContainer` already meets contrast) | Warning indicators in the same codebase use `<Chip variant="Warning">` or `<Badge variant="Warning">`; the 3px left-border card pattern has no folds equivalent |
|
|
||||||
| N63 | Report Modals — Box Instead of Dialog | `ReportRoomModal.tsx` / `ReportUserModal.tsx` | 97–110 / 103–116 | Both modals render as `<Box as="form" role="dialog">` with inline `background`/`borderRadius`/`boxShadow`; use `config.radii.R400` (rounder) vs native `Dialog` which uses `R300` — **FIXED**: both shells are now `<Dialog as="form" variant="Surface">`; removed inline surface styles (Dialog provides background/radius/shadow) | Native `MessageReportItem` at `Message.tsx:634` and all other Cinny message-action modals use `<Dialog variant="Surface">` |
|
|
||||||
| N64 | EditHistoryModal — `<Modal>` vs `<Dialog>` | `EditHistoryModal.tsx` | 166 | Uses `<Modal variant="Surface" size="500">` while sibling message-action modals (`DeleteMessageItem:505`, `MessageReportItem:634`) all use `<Dialog variant="Surface">` — different widths and internal padding | `<Dialog variant="Surface">` is the established modal shell for all message-triggered dialogs |
|
|
||||||
| N65 | EditHistoryModal — No "Load More" | `EditHistoryModal.tsx` | 253–259 | When `hasMore` is true the modal shows passive `<Text>"Showing the 50 most recent edits"</Text>` with no action; older edits are inaccessible — **FIXED**: implemented real pagination — edits accumulate across `next_batch` fetches (de-duped by event id, re-sorted by ts), with a folds `<Button>Load more</Button>` (spinner while loading) replacing the passive text | `RoomActivityLog.tsx:425` and `MessageSearch.tsx:129` both render a folds `<Button size="300" variant="Secondary">Load more</Button>` to fetch the next page |
|
|
||||||
| N66 | DateRangeButton — Native `<input type="date">` | `SearchFilters.tsx` | 558–589 | "From" and "To" date fields are raw `<input type="date">` with inline style overrides including `fontSize: '0.82rem'` — **FIXED**: replaced both with folds `<Input type="date" variant="SurfaceVariant" size="300" radii="300">`; removed now-unused `color` import | `SelectRoomButton` (same file, line 224) and `SelectSenderButton` (line 424) both use folds `<Input size="300" radii="300">`; the date inputs are the only native browser inputs in the search filter row |
|
|
||||||
| N67 | SeasonalEffect / NightLight Z-Index Order | `SeasonalEffect.tsx` / `App.tsx` | 759 / 62–77 | `SeasonalEffect` mounts at `zIndex: 9999`; `NightLightOverlay` at `zIndex: 9998`. Seasonal particles render **above** Night Light so they are never tinted. `SeasonalEffect` also shares `z-index: 9999` with the skip-to-content link in `ClientLayout.tsx` — **FIXED**: lowered `SeasonalEffect` overlay to `zIndex: 9997` (below Night Light at 9998 and modals at 9999), so Night Light now tints the particles and dialogs are never obscured | Expected UX: Night Light tints all visible content including effects; requires either a higher Night Light z-index or a lower SeasonalEffect z-index |
|
|
||||||
| N68 | Syntax Highlighting — `--lt-accent-*` Vars in Non-TDS Themes | `syntaxHighlight.ts` | 313–323 | `tokenStyle()` returns `var(--lt-accent-cyan/green/orange/purple, hardcoded-fallback)` — `--lt-*` vars only exist in TDS mode; fallbacks are Monokai dark colors that have poor contrast on light themes and no relationship to the existing `--prism-*` variables in `ReactPrism.css` — **FIXED**: `tokenStyle()` now maps to the `--prism-*` family (keyword/selector/boolean/atrule/comment) which has proper light/dark/TDS palettes; comment uses `--prism-comment` instead of an opacity hack | `ReactPrism.css` uses `--prism-keyword`, `--prism-selector` etc. which switch correctly between light and dark palettes; syntax highlighting should use the same variable family |
|
|
||||||
| N69 | Mention Highlight — `<input type="color">` Instead of `HexColorPickerPopOut` | `General.tsx` | 644–675 | Raw `<input type="color">` with hardcoded pixel dimensions; OS-native color picker chrome renders completely differently from the rest of settings UI — **FIXED**: replaced with `<HexColorPickerPopOut>` + `<HexColorPicker>` (react-colorful) behind a folds `<Button>` trigger showing a color swatch; the picker's built-in `onRemove` replaces the separate Reset button | `PowersEditor.tsx:125–143` establishes `<HexColorPickerPopOut picker={<HexColorPicker ...>}>` as the codebase's color-picking pattern; Reset button should be `<Button size="300" variant="Secondary" radii="300">` |
|
|
||||||
| N70 | ChatBgGrid / SeasonalBgGrid — Raw `<button>` Elements | `General.tsx` | 1648–1689 / 1711–1742 | Both pickers use raw HTML `<button>` elements with hardcoded `width: toRem(76)`, `height: toRem(50/56)`, `borderRadius: toRem(8)`, `border: 2px solid rgba(...)` — no focus ring via folds, no `variant` prop, no hover state from the design system — **FIXED**: chrome (radius, border, hover, **keyboard `:focus-visible` ring**, selected state via `data-selected`) moved to a shared `BgSwatch.css.ts` using `config`/`color` tokens; only the per-swatch size + live preview background remain inline (these are inherently custom preview tiles, not folds `MenuItem`/`Chip` candidates) | Native Cinny theme pickers use folds `<MenuItem>` or `<Chip>` which respond to theme and provide focus/hover states automatically |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 🟡 Additional Minor Findings
|
|
||||||
|
|
||||||
| # | Area | File | Lines | Issue | Native Pattern |
|
|
||||||
| :-- | :-------------------------------------------- | :-------------------------------------------- | :---------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| N71 | Call Prescreen Text | `CallView.tsx` | 63–85 | `ChannelFullMessage` and `AlreadyInCallMessage` use `<Text style={{ color: color.Critical/Warning.Main }}>` inline instead of folds `<Badge variant="Critical/Warning">` — **WON'T FIX (deliberate)**: these are full, centered explanatory **sentences** ("Channel Full (N/M) — Wait for someone to leave…"), not short labels. A `Badge` is for compact chips like "N Live"; wrapping a sentence in one is visually wrong. They already use folds `color.*` tokens. The sibling `LivekitServerMissingMessage`/`NoPermissionMessage` use the same (un-flagged) pattern. | The "N Live" badge directly above (line 127) correctly uses `<Badge variant="Critical" fill="Solid" size="400">` |
|
|
||||||
| N72 | Mute MenuItem Icon | `RoomNavItem.tsx` | 454–466 | "Mute" `<MenuItem>` places bell-mute icon as a raw child node instead of using the `before` prop — **FIXED**: moved `Icons.BellMute` to `before` prop | Every other `<MenuItem>` in both `RoomNavItemMenu` and `RoomMenu` places its leading icon in the `before` prop |
|
|
||||||
| N73 | Pending Requests Header | `MembersDrawer.tsx` | 415–422 | "Pending Requests" section header is bare `<Text>` with inline padding instead of `className={css.MembersGroupLabel}` — **FIXED**: now uses `className={css.MembersGroupLabel}` like every other section header | Power-level group labels at lines 506–519 use `className={css.MembersGroupLabel}` for all other section headers in the same virtualizer list |
|
|
||||||
| N74 | Emoji Prefix Span | `RoomNavItem.tsx` | 730–736 | Emoji prefix rendered as raw `<span style={{ fontSize: '1.15em', lineHeight: 1 }}>` inside a `<Text>` node — **FIXED**: removed the emoji-splitting span; the room name (including any leading emoji) now renders directly inside `<Text>` | All other nav item text uses folds `<Text size="Inherit">` or similar — no raw `<span>` with em-based font-size override exists elsewhere in the sidebar |
|
|
||||||
| N75 | Room Name Override / Star Indicators | `RoomNavItem.tsx` | 741–757 | Pencil and star indicator icons are embedded inside the name `<Box as="span">`, giving them the same visual baseline as the room name text — **WON'T FIX (deliberate)**: an inline favorite-star / local-name marker adjacent to the name is a deliberate, common design (cf. Element/Slack pinned-name markers). Moving them to the far right would collide with the unread/notification indicators already there and risks layout regressions. Low value, real regression risk. | Native sidebar status indicators (unread count, notification mode icon) are placed to the far right of the item, never inside the name text span group |
|
|
||||||
| N76 | Report Modals — Extra Cancel Button | `ReportRoomModal.tsx` / `ReportUserModal.tsx` | 189–191 / 195–197 | Both custom report modals include a "Cancel" `<Button>` in the footer row — **FIXED**: removed the Cancel button; dismissal is via the header `×` / click-outside, matching `MessageReportItem` | Native `MessageReportItem` (`Message.tsx:675–691`) has no Cancel button — dismissal is via `×` header button or click-outside only |
|
|
||||||
| N77 | Search Filter Inline Lambdas | `SearchFilters.tsx` | 480, 625 | `SelectSenderButton` and `DateRangeButton` trigger chips use inline `onClick` arrow functions — **WON'T FIX (deliberate)**: purely a code-style nit with zero user-facing or behavioural impact. Inline arrow handlers are idiomatic React and used throughout this very file; extracting them yields no functional benefit. | `OrderButton` (line 58) and `SelectRoomButton` (line 195) both extract a named `const handleOpenMenu: MouseEventHandler<HTMLButtonElement>` handler — bypassing the type annotation in the inline form |
|
|
||||||
| N78 | HasLink Chip Active Color | `SearchFilters.tsx` | 755 | `HasLink` active state uses `variant="Primary"` (blue); all boolean scope-toggle chips in the same bar use `variant="Success"` (green) with `outlined` — **FIXED**: changed to `variant={containsUrl ? 'Success' : 'SurfaceVariant'} outlined={!!containsUrl}` | `variant="Success" outlined` is the established active-state pattern for boolean toggles in the filter bar |
|
|
||||||
| N79 | Server Notice Chip Radii | `RoomViewHeader.tsx` | 570 | `<Chip size="400" radii="Pill">` — `Pill` radii on a room-type label — **FIXED**: changed to `radii="300"` | Room/space type labels in lobby (`RoomItem.tsx:83`, `SpaceItem.tsx:63`) use `radii="300"`; `radii="Pill"` is for filter/tag chips only |
|
|
||||||
| N80 | Server Support Contact Layout | `About.tsx` | 172–239 | Homeserver support contacts rendered as raw `<Box direction="Column">` with `<Text as="a">` pairs — custom label/link layout — **WON'T FIX (deliberate)**: a contact is `role → {matrix_id?, email?, …}` (one-to-many links per role), which doesn't map onto `SettingTile`'s single `title`/`description`/`after` slots without contortion. The current layout already uses folds `Box`/`Text`/`SequenceCard` + tokens and `Text as="a"` (a valid folds pattern); no undefined vars or raw HTML chrome. | All other `<SequenceCard>` content in `About.tsx` and `General.tsx` uses `<SettingTile title="..." description="..." after={...}>` as the content unit |
|
|
||||||
| N81 | Background Picker Grid — No Responsive Layout | `General.tsx` | 1707–1742 | Fixed `width: toRem(76)` flex-wrap cells with no `minWidth` floor or CSS grid `auto-fill` — SeasonalBgGrid's 13 items produce a visually lopsided orphan last row at any viewport width — **FIXED** (`50076962`): both `ChatBgGrid` and `SeasonalBgGrid` containers switched to `display: grid; grid-template-columns: repeat(auto-fill, minmax(toRem(76), 1fr))`, so swatches fill each row evenly | Cinny's native grids use `grid-template-columns: repeat(auto-fill, minmax(N, 1fr))` or equivalent for responsive fill |
|
|
||||||
| N82 | Join/Leave Sounds Auto-Preview | `General.tsx` | 1592–1609 | Selecting a sound in the dropdown immediately plays a preview, but no UI affordance communicates this to the user — **FIXED**: the tile description now reads "…Selecting an option plays a preview." (the same affordance was applied to the new Ringtone selector) | Settings tiles with side effects on selection (theme picker, chat background) show a live visual preview or a dedicated control explaining the side effect |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Round 3 — Rich Topic Editor, RemindMe Dialog, Composer Toolbar, Voice Recorder, Uploads, Location, Mention Highlight
|
|
||||||
|
|
||||||
#### 🔴 Additional Major Findings
|
|
||||||
|
|
||||||
**N83 — Rich Topic Formatting Toolbar: Raw `<button>` Elements with Fully Inline Styles**
|
|
||||||
|
|
||||||
- **File:** `src/app/features/common-settings/general/RoomProfile.tsx`, lines 335–358
|
|
||||||
- **Status:** **FIXED** — replaced raw `<button>` elements with `<Button size="300" radii="300" variant="Secondary" fill="Soft">` with styled `<Text>` children for B/I/S/code labels
|
|
||||||
- **Issue:** The four formatting buttons (B, I, S, `` ` ``) in the room topic editor are plain HTML `<button>` elements with entirely inline styles: manual `border`, `borderRadius`, `background`, `color`, `cursor`, `fontSize`, `fontWeight`, `fontStyle`, `fontFamily`, `lineHeight`. They bypass the folds design token system completely — no `variant`, `size`, or `radii` props, no theme-reactive hover/focus states.
|
|
||||||
- **Root Cause:** Custom addition without referencing folds primitives.
|
|
||||||
- **Fix:** Replace with `<IconButton type="button" size="300" radii="300" variant="Surface" fill="Soft">` matching the emoji-picker trigger immediately above them at line 285, which already uses the correct pattern.
|
|
||||||
|
|
||||||
**N84 — Topic Preview in Room Settings Renders Plain Text Instead of `formatted_body`**
|
|
||||||
|
|
||||||
- **File:** `src/app/features/common-settings/general/RoomProfile.tsx`, lines 457–461
|
|
||||||
- **Status:** **FIXED** — read-mode topic now checks `topic.format === 'org.matrix.custom.html'` and renders `parse(sanitizeCustomHtml(topic.formatted_body))`, matching `RoomTopicViewer` and all other display sites
|
|
||||||
- **Issue:** The read-mode topic display wraps `topic.topic` (the plain-text field) in `<Linkify>` and never reads `formatted_body`. However `buildTopicContent()` (lines 82–89) intentionally stores both `topic` and `formatted_body` under `org.matrix.custom.html`. After the user saves a formatted topic, the preview panel immediately shows the stripped plain-text version — the formatting appears to disappear within the same settings panel.
|
|
||||||
- **Root Cause:** The existing `RoomTopicViewer` component (`src/app/components/room-topic-viewer/RoomTopicViewer.tsx:24–51`) already checks `topic.format === 'org.matrix.custom.html'` and pipes `formatted_body` through `sanitizeCustomHtml`. This component is used everywhere else (`RoomIntro`, `LobbyHero`, `RoomItem`, `Invites`, etc.) but not in Room Settings.
|
|
||||||
- **Fix:** Replace the inline plain-text render with `<RoomTopicViewer topic={roomTopic}>` to match all other display sites.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 🟠 Additional Moderate Findings
|
|
||||||
|
|
||||||
| # | Area | File | Lines | Issue | Native Pattern |
|
|
||||||
| :-- | :--------------------------------- | :------------------------- | :----------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| N85 | RemindMe Dialog Shell | `RemindMeDialog.tsx` | 69–81 | Dialog shell is `<Box role="dialog">` with `background`, `borderRadius`, `boxShadow`, `overflow` all set as inline styles using token lookups. Corner radius is `config.radii.R400` which differs from the `R300` embedded in `<Dialog variant="Surface">` — **FIXED**: shell replaced with `<Dialog variant="Surface" style={modalStyle}>`; removed the inline `background`/`borderRadius`/`boxShadow`/`overflow` and the now-unused `color` import | All small message-action dialogs (`LeaveRoomPrompt`, `LogoutDialog`, `JoinAddressPrompt`, `PowerChip`, `DeleteMessageItem`) use `<Dialog variant="Surface" style={modalStyle}>` as the shell |
|
|
||||||
| N86 | RemindMe Preset Buttons | `RemindMeDialog.tsx` | 111–117 | The four preset time choices (20 min, 1 hr, 3 hr, tomorrow) use `<MenuItem size="300" radii="300">` — `MenuItem` is a navigation primitive tied to `menu`/`menubar` ARIA roles; placing it inside `role="dialog"` is an invalid ARIA combination — **FIXED**: each preset is now a folds `<Button variant="Secondary" fill="Soft" radii="300">`, resolving the invalid `menuitem`-in-`dialog` ARIA | Dialog action choices use `<Button>` (delete/leave/logout dialogs) or `<Chip>` (selection choices). No other dialog in the codebase uses `MenuItem` for action items |
|
|
||||||
| N87 | Composer Toolbar Toggle Pattern | `General.tsx` | 1100–1114 | Per-button toolbar toggles (Format, Emoji, Sticker, GIF, Location, Poll, Voice, Schedule) use `<Chip variant="Primary"/"Secondary" radii="Pill">` in a wrap grid — a compact chip-toggle grid inside a `SettingTile`, different from every adjacent row | The three sibling tiles in the same `Editor()` function (ENTER for Newline, Markdown, Formatting Toolbar) all use `<SettingTile after={<Switch variant="Primary">}>`. 15+ other binary settings in the file use the Switch pattern |
|
|
||||||
| N88 | Voice Recorder Recording State | `VoiceMessageRecorder.tsx` | 195, 206, 240, 276 | Recording container background is `var(--bg-surface-variant)`, the live pulse dot is `var(--tc-danger-normal)`, waveform bars are `var(--tc-primary-normal)` — custom Lotus CSS vars that may not exist in folds themes, falling back to transparent/black — **FIXED**: replaced with `color.SurfaceVariant.Container`, `color.Critical.Main`, `color.Primary.Main` | Native message components use JS-accessible `color.*` tokens that are always populated regardless of theme class |
|
|
||||||
| N89 | Voice Recorder Preview Audio | `VoiceMessageRecorder.tsx` | 282–283 | Preview state renders bare `<audio src={previewUrl} controls>` — native browser element with inconsistent cross-browser chrome — **FIXED**: replaced with `<audio ref>` + folds `<IconButton>` play/pause toggle; `onEnded` resets playing state | Native audio messages use folds `Attachment`/`AttachmentContent` layout wrappers; pre-send preview should use `<IconButton>` play/pause controls |
|
|
||||||
| N90 | Mention Highlight Contrast Formula | `App.tsx` | 36–40 | Auto-computed text color (black/white) uses simplified luma `(0.299r + 0.587g + 0.114b)/255 > 0.5` — not WCAG 2.1 relative luminance (which requires gamma linearization) — **FIXED**: replaced with WCAG 2.1 relative luminance formula using `((c+0.055)/1.055)^2.4` gamma linearization; threshold moved from 0.5 to 0.179 | Folds `color.*.OnContainer` tokens are manually curated to pass WCAG AA 4.5:1 contrast ratios; custom computation must match this guarantee |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
#### 🟡 Additional Minor Findings
|
|
||||||
|
|
||||||
| # | Area | File | Lines | Issue | Native Pattern |
|
|
||||||
| :-- | :--------------------------------- | :----------------------- | :------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| N91 | Upload Card Caption Input | `UploadCardRenderer.tsx` | 356–376 | Caption input is raw `<input type="text">` with hardcoded inline CSS using Lotus-specific vars not in folds — **FIXED**: replaced with folds `<Input variant="Secondary" size="300" radii="300">` | Other text inputs in the UI use folds `<Input size="300" radii="300">` with folds-token props for all sizing and color |
|
|
||||||
| N92 | Location "Open Location" Button | `MsgTypeRenderers.tsx` | 534–547 | "Open Location" action link uses `<Chip as="a">` — compact badge-sized element — **FIXED**: replaced with `<Button as="a" variant="Secondary" fill="Solid" radii="300" size="400">` matching FileContent pattern | `FileContent.tsx` uses `<Button variant="Secondary" fill="Solid" radii="300" size="400">` for "Open File"/"Open PDF" |
|
|
||||||
| N93 | Location Coordinates Text | `MsgTypeRenderers.tsx` | 532 | `<Text size="T300" style={{ opacity: 0.65 }}>` — hardcoded non-standard opacity — **FIXED**: replaced with `<Text size="T300" priority="300">` | Secondary text uses folds `priority` prop; `0.65` is outside the token scale |
|
|
||||||
| N94 | Mention Highlight Border Invisible | `App.tsx` | 41 | `--mention-highlight-border` is set to the same value as `--mention-highlight-bg` — the border is invisible — **FIXED**: border is now `rgba(r,g,b,0.5)` — same hue as the background at 50% opacity, always visible | In folds, `color.*.ContainerLine` is always a lighter/muted sibling of `color.*.Container`, providing the 1px outline that gives mention chips visual definition |
|
|
||||||
+284
-21
@@ -18,14 +18,16 @@ Last updated: June 2026.
|
|||||||
9. [Per-Message Read Receipts](#per-message-read-receipts)
|
9. [Per-Message Read Receipts](#per-message-read-receipts)
|
||||||
10. [Delivery Status Indicators](#delivery-status-indicators)
|
10. [Delivery Status Indicators](#delivery-status-indicators)
|
||||||
11. [Messaging Enhancements](#messaging-enhancements)
|
11. [Messaging Enhancements](#messaging-enhancements)
|
||||||
12. [Presence](#presence)
|
12. [Threads (P3-8)](#threads-p3-8)
|
||||||
13. [UX & Composer](#ux--composer)
|
13. [Presence](#presence)
|
||||||
14. [Room Customization](#room-customization)
|
14. [UX & Composer](#ux--composer)
|
||||||
15. [Moderation](#moderation)
|
15. [Room Customization](#room-customization)
|
||||||
16. [Notifications](#notifications)
|
16. [Moderation](#moderation)
|
||||||
17. [Server Integration](#server-integration)
|
17. [Notifications](#notifications)
|
||||||
18. [Infrastructure](#infrastructure)
|
18. [Server Integration](#server-integration)
|
||||||
19. [Key Custom Files](#key-custom-files)
|
19. [Infrastructure](#infrastructure)
|
||||||
|
20. [Desktop App Features](#desktop-app-features)
|
||||||
|
21. [Key Custom Files](#key-custom-files)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -322,9 +324,104 @@ Users can set a custom background color for `@mention` chips that highlight thei
|
|||||||
|
|
||||||
## Voice / Video Call Improvements
|
## Voice / Video Call Improvements
|
||||||
|
|
||||||
### Element Call Upgrade
|
> 🔱 **[EC-FORK] LIVE (2026-06).** Element Call is now our **self-built fork**
|
||||||
|
> (`@lotusguild/element-call-embedded@0.20.1-lotus.1`, source at
|
||||||
|
> `LotusGuild/element-call`), served same-origin — no longer the upstream
|
||||||
|
> pre-built npm bundle. Several in-call behaviors below are now first-class
|
||||||
|
> source changes rather than DOM/widget hacks. Background, plan, and the Phase-2
|
||||||
|
> work list are in
|
||||||
|
> the Element Call fork reference in [`LOTUS_TODO.md`](./LOTUS_TODO.md).
|
||||||
|
|
||||||
Upgraded embedded Element Call widget from **0.16.3** to **0.19.4**.
|
### Element Call — Self-Built Fork (`0.20.1-lotus.1`)
|
||||||
|
|
||||||
|
The embedded widget was upgraded **0.16.3 → 0.19.4 → 0.20.1**, then **forked**.
|
||||||
|
We self-build `LotusGuild/element-call` and publish it to our private Gitea npm
|
||||||
|
registry as `@lotusguild/element-call-embedded`; cinny consumes that instead of
|
||||||
|
`@element-hq/element-call-embedded`. The iframe prints
|
||||||
|
`Element Call embedded-v0.20.1-lotus.1` in its console (vs. `embedded-v0.20.1`
|
||||||
|
upstream) — the quickest way to confirm a deploy landed the fork.
|
||||||
|
|
||||||
|
All custom behavior lives in the fork's `src/lotus/` modules and is **additive
|
||||||
|
and dormant by default**, gated by URL flags / widget actions the host opts into,
|
||||||
|
so a stock EC config is byte-for-byte upstream behavior.
|
||||||
|
|
||||||
|
**Active (cinny drives them today):**
|
||||||
|
|
||||||
|
| # | Feature | Mechanism | Replaces (old hack) |
|
||||||
|
| --- | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| A7 | **Denoise in-source** | ML noise suppression runs inside EC as a LiveKit `TrackProcessor<Audio>` (flag `lotusDenoiseSource=1`); re-applied on every (re)publish | the build-time `getUserMedia` monkeypatch injected into `index.html` — **removed**. Fixes mic-dead-after-reconnect. |
|
||||||
|
| #2 | **Speaking / mute events** | EC emits `io.lotus.call_state` (throttled); cinny reads speaker + mute state from it (flag `lotusCallState=1`) | scraping EC's DOM for `[data-lk-speaking]` (kept only as fallback) |
|
||||||
|
| A5 | **Focus participant** | host sends `io.lotus.focus_participant` to pin a tile, coexisting with / overriding the screenshare spotlight | the `.click()`-the-tile DOM hack in `CallControl.ts` — **removed** |
|
||||||
|
| #6 | **In-call avatar decorations** | host pushes `io.lotus.decorations` (per-user APNG URLs); the fork renders them on EC's video-tile avatars | previously impossible — decorations only showed on our pre-join lobby roster |
|
||||||
|
| #5 | **Native transparent background** | flag `lotusTransparent=1` makes EC's surface transparent so the host wallpaper shows through | the injected `background:none !important` CSS |
|
||||||
|
|
||||||
|
**Now wired (cinny drives them — ⚠️ awaiting live verification):**
|
||||||
|
|
||||||
|
| # | Capability | Widget action | cinny surface |
|
||||||
|
| ----- | -------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------- |
|
||||||
|
| P5-15 | **Audio inject** | `io.lotus.inject_audio` — plays a clip into the call as a separately published track | In-Call Soundboard (uploadable clips) — see below |
|
||||||
|
| P5-31 | **Quality controls** | `io.lotus.set_quality` — sets audio/screenshare encoding bitrate/framerate | Call Quality Controls (user settings + room-admin caps) — see below |
|
||||||
|
|
||||||
|
> Both were dormant capabilities; cinny now drives them (armed via
|
||||||
|
> `lotusAudioInject=1`). The **only** EC item still open is the P5-31
|
||||||
|
> **server-side** quality guard (a `voice-limit-guard`-style sidecar reading
|
||||||
|
> `io.lotus.room_quality`) for hard enforcement across all Matrix clients — the
|
||||||
|
> client cap is best-effort.
|
||||||
|
|
||||||
|
### In-Call Soundboard (P5-15)
|
||||||
|
|
||||||
|
A soundboard button (🔔) in the call controls bar opens a popout of the user's
|
||||||
|
clips. Clicking one **injects it into the call as a real published LiveKit
|
||||||
|
track** (every participant hears it, via the fork's `io.lotus.inject_audio`) and
|
||||||
|
plays it locally for the presser (LiveKit doesn't loop your own track back).
|
||||||
|
|
||||||
|
- **User-uploadable, like custom emoji/sticker packs.** Clips are stored in the
|
||||||
|
`io.lotus.soundboard` account data event, so they **sync across all your
|
||||||
|
devices**. Upload short audio (≤ 1 MB, ≤ 40 clips) from the popout; delete
|
||||||
|
inline.
|
||||||
|
- Authenticated media can't be fetched from the widget's realm, so the host
|
||||||
|
resolves each mxc clip → an authenticated download → a same-session `blob:`
|
||||||
|
object URL and hands that to the widget.
|
||||||
|
- Gated by the **Soundboard** toggle (Settings → General → Calls) with a volume
|
||||||
|
slider. The button is hidden when disabled.
|
||||||
|
- Files: `utils/soundboardClips.ts`, `hooks/useSoundboard.ts`,
|
||||||
|
`features/call/CallSoundboard.tsx`, `plugins/call/CallControl.ts#injectAudio`.
|
||||||
|
|
||||||
|
### Call Quality Controls (P5-31)
|
||||||
|
|
||||||
|
Discord-style encoding controls applied to the local tracks via the fork's
|
||||||
|
`io.lotus.set_quality` (`RTCRtpSender.setParameters` across all simulcast
|
||||||
|
encodings, re-applied on every re-publish/reconnect).
|
||||||
|
|
||||||
|
- **User settings** (Settings → General → Calls): Microphone Bitrate,
|
||||||
|
Screenshare Bitrate, Screenshare Framerate (each defaults to **Auto**).
|
||||||
|
- **Room-admin caps**: admins set a ceiling in Room Settings → General → Voice
|
||||||
|
(`io.lotus.room_quality` state event); every Lotus client clamps its per-user
|
||||||
|
quality to `min(user setting, room cap)`.
|
||||||
|
- Applied by the `useCallQuality` hook on join and whenever settings/caps
|
||||||
|
change; `utils/callQuality.ts` builds the payload (unit-tested).
|
||||||
|
|
||||||
|
**Server-enforced call permissions (hard, ALL clients).** The same
|
||||||
|
`io.lotus.room_quality` event carries a **publish-source policy**
|
||||||
|
(`allow_screenshare`, `allow_camera`) enforced server-side by
|
||||||
|
`voice-limit-guard` (matrix repo, LXC 151): it re-signs the LiveKit JWT's
|
||||||
|
`canPublishSources`, so the SFU refuses screenshare/camera tracks for **every**
|
||||||
|
Matrix client (Element, FluffyChat, our fork) — not just Lotus. Admins toggle
|
||||||
|
these in Room Settings → Voice → **Call Permissions**; cinny also hides the
|
||||||
|
blocked buttons in the call bar. Enforcement is **live**: the JWT re-sign covers
|
||||||
|
new joins, and a background reconcile loop revokes an **in-progress**
|
||||||
|
screenshare/camera (via LiveKit `UpdateParticipant`) within ~3 s of an admin
|
||||||
|
flipping the policy — so it kills active shares mid-call, not just future ones.
|
||||||
|
|
||||||
|
- **Why numeric caps aren't server-enforced:** LiveKit is a pure SFU (forwards,
|
||||||
|
never transcodes) and has no publisher bitrate/fps field anywhere in the JWT
|
||||||
|
grant, room config, server `limit:`, or admin API; stock Element Call ignores
|
||||||
|
room metadata for publish quality. Numeric caps are therefore inherently
|
||||||
|
**cooperative** — our fork honors them, which is the design above. The
|
||||||
|
publish-source policy is the one genuine hard, cross-client lever, and it's
|
||||||
|
implemented.
|
||||||
|
- **Not yet**: screenshare resolution control (needs a `getDisplayMedia` hook in
|
||||||
|
the fork).
|
||||||
|
|
||||||
### Camera Default Off
|
### Camera Default Off
|
||||||
|
|
||||||
@@ -417,7 +514,7 @@ A comprehensive mic noise-suppression system in **Settings → General → Calls
|
|||||||
|
|
||||||
**Advanced Features & Test Options:**
|
**Advanced Features & Test Options:**
|
||||||
|
|
||||||
- **Multiple ML Models:** Toggle between **RNNoise** (standard hybrid) and **Speex** (legacy DSP-based) to compare artifact levels and suppression strength.
|
- **Multiple ML Models:** Four in-source models, selectable from a dropdown **ordered by quality/CPU** (best first): **DeepFilterNet 3** (48 kHz, best), **DTLN** (16 kHz), **RNNoise** (48 kHz), **Speex** (48 kHz, lightest). The **tier default is Browser-native**; when a user opts into ML the default model is **DeepFilterNet 3**.
|
||||||
- **Series Suppression (Combination):** Optional toggle to run the browser's native stationary noise filter _before_ the ML model. This allows testing the individual performance of the ML model vs the combined effectiveness at removing fan hum.
|
- **Series Suppression (Combination):** Optional toggle to run the browser's native stationary noise filter _before_ the ML model. This allows testing the individual performance of the ML model vs the combined effectiveness at removing fan hum.
|
||||||
- **Noise Gate:** Configurable hardware-style gate with a dB threshold. Hard-cuts all audio when input is below the threshold, ensuring absolute silence between sentences.
|
- **Noise Gate:** Configurable hardware-style gate with a dB threshold. Hard-cuts all audio when input is below the threshold, ensuring absolute silence between sentences.
|
||||||
- **Live Microphone Meter:** A real-time volume visualizer in the settings panel to help users accurately tune their Noise Gate threshold.
|
- **Live Microphone Meter:** A real-time volume visualizer in the settings panel to help users accurately tune their Noise Gate threshold.
|
||||||
@@ -426,20 +523,44 @@ A comprehensive mic noise-suppression system in **Settings → General → Calls
|
|||||||
- **Support Detection:** UI now detects `AudioWorklet` / `AudioContext` support and disables ML options in unsupported environments.
|
- **Support Detection:** UI now detects `AudioWorklet` / `AudioContext` support and disables ML options in unsupported environments.
|
||||||
- **Status Reporting:** The ML shim notifies the host app via `postMessage`. If initialization fails, a system toast alerts the user of the fallback to the raw microphone.
|
- **Status Reporting:** The ML shim notifies the host app via `postMessage`. If initialization fails, a system toast alerts the user of the fallback to the raw microphone.
|
||||||
|
|
||||||
**Open-Source Model Roadmap:**
|
**Open-Source Models (all now in-source in the EC fork):**
|
||||||
| Model | Transients (Clicks) | Voice Quality | CPU Usage (WASM) |
|
| Model | Transients (Clicks) | Voice Quality | CPU Usage (WASM) | Sample rate |
|
||||||
| :--- | :--- | :--- | :--- |
|
| :--- | :--- | :--- | :--- | :--- |
|
||||||
| **RNNoise** | Poor | Moderate | < 5% |
|
| **DeepFilterNet 3** (ML default) | **Excellent** | **Very High** | 25-50%+ | 48 kHz |
|
||||||
| **DTLN** | Good | High | 10-20% |
|
| **DTLN** | Good | High | 10-20% | 16 kHz |
|
||||||
| **DeepFilterNet 3** | **Excellent** | **Very High** | 25-50%+ |
|
| **RNNoise** | Poor | Moderate | < 5% | 48 kHz |
|
||||||
|
| **Speex** | Poor | Low | < 5% | 48 kHz |
|
||||||
|
|
||||||
> **Note:** DeepFilterNet 3 is planned for future inclusion in the desktop build where larger binaries and higher CPU overhead are more acceptable.
|
> **Update (2026-06):** with the EC fork live, denoise runs **inside** Element
|
||||||
|
> Call as a LiveKit `TrackProcessor` and **all four models ship in-source**
|
||||||
|
> (DTLN at 16 kHz, the rest at 48 kHz; the processor degrades to the raw mic
|
||||||
|
> rather than ever going silent). The model picker selects between them.
|
||||||
|
|
||||||
|
> **Update (2026-07) — quality, reliability & AEC/AGC:**
|
||||||
|
>
|
||||||
|
> - **Quality tuning** (addresses the "robotic/underwater" RNNoise reports):
|
||||||
|
> a **dry/wet attenuation floor** (default ~-16 dB) blends a little raw mic
|
||||||
|
> under the denoised signal so suppression can't fully collapse the noise
|
||||||
|
> floor — applied only to the low-latency flat models (RNNoise/Speex); DTLN/DFN
|
||||||
|
> would comb-filter, so they rely on their own level. The **noise gate now runs
|
||||||
|
> after the ML stage**, and **DeepFilterNet 3 level 80 → 60**. Tunable via the
|
||||||
|
> `lotusDenoiseFloor` param.
|
||||||
|
> - **AEC/AGC:** browser **echo cancellation stays ON**, but the ML tier now sets
|
||||||
|
> **auto gain control OFF** (`autoGainControl=false`) so the browser's dynamic
|
||||||
|
> gain doesn't fight the ML model. Browser/off tiers keep AGC on. (Remote
|
||||||
|
> playback stays on standard elements — no AEC-defeat vector.)
|
||||||
|
> - **Reliability:** never-silent watchdog (auto-resume a suspended context),
|
||||||
|
> `resume()` timeout (no track-lock deadlock), rejected-WASM-fetch eviction
|
||||||
|
> (transient failures recover), activation off the local participant (works
|
||||||
|
> solo), and init/build-failure leak fixes.
|
||||||
|
> - Real-call **audio-quality** A/B (model choice, floor value, AGC on/off) is the
|
||||||
|
> open by-ear validation item — see `LOTUS_TESTING.md` §D2-1.
|
||||||
|
|
||||||
### Files
|
### Files
|
||||||
|
|
||||||
- `build/lotus-denoise.js` — multi-model getUserMedia shim
|
- **EC fork** `src/lotus/lotusDenoise.ts` + `lotusDenoiseProcessor.ts` — in-source LiveKit `TrackProcessor` (RNNoise/Speex 48 kHz, DTLN 16 kHz, DeepFilterNet 48 kHz); activated by `lotusDenoiseSource=1`. (The old build-time `getUserMedia` shim `build/lotus-denoise.js` is **removed**.)
|
||||||
- `vite.config.js` — `lotusDenoise()` plugin (copies assets for RNNoise, Speex, and NoiseGate)
|
- `vite.config.js` — `lotusDenoise()` plugin (now only **copies model assets** for the fork to load; no longer injects a shim)
|
||||||
- `src/app/plugins/call/CallEmbed.ts` — advanced tier → widget URL params
|
- `src/app/plugins/call/CallEmbed.ts` — advanced tier → `lotusDenoiseSource` widget URL param
|
||||||
- `src/app/utils/lotusDenoiseUtils.ts` — support detection and model comparison metadata
|
- `src/app/utils/lotusDenoiseUtils.ts` — support detection and model comparison metadata
|
||||||
- `src/app/features/settings/general/General.tsx` — advanced settings UI + mic meter
|
- `src/app/features/settings/general/General.tsx` — advanced settings UI + mic meter
|
||||||
|
|
||||||
@@ -570,6 +691,24 @@ Context menu → **Forward** allows forwarding a message to any room the user is
|
|||||||
- The search panel accepts `from_ts` and `to_ts` values (epoch milliseconds) passed to the search API
|
- The search panel accepts `from_ts` and `to_ts` values (epoch milliseconds) passed to the search API
|
||||||
- A chip shows the active date range with an **×** button to clear it
|
- A chip shows the active date range with an **×** button to clear it
|
||||||
|
|
||||||
|
### Encrypted Search Cache (P4-8, opt-in)
|
||||||
|
|
||||||
|
Persistent local index for encrypted-room search, so coverage survives page reloads instead of requiring re-pagination + re-decryption every session.
|
||||||
|
|
||||||
|
- Raw IndexedDB (`lotus-search-cache`): message rows keyed `[roomId, eventId]` + per-room coverage markers; merged into local search results with in-memory-wins dedupe
|
||||||
|
- **Opt-in, default OFF** (it stores decrypted text at rest): toggle + "Clear cached index" live in the search panel's Encrypted Rooms section, with the privacy note "Stores decrypted text on this device"
|
||||||
|
- Always wiped on logout; any IndexedDB error degrades to a cache-miss (never breaks search)
|
||||||
|
- Files: `src/app/utils/searchCache.ts`, `src/app/state/searchCacheEnabled.ts`, `features/message-search/useLocalMessageSearch.ts`
|
||||||
|
|
||||||
|
### Math / LaTeX Rendering (P4-4)
|
||||||
|
|
||||||
|
KaTeX-rendered math in messages, two paths:
|
||||||
|
|
||||||
|
- **Spec path (CS-API §11.5):** `<span/div data-mx-maths="…">` in `formatted_body` renders the attribute's LaTeX (block for div, inline for span); on render failure the element's child fallback content shows instead
|
||||||
|
- **Plain-text path:** `$…$` (inline) and `$$…$$` (block) with conservative rules — escape-aware (`\$`), currency-guarded (`$5 and $10` stays text), never inside `code`/`pre`
|
||||||
|
- KaTeX + its CSS load lazily on first math encountered — zero cost to the main bundle
|
||||||
|
- Files: `src/app/utils/mathParse.ts` (+14 tests), `components/math/KaTeX.tsx`, `plugins/react-custom-html-parser.tsx`
|
||||||
|
|
||||||
### Image / Video Captions
|
### Image / Video Captions
|
||||||
|
|
||||||
Images and videos can be sent with a caption. The caption and media are sent as a single event.
|
Images and videos can be sent with a caption. The caption and media are sent as a single event.
|
||||||
@@ -645,6 +784,36 @@ Generic (non-domain-specific) cards display a Google S2 favicon. Empty or unpars
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Threads (P3-8)
|
||||||
|
|
||||||
|
Full threaded-conversation support (`m.thread`, matrix-js-sdk `threadSupport`), Element-consistent.
|
||||||
|
|
||||||
|
### Thread Panel
|
||||||
|
|
||||||
|
A right-side drawer (mirrors the members drawer; fullscreen on mobile) with the thread's root message emphasized at top, an "N replies" divider, the full reply timeline (virtualized, back-paginates via `/relations`, decrypts E2EE threads), reactions/edits/redactions, and its own composer. Open it from **Reply in Thread** in the message menu, a reply's thread indicator, or a summary chip; close with **×** or Escape. Reading the panel sends threaded read receipts so per-thread unread counts clear.
|
||||||
|
|
||||||
|
### Summary Chips
|
||||||
|
|
||||||
|
Root messages in the main timeline show a **"N replies · time"** chip (server-aggregated `m.thread` bundle, or the live Thread once loaded) with an unread badge — threaded replies no longer render inline in the main timeline, so the chip is how conversations stay discoverable.
|
||||||
|
|
||||||
|
### Thread Composer
|
||||||
|
|
||||||
|
The panel embeds the full composer (uploads, emoji, stickers, GIFs, voice, location, polls) with drafts, reply state, and upload queues **isolated per thread** (`roomId::threadRootId` keys). Replies-to-replies produce spec-correct `m.thread` + `m.in_reply_to` (`is_falling_back: false`). Scheduling and slash commands are disabled inside threads (v1).
|
||||||
|
|
||||||
|
### Notifications (Slack-style, P4-1)
|
||||||
|
|
||||||
|
By default you're notified for a thread reply only when you **participate** in that thread (you've posted in it) or the reply **@mentions** you — other threads accumulate quietly behind their chip badges. Every thread can be overridden from the bell menu in the panel header: **Default (participating) / All replies / Mentions only / Mute**. Modes sync across your devices (`io.lotus.thread_notifications` account data, auto-pruned). Muting a thread silences notifications and sounds, removes the chip's unread badge (a small bell-mute glyph shows instead), and subtracts that thread from the room's sidebar unread badge (client-side — other Matrix clients on the account still count it).
|
||||||
|
|
||||||
|
### Under the Hood
|
||||||
|
|
||||||
|
- `threadSupport: true` (startClient) partitions thread events into SDK `Thread` timelines; markAsRead sends **unthreaded** receipts so room badges keep clearing
|
||||||
|
- Thread replies are notified via exactly one path (room-level `ThreadEvent.NewReply` w/ per-thread dedupe + panel-aware focus suppression); the main timeline notifier is thread-guarded, and room badges refresh live on `RoomEvent.UnreadNotifications`
|
||||||
|
- Pending sends render via a `LocalEchoUpdated` strip (chronological local echo never enters thread timelineSets)
|
||||||
|
- Deep links to thread events redirect into the panel
|
||||||
|
- Files: `features/room/thread/*`, `state/room/thread.ts`, `hooks/useThreadSummary.ts` (+35 tests across the stack)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Presence
|
## Presence
|
||||||
|
|
||||||
### Discord-Style Presence Selector
|
### Discord-Style Presence Selector
|
||||||
@@ -736,6 +905,14 @@ Hook: `src/app/hooks/useUserNotes.ts`
|
|||||||
|
|
||||||
## UX & Composer
|
## UX & Composer
|
||||||
|
|
||||||
|
### Forward to Multiple Rooms (P6-3)
|
||||||
|
|
||||||
|
The Forward Message dialog is a checkbox multi-select: pick any number of rooms (search + select persist across queries) and **"Send to N rooms"** forwards in one batch (`Promise.allSettled`). Full success auto-closes; a partial failure keeps the dialog open with a "Forwarded to X/N — failed: …" summary. The forwarded content (latest edit via `m.new_content`, reply-quote stripped, undecryptable refused) is built by the shared, unit-tested `forwardContent.ts`.
|
||||||
|
|
||||||
|
### Live Bookmark Previews (P6-3)
|
||||||
|
|
||||||
|
`BookmarksPanel` resolves each saved message's **live event** (`useRoomEvent`) so previews reflect **edits** and show a **deleted** indicator for redactions, instead of the save-time snapshot. The stored snapshot (`previewText`) remains the fallback while loading, on fetch failure, or when you've **left the room**.
|
||||||
|
|
||||||
### Message Length Counter
|
### Message Length Counter
|
||||||
|
|
||||||
A character count indicator is shown in the composer when `charCount > 0`. The counter resets to zero when switching rooms.
|
A character count indicator is shown in the composer when `charCount > 0`. The counter resets to zero when switching rooms.
|
||||||
@@ -1010,6 +1187,18 @@ Three one-tap presets at the top of **Settings → Notifications** that apply a
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Accessibility (P3-4)
|
||||||
|
|
||||||
|
WCAG 2.1 AA hardening of the golden path (find room → read → reply → send) for keyboard and screen-reader users.
|
||||||
|
|
||||||
|
- **Timeline for screen readers:** each message is `role="article"`; **collapsed messages announce their sender + time** (they drop the visible header, so AT would otherwise hear the body with no attribution). The timeline is a `role="log"` `aria-live="polite"` region so new messages are announced; emoji/emoticons carry text labels.
|
||||||
|
- **Live status:** typing indicators announce via a `role="status"` region; editing a message announces "Editing message from <sender>".
|
||||||
|
- **Forms & overlays:** all inputs have associated labels (visible `<label htmlFor>` or `aria-label`); the Media Gallery and Search overlays are named.
|
||||||
|
- **Focus management:** skip-to-content link + `nav`/`main` landmarks; genuine dialogs return focus to their trigger on close (inline popouts intentionally keep focus in context).
|
||||||
|
- **Keyboard-shortcuts help:** press <kbd>?</kbd> for a dialog of the existing shortcuts (Escape, type-to-focus composer, Enter/Shift+Enter send, message actions).
|
||||||
|
- **Regression gate:** a curated `eslint-plugin-jsx-a11y` rule set (ARIA correctness + label association) runs in CI. Files: `components/message/*`, `features/room/RoomViewTyping.tsx`, `features/shortcuts/*`, `utils/a11y.ts`, `eslint.config.mjs`.
|
||||||
|
- _Known limitation:_ list virtualization keeps far-scrolled history out of the a11y tree (perf trade-off); newly-arriving messages are announced.
|
||||||
|
|
||||||
## Infrastructure
|
## Infrastructure
|
||||||
|
|
||||||
### Authenticated Media
|
### Authenticated Media
|
||||||
@@ -1040,6 +1229,80 @@ The `useAuthentication` parameter was previously mispositioned, causing unauthen
|
|||||||
|
|
||||||
The `encUrlPreview` setting defaults to `true` rather than `false`. A security advisory chip in **Settings → Privacy** explains the tradeoff (the homeserver can see which URLs are being previewed) so users can make an informed choice.
|
The `encUrlPreview` setting defaults to `true` rather than `false`. A security advisory chip in **Settings → Privacy** explains the tradeoff (the homeserver can see which URLs are being previewed) so users can make an informed choice.
|
||||||
|
|
||||||
|
### Hardened Session Storage (N97 partial, 2026-07)
|
||||||
|
|
||||||
|
The session persists as ONE atomic `cinny_session_v1` JSON write (previously ~10 separate localStorage keys written non-atomically). Reads prefer the blob with transparent migration from the legacy keys (dual-written one release for rollback). Cross-tab sync: logging out or in from one tab reloads the others so no tab runs with stale credentials. `state/sessions.ts` (22 tests), `hooks/useSessionSync.ts`.
|
||||||
|
|
||||||
|
### Crypto Diagnostics (E2EE investigation kit)
|
||||||
|
|
||||||
|
**Settings → Developer Tools → Crypto Diagnostics**: a capture-only ring buffer (max 200) hooks `console.warn/error` for E2EE failure signatures (OTK upload conflicts, missing call media keys, decryption errors, delayed-event timeouts) and downloads a JSON report — the evidence input for the KE-1→4 investigation. Companion diagnosis: the Encryption / E2EE section of [`LOTUS_TODO.md`](./LOTUS_TODO.md). `utils/cryptoDiagLog.ts`, `features/settings/developer/CryptoDiagnostics.tsx`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Desktop App Features
|
||||||
|
|
||||||
|
Native capabilities of the Lotus Chat **Tauri v2** desktop app (Windows, macOS, Linux) on top of the shared web client. Web hooks live in `src/app/hooks/useTauri*.ts` (each no-ops in the browser) and call Rust commands in `cinny-desktop/src-tauri/src/native/*`. Windows-only pieces are `#[cfg(target_os = "windows")]`, compile-verified in CI (Windows runners).
|
||||||
|
|
||||||
|
### Call Continuity — No-Sleep (P5-46)
|
||||||
|
|
||||||
|
Holds the system awake (`SetThreadExecutionState`) while a voice/video call is active; releases on end. `useTauriCallPower` ↔ `native/power.rs`.
|
||||||
|
|
||||||
|
### Windows Jump List (P5-36)
|
||||||
|
|
||||||
|
Right-click the taskbar icon → a **Recent Rooms** list of your most-active rooms; each entry opens that room via the `matrix:` deep-link. `useTauriJumpList` ↔ `native/jumplist.rs` (`ICustomDestinationList`).
|
||||||
|
|
||||||
|
### Taskbar Thumbnail Toolbar (P5-44)
|
||||||
|
|
||||||
|
Hover the taskbar preview during a call → **Mute / Deafen / End Call** buttons. `useTauriThumbbar` ↔ `native/thumbbar.rs` (`ITaskbarList3` + a window subclass for `THBN_CLICKED`).
|
||||||
|
|
||||||
|
### System Media Transport Controls — SMTC (P5-43)
|
||||||
|
|
||||||
|
Exposes call status + a mute control to the Windows volume-flyout / media overlay (WinRT `SystemMediaTransportControls`). `useTauriSmtc` ↔ `native/smtc.rs`. _Experimental — may require an active audio session to surface._
|
||||||
|
|
||||||
|
### Network Awareness (P5-49)
|
||||||
|
|
||||||
|
Detects Windows connectivity changes (`INetworkListManager`) and nudges the Matrix client to reconnect (`retryImmediately`). `useTauriNetwork` ↔ `native/network.rs`.
|
||||||
|
|
||||||
|
### Instant Background Sync (P5-42)
|
||||||
|
|
||||||
|
Keeps the `/sync` loop + notifications running full-speed while the app is closed to the tray, by disabling Chromium background throttling via WebView2 `additional_browser_args` (`lib.rs`) — no separate background process. Windows/WebView2 only; doesn't block system sleep.
|
||||||
|
|
||||||
|
### Native Rich Notifications (P5-41 / P5-35)
|
||||||
|
|
||||||
|
Windows toasts with **click-to-open-room** and **inline quick reply** (WinRT `ToastNotification`, in-process `Activated` event). Falls back to the standard toast otherwise. `useTauriToastActions` ↔ `native/toast.rs`; the desktop notification bridge routes room notifications to it.
|
||||||
|
|
||||||
|
### Focus Assist Sync (P5-56)
|
||||||
|
|
||||||
|
When Windows Focus Assist / Quiet Hours is active, Lotus suppresses its own notifications + sounds (reuses the quiet-hours gate). `useTauriFocusAssist` + `focusAssistActiveAtom` ↔ `native/focus_assist.rs` (`SHQueryUserNotificationState`).
|
||||||
|
|
||||||
|
### Linux parity + cross-platform extras (P6-1)
|
||||||
|
|
||||||
|
Rounds out the native app beyond Windows (macOS out of scope):
|
||||||
|
|
||||||
|
- **No-sleep during calls on Linux** — a D-Bus `org.freedesktop.ScreenSaver` inhibit (zbus) keeps the display awake mid-call, matching the Windows behavior. `native/power.rs`.
|
||||||
|
- **Launcher unread badge on Linux** — best-effort Unity `LauncherEntry` D-Bus signal (Ubuntu/Dash-to-Dock/KDE), mirroring the Windows taskbar badge.
|
||||||
|
- **Launch on login** — `tauri-plugin-autostart` + a **Settings → General "Launch on login"** toggle (desktop-only).
|
||||||
|
- **Tray "Do Not Disturb"** — a tray checkbox that silences Lotus notifications (feeds `manualDndAtom` into the same quiet-gate as Focus Assist). `useTauriDnd`.
|
||||||
|
|
||||||
|
### Custom Window Chrome (P5-47)
|
||||||
|
|
||||||
|
Opt-in (Settings → General → **Custom Window Chrome**): replaces the OS title bar with a TDS-styled titlebar (min / max / close + drag region), runtime-reversible via `set_decorations`. `features/desktop/TitleBar.tsx` + `useTauriWindowChrome` ↔ `native/chrome.rs`.
|
||||||
|
|
||||||
|
### Proactive Update Toast (P5-40)
|
||||||
|
|
||||||
|
Checks for a new desktop release every 12h and offers a one-click update. `TauriUpdateFeature` (ClientNonUIFeatures) + `useTauriUpdater`.
|
||||||
|
|
||||||
|
### Cross-platform composer niceties
|
||||||
|
|
||||||
|
- **Composer toolbar drag-reorder (P5-55)** — drag to reorder the composer buttons (Settings → General), via `@atlaskit/pragmatic-drag-and-drop`.
|
||||||
|
- **Draft-saved indicator (P5-57)** — a subtle cue in the composer when the current room has a persisted draft.
|
||||||
|
- **Recursive folder drag-drop (P5-48)** — drop a folder to upload every file inside it (all nesting levels), `utils/fileEntries.ts`.
|
||||||
|
|
||||||
|
### Files
|
||||||
|
|
||||||
|
- Web: `src/app/hooks/useTauri*.ts`, `src/app/components/TauriDesktopFeatures.tsx`, `src/app/features/desktop/TitleBar.tsx`, `src/app/features/room/DraftIndicator.tsx`, `src/app/utils/fileEntries.ts`, `src/app/state/{customWindowChrome,focusAssist}.ts`.
|
||||||
|
- Native (`cinny-desktop`): `src-tauri/src/native/{power,jumplist,thumbbar,smtc,network,chrome,toast,focus_assist}.rs` + `native/mod.rs` (registered in `lib.rs`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Key Custom Files
|
## Key Custom Files
|
||||||
|
|||||||
+387
-13
@@ -1,6 +1,6 @@
|
|||||||
# Lotus Chat — Manual Testing Guide
|
# Lotus Chat — Manual Testing Guide
|
||||||
|
|
||||||
**Generated:** June 2026
|
**Generated:** June 2026 · **Updated:** July 2026 (added §O — threads, per-thread notifications, math, search cache, session hardening, audit wave, desktop CSP)
|
||||||
**Scope:** Everything landed on the `lotus` branch since the v4.12.3 merge that I (Claude) could **not** verify statically and that needs a human in a real environment to confirm. Work through it top-to-bottom; the highest-risk / hardest-to-reproduce items are first.
|
**Scope:** Everything landed on the `lotus` branch since the v4.12.3 merge that I (Claude) could **not** verify statically and that needs a human in a real environment to confirm. Work through it top-to-bottom; the highest-risk / hardest-to-reproduce items are first.
|
||||||
|
|
||||||
> **How to report back:** For each numbered check, tell me **PASS** / **FAIL** (or **partial**). On any FAIL, include: what you saw vs. expected, the browser/OS (and whether web LXC 106 or the desktop/Tauri build), the theme you were on, and any **browser console** errors (F12 → Console). Screenshots help for anything visual.
|
> **How to report back:** For each numbered check, tell me **PASS** / **FAIL** (or **partial**). On any FAIL, include: what you saw vs. expected, the browser/OS (and whether web LXC 106 or the desktop/Tauri build), the theme you were on, and any **browser console** errors (F12 → Console). Screenshots help for anything visual.
|
||||||
@@ -106,18 +106,19 @@
|
|||||||
|
|
||||||
**Expected:** the decoration ring/overlay renders around that participant's avatar on the call tile, the same way it does in member lists.
|
**Expected:** the decoration ring/overlay renders around that participant's avatar on the call tile, the same way it does in member lists.
|
||||||
|
|
||||||
### A7. EC iframe load watchdog + recovery UI (#EC)
|
### A7. EC iframe load watchdog + recovery UI (#EC, N96)
|
||||||
|
|
||||||
This guards against a permanently-stuck "Loading…" call.
|
This guards against a permanently-stuck "Loading…" call. Also covers the N96 button-label fix (the old "Retry" and "Leave" buttons were identical — now there is a single **"Back"** button).
|
||||||
|
|
||||||
1. Normal case: **join a call** → it should connect within a few seconds as usual (the watchdog stays invisible).
|
1. Normal case: **join a call** → it should connect within a few seconds as usual (the watchdog stays invisible).
|
||||||
2. Failure case (best-effort to reproduce): throttle your network hard (devtools → Network → Offline) **right as** you click join, or block the Element Call origin, so the iframe can't finish loading.
|
2. Failure case (best-effort to reproduce): throttle your network hard (devtools → Network → Offline) **right as** you click join, or block the Element Call origin, so the iframe can't finish loading.
|
||||||
|
|
||||||
**Expected**
|
**Expected**
|
||||||
|
|
||||||
- On a genuine failure/timeout (~25s), instead of an endless spinner you get a **visible error overlay with Retry / Leave** buttons.
|
- On a genuine failure/timeout (~25s), instead of an endless spinner you get a **visible error overlay with a single "Back" button** (the old "Retry" + "Leave" pair is gone — they did the same thing and "Retry" was misleading).
|
||||||
- **Retry** attempts to reload the call; **Leave** exits cleanly.
|
- Clicking **Back** returns you to the call prescreen, where you can manually click Join to try again.
|
||||||
- Normal joins must **not** trigger the error overlay (no false positives) — this is the important part to confirm.
|
- Normal joins must **not** trigger the error overlay (no false positives) — this is the important part to confirm.
|
||||||
|
- **Self-heal:** if the error overlay appears on a slow network but EC then finishes loading anyway, the overlay should **dismiss itself** and drop you into the live call. Worth confirming on a deliberately throttled-but-not-blocked connection.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -125,7 +126,7 @@ This guards against a permanently-stuck "Loading…" call.
|
|||||||
|
|
||||||
This was the actual bug: poll buttons used undefined CSS variables, so on the **default (non-Lotus-Terminal) themes** they rendered with invisible borders / no selected state.
|
This was the actual bug: poll buttons used undefined CSS variables, so on the **default (non-Lotus-Terminal) themes** they rendered with invisible borders / no selected state.
|
||||||
|
|
||||||
### B1. Poll renders on a default theme
|
### B1. Poll renders on a default theme — ✅ PASS
|
||||||
|
|
||||||
1. Switch to a **default Cinny theme** (Settings → Appearance — **not** Lotus Terminal / TDS). Test both a **dark** and a **light** theme.
|
1. Switch to a **default Cinny theme** (Settings → Appearance — **not** Lotus Terminal / TDS). Test both a **dark** and a **light** theme.
|
||||||
2. In any room, create a poll (composer → poll button): a **single-choice** poll with 3 options.
|
2. In any room, create a poll (composer → poll button): a **single-choice** poll with 3 options.
|
||||||
@@ -153,7 +154,7 @@ This was the actual bug: poll buttons used undefined CSS variables, so on the **
|
|||||||
- Indicators are **square checkboxes** (not circles); selected ones show a **✓** that's legible against the filled box.
|
- Indicators are **square checkboxes** (not circles); selected ones show a **✓** that's legible against the filled box.
|
||||||
- You can select **several** options; each shows its own progress fill.
|
- You can select **several** options; each shows its own progress fill.
|
||||||
|
|
||||||
### B4. Lotus Terminal theme regression
|
### B4. Lotus Terminal theme regression — ✅ PASS
|
||||||
|
|
||||||
1. Switch to **Lotus Terminal / TDS** theme and re-open a poll.
|
1. Switch to **Lotus Terminal / TDS** theme and re-open a poll.
|
||||||
**Expected:** still looks correct (the fix uses theme tokens, so the TDS accent should now drive it) — no worse than before.
|
**Expected:** still looks correct (the fix uses theme tokens, so the TDS accent should now drive it) — no worse than before.
|
||||||
@@ -206,9 +207,128 @@ If any control does nothing, that usually means an EC DOM selector changed — c
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## D2. Element Call **fork** — Phase 2 feature sweep (👥 2 people) — `0.20.1-lotus.1`
|
||||||
|
|
||||||
|
> The whole EC iframe is now our **self-built fork** (`@lotusguild/element-call-embedded@0.20.1-lotus.1`).
|
||||||
|
> Five features are **active** (the host sets their flags / sends their actions); two ship **dormant**.
|
||||||
|
> **Confirm you're on the fork first:** EC iframe console prints `Element Call embedded-v0.20.1-lotus.1`
|
||||||
|
> (the old build prints `embedded-v0.20.1`). If it says the old version, the web deploy hasn't landed —
|
||||||
|
> the fork features won't be present, so don't test D2 yet.
|
||||||
|
> For non-dev testers, each item below also states the plain "✅ good if / ❌ tell us if" outcome.
|
||||||
|
|
||||||
|
### D2-1. Denoise **in-source** — survives reconnect (fixes A7) ⭐ highest risk (everyone's mic)
|
||||||
|
|
||||||
|
Flag: cinny sets `lotusDenoiseSource=1` when ML denoise is selected (the old build-time getUserMedia
|
||||||
|
shim is **removed**). This is the single change with the widest blast radius — test deliberately.
|
||||||
|
|
||||||
|
- [ ] **Audio flows, no silence** with ML denoise on (baseline, also §D line 204).
|
||||||
|
- [ ] **Reconnect (the A7 fix):** in a call with ML denoise on, kill network ~10 s (devtools → Offline)
|
||||||
|
so EC shows "Connection lost / Reconnect", then restore. **Mic still works AND still denoised**
|
||||||
|
afterward, **without** End+rejoin. _(This is the exact bug that was reintroduced then fixed; if it
|
||||||
|
regresses, mic dies on every reconnect.)_
|
||||||
|
- [ ] **Mic device switch mid-call** (Settings → change microphone): audio keeps working (same
|
||||||
|
`restart()` path as reconnect).
|
||||||
|
- [ ] **Mute → unmute** a few times: audio returns each time.
|
||||||
|
- [ ] **Each model** if the picker offers them: `rnnoise` (default), `speex`, `dtln`, `deepfilternet` —
|
||||||
|
each loads + denoises, no silence. (All four are in-source now; DTLN runs at 16 kHz, others 48 kHz.)
|
||||||
|
- [ ] **No double-processing:** audio isn't over-suppressed/artifacted (would mean the old shim is still
|
||||||
|
injected alongside the in-source engine).
|
||||||
|
- **Rollback if bad for everyone:** revert the cinny deploy commit (restores the shim + `@element-hq` parity).
|
||||||
|
|
||||||
|
### D2-2. Speaking + mute indicators from widget **events** (#2)
|
||||||
|
|
||||||
|
Flag: `lotusCallState=1`. cinny now reads speaker/mute state from `io.lotus.call_state` events instead of
|
||||||
|
scraping EC's DOM (DOM fallback retained). Overlaps **G1**.
|
||||||
|
|
||||||
|
- [ ] **Speaking glow** lights the **correct** person when they talk (you, then your friend).
|
||||||
|
- [ ] **PiP "All muted" / "You muted" badge** points at the right person and updates on mute/unmute.
|
||||||
|
|
||||||
|
### D2-3. Focus camera **during a screenshare** (#4 / A5)
|
||||||
|
|
||||||
|
Action: cinny sends `io.lotus.focus_participant` (the DOM `.click()` hack is gone). Overlaps **A5 / G2**.
|
||||||
|
|
||||||
|
- [ ] Person A screenshares; Person B camera on; **MemberGlance → Focus camera** on B → B's camera is
|
||||||
|
spotlighted **alongside/over** the shared screen (not ignored).
|
||||||
|
- [ ] Camera-**off** target = graceful (no error, no kick out of the screenshare).
|
||||||
|
|
||||||
|
### D2-4. In-call avatar decorations (#6) — **NEW, beyond A6**
|
||||||
|
|
||||||
|
Action: cinny pushes `io.lotus.decorations`. **A6 only covered the lobby roster** and called in-call EC
|
||||||
|
tiles out of scope — that's now in scope.
|
||||||
|
|
||||||
|
- [ ] A participant with a **Profile decoration** joins **camera off** → the decoration ring renders on
|
||||||
|
their **in-call video-tile avatar** (inside EC, not just the lobby), correctly sized/positioned.
|
||||||
|
- [ ] Decoration tracks the right person across grid/spotlight layout changes; disappears when they leave.
|
||||||
|
|
||||||
|
### D2-5. Native transparent background (#5)
|
||||||
|
|
||||||
|
Flag: `lotusTransparent=1` (native, replacing the injected `background:none !important`).
|
||||||
|
|
||||||
|
- [ ] Call background looks right — host wallpaper/surface shows through; **no** black box, bad
|
||||||
|
see-through, or layout breakage (also covered loosely by §D2 "looks right").
|
||||||
|
|
||||||
|
### D2-7. In-Call Soundboard (#3 / P5-15) — 👥 2 people — **NEW**
|
||||||
|
|
||||||
|
Flag: `lotusAudioInject=1`. A 🔔 **Soundboard** button now sits in the call controls bar (left group,
|
||||||
|
next to the chat button). Clips are user-uploadable and sync across your devices like emoji packs.
|
||||||
|
_Prereq:_ Settings → General → Calls → **Soundboard** must be ON (default on).
|
||||||
|
|
||||||
|
- [ ] **Upload:** open the soundboard popout → **Upload** → pick a short audio file (mp3/ogg/wav, ≤ 1 MB).
|
||||||
|
It appears as a clip tile. (Too-big / too-many shows an error, doesn't crash.)
|
||||||
|
- [ ] **Plays into the call:** with a second person in the call, click a clip. **They hear it**, and
|
||||||
|
**you hear it locally** too. ✅ good if both hear it; ❌ tell us if only one side does.
|
||||||
|
- [ ] **Sync:** the uploaded clip shows up on your **other device**/session (account-data sync).
|
||||||
|
- [ ] **Delete:** the ✕ on a tile removes it (everywhere, after sync).
|
||||||
|
- [ ] **Off switch:** turn Settings → Calls → **Soundboard** off → the call-bar button disappears.
|
||||||
|
- [ ] Injecting a clip does **not** mute/interrupt your mic or anyone else's audio.
|
||||||
|
|
||||||
|
### D2-8. Call Quality Controls (#7 / P5-31) — 👥 2 people — **NEW**
|
||||||
|
|
||||||
|
Action: `io.lotus.set_quality`. User settings in **Settings → General → Calls** (Microphone Bitrate,
|
||||||
|
Screenshare Bitrate, Screenshare Framerate; all default **Auto**). Admin caps in **Room Settings →
|
||||||
|
General → Voice → Call Quality Caps**.
|
||||||
|
|
||||||
|
- [ ] **No regression at Auto:** with everything on **Auto**, calls/screenshare work exactly as before.
|
||||||
|
- [ ] **User cap takes effect:** set Microphone Bitrate to **32 kbps**, rejoin/continue a call — audio
|
||||||
|
still flows (thinner is fine). Set Screenshare Framerate to **15 fps** and share your screen — it
|
||||||
|
still shares. ❌ tell us if any setting kills audio/screenshare.
|
||||||
|
- [ ] **Applies mid-call:** changing a setting **during** a call takes effect without End+rejoin.
|
||||||
|
- [ ] **Room-admin cap (admin needed):** as a room admin, set **Max Microphone Bitrate = 64 kbps** in
|
||||||
|
Room Settings → Voice. A member whose user setting is higher (e.g. 256) should be **clamped to 64**
|
||||||
|
(best-effort/UX — this is client-side; hard server enforcement is a separate follow-up).
|
||||||
|
- [ ] Resetting a setting back to **Auto** removes the cap for the rest of the call.
|
||||||
|
|
||||||
|
> Soundboard + quality are no longer "dormant" — if either does nothing, grab the **EC iframe console**
|
||||||
|
> and check for `io.lotus.inject_audio` / `io.lotus.set_quality` rejections.
|
||||||
|
|
||||||
|
### D2-9. Call Permissions — HARD server-side, cross-client (👥 2 people, admin) — **NEW**
|
||||||
|
|
||||||
|
This is enforced by the `voice-limit-guard` on the server (re-signs the LiveKit JWT), so it applies to
|
||||||
|
**every** client, not just Lotus Chat. Set in **Room Settings → General → Voice → Call Permissions**.
|
||||||
|
_(Requires the guard deployed on LXC 151 — auto-deploys on a `matrix` repo push.)_
|
||||||
|
|
||||||
|
- [ ] **Disable screenshare:** as admin, turn **Allow Screen Sharing** off. In a call, the
|
||||||
|
**screenshare button disappears** in Lotus Chat. ✅ good if no one can screenshare.
|
||||||
|
- [ ] **Cross-client (the important one):** have someone join the **same room from stock Element / Element
|
||||||
|
X** and try to screenshare → the server **refuses** the track (it won't publish). This proves it's
|
||||||
|
not just our client hiding a button.
|
||||||
|
- [ ] **Audio-only room:** turn **Allow Camera** off too → the camera button disappears and cameras are
|
||||||
|
server-blocked for all clients; **microphones still work**.
|
||||||
|
- [ ] **⭐ Live kill (mid-call):** while someone is **actively screensharing**, an admin turns **Allow
|
||||||
|
Screen Sharing** off. Within a few seconds their screenshare should **stop for everyone** on its own
|
||||||
|
(no rejoin needed) — this is the server reconcile loop revoking it live. Works even if the sharer is
|
||||||
|
on stock Element. ✅ good if the share drops within ~3–5 s; ❌ tell us if it keeps going.
|
||||||
|
- [ ] **Turning it back on** restores the ability to screenshare/camera (start a new share).
|
||||||
|
- [ ] **No policy = no change:** a room with Call Permissions left on defaults behaves exactly as before.
|
||||||
|
|
||||||
|
> If any D2 item fails, grab the **EC iframe console** (right-click the call → inspect the iframe) — a
|
||||||
|
> widget-action/payload mismatch shows up there as a `io.lotus.*` rejection or a `MissingKey`/transport log.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
# Backlog of previously-fixed-but-unverified items
|
# Backlog of previously-fixed-but-unverified items
|
||||||
|
|
||||||
> Sections A–D above are **this session's** work. Everything below was fixed in earlier waves and is still flagged **⚠️ UNTESTED** in `LOTUS_BUGS.md` / `LOTUS_TODO.md`. They're grouped by what kind of environment you need (mobile, desktop, screen reader, etc.) so you can knock out a whole category at once. None of these are urgent the way A–D are; do them as you have the right device handy.
|
> Sections A–D above are **this session's** work. Everything below was fixed in earlier waves and is still flagged **⚠️ UNTESTED** (see the outstanding-verification backlog below / `LOTUS_TODO.md`). They're grouped by what kind of environment you need (mobile, desktop, screen reader, etc.) so you can knock out a whole category at once. None of these are urgent the way A–D are; do them as you have the right device handy.
|
||||||
|
|
||||||
## E. Mobile / responsive (needs a real phone, or devtools device emulation)
|
## E. Mobile / responsive (needs a real phone, or devtools device emulation)
|
||||||
|
|
||||||
@@ -341,10 +461,264 @@ Trigger a desktop/browser notification for a new message.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## L. Fixed — verify
|
||||||
|
|
||||||
|
### L1. AFK auto-mute releases the OS microphone indicator on mute (N95) — 👥 live call
|
||||||
|
|
||||||
|
**Context (now FIXED):** `useAfkAutoMute.ts` opened its own `getUserMedia` level-monitor capture for the whole call, so the OS recording indicator (green dot on macOS, mic icon on Windows/Linux) stayed lit even when muted. The capture is now gated on the reactive mic-on state — it runs only while unmuted, so muting releases the stream.
|
||||||
|
|
||||||
|
**To verify:**
|
||||||
|
|
||||||
|
1. Enable **AFK auto-mute** in Settings → Calls and **join a call**.
|
||||||
|
2. Manually **mute your mic** using the call controls → the **OS recording indicator should clear** within ~a second.
|
||||||
|
3. **Unmute** → the indicator should re-appear (capture re-acquired).
|
||||||
|
4. Also confirm AFK still works end-to-end: stay unmuted and silent past the configured timeout → mic auto-mutes with the "muted after inactivity" toast, and the indicator clears.
|
||||||
|
|
||||||
|
### L2. Maskable PWA icon (N108) — Android install
|
||||||
|
|
||||||
|
1. On **Android Chrome**, install Lotus Chat as a PWA (Add to Home Screen).
|
||||||
|
2. Look at the **home-screen icon**.
|
||||||
|
|
||||||
|
**Expected:** the icon fills the adaptive-icon shape cleanly (the logo centered with safe-zone padding on the dark background), **not** clipped at the corners or floating in an odd box. Also worth a quick check in Chrome DevTools → Application → Manifest that the two `purpose: maskable` icons load without a 404 (this also validates the manifest's icon paths resolve in production — a pre-existing path convention I couldn't verify statically).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## M. New features (this round)
|
||||||
|
|
||||||
|
### M1. Search: `has:image` / `has:file` / `has:video` filters
|
||||||
|
|
||||||
|
1. Open message search (in a room with shared images/files/videos in history).
|
||||||
|
2. Run a broad search, then toggle the **Images**, **Files**, **Video** chips (in the filter bar, next to "Has link").
|
||||||
|
|
||||||
|
**Expected:**
|
||||||
|
|
||||||
|
- Each chip narrows the visible results to that message type; multiple active chips = union (any of them).
|
||||||
|
- Toggling them off restores the full results. The existing room/sender/date/has-link filters still work alongside.
|
||||||
|
- **Known limitation (by design):** filtering is client-side over already-fetched results, so the visible count can be lower than the server's total for that query — paginating/loading more pulls in more to filter. Confirm this reads acceptably.
|
||||||
|
|
||||||
|
### M2. Search: recent searches
|
||||||
|
|
||||||
|
1. Run a few different searches, then **clear the search box** and focus it.
|
||||||
|
|
||||||
|
**Expected:** your last (up to 10) distinct searches appear as clickable chips; clicking one re-runs it. A **Clear** affordance wipes the list. The list **persists across a page refresh** (localStorage).
|
||||||
|
|
||||||
|
### M3. Custom accent color (non-TDS themes) — ⚠️ needs your visual judgment
|
||||||
|
|
||||||
|
1. Make sure **Lotus Terminal (TDS)** is **off**. Settings → Appearance → **Custom Accent Color** → pick a color.
|
||||||
|
|
||||||
|
**Expected:**
|
||||||
|
|
||||||
|
- The app's accent (buttons, selected/active states, links, primary chips) recolors to your choice **live**.
|
||||||
|
- **Look critically at quality** (this is the part I can't verify): button **text legibility** (OnMain contrast) on the accent buttons; **hover/active** shades; and **selected-row / chip** backgrounds (the translucent "Container" tints). Try a **light** color and a **dark** color and a **saturated** one.
|
||||||
|
- If a dark accent makes selected-row text (OnContainer) hard to read, tell me — that's the one spot in the auto-derived palette most likely to need tuning.
|
||||||
|
- **Reset** clears it back to the theme default.
|
||||||
|
- Turn **Lotus Terminal ON** → the custom accent should be **ignored** (TDS fixed palette wins) and the picker shows a "non-TDS only" note; turn it back off → custom accent returns.
|
||||||
|
- Reload → the chosen accent **persists**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M4. Search: "Pinned only" filter
|
||||||
|
|
||||||
|
In message search, toggle the **Pinned** chip.
|
||||||
|
**Expected:** results narrow to messages currently pinned in their room; composes with the Images/Files/Video chips and room/sender/date filters; toggling off restores results. It also narrows the **encrypted/local-cache** results section (not just server results). Needs a room with actually pinned messages.
|
||||||
|
|
||||||
|
### M5. New theme presets (Cyberpunk / Ocean / Blood Red / Classic Matrix / Midnight) — ⚠️ visual judgment
|
||||||
|
|
||||||
|
Settings → Appearance → theme picker → try each of the 5 new themes.
|
||||||
|
**Expected:** each applies a complete, legible dark palette. Code review computed WCAG contrast and all pass AA, but **eyeball these specifically**: **Midnight** (lowest-contrast accent `#6b7ca8` — selected/focus states), **Classic Matrix** (green accents, light-green body text on near-black), **Blood Red** (white-ish text on bright-red buttons). Confirm Success/Warning/Critical (save/leave/delete) still look correctly green/amber/red, not recolored. Switching back to a stock theme should fully revert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## N. OIDC / Next-Gen Auth login (MSC3861) — P4-6
|
||||||
|
|
||||||
|
The Lotus client can now sign into OIDC-native homeservers (ones that delegate
|
||||||
|
auth to a Matrix Authentication Service / MAS), e.g. mozilla.org. lotusguild's
|
||||||
|
own server is **not** MSC3861, so test EITHER against a **local MAS dev loop**
|
||||||
|
(full setup in `dev/oidc-test/README.md` — docker-compose + Synapse `msc3861`
|
||||||
|
delta + a `config.json` override) OR against **mozilla.org** with a real account.
|
||||||
|
|
||||||
|
### N1. OIDC login flow (the core test) — needs a MAS homeserver
|
||||||
|
|
||||||
|
1. On the login screen, select the OIDC homeserver (local `localhost:8008`, or `mozilla.org`).
|
||||||
|
2. **Expected:** instead of the username/password form, a single **"Continue with single sign-on"** button appears (password + legacy-SSO are suppressed for that server).
|
||||||
|
3. Click it → redirected to the provider's login page (MAS / `chat.mozilla.org`).
|
||||||
|
4. Authenticate there → redirected back to `…/auth/oidc/callback` → a brief "Signing you in…" spinner → you land in the app, logged in.
|
||||||
|
|
||||||
|
**Expected:** no console CSP violations; you reach the room list as the OIDC user.
|
||||||
|
|
||||||
|
### N2. Session persists across reload (token storage)
|
||||||
|
|
||||||
|
After N1, hard-refresh the page.
|
||||||
|
**Expected:** you stay logged in — the OIDC session (access + refresh token + issuer/clientId/claims) was persisted (`cinny_refresh_token`, `cinny_oidc_*` keys in localStorage).
|
||||||
|
|
||||||
|
### N3. Token refresh (long-lived session)
|
||||||
|
|
||||||
|
Leave the session past the access-token lifetime (MAS default is short — or revoke the access token in the MAS admin UI to force a 401).
|
||||||
|
**Expected:** the client refreshes transparently (no logout); the stored access token rotates (reactive 401 refresh via the wired `OidcTokenRefresher`).
|
||||||
|
|
||||||
|
### N4. Logout revokes at the issuer
|
||||||
|
|
||||||
|
Log out from Settings.
|
||||||
|
**Expected:** back to login; OIDC tokens are revoked at the issuer's `revocation_endpoint` (best-effort) and all `cinny_*` / `cinny_oidc_*` keys are cleared. Logging back in works.
|
||||||
|
|
||||||
|
### N5. Account-management deep-link
|
||||||
|
|
||||||
|
Settings → Account.
|
||||||
|
**Expected:** on an OIDC server a **"Manage account"** card appears (opens the provider's account page in a new tab). On a non-OIDC server (lotusguild) the card is **absent**.
|
||||||
|
|
||||||
|
### N6. Non-OIDC regression — password login unchanged
|
||||||
|
|
||||||
|
Log into **matrix.lotusguild.org** (password) and **matrix.org**.
|
||||||
|
**Expected:** identical to before — username/password form (+ SSO button where offered). The OIDC path only activates when discovery advertises an issuer, so nothing changes for these servers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## O. July 2026 batch — threads, notifications, math, search cache, audit wave
|
||||||
|
|
||||||
|
Everything landed after the OIDC work. These mirror the checklists in `LOTUS_TODO.md` (§P3-8, §P4-1) and the outstanding-verification backlog below (P3-8/P4-1/P4-4/P4-8/N97a/AW-1…4). **⚠️ Threads change the main timeline** — thread replies no longer render inline; that's intended (see O1).
|
||||||
|
|
||||||
|
### O1. Thread Panel (P3-8) — 👥 2 people help for live replies
|
||||||
|
|
||||||
|
1. Hover a message → **Reply in Thread** (message menu). The right-side **thread panel** opens with that message as the root.
|
||||||
|
2. Send text, an emoji, and a file upload into the thread; have the second person reply too.
|
||||||
|
3. Reply to a reply _inside_ the panel.
|
||||||
|
|
||||||
|
**Expected:** the panel shows the root at top + an "N replies" divider + the reply timeline (own composer at the bottom). Your sends appear immediately (pending → confirmed). A reply-to-a-reply is a proper thread reply. In the **main** timeline the replies do **not** appear inline — the root message instead shows a **"N replies · time"** chip. Clicking the chip (or a reply's thread indicator) opens the panel. **×** or **Escape** closes it; on mobile the panel is fullscreen. Scrolled up in a long thread → a **Jump to Latest** chip appears. Reload the page → the root/reply split persists; in an **encrypted** room the thread replies decrypt (not "Unable to decrypt").
|
||||||
|
|
||||||
|
### O2. Per-thread notifications (P4-1, Slack-style) — 👥 2 people
|
||||||
|
|
||||||
|
1. Have the second person reply in a thread **you have posted in** → expect a notification + sound.
|
||||||
|
2. Have them reply in a thread **you have never touched** and don't @mention you → expect **silence** (only the chip's unread badge updates).
|
||||||
|
3. Have them **@mention** you in any thread → expect a notification regardless of participation.
|
||||||
|
4. Open the panel's **bell menu** (header) → set the thread to **Mute** → expect no notifications, the chip's unread badge gone (bell-mute glyph shown), and the room's **sidebar badge drops** by that thread's count. Try **All** (every reply notifies) and **Mentions only** (only @mentions).
|
||||||
|
5. On a **second device**, confirm the same per-thread modes are set (they sync via account data).
|
||||||
|
6. Room-level **Mute** (room context menu) still silences everything, including thread overrides.
|
||||||
|
|
||||||
|
**Known caveat:** Mentions-only can under-notify in E2EE rooms (the decision runs before decryption). Muted-thread badge subtraction is Lotus-only.
|
||||||
|
|
||||||
|
### O3. Math / LaTeX (P4-4)
|
||||||
|
|
||||||
|
Send each and confirm rendering: `$x^2 + y^2$` (inline), `$$\int_0^1 f(x)\,dx$$` (block, centered), `$5 and $10 for lunch` (**stays plain text** — currency guard), and a code block containing `$x$` (**stays literal** inside the code block). **Expected:** the first two render as math (KaTeX); the last two are untouched. First math of the session may show the raw `$…$` for a beat while the KaTeX chunk lazy-loads, then renders.
|
||||||
|
|
||||||
|
### O4. Encrypted search cache (P4-8) — opt-in
|
||||||
|
|
||||||
|
In an **encrypted** room's message search, enable **"Persist search index on this device"** (Encrypted Rooms panel). Search, then **reload** and search the same term. **Expected:** coverage survives the reload (results without re-paginating everything). **Clear cached index** empties it. **Log out** → the cache is wiped (privacy). Toggling the setting OFF does **not** wipe (only Clear/logout do).
|
||||||
|
|
||||||
|
### O5. Session hardening (N97a) — cross-tab
|
||||||
|
|
||||||
|
1. Log in on a build that predates the change, then load this build → you stay logged in (legacy keys migrate to the `cinny_session_v1` blob; check DevTools → Application → Local Storage).
|
||||||
|
2. Open the app in **two tabs**; **log out** in tab A → tab B reloads to the auth screen within a moment. Log in again in one tab → the other reloads too.
|
||||||
|
|
||||||
|
### O6. Audit-wave correctness fixes (AW-1)
|
||||||
|
|
||||||
|
- **Scheduled-message cancel:** schedule a message, then cancel it **with the network cut** (DevTools offline) → the item **stays** with an inline error (it does **not** silently disappear and still send). Restore network, retry → cancels cleanly.
|
||||||
|
- **Escape coordination:** in a thread panel, open the mention autocomplete or set a reply draft, press **Escape** → it dismisses the autocomplete/reply **without** closing the panel. A bare Escape (nothing to dismiss) still marks the room read / closes the panel as before.
|
||||||
|
- **Panel exclusivity:** on mobile, opening a thread while the media gallery (or members drawer) is open shows only **one** right panel (thread wins), not stacked fullscreen overlays.
|
||||||
|
- **Emoji board (AW-2):** the **first** time you open the emoji board / autocomplete in a session, the grid **and search** populate with unicode emoji (they don't stay empty). Reactions still show a label.
|
||||||
|
|
||||||
|
### O7. Desktop (Tauri) — CSP tighten + native stack (AW-4) — 🖥️ desktop build only
|
||||||
|
|
||||||
|
The webview CSP was tightened and the full native module set now compiles. Smoke-test the desktop build:
|
||||||
|
|
||||||
|
1. App **boots**, avatars + media thumbnails load, the **VT323** terminal font renders (Lotus Terminal theme), a **location message** embeds its OpenStreetMap map, **calls** connect (EC iframe), **deep links** (`matrix:` / clicking a room link) navigate.
|
||||||
|
2. **Native features:** minimize to tray (notifications still arrive), a message notification is a **rich toast** (click opens the room; reply box sends), the taskbar **Jump List** lists recent rooms, in a call the taskbar thumbnail shows **Mute/Deafen/End**, Windows **Focus Assist** silences Lotus.
|
||||||
|
3. **Console** (desktop devtools) shows **no CSP violations** during normal use. If something visual/media is blocked, that's the CSP to loosen — note exactly what and where.
|
||||||
|
|
||||||
|
### O8. E2EE / call-key cluster (KE-1→4) — 👥 2 people, during a real call
|
||||||
|
|
||||||
|
We shipped the diagnostics kit + a **Crypto Diagnostics** card (**Settings → Developer Tools**). During your next call that glitches (audio cutouts, "Unable to decrypt"), open it and **Download report**, and note whether the symptoms even still occur now that we're on **matrix-js-sdk 41.7.0** (crypto-wasm 18.3.1). Send me the report; the KE-1..4 diagnosis + capture guidance is in `LOTUS_TODO.md` (Encryption / E2EE), with the full original runbook in git history.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P. Accessibility (P3-4) — needs a browser + a screen reader
|
||||||
|
|
||||||
|
The compliance fixes are gate-verified in code; these confirm the runtime a11y behavior only a human + AT can check. Tools: browser DevTools "axe" extension / Lighthouse a11y, plus **VoiceOver** (macOS ⌘F5) or **NVDA** (Windows).
|
||||||
|
|
||||||
|
### P1. Keyboard-only golden path (no mouse)
|
||||||
|
|
||||||
|
Tab from page load: **skip-to-content** link appears first (Enter jumps to the timeline). Tab reaches the room list (rooms are focusable, active room announced), open a room (Enter), type a character → focus lands in the composer, send with Enter (or Shift+Enter per your `enterForNewline` setting). No keyboard trap; visible focus ring throughout.
|
||||||
|
|
||||||
|
### P2. `?` shortcuts dialog
|
||||||
|
|
||||||
|
Press **?** (Shift+/) with focus NOT in a text field → the keyboard-shortcuts dialog opens, is focus-trapped, Escape closes it and focus returns to where you were. Pressing `?` while typing in the composer/search inserts a literal `?` (does NOT open the dialog).
|
||||||
|
|
||||||
|
### P3. Screen-reader: reading messages
|
||||||
|
|
||||||
|
With VoiceOver/NVDA on, arrow through the timeline: each message is announced as an article with **sender name + time** — critically, this includes **collapsed messages** (consecutive messages from the same person), which previously announced only the body with no sender. Reactions, "edited", replies, and delivery status are announced with labels.
|
||||||
|
|
||||||
|
### P4. Screen-reader: live announcements
|
||||||
|
|
||||||
|
- **New message** arrives while you're reading → announced (polite).
|
||||||
|
- **Someone starts typing** → "X is typing" announced once (not spammed per keystroke).
|
||||||
|
- **Editing a message** → the edit box announces "Editing message from X".
|
||||||
|
|
||||||
|
### P5. Focus return from dialogs
|
||||||
|
|
||||||
|
Open then close (Escape or ×): the **room topic viewer**, a **reaction viewer** (click a reaction count), and **Search** → focus returns to the button/element you opened them from (not lost to `<body>`). Inline popouts (emoji picker, autocomplete, hover menus) intentionally keep focus in context — that's expected, not a bug.
|
||||||
|
|
||||||
|
### P6. axe / Lighthouse scan
|
||||||
|
|
||||||
|
Run the axe DevTools extension (or Lighthouse → Accessibility) on a room view, Settings, and the login screen. Expect **no critical/serious** "missing accessible name" or "ARIA" violations on the golden path. Report any that appear (note: far-scrolled timeline history being virtualized out is a known, accepted limitation — not a finding).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Priority if you're short on time
|
## Priority if you're short on time
|
||||||
|
|
||||||
1. **A4** (in-call banner) + **A3** (ringtone) — newest, most logic, hardest to reproduce.
|
1. **O1 + O2** (threads + per-thread notifications) — the largest new surface; the main-timeline change is user-visible.
|
||||||
2. **B1–B3** (polls on a default theme) — the confirmed visual bug.
|
2. **O7** (desktop CSP smoke) — CI can't catch CSP breakage; a wrong directive silently breaks media/fonts/maps.
|
||||||
3. **D** (EC 0.20.1 control sweep) — guards against the upstream merge breaking calls.
|
3. **O5** (session cross-tab) + **O6** (scheduled-cancel ghost-send) — auth-critical + a real data-loss-class fix.
|
||||||
4. **A7** false-positive check (normal joins don't show the error overlay).
|
4. **A4** (in-call banner) + **A3** (ringtone) — newest call logic, hardest to reproduce.
|
||||||
5. Everything else.
|
5. **D** (EC control sweep) — guards against the fork breaking calls.
|
||||||
|
6. Everything else.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Outstanding verification backlog
|
||||||
|
|
||||||
|
**Unread dot on federated rooms + avatar-decoration console storm (2026-07):**
|
||||||
|
|
||||||
|
- **Read receipts (regression guard — highest priority):** open several rooms and open the Home/Direct tabs (which mark all orphan rooms read on mount) → rooms **stay read**, unread dots clear and don't come back. (A prior attempt sent a receipt for the thread _root_ when a thread's replies weren't loaded, which the SDK treats as a main receipt at an old event and re-unread every room on every mark-read. Fixed + locked by `notifications.test.ts`.)
|
||||||
|
- **Thread dot:** a room with an unread reply in a thread whose replies are loaded → its dot clears on read; for a thread not yet loaded, the dot clears once you open/load the thread. (mark-as-read now sends a threaded receipt only for a genuine loaded reply, never the root.)
|
||||||
|
- With DevTools console open on federated rooms, the `io.lotus.avatar_decoration` `403`/`502` (and federated media) errors should **not** repeat on every scroll/mount — each failing user is now requested at most ~twice per session, so the storm (and its homeserver load) is gone.
|
||||||
|
|
||||||
|
**Custom Window Chrome (Beta) fix (2026-07):** on the desktop build, Settings → General → toggle **Custom Window Chrome** — it should reload and come up with the Lotus title bar and a normal, stable feed (no screen-expand / auto-scroll-into-the-past). Toggle back off → reloads to the native frame.
|
||||||
|
|
||||||
|
_Ported from the retired `LOTUS_BUGS.md` (2026-07). Compact index of shipped-but-not-live-tested items; the detailed steps are in the lettered sections above._
|
||||||
|
|
||||||
|
Implemented and gate-green; confirm each per `LOTUS_TESTING.md`, then delete the row.
|
||||||
|
|
||||||
|
| ID | Item | File / area | Test |
|
||||||
|
| :--- | :-------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| #2 | Chat-background animation flicker (`contain:paint`) | `lotus/chatBackground.ts` | F1 |
|
||||||
|
| #4 | Ringtone re-fixes: classic loudness + caller decline notice (A2 ✓ live) | `CallEmbedProvider.tsx`, `ringtones.ts` | A1,A3,A4 |
|
||||||
|
| #6 | Background vs. seasonal theme mutual exclusion | `state/settings.ts`, `General.tsx` | F2 |
|
||||||
|
| #7 | Composer toolbar touch targets (≥44px) | `room/RoomInput.tsx` | E1 |
|
||||||
|
| #8 | Room Settings horizontal overflow (mobile) | `components/page/style.css.ts` | E2 |
|
||||||
|
| #9 | Modal fullscreen on mobile (`useModalStyle`) | 22+ modal files | E3 |
|
||||||
|
| #10 | Composer not hidden by keyboard (`100dvh`) | `src/index.css` | E4 |
|
||||||
|
| #12 | PiP "All muted" badge re-fixed (was firing on any single mute) | `hooks/useCallSpeakers.ts` | G1 |
|
||||||
|
| N96 | Call-recovery overlay single "Back" button | `call/CallView.tsx` | A7 |
|
||||||
|
| N95 | AFK-monitor mic released on mute (OS indicator clears) | `hooks/useAfkAutoMute.ts` | L1 |
|
||||||
|
| N108 | Maskable PWA icons (Android adaptive) | `public/manifest.json` + `res/android/maskable-*` | L2 |
|
||||||
|
| EC | EC iframe load watchdog + self-heal + recovery UI | `plugins/call/CallEmbed.ts`, `CallView.tsx` | A7 |
|
||||||
|
| N105 | Notification clicks work after tab close (SW `notificationclick` + `showNotification`) | `sw.ts`, `utils/dom.ts`, `ClientNonUIFeatures.tsx` | get a msg notif, close the tab, click it → app focuses/opens + routes to the room |
|
||||||
|
| Gal | MediaGallery lazy-decrypt (true virtualization deferred) | `room/MediaGallery.tsx` | H1 |
|
||||||
|
| a11y | aria-labels: edit-history / reaction / thread / reply | `message/*` (`FallbackContent`, `Reaction`, `Reply`) | I |
|
||||||
|
| P3-8 | Thread Panel (side drawer, chips, threaded receipts, thread composer) | `features/room/thread/*`, `RoomTimeline/RoomInput` | 6-step checklist in LOTUS_TODO §P3-8 |
|
||||||
|
| P4-4 | KaTeX math (`$…$`, `$$…$$`, data-mx-maths; lazy chunk) | `utils/mathParse.ts`, `components/math/` | send `$x^2$`, `$$\int f$$`, `$5 and $10` (stays text), math inside code block (stays text) |
|
||||||
|
| P4-8 | Encrypted-search cache (opt-in toggle, clear button, logout wipe) | `utils/searchCache.ts`, message-search | enable in search panel → search → reload → coverage persists; logout wipes |
|
||||||
|
| N97a | Session blob migration + cross-tab logout sync | `state/sessions.ts`, `useSessionSync` | login on old build → new build migrates; logout in tab A → tab B drops to auth |
|
||||||
|
| P4-1 | Slack-style thread notifications (participating default, All/Mentions/Mute, badge math) | `utils/threadNotifications.ts`, `ClientNonUIFeatures`, `roomToUnread` | 6-step checklist in LOTUS_TODO §P4-1 |
|
||||||
|
| AW-1 | Scheduled-message cancel no longer ghost-sends (error row on failure) | `ScheduledMessagesTray.tsx` | schedule → cancel with network cut → item stays + error; retry works |
|
||||||
|
| AW-2 | Emoji lazy-load (search/autocomplete/recents fill in; board opens fast) | `plugins/emoji.ts` + consumers | first emoji-board open of a session: grid+search populate; reactions still label |
|
||||||
|
| AW-3 | SW precache (repeat-visit near-instant; deploys still picked up immediately) | `sw.ts`, `vite.config.js` | load app twice (2nd = cached assets); deploy → reload picks new version |
|
||||||
|
| AW-4 | Desktop CSP tighten + Escape/panel fixes + thread Jump to Latest | `tauri.conf.json`, Room/ThreadPanel | desktop: boots, avatars/media load, VT323 font renders, location maps embed, calls connect, deep links work |
|
||||||
|
| P3-4 | Accessibility compliance pass (collapsed-msg SR sender, form/overlay labels, typing announce, focus-return, `?` help, jsx-a11y CI gate) | `message/*`, `RoomViewTyping`, `features/shortcuts/*`, `eslint.config.mjs` | LOTUS_TESTING §P — axe-core + VoiceOver/NVDA on the golden path |
|
||||||
|
| P6-1 | Desktop Linux parity (no-sleep in calls, launcher badge), autostart toggle, tray Do-Not-Disturb | `native/power.rs`, `lib.rs`, `useTauriDnd`, `General.tsx` | Linux desktop: no display sleep during a call; tray DND silences notifications; launch-on-login persists; Unity badge (Ubuntu); DND toggle polarity |
|
||||||
|
| P6-2 | EC deafen/screenshare-audio-mute via `io.lotus.set_deafen` (retires the `<audio>.muted` iframe hack) | fork `lotusDeafen.ts`, cinny `CallControl.ts` | AFTER publish+pin-bump: deafen silences remote audio + survives a reconnect / new screenshare / late joiner (the cases the DOM hack failed); screenshare-audio-mute toggles independently |
|
||||||
|
| P6-3 | Forward-to-multiple-rooms (multi-select + partial-failure summary) + live bookmark previews (edits/redactions, snapshot fallback) | `ForwardMessageDialog.tsx`+`forwardContent.ts`, `BookmarksPanel.tsx` | forward one msg to 3 rooms (incl. 1 you cannot post to = partial summary); bookmark then edit shows edited; redact shows deleted; leave room shows snapshot |
|
||||||
|
| P6-4 | HSTS + Permissions-Policy on prod nginx (+ contrib examples) | `matrix/cinny/nginx.conf`, `contrib/nginx`, `contrib/caddy` | after `nginx -s reload`: `curl -sI https://chat.lotusguild.org` shows HSTS + Permissions-Policy; a call (cam/mic/screenshare) + location share still work |
|
||||||
|
|
||||||
|
**Verified working in live testing (2026-06):** A2, B1–B4, C1, C3, D (mic/camera/deafen/screenshare/fullscreen/more-menu/PiP). Denoise quality in D is still poor — tracked under the denoise project, not a regression.
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
+387
-178
@@ -5,28 +5,6 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🏗️ Infrastructure & Maintenance
|
|
||||||
|
|
||||||
- [x] **Upgrade Synapse to v1.155.0** ✅ Done 2026-06-18
|
|
||||||
- **Context:** 1.155.0 is the last version supporting Debian 12 Bookworm. LXC 151 is already on Debian 13 Trixie — OS migration was completed prior to this upgrade.
|
|
||||||
- **What changed (1.154→1.155):** No breaking changes, no config changes, no DB migrations. Bugfixes: to-device EDU size limiting, restricted room joins, sliding sync subscription response timing. Rust port of more internal classes (perf only).
|
|
||||||
- **MSC4452** (Preview URL capabilities) shipped in 1.154 — opt-in via `msc4452_enabled`, not enabled.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📱 Quick Feature Additions
|
|
||||||
|
|
||||||
- [x] **Full-Screen Camera Broadcasts** ⚠️ UNTESTED — verify in a real call
|
|
||||||
- **Context:** Element Call currently supports full-screening screenshares. We need to parity this functionality for camera broadcasts.
|
|
||||||
- **Goal:** Users should be able to toggle any camera feed to full-screen mode, similar to the existing screenshare full-screen implementation.
|
|
||||||
- **Implemented 2026-06-18:**
|
|
||||||
1. **Fullscreen button always shows** — removed `screenshare &&` gate in `CallControls.tsx`. The fullscreen button is now available in camera-only calls, not just during screenshares.
|
|
||||||
2. **Per-participant camera focus** — `CallControl.focusCameraParticipant(userId)` added. Finds the participant's video tile via `[data-testid="videoTile"]` / `[data-video-fit]` + `[aria-label="${userId}"]`, enables spotlight mode, then clicks the tile to focus them.
|
|
||||||
3. **MemberGlance "Focus camera" action** — clicking a participant avatar in the call status bar now opens a mini popup with "Focus camera" (triggers focusCameraParticipant) and "View profile" options, rather than immediately opening the profile.
|
|
||||||
4. **PiP fullscreen button** — a small fullscreen toggle button (⛶/⊡) is shown in the PiP overlay top-right, allowing users to go fullscreen directly from PiP mode without navigating back to the call room.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚠️ TDS DESIGN LAW — READ BEFORE TOUCHING ANY UI
|
## ⚠️ TDS DESIGN LAW — READ BEFORE TOUCHING ANY UI
|
||||||
|
|
||||||
> **ALL Lotus Terminal Design System (TDS) styling — colors, animations, glows, borders, fonts, spacing — MUST come exclusively from `/root/code/web_template/base.css` CSS variables.**
|
> **ALL Lotus Terminal Design System (TDS) styling — colors, animations, glows, borders, fonts, spacing — MUST come exclusively from `/root/code/web_template/base.css` CSS variables.**
|
||||||
@@ -37,10 +15,45 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 🧩 NATIVE-CINNY LAW — EVERY FEATURE MUST FEEL LIKE STOCK CINNY
|
||||||
|
|
||||||
|
> **Every feature we implement must feel native to the upstream Cinny app — indistinguishable from something the Cinny team would have shipped.** Reference: <https://github.com/cinnyapp/cinny>.
|
||||||
|
>
|
||||||
|
> Concretely this means:
|
||||||
|
>
|
||||||
|
> - **Use the `folds` design system, not bespoke UI.** Build with folds primitives (`Button`, `Chip`, `IconButton`, `Menu`, `MenuItem`, `Dialog`, `Modal`, `Input`, `Switch`, `Badge`, `SettingTile`, `SequenceCard`, etc.) and folds tokens (`color.*`, `config.space.*`, `config.radii.*`, `config.borderWidth.*`). No hardcoded hex/`rgba()` for UI chrome, no invented/undefined CSS variables.
|
||||||
|
> - **Match Cinny's existing patterns.** Before adding UI, find the closest existing Cinny component/flow and mirror it (e.g. a new dropdown uses `Button`+`PopOut`+`Menu`+`MenuItem` like the rest; a new modal has a `Header` with a close `IconButton`; a new setting is a `SettingTile` inside a `SequenceCard`). Consistency with stock Cinny beats personal style.
|
||||||
|
> - **Lotus-custom additions should be unobtrusive** and fit Cinny's visual language, spacing, and interaction conventions — a stranger using Cinny should not be able to tell which features are ours.
|
||||||
|
>
|
||||||
|
> **The ONE exception:** explicit **Lotus Terminal Design System (TDS)** features, which intentionally have their own distinct look and follow the **TDS Design Law** above. TDS styling is opt-in (only active in Lotus Terminal mode); everything else must look and feel like native Cinny.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
Completed features are documented in [LOTUS_FEATURES.md](./LOTUS_FEATURES.md).
|
Completed features are documented in [LOTUS_FEATURES.md](./LOTUS_FEATURES.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## ✅ Done — Awaiting Verification
|
||||||
|
|
||||||
|
Built and gate-green; verify per [LOTUS_TESTING.md](./LOTUS_TESTING.md), then they graduate to LOTUS_FEATURES.md. (Open bugs + the verification backlog now live in this file and LOTUS_TESTING.md.)
|
||||||
|
|
||||||
|
| Feature | Test guide |
|
||||||
|
| :-------------------------------------------------------------------------------- | :---------------- |
|
||||||
|
| Full-Screen Camera Broadcasts (per-participant focus) | A5 / G2 |
|
||||||
|
| Advanced search filters (sender/date/has-link/has:image·file·video/pinned/recent) | K2 / M1 / M2 / M4 |
|
||||||
|
| Custom Accent Color Picker (non-TDS themes) | M3 |
|
||||||
|
| 5 Color Theme Presets (Cyberpunk/Ocean/Blood Red/Matrix/Midnight) | M5 |
|
||||||
|
| Intersection-based lazy media loading | H1 |
|
||||||
|
| Context-aware thumbnail previews | H2 |
|
||||||
|
| Desktop — proactive update notifications (Tauri) | J1 |
|
||||||
|
| Remind Me Later | K1 |
|
||||||
|
| Mobile Bookmarks access | E5 |
|
||||||
|
| In-Call Soundboard (P5-15, uploadable clips → real call inject) | D2-7 |
|
||||||
|
| Call Quality Controls (P5-31, user + room-admin caps) | D2-8 |
|
||||||
|
| Call Permissions (P5-31, hard server-side screenshare/camera policy) | D2-9 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
Legend:
|
Legend:
|
||||||
|
|
||||||
- `[AUDIT REQUIRED]` — at least one assumption needs code/server verification before implementing
|
- `[AUDIT REQUIRED]` — at least one assumption needs code/server verification before implementing
|
||||||
@@ -63,7 +76,7 @@ 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 |
|
||||||
@@ -83,7 +96,7 @@ Status: `[ ]` pending · `[~]` in progress · `[x]` completed
|
|||||||
| `knockSupported()` utility exists at `matrix.ts:376-391` | Knock UX: only need "Request to Join" in `RoomIntro.tsx` |
|
| `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 |
|
||||||
@@ -128,7 +141,12 @@ Status: `[ ]` pending · `[~]` in progress · `[x]` completed
|
|||||||
|
|
||||||
## Priority 3 — Higher complexity / lower daily frequency
|
## Priority 3 — Higher complexity / lower daily frequency
|
||||||
|
|
||||||
### [ ] P3-4 · Accessibility Improvements (WCAG 2.1 AA)
|
### [~] P3-4 · Accessibility Improvements (WCAG 2.1 AA) — COMPLIANCE PASS DONE (2026-07), ⚠️ AWAITING LIVE AXE/SR AUDIT
|
||||||
|
|
||||||
|
**Shipped (compliance + shortcuts-help tier):** messages `role="article"` + collapsed-message sender/time announced to AT (the biggest gap — collapsed rows had no sender for a screen reader); ~10 unlabeled form inputs + Media Gallery / Search overlays named; emoji/emoticon aria-labels; typing indicator now announced via a `role="status"` live region; editing a message announces "Editing message from X"; focus now returns to the trigger on close of 4 genuine dialogs (RoomIntro/Reactions/RoomViewHeader-topic/Search — inline popouts correctly left); a `?` keyboard-shortcuts help dialog; and a **jsx-a11y lint gate** (curated ARIA-correctness + label rules, enforced in CI) to prevent regressions. Already-good before this pass: skip link + landmarks, timeline `role="log"`/`aria-live`, ~99% icon-button labels, labeled editor.
|
||||||
|
**DEFERRED (documented):** virtualization keeps scrolled-away history out of the a11y tree (architectural; the live-region announces newly-arriving messages) — not re-architected to avoid perf regression; roving-tabindex + command palette + section-jump shortcuts (user-deferred); the live axe-core / VoiceOver+NVDA audit → LOTUS_TESTING §P.
|
||||||
|
|
||||||
|
_Original scope (for reference):_
|
||||||
|
|
||||||
**What:** Comprehensive audit and fix pass targeting the critical user paths:
|
**What:** Comprehensive audit and fix pass targeting the critical user paths:
|
||||||
|
|
||||||
@@ -149,10 +167,19 @@ Status: `[ ]` pending · `[~]` in progress · `[x]` completed
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### [ ] P3-8 · Thread Panel (full side drawer)
|
### [~] P3-8 · Thread Panel (full side drawer) — IMPLEMENTED (2026-07), ⚠️ AWAITING LIVE QA
|
||||||
|
|
||||||
**⚠️ LARGEST FEATURE — requires its own planning session before implementation.**
|
Built per the design below (4-agent build + 2-agent review). Gates green (tsc/eslint/build/tests). **Release note: threaded replies no longer render inline in the main timeline — roots show a "N replies" chip that opens the panel.**
|
||||||
**What:** A right-side drawer for threaded conversations. Currently "Reply in Thread" exists but there is no panel to read or write thread replies.
|
|
||||||
|
**Manual QA checklist (post-deploy):**
|
||||||
|
|
||||||
|
1. Reply in Thread (message menu) → panel opens; send text/upload/emoji into it (appears pending → confirmed)
|
||||||
|
2. Reply to a reply inside the panel → event carries `m.thread` + `m.in_reply_to` with `is_falling_back:false`
|
||||||
|
3. Main timeline: root + chip only (replies absent); chip count/time updates live; unread badge appears for others' thread replies and clears after viewing the panel
|
||||||
|
4. Room badge clears via normal markAsRead even with unread threads (unthreaded receipt)
|
||||||
|
5. Reload: partitioning persists; encrypted-room threads decrypt (back-pagination too)
|
||||||
|
6. Escape / × closes; mobile = fullscreen panel; switching rooms and back restores the open thread
|
||||||
|
**What:** A right-side drawer for threaded conversations. Currently "Reply in Thread" exists but there is no panel to read or write thread replies.
|
||||||
|
|
||||||
Features:
|
Features:
|
||||||
|
|
||||||
@@ -183,40 +210,28 @@ Features:
|
|||||||
|
|
||||||
## Priority 4 — Specialized, high complexity, or low priority
|
## Priority 4 — Specialized, high complexity, or low priority
|
||||||
|
|
||||||
### [ ] P4-7 · Virtualized Infinite Scroll for Search Results
|
### [x] P4-7 · Virtualized Infinite Scroll for Search Results — ALREADY IMPLEMENTED (found 2026-07)
|
||||||
|
|
||||||
**What:** Replace the manual "load more" button with an automated, virtualized infinite scroll for search results.
|
**What:** Replace the manual "load more" button with an automated, virtualized infinite scroll for search results.
|
||||||
**Approach:** Utilize `@tanstack/react-virtual` in `MessageSearch.tsx` to handle the `nextToken` automatically as the user scrolls.
|
**Status:** Done in a prior session — `MessageSearch.tsx` already uses `useVirtualizer` (~line 336) over the result groups AND auto-fetches the `nextToken` page when the last virtual item scrolls into view (~line 469) via `useInfiniteQuery`. Nothing left to build.
|
||||||
|
|
||||||
### [ ] P4-8 · Encrypted Message Search Indexing & Caching
|
### [~] P4-8 · Encrypted Message Search Indexing & Caching — IMPLEMENTED (2026-07), opt-in
|
||||||
|
|
||||||
**What:** Implement a persistent local cache for search results, optimized for encrypted rooms.
|
**Shipped:** `src/app/utils/searchCache.ts` — raw-IndexedDB per-room index (`lotus-search-cache`) of decrypted search rows + coverage markers, merged into local search (in-memory-wins dedupe). **Opt-in, default OFF** (stores plaintext at rest) with a privacy note, Clear button, and logout wipe. Awaiting live QA (LOTUS_TESTING outstanding-verification backlog).
|
||||||
**Approach:** Use `IndexedDB` to store search metadata (event IDs, timestamps) to prevent redundant server-side decryption/fetching.
|
|
||||||
|
|
||||||
### [x] P4-9 · Advanced Search Filter UI — PARTIALLY DONE (UNTESTED)
|
### [~] P4-1 · Thread Notification Mode Per-Thread — IMPLEMENTED (2026-07), ⚠️ AWAITING LIVE QA
|
||||||
|
|
||||||
**What:** Improve search filter UX in `SearchFilters.tsx`.
|
**Shipped (Slack-style):** default = **Participating** (notified only for threads you've posted in or where you're @mentioned); per-thread override **All / Mentions-only / Mute** via the bell menu in the thread panel header; modes sync across devices (`io.lotus.thread_notifications` account data, pruned on write). Mute also suppresses the chip badge and subtracts the thread from the room's sidebar badge (client-side). Also fixed the underlying path: thread replies are notified via exactly one handler (room-level `ThreadEvent.NewReply`), with the main-timeline notifier + unread binder thread-guarded, and live badge refresh on `RoomEvent.UnreadNotifications`.
|
||||||
**Completed 2026-06-18:**
|
|
||||||
|
|
||||||
- ✅ `SelectSenderButton` — picker UI for sender filter (previously required typing `from:@user` by hand)
|
**Manual QA checklist (post-deploy):**
|
||||||
- ✅ `DateRangeButton` — quick-pick presets: Today / Last week / Last month / Last year
|
|
||||||
- ✅ `Has link` chip — `contains_url: true` filter, wired to Matrix API and URL param
|
|
||||||
**UNTESTED** — needs verification at chat.lotusguild.org.
|
|
||||||
|
|
||||||
**Remaining for parity with Discord/Slack:**
|
1. Friend replies in a thread YOU posted in → notification + sound; in a thread you never touched → silent (chip badge only)
|
||||||
|
2. @mention in any thread → notified regardless of participation
|
||||||
- [ ] `has:image` / `has:file` / `has:video` — msgtype filters (require client-side post-filtering, no server API)
|
3. Set a thread to Mute → no notifications, chip badge gone (bell-mute glyph), room sidebar badge drops by that thread's count
|
||||||
- [ ] Pinned messages filter
|
4. Set to All → every reply notifies; Mentions-only → only @mentions
|
||||||
- [ ] Saved searches / search history
|
5. Second device shows the same per-thread modes (account-data sync)
|
||||||
|
6. Room-level Mute still silences everything incl. thread overrides
|
||||||
---
|
**Known caveats:** Mentions-only can under-notify in E2EE rooms (decision runs pre-decryption — same class as the existing notifier); muted-thread badge subtraction is Lotus-only (other clients still count them).
|
||||||
|
|
||||||
### [ ] P4-1 · Thread Notification Mode Per-Thread (MSC3771)
|
|
||||||
|
|
||||||
**Spec:** MSC3771 (stable). Depends on Thread Panel (#P3-8).
|
|
||||||
**What:** Per-thread notification toggle: "All messages" vs "Mentions only". Accessible from the thread panel header. Tracks unread counts separately per thread.
|
|
||||||
**[AUDIT REQUIRED]** — Implement after Thread Panel. Requires understanding how the SDK tracks per-thread unread counts.
|
|
||||||
**Complexity:** Medium (after thread panel exists).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -249,69 +264,42 @@ Features:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### [ ] P4-6 · OIDC / SSO Next-Gen Auth (MSC3861) (EXTREME COMPLEXITY, LOW PRIORITY)
|
### [~] P4-6 · OIDC / SSO Next-Gen Auth (MSC3861) — CLIENT-SIDE BUILT, awaiting live verification
|
||||||
|
|
||||||
**Spec:** MSC3861, merged Matrix spec v1.15. Uses Matrix Authentication Service (MAS).
|
**Spec:** MSC3861 / MSC2965, Matrix spec v1.15. OAuth2-native auth via a Matrix Authentication Service (MAS).
|
||||||
**Context:** ~80% of homeserver users have LLDAP/Authelia/SSO accounts. SSO is currently enabled on `matrix.lotusguild.org` but accounts are not yet linked. This would allow users to log in via their SSO credentials.
|
**Scope decision (2026-06):** CLIENT-ONLY. We implemented OIDC login _in the Lotus client_ so it can sign into next-gen homeservers (mozilla.org, eventually matrix.org). We deliberately did **not** convert lotusguild's own Synapse to MAS (no account migration; lotusguild keeps password + legacy Authelia SSO).
|
||||||
**What:** OAuth 2.0 / OIDC login flow, token refresh, account management page linking Matrix identity to SSO identity.
|
**Built (matrix-js-sdk already ships the OIDC API; this was wiring):**
|
||||||
**EXTREME COMPLEXITY** — requires: MAS deployment/configuration on the homeserver, significant auth flow changes in the client, token refresh handling, session management overhaul.
|
|
||||||
**[SERVER CHECK]** — Before any client work, audit whether MAS is already deployed on `compute-storage-01`. Check: `pct exec 151 -- systemctl status matrix-authentication-service` or similar.
|
- Discovery: `cs-api.ts` `getOidcIssuer()` (stable `m.authentication` + msc2965). Flow hint: `useParsedLoginFlows` `getOidcCompatibilityFlag()` (MSC3824).
|
||||||
**Complexity:** Extreme. Multi-sprint project. Plan separately.
|
- Login: `pages/auth/oidc/{oidcConfig,oidcLoginUtil,oidcState}.ts` (dynamic registration + cache, PKCE authorize), `login/OidcLogin.tsx`, issuer-gated `Login.tsx`.
|
||||||
|
- Callback: `oidc/OidcCallback.tsx` + `App.tsx` short-circuit (non-hash redirect path).
|
||||||
|
- Session/refresh: `state/sessions.ts` OIDC fields, `client/{oidcTokenRefresher,oidcLogout}.ts`, `initMatrix.ts` wiring.
|
||||||
|
- Account mgmt: `settings/account/OidcManageAccount.tsx`.
|
||||||
|
- 13 unit tests (discovery/flow/session/cache/callback parsing). All gates green.
|
||||||
|
**Awaiting verification (needs a real MSC3861 server — lotusguild is NOT one):** deploy + log into **mozilla.org** (requires adding mozilla to the deployed `config.json` homeserverList + its domains to the CSP `connect-src`/`img-src` — see below), OR run a local `matrix-authentication-service` + Synapse `msc3861` dev loop.
|
||||||
|
**Mozilla.org test enablement: ALREADY DEPLOYED (verified 2026-07)** — `matrix/cinny/config.json` homeserverList includes `mozilla.org` and the nginx CSP `connect-src` includes the mozilla/modular/vector domains (`matrix/cinny/nginx.conf:42`). **Nothing blocks the test — just pick mozilla.org on the login screen and complete an OIDC login.**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Priority 5 — Gamer / Aesthetic / Customization
|
## Priority 5 — Gamer / Aesthetic / Customization
|
||||||
|
|
||||||
### [ ] P5-1 · Custom Accent Color Picker (non-TDS mode only)
|
|
||||||
|
|
||||||
**What:** A hex/HSL color picker in Settings → Appearance. Chosen color replaces the primary accent throughout the UI: buttons, badges, active states, highlights, presence dot, links. Applied via a CSS custom property override injected into `<head>`.
|
|
||||||
**IMPORTANT:** This feature is completely inactive when TDS is enabled — TDS has its own fixed palette. Add this setting under a "Non-TDS Themes" section that is hidden when TDS is active.
|
|
||||||
**[AUDIT REQUIRED]** Identify all CSS custom properties that constitute the "accent color" in non-TDS mode. Map them to the folds/vanilla-extract token names. (Confirmed: folds uses vanilla-extract, NOT CSS custom properties — must create a new vanilla-extract theme variant dynamically.)
|
|
||||||
**Complexity:** Medium.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### [ ] P5-2 · Additional Color Theme Presets
|
|
||||||
|
|
||||||
**What:** 5 new one-click theme presets alongside TDS. Each must be a complete, polished system with proper contrast ratios (WCAG AA). All implemented as vanilla-extract themes matching the existing TDS pattern.
|
|
||||||
|
|
||||||
Themes:
|
|
||||||
|
|
||||||
1. **Cyberpunk** — deep navy bg (`#0a0015`), electric purple (`#bf5fff`) + hot pink (`#ff2d9b`) accents, neon glow
|
|
||||||
2. **Ocean** — deep sea blue bg (`#020b18`), teal (`#00c9b1`) + aqua (`#0096d6`) accents, soft feel
|
|
||||||
3. **Blood Red** — near-black bg (`#0d0203`), deep crimson (`#7a0010`) + bright red (`#ff2233`) accents
|
|
||||||
4. **Classic Matrix** — pure black bg (`#000000`), phosphor green (`#00ff41`) text + accents
|
|
||||||
5. **Midnight** — dark charcoal (`#111827`), cool blue-grey (`#6b7ca8`) accents, clean minimal
|
|
||||||
|
|
||||||
**[AUDIT REQUIRED]** Study `src/lotus-terminal.css.ts` for the full token list before designing themes. All tokens must be covered (~50 CSS custom properties each).
|
|
||||||
**Complexity:** Medium (design effort is the main cost).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### [MOVED] P5-9 · LFG (Looking for Group) Command → LotusBot
|
### [MOVED] P5-9 · LFG (Looking for Group) Command → LotusBot
|
||||||
|
|
||||||
**Decision:** Implemented as `!lfg` in LotusBot rather than a client slash command. Bot-side rendering works consistently across all Matrix clients; client-side enhanced cards would only be visible to Lotus Chat users and require sanitizer auditing. The bot can also support richer flows (list active LFGs, DM interested players, auto-expire posts).
|
**Decision:** Implemented as `!lfg` in LotusBot rather than a client slash command. Bot-side rendering works consistently across all Matrix clients; client-side enhanced cards would only be visible to Lotus Chat users and require sanitizer auditing. The bot can also support richer flows (list active LFGs, DM interested players, auto-expire posts).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### [x] P5-5 · Intersection-Based Lazy Loading ⚠️ UNTESTED — needs verification in timeline with many images
|
### [~] P5-15 · In-Call Soundboard — IMPLEMENTED (⚠️ awaiting live verification, D2-7)
|
||||||
|
|
||||||
**What:** Use `IntersectionObserver` to trigger media decryption and loading only when components approach the viewport.
|
**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**.
|
||||||
**Approach:** Reduce initial memory footprint and improve timeline load times by deferring decryption of images/videos until they are visible.
|
**🔱 [EC-FORK] Fork side + cinny side DONE.** The fork ships `io.lotus.inject_audio` (`LotusWidgetActions.InjectAudio`, allow-listed in `widget.ts`), armed via the `lotusAudioInject=1` flag; it publishes a clip as a separate LiveKit track — a **real** in-call soundboard mixed into the call, not local-only. cinny now drives it.
|
||||||
|
**Shipped (cinny):**
|
||||||
|
|
||||||
### [x] P5-6 · Context-Aware Thumbnail Previews ⚠️ UNTESTED
|
- 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.
|
||||||
**What:** Enhance thumbnail rendering in the timeline for consistent, polished aesthetics.
|
- `CallSoundboard.tsx` popout in the call bar (upload / play / delete), gated on the `soundboardEnabled` setting (Settings → General → Calls, + volume slider).
|
||||||
**Approach:** Use CSS `object-fit: cover` with improved focal-point centering within `ThumbnailContent` to prevent media stretching or awkward aspect-ratio cropping.
|
**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`.
|
||||||
**Fix Applied:** Added `objectPosition: 'center top'` to: (1) `media.css.ts` → `Image` component (timeline images), (2) video thumbnail inline style in `RenderMessageContent.tsx`, (3) `GalleryTile` `<img>` in `MediaGallery.tsx`. Full-size viewers retain `objectFit: 'contain'` — no change. `objectPosition: 'center top'` prevents face/subject cropping on tall portrait images capped at 600px by `AttachmentBox`.
|
**Complexity:** Medium — done.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### [ ] P5-15 · In-Call Soundboard
|
|
||||||
|
|
||||||
**What:** Grid of short audio clips playable into the call audio stream via Web Audio API (AudioBufferSourceNode → MediaStreamDestinationNode → mixed with mic). Built-in clips + user-uploadable custom clips (stored as mxc://). Accessible from call controls bar.
|
|
||||||
**[AUDIT REQUIRED]** Verify the Element Call integration exposes the mic MediaStream for mixing. This is the highest-risk part of this feature.
|
|
||||||
**Complexity:** High.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -327,38 +315,55 @@ Themes:
|
|||||||
### [x] P5-30 · Advanced ML Noise Suppression (Krisp-style)
|
### [x] P5-30 · Advanced ML Noise Suppression (Krisp-style)
|
||||||
|
|
||||||
**What:** High-end background noise cancellation using a pre-trained ML model (RNNoise) running in the browser. Removes dogs, fans, and keyboard clicks from the mic stream.
|
**What:** High-end background noise cancellation using a pre-trained ML model (RNNoise) running in the browser. Removes dogs, fans, and keyboard clicks from the mic stream.
|
||||||
**Shipped:** 3-tier setting (Off / Browser-native / ML) in Settings → General → Calls. ML tier injects a same-origin pre-init shim into the vendored Element Call `index.html` that monkeypatches `getUserMedia` and routes the captured mic through an RNNoise `AudioWorklet` before LiveKit publishes — no EC fork required. See LOTUS_FEATURES.md → "Noise Suppression (Advanced Multi-Tier)".
|
**Shipped:** 3-tier setting (Off / Browser-native / ML) in Settings → General → Calls.
|
||||||
**Key decision:** LiveKit's Krisp filter is LiveKit-Cloud-only (we self-host the SFU); EC's own RNNoise PR #3892 is unmerged. The shim is the same post-capture pipeline #3892 uses, executed from the realm we control, so it survives EC version bumps.
|
**🔱 [EC-FORK] DONE — moved in-source (2026-06).** ML denoise is now a first-class audio stage **inside** the forked Element Call: a LiveKit `TrackProcessor<Audio>` activated by `lotusDenoiseSource=1` (cinny sets it when ML is selected). The old build-time `getUserMedia`/`index.html` monkeypatch is **removed**. Because EC re-runs the processor on every (re)publish, denoise now **survives reconnects and mic-device switches** — this is the A7 fix (see `LOTUS_TESTING.md` §D2-1). The processor degrades to the raw mic rather than going silent.
|
||||||
**AEC note (resolved-as-accepted):** WebAudio capture routing can weaken browser AEC — same tradeoff as EC's upstream feature; mitigated by keeping `echoCancellation`/`autoGainControl` on the raw capture and labeling the tier "beta".
|
**Key decision:** LiveKit's Krisp filter is LiveKit-Cloud-only (we self-host the SFU); EC's own RNNoise PR #3892 is unmerged. Owning the fork let us implement the in-source stage directly.
|
||||||
|
|
||||||
**Model Roadmap (priority order):**
|
**Models — all in-source in the fork:**
|
||||||
|
|
||||||
- [ ] **Verify DTLN** (16 kHz narrowband fix) in a real call before investing further — wired but unverified.
|
- [x] **DeepFilterNet 3** (48 kHz, **ML default**) · **DTLN** (16 kHz) · **RNNoise** (48 kHz) · **Speex** (48 kHz) — all four wired and selectable; dropdown ordered best-quality first. Tier default is **Browser-native**.
|
||||||
- [ ] **DeepFilterNet 3** — best self-hostable upgrade: Rust→WASM, CPU real-time, 48 kHz fullband. Effort: self-host `df_bg.wasm` + DFN3 ONNX model, wire a 48 kHz worklet.
|
- [x] **Quality tuning (2026-07):** dry/wet **attenuation floor** (~-16 dB, RNNoise/Speex only — the "robotic" fix; DTLN/DFN would comb-filter), **gate-after-ML**, **DFN level 80→60**. Floor tunable via `lotusDenoiseFloor`.
|
||||||
- [ ] **Desktop-only / HW-gated:** FRCRN or NVIDIA Maxine (RTX/Tensor only) — impossible in-browser; would run in Tauri Rust backend + bridge a virtual mic into the webview. Must detect capability and only offer on supported hardware; web falls back to RNNoise.
|
- [x] **AEC/AGC (2026-07):** echo-cancellation ON; **AGC OFF for the ML tier** (`autoGainControl=false`, threaded through EC `UrlParams`→`ConnectionFactory`) so browser AGC doesn't fight the model; playback confirmed no AEC-defeat.
|
||||||
|
- [x] **Reliability (2026-07):** never-silent watchdog, resume-timeout, WASM-cache reject-eviction, activate-off-local-participant, init/build leak fixes.
|
||||||
|
- [ ] **Open verification:** real-call by-ear **A/B** — model choice, floor value, AGC on/off (RNNoise known-weak historically). `LOTUS_TESTING.md` §D2-1 / J2.
|
||||||
|
- [ ] **GTCRN (RESEARCHED — DEFERRED):** tiny MIT 16 kHz model that beats RNNoise, but **no drop-in browser package** — needs a ~1-week from-scratch build: `onnxruntime-web` (WASM, 1 thread) in a **Web Worker** (ORT can't run in an AudioWorklet — issue #13072) behind a custom AudioWorklet ring-buffer node presenting as an `AudioNode`; model `gtcrn_simple.onnx` (~300 KB, stateful — thread `conv/tra/inter` caches per frame); we write STFT/iSTFT (n_fft 512/hop 256). Assets ~3–4 MB via the `lotusDenoise()` vite plugin. Registration checklist known (both repos, incl. the 2nd `denoisePipeline.ts` used by the DenoiseTester). **Revisit only if low-power quality is insufficient after validating the current tuning.**
|
||||||
|
- [ ] **Desktop-only / HW-gated (future):** FRCRN or NVIDIA Maxine (RTX/Tensor only) — impossible in-browser; would run in the Tauri Rust backend + bridge a virtual mic into the webview. Detect capability; web falls back to RNNoise.
|
||||||
- **Excluded:** Krisp (LiveKit Cloud only); FRCRN/Maxine on web (GPU/server-bound).
|
- **Excluded:** Krisp (LiveKit Cloud only); FRCRN/Maxine on web (GPU/server-bound).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### [ ] P5-31 · Granular Voice & Screenshare Quality Controls (Discord-style)
|
### [~] P5-31 · Granular Voice & Screenshare Quality Controls — IMPLEMENTED (⚠️ awaiting live verification, D2-8)
|
||||||
|
|
||||||
**What:** Let users (or room admins via room settings) adjust audio bitrates (e.g., 64kbps to 512kbps) and screenshare quality (resolution: 720p/1080p/Source, framerate: 15/30/60fps).
|
**What:** Let users (and room admins) adjust audio bitrate and screenshare bitrate/framerate.
|
||||||
**Note:** Requires tight integration with the LiveKit SFU and custom state events for per-room quality caps.
|
**🔱 [EC-FORK] Fork side + client side DONE.** The fork ships `io.lotus.set_quality` (`LotusWidgetActions.SetQuality`) that applies audio/screenshare encoding params (`RTCRtpSender.setParameters`, all simulcast encodings, re-applied on `TrackUnmuted`/republish) inside EC. cinny now drives it.
|
||||||
**[AUDIT REQUIRED]** Must verify if current `lk-jwt-service` can be extended with custom bitrate/resolution claims or if a new sidecar (similar to `voice-limit-guard`) is needed for server-side enforcement.
|
|
||||||
**Complexity:** Extreme.
|
**Shipped (cinny):**
|
||||||
|
|
||||||
|
1. **User settings** (Settings → General → Calls): Microphone Bitrate, Screenshare Bitrate, Screenshare Framerate (`callAudioBitrate` / `screenshareBitrate` / `screenshareFramerate`).
|
||||||
|
2. **Room-admin caps**: `io.lotus.room_quality` state event (`StateEvent.LotusRoomQuality`) + `RoomQuality.tsx` in Room Settings → General → Voice (mirrors `RoomVoiceLimit`).
|
||||||
|
3. **Apply logic**: `useCallQuality` (wired in `CallEmbedProvider`'s `CallUtils`) builds `min(user setting, room cap)` and sends `io.lotus.set_quality` on join / when settings change (`utils/callQuality.ts`, unit-tested).
|
||||||
|
|
||||||
|
**Server-side enforcement (DONE — matrix repo):** extended `voice-limit-guard.py` (LXC 151) to also read `io.lotus.room_quality` and hard-enforce a **publish-source policy** for ALL clients.
|
||||||
|
|
||||||
|
- **Reality (researched, primary-source, LiveKit 1.9.11):** numeric bitrate/fps caps **cannot** be hard-enforced server-side — LiveKit is a pure SFU (forwards, never transcodes); there is NO bitrate/fps field in the JWT grant, `RoomConfiguration`, server `limit:` config, or any admin RPC, and stock Element Call ignores room metadata / custom claims for publish quality. So numeric caps stay **cooperative** (our fork honors them via `min()` → `set_quality`, already shipped).
|
||||||
|
- **What IS hard-enforced cross-client:** `VideoGrant.canPublishSources`. The guard holds the LiveKit secret, so when `io.lotus.room_quality` sets `allow_screenshare:false` / `allow_camera:false` it re-signs the issued JWT with a narrowed source list → the SFU refuses those tracks for **every** client (Element, FluffyChat, our fork). Mic always kept. Fail-open; unit-tested (`livekit/test_voice_limit_guard.py`). Admin UI: Room Settings → Voice → **Call Permissions** switches. cinny also hides the blocked buttons.
|
||||||
|
- **Live (mid-call) enforcement — DONE:** the JWT re-sign covers new joins; for participants **already in the call**, a background reconcile loop in the guard calls LiveKit `UpdateParticipant` every ~3 s to narrow `canPublishSources`, which unpublishes an in-progress screenshare/camera **server-side for all clients** and blocks re-publish (verified LiveKit 1.9.11 auto-unpublishes on permission narrowing). Only removes forbidden sources (never grants), preserves other permission flags, no-ops once compliant. So flipping a room audio-only kills live cameras/screenshares within ~one interval.
|
||||||
|
- **Not enforceable / deferred:** numeric server enforcement (impossible — see above); screenshare **resolution** control (`set_quality` covers bitrate + framerate; resolution needs a `getDisplayMedia` hook inside the fork).
|
||||||
|
|
||||||
|
**Complexity:** DONE — client (cooperative numeric caps) + server (hard publish-source policy). Only the physically-impossible numeric server enforcement is out of scope.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### [ ] P5-35 · Desktop — Notification Click Opens Room (DEFERRED)
|
### [~] P5-35 · Desktop — Notification Click Opens Room — IMPLEMENTED (Tier B, via the P5-41 WinRT toast: click → open room, reply → send); native CI-compile-pending, runtime-verify on Windows
|
||||||
|
|
||||||
**What:** Clicking a system tray notification navigates to the relevant room. Quick-reply from the notification toast would send the reply without opening the window.
|
**What:** Clicking a system tray notification navigates to the relevant room. Quick-reply from the notification toast would send the reply without opening the window.
|
||||||
**Status:** Deferred — `tauri-plugin-notification` has no Rust click/action callback API. Quick-reply would need a custom WinRT toast activator + COM registration, which can't be compile-tested without a Windows build environment.
|
**Status:** Deferred (Tier B). Note: the "can't compile-test without a Windows build environment" premise is **outdated** — CI now compiles Windows (Gitea self-hosted `windows` runner + GitHub `windows-latest`), and `windows`-crate/COM code already ships (e.g. `set_badge_count`, and the Tier A jump list). This still depends on P5-41 (the WinRT toast + custom activator), so it rides with that; it was not part of the Tier A wave.
|
||||||
**Note:** Tray icon and `matrix:` deep links already bring the window forward on most interactions. Revisit when tauri-plugin-notification gains click handler support upstream.
|
**Note:** Tray icon and `matrix:` deep links already bring the window forward on most interactions. Revisit when tauri-plugin-notification gains click handler support upstream.
|
||||||
**Complexity:** High (platform-specific native code required).
|
**Complexity:** High (platform-specific native code required).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### [ ] P5-36 · Desktop — Windows Jump List (DEFERRED)
|
### [~] P5-36 · Desktop — Windows Jump List — IMPLEMENTED (Tier A); native CI-compile-pending, runtime-verify on Windows
|
||||||
|
|
||||||
**What:** Right-clicking the taskbar icon shows a jump list with recent/favorite rooms for quick navigation.
|
**What:** Right-clicking the taskbar icon shows a jump list with recent/favorite rooms for quick navigation.
|
||||||
**Status:** Deferred — implementing the Windows COM jump list API in Tauri requires iterating on C++/COM code that can only be compile-checked on Windows, making blind CI iteration impractical.
|
**Status:** Deferred — implementing the Windows COM jump list API in Tauri requires iterating on C++/COM code that can only be compile-checked on Windows, making blind CI iteration impractical.
|
||||||
@@ -367,100 +372,93 @@ Themes:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### [x] P5-40 · Desktop — Proactive Update Notifications (Tauri) ⚠️ UNTESTED (requires Tauri build)
|
### [~] P5-41 · Desktop — Native WinRT Toast Notifications — IMPLEMENTED (Tier B; ToastNotification + reply input + in-process Activated; falls back to tauri-plugin-notification); native CI-compile-pending. Runtime needs a Start-menu shortcut + matching AppUserModelID to surface
|
||||||
|
|
||||||
**What:** Automatically check for app updates on launch and periodically during long sessions. If an update is available, show an in-app toast or badge (e.g., on the Settings icon) to alert the user without requiring a manual check in settings.
|
|
||||||
**Mechanism:** Use the `useTauriUpdater` hook in a global component like `ClientNonUIFeatures.tsx`.
|
|
||||||
**Note:** Ensure the check is throttled (e.g., once every 12 hours) to avoid redundant Tauri commands.
|
|
||||||
**Complexity:** Low-Medium.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### [ ] P5-41 · Desktop — Native WinRT Toast Notifications
|
|
||||||
|
|
||||||
**What:** Replace emulated notifications with native WinRT Toast notifications.
|
**What:** Replace emulated notifications with native WinRT Toast notifications.
|
||||||
**Approach:** Implement native WinRT Toast integration using `windows-rs` to enable full Action Center integration, including native Quick Reply functionality.
|
**Approach:** Implement native WinRT Toast integration using `windows-rs` to enable full Action Center integration, including native Quick Reply functionality.
|
||||||
|
|
||||||
### [ ] P5-42 · Desktop — Persistent Background Sync
|
### [~] P5-42 · Desktop — Persistent Background Sync — IMPLEMENTED (Batch 3, pragmatic keep-alive); native CI-compile-pending, runtime-verify on Windows
|
||||||
|
|
||||||
**What:** Maintain light connection to homeserver when WebView2 is suspended.
|
**What:** Keep receiving messages/notifications instantly while the app is closed to the tray.
|
||||||
**Approach:** Implement a headless Rust sidecar to fetch unread counts/notifications while the webview is suspended to ensure instant notification delivery.
|
**Shipped approach (80/20):** rather than a multi-sprint headless Rust sync client, disable Chromium background throttling via WebView2 `additional_browser_args` (`--disable-background-timer-throttling --disable-renderer-backgrounding --disable-backgrounding-occluded-windows`, added to the existing Tauri default args) so the existing JS Matrix `/sync` loop keeps running full-speed in the tray. Windows/WebView2 only; does not block system sleep. See `cinny-desktop/src-tauri/src/lib.rs` (WebviewWindowBuilder).
|
||||||
|
**Deferred (not needed):** the full headless Rust sidecar — only revisit if WebView2 ever hard-suspends despite these flags (would require a second Matrix client with its own /sync + push-rule eval + E2EE-aware notification content).
|
||||||
|
|
||||||
### [ ] P5-43 · Desktop — System Media Transport Controls (SMTC)
|
### [~] P5-43 · Desktop — System Media Transport Controls (SMTC) — IMPLEMENTED (Tier A); CI-compile-pending; SMTC may need an active media/audio session to surface — verify on Windows
|
||||||
|
|
||||||
**What:** Integrate with Windows SMTC for volume flyout call/media control.
|
**What:** Integrate with Windows SMTC for volume flyout call/media control.
|
||||||
**Approach:** Use Windows SMTC API to expose call status, mic mute/unmute, and media controls to the Windows volume flyout/media overlay.
|
**Approach:** Use Windows SMTC API to expose call status, mic mute/unmute, and media controls to the Windows volume flyout/media overlay.
|
||||||
|
|
||||||
### [ ] P5-44 · Desktop — Taskbar Thumbnail Toolbar
|
### [~] P5-44 · Desktop — Taskbar Thumbnail Toolbar — IMPLEMENTED (Tier A); native CI-compile-pending, runtime-verify on Windows
|
||||||
|
|
||||||
**What:** Add persistent call controls to the taskbar preview.
|
**What:** Add persistent call controls to the taskbar preview.
|
||||||
**Approach:** Implement a COM thumbnail toolbar in the application preview window, featuring Mute/Deafen/End Call buttons.
|
**Approach:** Implement a COM thumbnail toolbar in the application preview window, featuring Mute/Deafen/End Call buttons.
|
||||||
|
|
||||||
### [ ] P5-46 · Desktop — System Power Management (Call Continuity)
|
### [~] P5-46 · Desktop — System Power Management (Call Continuity) — IMPLEMENTED (Tier A reference); web verified; native CI-compile-pending (Windows only; macOS/Linux no-op TODO)
|
||||||
|
|
||||||
**What:** Prevent system sleep/hibernate during active calls.
|
**What:** Prevent system sleep/hibernate during active calls.
|
||||||
**Approach:** Use Tauri/Rust `power-manager` or platform-specific APIs to block system power saving states while a voice/video session is active.
|
**Approach:** Use Tauri/Rust `power-manager` or platform-specific APIs to block system power saving states while a voice/video session is active.
|
||||||
|
|
||||||
### [ ] P5-47 · Desktop — TDS-Styled Native Window Chrome
|
### [~] P5-47 · Desktop — TDS-Styled Native Window Chrome — IMPLEMENTED (Tier A, OPT-IN/default-off, runtime-reversible); web verified; native CI-compile-pending
|
||||||
|
|
||||||
**What:** Replace system titlebar with custom Lotus TDS chrome.
|
**What:** Replace system titlebar with custom Lotus TDS chrome.
|
||||||
**Approach:** Configure Tauri window (`decorations: false`) and implement custom, TDS-token compliant titlebar controls (Close/Max/Min) for a cohesive UI.
|
**Approach:** Configure Tauri window (`decorations: false`) and implement custom, TDS-token compliant titlebar controls (Close/Max/Min) for a cohesive UI.
|
||||||
|
|
||||||
### [ ] P5-48 · Desktop — Native File System Drag-and-Drop Improvements
|
### [~] P5-48 · Desktop — Native File System Drag-and-Drop Improvements — IMPLEMENTED (recursive folder upload, web-verified: tsc/build/tests). SCOPED-OUT: `.lnk` shortcut resolution (webview never exposes a dropped file's OS path → native can't resolve the target) and "Send To" (installer/registry shell integration) — deferred
|
||||||
|
|
||||||
**What:** Enhance drag-and-drop support for Windows.
|
**What:** Enhance drag-and-drop support for Windows.
|
||||||
**Approach:** Improve handling for Windows file shortcuts, recursive folder uploads, and shell-integrated "Send To" context menu actions.
|
**Approach:** Improve handling for Windows file shortcuts, recursive folder uploads, and shell-integrated "Send To" context menu actions.
|
||||||
|
|
||||||
### [ ] P5-49 · Desktop — Network Awareness (NCSI Integration)
|
### [~] P5-49 · Desktop — Network Awareness (NCSI Integration) — IMPLEMENTED (Tier A; INetworkListManager poll → mx.retryImmediately); native CI-compile-pending, runtime-verify on Windows
|
||||||
|
|
||||||
**What:** Proactively detect Windows network connectivity changes.
|
**What:** Proactively detect Windows network connectivity changes.
|
||||||
**Approach:** Integrate with the Windows Network Connectivity Status Indicator (NCSI) API to improve offline mode transition latency and network recovery.
|
**Approach:** Integrate with the Windows Network Connectivity Status Indicator (NCSI) API to improve offline mode transition latency and network recovery.
|
||||||
|
|
||||||
### [ ] P5-50 · Desktop — Windows Hardware-Accelerated Media Pipeline
|
### [WON'T FIX] P5-50 · Desktop — Windows Hardware-Accelerated Media Pipeline
|
||||||
|
|
||||||
**What:** Replace standard browser decoding with native Windows Media Foundation.
|
**What:** Replace standard browser decoding with native Windows Media Foundation.
|
||||||
**Approach:** Leverage DirectShow/Media Foundation to offload video/audio decoding from the CPU to the GPU, significantly reducing power consumption and latency during calls.
|
**Why won't-fix (researched):** WebRTC media (the call pipeline) lives entirely inside WebView2/Chromium — you cannot inject Media Foundation/DirectShow into WebRTC's decode path from the Tauri host. Chromium already uses the platform's hardware decoders (D3D11VA/MF) where the GPU supports the codec, so there is no separate CPU pipeline to offload. Not actionable as described.
|
||||||
|
|
||||||
### [ ] P5-51 · Desktop — Federated "Identity Contexts" (Isolation Manager)
|
### [DEFERRED] P5-51 · Desktop — Federated "Identity Contexts" (Isolation Manager)
|
||||||
|
|
||||||
**What:** Compartmentalize sessions, local databases, and caches into isolated "Contexts."
|
**What:** Compartmentalize sessions, local databases, and caches into isolated "Contexts" (e.g. Work vs. Personal) with a zero-leak boundary.
|
||||||
**Approach:** Implement a zero-leak boundary for personas (e.g., Work vs. Personal) by isolating `IndexedDB`, filesystem caches, and session persistence per context.
|
**Decision:** Deferred (reviewed 2026-07). Multi-sprint, and it touches the auth/crypto/storage core — not worth the risk/effort for a niche need right now. Kept here with a concrete spec so it's actionable later.
|
||||||
|
|
||||||
|
**Future-work spec (why it's big):** the app is currently **single-session**.
|
||||||
|
|
||||||
|
- Session lives in `src/app/state/sessions.ts` under fixed localStorage keys — `cinny_access_token`, `cinny_device_id`, `cinny_user_id`, `cinny_hs_base_url`, plus the OIDC keys (`cinny_refresh_token`, `cinny_expires_at`, `cinny_oidc_*`).
|
||||||
|
- Persistence lives in `src/client/initMatrix.ts`: two fixed IndexedDB stores — `web-sync-store` (`IndexedDBStore`) and `crypto-store` (`IndexedDBCryptoStore`) — feeding one `createClient(...)`.
|
||||||
|
|
||||||
|
True per-context isolation would require: (1) namespace every localStorage key per context (`ctx:<id>:cinny_*`); (2) per-context IndexedDB dbNames for **both** the sync store and the crypto store; (3) a context registry + switcher UI (create/rename/delete/switch); (4) full client teardown + re-init on switch (`initMatrix` currently assumes one global client); (5) per-context settings + notification/quiet-hours state; (6) careful crypto-store isolation so device keys never bleed across contexts. **Smaller intermediate step** if demand appears: plain multi-account (fast account switch) _without_ the hard isolation boundary — much less risky, reuses most of the login flow.
|
||||||
**Priority:** Extreme Low (Multi-sprint/Architectural).
|
**Priority:** Extreme Low (Multi-sprint/Architectural).
|
||||||
|
|
||||||
### [~] P5-52 · Desktop — Room-Level Sync Governor (Performance Control) [STILL_CONSIDERING]
|
### [DROPPED] P5-52 · Desktop — Room-Level Sync Governor (Performance Control)
|
||||||
|
|
||||||
**What:** Granular sync tuning for individual rooms.
|
**What:** Granular per-room sync tuning (frequency, event-type filtering).
|
||||||
**Approach:** Allow per-room overrides for sync frequency and event type filtering (e.g., disable read receipts/typing in heavy rooms) to optimize performance. Implementation requires careful UX to prevent complexity fatigue.
|
**Why dropped (reviewed 2026-07):** matrix-js-sdk can't do **true** per-room sync filtering — all room events still come down the single `/sync` stream, so "disable typing/receipts in heavy rooms" can only be a **cosmetic client-side hide**, not an actual performance/bandwidth win. That, plus the UX-complexity risk flagged originally, makes it not worth building. If per-room quieting is ever wanted, add a simple "mute typing & receipts in this room" toggle to normal room settings — not a "governor."
|
||||||
|
|
||||||
### [ ] P5-53 · Desktop — Local-Only "Scripting" Plugin System (Tampermonkey-like)
|
### [DEFERRED] P5-53 · Desktop — Local-Only "Scripting" Plugin System (Tampermonkey-like)
|
||||||
|
|
||||||
**What:** A sandboxed environment for local execution of user scripts on Matrix events.
|
**What:** A sandboxed environment for local execution of user scripts on Matrix events.
|
||||||
**Approach:** Implement a WASM-based execution engine that allows users to write local-only, client-side scripts to interact with incoming Matrix events, trigger sounds/notifications, or inject custom UI elements based on event payload rules. Designed for privacy — all logic runs exclusively on the local machine.
|
**Decision:** Deferred (reviewed 2026-07). A full WASM execution engine + script registry + management UI + security model is a large surface for a very small (power-user) audience.
|
||||||
|
**Recommended lighter alternative (the ~80/20) if we ever want event automation:** a built-in **automation-rules** feature — declarative "when an incoming event matches X (room / sender / keyword / type) → notify / play sound / highlight / auto-react" rules, configured in Settings. Covers the realistic use cases (custom alerts, keyword pings) with **no arbitrary code execution**, so no sandbox/security burden. Build that instead of a scripting engine if the need arises.
|
||||||
|
|
||||||
### [ ] P5-55 · Desktop — Composer Toolbar Drag-and-Drop Reordering
|
### [~] P5-55 · Desktop — Composer Toolbar Drag-and-Drop Reordering — IMPLEMENTED (Tier A); web-verified (tsc/build/tests). Awaiting live UX check
|
||||||
|
|
||||||
**What:** Allow users to reorder toolbar icons via drag-and-drop.
|
**What:** Allow users to reorder toolbar icons via drag-and-drop.
|
||||||
**Approach:** Extend the current settings-based toolbar toggle system to include a drag-and-drop UI mode in the composer settings, allowing users to personalize their icon order.
|
**Approach:** Extend the current settings-based toolbar toggle system to include a drag-and-drop UI mode in the composer settings, allowing users to personalize their icon order.
|
||||||
|
|
||||||
### [ ] P5-56 · Desktop — Windows "Focus Assist" (DND) Sync
|
### [~] P5-56 · Desktop — Windows "Focus Assist" (DND) Sync — IMPLEMENTED (Tier B; SHQueryUserNotificationState poll → suppresses notifications+sounds via the quiet-hours gate); native CI-compile-pending, runtime-verify on Windows
|
||||||
|
|
||||||
**What:** Automatically toggle notification state based on Windows Focus Assist.
|
**What:** Automatically toggle notification state based on Windows Focus Assist.
|
||||||
**Approach:** Integrate with the Windows `NotificationCenter` / `Focus` state via Tauri/Rust to automatically enable/disable Lotus Chat's internal notification suppression mode when Windows Focus Assist is toggled.
|
**Approach:** Integrate with the Windows `NotificationCenter` / `Focus` state via Tauri/Rust to automatically enable/disable Lotus Chat's internal notification suppression mode when Windows Focus Assist is toggled.
|
||||||
|
|
||||||
### [ ] P5-57 · Desktop — Visual Draft Persistence Indicator
|
### [~] P5-57 · Desktop — Visual Draft Persistence Indicator — IMPLEMENTED (Tier A); web-verified. Awaiting live UX check
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚀 Features to Add
|
## 🚀 Features to Add
|
||||||
|
|
||||||
- [ ] **Mobile Audit:** Comprehensive audit of all features in LOTUS_FEATURES.md for mobile PWA usability and layout responsiveness.
|
- [ ] **Mobile Audit:** Comprehensive audit of all features in LOTUS_FEATURES.md for mobile PWA usability and layout responsiveness.
|
||||||
- [x] **Remind Me Later:** Slack-style reminders for messages — fully implemented ⚠️ UNTESTED end-to-end
|
|
||||||
- **Storage:** `useReminders.ts` — persists to `io.lotus.reminders` account data with `addReminder` / `removeReminder` / `getReminders`
|
|
||||||
- **UI:** `RemindMeDialog.tsx` — 4 presets (20 min, 1 hr, 3 hr, tomorrow 9am); wired into `Message.tsx` context menu via `remindOpen` state; `useModalStyle(320)` for mobile fullscreen
|
|
||||||
- **Monitor:** `ReminderMonitor` in `ClientNonUIFeatures.tsx` — polls every 30s + on tab visibility; fires Lotus toast when due and calls `removeReminder`
|
|
||||||
- [x] **Mobile Bookmarks:** Fixed ⚠️ UNTESTED — bookmarks now accessible from within any room on mobile
|
|
||||||
- **Root Cause:** `BookmarksPanel` renders correctly on mobile but `BookmarksTab` lives in `SidebarNav`, which is hidden when inside a room on mobile (`MobileFriendlyClientNav` returns `null`). No trigger existed.
|
|
||||||
- **Fix:** Added "Saved Messages" `MenuItem` to the `RoomMenu` (···More Options) in `RoomViewHeader.tsx`. Toggles `bookmarksPanelAtom` and closes the menu. Works on all screen sizes — desktop users see it as a duplicate of the sidebar star, mobile users now have their only in-room access point.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -505,9 +503,69 @@ Check back after each Synapse upgrade — re-run `/matrix/client/versions` and `
|
|||||||
|
|
||||||
## Pending Audits
|
## Pending Audits
|
||||||
|
|
||||||
### [ ] Audit-3 · Profile banner image — Matrix protocol support
|
### [DEFERRED] Audit-3 · Profile banner image — Matrix protocol support — RESEARCHED (2026-07)
|
||||||
|
|
||||||
Research whether Matrix spec or MSC4133 (v1.16) defines a standard profile banner field. `uk.tcpip.msc4133.stable = true` on our server — check if a `banner_url` or similar field is defined. If no cross-client standard exists, do not implement.
|
**Finding:** [MSC4427 — Custom banners for user profiles](https://github.com/matrix-org/matrix-spec-proposals/pull/4427) defines a `banner_url` profile field on top of the MSC4133 extensible-profile system (which our server supports, `uk.tcpip.msc4133.stable = true`, and which became stable in Matrix v1.16). However MSC4427 is an **open proposal, not merged** — no cross-client standard yet, so per this item's own rule: do not implement. **Revisit when MSC4427 merges** (implementation would then be small: read/write the field via the MSC4133 profile API + render a banner in UserHero/profile popouts).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Priority 6 — Post-audit batches (2026-07)
|
||||||
|
|
||||||
|
Buildable follow-ups surfaced by the deep-audit wave. Web Push (N107) deliberately deferred. **macOS is out of scope for all of these — Linux is the parity target (Windows already has most native features).**
|
||||||
|
|
||||||
|
### [~] P6-1 · Desktop — cross-platform parity (Linux + Windows; NO macOS) — IMPLEMENTED (2026-07); native CI-compile-pending, runtime-verify on Linux
|
||||||
|
|
||||||
|
From the desktop audit. Round out the native app now that the full Rust stack compiles:
|
||||||
|
|
||||||
|
- **No-sleep during calls on Linux** — `power.rs` is Windows-only (`SetThreadExecutionState`); add a Linux inhibitor (`org.freedesktop.login1.Manager.Inhibit` / ScreenSaver inhibit via zbus/D-Bus) so the display/system doesn't sleep mid-call.
|
||||||
|
- **Taskbar/launcher unread badge on Linux** — `set_badge_count` is Windows-only; add Unity/`com.canonical.Unity.LauncherEntry` (D-Bus) count where supported.
|
||||||
|
- **Launch-on-login** — add `tauri-plugin-autostart` (cross-platform) + a Settings/tray toggle.
|
||||||
|
- **Tray "Do Not Disturb" toggle** — the tray menu is Open/Quit only; add a DND item (reuses the Focus-Assist suppression atom path) so users can silence notifications from the tray.
|
||||||
|
CI-compile-verified (Windows + Linux runners); no local Rust.
|
||||||
|
|
||||||
|
### [~] P6-2 · Element Call fork — retire the remaining DOM hacks — DEAFEN DONE (2026-07), Phase-2 pending publish
|
||||||
|
|
||||||
|
**Shipped (Phase 1):** new `io.lotus.set_deafen` action in the fork (`lotusDeafen.ts`) sets remote `RemoteParticipant.setVolume` per source (mic + screenshare-audio), persisting to late joiners — replaces the brittle `CallControl.setSound`/`applyScreenshareAudioMuted` `<audio>.muted` iframe-DOM hack. cinny now sends it (join-gated) alongside the retained DOM hack (transitional). Folded into unpublished fork `0.20.1-lotus.2`.
|
||||||
|
**Phase 2 (needs user publish):** publish `0.20.1-lotus.2` to npm → bump cinny pin `lotus.1`→`lotus.2` → delete the DOM `.muted` code. See HANDOFF §12.4.
|
||||||
|
**DEFERRED (rationale):** the `useCallSpeakers` DOM-scrape is a dormant _fallback_ behind `io.lotus.call_state` (deleting only removes the safety net); the `.click()`-by-`data-testid` UI toggles (screenshare/grid/spotlight/reactions/settings) are low-value and would balloon fork surface for buttons that just trigger EC's own UI.
|
||||||
|
**Divergence:** deafen doesn\'t silence soundboard/`Unknown`-source audio (setVolume type limit) — confirm UX.
|
||||||
|
|
||||||
|
_Original scope below._
|
||||||
|
|
||||||
|
### [ ] P6-2b · Element Call fork — remaining DOM hacks (deferred pieces)
|
||||||
|
|
||||||
|
Replace cinny's fragile iframe-`contentDocument` reaches with proper `io.lotus.*` widget actions in the fork (`LotusGuild/element-call`), which break on EC re-renders/version bumps:
|
||||||
|
|
||||||
|
- **Deafen / screenshare-audio-mute** → an `io.lotus` action that mutes/attenuates `RemoteAudioTrack`s at the LiveKit source (replaces `CallControl.ts` `setSound`/`applyScreenshareAudioMuted` DOM `.muted` poking).
|
||||||
|
- **UI-toggle actions** (screenshare/spotlight/reactions/settings) → replace the `.click()`-by-`data-testid` calls.
|
||||||
|
- Retire the `useCallSpeakers` DOM-scrape fallback once `io.lotus.call_state` is verified.
|
||||||
|
Fork commits are local (coordinator); publishing needs the user's npm token.
|
||||||
|
|
||||||
|
### [~] P6-3 · Web UX wins - DONE (2026-07): forward multi-select + live bookmark previews
|
||||||
|
|
||||||
|
**Shipped:** Forward Message multi-select (checkbox rooms + "Send to N", batch `Promise.allSettled` with partial-failure summary; content builder extracted to tested `forwardContent.ts`). Live bookmark previews (`BookmarksPanel` renders the live event via `useRoomEvent` - edits + redactions - snapshot as fallback / left-room). Both `lotus`, gate-green (665 tests).
|
||||||
|
|
||||||
|
_Original scope:_
|
||||||
|
|
||||||
|
### [ ] P6-3-orig · Web UX wins (from the audit ADD list)
|
||||||
|
|
||||||
|
- **Forward to multiple rooms** — multi-select (checkbox + "Send to N") in `ForwardMessageDialog` (currently one room per open, capped at 60).
|
||||||
|
- **Live bookmark previews** — `BookmarksPanel` shows a stale snapshot captured at save time; resolve live from the event when cached (edits/redactions), fall back to the snapshot.
|
||||||
|
- Other small paper-cuts as scoped.
|
||||||
|
|
||||||
|
### [~] P6-4 · Hygiene sweep - TRIMMED (2026-07): security headers only
|
||||||
|
|
||||||
|
**Shipped:** HSTS + Permissions-Policy on the real prod nginx (`matrix/cinny/nginx.conf`, already had X-Frame/CSP/Referrer) + synced the `contrib/nginx` + `contrib/caddy` examples (also fixed the caddy `try_files` SPA fallback). Permissions-Policy allows `self` for the features the app uses (camera/mic/display-capture/geolocation/autoplay/fullscreen), denies unused. **User must `nginx -s reload` on the LXC + verify calls/location still work.**
|
||||||
|
**WON'T-DO (rationale):** patch-package migration - the current `patch-folds.mjs` is already robust (fails hard on drift) and patch-package would be more brittle to folds restructuring; `types/matrix` drift - risky spot-fixes with no concrete bug; build-config streamlining - build is already ~5s. Known follow-up: nginx `add_header` isn't inherited by the cache `location` blocks (pre-existing; the SPA entry `/` still gets all headers, so HSTS is delivered).
|
||||||
|
|
||||||
|
_Original scope:_
|
||||||
|
|
||||||
|
### [ ] P6-4-orig · Hygiene sweep
|
||||||
|
|
||||||
|
- `patch-folds.mjs` (edits `node_modules` directly) → `patch-package`.
|
||||||
|
- `contrib/nginx` + `contrib/caddy`: security headers (HSTS/CSP), `try_files` over rewrites, fix the caddy placeholder path.
|
||||||
|
- `types/matrix/` drift (mirrors SDK types) — spot-fix the highest-risk.
|
||||||
|
- Build-config: streamline `lotusDenoise` sequential `fs` work + redundant `viteStaticCopy` renames.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -515,26 +573,37 @@ Research whether Matrix spec or MSC4133 (v1.16) defines a standard profile banne
|
|||||||
|
|
||||||
Exhaustive, low-level implementation details for backlog items. Follow these patterns to ensure code is "Lotus-perfect" (idiomatic, performant, and TDS-compliant).
|
Exhaustive, low-level implementation details for backlog items. Follow these patterns to ensure code is "Lotus-perfect" (idiomatic, performant, and TDS-compliant).
|
||||||
|
|
||||||
### P3-8 · Thread Panel (Full Side Drawer)
|
### P3-8 · Thread Panel (Full Side Drawer) — 🟢 FULL DESIGN (2026-07, ready to execute)
|
||||||
|
|
||||||
**Architecture:** Mirror the `MembersDrawer` pattern but with a specialized timeline.
|
**Decisions (each backed by SDK evidence in node_modules/matrix-js-sdk):**
|
||||||
|
|
||||||
- **State (`src/app/state/room/thread.ts`):**
|
| Question | Decision |
|
||||||
```typescript
|
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
export const activeThreadIdAtom = atom<string | null>(null);
|
| Thread rendering | **New lean `ThreadTimeline`** reusing `Message`, `useVirtualPaginator`, and RoomTimeline's exported timeline helpers (lines 156-227). Do NOT refactor 2214-line RoomTimeline (its ~35 hooks are hardwired to the room live timeline). |
|
||||||
```
|
| threadSupport | **Enable `threadSupport: true`** in `initMatrix.ts` (~line 39). ⚠️ Thread replies then LEAVE the main timeline (`room.js eventShouldLiveIn` → `shouldLiveInRoom:false`), retroactively on reload — MUST ship the "N replies" summary chip in the same release. Roots stay in both timelines. |
|
||||||
- **Layout (`src/app/features/room/Room.tsx`):** Insert `ThreadPanel` conditionally alongside `RoomTimeline`:
|
| State | `roomIdToActiveThreadIdAtomFamily` (per-room, mirrors `roomIdToReplyDraftAtomFamily`) in new `state/room/thread.ts` + `getThreadDraftKey(roomId, threadRootId)` = `` `${roomId}::${threadRootId}` `` |
|
||||||
```tsx
|
| Composer | **Reuse RoomInput**: add optional `threadRootId` prop; scope its 3 atom-family lookups by draftKey (isolates thread drafts from the main composer); pass `threadRootId ?? null` at all 7 `mx.sendMessage/sendEvent` call sites — the SDK's `addThreadRelationIfNeeded` then emits spec-correct `m.thread` relations incl. reply-in-thread. Separate `useEditor()` instance in the panel. Hide schedule + commands in thread mode v1. |
|
||||||
{
|
| Unreads | v1 = unread badge on the summary chip (`room.getThreadUnreadNotificationCount` — counts already synced independent of threadSupport) + `markThreadAsRead` threaded receipt when panel open at bottom. |
|
||||||
activeThreadId && (
|
| Mobile | Pure CSS like `MembersDrawer.css.ts`: fixed width toRem(360) desktop, `position:fixed; inset:0` under 750px. |
|
||||||
<>
|
|
||||||
<Line variant="Background" direction="Vertical" size="300" />
|
**Critical side-effect fixes (one-liners, land FIRST):**
|
||||||
<ThreadPanel roomId={roomId} threadId={activeThreadId} />
|
|
||||||
</>
|
1. `initMatrix.ts` → `threadSupport: true`.
|
||||||
);
|
2. `utils/notifications.ts:24` → `sendReadReceipt(latestEvent, type, /*unthreaded*/ true)` — otherwise markAsRead becomes `main`-scoped and room badges stick permanently unread (room unread total includes thread counts).
|
||||||
}
|
|
||||||
```
|
**Known SDK traps (verified):**
|
||||||
- **Component (`src/app/features/room/thread/ThreadPanel.tsx`):** Use `room.getThread(threadId)` from the SDK. Render a `Header` with a "Close" button that sets `activeThreadIdAtom` to `null`. Reuse `RoomTimeline` but pass a filtered `EventTimelineSet`. Use `thread.timelineSet` directly for the most accurate thread view.
|
|
||||||
|
- **Local echo gap:** chronological pending ordering means the thread timelineSet never receives pending events (`canContain` rejects; `room.getPendingEvents()` THROWS in this mode) — ThreadTimeline must render its own pending strip via `RoomEvent.LocalEchoUpdated` filtering on `threadRootId`, deduped against `thread.findEventById`.
|
||||||
|
- **Bootstrap:** `room.getThread(id) ?? room.createThread(id, room.findEventById(id), [], false)` — the SDK auto-fetches via `/relations` and inserts the root at top; gate rendering on `thread.initialEventsFetched`; decrypt with `decryptAllTimelineEvent` after init + each pagination.
|
||||||
|
- **Deep links:** `getEventTimeline(mainSet, threadEventId)` returns undefined for thread events — redirect jump-to-event to the panel (best-effort v1).
|
||||||
|
- **Summary chip** must render from the server-aggregated bundle (`unsigned['m.relations']['m.thread']`) so it works before any Thread object exists.
|
||||||
|
- Room-list "latest message" preview may show the root, not the newest reply — cosmetic, accept v1.
|
||||||
|
|
||||||
|
**File inventory — new:** `state/room/thread.ts` (+test), `features/room/thread/{useThread.ts, threadSummary.ts(+test), ThreadTimeline.tsx(+css), ThreadPanel.tsx(+css), ThreadSummary.tsx, index.ts}`, `hooks/useThreadSummary.ts`. **Edited:** `initMatrix.ts` + `utils/notifications.ts` (coordinator, step 0), `RoomInput.tsx` (threadRootId prop), `RoomTimeline.tsx` (handleReplyClick startThread → open panel; ThreadSummary chips at the two Message call sites; Reply onThreadClick; deep-link redirect), `components/message/Reply.tsx`, `Room.tsx` (render panel after MediaGallery block, gated `!callView && activeThreadId`, `key={roomId+threadId}`).
|
||||||
|
|
||||||
|
**4-agent partition:** step 0 (coordinator one-liners) → A: state+SDK glue (+tests) · B: ThreadTimeline (largest; copies the `useTimelinePagination` pattern rather than exporting it) · C: RoomInput changes · D: panel shell + RoomTimeline/Reply integration — all parallel against pinned interface contracts → coordinator wires Room.tsx + gates.
|
||||||
|
|
||||||
|
**Verification:** gates (tsc/eslint/build/tests) + post-merge manual QA: open thread via chip/menu/indicator; pending→confirmed echo; `is_falling_back:false` on reply-in-thread; main timeline shows root+chip only; badge clears; reload keeps partitioning; encrypted threads decrypt. **Release note required:** threaded replies no longer render inline in the main timeline.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -543,6 +612,7 @@ Exhaustive, low-level implementation details for backlog items. Follow these pat
|
|||||||
**Mechanism:** KaTeX injection into the HTML parser.
|
**Mechanism:** KaTeX injection into the HTML parser.
|
||||||
|
|
||||||
- **Sanitizer (`src/app/utils/sanitize.ts`):** Allow KaTeX-specific tags and classes (e.g., `span`, `annotation`, `math`). Use a specialized allowed list for math blocks.
|
- **Sanitizer (`src/app/utils/sanitize.ts`):** Allow KaTeX-specific tags and classes (e.g., `span`, `annotation`, `math`). Use a specialized allowed list for math blocks.
|
||||||
|
> [Gemini_Found] `sanitize.ts` uses **`sanitize-html`** (not DOMPurify) with an explicit allowlist (`allowedTags`) and `disallowedTagsMode: 'discard'`. All MathML tags are currently absent from the allowlist and are silently stripped. Update `permittedHtmlTags` to include: `<math>`, `<mi>`, `<mo>`, `<mn>`, `<ms>`, `<mtext>`, `<mspace>`, `<mrow>`, `<mfrac>`, `<msqrt>`, `<mroot>`, `<mstyle>`, `<merror>`, `<mpadded>`, `<mphantom>`, `<mfenced>`, `<menclose>`, `<msub>`, `<msup>`, `<msubsup>`, `<munder>`, `<mover>`, `<munderover>`, `<mmultiscripts>`, `<mtable>`, `<mtr>`, `<mtd>`, `<maligngroup>`, `<malignmark>`, and `annotation`. Also add the required MathML attributes (e.g. `xmlns`, `display`, `mathvariant`) to `permittedTagToAttributes`.
|
||||||
- **Parser (`src/app/plugins/react-custom-html-parser.tsx`):** Detect `$ ... $` and `$$ ... $$` patterns in text nodes:
|
- **Parser (`src/app/plugins/react-custom-html-parser.tsx`):** Detect `$ ... $` and `$$ ... $$` patterns in text nodes:
|
||||||
```tsx
|
```tsx
|
||||||
if (node.type === 'text') {
|
if (node.type === 'text') {
|
||||||
@@ -592,12 +662,18 @@ Exhaustive, low-level implementation details for backlog items. Follow these pat
|
|||||||
- Route the mic `MediaStream` and the clip source to the destination node.
|
- Route the mic `MediaStream` and the clip source to the destination node.
|
||||||
- Pass the destination's `.stream` to the call bridge.
|
- Pass the destination's `.stream` to the call bridge.
|
||||||
|
|
||||||
|
> ⚠️ **[Gemini_Found — CORRECTED]** Gemini originally suggested using LiveKit's `LocalAudioTrack.replaceTrack()` to mix audio into the call stream. This is **not possible** from Lotus Chat's realm: Element Call runs in a **cross-origin iframe** controlled via `matrix-widget-api` (postMessage). LiveKit's JS SDK and its `LocalAudioTrack` live inside EC's sandboxed context — inaccessible from our code. This directly contradicts the confirmed constraint already listed in the Server Capabilities table: _"Cindy CANNOT inject audio into EC call stream — In-call soundboard must be redesigned as local-only."_ The soundboard must be a local-playback-only feature (output through the user's speakers, not mixed into the call audio stream).
|
||||||
|
>
|
||||||
|
> 🔱 **[EC-FORK — RESOLVED]** Both the original claim and the earlier "practical blocker still holds" correction are now **outdated**. EC is same-origin **and** we own the source, so we no longer reach into EC's module scope from cinny — instead the fork **exposes the inject point itself**: the `io.lotus.inject_audio` widget action (`LotusWidgetActions.InjectAudio`) publishes a clip as a separate LiveKit track from inside EC. A **real** in-call soundboard (mixed into the call, not local-only) is therefore unblocked, and the cinny-side soundboard UI is now **built** (P5-15 above): uploadable clips played into the call via this action, stored in `io.lotus.soundboard` account data.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### P5-20 · Quick Reply from Browser Notification
|
### P5-20 · Quick Reply from Browser Notification
|
||||||
|
|
||||||
**Mechanism:** Service Worker `notificationclick` Action.
|
**Mechanism:** Service Worker `notificationclick` Action.
|
||||||
|
|
||||||
|
> [Gemini_Found] Implementation detail: `serviceWorkerRegistration.showNotification()` should be used instead of `new Notification()` so that the service worker can listen to the `notificationclick` event. `new Notification()` creates notifications that are bound to the client page, not the SW.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// src/sw.ts
|
// src/sw.ts
|
||||||
self.addEventListener('notificationclick', (event) => {
|
self.addEventListener('notificationclick', (event) => {
|
||||||
@@ -659,7 +735,7 @@ See shipped implementation in LOTUS_FEATURES.md → "Noise Suppression (Advanced
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### P5-40 · Desktop — Proactive Update Notifications (Tauri)
|
### [x] P5-40 · Desktop — Proactive Update Notifications (Tauri) — DONE (already shipped: `TauriUpdateFeature` in ClientNonUIFeatures.tsx polls every 12h + fires the sticky update toast)
|
||||||
|
|
||||||
**Key Files:** `src/app/hooks/useTauriUpdater.ts`, `src/app/pages/client/ClientNonUIFeatures.tsx`, `src/app/features/toast/LotusToastContainer.tsx`.
|
**Key Files:** `src/app/hooks/useTauriUpdater.ts`, `src/app/pages/client/ClientNonUIFeatures.tsx`, `src/app/features/toast/LotusToastContainer.tsx`.
|
||||||
|
|
||||||
@@ -750,3 +826,136 @@ edit → commit → git push origin lotus
|
|||||||
- **Synapse (Matrix):** LXC 151 on `compute-storage-01` — `pct exec 151 -- bash`
|
- **Synapse (Matrix):** LXC 151 on `compute-storage-01` — `pct exec 151 -- bash`
|
||||||
- **Config:** `/etc/matrix-synapse/homeserver.yaml`
|
- **Config:** `/etc/matrix-synapse/homeserver.yaml`
|
||||||
- **Version check:** `curl -s https://matrix.lotusguild.org/_matrix/client/versions`
|
- **Version check:** `curl -s https://matrix.lotusguild.org/_matrix/client/versions`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Element Call fork — operational reference
|
||||||
|
|
||||||
|
_Ported from the retired `HANDOFF_ELEMENT_CALL_FORK.md` (2026-07; full history in git). The fork lives at `LotusGuild/element-call` (branch `lotus`, forked from upstream tag `v0.20.1`); cinny consumes it as the npm package `@lotusguild/element-call-embedded`, whose built bundle is copied into `public/element-call/`._
|
||||||
|
|
||||||
|
**Publish a new fork version (manual; needs the Gitea npm token):**
|
||||||
|
|
||||||
|
1. In the fork, bump `embedded/web/package.json` version (current unpublished: `0.20.1-lotus.2`).
|
||||||
|
2. Build: `pnpm run build:embedded` (Node 24, pnpm 10.33.0; output → repo `dist/`, staged into `embedded/web/dist`).
|
||||||
|
3. `cd embedded/web && npm version <tag> --no-git-tag-version && npm publish` to the Gitea registry (`code.lotusguild.org`). Publicly readable; only publishing needs the token.
|
||||||
|
4. In cinny: bump the `@lotusguild/element-call-embedded` pin (`package.json`, currently `0.20.1-lotus.1`) → the new version, `npm install`, build.
|
||||||
|
|
||||||
|
**`io.lotus.*` widget actions (fork ↔ cinny host):**
|
||||||
|
| Action | Direction | Purpose | Fork module |
|
||||||
|
| :-- | :-- | :-- | :-- |
|
||||||
|
| `io.lotus.call_state` | EC→host | speaker/mute/camera state stream (URL `lotusCallState=1`) | `lotusCallState.ts` |
|
||||||
|
| `io.lotus.focus_participant` | host→EC | spotlight a participant (works during screenshare) | `lotusFocus.ts` |
|
||||||
|
| `io.lotus.inject_audio` | host→EC | soundboard clip mixed into the call (URL `lotusAudioInject=1`) | `lotusAudioInject.ts` |
|
||||||
|
| `io.lotus.set_quality` | host→EC | audio/screenshare bitrate/fps caps | `lotusQuality.ts` |
|
||||||
|
| `io.lotus.decorations` | host→EC | in-call avatar decorations | `lotusDecorations.ts` |
|
||||||
|
| `io.lotus.set_deafen` | host→EC | deafen / screenshare-audio-mute at the LiveKit source (P6-2) | `lotusDeafen.ts` |
|
||||||
|
|
||||||
|
Also flag-gated (URL params): `lotusTransparent`/`lotusTheme` (theme), `lotusDenoiseSource=1` (in-source ML denoise). New toWidget actions must be added to the enum + `LOTUS_TO_WIDGET_ACTIONS` in `src/lotus/lotusActions.ts` and only SENT after call-join (else a 10s timeout). **P6-2 phase 2 pending:** after publishing lotus.2, bump the cinny pin + delete the `CallControl.ts` `<audio>.muted` fallback.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔴 Open — Actionable
|
||||||
|
|
||||||
|
### 🧨 Encryption / E2EE — ⚠️ EXTREME COMPLEXITY · 🧠 PLANNING SESSION REQUIRED · 👤 SENIOR ENGINEER
|
||||||
|
|
||||||
|
> 🧰 **Investigation kit ready (2026-07):** `LOTUS_E2EE_INVESTIGATION.md` (git history)
|
||||||
|
> has the per-KE capture runbook (console signatures, synapse-side queries, the
|
||||||
|
> KE-1→KE-2 causality decision tree, ranked remediations), and the client now
|
||||||
|
> ships a **Crypto Diagnostics** capture helper (Settings) — run it during the
|
||||||
|
> next affected call and download the report before starting any fix.
|
||||||
|
|
||||||
|
> **Observed live in prod 2026-06-30** on `chat.lotusguild.org` during a 2-person
|
||||||
|
> **Element Call** (E2EE enabled). These span **client rust-crypto (via
|
||||||
|
> `matrix-js-sdk@41.6.0-rc.0`) ↔ Synapse ↔ Element Call's MatrixRTC E2EE** and are
|
||||||
|
> very likely **interrelated** (see KE-1 → KE-2). Do **not** spot-fix — they need
|
||||||
|
> a dedicated cross-system planning session with the homeserver owner. Capture
|
||||||
|
> full client console + a synapse-side trace for the same call before starting.
|
||||||
|
> **None of these are caused by the EC fork work** (the issues reproduce on the
|
||||||
|
> old build; the local mic/denoise path is unrelated to key distribution).
|
||||||
|
|
||||||
|
- **KE-1 — One-time-key (OTK) upload conflict storm (CRITICAL, root-cause candidate).**
|
||||||
|
`POST /_matrix/client/v3/keys/upload` returns `400 M_UNKNOWN: One time key
|
||||||
|
signed_curve25519:AAAAAAAAAGQ already exists. Old key: {…} new key: {…}` —
|
||||||
|
firing **continuously** (many/sec). The client repeatedly tries to publish an
|
||||||
|
OTK at a key id the server already holds **with a different value**, i.e. the
|
||||||
|
rust-crypto key store and Synapse have **diverged OTK state**. Impact: floods
|
||||||
|
the crypto outgoing-request loop and is the prime suspect for the downstream
|
||||||
|
missing-key failures (no fresh OTKs ⇒ no new Olm sessions ⇒ undecryptable
|
||||||
|
to-device key events). _Investigate:_ device/key-store reset-or-restore
|
||||||
|
mismatch, OTK id-counter desync, RC-SDK (`41.6.0-rc.0`) regression, or a
|
||||||
|
Synapse OTK bug. Repro signature: grep console for `already exists`.
|
||||||
|
**Extreme — planning session.**
|
||||||
|
**Update 2026-07 (investigation §6):** upstream `matrix-rust-sdk#5200` (still
|
||||||
|
OPEN) confirms the mechanism — on the 400, `mark_request_as_sent()` never fires
|
||||||
|
so the SDK re-issues the identical upload forever. **`41.7.0` does NOT fix it**
|
||||||
|
(crypto-wasm 17→18.3.1 has no OTK/upload change; 18.3.x was to-device security
|
||||||
|
only) — the SDK-pin lever is closed. Root cause = **store↔server OTK
|
||||||
|
divergence**; the leading **web-specific** trigger is that cinny never calls
|
||||||
|
**`navigator.storage.persist()`**, so the IndexedDB crypto store is evictable
|
||||||
|
while the `localStorage` session/device-id survives → device resurrects with a
|
||||||
|
blank store → re-uploads OTKs the server still holds. **Actionable preventive
|
||||||
|
fix (buildable now, no call needed):** request persistent storage on login
|
||||||
|
(+ optional multi-tab guard + 400-loop→recovery-prompt). Healing an already-
|
||||||
|
diverged device still needs a clean **logout+login** (not just "clear
|
||||||
|
storage"). Full runbook (synapse SQL, capture checklist, §6 diagnosis) is in git history at `LOTUS_E2EE_INVESTIGATION.md` (removed 2026-07).
|
||||||
|
|
||||||
|
- **KE-2 — Element Call media keys not arriving/decrypting → audio & video cut out (CRITICAL).**
|
||||||
|
`MissingKey: missing key at index N for participant @user`, `skipping decryption
|
||||||
|
due to missing key`, `MissingKey: key set not found for @user at index 0`, and
|
||||||
|
rust-crypto `WARN … Received an unexpected encrypted to-device event …
|
||||||
|
event_type="io.element.call.encryption_keys"`. EC distributes per-participant
|
||||||
|
media keys as **encrypted to-device `io.element.call.encryption_keys`** events;
|
||||||
|
these aren't being received/decrypted in order, so remote LiveKit audio/video
|
||||||
|
can't be decrypted — **this is the "friend's audio cuts out occasionally"
|
||||||
|
symptom.** Almost certainly downstream of **KE-1** (broken Olm sessions). Spans
|
||||||
|
EC's MatrixRTC E2EE + rust-crypto to-device + Synapse. **Extreme — planning
|
||||||
|
session.**
|
||||||
|
|
||||||
|
- **KE-3 — Timeline decryption error: missing `algorithm` field (HIGH).**
|
||||||
|
`Error decrypting event (… type=m.room.encrypted …): DecryptionError[msg:
|
||||||
|
missing field 'algorithm' at line 1 column 138 …]`. A malformed/legacy
|
||||||
|
encrypted event (or a serialization mismatch in the RC SDK) that rust-crypto
|
||||||
|
can't parse. Lower frequency than KE-1/2 but a distinct decode-path failure —
|
||||||
|
capture the offending event id (`$SASBBzoqj…` seen) and inspect its raw content.
|
||||||
|
|
||||||
|
- **KE-4 — MatrixRTC delayed-event / membership timeouts (MEDIUM-HIGH, reliability).**
|
||||||
|
`[MembershipManager] Network local timeout error while sending event, immediate
|
||||||
|
retry … AbortError: Restart delayed event timed out before the HS responded`,
|
||||||
|
with repeated `org.matrix.msc4157.update_delayed_event`. MSC4140/4157
|
||||||
|
delayed-event reliability against `matrix.lotusguild.org` — can cause stale/ghost
|
||||||
|
call membership and missed leave events. May be partly **homeserver
|
||||||
|
responsiveness**; correlate with synapse latency/load. Include in the same
|
||||||
|
planning session since it shares the call-reliability + HS-interaction surface.
|
||||||
|
|
||||||
|
### Security & Privacy
|
||||||
|
|
||||||
|
- **N97 — Access token stored in plaintext `localStorage`** (`state/sessions.ts`), vulnerable to XSS; device ID likewise. Architectural — needs a token-protection / session-storage redesign.
|
||||||
|
- ~~**Session writes are non-atomic and not cross-tab synced**~~ — **done (2026-07):** atomic single-key `cinny_session_v1` blob (legacy-key migration + dual-write) + `subscribeSessionChanges`/`useSessionSync` cross-tab reload. (The plaintext-token concern in N97 above is the remaining, separate architectural item.)
|
||||||
|
- **Persisted PII without encryption:** user status message + expiry (`settings/account/Profile.tsx`), unsent composer drafts (`room/RoomInput.tsx`). Leak risk on shared devices.
|
||||||
|
|
||||||
|
### PWA / Offline / Notifications
|
||||||
|
|
||||||
|
- **N107 — SW has no `push` handler** — Web Push delivery is entirely non-functional. Needs a `push` listener + a Matrix push-gateway integration.
|
||||||
|
- **No app-asset caching strategy** (`src/sw.ts`) — no offline capability.
|
||||||
|
- ~~**`manifest: false`** may block PWA install~~ — **verified OK (2026-06):** `index.html` links `/manifest.json`, which exists in `public/` and is copied to `dist/`; VitePWA intentionally doesn't generate one. Not a bug.
|
||||||
|
|
||||||
|
### Dependencies & Build
|
||||||
|
|
||||||
|
- ~~**`matrix-js-sdk` pinned to a Release Candidate**~~ — **done (2026-07):** moved to `41.7.0` stable (crypto-wasm 18.3.1 security bump). Deep-audit dep triage: all 16 npm advisories are dev-only/unreachable/dead-dep — zero shipped exposure; dead `dompurify` removed. `@atlaskit`/build-tool pins remain review-worthy but low priority.
|
||||||
|
- **Build-time overhead:** `lotusDenoise` does heavy sequential `fs` work in `closeBundle`; `viteStaticCopy` config is complex with redundant renames — could be streamlined.
|
||||||
|
|
||||||
|
### Code Hygiene / DevEx
|
||||||
|
|
||||||
|
- **Automated test suite — 561+ tests across 65+ modules, a hard CI gate.** `npm test` runs Node's built-in runner via `tsx` (not vitest — Vite 8 is ahead of vitest's range) and **blocks the build job on failure**. Broad pure-logic coverage: utils (common, regex, sanitize/XSS, time, matrix, matrix-uia, mimeTypes, sort, accentColor, findAndReplace, AsyncSearch, ASCIILexicalTable, keyboard, room, matrix-crypto, featureCheck, syntaxHighlight, imageCompression, user-agent, callSounds), state (settings, sessions, recentSearches, upload, typingMembers, lists, room-list, toast, scheduledMessages, backupRestore, callEmbed/callPreferences, spaceRooms, …), plugins (matrix-to, call/utils, via-servers, bad-words, recent-emoji, custom-emoji, markdown block/inline/utils), OIDC (cs-api, useParsedLoginFlows, oidcState), lotus/avatarDecorations, message-search, search filters. Prevention work has caught + fixed **4 real bugs** (`findAndReplace` infinite-loop; `getSettings` crash-on-load when storage is blocked; `isMacOS` never matching modern Macs; `isMLDenoiseSupported` throwing `ReferenceError` instead of returning false on browsers lacking the `AudioWorkletNode` binding). **Next:** component/integration tests (the untestable-under-tsx DOM/React surface).
|
||||||
|
- **Extensive `as any` casts** across `src/` — gradual typing cleanup.
|
||||||
|
- **`types/matrix/` mirrors SDK types** instead of importing them — drift risk.
|
||||||
|
- ~~**Hardcoded CDN URL** should move to an env var~~ — **done:** `avatarDecorations.ts` already honors a `VITE_DECORATION_CDN` env override (lines 14-16); the in-repo literal is only the default. Nothing left.
|
||||||
|
- **`patch-folds.mjs` edits `node_modules` directly** — consider `patch-package`.
|
||||||
|
- **Infra docs:** `contrib/nginx` lacks security headers (HSTS/CSP) + uses rewrites over `try_files`; `contrib/caddy` has a placeholder path. CI/CD (`prod-deploy.yml`): sequential deploy, aggressive 1-min Netlify timeout, `package-manager-cache: false`.
|
||||||
|
- **README:** keep the fork-sync version + logo path current. (`CONTRIBUTING.md` is intentionally left as upstream Cinny's — not a Lotus concern.)
|
||||||
|
- **Architecture notes (low priority):** deep `features/` + `hooks/` nesting, many small coupled hooks, possible dead CSS/components, `SpacingVariant` / `DropTarget` recipe simplification.
|
||||||
|
- **Git workflow (forward-looking):** keep commits scoped — past monolithic "fix all bugs" commits and inconsistent prefixes hurt `git bisect`.
|
||||||
|
|
||||||
|
### Big Projects
|
||||||
|
|
||||||
|
- ~~**#5 — Seasonal themes & chat-background redesign.**~~ **DONE (2026-06/07):** 11 seasonal/holiday overlays shipped and later toned down + given a settings preview grid; all 19 chat backgrounds redesigned (Carbon + Aurora kept per user preference), one design sprint each, GPU-friendly CSS with `prefers-reduced-motion` + pause toggle. Remaining polish rides normal bug flow, not a "big project."
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
A Matrix chat client built for Lotus Guild — fast, private, and packed with the features you actually want.
|
A Matrix chat client built for Lotus Guild — fast, private, and packed with the features you actually want.
|
||||||
|
|
||||||
**Deployed at [chat.lotusguild.org](https://chat.lotusguild.org)** | Forked from [Cinny](https://github.com/cinnyapp/cinny) v4.12.1
|
**Deployed at [chat.lotusguild.org](https://chat.lotusguild.org)** | Forked from [Cinny](https://github.com/cinnyapp/cinny), synced through v4.12.3
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ A Matrix chat client built for Lotus Guild — fast, private, and packed with th
|
|||||||
|
|
||||||
The source code is licensed under [AGPLv3](LICENSE), the same license as the upstream Cinny project. The source for this fork is public at [code.lotusguild.org/LotusGuild/cinny](https://code.lotusguild.org/LotusGuild/cinny).
|
The source code is licensed under [AGPLv3](LICENSE), the same license as the upstream Cinny project. The source for this fork is public at [code.lotusguild.org/LotusGuild/cinny](https://code.lotusguild.org/LotusGuild/cinny).
|
||||||
|
|
||||||
The Lotus Chat logo (`lotus_chat.png`) is a derivative work based on the original Cinny logo by Ajay Bura and contributors, used under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). The modified logo is © Lotus Guild and is also made available under CC BY 4.0.
|
The Lotus Chat logo (`public/res/Lotus.png`) is a derivative work based on the original Cinny logo by Ajay Bura and contributors, used under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). The modified logo is © Lotus Guild and is also made available under CC BY 4.0.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -18,6 +18,8 @@ The Lotus Chat logo (`lotus_chat.png`) is a derivative work based on the origina
|
|||||||
|
|
||||||
### Messaging
|
### Messaging
|
||||||
|
|
||||||
|
- Threads: reply in a thread and read/write the whole conversation in a side panel — root messages show a "N replies" chip with an unread badge (threaded replies live in the panel now, not inline in the room)
|
||||||
|
- Slack-style thread notifications: by default you're only pinged for threads you're in or where you're @mentioned; set any thread to All / Mentions-only / Mute from the panel's bell menu (muted threads stop bumping badges; syncs across devices)
|
||||||
- See who has read each message, and track delivery status (sending / sent / failed)
|
- See who has read each message, and track delivery status (sending / sent / failed)
|
||||||
- Bookmark any message and revisit saved messages from the sidebar
|
- Bookmark any message and revisit saved messages from the sidebar
|
||||||
- Schedule messages to send at a specific time
|
- Schedule messages to send at a specific time
|
||||||
@@ -33,6 +35,8 @@ The Lotus Chat logo (`lotus_chat.png`) is a derivative work based on the origina
|
|||||||
- Search for and send GIFs from a built-in GIF picker
|
- Search for and send GIFs from a built-in GIF picker
|
||||||
- Control voice message playback speed: 0.75× / 1× / 1.5× / 2×
|
- Control voice message playback speed: 0.75× / 1× / 1.5× / 2×
|
||||||
- Search messages with a date range filter
|
- Search messages with a date range filter
|
||||||
|
- Optional persistent search index for encrypted rooms (off by default — stores decrypted text on your device; clearable, wiped on logout)
|
||||||
|
- Write math with LaTeX: `$inline$` and `$$block$$` render via KaTeX (spec `data-mx-maths` supported)
|
||||||
- Room topics support rich formatting (bold, links, italics)
|
- Room topics support rich formatting (bold, links, italics)
|
||||||
- Deleted messages show a placeholder instead of disappearing
|
- Deleted messages show a placeholder instead of disappearing
|
||||||
- Code blocks highlight syntax for JS/TS, Python, and Rust
|
- Code blocks highlight syntax for JS/TS, Python, and Rust
|
||||||
@@ -52,6 +56,9 @@ The Lotus Chat logo (`lotus_chat.png`) is a derivative work based on the origina
|
|||||||
- AFK auto-mute: mic is automatically silenced after a configurable idle timeout (1–30 min); a toast confirms the action
|
- AFK auto-mute: mic is automatically silenced after a configurable idle timeout (1–30 min); a toast confirms the action
|
||||||
- Voice channel user limit: admins can cap how many people can be in a room's call — enforced server-side for every Matrix client (not just Lotus Chat); others see "Channel Full" until a spot opens
|
- Voice channel user limit: admins can cap how many people can be in a room's call — enforced server-side for every Matrix client (not just Lotus Chat); others see "Channel Full" until a spot opens
|
||||||
- Custom join/leave sound effects when someone enters or leaves your call — choose Chime, Soft, Retro, or off
|
- Custom join/leave sound effects when someone enters or leaves your call — choose Chime, Soft, Retro, or off
|
||||||
|
- Soundboard: upload your own short audio clips (like custom emojis — they sync across your devices) and play them into a call so everyone hears them
|
||||||
|
- Call quality settings: cap your microphone bitrate, screenshare bitrate, and screenshare framerate — handy on a slow connection (Settings → Calls)
|
||||||
|
- Room call permissions: admins can turn off screen sharing or make a room audio-only (no cameras) — enforced server-side for every Matrix client, and it stops an in-progress share within seconds of being switched off
|
||||||
|
|
||||||
### Customization & Appearance
|
### Customization & Appearance
|
||||||
|
|
||||||
@@ -136,6 +143,20 @@ When you first run the installer on Windows, you may see a popup that says **"Wi
|
|||||||
|
|
||||||
After the first install, automatic in-app updates handle all future versions — you will not see this prompt again for updates.
|
After the first install, automatic in-app updates handle all future versions — you will not see this prompt again for updates.
|
||||||
|
|
||||||
|
### Desktop-Specific Features
|
||||||
|
|
||||||
|
Beyond the web client, the desktop app adds native OS integration (Windows-focused; graceful no-ops elsewhere). See [`LOTUS_FEATURES.md`](./LOTUS_FEATURES.md#desktop-app-features) for detail.
|
||||||
|
|
||||||
|
- **Native rich notifications** — Windows toasts you can click to open the room or reply to inline, right from the toast.
|
||||||
|
- **Focus Assist sync** — Lotus silences its own notifications while Windows Focus Assist / Quiet Hours is on.
|
||||||
|
- **Windows Jump List** — right-click the taskbar icon for quick access to your most-active rooms.
|
||||||
|
- **Taskbar call controls** — Mute / Deafen / End Call buttons on the taskbar thumbnail during a call, plus call status in the volume flyout (SMTC).
|
||||||
|
- **Stays awake in calls** — the system won't sleep or dim during a voice/video call.
|
||||||
|
- **Network awareness** — reconnects promptly when Windows connectivity changes.
|
||||||
|
- **Custom window chrome** (opt-in) — a Lotus-styled title bar in place of the OS one.
|
||||||
|
- **Recursive folder drag-drop** — drop a whole folder onto the composer to upload everything inside it.
|
||||||
|
- **Automatic background updates** with a one-click update toast.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## For Developers
|
## For Developers
|
||||||
@@ -144,6 +165,26 @@ The source code lives in `/root/code/cinny`. All changes should be made on the `
|
|||||||
|
|
||||||
See [LOTUS_FEATURES.md](LOTUS_FEATURES.md) for the full feature changelog and [LOTUS_TODO.md](LOTUS_TODO.md) for the work backlog.
|
See [LOTUS_FEATURES.md](LOTUS_FEATURES.md) for the full feature changelog and [LOTUS_TODO.md](LOTUS_TODO.md) for the work backlog.
|
||||||
|
|
||||||
|
### 🔱 Element Call fork ("Lotus Call") — LIVE
|
||||||
|
|
||||||
|
Voice/video channels embed **Element Call**, which is now our **self-built fork**
|
||||||
|
(`@lotusguild/element-call-embedded` `0.20.1-lotus.1`, source at
|
||||||
|
`LotusGuild/element-call`), published to our private Gitea npm registry and served
|
||||||
|
same-origin. We no longer depend on the upstream prebuilt bundle, so in-call
|
||||||
|
behavior is editable source instead of fragile DOM/widget hacks.
|
||||||
|
|
||||||
|
**Shipped via the fork:** denoise as an in-source LiveKit audio stage (survives
|
||||||
|
reconnects), in-call speaking/mute events, focus-a-participant during screenshare,
|
||||||
|
avatar decorations on EC video tiles, and a native transparent background.
|
||||||
|
**Built but dormant (need cinny UI):** real call-audio injection
|
||||||
|
(`io.lotus.inject_audio` → in-call soundboard) and quality controls
|
||||||
|
(`io.lotus.set_quality`).
|
||||||
|
|
||||||
|
The fork's `io.lotus.*` action catalog + the publish procedure are in
|
||||||
|
**[`LOTUS_TODO.md`](LOTUS_TODO.md)** ("Element Call fork — operational reference");
|
||||||
|
infra/hosting + build-pipeline notes live in the `LotusGuild/matrix` repo README.
|
||||||
|
Search the docs for the **`[EC-FORK]`** tag to find every related note.
|
||||||
|
|
||||||
### Build
|
### Build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+19
-2
@@ -30,6 +30,17 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Derive the parent origin for postMessage targetOrigin from the parentUrl
|
||||||
|
// widget param (a full URL) so denoise-status messages aren't broadcast with
|
||||||
|
// '*'. Fall back to this frame's own origin if parentUrl is missing/malformed.
|
||||||
|
var targetOrigin;
|
||||||
|
try {
|
||||||
|
var parentUrl = params.get('parentUrl');
|
||||||
|
targetOrigin = parentUrl ? new URL(parentUrl).origin : window.location.origin;
|
||||||
|
} catch (e) {
|
||||||
|
targetOrigin = window.location.origin;
|
||||||
|
}
|
||||||
|
|
||||||
var md = navigator.mediaDevices;
|
var md = navigator.mediaDevices;
|
||||||
if (!md || typeof md.getUserMedia !== 'function') return;
|
if (!md || typeof md.getUserMedia !== 'function') return;
|
||||||
if (typeof AudioWorkletNode === 'undefined' || typeof AudioContext === 'undefined') return;
|
if (typeof AudioWorkletNode === 'undefined' || typeof AudioContext === 'undefined') return;
|
||||||
@@ -274,6 +285,9 @@
|
|||||||
source.disconnect();
|
source.disconnect();
|
||||||
mlNode.disconnect();
|
mlNode.disconnect();
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
try {
|
||||||
|
if (gateNode) gateNode.disconnect();
|
||||||
|
} catch (e) {}
|
||||||
try {
|
try {
|
||||||
origTrack.stop();
|
origTrack.stop();
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
@@ -301,7 +315,7 @@
|
|||||||
nativeNS: USE_NATIVE_NS,
|
nativeNS: USE_NATIVE_NS,
|
||||||
gate: USE_GATE,
|
gate: USE_GATE,
|
||||||
},
|
},
|
||||||
'*',
|
targetOrigin,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,7 +330,10 @@
|
|||||||
.catch(function (e) {
|
.catch(function (e) {
|
||||||
var msg = e instanceof Error ? e.message : String(e);
|
var msg = e instanceof Error ? e.message : String(e);
|
||||||
console.error('[lotus-denoise] Setup failed:', msg);
|
console.error('[lotus-denoise] Setup failed:', msg);
|
||||||
window.parent.postMessage({ type: 'lotus-denoise-status', active: false, error: msg }, '*');
|
window.parent.postMessage(
|
||||||
|
{ type: 'lotus-denoise-status', active: false, error: msg },
|
||||||
|
targetOrigin,
|
||||||
|
);
|
||||||
return stream;
|
return stream;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-1
@@ -1,6 +1,17 @@
|
|||||||
# more info: https://caddyserver.com/docs/caddyfile/patterns#single-page-apps-spas
|
# more info: https://caddyserver.com/docs/caddyfile/patterns#single-page-apps-spas
|
||||||
cinny.domain.tld {
|
cinny.domain.tld {
|
||||||
root * /path/to/cinny/dist
|
root * /path/to/cinny/dist
|
||||||
try_files {path} / index.html
|
try_files {path} /index.html
|
||||||
file_server
|
file_server
|
||||||
|
|
||||||
|
# Security headers (generic; add a Content-Security-Policy suited to your
|
||||||
|
# homeserver + any embedded services). Caddy serves HTTPS automatically, so
|
||||||
|
# HSTS is delivered over TLS.
|
||||||
|
header {
|
||||||
|
X-Frame-Options SAMEORIGIN
|
||||||
|
X-Content-Type-Options nosniff
|
||||||
|
Referrer-Policy strict-origin-when-cross-origin
|
||||||
|
Strict-Transport-Security "max-age=63072000; includeSubDomains"
|
||||||
|
Permissions-Policy "accelerometer=(), autoplay=(self), camera=(self), display-capture=(self), encrypted-media=(self), fullscreen=(self), geolocation=(self), gyroscope=(), magnetometer=(), microphone=(self), midi=(), payment=(), usb=()"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,15 @@ server {
|
|||||||
listen [::]:443 ssl;
|
listen [::]:443 ssl;
|
||||||
server_name cinny.domain.tld;
|
server_name cinny.domain.tld;
|
||||||
|
|
||||||
|
# Security headers (generic; add a Content-Security-Policy suited to your
|
||||||
|
# homeserver + any embedded services). NOTE: nginx does not inherit
|
||||||
|
# server-level add_header into a location that sets its own add_header.
|
||||||
|
add_header X-Frame-Options SAMEORIGIN always;
|
||||||
|
add_header X-Content-Type-Options nosniff always;
|
||||||
|
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
||||||
|
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
|
||||||
|
add_header Permissions-Policy "accelerometer=(), autoplay=(self), camera=(self), display-capture=(self), encrypted-media=(self), fullscreen=(self), geolocation=(self), gyroscope=(), magnetometer=(), microphone=(self), midi=(), payment=(), usb=()" always;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
root /opt/cinny/dist/;
|
root /opt/cinny/dist/;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# Local OIDC / next-gen-auth (MSC3861) test loop
|
||||||
|
|
||||||
|
The Lotus client gained MSC3861/MSC2965 OIDC login (P4-6). lotusguild's own
|
||||||
|
homeserver is **not** MSC3861, so to exercise the flow without a mozilla.org
|
||||||
|
tester you need a local homeserver that delegates auth to a **Matrix
|
||||||
|
Authentication Service (MAS)**. This is the dev loop.
|
||||||
|
|
||||||
|
> Status: the Lotus-client side is unit-tested + gate-green; this server loop is
|
||||||
|
> the manual end-to-end check. It hasn't been run in CI (no container runtime
|
||||||
|
> there), so treat version pins as a starting point and bump as needed.
|
||||||
|
|
||||||
|
## 1. Stand up MAS + Synapse
|
||||||
|
|
||||||
|
The simplest path is the **upstream MAS docker-compose quickstart** — it's
|
||||||
|
maintained and handles key generation + the database:
|
||||||
|
<https://element-hq.github.io/matrix-authentication-service/setup/installation.html>
|
||||||
|
(`docker compose` section). Use it to get MAS + Synapse + Postgres running, then
|
||||||
|
apply the two Lotus-specific deltas below.
|
||||||
|
|
||||||
|
A minimal `compose.yaml` skeleton (generate MAS keys first — do **not** hand-write them):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16
|
||||||
|
environment: { POSTGRES_USER: synapse, POSTGRES_PASSWORD: pw, POSTGRES_DB: synapse }
|
||||||
|
mas:
|
||||||
|
image: ghcr.io/element-hq/matrix-authentication-service:latest
|
||||||
|
command: server
|
||||||
|
ports: ['8090:8080'] # MAS issuer on http://localhost:8090
|
||||||
|
volumes: ['./mas:/data']
|
||||||
|
# First run once: `docker compose run --rm mas config generate -o /data/config.yaml`
|
||||||
|
# then edit /data/mas/config.yaml (see §1a) before `up`.
|
||||||
|
synapse:
|
||||||
|
image: ghcr.io/element-hq/synapse:latest
|
||||||
|
ports: ['8008:8008'] # client/federation API
|
||||||
|
volumes: ['./synapse:/data']
|
||||||
|
depends_on: [postgres, mas]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1a. MAS `config.yaml` — the parts that matter
|
||||||
|
After `config generate` (which fills in `secrets.keys` + `encryption`), set:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
http:
|
||||||
|
public_base: http://localhost:8090/
|
||||||
|
issuer: http://localhost:8090/
|
||||||
|
database:
|
||||||
|
uri: postgresql://synapse:pw@postgres/synapse
|
||||||
|
matrix:
|
||||||
|
homeserver: localhost # the server_name
|
||||||
|
endpoint: http://synapse:8008/
|
||||||
|
secret: "REPLACE_WITH_A_LONG_SHARED_ADMIN_TOKEN"
|
||||||
|
clients:
|
||||||
|
- client_id: "0000000000000000000SYNAPSE"
|
||||||
|
client_auth_method: client_secret_basic
|
||||||
|
client_secret: "REPLACE_WITH_A_SHARED_CLIENT_SECRET"
|
||||||
|
passwords: # so you can create a local test account in the MAS UI
|
||||||
|
enabled: true
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1b. Synapse `homeserver.yaml` — delegate auth to MAS
|
||||||
|
See `synapse-msc3861.yaml` in this folder; the key block is:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
experimental_features:
|
||||||
|
msc3861:
|
||||||
|
enabled: true
|
||||||
|
issuer: http://localhost:8090/
|
||||||
|
client_id: "0000000000000000000SYNAPSE"
|
||||||
|
client_auth_method: client_secret_basic
|
||||||
|
client_secret: "REPLACE_WITH_A_SHARED_CLIENT_SECRET" # == MAS clients[].client_secret
|
||||||
|
admin_token: "REPLACE_WITH_A_LONG_SHARED_ADMIN_TOKEN" # == MAS matrix.secret
|
||||||
|
account_management_url: "http://localhost:8090/account"
|
||||||
|
```
|
||||||
|
|
||||||
|
Create a test user via the MAS UI (`http://localhost:8090/`) or
|
||||||
|
`docker compose exec mas mas-cli manage register-user`.
|
||||||
|
|
||||||
|
Sanity check discovery (the client relies on this):
|
||||||
|
```bash
|
||||||
|
curl -s http://localhost:8008/.well-known/matrix/client | jq '."m.authentication"'
|
||||||
|
# -> { "issuer": "http://localhost:8090/", "account": "http://localhost:8090/account" }
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Point the Lotus dev client at it
|
||||||
|
|
||||||
|
Run the client: `npm start` (vite dev). Override `public/config.json` so the
|
||||||
|
local server is selectable and custom servers are allowed:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"defaultHomeserver": 0,
|
||||||
|
"homeserverList": ["localhost:8008"],
|
||||||
|
"allowCustomHomeservers": true,
|
||||||
|
"hashRouter": { "enabled": false, "basename": "/" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Dynamic client registration handles the redirect URI automatically — it's
|
||||||
|
`<vite-origin>/auth/oidc/callback` (e.g. `http://localhost:5173/auth/oidc/callback`),
|
||||||
|
and MAS allows `http://localhost` redirects in dev.
|
||||||
|
|
||||||
|
## 3. Run the checklist
|
||||||
|
|
||||||
|
See **section N** of `../../LOTUS_TESTING.md` for the actual pass/fail steps
|
||||||
|
(login redirect, callback, session-persist-on-reload, token refresh, logout
|
||||||
|
revocation, account-management link, and the non-OIDC-regression check).
|
||||||
|
|
||||||
|
## Files here
|
||||||
|
- `synapse-msc3861.yaml` — the Synapse experimental-features delta.
|
||||||
|
- `config.local.json` — the Lotus `public/config.json` override.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"defaultHomeserver": 0,
|
||||||
|
"homeserverList": ["localhost:8008"],
|
||||||
|
"allowCustomHomeservers": true,
|
||||||
|
"featuredCommunities": { "openAsDefault": false, "spaces": [], "rooms": [], "servers": [] },
|
||||||
|
"hashRouter": { "enabled": false, "basename": "/" }
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Synapse experimental-features delta to delegate auth to a local MAS (MSC3861).
|
||||||
|
# Merge this into your test homeserver.yaml. The client_secret + admin_token MUST
|
||||||
|
# match the MAS config (clients[].client_secret and matrix.secret respectively).
|
||||||
|
experimental_features:
|
||||||
|
msc3861:
|
||||||
|
enabled: true
|
||||||
|
issuer: http://localhost:8090/
|
||||||
|
client_id: '0000000000000000000SYNAPSE'
|
||||||
|
client_auth_method: client_secret_basic
|
||||||
|
client_secret: 'REPLACE_WITH_A_SHARED_CLIENT_SECRET'
|
||||||
|
admin_token: 'REPLACE_WITH_A_LONG_SHARED_ADMIN_TOKEN'
|
||||||
|
account_management_url: 'http://localhost:8090/account'
|
||||||
|
|
||||||
|
# With msc3861 enabled, Synapse disables its own password/SSO login and advertises
|
||||||
|
# `m.authentication` in /.well-known/matrix/client — which is exactly what the
|
||||||
|
# Lotus client's getOidcIssuer() reads to switch into the OIDC flow.
|
||||||
+28
-1
@@ -25,7 +25,7 @@ export default [
|
|||||||
tsPlugin.configs['flat/eslint-recommended'],
|
tsPlugin.configs['flat/eslint-recommended'],
|
||||||
...tsPlugin.configs['flat/recommended'],
|
...tsPlugin.configs['flat/recommended'],
|
||||||
reactPlugin.configs.flat.recommended,
|
reactPlugin.configs.flat.recommended,
|
||||||
reactHooksPlugin.configs.flat['recommended'],
|
reactHooksPlugin.configs.flat.recommended,
|
||||||
// Register jsx-a11y plugin (rules selectively enabled below)
|
// Register jsx-a11y plugin (rules selectively enabled below)
|
||||||
{ plugins: { 'jsx-a11y': jsxA11yPlugin } },
|
{ plugins: { 'jsx-a11y': jsxA11yPlugin } },
|
||||||
// airbnb-base via FlatCompat (JS/import rules; no React plugin, no getFilename issue)
|
// airbnb-base via FlatCompat (JS/import rules; no React plugin, no getFilename issue)
|
||||||
@@ -115,6 +115,26 @@ export default [
|
|||||||
'jsx-a11y/media-has-caption': 'off',
|
'jsx-a11y/media-has-caption': 'off',
|
||||||
'jsx-a11y/no-noninteractive-element-interactions': 'off',
|
'jsx-a11y/no-noninteractive-element-interactions': 'off',
|
||||||
'jsx-a11y/alt-text': 'off',
|
'jsx-a11y/alt-text': 'off',
|
||||||
|
// A11y regression gate (P3-4). A CURATED set — correctness rules that catch
|
||||||
|
// real WCAG gaps (missing accessible names, malformed ARIA) without
|
||||||
|
// flooding on the pre-existing clickable-div patterns. The heavier
|
||||||
|
// interaction rules (no-static-element-interactions,
|
||||||
|
// click-events-have-key-events) are a separate cleanup and stay OFF.
|
||||||
|
'jsx-a11y/aria-props': 'error',
|
||||||
|
'jsx-a11y/aria-proptypes': 'error',
|
||||||
|
'jsx-a11y/aria-role': ['error', { ignoreNonDOM: true }],
|
||||||
|
'jsx-a11y/aria-unsupported-elements': 'error',
|
||||||
|
'jsx-a11y/role-has-required-aria-props': 'error',
|
||||||
|
'jsx-a11y/role-supports-aria-props': 'error',
|
||||||
|
'jsx-a11y/no-redundant-roles': 'error',
|
||||||
|
'jsx-a11y/anchor-has-content': 'error',
|
||||||
|
'jsx-a11y/heading-has-content': 'error',
|
||||||
|
'jsx-a11y/label-has-associated-control': ['error', { assert: 'either', depth: 5 }],
|
||||||
|
// NOT enabled: control-has-associated-label. This repo labels most inputs
|
||||||
|
// with folds `<Text as="label" htmlFor>` — a component the rule's static
|
||||||
|
// analysis can't see as a <label>, producing false positives on correctly
|
||||||
|
// labeled controls. The genuinely-unlabeled controls it surfaced (sliders,
|
||||||
|
// file input, media players, notes) were fixed directly with aria-label.
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -123,4 +143,11 @@ export default [
|
|||||||
'no-undef': 'off',
|
'no-undef': 'off',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Test files commonly define several small mock/fake classes.
|
||||||
|
files: ['**/*.test.ts', '**/*.test.tsx'],
|
||||||
|
rules: {
|
||||||
|
'max-classes-per-file': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
Generated
+81
-544
@@ -21,11 +21,9 @@
|
|||||||
"@giphy/js-util": "5.2.0",
|
"@giphy/js-util": "5.2.0",
|
||||||
"@giphy/react-components": "10.1.2",
|
"@giphy/react-components": "10.1.2",
|
||||||
"@sapphi-red/web-noise-suppressor": "0.3.5",
|
"@sapphi-red/web-noise-suppressor": "0.3.5",
|
||||||
"@sentry/react": "10.53.1",
|
|
||||||
"@tanstack/react-query": "5.100.13",
|
"@tanstack/react-query": "5.100.13",
|
||||||
"@tanstack/react-query-devtools": "5.100.13",
|
"@tanstack/react-query-devtools": "5.100.13",
|
||||||
"@tanstack/react-virtual": "3.13.25",
|
"@tanstack/react-virtual": "3.13.25",
|
||||||
"@types/dompurify": "3.2.0",
|
|
||||||
"@workadventure/noise-suppression": "0.0.4",
|
"@workadventure/noise-suppression": "0.0.4",
|
||||||
"await-to-js": "3.0.0",
|
"await-to-js": "3.0.0",
|
||||||
"badwords-list": "2.0.1-4",
|
"badwords-list": "2.0.1-4",
|
||||||
@@ -37,7 +35,6 @@
|
|||||||
"dayjs": "1.11.20",
|
"dayjs": "1.11.20",
|
||||||
"deepfilternet3-noise-filter": "1.2.1",
|
"deepfilternet3-noise-filter": "1.2.1",
|
||||||
"domhandler": "6.0.1",
|
"domhandler": "6.0.1",
|
||||||
"dompurify": "3.4.5",
|
|
||||||
"emojibase": "17.0.0",
|
"emojibase": "17.0.0",
|
||||||
"emojibase-data": "17.0.0",
|
"emojibase-data": "17.0.0",
|
||||||
"file-saver": "2.0.5",
|
"file-saver": "2.0.5",
|
||||||
@@ -52,10 +49,10 @@
|
|||||||
"immer": "11.1.8",
|
"immer": "11.1.8",
|
||||||
"is-hotkey": "0.2.0",
|
"is-hotkey": "0.2.0",
|
||||||
"jotai": "2.20.0",
|
"jotai": "2.20.0",
|
||||||
|
"katex": "0.16.11",
|
||||||
"linkify-react": "4.3.3",
|
"linkify-react": "4.3.3",
|
||||||
"linkifyjs": "4.3.3",
|
"linkifyjs": "4.3.3",
|
||||||
"lodash": "4.18.1",
|
"matrix-js-sdk": "41.7.0",
|
||||||
"matrix-js-sdk": "41.6.0-rc.0",
|
|
||||||
"matrix-widget-api": "1.17.0",
|
"matrix-widget-api": "1.17.0",
|
||||||
"millify": "6.1.0",
|
"millify": "6.1.0",
|
||||||
"pdfjs-dist": "5.7.284",
|
"pdfjs-dist": "5.7.284",
|
||||||
@@ -76,16 +73,17 @@
|
|||||||
"slate-history": "0.113.1",
|
"slate-history": "0.113.1",
|
||||||
"slate-react": "0.124.2",
|
"slate-react": "0.124.2",
|
||||||
"styled-components": "6.4.2",
|
"styled-components": "6.4.2",
|
||||||
"ua-parser-js": "2.0.10"
|
"ua-parser-js": "2.0.10",
|
||||||
|
"workbox-precaching": "7.4.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@element-hq/element-call-embedded": "0.20.1",
|
"@lotusguild/element-call-embedded": "0.20.1-lotus.1",
|
||||||
"@rollup/plugin-inject": "5.0.5",
|
"@rollup/plugin-inject": "5.0.5",
|
||||||
"@rollup/plugin-wasm": "6.2.2",
|
"@rollup/plugin-wasm": "6.2.2",
|
||||||
"@sentry/vite-plugin": "5.3.0",
|
|
||||||
"@types/chroma-js": "3.1.2",
|
"@types/chroma-js": "3.1.2",
|
||||||
"@types/file-saver": "2.0.7",
|
"@types/file-saver": "2.0.7",
|
||||||
"@types/is-hotkey": "0.1.10",
|
"@types/is-hotkey": "0.1.10",
|
||||||
|
"@types/katex": "0.16.8",
|
||||||
"@types/node": "25.9.1",
|
"@types/node": "25.9.1",
|
||||||
"@types/prismjs": "1.26.6",
|
"@types/prismjs": "1.26.6",
|
||||||
"@types/react": "19.2.15",
|
"@types/react": "19.2.15",
|
||||||
@@ -111,6 +109,7 @@
|
|||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"lint-staged": "17.0.5",
|
"lint-staged": "17.0.5",
|
||||||
"prettier": "3.8.3",
|
"prettier": "3.8.3",
|
||||||
|
"tsx": "4.22.4",
|
||||||
"typescript": "6.0.3",
|
"typescript": "6.0.3",
|
||||||
"vite": "8.0.14",
|
"vite": "8.0.14",
|
||||||
"vite-plugin-pwa": "1.3.0",
|
"vite-plugin-pwa": "1.3.0",
|
||||||
@@ -1791,12 +1790,6 @@
|
|||||||
"node": ">=v18"
|
"node": ">=v18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@element-hq/element-call-embedded": {
|
|
||||||
"version": "0.20.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@element-hq/element-call-embedded/-/element-call-embedded-0.20.1.tgz",
|
|
||||||
"integrity": "sha512-ODg2r7UmR8UjRpapLKbn6v1PS8fu/r58zdbvXMYaAlUEAC2f6L/9Moc9S4noG1+ARgWxY+m2vLmNDK9G9uFZYQ==",
|
|
||||||
"dev": true
|
|
||||||
},
|
|
||||||
"node_modules/@emnapi/core": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.10.0",
|
"version": "1.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||||
@@ -2696,10 +2689,16 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz",
|
||||||
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
|
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
|
||||||
},
|
},
|
||||||
|
"node_modules/@lotusguild/element-call-embedded": {
|
||||||
|
"version": "0.20.1-lotus.1",
|
||||||
|
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.20.1-lotus.1/element-call-embedded-0.20.1-lotus.1.tgz",
|
||||||
|
"integrity": "sha512-hy1KEnFw4MuwvlactUFPPvvtPZh1y56JMK/ehnficUmJNwdJsOhSwThaYp35RZ/ar6RCuiW86yQqlQBOSpZJVQ==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
||||||
"version": "18.3.0",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-18.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-18.3.1.tgz",
|
||||||
"integrity": "sha512-9a4feyt8QLysARu7PHKaRWT+wcCd+IYH074LXp9QK5WqfN4zUXueRhiSSMNT18Bm+8q3sBR/4zxDxOSDR0M8Kg==",
|
"integrity": "sha512-VRjWhE1UgHnPpJ3b9B5+8z71ZC/HICFngPPFIN6ktzmUBKI5RusPujzbAQUoB3CgZ0yU58L99AfSQS4YTztSWw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 18"
|
"node": ">= 18"
|
||||||
@@ -3783,403 +3782,6 @@
|
|||||||
"integrity": "sha512-jh3+V9yM+zxLriQexoGm0GatoPaJWjs6ypFIbFYwQp+AoUb55eUXrjKtKQyuC5zShzzeAQUl0M5JzqB7SSrsRA==",
|
"integrity": "sha512-jh3+V9yM+zxLriQexoGm0GatoPaJWjs6ypFIbFYwQp+AoUb55eUXrjKtKQyuC5zShzzeAQUl0M5JzqB7SSrsRA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@sentry-internal/browser-utils": {
|
|
||||||
"version": "10.53.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.53.1.tgz",
|
|
||||||
"integrity": "sha512-X4d6y8sBMjmNhcDW4eMBU3ASsNIMz8dqaFkhyIMN/dkYr/yZKnbRZPaVuVUGvHKjnlficPpIH0/HK9KBjrYxPw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@sentry/core": "10.53.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry-internal/feedback": {
|
|
||||||
"version": "10.53.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.53.1.tgz",
|
|
||||||
"integrity": "sha512-vVpTI/aEYN5d9IgZeYJWMqVaN0+iFgidSrYNAsZTh1US5sJUzF/wrl+68KdpmCtFROrN3jiAn1oPSwL5CKvEJA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@sentry/core": "10.53.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry-internal/replay": {
|
|
||||||
"version": "10.53.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.53.1.tgz",
|
|
||||||
"integrity": "sha512-wZNzTBYkgGUPWMuUQv7L64+OJmoCnz7GQNiTrTFK6EVAjJXFBCSsPp/nhif0bLhbk8+0g4xz633uOhpXuQbFdw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@sentry-internal/browser-utils": "10.53.1",
|
|
||||||
"@sentry/core": "10.53.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry-internal/replay-canvas": {
|
|
||||||
"version": "10.53.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.53.1.tgz",
|
|
||||||
"integrity": "sha512-aueLaf/2prExwA76BGU5/bOXCKWqtt6jQXWA6WJQNrmKpPEtZJB4ypnpsou0McXQCF8tur2Y8U0TEkwQP13yJQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@sentry-internal/replay": "10.53.1",
|
|
||||||
"@sentry/core": "10.53.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/babel-plugin-component-annotate": {
|
|
||||||
"version": "5.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-5.3.0.tgz",
|
|
||||||
"integrity": "sha512-p4q8gn8wcFqZGP/s2MnJCAAd8fTikaU6A0mM97RDHQgStcrYiaS0Sc5zUNfb1V+UOLPuvdEdL6MwyxfzjYJQTA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/browser": {
|
|
||||||
"version": "10.53.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.53.1.tgz",
|
|
||||||
"integrity": "sha512-zXF373hzUOGzUOrqd8xb1U3LQi5uYC3mwv+z5OMKUUinQlu30tTWBs7ypy6YTchtix9QlYaHWlayUF8vBZ5UjA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@sentry-internal/browser-utils": "10.53.1",
|
|
||||||
"@sentry-internal/feedback": "10.53.1",
|
|
||||||
"@sentry-internal/replay": "10.53.1",
|
|
||||||
"@sentry-internal/replay-canvas": "10.53.1",
|
|
||||||
"@sentry/core": "10.53.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/bundler-plugin-core": {
|
|
||||||
"version": "5.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/bundler-plugin-core/-/bundler-plugin-core-5.3.0.tgz",
|
|
||||||
"integrity": "sha512-L5T60sWdAI3qWwdg3Ptwek/0TY59PERrxyqp4XMUkroayQvGd9r5dIW9Q1kSeXX9iJ442nXbFZKAOyCKV4Z13Q==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/core": "^7.18.5",
|
|
||||||
"@sentry/babel-plugin-component-annotate": "5.3.0",
|
|
||||||
"@sentry/cli": "^2.58.5",
|
|
||||||
"dotenv": "^16.3.1",
|
|
||||||
"find-up": "^5.0.0",
|
|
||||||
"glob": "^13.0.6",
|
|
||||||
"magic-string": "~0.30.8"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/bundler-plugin-core/node_modules/balanced-match": {
|
|
||||||
"version": "4.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
|
||||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "18 || 20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/bundler-plugin-core/node_modules/brace-expansion": {
|
|
||||||
"version": "5.0.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
|
||||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"balanced-match": "^4.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "18 || 20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/bundler-plugin-core/node_modules/glob": {
|
|
||||||
"version": "13.0.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
|
|
||||||
"integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "BlueOak-1.0.0",
|
|
||||||
"dependencies": {
|
|
||||||
"minimatch": "^10.2.2",
|
|
||||||
"minipass": "^7.1.3",
|
|
||||||
"path-scurry": "^2.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "18 || 20 || >=22"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/bundler-plugin-core/node_modules/minimatch": {
|
|
||||||
"version": "10.2.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
|
||||||
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "BlueOak-1.0.0",
|
|
||||||
"dependencies": {
|
|
||||||
"brace-expansion": "^5.0.5"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "18 || 20 || >=22"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/bundler-plugin-core/node_modules/minipass": {
|
|
||||||
"version": "7.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
|
||||||
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "BlueOak-1.0.0",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=16 || 14 >=14.17"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/cli": {
|
|
||||||
"version": "2.58.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.58.6.tgz",
|
|
||||||
"integrity": "sha512-baBcNPLLfUi9WuL+Tpri9BFaAdvugZIKelC5X0tt0Zdy+K0K+PCVSrnNmwMWU/HyaF/SEv6b6UHnXIdqanBlcg==",
|
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "FSL-1.1-MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"https-proxy-agent": "^5.0.0",
|
|
||||||
"node-fetch": "^2.6.7",
|
|
||||||
"progress": "^2.0.3",
|
|
||||||
"proxy-from-env": "^1.1.0",
|
|
||||||
"which": "^2.0.2"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"sentry-cli": "bin/sentry-cli"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@sentry/cli-darwin": "2.58.6",
|
|
||||||
"@sentry/cli-linux-arm": "2.58.6",
|
|
||||||
"@sentry/cli-linux-arm64": "2.58.6",
|
|
||||||
"@sentry/cli-linux-i686": "2.58.6",
|
|
||||||
"@sentry/cli-linux-x64": "2.58.6",
|
|
||||||
"@sentry/cli-win32-arm64": "2.58.6",
|
|
||||||
"@sentry/cli-win32-i686": "2.58.6",
|
|
||||||
"@sentry/cli-win32-x64": "2.58.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/cli-darwin": {
|
|
||||||
"version": "2.58.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-2.58.6.tgz",
|
|
||||||
"integrity": "sha512-udAVvcyfNa0R+95GvPz/+43/N3TC0TYKdkQ7D7jhPSzbcMc7l2fxRNN5yB3UpCA5fWFnW4toeaqwDBhb/Wh3LA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "FSL-1.1-MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/cli-linux-arm": {
|
|
||||||
"version": "2.58.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-2.58.6.tgz",
|
|
||||||
"integrity": "sha512-pD0LAt5PcUzAinBwvDqc66x9+2CabHEv486yP0gRjWO7SakbaxmfVq/EXd8VLq/Tzi39LAu422UYK1lpW3MILw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "FSL-1.1-MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux",
|
|
||||||
"freebsd",
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/cli-linux-arm64": {
|
|
||||||
"version": "2.58.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.58.6.tgz",
|
|
||||||
"integrity": "sha512-q8mEcNNmeXMy5i+jWT30TVpH7LcP4HD21CD5XRSPAd/a912HF6EpK0ybf/1USO14WOhoXbAGi9txwaWabSe33g==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "FSL-1.1-MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux",
|
|
||||||
"freebsd",
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/cli-linux-i686": {
|
|
||||||
"version": "2.58.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-2.58.6.tgz",
|
|
||||||
"integrity": "sha512-q8vNJi1eOV/4vxAFWBsEwLHoSYapaZHIf4j76KJGJXFKTkEbsjCOOsKbwUIBTQQhRgV4DFWh3ryfsPS/que4Kg==",
|
|
||||||
"cpu": [
|
|
||||||
"x86",
|
|
||||||
"ia32"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "FSL-1.1-MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux",
|
|
||||||
"freebsd",
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/cli-linux-x64": {
|
|
||||||
"version": "2.58.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-2.58.6.tgz",
|
|
||||||
"integrity": "sha512-DZu956Mhi3ZRjTBe1WdbGV46ldVbA8d2rgp/fh51GsI25zjBHah4wZnPTSzpc+YqxU6pJpg579B/r3jrIK530Q==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "FSL-1.1-MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux",
|
|
||||||
"freebsd",
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/cli-win32-arm64": {
|
|
||||||
"version": "2.58.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.58.6.tgz",
|
|
||||||
"integrity": "sha512-nj0Ff/kmAB73EPDhR8B4O9r+NUHK5GkPCkGWC+kXVemqAJWL5jcJ5KdxG0l/S0z6RoEoltID8/43/B+TaMlT7A==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "FSL-1.1-MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/cli-win32-i686": {
|
|
||||||
"version": "2.58.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-2.58.6.tgz",
|
|
||||||
"integrity": "sha512-WNZiDzPbgsEMQWq4avsQ391v/xWKJDIWWWo9GYl+N/w5qcYKkoDW7wQG7T9FasI6ENn68phChTOAPXXxbfAdOg==",
|
|
||||||
"cpu": [
|
|
||||||
"x86",
|
|
||||||
"ia32"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "FSL-1.1-MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/cli-win32-x64": {
|
|
||||||
"version": "2.58.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-2.58.6.tgz",
|
|
||||||
"integrity": "sha512-R35WJ17oF4D2eqI1DR2sQQqr0fjRTt5xoP16WrTu91XM2lndRMFsnjh+/GttbxapLCBNlrjzia99MJ0PZHZpgA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "FSL-1.1-MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/core": {
|
|
||||||
"version": "10.53.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.53.1.tgz",
|
|
||||||
"integrity": "sha512-XG4ezlkyuAPjBC5+9kXC94rXXuqYTw9NRhfaDHssbTFaGnqBR8vQX2UUgZfY7ucbeelRDGfBu1sywoU+mB04uA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/react": {
|
|
||||||
"version": "10.53.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.53.1.tgz",
|
|
||||||
"integrity": "sha512-lrwNq5T/zW84l60894TpKHPcvFuc1I/Hnohecc0TfYVpIcYYuw2orCHoU4v4wgkFaJUpegVetbgdOphViyLVjA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@sentry/browser": "10.53.1",
|
|
||||||
"@sentry/core": "10.53.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^16.14.0 || 17.x || 18.x || 19.x"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/rollup-plugin": {
|
|
||||||
"version": "5.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/rollup-plugin/-/rollup-plugin-5.3.0.tgz",
|
|
||||||
"integrity": "sha512-hgPGPYdQJ/G1cGYOxAb7d4z3V+/k/E5/P/5TFPEEBLuIbFFk+JG0CISUDJdzXJjO382Lb99PBJuXGbueBmO79w==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@sentry/bundler-plugin-core": "5.3.0",
|
|
||||||
"magic-string": "~0.30.8"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 18"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"rollup": ">=3.2.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"rollup": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/vite-plugin": {
|
|
||||||
"version": "5.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/vite-plugin/-/vite-plugin-5.3.0.tgz",
|
|
||||||
"integrity": "sha512-qcoSzo4n2MulVQ70UUPLq6dTleb2a2HwL2wuwvAgWhPChrYTuk6A6mDg6aQb9fairPAwFPiU9PzOANpoDJcz1A==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@sentry/bundler-plugin-core": "5.3.0",
|
|
||||||
"@sentry/rollup-plugin": "5.3.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@simple-libs/stream-utils": {
|
"node_modules/@simple-libs/stream-utils": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@simple-libs/stream-utils/-/stream-utils-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@simple-libs/stream-utils/-/stream-utils-1.2.0.tgz",
|
||||||
@@ -4317,16 +3919,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/dompurify": {
|
|
||||||
"version": "3.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.2.0.tgz",
|
|
||||||
"integrity": "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==",
|
|
||||||
"deprecated": "This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"dompurify": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@@ -4373,6 +3965,13 @@
|
|||||||
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
|
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/katex": {
|
||||||
|
"version": "0.16.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz",
|
||||||
|
"integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "25.9.1",
|
"version": "25.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
|
||||||
@@ -4441,7 +4040,7 @@
|
|||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||||
"devOptional": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@types/ua-parser-js": {
|
"node_modules/@types/ua-parser-js": {
|
||||||
"version": "0.7.39",
|
"version": "0.7.39",
|
||||||
@@ -4894,18 +4493,6 @@
|
|||||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/agent-base": {
|
|
||||||
"version": "6.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
|
||||||
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
|
||||||
"debug": "4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 6.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "6.15.0",
|
"version": "6.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||||
@@ -5952,12 +5539,16 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/content-type": {
|
"node_modules/content-type": {
|
||||||
"version": "1.0.5",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
|
||||||
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/conventional-commit-types": {
|
"node_modules/conventional-commit-types": {
|
||||||
@@ -6598,15 +6189,6 @@
|
|||||||
"node": ">=20.19.0"
|
"node": ">=20.19.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dompurify": {
|
|
||||||
"version": "3.4.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.5.tgz",
|
|
||||||
"integrity": "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA==",
|
|
||||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@types/trusted-types": "^2.0.7"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/domutils": {
|
"node_modules/domutils": {
|
||||||
"version": "3.2.2",
|
"version": "3.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||||
@@ -6635,19 +6217,6 @@
|
|||||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dotenv": {
|
|
||||||
"version": "16.6.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
|
||||||
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-2-Clause",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://dotenvx.com"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@@ -8474,19 +8043,6 @@
|
|||||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/https-proxy-agent": {
|
|
||||||
"version": "5.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
|
||||||
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
|
||||||
"agent-base": "6",
|
|
||||||
"debug": "4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/husky": {
|
"node_modules/husky": {
|
||||||
"version": "9.1.7",
|
"version": "9.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
|
||||||
@@ -9524,6 +9080,31 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/katex": {
|
||||||
|
"version": "0.16.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.11.tgz",
|
||||||
|
"integrity": "sha512-RQrI8rlHY92OLf3rho/Ts8i/XvjgguEjOkO1BEXcU3N8BqPpSzBNwV/G0Ukr+P/l3ivvJUE/Fa/CwbS6HesGNQ==",
|
||||||
|
"funding": [
|
||||||
|
"https://opencollective.com/katex",
|
||||||
|
"https://github.com/sponsors/katex"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"commander": "^8.3.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"katex": "cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/katex/node_modules/commander": {
|
||||||
|
"version": "8.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
|
||||||
|
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/keyv": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@@ -10374,16 +9955,16 @@
|
|||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/matrix-js-sdk": {
|
"node_modules/matrix-js-sdk": {
|
||||||
"version": "41.6.0-rc.0",
|
"version": "41.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-41.6.0-rc.0.tgz",
|
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-41.7.0.tgz",
|
||||||
"integrity": "sha512-FcTQyR+Nfh0ASEogYcX393hxGr1936Esg53Z+0f9O4SBsAxl1ZSkLXY3JfLZRLX9dNe38VVwQDQE6QuwnwV7Zw==",
|
"integrity": "sha512-MP0xNv/VVRbshq00TE6EVo77IIXsQk0KjiVtgKV0t9j/V77a6Klt00QrrO0XykkTUsNC0+mQeBMxnx75rZO86Q==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.12.5",
|
"@babel/runtime": "^7.12.5",
|
||||||
"@matrix-org/matrix-sdk-crypto-wasm": "^18.2.0",
|
"@matrix-org/matrix-sdk-crypto-wasm": "^18.3.1",
|
||||||
"another-json": "^0.2.0",
|
"another-json": "^0.2.0",
|
||||||
"bs58": "^6.0.0",
|
"bs58": "^6.0.0",
|
||||||
"content-type": "^1.0.4",
|
"content-type": "^2.0.0",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
"loglevel": "^1.9.2",
|
"loglevel": "^1.9.2",
|
||||||
"matrix-events-sdk": "0.0.1",
|
"matrix-events-sdk": "0.0.1",
|
||||||
@@ -10600,26 +10181,6 @@
|
|||||||
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
|
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/node-fetch": {
|
|
||||||
"version": "2.7.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
|
||||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
|
||||||
"whatwg-url": "^5.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "4.x || >=6.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"encoding": "^0.1.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"encoding": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.19",
|
"version": "2.0.19",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
|
||||||
@@ -11179,16 +10740,6 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/progress": {
|
|
||||||
"version": "2.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
|
|
||||||
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/prop-types": {
|
"node_modules/prop-types": {
|
||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
@@ -11199,13 +10750,6 @@
|
|||||||
"react-is": "^16.13.1"
|
"react-is": "^16.13.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/proxy-from-env": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/punycode": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
@@ -12784,12 +12328,6 @@
|
|||||||
"integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==",
|
"integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/tr46": {
|
|
||||||
"version": "0.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
|
||||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
|
||||||
"dev": true
|
|
||||||
},
|
|
||||||
"node_modules/ts-api-utils": {
|
"node_modules/ts-api-utils": {
|
||||||
"version": "2.5.0",
|
"version": "2.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
||||||
@@ -12838,6 +12376,25 @@
|
|||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
|
||||||
},
|
},
|
||||||
|
"node_modules/tsx": {
|
||||||
|
"version": "4.22.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
|
||||||
|
"integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"esbuild": "~0.28.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"tsx": "dist/cli.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "~2.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/type-check": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||||
@@ -13337,22 +12894,6 @@
|
|||||||
"defaults": "^1.0.3"
|
"defaults": "^1.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/webidl-conversions": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
|
||||||
"dev": true
|
|
||||||
},
|
|
||||||
"node_modules/whatwg-url": {
|
|
||||||
"version": "5.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
|
||||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
|
||||||
"tr46": "~0.0.3",
|
|
||||||
"webidl-conversions": "^3.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
@@ -13671,7 +13212,6 @@
|
|||||||
"version": "7.4.1",
|
"version": "7.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.4.1.tgz",
|
||||||
"integrity": "sha512-DT+vu46eh/2vRsSHTY4Xmc32Z1rr9PRlQUXr1Dx30ZuXRWwOsvZgGgcwxcasubQLQmbTNYZjv44LkBAQ4tT5tQ==",
|
"integrity": "sha512-DT+vu46eh/2vRsSHTY4Xmc32Z1rr9PRlQUXr1Dx30ZuXRWwOsvZgGgcwxcasubQLQmbTNYZjv44LkBAQ4tT5tQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/workbox-expiration": {
|
"node_modules/workbox-expiration": {
|
||||||
@@ -13712,7 +13252,6 @@
|
|||||||
"version": "7.4.1",
|
"version": "7.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.4.1.tgz",
|
||||||
"integrity": "sha512-cdr/9qByww7yzEp7zg/qI4ukUrrNjQLgN+ONQRpjy/VqGQXwkgHwr00KksGJK8v0VifwDXBb8a4cWNZH71jn3Q==",
|
"integrity": "sha512-cdr/9qByww7yzEp7zg/qI4ukUrrNjQLgN+ONQRpjy/VqGQXwkgHwr00KksGJK8v0VifwDXBb8a4cWNZH71jn3Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"workbox-core": "7.4.1",
|
"workbox-core": "7.4.1",
|
||||||
@@ -13749,7 +13288,6 @@
|
|||||||
"version": "7.4.1",
|
"version": "7.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.4.1.tgz",
|
||||||
"integrity": "sha512-yubJGErZOusuidAenaL5ypfhQOa7urxP/f8E0ws7FPb4039RiWXUWBAyUkmUoOL/BcQGen3h0J8872d51IYxtA==",
|
"integrity": "sha512-yubJGErZOusuidAenaL5ypfhQOa7urxP/f8E0ws7FPb4039RiWXUWBAyUkmUoOL/BcQGen3h0J8872d51IYxtA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"workbox-core": "7.4.1"
|
"workbox-core": "7.4.1"
|
||||||
@@ -13759,7 +13297,6 @@
|
|||||||
"version": "7.4.1",
|
"version": "7.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-7.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-7.4.1.tgz",
|
||||||
"integrity": "sha512-GZxpaw9NbmOelj7667uZ2kpk5BFpOGbO4X0qjwh5ls8XQ8C+Lha5LQchTiUzsTFSS+NlUpftYAyOVXvQUrcqOQ==",
|
"integrity": "sha512-GZxpaw9NbmOelj7667uZ2kpk5BFpOGbO4X0qjwh5ls8XQ8C+Lha5LQchTiUzsTFSS+NlUpftYAyOVXvQUrcqOQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"workbox-core": "7.4.1"
|
"workbox-core": "7.4.1"
|
||||||
|
|||||||
+8
-8
@@ -16,6 +16,7 @@
|
|||||||
"check:prettier": "prettier --check .",
|
"check:prettier": "prettier --check .",
|
||||||
"fix:prettier": "prettier --write .",
|
"fix:prettier": "prettier --write .",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "node --import tsx --test $(find src -name '*.test.ts')",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"commit": "git-cz",
|
"commit": "git-cz",
|
||||||
"postinstall": "node scripts/patch-folds.mjs",
|
"postinstall": "node scripts/patch-folds.mjs",
|
||||||
@@ -45,11 +46,9 @@
|
|||||||
"@giphy/js-util": "5.2.0",
|
"@giphy/js-util": "5.2.0",
|
||||||
"@giphy/react-components": "10.1.2",
|
"@giphy/react-components": "10.1.2",
|
||||||
"@sapphi-red/web-noise-suppressor": "0.3.5",
|
"@sapphi-red/web-noise-suppressor": "0.3.5",
|
||||||
"@sentry/react": "10.53.1",
|
|
||||||
"@tanstack/react-query": "5.100.13",
|
"@tanstack/react-query": "5.100.13",
|
||||||
"@tanstack/react-query-devtools": "5.100.13",
|
"@tanstack/react-query-devtools": "5.100.13",
|
||||||
"@tanstack/react-virtual": "3.13.25",
|
"@tanstack/react-virtual": "3.13.25",
|
||||||
"@types/dompurify": "3.2.0",
|
|
||||||
"@workadventure/noise-suppression": "0.0.4",
|
"@workadventure/noise-suppression": "0.0.4",
|
||||||
"await-to-js": "3.0.0",
|
"await-to-js": "3.0.0",
|
||||||
"badwords-list": "2.0.1-4",
|
"badwords-list": "2.0.1-4",
|
||||||
@@ -61,7 +60,6 @@
|
|||||||
"dayjs": "1.11.20",
|
"dayjs": "1.11.20",
|
||||||
"deepfilternet3-noise-filter": "1.2.1",
|
"deepfilternet3-noise-filter": "1.2.1",
|
||||||
"domhandler": "6.0.1",
|
"domhandler": "6.0.1",
|
||||||
"dompurify": "3.4.5",
|
|
||||||
"emojibase": "17.0.0",
|
"emojibase": "17.0.0",
|
||||||
"emojibase-data": "17.0.0",
|
"emojibase-data": "17.0.0",
|
||||||
"file-saver": "2.0.5",
|
"file-saver": "2.0.5",
|
||||||
@@ -76,10 +74,10 @@
|
|||||||
"immer": "11.1.8",
|
"immer": "11.1.8",
|
||||||
"is-hotkey": "0.2.0",
|
"is-hotkey": "0.2.0",
|
||||||
"jotai": "2.20.0",
|
"jotai": "2.20.0",
|
||||||
|
"katex": "0.16.11",
|
||||||
"linkify-react": "4.3.3",
|
"linkify-react": "4.3.3",
|
||||||
"linkifyjs": "4.3.3",
|
"linkifyjs": "4.3.3",
|
||||||
"lodash": "4.18.1",
|
"matrix-js-sdk": "41.7.0",
|
||||||
"matrix-js-sdk": "41.6.0-rc.0",
|
|
||||||
"matrix-widget-api": "1.17.0",
|
"matrix-widget-api": "1.17.0",
|
||||||
"millify": "6.1.0",
|
"millify": "6.1.0",
|
||||||
"pdfjs-dist": "5.7.284",
|
"pdfjs-dist": "5.7.284",
|
||||||
@@ -100,16 +98,17 @@
|
|||||||
"slate-history": "0.113.1",
|
"slate-history": "0.113.1",
|
||||||
"slate-react": "0.124.2",
|
"slate-react": "0.124.2",
|
||||||
"styled-components": "6.4.2",
|
"styled-components": "6.4.2",
|
||||||
"ua-parser-js": "2.0.10"
|
"ua-parser-js": "2.0.10",
|
||||||
|
"workbox-precaching": "7.4.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@element-hq/element-call-embedded": "0.20.1",
|
"@lotusguild/element-call-embedded": "0.20.1-lotus.1",
|
||||||
"@rollup/plugin-inject": "5.0.5",
|
"@rollup/plugin-inject": "5.0.5",
|
||||||
"@rollup/plugin-wasm": "6.2.2",
|
"@rollup/plugin-wasm": "6.2.2",
|
||||||
"@sentry/vite-plugin": "5.3.0",
|
|
||||||
"@types/chroma-js": "3.1.2",
|
"@types/chroma-js": "3.1.2",
|
||||||
"@types/file-saver": "2.0.7",
|
"@types/file-saver": "2.0.7",
|
||||||
"@types/is-hotkey": "0.1.10",
|
"@types/is-hotkey": "0.1.10",
|
||||||
|
"@types/katex": "0.16.8",
|
||||||
"@types/node": "25.9.1",
|
"@types/node": "25.9.1",
|
||||||
"@types/prismjs": "1.26.6",
|
"@types/prismjs": "1.26.6",
|
||||||
"@types/react": "19.2.15",
|
"@types/react": "19.2.15",
|
||||||
@@ -135,6 +134,7 @@
|
|||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"lint-staged": "17.0.5",
|
"lint-staged": "17.0.5",
|
||||||
"prettier": "3.8.3",
|
"prettier": "3.8.3",
|
||||||
|
"tsx": "4.22.4",
|
||||||
"typescript": "6.0.3",
|
"typescript": "6.0.3",
|
||||||
"vite": "8.0.14",
|
"vite": "8.0.14",
|
||||||
"vite-plugin-pwa": "1.3.0",
|
"vite-plugin-pwa": "1.3.0",
|
||||||
|
|||||||
+13
-2
@@ -25,7 +25,17 @@
|
|||||||
"close": "Close",
|
"close": "Close",
|
||||||
"accept": "Accept",
|
"accept": "Accept",
|
||||||
"they_match": "They Match",
|
"they_match": "They Match",
|
||||||
"okay": "Okay"
|
"okay": "Okay",
|
||||||
|
"do_not_match": "Do not Match",
|
||||||
|
"please_accept": "Please accept the request from other device.",
|
||||||
|
"waiting_accept": "Waiting for request to be accepted...",
|
||||||
|
"click_accept": "Click accept to start the verification process.",
|
||||||
|
"request_accepted": "Verification request has been accepted.",
|
||||||
|
"waiting_response": "Waiting for the response from other device...",
|
||||||
|
"starting_emoji": "Starting verification using emoji comparison...",
|
||||||
|
"confirm_emoji": "Confirm the emoji below are displayed on both devices, in the same order:",
|
||||||
|
"device_verified": "Your device is verified.",
|
||||||
|
"verification_canceled": "Verification has been canceled."
|
||||||
},
|
},
|
||||||
"UrlPreview": {
|
"UrlPreview": {
|
||||||
"join_server": "Join Server"
|
"join_server": "Join Server"
|
||||||
@@ -41,7 +51,8 @@
|
|||||||
"PasswordStage": {
|
"PasswordStage": {
|
||||||
"account_password": "Account Password",
|
"account_password": "Account Password",
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
"invalid_password": "Invalid Password!"
|
"invalid_password": "Invalid Password!",
|
||||||
|
"authenticate_prompt": "To perform this action you need to authenticate yourself by entering you account password."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,18 @@
|
|||||||
"src": "./res/android/android-chrome-512x512.png",
|
"src": "./res/android/android-chrome-512x512.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "./res/android/maskable-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "./res/android/maskable-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"categories": ["social", "communication", "productivity"],
|
"categories": ["social", "communication", "productivity"],
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 208 KiB After Width: | Height: | Size: 19 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
+11
-2
@@ -19,8 +19,17 @@ try {
|
|||||||
writeFileSync(foldsPath, content, 'utf8');
|
writeFileSync(foldsPath, content, 'utf8');
|
||||||
console.log('Applied defensive Icon src guard to folds.');
|
console.log('Applied defensive Icon src guard to folds.');
|
||||||
} else {
|
} else {
|
||||||
console.warn('Warning: folds Icon patch target not found - may need updating.');
|
// Genuine "patch could not be applied" case: the target string is gone
|
||||||
|
// (folds renamed/restructured it) AND it isn't already patched. Fail hard
|
||||||
|
// so the postinstall hook / CI breaks loudly instead of silently shipping
|
||||||
|
// an unpatched folds (which crashes at render with "src is not a function").
|
||||||
|
console.error(
|
||||||
|
'ERROR: folds Icon patch target not found - folds may have updated. ' +
|
||||||
|
'Update the patch target string in scripts/patch-folds.mjs before building.',
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Warning: Could not patch folds:', e.message);
|
console.error('ERROR: Could not patch folds:', e.message);
|
||||||
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,10 +21,25 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|||||||
const root = join(__dirname, '..');
|
const root = join(__dirname, '..');
|
||||||
const catalogPath = join(root, 'src', 'app', 'features', 'lotus', 'avatarDecorations.ts');
|
const catalogPath = join(root, 'src', 'app', 'features', 'lotus', 'avatarDecorations.ts');
|
||||||
|
|
||||||
const CDN = 'https://drive.lotusguild.org/public.php/dav/files/bHswJ9pNKp2t26N/cinny-decorations';
|
// Single source of truth: the CDN base URL lives in avatarDecorations.ts as
|
||||||
|
// `export const DECORATION_CDN`. We extract it from there at runtime rather than
|
||||||
|
// re-declaring it here, so the build script and the app can never drift. This
|
||||||
|
// .mjs script can't cleanly import the browser-side .ts module (it's outside the
|
||||||
|
// Vite/TS app graph), so we parse the constant out of the file text instead.
|
||||||
|
// If you migrate the CDN, change it ONLY in avatarDecorations.ts.
|
||||||
|
const catalog = readFileSync(catalogPath, 'utf8');
|
||||||
|
|
||||||
|
const cdnMatch = catalog.match(/export const DECORATION_CDN\s*=\s*['"]([^'"]+)['"]/);
|
||||||
|
if (!cdnMatch) {
|
||||||
|
console.error(
|
||||||
|
'Could not find `export const DECORATION_CDN` in avatarDecorations.ts — ' +
|
||||||
|
'the constant may have been renamed. Update scripts/syncDecorations.mjs.',
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const CDN = cdnMatch[1];
|
||||||
|
|
||||||
// Extract all slugs from the catalog file
|
// Extract all slugs from the catalog file
|
||||||
const catalog = readFileSync(catalogPath, 'utf8');
|
|
||||||
const slugMatches = [...catalog.matchAll(/slug: '([^']+)'/g)].map((m) => m[1]);
|
const slugMatches = [...catalog.matchAll(/slug: '([^']+)'/g)].map((m) => m[1]);
|
||||||
|
|
||||||
if (slugMatches.length === 0) {
|
if (slugMatches.length === 0) {
|
||||||
@@ -41,7 +56,8 @@ async function headCheck(slug) {
|
|||||||
const res = await fetch(`${CDN}/${slug}.png`, { method: 'HEAD' });
|
const res = await fetch(`${CDN}/${slug}.png`, { method: 'HEAD' });
|
||||||
return { slug, ok: res.ok, status: res.status };
|
return { slug, ok: res.ok, status: res.status };
|
||||||
} catch {
|
} catch {
|
||||||
return { slug, ok: false, status: 0 };
|
// Network/DNS/TLS failure — NOT a confirmation the file is gone.
|
||||||
|
return { slug, ok: false, status: 0, networkError: true };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +69,27 @@ for (let i = 0; i < slugMatches.length; i += BATCH) {
|
|||||||
results.push(...batchResults);
|
results.push(...batchResults);
|
||||||
}
|
}
|
||||||
|
|
||||||
const missing = results.filter((r) => !r.ok);
|
// Only a CONFIRMED HTTP 404 means the file is genuinely gone and safe to
|
||||||
|
// remove. A network error or any other non-ok status (5xx, 403, timeout) is
|
||||||
|
// ambiguous — the CDN may be unreachable — so refuse to remove anything and
|
||||||
|
// abort, otherwise a transient outage would wipe the whole catalog from source
|
||||||
|
// control (N119).
|
||||||
|
const transient = results.filter((r) => !r.ok && r.status !== 404);
|
||||||
|
if (transient.length > 0) {
|
||||||
|
console.error(
|
||||||
|
`Aborting: ${transient.length} decoration(s) returned a non-404 failure ` +
|
||||||
|
`(network error / server error). The CDN may be unreachable — refusing to ` +
|
||||||
|
`remove entries to avoid wiping the catalog.`,
|
||||||
|
);
|
||||||
|
transient
|
||||||
|
.slice(0, 8)
|
||||||
|
.forEach((r) =>
|
||||||
|
console.error(` ${r.slug}: ${r.networkError ? 'network error' : `HTTP ${r.status}`}`),
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const missing = results.filter((r) => r.status === 404);
|
||||||
const found = results.filter((r) => r.ok);
|
const found = results.filter((r) => r.ok);
|
||||||
|
|
||||||
if (missing.length === 0) {
|
if (missing.length === 0) {
|
||||||
|
|||||||
@@ -213,6 +213,7 @@ function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps)
|
|||||||
<Text size="L400">Account Data</Text>
|
<Text size="L400">Account Data</Text>
|
||||||
<Input
|
<Input
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
|
aria-label="Account data type"
|
||||||
size="400"
|
size="400"
|
||||||
radii="300"
|
radii="300"
|
||||||
readOnly
|
readOnly
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
config,
|
config,
|
||||||
Dialog,
|
Dialog,
|
||||||
Icon,
|
Icon,
|
||||||
|
IconButton,
|
||||||
Icons,
|
Icons,
|
||||||
Overlay,
|
Overlay,
|
||||||
OverlayBackdrop,
|
OverlayBackdrop,
|
||||||
@@ -36,6 +37,7 @@ import {
|
|||||||
useCallStart,
|
useCallStart,
|
||||||
} from '../hooks/useCallEmbed';
|
} from '../hooks/useCallEmbed';
|
||||||
import { callChatAtom, callEmbedAtom } from '../state/callEmbed';
|
import { callChatAtom, callEmbedAtom } from '../state/callEmbed';
|
||||||
|
import { toastQueueAtom } from '../state/toast';
|
||||||
import { CallEmbed, useCallControlState } from '../plugins/call';
|
import { CallEmbed, useCallControlState } from '../plugins/call';
|
||||||
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
|
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
|
||||||
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
|
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
|
||||||
@@ -43,6 +45,7 @@ import { useMatrixClient } from '../hooks/useMatrixClient';
|
|||||||
import { previewRingtone, startRingtone } from '../utils/ringtones';
|
import { previewRingtone, startRingtone } from '../utils/ringtones';
|
||||||
import { useCallMembersChange, useCallSession } from '../hooks/useCall';
|
import { useCallMembersChange, useCallSession } from '../hooks/useCall';
|
||||||
import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds';
|
import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds';
|
||||||
|
import { useCallQuality } from '../hooks/useCallQuality';
|
||||||
import { useRemoteAllMuted } from '../hooks/useCallSpeakers';
|
import { useRemoteAllMuted } from '../hooks/useCallSpeakers';
|
||||||
import { useRoomAvatar, useRoomName } from '../hooks/useRoomMeta';
|
import { useRoomAvatar, useRoomName } from '../hooks/useRoomMeta';
|
||||||
import { mDirectAtom } from '../state/mDirectList';
|
import { mDirectAtom } from '../state/mDirectList';
|
||||||
@@ -51,6 +54,7 @@ import { mxcUrlToHttp, getMxIdLocalPart } from '../utils/matrix';
|
|||||||
import { RoomAvatar, RoomIcon } from './room-avatar';
|
import { RoomAvatar, RoomIcon } from './room-avatar';
|
||||||
import { useRoomNavigate } from '../hooks/useRoomNavigate';
|
import { useRoomNavigate } from '../hooks/useRoomNavigate';
|
||||||
import { getChatBg } from '../features/lotus/chatBackground';
|
import { getChatBg } from '../features/lotus/chatBackground';
|
||||||
|
import { ExitFullscreenIcon, FullscreenIcon } from '../features/call/Controls';
|
||||||
import { useTheme, ThemeKind } from '../hooks/useTheme';
|
import { useTheme, ThemeKind } from '../hooks/useTheme';
|
||||||
import { useSetting } from '../state/hooks/settings';
|
import { useSetting } from '../state/hooks/settings';
|
||||||
import { settingsAtom } from '../state/settings';
|
import { settingsAtom } from '../state/settings';
|
||||||
@@ -62,6 +66,7 @@ import { getRoomPermissionsAPI } from '../hooks/useRoomPermissions';
|
|||||||
import { useLivekitSupport } from '../hooks/useLivekitSupport';
|
import { useLivekitSupport } from '../hooks/useLivekitSupport';
|
||||||
import { CallAvatarAnimation } from '../styles/Animations.css';
|
import { CallAvatarAnimation } from '../styles/Animations.css';
|
||||||
import { webRTCSupported } from '../utils/rtc';
|
import { webRTCSupported } from '../utils/rtc';
|
||||||
|
import { zIndices } from '../styles/zIndex';
|
||||||
|
|
||||||
const PIP_MIN_W = 200;
|
const PIP_MIN_W = 200;
|
||||||
const PIP_MIN_H = 112;
|
const PIP_MIN_H = 112;
|
||||||
@@ -289,11 +294,16 @@ function IncomingCallBanner({ dm, info, onIgnore, onAnswer, onReject }: Incoming
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Single soft ping (non-looping) on arrival, respecting the chosen ringtone
|
// Single soft ping (non-looping) on arrival, respecting the chosen ringtone
|
||||||
// + volume. We intentionally do NOT loop here — the user is mid-call.
|
// + volume. We intentionally do NOT loop here — the user is mid-call — and we
|
||||||
|
// ping exactly once per incoming call, not again if the user happens to tweak
|
||||||
|
// ringtone settings while the banner is showing.
|
||||||
|
const pingedRef = useRef<string | undefined>(undefined);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (info.notificationType !== 'ring') return;
|
if (info.notificationType !== 'ring') return;
|
||||||
|
if (pingedRef.current === info.refEventId) return;
|
||||||
|
pingedRef.current = info.refEventId;
|
||||||
previewRingtone(ringtoneId, Math.max(0, Math.min(1, ringtoneVolume / 100)));
|
previewRingtone(ringtoneId, Math.max(0, Math.min(1, ringtoneVolume / 100)));
|
||||||
}, [info.notificationType, ringtoneId, ringtoneVolume]);
|
}, [info.notificationType, info.refEventId, ringtoneId, ringtoneVolume]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const remaining = info.senderTs + info.lifetime - Date.now();
|
const remaining = info.senderTs + info.lifetime - Date.now();
|
||||||
@@ -316,7 +326,7 @@ function IncomingCallBanner({ dm, info, onIgnore, onAnswer, onReject }: Incoming
|
|||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
top: config.space.S400,
|
top: config.space.S400,
|
||||||
right: config.space.S400,
|
right: config.space.S400,
|
||||||
zIndex: 9990,
|
zIndex: zIndices.inCallBanner,
|
||||||
width: toRem(300),
|
width: toRem(300),
|
||||||
maxWidth: `calc(100vw - 2 * ${config.space.S400})`,
|
maxWidth: `calc(100vw - 2 * ${config.space.S400})`,
|
||||||
padding: config.space.S300,
|
padding: config.space.S300,
|
||||||
@@ -397,6 +407,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
|||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const directs = useAtomValue(mDirectAtom);
|
const directs = useAtomValue(mDirectAtom);
|
||||||
const { navigateRoom } = useRoomNavigate();
|
const { navigateRoom } = useRoomNavigate();
|
||||||
|
const setToast = useSetAtom(toastQueueAtom);
|
||||||
|
|
||||||
const [callInfo, setCallInfo] = useState<IncomingCallInfo>();
|
const [callInfo, setCallInfo] = useState<IncomingCallInfo>();
|
||||||
const dm = callInfo ? directs.has(callInfo.room.roomId) : false;
|
const dm = callInfo ? directs.has(callInfo.room.roomId) : false;
|
||||||
@@ -416,6 +427,31 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
|||||||
await event.getDecryptionPromise();
|
await event.getDecryptionPromise();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Caller-side: a participant declined a call we're hosting in this room.
|
||||||
|
// Without this the caller's UI keeps "ringing" until the notification
|
||||||
|
// lifetime expires, with no indication the callee said no.
|
||||||
|
if (event.getType() === EventType.RTCDecline) {
|
||||||
|
const decliner = event.getSender();
|
||||||
|
if (
|
||||||
|
data.liveEvent &&
|
||||||
|
room &&
|
||||||
|
decliner &&
|
||||||
|
decliner !== mx.getSafeUserId() &&
|
||||||
|
callEmbed?.roomId === room.roomId
|
||||||
|
) {
|
||||||
|
const declinerName =
|
||||||
|
getMemberDisplayName(room, decliner) ?? getMxIdLocalPart(decliner) ?? decliner;
|
||||||
|
setToast({
|
||||||
|
id: `rtc-decline-${event.getId() ?? decliner}`,
|
||||||
|
displayName: declinerName,
|
||||||
|
body: 'Declined your call',
|
||||||
|
roomName: room.name,
|
||||||
|
roomId: room.roomId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!room ||
|
!room ||
|
||||||
event.getType() !== EventType.RTCNotification ||
|
event.getType() !== EventType.RTCNotification ||
|
||||||
@@ -478,7 +514,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
|||||||
|
|
||||||
setCallInfo(info);
|
setCallInfo(info);
|
||||||
},
|
},
|
||||||
[mx, directs],
|
[mx, directs, callEmbed, setToast],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -549,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(() => {
|
||||||
@@ -715,7 +752,25 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|||||||
if (pipMode) {
|
if (pipMode) {
|
||||||
if (!wasInPip) {
|
if (!wasInPip) {
|
||||||
const saved = localStorage.getItem('pip-position');
|
const saved = localStorage.getItem('pip-position');
|
||||||
const savedPos = saved ? (JSON.parse(saved) as { left: number; top: number }) : null;
|
let savedPos: { left: number; top: number } | null = null;
|
||||||
|
if (saved) {
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(saved) as { left?: unknown; top?: unknown };
|
||||||
|
// Validate shape + finiteness: a corrupt value would otherwise feed
|
||||||
|
// NaN into Math.min and produce an invalid `NaNpx` CSS value.
|
||||||
|
if (
|
||||||
|
raw &&
|
||||||
|
typeof raw.left === 'number' &&
|
||||||
|
Number.isFinite(raw.left) &&
|
||||||
|
typeof raw.top === 'number' &&
|
||||||
|
Number.isFinite(raw.top)
|
||||||
|
) {
|
||||||
|
savedPos = { left: raw.left, top: raw.top };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
savedPos = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
el.style.right = 'auto';
|
el.style.right = 'auto';
|
||||||
el.style.bottom = 'auto';
|
el.style.bottom = 'auto';
|
||||||
if (savedPos) {
|
if (savedPos) {
|
||||||
@@ -1072,10 +1127,13 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|||||||
>
|
>
|
||||||
<div style={{ display: 'flex', gap: config.space.S100, alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: config.space.S100, alignItems: 'center' }}>
|
||||||
{document.fullscreenEnabled && (
|
{document.fullscreenEnabled && (
|
||||||
<button
|
<IconButton
|
||||||
type="button"
|
type="button"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="Surface"
|
||||||
|
fill="None"
|
||||||
aria-label={pipIsFullscreen ? 'Exit fullscreen' : 'Fullscreen camera'}
|
aria-label={pipIsFullscreen ? 'Exit fullscreen' : 'Fullscreen camera'}
|
||||||
title={pipIsFullscreen ? 'Exit fullscreen' : 'Fullscreen camera'}
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handlePipFullscreen();
|
handlePipFullscreen();
|
||||||
@@ -1084,19 +1142,11 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|||||||
// Dark scrim is intentional for legibility over arbitrary video.
|
// Dark scrim is intentional for legibility over arbitrary video.
|
||||||
background: 'rgba(0,0,0,0.65)',
|
background: 'rgba(0,0,0,0.65)',
|
||||||
backdropFilter: 'blur(4px)',
|
backdropFilter: 'blur(4px)',
|
||||||
border: 'none',
|
|
||||||
borderRadius: config.radii.R300,
|
|
||||||
padding: `${config.space.S100} ${config.space.S200}`,
|
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
fontSize: '13px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
lineHeight: 1,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{pipIsFullscreen ? '⊡' : '⛶'}
|
{pipIsFullscreen ? <ExitFullscreenIcon /> : <FullscreenIcon />}
|
||||||
</button>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -64,10 +64,11 @@ function VerificationUnexpected({ message, onClose }: VerificationUnexpectedProp
|
|||||||
}
|
}
|
||||||
|
|
||||||
function VerificationWaitAccept() {
|
function VerificationWaitAccept() {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="400">
|
<Box direction="Column" gap="400">
|
||||||
<Text>Please accept the request from other device.</Text>
|
<Text>{t('Organisms.DeviceVerification.please_accept')}</Text>
|
||||||
<WaitingMessage message="Waiting for request to be accepted..." />
|
<WaitingMessage message={t('Organisms.DeviceVerification.waiting_accept')} />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -82,7 +83,7 @@ function VerificationAccept({ onAccept }: VerificationAcceptProps) {
|
|||||||
const accepting = acceptState.status === AsyncStatus.Loading;
|
const accepting = acceptState.status === AsyncStatus.Loading;
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="400">
|
<Box direction="Column" gap="400">
|
||||||
<Text>Click accept to start the verification process.</Text>
|
<Text>{t('Organisms.DeviceVerification.click_accept')}</Text>
|
||||||
<Button
|
<Button
|
||||||
variant="Primary"
|
variant="Primary"
|
||||||
fill="Solid"
|
fill="Solid"
|
||||||
@@ -97,10 +98,11 @@ function VerificationAccept({ onAccept }: VerificationAcceptProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function VerificationWaitStart() {
|
function VerificationWaitStart() {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="400">
|
<Box direction="Column" gap="400">
|
||||||
<Text>Verification request has been accepted.</Text>
|
<Text>{t('Organisms.DeviceVerification.request_accepted')}</Text>
|
||||||
<WaitingMessage message="Waiting for the response from other device..." />
|
<WaitingMessage message={t('Organisms.DeviceVerification.waiting_response')} />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -109,13 +111,14 @@ type VerificationStartProps = {
|
|||||||
onStart: () => Promise<void>;
|
onStart: () => Promise<void>;
|
||||||
};
|
};
|
||||||
function AutoVerificationStart({ onStart }: VerificationStartProps) {
|
function AutoVerificationStart({ onStart }: VerificationStartProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onStart();
|
onStart();
|
||||||
}, [onStart]);
|
}, [onStart]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="400">
|
<Box direction="Column" gap="400">
|
||||||
<WaitingMessage message="Starting verification using emoji comparison..." />
|
<WaitingMessage message={t('Organisms.DeviceVerification.starting_emoji')} />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -129,7 +132,7 @@ function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="400">
|
<Box direction="Column" gap="400">
|
||||||
<Text>Confirm the emoji below are displayed on both devices, in the same order:</Text>
|
<Text>{t('Organisms.DeviceVerification.confirm_emoji')}</Text>
|
||||||
<Box
|
<Box
|
||||||
className={ContainerColor({ variant: 'SurfaceVariant' })}
|
className={ContainerColor({ variant: 'SurfaceVariant' })}
|
||||||
style={{
|
style={{
|
||||||
@@ -169,7 +172,7 @@ function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) {
|
|||||||
onClick={() => sasData.mismatch()}
|
onClick={() => sasData.mismatch()}
|
||||||
disabled={confirming}
|
disabled={confirming}
|
||||||
>
|
>
|
||||||
<Text size="B400">Do not Match</Text>
|
<Text size="B400">{t('Organisms.DeviceVerification.do_not_match')}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -181,6 +184,7 @@ type SasVerificationProps = {
|
|||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
};
|
};
|
||||||
function SasVerification({ verifier, onCancel }: SasVerificationProps) {
|
function SasVerification({ verifier, onCancel }: SasVerificationProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [sasData, setSasData] = useState<ShowSasCallbacks>();
|
const [sasData, setSasData] = useState<ShowSasCallbacks>();
|
||||||
|
|
||||||
useVerifierShowSas(verifier, setSasData);
|
useVerifierShowSas(verifier, setSasData);
|
||||||
@@ -196,7 +200,7 @@ function SasVerification({ verifier, onCancel }: SasVerificationProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="400">
|
<Box direction="Column" gap="400">
|
||||||
<WaitingMessage message="Starting verification using emoji comparison..." />
|
<WaitingMessage message={t('Organisms.DeviceVerification.starting_emoji')} />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -209,7 +213,7 @@ function VerificationDone({ onExit }: VerificationDoneProps) {
|
|||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="400">
|
<Box direction="Column" gap="400">
|
||||||
<div>
|
<div>
|
||||||
<Text>Your device is verified.</Text>
|
<Text>{t('Organisms.DeviceVerification.device_verified')}</Text>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="Primary" fill="Solid" onClick={onExit}>
|
<Button variant="Primary" fill="Solid" onClick={onExit}>
|
||||||
<Text size="B400">{t('Organisms.DeviceVerification.okay')}</Text>
|
<Text size="B400">{t('Organisms.DeviceVerification.okay')}</Text>
|
||||||
@@ -225,7 +229,7 @@ function VerificationCanceled({ onClose }: VerificationCanceledProps) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="400">
|
<Box direction="Column" gap="400">
|
||||||
<Text>Verification has been canceled.</Text>
|
<Text>{t('Organisms.DeviceVerification.verification_canceled')}</Text>
|
||||||
<Button variant="Secondary" fill="Soft" onClick={onClose}>
|
<Button variant="Secondary" fill="Soft" onClick={onClose}>
|
||||||
<Text size="B400">{t('Organisms.DeviceVerification.close')}</Text>
|
<Text size="B400">{t('Organisms.DeviceVerification.close')}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Icon, Icons, Text, Tooltip, TooltipProvider } from 'folds';
|
import { color, Icon, Icons, Text, Tooltip, TooltipProvider } from 'folds';
|
||||||
import { useUserVerifiedStatus } from '../hooks/useUserVerifiedStatus';
|
import { useUserVerifiedStatus } from '../hooks/useUserVerifiedStatus';
|
||||||
|
|
||||||
type MemberVerificationBadgeProps = {
|
type MemberVerificationBadgeProps = {
|
||||||
@@ -9,8 +9,7 @@ type MemberVerificationBadgeProps = {
|
|||||||
export function MemberVerificationBadge({ userId }: MemberVerificationBadgeProps) {
|
export function MemberVerificationBadge({ userId }: MemberVerificationBadgeProps) {
|
||||||
const vs = useUserVerifiedStatus(userId);
|
const vs = useUserVerifiedStatus(userId);
|
||||||
if (vs === 'unknown') return null;
|
if (vs === 'unknown') return null;
|
||||||
const color =
|
const iconColor = vs === 'verified' ? color.Success.Main : color.Warning.Main;
|
||||||
vs === 'verified' ? 'var(--tc-positive-normal, #5effc4)' : 'var(--tc-warning-normal, #ffcc55)';
|
|
||||||
const label = vs === 'verified' ? 'Identity verified' : 'Not verified';
|
const label = vs === 'verified' ? 'Identity verified' : 'Not verified';
|
||||||
return (
|
return (
|
||||||
<TooltipProvider
|
<TooltipProvider
|
||||||
@@ -27,7 +26,7 @@ export function MemberVerificationBadge({ userId }: MemberVerificationBadgeProps
|
|||||||
title={label}
|
title={label}
|
||||||
style={{ display: 'inline-flex', alignItems: 'center', flexShrink: 0 }}
|
style={{ display: 'inline-flex', alignItems: 'center', flexShrink: 0 }}
|
||||||
>
|
>
|
||||||
<Icon size="100" src={Icons.ShieldUser} style={{ color }} />
|
<Icon size="100" src={Icons.ShieldUser} style={{ color: iconColor }} />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { useTauriCallPower } from '../hooks/useTauriCallPower';
|
||||||
|
import { useTauriJumpList } from '../hooks/useTauriJumpList';
|
||||||
|
import { useTauriThumbbar } from '../hooks/useTauriThumbbar';
|
||||||
|
import { useTauriSmtc } from '../hooks/useTauriSmtc';
|
||||||
|
import { useTauriNetwork } from '../hooks/useTauriNetwork';
|
||||||
|
import { useTauriToastActions } from '../hooks/useTauriToastActions';
|
||||||
|
import { useTauriFocusAssist } from '../hooks/useTauriFocusAssist';
|
||||||
|
import { useTauriDnd } from '../hooks/useTauriDnd';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mounts the client-scoped native desktop feature hooks (call/room aware). Each
|
||||||
|
* `useTauri*` hook no-ops in the browser (guards on `isTauri`), so this is safe
|
||||||
|
* to render unconditionally. Rendered once by `ClientNonUIFeatures`. App-level
|
||||||
|
* desktop features (window chrome) live in `App.tsx` instead, so they work
|
||||||
|
* before login.
|
||||||
|
*/
|
||||||
|
export function TauriDesktopFeatures(): null {
|
||||||
|
useTauriCallPower(); // P5-46 no-sleep during calls
|
||||||
|
useTauriJumpList(); // P5-36 Windows jump list of recent rooms
|
||||||
|
useTauriThumbbar(); // P5-44 taskbar thumbnail toolbar (mute/deafen/end)
|
||||||
|
useTauriSmtc(); // P5-43 system media transport controls
|
||||||
|
useTauriNetwork(); // P5-49 network-change awareness → sync retry
|
||||||
|
useTauriToastActions(); // P5-41/35 rich toast click → open room, quick reply → send
|
||||||
|
useTauriFocusAssist(); // P5-56 Windows Focus Assist → DND suppression atom
|
||||||
|
useTauriDnd(); // P6-1 tray "Do Not Disturb" → notification suppression atom
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { Box, config, Icon, Icons, IconSrc, Menu, MenuItem, PopOut, RectCords, Text } from 'folds';
|
||||||
|
import React, { MouseEventHandler, ReactNode, useMemo, useState } from 'react';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { stopPropagation } from '../utils/keyboard';
|
||||||
|
import { ThreadNotificationMode } from '../utils/threadNotifications';
|
||||||
|
import { useSetThreadNotificationMode } from '../hooks/useThreadNotifications';
|
||||||
|
import { AsyncStatus } from '../hooks/useAsyncCallback';
|
||||||
|
|
||||||
|
export const getThreadNotificationModeIcon = (mode?: ThreadNotificationMode): IconSrc => {
|
||||||
|
if (mode === ThreadNotificationMode.Mute) return Icons.BellMute;
|
||||||
|
if (mode === ThreadNotificationMode.MentionsOnly) return Icons.BellPing;
|
||||||
|
if (mode === ThreadNotificationMode.All) return Icons.BellRing;
|
||||||
|
|
||||||
|
return Icons.Bell;
|
||||||
|
};
|
||||||
|
|
||||||
|
const useThreadNotificationModes = (): ThreadNotificationMode[] =>
|
||||||
|
useMemo(
|
||||||
|
() => [
|
||||||
|
ThreadNotificationMode.Default,
|
||||||
|
ThreadNotificationMode.All,
|
||||||
|
ThreadNotificationMode.MentionsOnly,
|
||||||
|
ThreadNotificationMode.Mute,
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const useThreadNotificationModeStr = (): Record<ThreadNotificationMode, string> =>
|
||||||
|
useMemo(
|
||||||
|
() => ({
|
||||||
|
[ThreadNotificationMode.Default]: 'Default (participating)',
|
||||||
|
[ThreadNotificationMode.All]: 'All replies',
|
||||||
|
[ThreadNotificationMode.MentionsOnly]: 'Mentions only',
|
||||||
|
[ThreadNotificationMode.Mute]: 'Mute',
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
type ThreadNotificationModeSwitcherProps = {
|
||||||
|
roomId: string;
|
||||||
|
threadId: string;
|
||||||
|
value?: ThreadNotificationMode;
|
||||||
|
children: (
|
||||||
|
handleOpen: MouseEventHandler<HTMLButtonElement>,
|
||||||
|
opened: boolean,
|
||||||
|
changing: boolean,
|
||||||
|
) => ReactNode;
|
||||||
|
};
|
||||||
|
export function ThreadNotificationModeSwitcher({
|
||||||
|
roomId,
|
||||||
|
threadId,
|
||||||
|
value = ThreadNotificationMode.Default,
|
||||||
|
children,
|
||||||
|
}: ThreadNotificationModeSwitcherProps) {
|
||||||
|
const modes = useThreadNotificationModes();
|
||||||
|
const modeToStr = useThreadNotificationModeStr();
|
||||||
|
|
||||||
|
const { modeState, setMode } = useSetThreadNotificationMode(roomId, threadId);
|
||||||
|
const changing = modeState.status === AsyncStatus.Loading;
|
||||||
|
|
||||||
|
const [menuCords, setMenuCords] = useState<RectCords>();
|
||||||
|
|
||||||
|
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
|
setMenuCords(evt.currentTarget.getBoundingClientRect());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setMenuCords(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelect = (mode: ThreadNotificationMode) => {
|
||||||
|
if (changing) return;
|
||||||
|
setMode(mode);
|
||||||
|
handleClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PopOut
|
||||||
|
anchor={menuCords}
|
||||||
|
offset={5}
|
||||||
|
position="Bottom"
|
||||||
|
align="End"
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: handleClose,
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
isKeyForward: (evt: KeyboardEvent) =>
|
||||||
|
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||||||
|
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu>
|
||||||
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||||
|
{modes.map((mode) => (
|
||||||
|
<MenuItem
|
||||||
|
key={mode}
|
||||||
|
size="300"
|
||||||
|
variant="Surface"
|
||||||
|
aria-pressed={mode === value}
|
||||||
|
radii="300"
|
||||||
|
disabled={changing}
|
||||||
|
onClick={() => handleSelect(mode)}
|
||||||
|
before={
|
||||||
|
<Icon
|
||||||
|
size="100"
|
||||||
|
src={getThreadNotificationModeIcon(mode)}
|
||||||
|
filled={mode === value}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text size="T300">
|
||||||
|
{mode === value ? <b>{modeToStr[mode]}</b> : modeToStr[mode]}
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children(handleOpenMenu, !!menuCords, changing)}
|
||||||
|
</PopOut>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -282,7 +282,12 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
|||||||
>
|
>
|
||||||
{previewUrl && (
|
{previewUrl && (
|
||||||
<>
|
<>
|
||||||
<audio ref={previewAudioRef} src={previewUrl} onEnded={() => setPreviewPlaying(false)} />
|
<audio
|
||||||
|
ref={previewAudioRef}
|
||||||
|
src={previewUrl}
|
||||||
|
onEnded={() => setPreviewPlaying(false)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const audio = previewAudioRef.current;
|
const audio = previewAudioRef.current;
|
||||||
|
|||||||
@@ -78,11 +78,14 @@ export function CreateRoomAliasInput({ disabled }: { disabled?: boolean }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box shrink="No" direction="Column" gap="100">
|
<Box shrink="No" direction="Column" gap="100">
|
||||||
<Text size="L400">Address (Optional)</Text>
|
<Text as="label" htmlFor="create-room-alias" size="L400">
|
||||||
|
Address (Optional)
|
||||||
|
</Text>
|
||||||
<Text size="T200" priority="300">
|
<Text size="T200" priority="300">
|
||||||
Pick an unique address to make it discoverable.
|
Pick an unique address to make it discoverable.
|
||||||
</Text>
|
</Text>
|
||||||
<Input
|
<Input
|
||||||
|
id="create-room-alias"
|
||||||
ref={aliasInputRef}
|
ref={aliasInputRef}
|
||||||
onChange={handleAliasChange}
|
onChange={handleAliasChange}
|
||||||
before={
|
before={
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ type CustomEditorProps = {
|
|||||||
maxHeight?: string;
|
maxHeight?: string;
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
/** Explicit accessible name for the textbox; falls back to the placeholder. */
|
||||||
|
ariaLabel?: string;
|
||||||
onKeyDown?: KeyboardEventHandler;
|
onKeyDown?: KeyboardEventHandler;
|
||||||
onKeyUp?: KeyboardEventHandler;
|
onKeyUp?: KeyboardEventHandler;
|
||||||
onChange?: EditorChangeHandler;
|
onChange?: EditorChangeHandler;
|
||||||
@@ -82,6 +84,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
|||||||
maxHeight = '50vh',
|
maxHeight = '50vh',
|
||||||
editor,
|
editor,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
ariaLabel,
|
||||||
onKeyDown,
|
onKeyDown,
|
||||||
onKeyUp,
|
onKeyUp,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -139,7 +142,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
|||||||
data-editable-name={editableName}
|
data-editable-name={editableName}
|
||||||
className={css.EditorTextarea}
|
className={css.EditorTextarea}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
aria-label={placeholder ?? 'Message input'}
|
aria-label={ariaLabel ?? placeholder ?? 'Message input'}
|
||||||
aria-multiline="true"
|
aria-multiline="true"
|
||||||
renderPlaceholder={renderPlaceholder}
|
renderPlaceholder={renderPlaceholder}
|
||||||
renderElement={renderElement}
|
renderElement={renderElement}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { KeyboardEvent as ReactKeyboardEvent, useEffect, useMemo } from 'react';
|
import React, { KeyboardEvent as ReactKeyboardEvent, useEffect, useMemo, useState } from 'react';
|
||||||
import { Editor } from 'slate';
|
import { Editor } from 'slate';
|
||||||
import { Box, MenuItem, Text, toRem } from 'folds';
|
import { Box, MenuItem, Text, toRem } from 'folds';
|
||||||
import { Room } from 'matrix-js-sdk';
|
import { Room } from 'matrix-js-sdk';
|
||||||
@@ -11,7 +11,7 @@ import { onTabPress } from '../../../utils/keyboard';
|
|||||||
import { createEmoticonElement, moveCursor, replaceWithElement } from '../utils';
|
import { createEmoticonElement, moveCursor, replaceWithElement } from '../utils';
|
||||||
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
|
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
|
||||||
import { useRelevantImagePacks } from '../../../hooks/useImagePacks';
|
import { useRelevantImagePacks } from '../../../hooks/useImagePacks';
|
||||||
import { IEmoji, emojis } from '../../../plugins/emoji';
|
import { IEmoji, emojis, loadEmojiData } from '../../../plugins/emoji';
|
||||||
import { useKeyDown } from '../../../hooks/useKeyDown';
|
import { useKeyDown } from '../../../hooks/useKeyDown';
|
||||||
import { mxcUrlToHttp } from '../../../utils/matrix';
|
import { mxcUrlToHttp } from '../../../utils/matrix';
|
||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
@@ -47,13 +47,32 @@ export function EmoticonAutocomplete({
|
|||||||
const imagePacks = useRelevantImagePacks(ImageUsage.Emoticon, imagePackRooms);
|
const imagePacks = useRelevantImagePacks(ImageUsage.Emoticon, imagePackRooms);
|
||||||
const recentEmoji = useRecentEmoji(mx, 20);
|
const recentEmoji = useRecentEmoji(mx, 20);
|
||||||
|
|
||||||
|
// Lazily load emojibase data (see plugins/emoji `loadEmojiData`). Until it
|
||||||
|
// resolves, `emojis` is empty and autocomplete matches only custom-emoji
|
||||||
|
// packs; the unicode emoji list fills in once loaded.
|
||||||
|
const [loadedEmojis, setLoadedEmojis] = useState<IEmoji[]>(() => emojis);
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
loadEmojiData()
|
||||||
|
// Fresh array reference: loadEmojiData populates the module-level array
|
||||||
|
// IN PLACE, so state set to the same ref would bail out of re-rendering
|
||||||
|
// and the search list would never gain the unicode emojis.
|
||||||
|
.then((loaded) => {
|
||||||
|
if (alive) setLoadedEmojis(loaded.emojis.slice());
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const searchList = useMemo(() => {
|
const searchList = useMemo(() => {
|
||||||
const list: Array<EmoticonSearchItem> = [];
|
const list: Array<EmoticonSearchItem> = [];
|
||||||
return list.concat(
|
return list.concat(
|
||||||
imagePacks.flatMap((pack) => pack.getImages(ImageUsage.Emoticon)),
|
imagePacks.flatMap((pack) => pack.getImages(ImageUsage.Emoticon)),
|
||||||
emojis,
|
loadedEmojis,
|
||||||
);
|
);
|
||||||
}, [imagePacks]);
|
}, [imagePacks, loadedEmojis]);
|
||||||
|
|
||||||
const [result, search, resetSearch] = useAsyncSearch(
|
const [result, search, resetSearch] = useAsyncSearch(
|
||||||
searchList,
|
searchList,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import React, {
|
|||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { Box, config, Icons, Scroll } from 'folds';
|
import { Box, config, Icons, Scroll } from 'folds';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
@@ -15,7 +16,7 @@ import { isKeyHotkey } from 'is-hotkey';
|
|||||||
import { Room } from 'matrix-js-sdk';
|
import { Room } from 'matrix-js-sdk';
|
||||||
import { atom, PrimitiveAtom, useAtom, useSetAtom } from 'jotai';
|
import { atom, PrimitiveAtom, useAtom, useSetAtom } from 'jotai';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { IEmoji, emojiGroups, emojis } from '../../plugins/emoji';
|
import { EmojiData, IEmoji, emojiGroups, emojis, loadEmojiData } from '../../plugins/emoji';
|
||||||
import { useEmojiGroupLabels } from './useEmojiGroupLabels';
|
import { useEmojiGroupLabels } from './useEmojiGroupLabels';
|
||||||
import { useEmojiGroupIcons } from './useEmojiGroupIcons';
|
import { useEmojiGroupIcons } from './useEmojiGroupIcons';
|
||||||
import { preventScrollWithArrowKey, stopPropagation } from '../../utils/keyboard';
|
import { preventScrollWithArrowKey, stopPropagation } from '../../utils/keyboard';
|
||||||
@@ -56,6 +57,33 @@ import { VirtualTile } from '../virtualizer';
|
|||||||
const RECENT_GROUP_ID = 'recent_group';
|
const RECENT_GROUP_ID = 'recent_group';
|
||||||
const SEARCH_GROUP_ID = 'search_group';
|
const SEARCH_GROUP_ID = 'search_group';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazily pull in the emojibase data (see plugins/emoji `loadEmojiData`). The
|
||||||
|
* `emojis`/`emojiGroups` arrays are populated in place once the promise
|
||||||
|
* resolves; we wrap them in a fresh object on load so React re-renders and the
|
||||||
|
* board fills in. Before that, both are empty and the board shows only custom
|
||||||
|
* image packs / recents (which is fleeting — the load starts on mount).
|
||||||
|
*/
|
||||||
|
const useEmojiData = (): EmojiData => {
|
||||||
|
const [data, setData] = useState<EmojiData>(() => ({ emojis, emojiGroups }));
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
loadEmojiData()
|
||||||
|
// Fresh array references (not just a fresh wrapper): downstream memos
|
||||||
|
// depend on the arrays themselves, which are populated IN PLACE — same
|
||||||
|
// refs would skip recompute and leave emoji search empty until remount.
|
||||||
|
.then((loaded) => {
|
||||||
|
if (alive)
|
||||||
|
setData({ emojis: loaded.emojis.slice(), emojiGroups: loaded.emojiGroups.slice() });
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
type EmojiGroupItem = {
|
type EmojiGroupItem = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -75,6 +103,7 @@ const useGroups = (
|
|||||||
|
|
||||||
const recentEmojis = useRecentEmoji(mx, 21);
|
const recentEmojis = useRecentEmoji(mx, 21);
|
||||||
const labels = useEmojiGroupLabels();
|
const labels = useEmojiGroupLabels();
|
||||||
|
const { emojiGroups: loadedEmojiGroups } = useEmojiData();
|
||||||
|
|
||||||
const emojiGroupItems = useMemo(() => {
|
const emojiGroupItems = useMemo(() => {
|
||||||
const g: EmojiGroupItem[] = [];
|
const g: EmojiGroupItem[] = [];
|
||||||
@@ -99,7 +128,7 @@ const useGroups = (
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
emojiGroups.forEach((group) => {
|
loadedEmojiGroups.forEach((group) => {
|
||||||
g.push({
|
g.push({
|
||||||
id: group.id,
|
id: group.id,
|
||||||
name: labels[group.id],
|
name: labels[group.id],
|
||||||
@@ -108,7 +137,7 @@ const useGroups = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
return g;
|
return g;
|
||||||
}, [mx, recentEmojis, labels, imagePacks, tab]);
|
}, [mx, recentEmojis, labels, imagePacks, tab, loadedEmojiGroups]);
|
||||||
|
|
||||||
const stickerGroupItems = useMemo(() => {
|
const stickerGroupItems = useMemo(() => {
|
||||||
const g: StickerGroupItem[] = [];
|
const g: StickerGroupItem[] = [];
|
||||||
@@ -177,6 +206,7 @@ function EmojiSidebar({ activeGroupAtom, packs, onScrollToGroup }: EmojiSidebarP
|
|||||||
const usage = ImageUsage.Emoticon;
|
const usage = ImageUsage.Emoticon;
|
||||||
const labels = useEmojiGroupLabels();
|
const labels = useEmojiGroupLabels();
|
||||||
const icons = useEmojiGroupIcons();
|
const icons = useEmojiGroupIcons();
|
||||||
|
const { emojiGroups: loadedEmojiGroups } = useEmojiData();
|
||||||
|
|
||||||
const packLabels = useMemo(() => {
|
const packLabels = useMemo(() => {
|
||||||
const map = new Map<string, string | undefined>();
|
const map = new Map<string, string | undefined>();
|
||||||
@@ -234,7 +264,7 @@ function EmojiSidebar({ activeGroupAtom, packs, onScrollToGroup }: EmojiSidebarP
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SidebarDivider />
|
<SidebarDivider />
|
||||||
{emojiGroups.map((group) => (
|
{loadedEmojiGroups.map((group) => (
|
||||||
<GroupIcon
|
<GroupIcon
|
||||||
key={group.id}
|
key={group.id}
|
||||||
active={activeGroupId === group.id}
|
active={activeGroupId === group.id}
|
||||||
@@ -409,13 +439,14 @@ export function EmojiBoard({
|
|||||||
const [emojiGroupItems, stickerGroupItems] = useGroups(tab, imagePacks);
|
const [emojiGroupItems, stickerGroupItems] = useGroups(tab, imagePacks);
|
||||||
const groups = emojiTab ? emojiGroupItems : stickerGroupItems;
|
const groups = emojiTab ? emojiGroupItems : stickerGroupItems;
|
||||||
const renderItem = useItemRenderer(tab);
|
const renderItem = useItemRenderer(tab);
|
||||||
|
const { emojis: loadedEmojis } = useEmojiData();
|
||||||
|
|
||||||
const searchList = useMemo(() => {
|
const searchList = useMemo(() => {
|
||||||
let list: Array<PackImageReader | IEmoji> = [];
|
let list: Array<PackImageReader | IEmoji> = [];
|
||||||
list = list.concat(imagePacks.flatMap((pack) => pack.getImages(usage)));
|
list = list.concat(imagePacks.flatMap((pack) => pack.getImages(usage)));
|
||||||
if (emojiTab) list = list.concat(emojis);
|
if (emojiTab) list = list.concat(loadedEmojis);
|
||||||
return list;
|
return list;
|
||||||
}, [emojiTab, usage, imagePacks]);
|
}, [emojiTab, usage, imagePacks, loadedEmojis]);
|
||||||
|
|
||||||
const [result, search, resetSearch] = useAsyncSearch(
|
const [result, search, resetSearch] = useAsyncSearch(
|
||||||
searchList,
|
searchList,
|
||||||
|
|||||||
@@ -200,12 +200,24 @@ export function ImagePackProfileEdit({ meta, onCancel, onSave }: ImagePackProfil
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Box direction="Inherit" gap="100">
|
<Box direction="Inherit" gap="100">
|
||||||
<Text size="L400">Name</Text>
|
<Text as="label" htmlFor="image-pack-name" size="L400">
|
||||||
<Input name="nameInput" defaultValue={meta.name} variant="Secondary" radii="300" required />
|
Name
|
||||||
|
</Text>
|
||||||
|
<Input
|
||||||
|
id="image-pack-name"
|
||||||
|
name="nameInput"
|
||||||
|
defaultValue={meta.name}
|
||||||
|
variant="Secondary"
|
||||||
|
radii="300"
|
||||||
|
required
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Box direction="Inherit" gap="100">
|
<Box direction="Inherit" gap="100">
|
||||||
<Text size="L400">Attribution</Text>
|
<Text as="label" htmlFor="image-pack-attribution" size="L400">
|
||||||
|
Attribution
|
||||||
|
</Text>
|
||||||
<TextArea
|
<TextArea
|
||||||
|
id="image-pack-attribution"
|
||||||
name="attributionTextArea"
|
name="attributionTextArea"
|
||||||
defaultValue={meta.attribution}
|
defaultValue={meta.attribution}
|
||||||
variant="Secondary"
|
variant="Secondary"
|
||||||
|
|||||||
@@ -261,9 +261,12 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
|
|||||||
gap="400"
|
gap="400"
|
||||||
>
|
>
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Text size="L400">User ID</Text>
|
<Text as="label" htmlFor="invite-user-id" size="L400">
|
||||||
|
User ID
|
||||||
|
</Text>
|
||||||
<div>
|
<div>
|
||||||
<Input
|
<Input
|
||||||
|
id="invite-user-id"
|
||||||
size="500"
|
size="500"
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
onChange={handleSearchChange}
|
onChange={handleSearchChange}
|
||||||
@@ -334,8 +337,11 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
|
|||||||
</div>
|
</div>
|
||||||
</Box>
|
</Box>
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Text size="L400">Reason (Optional)</Text>
|
<Text as="label" htmlFor="invite-reason" size="L400">
|
||||||
|
Reason (Optional)
|
||||||
|
</Text>
|
||||||
<TextArea
|
<TextArea
|
||||||
|
id="invite-reason"
|
||||||
size="500"
|
size="500"
|
||||||
name="reasonInput"
|
name="reasonInput"
|
||||||
variant="Background"
|
variant="Background"
|
||||||
|
|||||||
@@ -108,8 +108,11 @@ export function JoinAddressPrompt({ onOpen, onCancel }: JoinAddressProps) {
|
|||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Text size="L400">Address</Text>
|
<Text as="label" htmlFor="join-address" size="L400">
|
||||||
|
Address
|
||||||
|
</Text>
|
||||||
<Input
|
<Input
|
||||||
|
id="join-address"
|
||||||
size="500"
|
size="500"
|
||||||
autoFocus
|
autoFocus
|
||||||
name="addressInput"
|
name="addressInput"
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import katex from 'katex';
|
||||||
|
import 'katex/dist/katex.min.css';
|
||||||
|
|
||||||
|
type KaTeXProps = {
|
||||||
|
/** Raw LaTeX source (without `$`/`$$` delimiters). */
|
||||||
|
latex: string;
|
||||||
|
/** Render as block (display) math when true, inline otherwise. */
|
||||||
|
displayMode?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazily-loaded KaTeX renderer.
|
||||||
|
*
|
||||||
|
* This module statically imports `katex` and its stylesheet, so both only enter
|
||||||
|
* the bundle via the dynamic `import()` of this file (see the `lazy()` wrapper
|
||||||
|
* in `react-custom-html-parser.tsx`). They are therefore NOT part of the eager
|
||||||
|
* import graph.
|
||||||
|
*
|
||||||
|
* We render with `throwOnError: false`, so KaTeX itself renders a parse error
|
||||||
|
* inline (in its error colour) rather than throwing. The HTML returned by
|
||||||
|
* `renderToString` is produced by our own trusted call from a fixed options
|
||||||
|
* object — it is safe to inject via `dangerouslySetInnerHTML`.
|
||||||
|
*/
|
||||||
|
export default function KaTeX({ latex, displayMode = false }: KaTeXProps) {
|
||||||
|
const html = katex.renderToString(latex, {
|
||||||
|
displayMode,
|
||||||
|
throwOnError: false,
|
||||||
|
output: 'htmlAndMathml',
|
||||||
|
});
|
||||||
|
|
||||||
|
const Wrapper = displayMode ? 'div' : 'span';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Wrapper
|
||||||
|
// KaTeX output is generated by our own render call (trusted-safe).
|
||||||
|
// eslint-disable-next-line react/no-danger
|
||||||
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -529,7 +529,7 @@ export function MLocation({ content }: MLocationProps) {
|
|||||||
style={{
|
style={{
|
||||||
width: '280px',
|
width: '280px',
|
||||||
height: '160px',
|
height: '160px',
|
||||||
border: '1px solid var(--bg-surface-border)',
|
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
display: 'block',
|
display: 'block',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ type ReplyProps = {
|
|||||||
replyEventId: string;
|
replyEventId: string;
|
||||||
threadRootId?: string | undefined;
|
threadRootId?: string | undefined;
|
||||||
onClick?: MouseEventHandler | undefined;
|
onClick?: MouseEventHandler | undefined;
|
||||||
|
onThreadClick?: ((threadRootId: string) => void) | undefined;
|
||||||
getMemberPowerTag?: GetMemberPowerTag;
|
getMemberPowerTag?: GetMemberPowerTag;
|
||||||
accessibleTagColors?: Map<string, string>;
|
accessibleTagColors?: Map<string, string>;
|
||||||
legacyUsernameColor?: boolean;
|
legacyUsernameColor?: boolean;
|
||||||
@@ -74,6 +75,7 @@ export const Reply = as<'div', ReplyProps>(
|
|||||||
replyEventId,
|
replyEventId,
|
||||||
threadRootId,
|
threadRootId,
|
||||||
onClick,
|
onClick,
|
||||||
|
onThreadClick,
|
||||||
getMemberPowerTag,
|
getMemberPowerTag,
|
||||||
accessibleTagColors,
|
accessibleTagColors,
|
||||||
legacyUsernameColor,
|
legacyUsernameColor,
|
||||||
@@ -110,7 +112,7 @@ export const Reply = as<'div', ReplyProps>(
|
|||||||
<ThreadIndicator
|
<ThreadIndicator
|
||||||
as="button"
|
as="button"
|
||||||
data-event-id={threadRootId}
|
data-event-id={threadRootId}
|
||||||
onClick={onClick}
|
onClick={onThreadClick ? () => onThreadClick(threadRootId) : onClick}
|
||||||
aria-label="View thread"
|
aria-label="View thread"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { Box, color, config, Text, toRem } from 'folds';
|
import { Box, color, config, Icon, Icons, Text, toRem } from 'folds';
|
||||||
import { RelationsEvent } from 'matrix-js-sdk/lib/models/relations';
|
import { RelationsEvent } from 'matrix-js-sdk/lib/models/relations';
|
||||||
import { RoomEvent } from 'matrix-js-sdk';
|
import { RoomEvent } from 'matrix-js-sdk';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
@@ -339,11 +339,7 @@ export function PollContent({
|
|||||||
transition: 'all 0.15s',
|
transition: 'all 0.15s',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{selected && isMultiple ? (
|
{selected && isMultiple ? <Icon size="50" src={Icons.Check} /> : null}
|
||||||
<Text as="span" size="T200" style={{ lineHeight: 1 }}>
|
|
||||||
✓
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
</span>
|
</span>
|
||||||
<Text as="span" size="T300" style={{ flexGrow: 1 }}>
|
<Text as="span" size="T300" style={{ flexGrow: 1 }}>
|
||||||
{text}
|
{text}
|
||||||
|
|||||||
@@ -117,7 +117,6 @@ export const PageHeroSection = style([
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
||||||
export const PageContentCenter = style([
|
export const PageContentCenter = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -95,7 +95,6 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
|
|||||||
<FocusTrap
|
<FocusTrap
|
||||||
focusTrapOptions={{
|
focusTrapOptions={{
|
||||||
initialFocus: false,
|
initialFocus: false,
|
||||||
returnFocusOnDeactivate: false,
|
|
||||||
clickOutsideDeactivates: true,
|
clickOutsideDeactivates: true,
|
||||||
onDeactivate: () => setViewTopic(false),
|
onDeactivate: () => setViewTopic(false),
|
||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
|
|||||||
@@ -1,136 +0,0 @@
|
|||||||
import { keyframes } from '@vanilla-extract/css';
|
|
||||||
|
|
||||||
/** Generic fall: particles drop from top to bottom with a slight rotate. */
|
|
||||||
export const animSeasonFall = keyframes({
|
|
||||||
'0%': { transform: 'translateY(-20px) translateX(0) rotate(0deg)', opacity: '0' },
|
|
||||||
'5%': { opacity: '1' },
|
|
||||||
'90%': { opacity: '0.8' },
|
|
||||||
'100%': { transform: 'translateY(110vh) translateX(25px) rotate(360deg)', opacity: '0' },
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Leaf fall: exaggerated horizontal sway as the leaf tumbles down. */
|
|
||||||
export const animLeafFall = keyframes({
|
|
||||||
'0%': { transform: 'translateY(-20px) translateX(0) rotate(-20deg)', opacity: '0' },
|
|
||||||
'8%': { opacity: '0.85' },
|
|
||||||
'25%': { transform: 'translateY(25vh) translateX(35px) rotate(40deg)' },
|
|
||||||
'50%': { transform: 'translateY(50vh) translateX(-25px) rotate(130deg)' },
|
|
||||||
'75%': { transform: 'translateY(75vh) translateX(45px) rotate(260deg)' },
|
|
||||||
'92%': { opacity: '0.6' },
|
|
||||||
'100%': { transform: 'translateY(110vh) translateX(5px) rotate(380deg)', opacity: '0' },
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Float up: hearts / embers rise from the bottom. */
|
|
||||||
export const animFloatUp = keyframes({
|
|
||||||
'0%': { transform: 'translateY(0) scale(0.6) translateX(0)', opacity: '0' },
|
|
||||||
'8%': { opacity: '0.9' },
|
|
||||||
'50%': { transform: 'translateY(-50vh) scale(1) translateX(15px)' },
|
|
||||||
'85%': { opacity: '0.4' },
|
|
||||||
'100%': { transform: 'translateY(-105vh) scale(1.3) translateX(-10px)', opacity: '0' },
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Bob: lanterns gently rise and fall with a slight tilt. */
|
|
||||||
export const animBob = keyframes({
|
|
||||||
'0%': { transform: 'translateY(0px) rotate(-4deg)' },
|
|
||||||
'50%': { transform: 'translateY(-18px) rotate(4deg)' },
|
|
||||||
'100%': { transform: 'translateY(0px) rotate(-4deg)' },
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Lantern tassel sway (used on the tassel element only). */
|
|
||||||
export const animTasselSway = keyframes({
|
|
||||||
'0%': { transform: 'rotate(-8deg)' },
|
|
||||||
'50%': { transform: 'rotate(8deg)' },
|
|
||||||
'100%': { transform: 'rotate(-8deg)' },
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Glitch jitter: rapid position jumps that feel like a signal error. */
|
|
||||||
export const animGlitch = keyframes({
|
|
||||||
'0%': { transform: 'translate(0, 0)' },
|
|
||||||
'2%': { transform: 'translate(-4px, 2px)' },
|
|
||||||
'4%': { transform: 'translate(4px, -2px)' },
|
|
||||||
'6%': { transform: 'translate(0, 0)' },
|
|
||||||
'48%': { transform: 'translate(0, 0)' },
|
|
||||||
'50%': { transform: 'translate(3px, -3px)' },
|
|
||||||
'52%': { transform: 'translate(-3px, 3px)' },
|
|
||||||
'54%': { transform: 'translate(0, 0)' },
|
|
||||||
'78%': { transform: 'translate(0, 0)' },
|
|
||||||
'80%': { transform: 'translate(-5px, 1px)' },
|
|
||||||
'82%': { transform: 'translate(0, 0)' },
|
|
||||||
'100%': { transform: 'translate(0, 0)' },
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Glitch color: hue + saturation spikes that look like a corrupted signal. */
|
|
||||||
export const animGlitchColor = keyframes({
|
|
||||||
'0%': { filter: 'hue-rotate(0deg) saturate(1)' },
|
|
||||||
'8%': { filter: 'hue-rotate(180deg) saturate(3)' },
|
|
||||||
'9%': { filter: 'hue-rotate(0deg) saturate(1)' },
|
|
||||||
'55%': { filter: 'hue-rotate(0deg) saturate(1)' },
|
|
||||||
'57%': { filter: 'hue-rotate(90deg) saturate(2)' },
|
|
||||||
'58%': { filter: 'hue-rotate(0deg) saturate(1)' },
|
|
||||||
'80%': { filter: 'hue-rotate(0deg) saturate(1)' },
|
|
||||||
'82%': { filter: 'hue-rotate(270deg) saturate(2.5)' },
|
|
||||||
'83%': { filter: 'hue-rotate(0deg) saturate(1)' },
|
|
||||||
'100%': { filter: 'hue-rotate(0deg) saturate(1)' },
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Glitch scanline: a horizontal band sweeps across, flickering. */
|
|
||||||
export const animGlitchScan = keyframes({
|
|
||||||
'0%': { transform: 'translateY(-100%)' },
|
|
||||||
'100%': { transform: 'translateY(100vh)' },
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Burst: circle expands outward from a point and fades — firework petal. */
|
|
||||||
export const animBurst = keyframes({
|
|
||||||
'0%': { transform: 'scale(0) rotate(0deg)', opacity: '1' },
|
|
||||||
'50%': { opacity: '0.7' },
|
|
||||||
'100%': { transform: 'scale(1) rotate(45deg)', opacity: '0' },
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Firework trail: a small dot rockets upward before bursting. */
|
|
||||||
export const animRocket = keyframes({
|
|
||||||
'0%': { transform: 'translateY(0)', opacity: '1' },
|
|
||||||
'100%': { transform: 'translateY(-40vh)', opacity: '0' },
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Deep space warp: stars streak from center outward. */
|
|
||||||
export const animWarp = keyframes({
|
|
||||||
'0%': { transform: 'scale(0.05) translate(0, 0)', opacity: '0' },
|
|
||||||
'10%': { opacity: '1' },
|
|
||||||
'100%': { transform: 'scale(4) translate(0, 0)', opacity: '0' },
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Arcade scanline flicker. */
|
|
||||||
export const animScanline = keyframes({
|
|
||||||
'0%': { opacity: '0.12' },
|
|
||||||
'50%': { opacity: '0.04' },
|
|
||||||
'100%': { opacity: '0.12' },
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Arcade pixel blink: decorative corner glyphs blink. */
|
|
||||||
export const animPixelBlink = keyframes({
|
|
||||||
'0%, 49%': { opacity: '1' },
|
|
||||||
'50%, 100%': { opacity: '0' },
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Gold shimmer: a shine sweeps across a metallic surface. */
|
|
||||||
export const animGoldShimmer = keyframes({
|
|
||||||
'0%': { backgroundPosition: '-300% 0' },
|
|
||||||
'100%': { backgroundPosition: '300% 0' },
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Clover drift: gentle fall with a slow spin. */
|
|
||||||
export const animCloverDrift = keyframes({
|
|
||||||
'0%': { transform: 'translateY(-20px) rotate(0deg)', opacity: '0' },
|
|
||||||
'5%': { opacity: '0.7' },
|
|
||||||
'90%': { opacity: '0.5' },
|
|
||||||
'100%': { transform: 'translateY(110vh) rotate(720deg)', opacity: '0' },
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Earth Day leaf sway: gentle horizontal oscillation for ambient leaf particles. */
|
|
||||||
export const animEarthLeafDrift = keyframes({
|
|
||||||
'0%': { transform: 'translateY(-10px) translateX(0) rotate(0deg)', opacity: '0' },
|
|
||||||
'8%': { opacity: '0.6' },
|
|
||||||
'30%': { transform: 'translateY(30vh) translateX(20px) rotate(90deg)' },
|
|
||||||
'60%': { transform: 'translateY(60vh) translateX(-15px) rotate(200deg)' },
|
|
||||||
'90%': { opacity: '0.4' },
|
|
||||||
'100%': { transform: 'translateY(110vh) translateX(10px) rotate(340deg)', opacity: '0' },
|
|
||||||
});
|
|
||||||
@@ -1,719 +1,25 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
import {
|
import { zIndices } from '../../styles/zIndex';
|
||||||
animSeasonFall,
|
import { SeasonTheme } from './types';
|
||||||
animLeafFall,
|
import { getActiveSeason } from './seasonSchedule';
|
||||||
animFloatUp,
|
import { HalloweenOverlay } from './themes/Halloween';
|
||||||
animBob,
|
import { ChristmasOverlay } from './themes/Christmas';
|
||||||
animTasselSway,
|
import { NewYearOverlay } from './themes/NewYear';
|
||||||
animGoldShimmer,
|
import { AutumnOverlay } from './themes/Autumn';
|
||||||
animCloverDrift,
|
import { AprilFoolsOverlay } from './themes/AprilFools';
|
||||||
animEarthLeafDrift,
|
import { LunarNewYearOverlay } from './themes/LunarNewYear';
|
||||||
animWarp,
|
import { ValentinesOverlay } from './themes/Valentines';
|
||||||
animScanline,
|
import { StPatricksOverlay } from './themes/StPatricks';
|
||||||
animPixelBlink,
|
import { EarthDayOverlay } from './themes/EarthDay';
|
||||||
} from './Seasonal.css';
|
import { DeepSpaceOverlay } from './themes/DeepSpace';
|
||||||
|
import { ArcadeOverlay } from './themes/Arcade';
|
||||||
|
|
||||||
export type SeasonTheme =
|
// SeasonTheme + the date-window logic now live in leaf modules (single source
|
||||||
| 'halloween'
|
// of truth, shared with the settings UI). Re-exported here for existing
|
||||||
| 'christmas'
|
// importers that still reach for it from this file.
|
||||||
| 'newyear'
|
export type { SeasonTheme };
|
||||||
| 'autumn'
|
|
||||||
| 'aprilfools'
|
|
||||||
| 'lunar'
|
|
||||||
| 'valentines'
|
|
||||||
| 'stpatricks'
|
|
||||||
| 'earthday'
|
|
||||||
| 'deepspace'
|
|
||||||
| 'arcade';
|
|
||||||
|
|
||||||
function getActiveSeason(now: Date): SeasonTheme | null {
|
|
||||||
const m = now.getMonth() + 1; // 1-12
|
|
||||||
const d = now.getDate();
|
|
||||||
|
|
||||||
// New Year takes highest priority (Dec 31 – Jan 2)
|
|
||||||
if ((m === 12 && d === 31) || (m === 1 && d <= 2)) return 'newyear';
|
|
||||||
// Valentine's Day (Feb 10–15)
|
|
||||||
if (m === 2 && d >= 10 && d <= 15) return 'valentines';
|
|
||||||
// St. Patrick's Day (March 15–18)
|
|
||||||
if (m === 3 && d >= 15 && d <= 18) return 'stpatricks';
|
|
||||||
// April Fool's (April 1)
|
|
||||||
if (m === 4 && d === 1) return 'aprilfools';
|
|
||||||
// Earth Day (April 20–23)
|
|
||||||
if (m === 4 && d >= 20 && d <= 23) return 'earthday';
|
|
||||||
// Lunar New Year (Jan 22 – Feb 5, approximate fixed window)
|
|
||||||
if ((m === 1 && d >= 22) || (m === 2 && d <= 5)) return 'lunar';
|
|
||||||
// International Video Game Day (Sept 12)
|
|
||||||
if (m === 9 && d === 12) return 'arcade';
|
|
||||||
// World Space Week (Oct 4–10)
|
|
||||||
if (m === 10 && d >= 4 && d <= 10) return 'deepspace';
|
|
||||||
// Halloween (Oct 15 – Nov 1)
|
|
||||||
if ((m === 10 && d >= 15) || (m === 11 && d === 1)) return 'halloween';
|
|
||||||
// Christmas (Dec 10–30)
|
|
||||||
if (m === 12 && d >= 10) return 'christmas';
|
|
||||||
// Autumn (Sept 21 – Oct 31, excluding Halloween/Deep Space windows above)
|
|
||||||
if ((m === 9 && d >= 21) || (m === 10 && d <= 14)) return 'autumn';
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Individual theme overlays ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function HalloweenOverlay({ reduced }: { reduced: boolean }) {
|
|
||||||
const particles = Array.from({ length: 22 });
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Dark purple ambient tint */}
|
|
||||||
<div
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
inset: 0,
|
|
||||||
backgroundColor: 'rgba(25,0,45,0.22)',
|
|
||||||
backgroundImage:
|
|
||||||
'radial-gradient(ellipse at 50% 50%, rgba(100,0,180,0.08) 0%, transparent 70%)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* Spider web corners */}
|
|
||||||
<div
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: '160px',
|
|
||||||
height: '160px',
|
|
||||||
backgroundImage: `url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160' viewBox='0 0 160 160'><g stroke='rgba(180,120,255,0.35)' stroke-width='0.7' fill='none'><line x1='0' y1='0' x2='80' y2='80'/><line x1='40' y1='0' x2='80' y2='80'/><line x1='80' y1='0' x2='80' y2='80'/><line x1='0' y1='40' x2='80' y2='80'/><line x1='0' y1='80' x2='80' y2='80'/><ellipse cx='80' cy='80' rx='20' ry='20'/><ellipse cx='80' cy='80' rx='40' ry='40'/><ellipse cx='80' cy='80' rx='60' ry='60'/><ellipse cx='80' cy='80' rx='80' ry='80'/></g></svg>")`,
|
|
||||||
backgroundRepeat: 'no-repeat',
|
|
||||||
opacity: 0.7,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* Falling purple/orange particles */}
|
|
||||||
{!reduced &&
|
|
||||||
particles.map((_, i) => {
|
|
||||||
const isOrange = i % 3 === 0;
|
|
||||||
const size = 4 + (i % 3) * 2;
|
|
||||||
const left = (i * 4597 + 137) % 100;
|
|
||||||
const duration = 8 + (i % 7) * 1.5;
|
|
||||||
const delay = (i * 0.45) % 7;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: '-8px',
|
|
||||||
left: `${left}%`,
|
|
||||||
width: `${size}px`,
|
|
||||||
height: `${size}px`,
|
|
||||||
borderRadius: '50%',
|
|
||||||
backgroundColor: isOrange ? 'rgba(255,100,0,0.75)' : 'rgba(160,0,255,0.7)',
|
|
||||||
boxShadow: isOrange ? '0 0 8px rgba(255,100,0,0.5)' : '0 0 8px rgba(160,0,255,0.5)',
|
|
||||||
animation: `${animSeasonFall} ${duration}s ease-in ${delay}s infinite`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ChristmasOverlay({ reduced }: { reduced: boolean }) {
|
|
||||||
const flakes = Array.from({ length: 28 });
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
inset: 0,
|
|
||||||
backgroundImage:
|
|
||||||
'radial-gradient(ellipse at 50% 0%, rgba(220,240,255,0.06) 0%, transparent 60%)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{!reduced &&
|
|
||||||
flakes.map((_, i) => {
|
|
||||||
const size = 3 + (i % 4) * 2;
|
|
||||||
const left = (i * 3571 + 251) % 100;
|
|
||||||
const duration = 10 + (i % 8) * 2;
|
|
||||||
const delay = (i * 0.55) % 10;
|
|
||||||
const drift = ((i % 5) - 2) * 12;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: '-10px',
|
|
||||||
left: `${left}%`,
|
|
||||||
width: `${size}px`,
|
|
||||||
height: `${size}px`,
|
|
||||||
borderRadius: '50%',
|
|
||||||
backgroundColor: 'rgba(255,255,255,0.82)',
|
|
||||||
boxShadow: '0 0 4px rgba(200,230,255,0.6)',
|
|
||||||
animation: `${animSeasonFall} ${duration}s ease-in ${delay}s infinite`,
|
|
||||||
transform: `translateX(${drift}px)`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Replaced flashing burst rays with gentle falling confetti
|
|
||||||
function NewYearOverlay({ reduced }: { reduced: boolean }) {
|
|
||||||
const confetti = Array.from({ length: 24 });
|
|
||||||
const colors = ['#ffd700', '#ff4466', '#00d4ff', '#aa44ff', '#ff8800', '#ffffff'];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
inset: 0,
|
|
||||||
backgroundColor: 'rgba(10,5,0,0.10)',
|
|
||||||
backgroundImage:
|
|
||||||
'radial-gradient(ellipse at 50% 50%, rgba(255,200,0,0.04) 0%, transparent 70%)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* Gentle falling confetti */}
|
|
||||||
{!reduced &&
|
|
||||||
confetti.map((_, i) => {
|
|
||||||
const c = colors[i % colors.length];
|
|
||||||
const left = (i * 4597 + 137) % 100;
|
|
||||||
const size = 3 + (i % 3) * 2;
|
|
||||||
const duration = 8 + (i % 7) * 1.5;
|
|
||||||
const delay = (i * 0.4) % 8;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: '-8px',
|
|
||||||
left: `${left}%`,
|
|
||||||
width: `${size}px`,
|
|
||||||
height: `${size}px`,
|
|
||||||
borderRadius: i % 2 === 0 ? '50%' : '1px',
|
|
||||||
backgroundColor: c,
|
|
||||||
boxShadow: `0 0 ${size + 2}px ${c}`,
|
|
||||||
animation: `${animSeasonFall} ${duration}s ease-in ${delay}s infinite`,
|
|
||||||
opacity: 0.7 + (i % 3) * 0.1,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{/* Slow gold shimmer */}
|
|
||||||
<div
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
inset: 0,
|
|
||||||
backgroundImage:
|
|
||||||
'linear-gradient(105deg, transparent 30%, rgba(255,215,0,0.05) 50%, transparent 70%)',
|
|
||||||
backgroundSize: '200% 100%',
|
|
||||||
animation: reduced ? 'none' : `${animGoldShimmer} 5s linear infinite`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AutumnOverlay({ reduced }: { reduced: boolean }) {
|
|
||||||
const leaves = Array.from({ length: 18 });
|
|
||||||
const colors = [
|
|
||||||
'rgba(220,80,20,0.75)',
|
|
||||||
'rgba(200,120,0,0.7)',
|
|
||||||
'rgba(180,50,10,0.7)',
|
|
||||||
'rgba(230,150,0,0.65)',
|
|
||||||
'rgba(160,80,0,0.6)',
|
|
||||||
];
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
inset: 0,
|
|
||||||
backgroundImage:
|
|
||||||
'radial-gradient(ellipse at 50% 100%, rgba(180,80,0,0.06) 0%, transparent 60%)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{!reduced &&
|
|
||||||
leaves.map((_, i) => {
|
|
||||||
const left = (i * 5381 + 179) % 100;
|
|
||||||
const duration = 12 + (i % 6) * 2;
|
|
||||||
const delay = (i * 0.65) % 12;
|
|
||||||
const size = 10 + (i % 4) * 4;
|
|
||||||
const col = colors[i % colors.length];
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: '-15px',
|
|
||||||
left: `${left}%`,
|
|
||||||
width: `${size}px`,
|
|
||||||
height: `${size * 0.7}px`,
|
|
||||||
borderRadius: '50% 0 50% 0',
|
|
||||||
backgroundColor: col,
|
|
||||||
boxShadow: `0 0 4px ${col}`,
|
|
||||||
animation: `${animLeafFall} ${duration}s ease-in ${delay}s infinite`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Replaced aggressive glitch with playful confetti rain
|
|
||||||
function AprilFoolsOverlay({ reduced }: { reduced: boolean }) {
|
|
||||||
const particles = Array.from({ length: 20 });
|
|
||||||
const symbols = ['?', '!', '¿', '‽', '?', '!'];
|
|
||||||
const colors = [
|
|
||||||
'rgba(255,80,80,0.55)',
|
|
||||||
'rgba(255,200,0,0.55)',
|
|
||||||
'rgba(80,200,80,0.55)',
|
|
||||||
'rgba(80,80,255,0.55)',
|
|
||||||
'rgba(200,80,200,0.55)',
|
|
||||||
'rgba(80,200,200,0.55)',
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Subtle rainbow stripe along top edge */}
|
|
||||||
<div
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
height: '3px',
|
|
||||||
backgroundImage:
|
|
||||||
'linear-gradient(90deg, rgba(255,0,0,0.4), rgba(255,165,0,0.4), rgba(255,255,0,0.4), rgba(0,200,0,0.4), rgba(0,0,255,0.4), rgba(128,0,128,0.4))',
|
|
||||||
opacity: 0.7,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* Gentle falling punctuation symbols */}
|
|
||||||
{!reduced &&
|
|
||||||
particles.map((_, i) => {
|
|
||||||
const left = (i * 5381 + 179) % 100;
|
|
||||||
const duration = 11 + (i % 5) * 2.5;
|
|
||||||
const delay = (i * 0.55) % 10;
|
|
||||||
const col = colors[i % colors.length];
|
|
||||||
const sym = symbols[i % symbols.length];
|
|
||||||
const size = 12 + (i % 3) * 5;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: '-20px',
|
|
||||||
left: `${left}%`,
|
|
||||||
fontSize: `${size}px`,
|
|
||||||
color: col,
|
|
||||||
fontWeight: 700,
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
animation: `${animSeasonFall} ${duration}s ease-in ${delay}s infinite`,
|
|
||||||
userSelect: 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{sym}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reduced to 4 lanterns, subtler tint and shimmer
|
|
||||||
function LunarNewYearOverlay({ reduced }: { reduced: boolean }) {
|
|
||||||
const lanterns = Array.from({ length: 4 }); // was 9
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Very subtle red silk tint */}
|
|
||||||
<div
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
inset: 0,
|
|
||||||
backgroundColor: 'rgba(140,0,0,0.05)',
|
|
||||||
backgroundImage: [
|
|
||||||
'repeating-linear-gradient(45deg, rgba(200,20,0,0.015) 0px, rgba(200,20,0,0.015) 1px, transparent 1px, transparent 8px)',
|
|
||||||
'repeating-linear-gradient(135deg, rgba(200,20,0,0.015) 0px, rgba(200,20,0,0.015) 1px, transparent 1px, transparent 8px)',
|
|
||||||
].join(','),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* Slow gold shimmer */}
|
|
||||||
<div
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
inset: 0,
|
|
||||||
backgroundImage:
|
|
||||||
'linear-gradient(100deg, transparent 25%, rgba(255,200,0,0.05) 45%, rgba(255,220,50,0.07) 50%, rgba(255,200,0,0.05) 55%, transparent 75%)',
|
|
||||||
backgroundSize: '300% 100%',
|
|
||||||
animation: reduced ? 'none' : `${animGoldShimmer} 8s linear infinite`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* 4 floating lanterns */}
|
|
||||||
{lanterns.map((_, i) => {
|
|
||||||
const left = 10 + ((i * 4603 + 311) % 75);
|
|
||||||
const top = 10 + ((i * 2311 + 97) % 50);
|
|
||||||
const duration = 3.5 + (i % 4) * 0.7;
|
|
||||||
const delay = i * 0.9;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
left: `${left}%`,
|
|
||||||
top: `${top}%`,
|
|
||||||
animation: reduced
|
|
||||||
? 'none'
|
|
||||||
: `${animBob} ${duration}s ease-in-out ${delay}s infinite`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: '18px',
|
|
||||||
height: '5px',
|
|
||||||
backgroundColor: '#ffd700',
|
|
||||||
borderRadius: '2px',
|
|
||||||
margin: '0 auto',
|
|
||||||
boxShadow: '0 0 4px rgba(255,215,0,0.6)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: '24px',
|
|
||||||
height: '32px',
|
|
||||||
backgroundColor: '#cc0000',
|
|
||||||
borderRadius: '50%',
|
|
||||||
border: '1.5px solid #ffd700',
|
|
||||||
boxShadow: '0 0 14px rgba(200,0,0,0.5), inset 0 0 10px rgba(255,200,0,0.2)',
|
|
||||||
margin: '1px auto',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: '18px',
|
|
||||||
height: '5px',
|
|
||||||
backgroundColor: '#ffd700',
|
|
||||||
borderRadius: '2px',
|
|
||||||
margin: '0 auto',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: '2px',
|
|
||||||
height: '14px',
|
|
||||||
backgroundColor: '#ffd700',
|
|
||||||
margin: '0 auto',
|
|
||||||
animation: reduced
|
|
||||||
? 'none'
|
|
||||||
: `${animTasselSway} ${duration * 0.6}s ease-in-out ${delay}s infinite`,
|
|
||||||
transformOrigin: 'top center',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ValentinesOverlay({ reduced }: { reduced: boolean }) {
|
|
||||||
const hearts = Array.from({ length: 18 });
|
|
||||||
const colors = [
|
|
||||||
'rgba(255,100,140,0.8)',
|
|
||||||
'rgba(255,150,180,0.65)',
|
|
||||||
'rgba(220,70,110,0.7)',
|
|
||||||
'rgba(255,180,200,0.55)',
|
|
||||||
];
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
inset: 0,
|
|
||||||
backgroundImage:
|
|
||||||
'radial-gradient(ellipse at 50% 100%, rgba(255,100,140,0.06) 0%, transparent 55%)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{!reduced &&
|
|
||||||
hearts.map((_, i) => {
|
|
||||||
const left = 3 + ((i * 6271 + 443) % 94);
|
|
||||||
const duration = 9 + (i % 6) * 1.8;
|
|
||||||
const delay = (i * 0.6) % 9;
|
|
||||||
const size = 14 + (i % 4) * 5;
|
|
||||||
const col = colors[i % colors.length];
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: '-20px',
|
|
||||||
left: `${left}%`,
|
|
||||||
fontSize: `${size}px`,
|
|
||||||
color: col,
|
|
||||||
filter: 'drop-shadow(0 0 4px rgba(255,100,140,0.4))',
|
|
||||||
animation: `${animFloatUp} ${duration}s ease-in ${delay}s infinite`,
|
|
||||||
userSelect: 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
♥
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StPatricksOverlay({ reduced }: { reduced: boolean }) {
|
|
||||||
const clovers = Array.from({ length: 18 });
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
inset: 0,
|
|
||||||
backgroundImage: [
|
|
||||||
'radial-gradient(ellipse at 50% 0%, rgba(0,160,60,0.07) 0%, transparent 50%)',
|
|
||||||
'radial-gradient(ellipse at 50% 100%, rgba(0,130,50,0.05) 0%, transparent 40%)',
|
|
||||||
].join(','),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
height: '3px',
|
|
||||||
backgroundImage:
|
|
||||||
'linear-gradient(90deg, transparent 0%, #ffd700 20%, #fff4a0 40%, #ffd700 60%, transparent 100%)',
|
|
||||||
backgroundSize: '300% 100%',
|
|
||||||
animation: reduced ? 'none' : `${animGoldShimmer} 3s linear infinite`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{!reduced &&
|
|
||||||
clovers.map((_, i) => {
|
|
||||||
const left = (i * 4129 + 223) % 100;
|
|
||||||
const duration = 14 + (i % 6) * 2;
|
|
||||||
const delay = (i * 0.7) % 12;
|
|
||||||
const size = 14 + (i % 3) * 6;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: '-20px',
|
|
||||||
left: `${left}%`,
|
|
||||||
fontSize: `${size}px`,
|
|
||||||
opacity: 0.45 + (i % 3) * 0.1,
|
|
||||||
filter: 'drop-shadow(0 0 3px rgba(0,180,60,0.3))',
|
|
||||||
animation: `${animCloverDrift} ${duration}s linear ${delay}s infinite`,
|
|
||||||
userSelect: 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
☘
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function EarthDayOverlay({ reduced }: { reduced: boolean }) {
|
|
||||||
const leaves = Array.from({ length: 16 });
|
|
||||||
const leafEmoji = ['🌿', '🍃', '🌱', '🍀'];
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
inset: 0,
|
|
||||||
backgroundImage: [
|
|
||||||
'radial-gradient(ellipse at 30% 70%, rgba(60,160,60,0.07) 0%, transparent 50%)',
|
|
||||||
'radial-gradient(ellipse at 70% 30%, rgba(100,180,80,0.05) 0%, transparent 45%)',
|
|
||||||
].join(','),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
left: 0,
|
|
||||||
top: 0,
|
|
||||||
bottom: 0,
|
|
||||||
width: '3px',
|
|
||||||
backgroundImage:
|
|
||||||
'linear-gradient(180deg, transparent 0%, rgba(60,160,60,0.4) 20%, rgba(80,180,60,0.6) 50%, rgba(60,160,60,0.4) 80%, transparent 100%)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{!reduced &&
|
|
||||||
leaves.map((_, i) => {
|
|
||||||
const left = 3 + ((i * 5023 + 317) % 92);
|
|
||||||
const duration = 13 + (i % 5) * 2;
|
|
||||||
const delay = (i * 0.75) % 11;
|
|
||||||
const size = 14 + (i % 3) * 5;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: '-20px',
|
|
||||||
left: `${left}%`,
|
|
||||||
fontSize: `${size}px`,
|
|
||||||
opacity: 0.5 + (i % 3) * 0.1,
|
|
||||||
animation: `${animEarthLeafDrift} ${duration}s ease-in ${delay}s infinite`,
|
|
||||||
userSelect: 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{leafEmoji[i % leafEmoji.length]}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DeepSpaceOverlay({ reduced }: { reduced: boolean }) {
|
|
||||||
const stars = Array.from({ length: 24 });
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
inset: 0,
|
|
||||||
backgroundColor: 'rgba(0,0,8,0.3)',
|
|
||||||
backgroundImage: [
|
|
||||||
'radial-gradient(ellipse at 30% 40%, rgba(80,0,180,0.10) 0%, transparent 50%)',
|
|
||||||
'radial-gradient(ellipse at 70% 60%, rgba(0,60,180,0.10) 0%, transparent 50%)',
|
|
||||||
'radial-gradient(ellipse at 50% 20%, rgba(120,0,200,0.07) 0%, transparent 40%)',
|
|
||||||
].join(','),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{!reduced &&
|
|
||||||
stars.map((_, i) => {
|
|
||||||
const angle = (i / stars.length) * 360;
|
|
||||||
const duration = 2.5 + (i % 5) * 0.4;
|
|
||||||
const delay = (i * 0.18) % 2.5;
|
|
||||||
const period = 3 + (i % 4) * 0.5;
|
|
||||||
const size = 1 + (i % 3);
|
|
||||||
const starColors = [
|
|
||||||
'rgba(200,180,255,0.9)',
|
|
||||||
'rgba(150,200,255,0.8)',
|
|
||||||
'rgba(255,255,255,0.7)',
|
|
||||||
];
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
left: '50%',
|
|
||||||
top: '50%',
|
|
||||||
width: `${80 + i * 6}px`,
|
|
||||||
height: `${size}px`,
|
|
||||||
backgroundColor: starColors[i % starColors.length],
|
|
||||||
transformOrigin: '0 50%',
|
|
||||||
transform: `rotate(${angle}deg)`,
|
|
||||||
boxShadow: `0 0 ${size * 2}px ${starColors[i % starColors.length]}`,
|
|
||||||
animation: `${animWarp} ${duration}s ease-out ${delay}s ${period}s infinite`,
|
|
||||||
opacity: 0,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ArcadeOverlay({ reduced }: { reduced: boolean }) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
inset: 0,
|
|
||||||
backgroundImage:
|
|
||||||
'repeating-linear-gradient(0deg, rgba(0,0,0,0.12) 0px, rgba(0,0,0,0.12) 1px, transparent 1px, transparent 3px)',
|
|
||||||
animation: reduced ? 'none' : `${animScanline} 3s ease-in-out infinite`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{(['0,0', '0,auto', 'auto,0', 'auto,auto'] as const).map((corner, i) => {
|
|
||||||
const [t, b] = corner.split(',');
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: t === '0' ? '8px' : undefined,
|
|
||||||
bottom: b === '0' ? '8px' : undefined,
|
|
||||||
left: i % 2 === 0 ? '8px' : undefined,
|
|
||||||
right: i % 2 === 1 ? '8px' : undefined,
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
fontSize: '11px',
|
|
||||||
color: 'rgba(0,255,136,0.5)',
|
|
||||||
letterSpacing: '0.05em',
|
|
||||||
animation: reduced ? 'none' : `${animPixelBlink} ${1 + i * 0.3}s step-end infinite`,
|
|
||||||
userSelect: 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{['[■]', '[■]', '[■]', '[■]'][i]}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<div
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: '16px',
|
|
||||||
left: '50%',
|
|
||||||
transform: 'translateX(-50%)',
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
fontSize: '12px',
|
|
||||||
letterSpacing: '0.2em',
|
|
||||||
color: 'rgba(255,220,0,0.4)',
|
|
||||||
animation: reduced ? 'none' : `${animPixelBlink} 1.2s step-end infinite`,
|
|
||||||
userSelect: 'none',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
— INSERT COIN —
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
inset: 0,
|
|
||||||
backgroundImage:
|
|
||||||
'radial-gradient(ellipse at 50% 50%, transparent 60%, rgba(0,0,0,0.35) 100%)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Overlay content map (shared between SeasonalOverlay and SeasonalPreview) ──
|
// ─── Overlay content map (shared between SeasonalOverlay and SeasonalPreview) ──
|
||||||
|
|
||||||
@@ -758,7 +64,7 @@ function SeasonalOverlay({ theme, reduced }: { theme: SeasonTheme; reduced: bool
|
|||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
// Below the Night Light overlay (9998) so seasonal particles are tinted
|
// Below the Night Light overlay (9998) so seasonal particles are tinted
|
||||||
// by it, and below modals (9999) so dialogs are never obscured.
|
// by it, and below modals (9999) so dialogs are never obscured.
|
||||||
zIndex: 9997,
|
zIndex: zIndices.seasonalEffect,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import { getActiveSeason, SEASON_SCHEDULE, SEASON_DATE_RANGES } from './seasonSchedule';
|
||||||
|
import { SeasonTheme } from './types';
|
||||||
|
|
||||||
|
// Date(year, monthIndex0, day)
|
||||||
|
const on = (monthIndex0: number, day: number): Date => new Date(2026, monthIndex0, day);
|
||||||
|
|
||||||
|
test('each theme activates on a representative day in its window', () => {
|
||||||
|
const cases: Array<[Date, SeasonTheme]> = [
|
||||||
|
[on(11, 31), 'newyear'], // Dec 31
|
||||||
|
[on(0, 1), 'newyear'], // Jan 1
|
||||||
|
[on(0, 25), 'lunar'], // Jan 25
|
||||||
|
[on(1, 3), 'lunar'], // Feb 3
|
||||||
|
[on(1, 12), 'valentines'], // Feb 12
|
||||||
|
[on(2, 16), 'stpatricks'], // Mar 16
|
||||||
|
[on(3, 1), 'aprilfools'], // Apr 1
|
||||||
|
[on(3, 21), 'earthday'], // Apr 21
|
||||||
|
[on(8, 12), 'arcade'], // Sep 12
|
||||||
|
[on(8, 25), 'autumn'], // Sep 25
|
||||||
|
[on(9, 20), 'halloween'], // Oct 20
|
||||||
|
[on(10, 1), 'halloween'], // Nov 1
|
||||||
|
[on(11, 15), 'christmas'], // Dec 15
|
||||||
|
];
|
||||||
|
for (const [date, expected] of cases) {
|
||||||
|
assert.equal(getActiveSeason(date), expected, `${date.toDateString()} -> ${expected}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('priority order resolves overlapping windows (Deep Space outranks Autumn)', () => {
|
||||||
|
// Oct 4-10 is inside Autumn's Oct<=14 window too; Deep Space comes first.
|
||||||
|
assert.equal(getActiveSeason(on(9, 5)), 'deepspace'); // Oct 5
|
||||||
|
// Oct 12 is past Deep Space -> falls through to Autumn.
|
||||||
|
assert.equal(getActiveSeason(on(9, 12)), 'autumn');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('New Year outranks Lunar New Year on Jan 1-2', () => {
|
||||||
|
assert.equal(getActiveSeason(on(0, 1)), 'newyear');
|
||||||
|
// Jan 22+ is past New Year -> Lunar.
|
||||||
|
assert.equal(getActiveSeason(on(0, 22)), 'lunar');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null on an off-season day', () => {
|
||||||
|
assert.equal(getActiveSeason(on(5, 15)), null); // Jun 15
|
||||||
|
assert.equal(getActiveSeason(on(6, 4)), null); // Jul 4
|
||||||
|
});
|
||||||
|
|
||||||
|
test('window boundaries are inclusive at both ends', () => {
|
||||||
|
assert.equal(getActiveSeason(on(1, 10)), 'valentines'); // Feb 10 start
|
||||||
|
assert.equal(getActiveSeason(on(1, 15)), 'valentines'); // Feb 15 end
|
||||||
|
assert.equal(getActiveSeason(on(1, 16)), null); // Feb 16 just after
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SEASON_DATE_RANGES has a label for every scheduled theme', () => {
|
||||||
|
assert.equal(SEASON_SCHEDULE.length, 11);
|
||||||
|
const themes = SEASON_SCHEDULE.map((e) => e.theme);
|
||||||
|
assert.equal(new Set(themes).size, 11); // unique
|
||||||
|
for (const t of themes) {
|
||||||
|
assert.ok(
|
||||||
|
typeof SEASON_DATE_RANGES[t] === 'string' && SEASON_DATE_RANGES[t].length > 0,
|
||||||
|
`missing date range for ${t}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { SeasonTheme } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single source of truth for when each seasonal theme auto-activates.
|
||||||
|
*
|
||||||
|
* Both `getActiveSeason` (the runtime "Auto" selector) and the settings UI read
|
||||||
|
* this list, so the date windows shown to the user can never drift from the
|
||||||
|
* dates actually used. Order matters: it is the activation PRIORITY — the first
|
||||||
|
* entry whose window matches wins (e.g. Deep Space outranks Autumn in their
|
||||||
|
* early-October overlap).
|
||||||
|
*/
|
||||||
|
export type SeasonScheduleEntry = {
|
||||||
|
theme: SeasonTheme;
|
||||||
|
/** Human-readable activation window for display in settings. */
|
||||||
|
dateRange: string;
|
||||||
|
/** Whether this theme is active on the given month (1-12) and day (1-31). */
|
||||||
|
matches: (month: number, day: number) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SEASON_SCHEDULE: SeasonScheduleEntry[] = [
|
||||||
|
{
|
||||||
|
theme: 'newyear',
|
||||||
|
dateRange: 'Dec 31 – Jan 2',
|
||||||
|
matches: (m, d) => (m === 12 && d === 31) || (m === 1 && d <= 2),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
theme: 'valentines',
|
||||||
|
dateRange: 'Feb 10 – 15',
|
||||||
|
matches: (m, d) => m === 2 && d >= 10 && d <= 15,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
theme: 'stpatricks',
|
||||||
|
dateRange: 'Mar 15 – 18',
|
||||||
|
matches: (m, d) => m === 3 && d >= 15 && d <= 18,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
theme: 'aprilfools',
|
||||||
|
dateRange: 'Apr 1',
|
||||||
|
matches: (m, d) => m === 4 && d === 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
theme: 'earthday',
|
||||||
|
dateRange: 'Apr 20 – 23',
|
||||||
|
matches: (m, d) => m === 4 && d >= 20 && d <= 23,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
theme: 'lunar',
|
||||||
|
dateRange: 'Jan 22 – Feb 5',
|
||||||
|
matches: (m, d) => (m === 1 && d >= 22) || (m === 2 && d <= 5),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
theme: 'arcade',
|
||||||
|
dateRange: 'Sep 12',
|
||||||
|
matches: (m, d) => m === 9 && d === 12,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
theme: 'deepspace',
|
||||||
|
dateRange: 'Oct 4 – 10',
|
||||||
|
matches: (m, d) => m === 10 && d >= 4 && d <= 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
theme: 'halloween',
|
||||||
|
dateRange: 'Oct 15 – Nov 1',
|
||||||
|
matches: (m, d) => (m === 10 && d >= 15) || (m === 11 && d === 1),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
theme: 'christmas',
|
||||||
|
dateRange: 'Dec 10 – 30',
|
||||||
|
matches: (m, d) => m === 12 && d >= 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
theme: 'autumn',
|
||||||
|
dateRange: 'Sep 21 – Oct 14',
|
||||||
|
matches: (m, d) => (m === 9 && d >= 21) || (m === 10 && d <= 14),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Map of theme → human-readable activation window (for settings captions). */
|
||||||
|
export const SEASON_DATE_RANGES: Record<SeasonTheme, string> = SEASON_SCHEDULE.reduce(
|
||||||
|
(acc, entry) => {
|
||||||
|
acc[entry.theme] = entry.dateRange;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<SeasonTheme, string>,
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The seasonal theme that should be active on `now`, or null if none. First
|
||||||
|
* matching entry in SEASON_SCHEDULE priority order wins.
|
||||||
|
*/
|
||||||
|
export function getActiveSeason(now: Date): SeasonTheme | null {
|
||||||
|
const month = now.getMonth() + 1; // 1-12
|
||||||
|
const day = now.getDate();
|
||||||
|
return SEASON_SCHEDULE.find((entry) => entry.matches(month, day))?.theme ?? null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { keyframes } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Doodle float-up — a hand-drawn glyph drifts gently upward while bobbing
|
||||||
|
* side to side and lazily rotating, like a thought balloon escaping the page.
|
||||||
|
* GPU-only: transform + opacity exclusively. A tall translateY lets one set of
|
||||||
|
* keyframes serve every doodle; per-element duration/delay/scale add variety.
|
||||||
|
*/
|
||||||
|
export const animDoodleFloat = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(0, 8vh, 0) rotate(-8deg) scale(0.85)', opacity: '0' },
|
||||||
|
'10%': { opacity: '1' },
|
||||||
|
'35%': { transform: 'translate3d(16px, -28vh, 0) rotate(6deg) scale(1)' },
|
||||||
|
'65%': { transform: 'translate3d(-14px, -64vh, 0) rotate(-5deg) scale(1.04)' },
|
||||||
|
'90%': { opacity: '0.8' },
|
||||||
|
'100%': { transform: 'translate3d(10px, -112vh, 0) rotate(7deg) scale(1.1)', opacity: '0' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confetti tumble — a small chip falls while flipping. Reuses a single tall
|
||||||
|
* translateY; the flip (rotate + scaleX) sells the paper tumble cheaply.
|
||||||
|
*/
|
||||||
|
export const animConfettiTumble = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(0, -8vh, 0) rotate(0deg) scaleX(1)', opacity: '0' },
|
||||||
|
'8%': { opacity: '1' },
|
||||||
|
'50%': { transform: 'translate3d(18px, 50vh, 0) rotate(220deg) scaleX(-1)' },
|
||||||
|
'92%': { opacity: '0.9' },
|
||||||
|
'100%': { transform: 'translate3d(-12px, 112vh, 0) rotate(440deg) scaleX(1)', opacity: '0' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Playful wobble — an almost-imperceptible skew/rotate of a faux tint layer so
|
||||||
|
* the whole scene feels gently "tickled". Tiny amplitude keeps it from being
|
||||||
|
* disorienting. Transform only, stays on the compositor.
|
||||||
|
*/
|
||||||
|
export const animWobble = keyframes({
|
||||||
|
'0%': { transform: 'rotate(-0.5deg) skewX(-0.4deg) scale(1.01)' },
|
||||||
|
'50%': { transform: 'rotate(0.5deg) skewX(0.4deg) scale(1.01)' },
|
||||||
|
'100%': { transform: 'rotate(-0.5deg) skewX(-0.4deg) scale(1.01)' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pastel aurora drift — a soft rainbow wash high in the scene slides and
|
||||||
|
* breathes. translateX + opacity (never background-position) to stay on GPU.
|
||||||
|
*/
|
||||||
|
export const animRainbowDrift = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(-5%, 0, 0) scaleY(1)', opacity: '0.55' },
|
||||||
|
'50%': { transform: 'translate3d(5%, 0, 0) scaleY(1.06)', opacity: '0.8' },
|
||||||
|
'100%': { transform: 'translate3d(-5%, 0, 0) scaleY(1)', opacity: '0.55' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Googly-eye look-around — the pupil layer nudges around its socket, giving
|
||||||
|
* each eye a cheeky wandering gaze. Small translate only.
|
||||||
|
*/
|
||||||
|
export const animGoogly = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(1.5px, 1px, 0)' },
|
||||||
|
'20%': { transform: 'translate3d(-1.5px, 1.5px, 0)' },
|
||||||
|
'45%': { transform: 'translate3d(1px, -1.5px, 0)' },
|
||||||
|
'70%': { transform: 'translate3d(-1px, -0.5px, 0)' },
|
||||||
|
'100%': { transform: 'translate3d(1.5px, 1px, 0)' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sly wink/sparkle — a four-point glint that twinkles open and shut, scaling
|
||||||
|
* and fading like a sly little wink. Transform + opacity only.
|
||||||
|
*/
|
||||||
|
export const animSparkle = keyframes({
|
||||||
|
'0%, 100%': { transform: 'scale(0.2) rotate(0deg)', opacity: '0' },
|
||||||
|
'40%': { transform: 'scale(1) rotate(35deg)', opacity: '0.9' },
|
||||||
|
'60%': { transform: 'scale(0.95) rotate(45deg)', opacity: '0.7' },
|
||||||
|
});
|
||||||
@@ -0,0 +1,409 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { SeasonalOverlayProps } from '../types';
|
||||||
|
import {
|
||||||
|
animDoodleFloat,
|
||||||
|
animConfettiTumble,
|
||||||
|
animWobble,
|
||||||
|
animRainbowDrift,
|
||||||
|
animGoogly,
|
||||||
|
animSparkle,
|
||||||
|
} from './AprilFools.css';
|
||||||
|
|
||||||
|
// Deterministic pseudo-random so the scene is identical on every mount and the
|
||||||
|
// reduced-motion preview thumbnail is stable. Large primes spread the values.
|
||||||
|
const rand = (seed: number) => {
|
||||||
|
const x = Math.sin(seed * 127.1 + 311.7) * 43758.5453;
|
||||||
|
return x - Math.floor(x);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bright-but-soft pastel rainbow in oklch. Kept luminous and gentle so the
|
||||||
|
// doodles read as crayon pastel over chat without ever fighting the text.
|
||||||
|
const PASTELS = [
|
||||||
|
'oklch(0.85 0.12 20)', // pink
|
||||||
|
'oklch(0.88 0.12 90)', // butter yellow
|
||||||
|
'oklch(0.82 0.12 160)', // mint
|
||||||
|
'oklch(0.8 0.12 260)', // periwinkle
|
||||||
|
'oklch(0.84 0.12 320)', // lilac
|
||||||
|
'oklch(0.86 0.11 50)', // peach
|
||||||
|
];
|
||||||
|
|
||||||
|
// Inline-SVG data-URI doodle glyphs, drawn hand-sketch style (round caps,
|
||||||
|
// open paths). `enc()` keeps them CSP-safe — no external assets, no base64.
|
||||||
|
const enc = (svg: string) => `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
|
||||||
|
|
||||||
|
// A single rough stroke wrapper helper for the glyph SVGs.
|
||||||
|
const stroke = (color: string, body: string) =>
|
||||||
|
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='none' ` +
|
||||||
|
`stroke='${color}' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'>${body}</svg>`;
|
||||||
|
|
||||||
|
// Question mark — the playful "huh?" centerpiece doodle.
|
||||||
|
const glyphQuestion = (c: string) =>
|
||||||
|
stroke(
|
||||||
|
c,
|
||||||
|
`<path d='M11 11 q0 -6 6 -6 q6 0 6 5 q0 4 -5 6 q-2 1 -2 4'/>` +
|
||||||
|
`<circle cx='16' cy='27' r='0.6' fill='${c}'/>`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Exclamation / "bang" — a surprised little doodle.
|
||||||
|
const glyphBang = (c: string) =>
|
||||||
|
stroke(c, `<path d='M16 5 L16 20'/><circle cx='16' cy='27' r='0.6' fill='${c}'/>`);
|
||||||
|
|
||||||
|
// Squiggle — a loopy scribble that adds whimsy.
|
||||||
|
const glyphSquiggle = (c: string) => stroke(c, `<path d='M5 18 q4 -10 8 0 t8 0 t8 0'/>`);
|
||||||
|
|
||||||
|
// Five-point doodle star (open-stroke, hand-drawn look).
|
||||||
|
const glyphStar = (c: string) =>
|
||||||
|
stroke(
|
||||||
|
c,
|
||||||
|
`<path d='M16 5 L19.4 13 L28 13.6 L21.4 19.2 L23.5 27.6 L16 22.8 L8.5 27.6 ` +
|
||||||
|
`L10.6 19.2 L4 13.6 L12.6 13 Z'/>`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// A tiny heart doodle for extra grin.
|
||||||
|
const glyphHeart = (c: string) =>
|
||||||
|
stroke(c, `<path d='M16 26 C6 18 7 8 16 12 C25 8 26 18 16 26 Z'/>`);
|
||||||
|
|
||||||
|
const GLYPHS = [glyphQuestion, glyphBang, glyphSquiggle, glyphStar, glyphHeart, glyphQuestion];
|
||||||
|
|
||||||
|
type Doodle = {
|
||||||
|
left: number;
|
||||||
|
size: number;
|
||||||
|
glyph: string;
|
||||||
|
duration: number;
|
||||||
|
delay: number;
|
||||||
|
startTop: number; // used for the static (reduced) scatter
|
||||||
|
opacity: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Confetti = {
|
||||||
|
left: number;
|
||||||
|
size: number;
|
||||||
|
color: string;
|
||||||
|
duration: number;
|
||||||
|
delay: number;
|
||||||
|
startTop: number;
|
||||||
|
ratio: number; // chip aspect
|
||||||
|
round: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Eye = {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
size: number;
|
||||||
|
duration: number;
|
||||||
|
delay: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Spark = {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
size: number;
|
||||||
|
color: string;
|
||||||
|
duration: number;
|
||||||
|
delay: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AprilFoolsOverlay({ reduced }: SeasonalOverlayProps) {
|
||||||
|
// ~16 drifting doodles. Built once; per-element timing creates the variety.
|
||||||
|
const doodles = useMemo<Doodle[]>(() => {
|
||||||
|
const count = 16;
|
||||||
|
const out: Doodle[] = [];
|
||||||
|
for (let i = 0; i < count; i += 1) {
|
||||||
|
const color = PASTELS[i % PASTELS.length];
|
||||||
|
out.push({
|
||||||
|
left: rand(i + 0.1) * 96 + 2,
|
||||||
|
size: 18 + rand(i + 0.3) * 22,
|
||||||
|
glyph: enc(GLYPHS[i % GLYPHS.length](color)),
|
||||||
|
duration: 16 + rand(i + 0.5) * 12,
|
||||||
|
delay: -rand(i + 0.7) * 26,
|
||||||
|
startTop: rand(i + 0.9) * 92 + 4,
|
||||||
|
opacity: 0.5 + rand(i + 0.2) * 0.32,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ~14 confetti chips in a couple of falling bands.
|
||||||
|
const confetti = useMemo<Confetti[]>(() => {
|
||||||
|
const count = 14;
|
||||||
|
const out: Confetti[] = [];
|
||||||
|
for (let i = 0; i < count; i += 1) {
|
||||||
|
out.push({
|
||||||
|
left: rand(i + 3.1) * 98 + 1,
|
||||||
|
size: 5 + rand(i + 3.3) * 6,
|
||||||
|
color: PASTELS[(i + 2) % PASTELS.length],
|
||||||
|
duration: 10 + rand(i + 3.5) * 9,
|
||||||
|
delay: -rand(i + 3.7) * 18,
|
||||||
|
startTop: rand(i + 3.9) * 96 + 2,
|
||||||
|
ratio: 0.45 + rand(i + 3.2) * 0.8,
|
||||||
|
round: rand(i + 3.6) > 0.6,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// A few googly eyes peeking from corners/edges — the cheeky surprise.
|
||||||
|
const eyes = useMemo<Eye[]>(() => {
|
||||||
|
const anchors = [
|
||||||
|
{ left: 6, top: 12 },
|
||||||
|
{ left: 90, top: 20 },
|
||||||
|
{ left: 80, top: 82 },
|
||||||
|
{ left: 14, top: 74 },
|
||||||
|
];
|
||||||
|
return anchors.map((a, i) => ({
|
||||||
|
left: a.left,
|
||||||
|
top: a.top,
|
||||||
|
size: 22 + rand(i + 5.1) * 12,
|
||||||
|
duration: 3 + rand(i + 5.3) * 2.5,
|
||||||
|
delay: -rand(i + 5.5) * 3,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Sly winking sparkles scattered sparsely.
|
||||||
|
const sparks = useMemo<Spark[]>(() => {
|
||||||
|
const count = 5;
|
||||||
|
const out: Spark[] = [];
|
||||||
|
for (let i = 0; i < count; i += 1) {
|
||||||
|
out.push({
|
||||||
|
left: rand(i + 7.1) * 90 + 5,
|
||||||
|
top: rand(i + 7.3) * 84 + 8,
|
||||||
|
size: 12 + rand(i + 7.5) * 12,
|
||||||
|
color: PASTELS[(i + 1) % PASTELS.length],
|
||||||
|
duration: 4 + rand(i + 7.7) * 3,
|
||||||
|
delay: -rand(i + 7.9) * 5,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Four-point glint used for the winking sparkles.
|
||||||
|
const sparkGlint = (c: string) =>
|
||||||
|
enc(
|
||||||
|
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'>` +
|
||||||
|
`<path d='M12 0 C13 8 16 11 24 12 C16 13 13 16 12 24 C11 16 8 13 0 12 C8 11 11 8 12 0 Z' fill='${c}'/></svg>`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Soft pastel ambient wash — layered oklch radials for depth. Very low
|
||||||
|
opacity so chat text keeps WCAG-AA contrast. */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
contain: 'layout paint style',
|
||||||
|
backgroundImage: [
|
||||||
|
'radial-gradient(110% 70% at 18% -8%, oklch(0.85 0.12 20 / 0.1) 0%, transparent 55%)',
|
||||||
|
'radial-gradient(95% 65% at 86% 0%, oklch(0.82 0.12 160 / 0.09) 0%, transparent 58%)',
|
||||||
|
'radial-gradient(120% 80% at 50% 112%, oklch(0.8 0.12 260 / 0.1) 0%, transparent 60%)',
|
||||||
|
'linear-gradient(180deg, oklch(0.88 0.12 90 / 0.05) 0%, transparent 30%, transparent 78%, oklch(0.84 0.12 320 / 0.06) 100%)',
|
||||||
|
].join(','),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Faux wobble layer — a near-invisible pastel haze that gently skews so
|
||||||
|
the whole scene feels playfully "tickled". Tiny amplitude = not
|
||||||
|
nauseating. backdrop-filter is one cheap layer for a candy bloom. */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: '-2%',
|
||||||
|
contain: 'layout paint style',
|
||||||
|
backdropFilter: 'saturate(1.06) brightness(1.01)',
|
||||||
|
WebkitBackdropFilter: 'saturate(1.06) brightness(1.01)',
|
||||||
|
backgroundImage:
|
||||||
|
'radial-gradient(130% 120% at 50% 45%, transparent 60%, oklch(0.86 0.11 50 / 0.05) 80%, oklch(0.8 0.12 260 / 0.08) 100%)',
|
||||||
|
transformOrigin: '50% 50%',
|
||||||
|
animation: reduced ? 'none' : `${animWobble} 14s ease-in-out infinite`,
|
||||||
|
willChange: reduced ? undefined : 'transform',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Pastel rainbow aurora high up — soft band of the full palette. */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '-8%',
|
||||||
|
left: '-10%',
|
||||||
|
right: '-10%',
|
||||||
|
height: '42%',
|
||||||
|
contain: 'layout paint style',
|
||||||
|
mixBlendMode: 'screen',
|
||||||
|
filter: 'blur(30px)',
|
||||||
|
opacity: reduced ? 0.6 : undefined,
|
||||||
|
backgroundImage: [
|
||||||
|
'radial-gradient(50% 100% at 18% 0%, oklch(0.85 0.12 20 / 0.16) 0%, transparent 72%)',
|
||||||
|
'radial-gradient(50% 100% at 40% 0%, oklch(0.88 0.12 90 / 0.14) 0%, transparent 72%)',
|
||||||
|
'radial-gradient(50% 100% at 62% 0%, oklch(0.82 0.12 160 / 0.14) 0%, transparent 72%)',
|
||||||
|
'radial-gradient(50% 100% at 84% 0%, oklch(0.8 0.12 260 / 0.16) 0%, transparent 72%)',
|
||||||
|
].join(','),
|
||||||
|
animation: reduced ? 'none' : `${animRainbowDrift} 20s ease-in-out infinite`,
|
||||||
|
willChange: reduced ? undefined : 'transform, opacity',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Drifting doodles. Motion: rise from below. Reduced: static scatter. */}
|
||||||
|
{doodles.map((d, i) => {
|
||||||
|
const common: React.CSSProperties = {
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${d.left}%`,
|
||||||
|
width: `${d.size}px`,
|
||||||
|
height: `${d.size}px`,
|
||||||
|
backgroundImage: d.glyph,
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
backgroundSize: 'contain',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
opacity: d.opacity,
|
||||||
|
filter: 'drop-shadow(0 1px 1px oklch(0.4 0.05 300 / 0.18))',
|
||||||
|
};
|
||||||
|
if (reduced) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`doodle-${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
...common,
|
||||||
|
top: `${d.startTop}%`,
|
||||||
|
transform: `rotate(${(rand(i + 11) - 0.5) * 24}deg)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`doodle-${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
...common,
|
||||||
|
top: 0,
|
||||||
|
animation: `${animDoodleFloat} ${d.duration}s ease-in-out ${d.delay}s infinite`,
|
||||||
|
willChange: 'transform, opacity',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Light confetti — tumbling pastel chips. */}
|
||||||
|
{confetti.map((c, i) => {
|
||||||
|
const common: React.CSSProperties = {
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${c.left}%`,
|
||||||
|
width: `${c.size}px`,
|
||||||
|
height: `${c.size * c.ratio}px`,
|
||||||
|
background: c.color,
|
||||||
|
borderRadius: c.round ? '50%' : '1px',
|
||||||
|
opacity: 0.75,
|
||||||
|
};
|
||||||
|
if (reduced) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`confetti-${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
...common,
|
||||||
|
top: `${c.startTop}%`,
|
||||||
|
transform: `rotate(${rand(i + 13) * 360}deg)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`confetti-${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
...common,
|
||||||
|
top: 0,
|
||||||
|
animation: `${animConfettiTumble} ${c.duration}s linear ${c.delay}s infinite`,
|
||||||
|
willChange: 'transform, opacity',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Googly eyes peeking from the edges — pupil wanders cheekily. */}
|
||||||
|
{eyes.map((e, i) => (
|
||||||
|
<div
|
||||||
|
key={`eye-${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${e.left}%`,
|
||||||
|
top: `${e.top}%`,
|
||||||
|
width: `${e.size}px`,
|
||||||
|
height: `${e.size}px`,
|
||||||
|
marginLeft: `${-e.size / 2}px`,
|
||||||
|
marginTop: `${-e.size / 2}px`,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background:
|
||||||
|
'radial-gradient(circle at 38% 32%, oklch(0.99 0.005 90 / 0.85) 0%, oklch(0.95 0.01 90 / 0.7) 62%, oklch(0.75 0.02 90 / 0.6) 100%)',
|
||||||
|
boxShadow: 'inset 0 0 0 1.5px oklch(0.45 0.03 300 / 0.35)',
|
||||||
|
opacity: 0.6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Pupil */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: '50%',
|
||||||
|
top: '50%',
|
||||||
|
width: `${e.size * 0.4}px`,
|
||||||
|
height: `${e.size * 0.4}px`,
|
||||||
|
marginLeft: `${-e.size * 0.2}px`,
|
||||||
|
marginTop: `${-e.size * 0.2}px`,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background:
|
||||||
|
'radial-gradient(circle at 36% 30%, oklch(0.5 0.04 300 / 0.95) 0%, oklch(0.28 0.04 300 / 0.95) 70%)',
|
||||||
|
animation: reduced
|
||||||
|
? 'none'
|
||||||
|
: `${animGoogly} ${e.duration}s ease-in-out ${e.delay}s infinite`,
|
||||||
|
willChange: reduced ? undefined : 'transform',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Catchlight */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: '22%',
|
||||||
|
top: '20%',
|
||||||
|
width: '28%',
|
||||||
|
height: '28%',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'oklch(0.99 0.005 90 / 0.85)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Sly winking sparkles. Static (reduced) shows them mid-glint. */}
|
||||||
|
{sparks.map((s, i) => (
|
||||||
|
<div
|
||||||
|
key={`spark-${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${s.left}%`,
|
||||||
|
top: `${s.top}%`,
|
||||||
|
width: `${s.size}px`,
|
||||||
|
height: `${s.size}px`,
|
||||||
|
backgroundImage: sparkGlint(s.color),
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
backgroundSize: 'contain',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
filter: `drop-shadow(0 0 3px ${s.color.replace(')', ' / 0.5)')})`,
|
||||||
|
opacity: reduced ? 0.8 : undefined,
|
||||||
|
transform: reduced ? 'scale(0.95) rotate(40deg)' : undefined,
|
||||||
|
animation: reduced
|
||||||
|
? 'none'
|
||||||
|
: `${animSparkle} ${s.duration}s ease-in-out ${s.delay}s infinite`,
|
||||||
|
willChange: reduced ? undefined : 'transform, opacity',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { keyframes } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arcade overlay keyframes — retro synthwave CRT.
|
||||||
|
*
|
||||||
|
* Every animation touches ONLY `transform` and `opacity` so the compositor can
|
||||||
|
* run them on the GPU without triggering layout or paint. keyframes() returns
|
||||||
|
* the generated animation-name string, which is applied inline in Arcade.tsx.
|
||||||
|
*
|
||||||
|
* Motion philosophy: a neon perspective grid scrolls toward the viewer, a soft
|
||||||
|
* CRT scanline field breathes, the whole screen glows and flickers ever so
|
||||||
|
* faintly, sparse pixel sparkles drift up, and an "INSERT COIN" blip pulses.
|
||||||
|
* The grid scroll is done with a translateY on a tiled, perspective-projected
|
||||||
|
* plane — never background-position — so it rides the compositor.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The neon grid plane is laid out twice its visible height and tiled with the
|
||||||
|
* horizontal rule lines. Translating it up by exactly one tile makes the lines
|
||||||
|
* appear to flow continuously toward the viewer (the horizon). Because the
|
||||||
|
* plane sits under a `perspective` transform, the lines also accelerate as they
|
||||||
|
* approach, giving a true receding-grid illusion. Pure transform.
|
||||||
|
*/
|
||||||
|
export const animGridScroll = keyframes({
|
||||||
|
'0%': { transform: 'translateZ(0) translateY(0)' },
|
||||||
|
'100%': { transform: 'translateZ(0) translateY(50%)' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slow vertical drift of the fine scanline field — a couple of pixels so the
|
||||||
|
* raster looks like it's gently rolling, the way a real CRT does. Transform
|
||||||
|
* only; the line texture itself never moves on the GPU's paint layer.
|
||||||
|
*/
|
||||||
|
export const animScanRoll = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(0, 0, 0)' },
|
||||||
|
'100%': { transform: 'translate3d(0, 4px, 0)' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The overall CRT screen-glow breathes: a barely-there opacity swell that keeps
|
||||||
|
* the static neon tint feeling alive and powered-on. Opacity only.
|
||||||
|
*/
|
||||||
|
export const animScreenGlow = keyframes({
|
||||||
|
'0%': { opacity: '0.72' },
|
||||||
|
'50%': { opacity: '1' },
|
||||||
|
'100%': { opacity: '0.72' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A faint, irregular CRT brightness flicker laid over the glow — the classic
|
||||||
|
* unstable-tube shimmer. Kept extremely shallow so it never distracts or harms
|
||||||
|
* readability. Opacity only.
|
||||||
|
*/
|
||||||
|
export const animCrtFlicker = keyframes({
|
||||||
|
'0%': { opacity: '0.94' },
|
||||||
|
'12%': { opacity: '1' },
|
||||||
|
'20%': { opacity: '0.9' },
|
||||||
|
'34%': { opacity: '0.98' },
|
||||||
|
'52%': { opacity: '0.92' },
|
||||||
|
'70%': { opacity: '1' },
|
||||||
|
'83%': { opacity: '0.95' },
|
||||||
|
'100%': { opacity: '0.94' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chromatic-aberration twin: the magenta/cyan fringe layers nudge a sub-pixel
|
||||||
|
* apart and back so the edges shimmer with RGB split, like a misconverged tube.
|
||||||
|
* transform + opacity only.
|
||||||
|
*/
|
||||||
|
export const animChromaShift = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(0, 0, 0)', opacity: '0.5' },
|
||||||
|
'50%': { transform: 'translate3d(1.5px, 0, 0)', opacity: '0.8' },
|
||||||
|
'100%': { transform: 'translate3d(0, 0, 0)', opacity: '0.5' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pixel sparkle drift: a tiny neon speck rises and twinkles like a coin-burst
|
||||||
|
* particle floating up off the grid. transform + opacity, single tall path.
|
||||||
|
*/
|
||||||
|
export const animSparkleDrift = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(0, 0, 0) scale(0.6)', opacity: '0' },
|
||||||
|
'12%': { opacity: '1' },
|
||||||
|
'50%': { transform: 'translate3d(8px, -42vh, 0) scale(1)', opacity: '0.85' },
|
||||||
|
'78%': { transform: 'translate3d(-6px, -70vh, 0) scale(0.8)', opacity: '0.5' },
|
||||||
|
'92%': { opacity: '0.18' },
|
||||||
|
'100%': { transform: 'translate3d(6px, -92vh, 0) scale(0.55)', opacity: '0' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Independent pixel twinkle layered on the drift so specks blink on/off like a
|
||||||
|
* low-res sprite. Stepped opacity for a crisp 8-bit feel.
|
||||||
|
*/
|
||||||
|
export const animSparkleTwinkle = keyframes({
|
||||||
|
'0%, 44%': { opacity: '1' },
|
||||||
|
'50%, 94%': { opacity: '0.35' },
|
||||||
|
'100%': { opacity: '1' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "INSERT COIN" blink: the classic attract-mode pulse. Stepped so it reads as a
|
||||||
|
* hard retro blink rather than a soft fade, but with a brief bright swell.
|
||||||
|
* Opacity + a hair of scale for a CRT bloom feel.
|
||||||
|
*/
|
||||||
|
export const animCoinBlink = keyframes({
|
||||||
|
'0%': { opacity: '0.85', transform: 'translateX(-50%) scale(1)' },
|
||||||
|
'6%': { opacity: '1', transform: 'translateX(-50%) scale(1.015)' },
|
||||||
|
'12%': { opacity: '0.85', transform: 'translateX(-50%) scale(1)' },
|
||||||
|
'49%': { opacity: '0.85', transform: 'translateX(-50%) scale(1)' },
|
||||||
|
'50%': { opacity: '0', transform: 'translateX(-50%) scale(1)' },
|
||||||
|
'100%': { opacity: '0', transform: 'translateX(-50%) scale(1)' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Score-blip pulse for the corner HUD glyph: a quick pop then settle, like a
|
||||||
|
* counter ticking up. transform + opacity.
|
||||||
|
*/
|
||||||
|
export const animScoreBlip = keyframes({
|
||||||
|
'0%': { opacity: '0.4', transform: 'scale(1)' },
|
||||||
|
'50%': { opacity: '0.85', transform: 'scale(1.12)' },
|
||||||
|
'100%': { opacity: '0.4', transform: 'scale(1)' },
|
||||||
|
});
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { SeasonalOverlayProps } from '../types';
|
||||||
|
import {
|
||||||
|
animGridScroll,
|
||||||
|
animScanRoll,
|
||||||
|
animScreenGlow,
|
||||||
|
animCrtFlicker,
|
||||||
|
animChromaShift,
|
||||||
|
animSparkleDrift,
|
||||||
|
animSparkleTwinkle,
|
||||||
|
animCoinBlink,
|
||||||
|
animScoreBlip,
|
||||||
|
} from './Arcade.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ArcadeOverlay — retro synthwave CRT.
|
||||||
|
*
|
||||||
|
* A full-screen, pointer-events:none ambient decoration. The parent supplies a
|
||||||
|
* fixed inset:0 overflow:hidden pointer-events:none container at the correct
|
||||||
|
* z-index, so this component only returns absolutely-positioned aria-hidden
|
||||||
|
* children and never sets position:fixed / z-index / pointer-events.
|
||||||
|
*
|
||||||
|
* Composition (back to front):
|
||||||
|
* 1. near-black synthwave ambient wash (magenta sky-glow up top, cyan/purple
|
||||||
|
* pool toward the floor) — layered oklch gradients for depth
|
||||||
|
* 2. a neon perspective grid receding to a vanishing point on the horizon,
|
||||||
|
* scrolling toward the viewer via transform translateY (never bg-position)
|
||||||
|
* 3. a soft horizon sun-glow + thin neon horizon line where the grid meets sky
|
||||||
|
* 4. drifting pixel sparkles / neon coin-burst specks rising off the grid
|
||||||
|
* 5. fine CRT scanlines, gently rolling
|
||||||
|
* 6. a faint chromatic-aberration fringe at the screen edges
|
||||||
|
* 7. a glowing "INSERT COIN" blip + a corner SCORE HUD glyph
|
||||||
|
* 8. a CRT vignette + screen-glow that frames and protects central text
|
||||||
|
*
|
||||||
|
* All motion is transform/opacity only (compositor-friendly). When `reduced` is
|
||||||
|
* true we render a static-but-gorgeous scene: a still neon grid, steady
|
||||||
|
* scanlines + vignette, and a steady "INSERT COIN" — no `animation` anywhere,
|
||||||
|
* no flicker. The settings preview always passes reduced=true, so the still
|
||||||
|
* form stands on its own.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Synthwave neon palette in oklch. Saturated where it glows, but every layer is
|
||||||
|
// held at low opacity so it tints rather than takes over the chat beneath.
|
||||||
|
const NEON_MAGENTA = 'oklch(0.65 0.25 350)';
|
||||||
|
const NEON_CYAN = 'oklch(0.80 0.15 200)';
|
||||||
|
const GRID_PURPLE = 'oklch(0.45 0.18 300)';
|
||||||
|
|
||||||
|
// The receding grid as an inline SVG data-URI (CSP-safe, no external assets).
|
||||||
|
// It is a 1x2 vertical tile of horizontal rule lines + a single set of vertical
|
||||||
|
// lines fanning toward a top-center vanishing point. The plane is then placed
|
||||||
|
// under a CSS `perspective` rotateX so the lines genuinely recede. Scrolling the
|
||||||
|
// tile up by one tile-height (animGridScroll → translateY 50%) loops seamlessly.
|
||||||
|
function gridDataUri(): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
// Horizontal rules — denser toward the top (the horizon) for a perspective
|
||||||
|
// feel even before the CSS rotateX is applied.
|
||||||
|
const rows = [0, 16, 34, 54, 76, 100, 126, 156, 190, 228, 270, 316, 366, 420, 478, 540];
|
||||||
|
rows.forEach((y) => {
|
||||||
|
lines.push(
|
||||||
|
`<line x1='0' y1='${y}' x2='600' y2='${y}' stroke='${GRID_PURPLE}' ` +
|
||||||
|
`stroke-width='1.4' stroke-opacity='0.9'/>`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
// Vertical lines fanning out from the top-center vanishing point.
|
||||||
|
for (let i = -7; i <= 7; i += 1) {
|
||||||
|
const topX = 300 + i * 6; // tight near the horizon
|
||||||
|
const botX = 300 + i * 95; // wide at the foreground
|
||||||
|
lines.push(
|
||||||
|
`<line x1='${topX}' y1='0' x2='${botX}' y2='600' stroke='${GRID_PURPLE}' ` +
|
||||||
|
`stroke-width='1.4' stroke-opacity='0.8'/>`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const svg =
|
||||||
|
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 600 600' ` +
|
||||||
|
`preserveAspectRatio='none'>${lines.join('')}</svg>`;
|
||||||
|
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Sparkle = {
|
||||||
|
left: number; // vw
|
||||||
|
bottom: number; // % up from floor where it spawns
|
||||||
|
size: number; // px
|
||||||
|
duration: number; // s
|
||||||
|
delay: number; // s
|
||||||
|
twinkle: number; // s
|
||||||
|
hue: 'magenta' | 'cyan';
|
||||||
|
opacity: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hand-placed still sparkles for the reduced/static scene — a few neon specks
|
||||||
|
// resting low over the grid, away from the busy chat center.
|
||||||
|
const RESTING_SPARKLES: ReadonlyArray<{
|
||||||
|
left: number;
|
||||||
|
bottom: number;
|
||||||
|
size: number;
|
||||||
|
hue: 'magenta' | 'cyan';
|
||||||
|
opacity: number;
|
||||||
|
}> = [
|
||||||
|
{ left: 12, bottom: 18, size: 4, hue: 'cyan', opacity: 0.5 },
|
||||||
|
{ left: 26, bottom: 30, size: 3, hue: 'magenta', opacity: 0.42 },
|
||||||
|
{ left: 78, bottom: 22, size: 4, hue: 'magenta', opacity: 0.5 },
|
||||||
|
{ left: 88, bottom: 34, size: 3, hue: 'cyan', opacity: 0.4 },
|
||||||
|
{ left: 50, bottom: 14, size: 3, hue: 'cyan', opacity: 0.38 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const GRID_URI = gridDataUri();
|
||||||
|
|
||||||
|
export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
|
||||||
|
// Deterministic sparkle field, computed ONCE. No per-frame state.
|
||||||
|
const sparkles = useMemo<Sparkle[]>(() => {
|
||||||
|
const COUNT = 16;
|
||||||
|
return Array.from({ length: COUNT }, (_, i) => ({
|
||||||
|
left: (i * 6.27 + 4) % 100,
|
||||||
|
bottom: (i * 3.7) % 28, // spawn in the lower third (over the grid)
|
||||||
|
size: 2 + (i % 3), // 2..4 px pixels
|
||||||
|
duration: 14 + (i % 6) * 2.2,
|
||||||
|
delay: -((i * 1.83) % 16),
|
||||||
|
twinkle: 1.4 + (i % 4) * 0.5,
|
||||||
|
hue: i % 2 === 0 ? 'cyan' : 'magenta',
|
||||||
|
opacity: 0.45 + (i % 3) * 0.12,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const sparkleColor = (hue: 'magenta' | 'cyan') => (hue === 'cyan' ? NEON_CYAN : NEON_MAGENTA);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 1. Near-black synthwave ambient wash. Magenta sky-glow up top, a
|
||||||
|
cyan/purple pool toward the floor, and an overall dark vertical
|
||||||
|
grade. Layered oklch gradients give depth at very low opacity. */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
backgroundImage: [
|
||||||
|
'radial-gradient(140% 80% at 50% -8%, oklch(0.65 0.25 350 / 0.16) 0%, transparent 55%)',
|
||||||
|
'radial-gradient(120% 70% at 50% 112%, oklch(0.45 0.18 300 / 0.20) 0%, transparent 60%)',
|
||||||
|
'linear-gradient(180deg, oklch(0.12 0.05 300 / 0.10) 0%, transparent 38%, oklch(0.10 0.06 310 / 0.16) 100%)',
|
||||||
|
].join(','),
|
||||||
|
contain: 'layout paint style',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 2. The neon perspective grid. A wide, tall plane is tilted away from
|
||||||
|
the viewer with `perspective` + rotateX so its rule lines recede to
|
||||||
|
a vanishing point at the top (the horizon). It lives in the lower
|
||||||
|
half of the screen — the "floor". The inner plane scrolls upward by
|
||||||
|
one tile via transform translateY, which reads as the grid flowing
|
||||||
|
toward the viewer. Pure transform; never background-position. */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: '-25%',
|
||||||
|
right: '-25%',
|
||||||
|
bottom: 0,
|
||||||
|
height: '62%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
perspective: '280px',
|
||||||
|
perspectiveOrigin: '50% 0%',
|
||||||
|
maskImage: 'linear-gradient(180deg, transparent 0%, #000 26%, #000 100%)',
|
||||||
|
WebkitMaskImage: 'linear-gradient(180deg, transparent 0%, #000 26%, #000 100%)',
|
||||||
|
opacity: reduced ? 0.5 : 0.62,
|
||||||
|
contain: 'layout paint style',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
height: '200%',
|
||||||
|
transformOrigin: 'top center',
|
||||||
|
transform: 'rotateX(74deg)',
|
||||||
|
backgroundImage: GRID_URI,
|
||||||
|
backgroundRepeat: 'repeat-y',
|
||||||
|
backgroundSize: '100% 50%',
|
||||||
|
filter: 'drop-shadow(0 0 3px oklch(0.55 0.22 320 / 0.6))',
|
||||||
|
willChange: reduced ? undefined : 'transform',
|
||||||
|
animation: reduced ? 'none' : `${animGridScroll} 7s linear infinite`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3. Horizon glow + neon horizon line. A soft synthwave sun-bloom sits
|
||||||
|
where the grid meets the sky, with a thin bright rule on top of it
|
||||||
|
to seal the vanishing point. Static (no motion) either way. */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: '50%',
|
||||||
|
top: '38%',
|
||||||
|
width: '70%',
|
||||||
|
height: '34%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
backgroundImage:
|
||||||
|
'radial-gradient(60% 100% at 50% 100%, oklch(0.70 0.22 350 / 0.22) 0%, oklch(0.65 0.18 330 / 0.10) 40%, transparent 72%)',
|
||||||
|
contain: 'layout paint style',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: '12%',
|
||||||
|
right: '12%',
|
||||||
|
top: '38%',
|
||||||
|
height: '1.5px',
|
||||||
|
background: `linear-gradient(90deg, transparent 0%, ${NEON_CYAN} 25%, oklch(0.92 0.10 320 / 0.95) 50%, ${NEON_CYAN} 75%, transparent 100%)`,
|
||||||
|
opacity: 0.55,
|
||||||
|
filter: 'blur(0.4px) drop-shadow(0 0 4px oklch(0.78 0.16 200 / 0.7))',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 4. Drifting pixel sparkles / neon coin-burst specks. Tiny square
|
||||||
|
neon pixels rising off the grid and twinkling. The static scene uses
|
||||||
|
a small resting set instead. */}
|
||||||
|
{reduced
|
||||||
|
? RESTING_SPARKLES.map((s, i) => (
|
||||||
|
<div
|
||||||
|
key={`rest-spark-${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${s.left}%`,
|
||||||
|
bottom: `${s.bottom}%`,
|
||||||
|
width: `${s.size}px`,
|
||||||
|
height: `${s.size}px`,
|
||||||
|
background: sparkleColor(s.hue),
|
||||||
|
opacity: s.opacity,
|
||||||
|
boxShadow: `0 0 6px ${sparkleColor(s.hue)}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
: sparkles.map((s, i) => (
|
||||||
|
<div
|
||||||
|
key={`spark-${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${s.left}%`,
|
||||||
|
bottom: `${s.bottom}%`,
|
||||||
|
width: `${s.size}px`,
|
||||||
|
height: `${s.size}px`,
|
||||||
|
willChange: 'transform, opacity',
|
||||||
|
animation: `${animSparkleDrift} ${s.duration}s linear ${s.delay}s infinite`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
background: sparkleColor(s.hue),
|
||||||
|
opacity: s.opacity,
|
||||||
|
boxShadow: `0 0 6px ${sparkleColor(s.hue)}`,
|
||||||
|
animation: `${animSparkleTwinkle} ${s.twinkle}s step-end infinite`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 5. Fine CRT scanlines. A repeating 1px dark rule field over the whole
|
||||||
|
screen, gently rolling downward on the compositor (transform only).
|
||||||
|
Held faint so text stays crisp. The pattern is in a child taller
|
||||||
|
than the frame so the roll never reveals an edge. */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
mixBlendMode: 'multiply',
|
||||||
|
opacity: 0.5,
|
||||||
|
contain: 'layout paint style',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: '-8px',
|
||||||
|
bottom: '-8px',
|
||||||
|
backgroundImage:
|
||||||
|
'repeating-linear-gradient(0deg, oklch(0.10 0.04 300 / 0.55) 0px, oklch(0.10 0.04 300 / 0.55) 1px, transparent 1px, transparent 3px)',
|
||||||
|
willChange: reduced ? undefined : 'transform',
|
||||||
|
animation: reduced ? 'none' : `${animScanRoll} 6s linear infinite`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 6. Chromatic-aberration fringe. Two thin edge-glows — magenta and cyan —
|
||||||
|
offset a sub-pixel apart at the screen border so the frame shimmers
|
||||||
|
with an RGB split, like a misconverged tube. Animated only; in the
|
||||||
|
static scene it sits as a steady fringe. */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
boxShadow: `inset 2px 0 14px oklch(0.65 0.25 350 / 0.16), inset -2px 0 14px oklch(0.80 0.15 200 / 0.16)`,
|
||||||
|
opacity: reduced ? 0.6 : undefined,
|
||||||
|
willChange: reduced ? undefined : 'transform, opacity',
|
||||||
|
contain: 'layout paint style',
|
||||||
|
animation: reduced ? 'none' : `${animChromaShift} 4.5s ease-in-out infinite`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 7a. Glowing "INSERT COIN" attract-mode blip, low-opacity, bottom-center.
|
||||||
|
Static scene shows it steady (no blink). */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '5%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
fontFamily: '"Courier New", monospace',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: '0.32em',
|
||||||
|
color: NEON_CYAN,
|
||||||
|
textShadow: '0 0 6px oklch(0.80 0.15 200 / 0.9), 0 0 14px oklch(0.65 0.25 350 / 0.5)',
|
||||||
|
userSelect: 'none',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
opacity: reduced ? 0.6 : undefined,
|
||||||
|
animation: reduced ? 'none' : `${animCoinBlink} 1.6s step-end infinite`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
INSERT COIN
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 7b. Corner SCORE HUD glyph — a tiny pixel score that blips, top-left,
|
||||||
|
very low opacity so it reads as ambient chrome, not UI. */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '2.5%',
|
||||||
|
left: '2%',
|
||||||
|
fontFamily: '"Courier New", monospace',
|
||||||
|
fontSize: '10px',
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: '0.18em',
|
||||||
|
color: NEON_MAGENTA,
|
||||||
|
textShadow: '0 0 6px oklch(0.65 0.25 350 / 0.8)',
|
||||||
|
userSelect: 'none',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
opacity: reduced ? 0.5 : undefined,
|
||||||
|
animation: reduced ? 'none' : `${animScoreBlip} 2.4s ease-in-out infinite`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
1UP 00<span style={{ color: NEON_CYAN }}>0000</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 8. CRT vignette + screen-glow. A radial darkening frames the corners,
|
||||||
|
with a faint magenta tube-glow swell. The vignette protects central
|
||||||
|
chat-text contrast. Static scene holds it steady; live scene adds a
|
||||||
|
shallow breathing glow + irregular flicker, both opacity-only. */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
backgroundImage: [
|
||||||
|
'radial-gradient(125% 95% at 50% 46%, oklch(0.72 0.18 340 / 0.05) 0%, transparent 40%)',
|
||||||
|
'radial-gradient(120% 115% at 50% 50%, transparent 50%, oklch(0.10 0.05 310 / 0.20) 84%, oklch(0.06 0.04 305 / 0.34) 100%)',
|
||||||
|
].join(','),
|
||||||
|
contain: 'layout paint style',
|
||||||
|
opacity: reduced ? 1 : undefined,
|
||||||
|
willChange: reduced ? undefined : 'opacity',
|
||||||
|
animation: reduced
|
||||||
|
? 'none'
|
||||||
|
: `${animScreenGlow} 8s ease-in-out infinite, ${animCrtFlicker} 5.5s steps(1, end) infinite`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { keyframes } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Autumn overlay keyframes. Every animation touches ONLY `transform` and
|
||||||
|
* `opacity` so the compositor can run them on the GPU without triggering
|
||||||
|
* layout or paint. keyframes() returns the generated animation-name string,
|
||||||
|
* which is applied inline in Autumn.tsx.
|
||||||
|
*
|
||||||
|
* Motion philosophy: warm, slow, cozy. Leaves tumble and rotate as they fall
|
||||||
|
* with a per-leaf sway decoupled on a wrapper; sun shafts breathe; dust motes
|
||||||
|
* drift up through the light; the whole frame has a barely-there warm pulse.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A leaf falls from above to below the viewport while continuously rotating.
|
||||||
|
* A single tall translateY serves every leaf — per-leaf duration/delay/scale
|
||||||
|
* create the parallax variety. Horizontal travel is intentionally small here
|
||||||
|
* because the real lateral motion comes from the sway wrapper below.
|
||||||
|
*/
|
||||||
|
export const animLeafFall = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(0, -12vh, 0) rotate(-30deg)', opacity: '0' },
|
||||||
|
'8%': { opacity: '1' },
|
||||||
|
'50%': { transform: 'translate3d(10px, 50vh, 0) rotate(200deg)' },
|
||||||
|
'92%': { opacity: '0.85' },
|
||||||
|
'100%': { transform: 'translate3d(-6px, 114vh, 0) rotate(430deg)', opacity: '0' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lateral sway applied to a leaf's wrapper so the descent reads as wind
|
||||||
|
* catching the blade. Decoupled from the fall so the two compose into an
|
||||||
|
* organic, non-repeating-looking path.
|
||||||
|
*/
|
||||||
|
export const animLeafSway = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(0, 0, 0)' },
|
||||||
|
'50%': { transform: 'translate3d(34px, 0, 0)' },
|
||||||
|
'100%': { transform: 'translate3d(0, 0, 0)' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A second flutter on the leaf's inner shape: a gentle skew/scale wobble that
|
||||||
|
* mimics the blade catching air as it spins. Cheap, transform-only.
|
||||||
|
*/
|
||||||
|
export const animLeafFlutter = keyframes({
|
||||||
|
'0%': { transform: 'rotate(-8deg) scaleX(1)' },
|
||||||
|
'50%': { transform: 'rotate(8deg) scaleX(0.82)' },
|
||||||
|
'100%': { transform: 'rotate(-8deg) scaleX(1)' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Low-sun light shaft: a long soft beam slowly slides and breathes. Uses
|
||||||
|
* translateX + opacity (never background-position) so it stays on the
|
||||||
|
* compositor. Scale on Y makes the beam subtly elongate as it brightens.
|
||||||
|
*/
|
||||||
|
export const animSunShaft = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(-4%, 0, 0) scaleY(1)', opacity: '0.4' },
|
||||||
|
'50%': { transform: 'translate3d(4%, 0, 0) scaleY(1.06)', opacity: '0.75' },
|
||||||
|
'100%': { transform: 'translate3d(-4%, 0, 0) scaleY(1)', opacity: '0.4' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dust / pollen mote: a tiny speck drifts upward through the light, swaying,
|
||||||
|
* pulsing softly in brightness as it catches the sun. transform + opacity.
|
||||||
|
*/
|
||||||
|
export const animMoteDrift = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(0, 0, 0) scale(0.7)', opacity: '0' },
|
||||||
|
'15%': { opacity: '0.85' },
|
||||||
|
'40%': { transform: 'translate3d(16px, -30vh, 0) scale(1)' },
|
||||||
|
'70%': { transform: 'translate3d(-12px, -58vh, 0) scale(0.85)', opacity: '0.6' },
|
||||||
|
'90%': { opacity: '0.2' },
|
||||||
|
'100%': { transform: 'translate3d(10px, -84vh, 0) scale(0.6)', opacity: '0' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Independent twinkle for motes — a brightness flicker layered on the drift so
|
||||||
|
* specks shimmer as if turning in the light. Opacity only.
|
||||||
|
*/
|
||||||
|
export const animMoteTwinkle = keyframes({
|
||||||
|
'0%': { opacity: '0.5' },
|
||||||
|
'50%': { opacity: '1' },
|
||||||
|
'100%': { opacity: '0.5' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Barely-there breathing of the warm vignette frame so the static tint feels
|
||||||
|
* alive without any distracting motion. Opacity only.
|
||||||
|
*/
|
||||||
|
export const animEmberPulse = keyframes({
|
||||||
|
'0%': { opacity: '0.82' },
|
||||||
|
'50%': { opacity: '1' },
|
||||||
|
'100%': { opacity: '0.82' },
|
||||||
|
});
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { SeasonalOverlayProps } from '../types';
|
||||||
|
import {
|
||||||
|
animLeafFall,
|
||||||
|
animLeafSway,
|
||||||
|
animLeafFlutter,
|
||||||
|
animSunShaft,
|
||||||
|
animMoteDrift,
|
||||||
|
animMoteTwinkle,
|
||||||
|
animEmberPulse,
|
||||||
|
} from './Autumn.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AutumnOverlay — warm falling leaves.
|
||||||
|
*
|
||||||
|
* A full-screen, pointer-events:none ambient decoration. The parent supplies a
|
||||||
|
* fixed inset:0 overflow:hidden pointer-events:none container at the correct
|
||||||
|
* z-index, so this component only returns absolutely-positioned aria-hidden
|
||||||
|
* children and never sets position:fixed / z-index / pointer-events.
|
||||||
|
*
|
||||||
|
* Composition (back to front):
|
||||||
|
* 1. amber -> rust ambient gradient wash (cozy low-sun atmosphere)
|
||||||
|
* 2. soft angled sun shafts breathing high across the scene
|
||||||
|
* 3. drifting pollen / dust motes catching the light
|
||||||
|
* 4. maple & oak leaf silhouettes tumbling and rotating as they fall
|
||||||
|
* 5. a warm low-saturation vignette that frames + protects text contrast
|
||||||
|
*
|
||||||
|
* All motion is transform/opacity only (compositor-friendly). When `reduced`
|
||||||
|
* is true we render a static-but-gorgeous scene: a handful of leaves at rest,
|
||||||
|
* still sun shafts, and the warm vignette — no `animation` anywhere. The
|
||||||
|
* settings preview always passes reduced=true, so the still form stands alone.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Warm autumn palette in oklch. Kept low-saturation enough to never fight the
|
||||||
|
// chat text underneath. Each leaf picks a tone for variety.
|
||||||
|
const LEAF_TONES = [
|
||||||
|
'oklch(0.75 0.15 70)', // amber
|
||||||
|
'oklch(0.55 0.16 40)', // rust
|
||||||
|
'oklch(0.82 0.13 85)', // warm gold
|
||||||
|
'oklch(0.62 0.16 55)', // burnt orange
|
||||||
|
'oklch(0.5 0.14 35)', // deep ember
|
||||||
|
];
|
||||||
|
|
||||||
|
// Two leaf silhouettes as inline SVG path data (no external assets, CSP-safe).
|
||||||
|
// `maple` = classic five-lobed maple; `oak` = rounded-lobe oak blade.
|
||||||
|
const MAPLE_PATH =
|
||||||
|
'M50 4 L57 30 L78 18 L66 40 L92 40 L70 52 L84 74 L58 62 L56 92 L50 70 ' +
|
||||||
|
'L44 92 L42 62 L16 74 L30 52 L8 40 L34 40 L22 18 L43 30 Z';
|
||||||
|
const OAK_PATH =
|
||||||
|
'M50 4 C58 14 56 22 64 24 C74 22 74 32 68 36 C78 38 76 48 68 50 ' +
|
||||||
|
'C78 54 74 64 66 64 C70 74 60 78 54 72 C54 84 50 96 50 96 ' +
|
||||||
|
'C50 96 46 84 46 72 C40 78 30 74 34 64 C26 64 22 54 32 50 ' +
|
||||||
|
'C24 48 22 38 32 36 C26 32 26 22 36 24 C44 22 42 14 50 4 Z';
|
||||||
|
|
||||||
|
/** Build a CSS-ready data-URI of a single tinted leaf silhouette. */
|
||||||
|
function leafDataUri(kind: 'maple' | 'oak', fill: string): string {
|
||||||
|
const path = kind === 'maple' ? MAPLE_PATH : OAK_PATH;
|
||||||
|
// A faint vein line gives the blade depth without extra DOM nodes.
|
||||||
|
const svg =
|
||||||
|
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'>` +
|
||||||
|
`<path d='${path}' fill='${fill}' fill-opacity='0.92'/>` +
|
||||||
|
`<path d='M50 96 L50 24' stroke='oklch(0.42 0.12 38)' stroke-opacity='0.35' ` +
|
||||||
|
`stroke-width='2.5' fill='none' stroke-linecap='round'/>` +
|
||||||
|
`</svg>`;
|
||||||
|
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Leaf = {
|
||||||
|
kind: 'maple' | 'oak';
|
||||||
|
uri: string;
|
||||||
|
left: number; // vw column
|
||||||
|
size: number; // px
|
||||||
|
duration: number; // s — fall time
|
||||||
|
delay: number; // s
|
||||||
|
swayDuration: number; // s — wrapper sway
|
||||||
|
flutterDuration: number; // s — inner flutter
|
||||||
|
tilt: number; // deg — resting rotation (used for static scene)
|
||||||
|
opacity: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Mote = {
|
||||||
|
left: number;
|
||||||
|
bottom: number;
|
||||||
|
size: number;
|
||||||
|
duration: number;
|
||||||
|
delay: number;
|
||||||
|
twinkle: number;
|
||||||
|
opacity: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// A few hand-placed leaves at rest for the reduced/static scene — arranged so
|
||||||
|
// they read as "settled" near edges and corners, never over the busy center.
|
||||||
|
const RESTING_LEAVES: ReadonlyArray<{
|
||||||
|
kind: 'maple' | 'oak';
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
size: number;
|
||||||
|
tilt: number;
|
||||||
|
tone: number;
|
||||||
|
opacity: number;
|
||||||
|
}> = [
|
||||||
|
{ kind: 'maple', left: 6, top: 14, size: 46, tilt: -22, tone: 0, opacity: 0.4 },
|
||||||
|
{ kind: 'oak', left: 88, top: 22, size: 38, tilt: 28, tone: 1, opacity: 0.34 },
|
||||||
|
{ kind: 'maple', left: 16, top: 78, size: 54, tilt: 16, tone: 3, opacity: 0.42 },
|
||||||
|
{ kind: 'oak', left: 80, top: 82, size: 44, tilt: -34, tone: 4, opacity: 0.36 },
|
||||||
|
{ kind: 'maple', left: 50, top: 90, size: 40, tilt: 8, tone: 2, opacity: 0.32 },
|
||||||
|
{ kind: 'oak', left: 70, top: 8, size: 32, tilt: -12, tone: 2, opacity: 0.3 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function AutumnOverlay({ reduced }: SeasonalOverlayProps) {
|
||||||
|
// Deterministic pseudo-random field, computed ONCE. No per-frame state.
|
||||||
|
const { leaves, motes } = useMemo(() => {
|
||||||
|
const LEAF_COUNT = 16;
|
||||||
|
const MOTE_COUNT = 12;
|
||||||
|
|
||||||
|
const builtLeaves: Leaf[] = Array.from({ length: LEAF_COUNT }, (_, i) => {
|
||||||
|
const kind: 'maple' | 'oak' = i % 3 === 0 ? 'oak' : 'maple';
|
||||||
|
const tone = LEAF_TONES[i % LEAF_TONES.length];
|
||||||
|
const sizeBucket = i % 4; // 0..3 → depth bucket for parallax
|
||||||
|
const size = 22 + sizeBucket * 9; // 22..49 px
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
uri: leafDataUri(kind, tone),
|
||||||
|
left: (i * 6.13 + 4) % 100,
|
||||||
|
size,
|
||||||
|
// Larger (nearer) leaves fall a touch faster; all slow + cozy.
|
||||||
|
duration: 16 - sizeBucket * 1.6 + (i % 3) * 1.3,
|
||||||
|
delay: -((i * 1.37) % 16), // negative → staggered, already mid-fall
|
||||||
|
swayDuration: 5 + (i % 5) * 0.8,
|
||||||
|
flutterDuration: 1.6 + (i % 4) * 0.45,
|
||||||
|
tilt: ((i * 53) % 70) - 35,
|
||||||
|
opacity: 0.34 + sizeBucket * 0.08, // nearer → slightly bolder
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const builtMotes: Mote[] = Array.from({ length: MOTE_COUNT }, (_, i) => ({
|
||||||
|
left: (i * 8.7 + 5) % 100,
|
||||||
|
bottom: (i * 4.3) % 30, // start in lower third, drift up
|
||||||
|
size: 2 + (i % 3),
|
||||||
|
duration: 16 + (i % 6) * 2.4,
|
||||||
|
delay: -((i * 2.1) % 16),
|
||||||
|
twinkle: 2.2 + (i % 4) * 0.6,
|
||||||
|
opacity: 0.4 + (i % 3) * 0.12,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { leaves: builtLeaves, motes: builtMotes };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 1. Ambient amber → rust atmospheric wash. Layered oklch gradients give
|
||||||
|
depth: a warm low-sun glow from the upper-left, a rust pool at the
|
||||||
|
base, and a faint gold core. Kept very low opacity. */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
backgroundImage: [
|
||||||
|
'radial-gradient(120% 90% at 18% 8%, oklch(0.82 0.13 85 / 0.16) 0%, transparent 55%)',
|
||||||
|
'radial-gradient(130% 100% at 50% 118%, oklch(0.55 0.16 40 / 0.18) 0%, transparent 60%)',
|
||||||
|
'linear-gradient(180deg, oklch(0.75 0.15 70 / 0.07) 0%, transparent 40%, oklch(0.5 0.14 35 / 0.08) 100%)',
|
||||||
|
].join(','),
|
||||||
|
contain: 'layout paint style',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 2. Soft angled low-sun light shafts. Two long beams skewed to suggest
|
||||||
|
late-afternoon light raking across the room. */}
|
||||||
|
{[
|
||||||
|
{ left: -8, rotate: 18, w: 38, opacity: 0.5, dur: 17, delay: 0 },
|
||||||
|
{ left: 46, rotate: 14, w: 30, opacity: 0.38, dur: 22, delay: -6 },
|
||||||
|
{ left: 78, rotate: 22, w: 26, opacity: 0.32, dur: 19, delay: -11 },
|
||||||
|
].map((shaft, i) => (
|
||||||
|
<div
|
||||||
|
key={`shaft-${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '-30%',
|
||||||
|
left: `${shaft.left}%`,
|
||||||
|
width: `${shaft.w}vw`,
|
||||||
|
height: '160%',
|
||||||
|
transformOrigin: 'top center',
|
||||||
|
transform: `rotate(${shaft.rotate}deg)`,
|
||||||
|
backgroundImage:
|
||||||
|
'linear-gradient(90deg, transparent 0%, oklch(0.85 0.12 82 / 0.5) 50%, transparent 100%)',
|
||||||
|
filter: 'blur(14px)',
|
||||||
|
mixBlendMode: 'screen',
|
||||||
|
opacity: reduced ? shaft.opacity * 0.85 : undefined,
|
||||||
|
willChange: reduced ? undefined : 'transform, opacity',
|
||||||
|
contain: 'layout paint style',
|
||||||
|
animation: reduced
|
||||||
|
? 'none'
|
||||||
|
: `${animSunShaft} ${shaft.dur}s ease-in-out ${shaft.delay}s infinite`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 3. Drifting pollen / dust motes catching the light. Static scene omits
|
||||||
|
them — stillness reads cleaner at rest. */}
|
||||||
|
{!reduced &&
|
||||||
|
motes.map((m, i) => (
|
||||||
|
<div
|
||||||
|
key={`mote-${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${m.left}%`,
|
||||||
|
bottom: `${m.bottom}%`,
|
||||||
|
width: `${m.size}px`,
|
||||||
|
height: `${m.size}px`,
|
||||||
|
willChange: 'transform, opacity',
|
||||||
|
animation: `${animMoteDrift} ${m.duration}s linear ${m.delay}s infinite`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background:
|
||||||
|
'radial-gradient(circle, oklch(0.88 0.1 85 / 0.95) 0%, oklch(0.78 0.12 70 / 0.4) 60%, transparent 100%)',
|
||||||
|
opacity: m.opacity,
|
||||||
|
animation: `${animMoteTwinkle} ${m.twinkle}s ease-in-out infinite`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 4. Falling / resting maple & oak leaves. */}
|
||||||
|
{reduced
|
||||||
|
? RESTING_LEAVES.map((leaf, i) => (
|
||||||
|
<div
|
||||||
|
key={`rest-${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${leaf.left}%`,
|
||||||
|
top: `${leaf.top}%`,
|
||||||
|
width: `${leaf.size}px`,
|
||||||
|
height: `${leaf.size}px`,
|
||||||
|
backgroundImage: leafDataUri(leaf.kind, LEAF_TONES[leaf.tone]),
|
||||||
|
backgroundSize: 'contain',
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
transform: `translate(-50%, -50%) rotate(${leaf.tilt}deg)`,
|
||||||
|
opacity: leaf.opacity,
|
||||||
|
filter: 'drop-shadow(0 2px 3px oklch(0.3 0.08 40 / 0.35))',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
: leaves.map((leaf, i) => (
|
||||||
|
// Sway wrapper: horizontal wind motion, decoupled from the fall.
|
||||||
|
<div
|
||||||
|
key={`leaf-${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: `${leaf.left}%`,
|
||||||
|
width: `${leaf.size}px`,
|
||||||
|
height: `${leaf.size}px`,
|
||||||
|
willChange: 'transform',
|
||||||
|
contain: 'layout paint style',
|
||||||
|
animation: `${animLeafSway} ${leaf.swayDuration}s ease-in-out ${leaf.delay}s infinite`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Fall wrapper: vertical descent + tumble rotation. */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
willChange: 'transform, opacity',
|
||||||
|
animation: `${animLeafFall} ${leaf.duration}s linear ${leaf.delay}s infinite`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Inner blade: the actual silhouette + flutter wobble. */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
backgroundImage: leaf.uri,
|
||||||
|
backgroundSize: 'contain',
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
opacity: leaf.opacity,
|
||||||
|
filter: 'drop-shadow(0 1px 2px oklch(0.3 0.08 40 / 0.3))',
|
||||||
|
animation: `${animLeafFlutter} ${leaf.flutterDuration}s ease-in-out infinite`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 5. Warm low-saturation vignette. Frames the scene and gently darkens
|
||||||
|
edges — protecting central chat text contrast. Breathes faintly. */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
backgroundImage:
|
||||||
|
'radial-gradient(120% 110% at 50% 45%, transparent 52%, oklch(0.38 0.07 45 / 0.14) 82%, oklch(0.3 0.06 40 / 0.22) 100%)',
|
||||||
|
contain: 'layout paint style',
|
||||||
|
opacity: reduced ? 1 : undefined,
|
||||||
|
animation: reduced ? 'none' : `${animEmberPulse} 9s ease-in-out infinite`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { keyframes } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snowfall — a flake drifts downward while swaying horizontally and slowly
|
||||||
|
* rotating. GPU-only: animates transform + opacity exclusively. The vertical
|
||||||
|
* travel uses a tall translateY so a single keyframe set serves all flakes;
|
||||||
|
* per-flake duration/delay/scale create the parallax variety.
|
||||||
|
*/
|
||||||
|
export const animSnowFall = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(0, -8vh, 0) rotate(0deg)', opacity: '0' },
|
||||||
|
'8%': { opacity: '1' },
|
||||||
|
'50%': { transform: 'translate3d(14px, 50vh, 0) rotate(180deg)' },
|
||||||
|
'92%': { opacity: '0.85' },
|
||||||
|
'100%': { transform: 'translate3d(-10px, 112vh, 0) rotate(360deg)', opacity: '0' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gentle lateral sway applied to a flake's wrapper so the drift reads as wind,
|
||||||
|
* decoupled from the fall so the two combine into an organic path.
|
||||||
|
*/
|
||||||
|
export const animSnowSway = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(0, 0, 0)' },
|
||||||
|
'50%': { transform: 'translate3d(18px, 0, 0)' },
|
||||||
|
'100%': { transform: 'translate3d(0, 0, 0)' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* String-light breathing — bokeh orbs softly pulse in brightness and scale,
|
||||||
|
* like incandescent bulbs warming and cooling. Opacity + transform only.
|
||||||
|
*/
|
||||||
|
export const animBulbBreathe = keyframes({
|
||||||
|
'0%': { transform: 'scale(0.92)', opacity: '0.55' },
|
||||||
|
'50%': { transform: 'scale(1.08)', opacity: '0.95' },
|
||||||
|
'100%': { transform: 'scale(0.92)', opacity: '0.55' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aurora shimmer — a wide soft band high in the scene slowly slides and
|
||||||
|
* breathes. Uses translateX + opacity (never background-position) so it stays
|
||||||
|
* on the compositor.
|
||||||
|
*/
|
||||||
|
export const animAurora = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(-6%, 0, 0) scaleY(1)', opacity: '0.5' },
|
||||||
|
'50%': { transform: 'translate3d(6%, 0, 0) scaleY(1.08)', opacity: '0.8' },
|
||||||
|
'100%': { transform: 'translate3d(-6%, 0, 0) scaleY(1)', opacity: '0.5' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vignette frost — a barely-there breathing of the cold frame so the static
|
||||||
|
* tint feels alive without distracting motion.
|
||||||
|
*/
|
||||||
|
export const animFrostPulse = keyframes({
|
||||||
|
'0%': { opacity: '0.85' },
|
||||||
|
'50%': { opacity: '1' },
|
||||||
|
'100%': { opacity: '0.85' },
|
||||||
|
});
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { SeasonalOverlayProps } from '../types';
|
||||||
|
import {
|
||||||
|
animSnowFall,
|
||||||
|
animSnowSway,
|
||||||
|
animBulbBreathe,
|
||||||
|
animAurora,
|
||||||
|
animFrostPulse,
|
||||||
|
} from './Christmas.css';
|
||||||
|
|
||||||
|
// Deterministic pseudo-random so the scene is identical every mount (no React
|
||||||
|
// state per frame). Large primes keep the distribution well spread.
|
||||||
|
const rand = (seed: number) => {
|
||||||
|
const x = Math.sin(seed * 127.1 + 311.7) * 43758.5453;
|
||||||
|
return x - Math.floor(x);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Warm incandescent string-light hues in oklch — gold, soft red, cool white,
|
||||||
|
// pine green, icy blue. Kept luminous and gentle so they read as bokeh glow.
|
||||||
|
const BULB_COLORS = [
|
||||||
|
'oklch(0.85 0.12 85)', // warm gold
|
||||||
|
'oklch(0.72 0.15 28)', // soft red
|
||||||
|
'oklch(0.95 0.03 230)', // icy white
|
||||||
|
'oklch(0.78 0.13 150)', // pine green
|
||||||
|
'oklch(0.8 0.1 235)', // cool blue
|
||||||
|
];
|
||||||
|
|
||||||
|
type Flake = {
|
||||||
|
left: number;
|
||||||
|
size: number;
|
||||||
|
duration: number;
|
||||||
|
delay: number;
|
||||||
|
swayDuration: number;
|
||||||
|
opacity: number;
|
||||||
|
blur: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Bulb = {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
size: number;
|
||||||
|
color: string;
|
||||||
|
duration: number;
|
||||||
|
delay: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ChristmasOverlay({ reduced }: SeasonalOverlayProps) {
|
||||||
|
// Three parallax bands of snow: far (small/slow/dim) -> near (large/fast).
|
||||||
|
const flakes = useMemo<Flake[]>(() => {
|
||||||
|
const bands = [
|
||||||
|
{ count: 12, size: [1.5, 2.5], dur: [16, 22], op: [0.35, 0.55], blur: 0.6 },
|
||||||
|
{ count: 10, size: [2.5, 4], dur: [11, 15], op: [0.55, 0.8], blur: 0.3 },
|
||||||
|
{ count: 8, size: [4, 6.5], dur: [8, 11], op: [0.7, 0.95], blur: 0 },
|
||||||
|
];
|
||||||
|
const out: Flake[] = [];
|
||||||
|
let s = 1;
|
||||||
|
bands.forEach((b) => {
|
||||||
|
for (let i = 0; i < b.count; i += 1) {
|
||||||
|
const r1 = rand(s);
|
||||||
|
const r2 = rand(s + 0.37);
|
||||||
|
const r3 = rand(s + 0.71);
|
||||||
|
const r4 = rand(s + 0.91);
|
||||||
|
out.push({
|
||||||
|
left: r1 * 100,
|
||||||
|
size: b.size[0] + r2 * (b.size[1] - b.size[0]),
|
||||||
|
duration: b.dur[0] + r3 * (b.dur[1] - b.dur[0]),
|
||||||
|
delay: -r4 * (b.dur[1] + 4),
|
||||||
|
swayDuration: 4 + r2 * 5,
|
||||||
|
opacity: b.op[0] + r3 * (b.op[1] - b.op[0]),
|
||||||
|
blur: b.blur,
|
||||||
|
});
|
||||||
|
s += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Bokeh string lights strung along the very top edge, gently sagging.
|
||||||
|
const bulbs = useMemo<Bulb[]>(() => {
|
||||||
|
const count = 9;
|
||||||
|
const out: Bulb[] = [];
|
||||||
|
for (let i = 0; i < count; i += 1) {
|
||||||
|
const t = i / (count - 1);
|
||||||
|
// Two-segment garland sag so the lights drape rather than sit in a line.
|
||||||
|
const sag = Math.sin(t * Math.PI * 2) * 3.2;
|
||||||
|
out.push({
|
||||||
|
left: 4 + t * 92,
|
||||||
|
top: 2.5 + Math.abs(Math.sin(t * Math.PI)) * 2 + sag,
|
||||||
|
size: 12 + rand(i + 5) * 8,
|
||||||
|
color: BULB_COLORS[i % BULB_COLORS.length],
|
||||||
|
duration: 3.4 + rand(i + 2) * 2.6,
|
||||||
|
delay: -rand(i + 9) * 3,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Deep night-blue ambient wash — layered radial + linear oklch gradients
|
||||||
|
for depth. Kept low-opacity so chat text stays legible (WCAG-AA). */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
contain: 'layout paint style',
|
||||||
|
backgroundImage: [
|
||||||
|
'radial-gradient(120% 80% at 50% -10%, oklch(0.25 0.07 250 / 0.16) 0%, transparent 55%)',
|
||||||
|
'radial-gradient(90% 60% at 85% 110%, oklch(0.3 0.06 255 / 0.1) 0%, transparent 60%)',
|
||||||
|
'linear-gradient(180deg, oklch(0.95 0.03 230 / 0.05) 0%, transparent 22%, transparent 80%, oklch(0.22 0.07 255 / 0.08) 100%)',
|
||||||
|
].join(','),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Frosted vignette frame — cold edges, clear center. backdrop-filter on a
|
||||||
|
single cheap layer for a faint icy haze around the rim. */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
contain: 'layout paint style',
|
||||||
|
backdropFilter: 'blur(0.4px) saturate(1.04)',
|
||||||
|
WebkitBackdropFilter: 'blur(0.4px) saturate(1.04)',
|
||||||
|
backgroundImage:
|
||||||
|
'radial-gradient(135% 120% at 50% 42%, transparent 52%, oklch(0.9 0.04 225 / 0.07) 74%, oklch(0.28 0.07 250 / 0.16) 100%)',
|
||||||
|
animation: reduced ? 'none' : `${animFrostPulse} 12s ease-in-out infinite`,
|
||||||
|
willChange: reduced ? undefined : 'opacity',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Aurora shimmer band high up — soft conic-ish wash of icy blue/green. */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '-6%',
|
||||||
|
left: '-10%',
|
||||||
|
right: '-10%',
|
||||||
|
height: '40%',
|
||||||
|
contain: 'layout paint style',
|
||||||
|
mixBlendMode: 'screen',
|
||||||
|
filter: 'blur(26px)',
|
||||||
|
opacity: reduced ? 0.6 : undefined,
|
||||||
|
backgroundImage: [
|
||||||
|
'radial-gradient(60% 100% at 30% 0%, oklch(0.85 0.12 165 / 0.18) 0%, transparent 70%)',
|
||||||
|
'radial-gradient(55% 100% at 68% 0%, oklch(0.8 0.1 235 / 0.16) 0%, transparent 72%)',
|
||||||
|
'radial-gradient(50% 100% at 50% 0%, oklch(0.9 0.06 280 / 0.1) 0%, transparent 75%)',
|
||||||
|
].join(','),
|
||||||
|
animation: reduced ? 'none' : `${animAurora} 18s ease-in-out infinite`,
|
||||||
|
willChange: reduced ? undefined : 'transform, opacity',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* String-light wire — a faint catenary line the bulbs hang from. */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: '14%',
|
||||||
|
contain: 'layout paint style',
|
||||||
|
backgroundImage:
|
||||||
|
'radial-gradient(140% 60% at 50% -30%, oklch(0.3 0.04 250 / 0.14) 0%, transparent 70%)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Bokeh string lights — soft blurred orbs that breathe. */}
|
||||||
|
{bulbs.map((b, i) => (
|
||||||
|
<div
|
||||||
|
key={`bulb-${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${b.left}%`,
|
||||||
|
top: `${b.top}%`,
|
||||||
|
width: `${b.size}px`,
|
||||||
|
height: `${b.size}px`,
|
||||||
|
marginLeft: `${-b.size / 2}px`,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: `radial-gradient(circle at 38% 34%, oklch(0.98 0.02 95 / 0.95) 0%, ${b.color} 38%, transparent 72%)`,
|
||||||
|
boxShadow: `0 0 ${b.size}px ${b.size * 0.45}px ${b.color.replace(')', ' / 0.45)')}`,
|
||||||
|
filter: 'blur(0.5px)',
|
||||||
|
opacity: reduced ? 0.9 : undefined,
|
||||||
|
animation: reduced
|
||||||
|
? 'none'
|
||||||
|
: `${animBulbBreathe} ${b.duration}s ease-in-out ${b.delay}s infinite`,
|
||||||
|
willChange: reduced ? undefined : 'transform, opacity',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Snowfall (motion only) — three parallax bands. Static dusting below. */}
|
||||||
|
{!reduced &&
|
||||||
|
flakes.map((f, i) => (
|
||||||
|
<div
|
||||||
|
key={`snow-${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: `${f.left}%`,
|
||||||
|
width: `${f.size}px`,
|
||||||
|
height: `${f.size}px`,
|
||||||
|
animation: `${animSnowSway} ${f.swayDuration}s ease-in-out ${f.delay}s infinite`,
|
||||||
|
willChange: 'transform',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background:
|
||||||
|
'radial-gradient(circle at 35% 35%, oklch(0.99 0.01 230 / 0.95) 0%, oklch(0.95 0.03 230 / 0.7) 60%, transparent 100%)',
|
||||||
|
boxShadow: '0 0 4px oklch(0.9 0.05 235 / 0.55)',
|
||||||
|
opacity: f.opacity,
|
||||||
|
filter: f.blur ? `blur(${f.blur}px)` : undefined,
|
||||||
|
animation: `${animSnowFall} ${f.duration}s linear ${f.delay}s infinite`,
|
||||||
|
willChange: 'transform, opacity',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Static dusting of snow for the reduced-motion / preview scene — a
|
||||||
|
sparse scatter so the thumbnail still reads as snowfall. */}
|
||||||
|
{reduced &&
|
||||||
|
flakes.map((f, i) => {
|
||||||
|
const fy = rand(i + 0.5) * 96 + 2;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`snow-static-${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${f.left}%`,
|
||||||
|
top: `${fy}%`,
|
||||||
|
width: `${f.size}px`,
|
||||||
|
height: `${f.size}px`,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background:
|
||||||
|
'radial-gradient(circle at 35% 35%, oklch(0.99 0.01 230 / 0.95) 0%, oklch(0.95 0.03 230 / 0.7) 60%, transparent 100%)',
|
||||||
|
boxShadow: '0 0 4px oklch(0.9 0.05 235 / 0.5)',
|
||||||
|
opacity: f.opacity,
|
||||||
|
filter: f.blur ? `blur(${f.blur}px)` : undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { keyframes } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deep Space overlay keyframes. Everything here animates ONLY transform/opacity
|
||||||
|
* so the compositor can run it cheaply. The `keyframes()` helper returns the
|
||||||
|
* generated class name string, which the component splices into inline
|
||||||
|
* `animation` shorthands.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Cosmos breathe: the whole nebula backdrop drifts and dims almost imperceptibly. */
|
||||||
|
export const animCosmosDrift = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(0, 0, 0) scale(1)', opacity: '0.9' },
|
||||||
|
'50%': { transform: 'translate3d(-1.5%, 1%, 0) scale(1.04)', opacity: '1' },
|
||||||
|
'100%': { transform: 'translate3d(0, 0, 0) scale(1)', opacity: '0.9' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Nebula cloud drift: a single blurred cloud floats slowly across its layer. */
|
||||||
|
export const animNebulaA = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(0, 0, 0) scale(1)' },
|
||||||
|
'50%': { transform: 'translate3d(4%, -3%, 0) scale(1.08)' },
|
||||||
|
'100%': { transform: 'translate3d(0, 0, 0) scale(1)' },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const animNebulaB = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(0, 0, 0) scale(1.05)' },
|
||||||
|
'50%': { transform: 'translate3d(-5%, 2.5%, 0) scale(1)' },
|
||||||
|
'100%': { transform: 'translate3d(0, 0, 0) scale(1.05)' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Galaxy spiral: an exceptionally slow rotation of a distant pinwheel. */
|
||||||
|
export const animGalaxySpin = keyframes({
|
||||||
|
'0%': { transform: 'rotate(0deg) scale(1)' },
|
||||||
|
'50%': { transform: 'rotate(180deg) scale(1.03)' },
|
||||||
|
'100%': { transform: 'rotate(360deg) scale(1)' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Tiny star twinkle: gentle opacity + micro-scale pulse. */
|
||||||
|
export const animTwinkle = keyframes({
|
||||||
|
'0%': { transform: 'scale(0.85)', opacity: '0.35' },
|
||||||
|
'50%': { transform: 'scale(1)', opacity: '1' },
|
||||||
|
'100%': { transform: 'scale(0.85)', opacity: '0.35' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Bright star pulse: a slower, fuller bloom for the few hero stars. */
|
||||||
|
export const animStarPulse = keyframes({
|
||||||
|
'0%': { transform: 'scale(0.8) rotate(0deg)', opacity: '0.55' },
|
||||||
|
'50%': { transform: 'scale(1.15) rotate(45deg)', opacity: '1' },
|
||||||
|
'100%': { transform: 'scale(0.8) rotate(0deg)', opacity: '0.55' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Parallax depth: a star layer drifts as if the viewer is gliding through space. */
|
||||||
|
export const animParallaxNear = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(0, 0, 0)' },
|
||||||
|
'100%': { transform: 'translate3d(-3%, 1.5%, 0)' },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const animParallaxFar = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(0, 0, 0)' },
|
||||||
|
'100%': { transform: 'translate3d(-1.2%, 0.6%, 0)' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Comet streak: a thin meteor crosses the field on a diagonal, fading in then
|
||||||
|
* out. The element is rotated by the component; this only translates along its
|
||||||
|
* own local X axis (its length direction) and fades.
|
||||||
|
*/
|
||||||
|
export const animComet = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(0, 0, 0)', opacity: '0' },
|
||||||
|
'6%': { opacity: '1' },
|
||||||
|
'40%': { opacity: '0.9' },
|
||||||
|
'60%': { transform: 'translate3d(150%, 0, 0)', opacity: '0' },
|
||||||
|
'100%': { transform: 'translate3d(150%, 0, 0)', opacity: '0' },
|
||||||
|
});
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { SeasonalOverlayProps } from '../types';
|
||||||
|
import {
|
||||||
|
animCosmosDrift,
|
||||||
|
animNebulaA,
|
||||||
|
animNebulaB,
|
||||||
|
animGalaxySpin,
|
||||||
|
animTwinkle,
|
||||||
|
animStarPulse,
|
||||||
|
animParallaxNear,
|
||||||
|
animParallaxFar,
|
||||||
|
animComet,
|
||||||
|
} from './DeepSpace.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deep Space overlay — a cosmic, awe-inspiring ambient mode. Layered oklch
|
||||||
|
* radial gradients build a deep violet void seeded with drifting magenta/cyan
|
||||||
|
* nebula clouds and a faint distant galaxy spiral. A parallax starfield sits at
|
||||||
|
* two depths (a dense field of tiny twinkling stars plus a handful of brighter
|
||||||
|
* hero stars), and slow comet streaks cross the sky occasionally.
|
||||||
|
*
|
||||||
|
* Palette (oklch): deep cosmic violet oklch(0.2 0.12 300), nebula magenta
|
||||||
|
* oklch(0.55 0.2 330), cyan oklch(0.75 0.13 200), starlight white
|
||||||
|
* oklch(0.98 0.02 280).
|
||||||
|
*
|
||||||
|
* RENDERING CONTRACT: the parent supplies a fixed inset:0 overflow:hidden
|
||||||
|
* pointer-events:none container at the right z-index. We only return
|
||||||
|
* absolutely-positioned aria-hidden children at low opacity — no z-index,
|
||||||
|
* position:fixed, or pointer-events here — kept well below opaque so chat text
|
||||||
|
* stays WCAG-AA legible.
|
||||||
|
*
|
||||||
|
* REDUCED MOTION: when `reduced`, render a static but gorgeous scene (a still
|
||||||
|
* nebula, a static starfield, a frozen galaxy and one frozen comet streak) with
|
||||||
|
* no `animation` at all. The settings preview always passes reduced=true.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STAR_TINTS = [
|
||||||
|
'oklch(0.98 0.02 280)', // starlight white
|
||||||
|
'oklch(0.9 0.07 230)', // cool cyan-white
|
||||||
|
'oklch(0.88 0.08 330)', // faint magenta-white
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const HERO_TINTS = [
|
||||||
|
'oklch(0.92 0.06 200)', // cyan starlight
|
||||||
|
'oklch(0.9 0.09 330)', // magenta starlight
|
||||||
|
'oklch(0.98 0.02 280)', // pure starlight
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type Star = {
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
size: number;
|
||||||
|
color: string;
|
||||||
|
duration: number;
|
||||||
|
delay: number;
|
||||||
|
staticOpacity: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type HeroStar = Star;
|
||||||
|
|
||||||
|
type Comet = {
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
length: number;
|
||||||
|
angle: number;
|
||||||
|
color: string;
|
||||||
|
duration: number;
|
||||||
|
delay: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Deterministic pseudo-random so the memoized scene is stable across renders.
|
||||||
|
const rand = (seed: number) => {
|
||||||
|
const x = Math.sin(seed * 12.9898 + 78.233) * 43758.5453;
|
||||||
|
return x - Math.floor(x);
|
||||||
|
};
|
||||||
|
|
||||||
|
// A four-point gleam (sparkle) as an inline SVG data-URI — CSP-safe, no assets.
|
||||||
|
const gleamUri = (color: string) =>
|
||||||
|
`url("data:image/svg+xml,${encodeURIComponent(
|
||||||
|
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M12 0 C12.6 7.4 16.6 11.4 24 12 C16.6 12.6 12.6 16.6 12 24 C11.4 16.6 7.4 12.6 0 12 C7.4 11.4 11.4 7.4 12 0 Z' fill='${color}'/></svg>`,
|
||||||
|
)}")`;
|
||||||
|
|
||||||
|
function makeStars(count: number, seedBase: number): Star[] {
|
||||||
|
return Array.from({ length: count }, (_, i) => {
|
||||||
|
const s = seedBase + i;
|
||||||
|
return {
|
||||||
|
top: rand(s + 1) * 100,
|
||||||
|
left: rand(s + 101) * 100,
|
||||||
|
size: 1 + Math.floor(rand(s + 201) * 2), // 1–2px tiny stars
|
||||||
|
color: STAR_TINTS[i % STAR_TINTS.length],
|
||||||
|
duration: 2.6 + rand(s + 301) * 3.4,
|
||||||
|
delay: rand(s + 401) * 5,
|
||||||
|
staticOpacity: 0.4 + rand(s + 501) * 0.55,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeepSpaceOverlay({ reduced }: SeasonalOverlayProps) {
|
||||||
|
// Two parallax depths. Far = dense + faint, Near = sparser + slightly larger.
|
||||||
|
const farStars = useMemo<Star[]>(() => makeStars(16, 1000), []);
|
||||||
|
const nearStars = useMemo<Star[]>(() => makeStars(12, 2000), []);
|
||||||
|
|
||||||
|
const heroStars = useMemo<HeroStar[]>(
|
||||||
|
() =>
|
||||||
|
Array.from({ length: 6 }, (_, i) => {
|
||||||
|
const s = 3000 + i;
|
||||||
|
return {
|
||||||
|
top: 6 + rand(s + 1) * 78,
|
||||||
|
left: 6 + rand(s + 101) * 88,
|
||||||
|
size: 9 + Math.floor(rand(s + 201) * 9), // 9–17px gleams
|
||||||
|
color: HERO_TINTS[i % HERO_TINTS.length],
|
||||||
|
duration: 4 + rand(s + 301) * 4,
|
||||||
|
delay: rand(s + 401) * 5,
|
||||||
|
staticOpacity: 0.85,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const comets = useMemo<Comet[]>(
|
||||||
|
() =>
|
||||||
|
Array.from({ length: 3 }, (_, i) => {
|
||||||
|
const s = 4000 + i;
|
||||||
|
return {
|
||||||
|
top: 8 + rand(s + 1) * 44,
|
||||||
|
left: -10 + rand(s + 101) * 30,
|
||||||
|
length: 120 + Math.floor(rand(s + 201) * 120),
|
||||||
|
angle: 18 + rand(s + 301) * 16, // gentle downward diagonal
|
||||||
|
color: i % 2 === 0 ? 'oklch(0.92 0.06 200)' : 'oklch(0.9 0.09 330)',
|
||||||
|
duration: 7 + rand(s + 401) * 5,
|
||||||
|
delay: 2 + i * 6 + rand(s + 501) * 4,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Deep cosmic void — layered oklch radial gradients for depth. A barely
|
||||||
|
perceptible drift gives the whole field life without distraction. */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: '-6%',
|
||||||
|
contain: 'layout paint style',
|
||||||
|
backgroundColor: 'oklch(0.2 0.12 300 / 0.16)',
|
||||||
|
backgroundImage: [
|
||||||
|
'radial-gradient(120% 90% at 50% -8%, oklch(0.28 0.13 295 / 0.2) 0%, transparent 60%)',
|
||||||
|
'radial-gradient(100% 80% at 12% 18%, oklch(0.55 0.2 330 / 0.1) 0%, transparent 55%)',
|
||||||
|
'radial-gradient(100% 80% at 88% 28%, oklch(0.75 0.13 200 / 0.08) 0%, transparent 55%)',
|
||||||
|
'radial-gradient(150% 130% at 50% 118%, oklch(0.18 0.1 300 / 0.22) 0%, transparent 70%)',
|
||||||
|
].join(','),
|
||||||
|
animation: reduced ? 'none' : `${animCosmosDrift} 26s ease-in-out infinite`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Drifting nebula clouds — blurred radial gradients in magenta + cyan. */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '-12%',
|
||||||
|
left: '-14%',
|
||||||
|
width: '70%',
|
||||||
|
height: '70%',
|
||||||
|
contain: 'layout paint style',
|
||||||
|
filter: 'blur(42px)',
|
||||||
|
background:
|
||||||
|
'radial-gradient(closest-side, oklch(0.55 0.2 330 / 0.16) 0%, oklch(0.45 0.18 320 / 0.06) 45%, transparent 72%)',
|
||||||
|
willChange: reduced ? undefined : 'transform',
|
||||||
|
animation: reduced ? 'none' : `${animNebulaA} 34s ease-in-out infinite`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '-16%',
|
||||||
|
right: '-12%',
|
||||||
|
width: '74%',
|
||||||
|
height: '74%',
|
||||||
|
contain: 'layout paint style',
|
||||||
|
filter: 'blur(46px)',
|
||||||
|
background:
|
||||||
|
'radial-gradient(closest-side, oklch(0.75 0.13 200 / 0.13) 0%, oklch(0.6 0.14 240 / 0.05) 48%, transparent 74%)',
|
||||||
|
willChange: reduced ? undefined : 'transform',
|
||||||
|
animation: reduced ? 'none' : `${animNebulaB} 40s ease-in-out infinite`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* A third, central violet wash to bind the two color clouds together. */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '20%',
|
||||||
|
left: '28%',
|
||||||
|
width: '50%',
|
||||||
|
height: '50%',
|
||||||
|
contain: 'layout paint style',
|
||||||
|
filter: 'blur(50px)',
|
||||||
|
background:
|
||||||
|
'radial-gradient(closest-side, oklch(0.35 0.16 305 / 0.12) 0%, transparent 70%)',
|
||||||
|
willChange: reduced ? undefined : 'transform',
|
||||||
|
animation: reduced ? 'none' : `${animNebulaA} 46s ease-in-out infinite reverse`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Faint distant galaxy spiral — an inline conic-ish swirl from layered
|
||||||
|
radial gradients, blurred and very slowly rotating. */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '12%',
|
||||||
|
right: '14%',
|
||||||
|
width: '180px',
|
||||||
|
height: '180px',
|
||||||
|
contain: 'layout paint style',
|
||||||
|
borderRadius: '50%',
|
||||||
|
filter: 'blur(6px)',
|
||||||
|
opacity: reduced ? 0.5 : 0.6,
|
||||||
|
background: [
|
||||||
|
'radial-gradient(closest-side, oklch(0.95 0.04 280 / 0.35) 0%, oklch(0.7 0.16 320 / 0.12) 22%, transparent 40%)',
|
||||||
|
'conic-gradient(from 0deg, transparent 0deg, oklch(0.7 0.16 320 / 0.16) 60deg, transparent 130deg, oklch(0.75 0.13 200 / 0.12) 230deg, transparent 300deg)',
|
||||||
|
].join(','),
|
||||||
|
maskImage: 'radial-gradient(closest-side, #000 0%, #000 55%, transparent 80%)',
|
||||||
|
WebkitMaskImage: 'radial-gradient(closest-side, #000 0%, #000 55%, transparent 80%)',
|
||||||
|
willChange: reduced ? undefined : 'transform',
|
||||||
|
transform: reduced ? 'rotate(28deg)' : undefined,
|
||||||
|
animation: reduced ? 'none' : `${animGalaxySpin} 120s linear infinite`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Far parallax starfield — dense, faint, tiny twinkling stars. */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
contain: 'layout paint style',
|
||||||
|
willChange: reduced ? undefined : 'transform',
|
||||||
|
animation: reduced ? 'none' : `${animParallaxFar} 60s ease-in-out infinite alternate`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{farStars.map((s, i) => (
|
||||||
|
<div
|
||||||
|
key={`f${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: `${s.top}%`,
|
||||||
|
left: `${s.left}%`,
|
||||||
|
width: `${s.size}px`,
|
||||||
|
height: `${s.size}px`,
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: s.color,
|
||||||
|
boxShadow: `0 0 ${s.size * 2}px ${s.color}`,
|
||||||
|
opacity: reduced ? s.staticOpacity : undefined,
|
||||||
|
transform: reduced ? 'scale(0.95)' : undefined,
|
||||||
|
animation: reduced
|
||||||
|
? 'none'
|
||||||
|
: `${animTwinkle} ${s.duration}s ease-in-out ${s.delay}s infinite`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Near parallax starfield — sparser, brighter, drifts a touch faster. */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
contain: 'layout paint style',
|
||||||
|
willChange: reduced ? undefined : 'transform',
|
||||||
|
animation: reduced ? 'none' : `${animParallaxNear} 48s ease-in-out infinite alternate`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{nearStars.map((s, i) => (
|
||||||
|
<div
|
||||||
|
key={`n${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: `${s.top}%`,
|
||||||
|
left: `${s.left}%`,
|
||||||
|
width: `${s.size + 1}px`,
|
||||||
|
height: `${s.size + 1}px`,
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: s.color,
|
||||||
|
boxShadow: `0 0 ${(s.size + 1) * 2.5}px ${s.color}`,
|
||||||
|
opacity: reduced ? Math.min(1, s.staticOpacity + 0.15) : undefined,
|
||||||
|
transform: reduced ? 'scale(1)' : undefined,
|
||||||
|
animation: reduced
|
||||||
|
? 'none'
|
||||||
|
: `${animTwinkle} ${s.duration * 0.85}s ease-in-out ${s.delay}s infinite`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hero stars — a few bright four-point gleams that pulse slowly. */}
|
||||||
|
{heroStars.map((s, i) => (
|
||||||
|
<div
|
||||||
|
key={`h${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: `${s.top}%`,
|
||||||
|
left: `${s.left}%`,
|
||||||
|
width: `${s.size}px`,
|
||||||
|
height: `${s.size}px`,
|
||||||
|
backgroundImage: gleamUri(s.color),
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
backgroundSize: 'contain',
|
||||||
|
filter: `drop-shadow(0 0 4px ${s.color})`,
|
||||||
|
opacity: reduced ? s.staticOpacity : undefined,
|
||||||
|
transform: reduced ? 'scale(1) rotate(20deg)' : undefined,
|
||||||
|
animation: reduced
|
||||||
|
? 'none'
|
||||||
|
: `${animStarPulse} ${s.duration}s ease-in-out ${s.delay}s infinite`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Comet / warp streaks. In reduced mode, freeze a single streak mid-flight
|
||||||
|
so the static thumbnail reads as a living cosmos. */}
|
||||||
|
{(reduced ? comets.slice(0, 1) : comets).map((c, i) => (
|
||||||
|
<div
|
||||||
|
key={`c${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: `${c.top}%`,
|
||||||
|
left: `${c.left}%`,
|
||||||
|
width: `${c.length}px`,
|
||||||
|
height: '2px',
|
||||||
|
transformOrigin: '0 50%',
|
||||||
|
// Outer wrapper holds the rotation; inner element does the travel so
|
||||||
|
// the streak always moves along its own length axis.
|
||||||
|
transform: `rotate(${c.angle}deg)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
borderRadius: '2px',
|
||||||
|
background: `linear-gradient(90deg, transparent 0%, ${c.color} 80%, oklch(0.98 0.02 280 / 0.95) 100%)`,
|
||||||
|
boxShadow: `0 0 6px ${c.color}`,
|
||||||
|
willChange: reduced ? undefined : 'transform, opacity',
|
||||||
|
opacity: reduced ? 0.85 : undefined,
|
||||||
|
transform: reduced ? 'translate3d(46%, 0, 0)' : undefined,
|
||||||
|
animation: reduced
|
||||||
|
? 'none'
|
||||||
|
: `${animComet} ${c.duration}s ease-in ${c.delay}s infinite`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { keyframes } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Earth Day overlay keyframes. Every animation touches ONLY `transform` and
|
||||||
|
* `opacity` so the compositor can run them on the GPU — no layout/paint thrash.
|
||||||
|
* keyframes() returns the generated animation-name string, applied inline.
|
||||||
|
*
|
||||||
|
* Motif: verdant, hopeful nature. Leaves tumble, seeds/spores drift, pollen
|
||||||
|
* motes glow and pulse, soft sun rays breathe from above, the blue-marble
|
||||||
|
* Earth gently respires in a corner.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Falling leaf: tumbles down with a wide pendular sway and slow spin. */
|
||||||
|
export const animLeafTumble = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(0, -10vh, 0) rotate(-18deg)', opacity: '0' },
|
||||||
|
'8%': { opacity: '0.7' },
|
||||||
|
'28%': { transform: 'translate3d(4vw, 22vh, 0) rotate(60deg)' },
|
||||||
|
'52%': { transform: 'translate3d(-3vw, 48vh, 0) rotate(165deg)' },
|
||||||
|
'76%': { transform: 'translate3d(5vw, 74vh, 0) rotate(280deg)' },
|
||||||
|
'90%': { opacity: '0.5' },
|
||||||
|
'100%': { transform: 'translate3d(1vw, 112vh, 0) rotate(360deg)', opacity: '0' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Tiny seed / spore: drifts slowly downward, swaying like dandelion fluff. */
|
||||||
|
export const animSeedDrift = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(0, -6vh, 0) rotate(0deg)', opacity: '0' },
|
||||||
|
'12%': { opacity: '0.55' },
|
||||||
|
'40%': { transform: 'translate3d(3vw, 34vh, 0) rotate(140deg)' },
|
||||||
|
'70%': { transform: 'translate3d(-2.5vw, 64vh, 0) rotate(250deg)' },
|
||||||
|
'88%': { opacity: '0.4' },
|
||||||
|
'100%': { transform: 'translate3d(2vw, 110vh, 0) rotate(360deg)', opacity: '0' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Pollen mote: floats gently upward in a soft serpentine path. */
|
||||||
|
export const animPollenFloat = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(0, 0, 0) scale(0.75)', opacity: '0' },
|
||||||
|
'14%': { opacity: '0.9' },
|
||||||
|
'38%': { transform: 'translate3d(10px, -22vh, 0) scale(1)' },
|
||||||
|
'64%': { transform: 'translate3d(-10px, -46vh, 0) scale(0.92)', opacity: '0.7' },
|
||||||
|
'90%': { opacity: '0.2' },
|
||||||
|
'100%': { transform: 'translate3d(6px, -72vh, 0) scale(0.7)', opacity: '0' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Soft brightness twinkle layered on each pollen mote's glow. */
|
||||||
|
export const animPollenGlow = keyframes({
|
||||||
|
'0%': { opacity: '0.55' },
|
||||||
|
'50%': { opacity: '1' },
|
||||||
|
'100%': { opacity: '0.55' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Sun rays from above: slow breathing of opacity + a faint scale shimmer. */
|
||||||
|
export const animRayBreathe = keyframes({
|
||||||
|
'0%': { transform: 'scaleY(1)', opacity: '0.4' },
|
||||||
|
'50%': { transform: 'scaleY(1.05)', opacity: '0.7' },
|
||||||
|
'100%': { transform: 'scaleY(1)', opacity: '0.4' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Green aurora veil: a wide, slow horizontal sway with a gentle swell. */
|
||||||
|
export const animAuroraSway = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(-6%, 0, 0) scale(1.1)', opacity: '0.45' },
|
||||||
|
'50%': { transform: 'translate3d(6%, -2%, 0) scale(1.2)', opacity: '0.7' },
|
||||||
|
'100%': { transform: 'translate3d(-6%, 0, 0) scale(1.1)', opacity: '0.45' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Blue-marble Earth: a barely-perceptible respiration of its halo. */
|
||||||
|
export const animEarthRespire = keyframes({
|
||||||
|
'0%': { transform: 'scale(1)', opacity: '0.85' },
|
||||||
|
'50%': { transform: 'scale(1.04)', opacity: '1' },
|
||||||
|
'100%': { transform: 'scale(1)', opacity: '0.85' },
|
||||||
|
});
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { SeasonalOverlayProps } from '../types';
|
||||||
|
import {
|
||||||
|
animLeafTumble,
|
||||||
|
animSeedDrift,
|
||||||
|
animPollenFloat,
|
||||||
|
animPollenGlow,
|
||||||
|
animRayBreathe,
|
||||||
|
animAuroraSway,
|
||||||
|
animEarthRespire,
|
||||||
|
} from './EarthDay.css';
|
||||||
|
|
||||||
|
// ─── Palette (oklch) ──────────────────────────────────────────────────────────
|
||||||
|
// Verdant, hopeful nature: living leaf greens, soft sky + deep ocean blues,
|
||||||
|
// and a warm sun highlight. Kept low-alpha so chat text stays WCAG-AA legible.
|
||||||
|
const LEAF_GREEN = 'oklch(0.65 0.15 145)';
|
||||||
|
const LEAF_DEEP = 'oklch(0.52 0.14 150)';
|
||||||
|
const LEAF_LIME = 'oklch(0.78 0.16 130)';
|
||||||
|
const SKY_BLUE = 'oklch(0.70 0.10 230)';
|
||||||
|
const OCEAN_BLUE = 'oklch(0.55 0.12 240)';
|
||||||
|
const SUN_WARM = 'oklch(0.92 0.10 95)';
|
||||||
|
const POLLEN_GOLD = 'oklch(0.88 0.13 95)';
|
||||||
|
|
||||||
|
// Soft, translucent tints for the ambient gradient washes.
|
||||||
|
const LEAF_GREEN_SOFT = 'oklch(0.65 0.15 145 / 0.10)';
|
||||||
|
const LEAF_LIME_SOFT = 'oklch(0.78 0.16 130 / 0.08)';
|
||||||
|
const SKY_BLUE_SOFT = 'oklch(0.70 0.10 230 / 0.07)';
|
||||||
|
const SUN_SOFT = 'oklch(0.92 0.10 95 / 0.10)';
|
||||||
|
const AURORA_TINT = 'oklch(0.74 0.16 155 / 0.22)';
|
||||||
|
|
||||||
|
// ─── Inline SVG leaf, drawn once (CSP-safe data-URI, no external assets) ───────
|
||||||
|
// A simple veined leaf silhouette. Color is baked per-variant so we can tint
|
||||||
|
// individual falling leaves without a runtime filter.
|
||||||
|
function leafUri(fill: string, vein: string): string {
|
||||||
|
const svg =
|
||||||
|
`<svg xmlns='http://www.w3.org/2000/svg' width='28' height='28' viewBox='0 0 28 28'>` +
|
||||||
|
`<path fill='${fill}' d='M14 1C7 5 2 11 2 18c0 5 4 9 9 9 7 0 15-7 15-19 0-3-1-6-2-6-3 1-6 2-10 0C12 1 13 1 14 1z'/>` +
|
||||||
|
`<path fill='none' stroke='${vein}' stroke-width='0.9' stroke-linecap='round' ` +
|
||||||
|
`d='M11 26C13 18 17 9 23 3M11 26c-1-4-2-7-4-9M13 20c2-1 4-2 6-5M12 14c2-1 3-2 5-5'/>` +
|
||||||
|
`</svg>`;
|
||||||
|
return `url("data:image/svg+xml;utf8,${encodeURIComponent(svg)}")`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Three leaf tints, generated once at module load.
|
||||||
|
const LEAF_URIS = [
|
||||||
|
leafUri('oklch(0.65 0.15 145 / 0.9)', 'oklch(0.40 0.10 150 / 0.7)'),
|
||||||
|
leafUri('oklch(0.78 0.16 130 / 0.9)', 'oklch(0.50 0.12 140 / 0.7)'),
|
||||||
|
leafUri('oklch(0.52 0.14 150 / 0.9)', 'oklch(0.34 0.08 155 / 0.7)'),
|
||||||
|
];
|
||||||
|
|
||||||
|
export function EarthDayOverlay({ reduced }: SeasonalOverlayProps) {
|
||||||
|
// ── Deterministic per-mount generation — never per-frame React state. ──
|
||||||
|
|
||||||
|
// Tumbling leaves (the heaviest motif → kept modest).
|
||||||
|
const leaves = useMemo(
|
||||||
|
() =>
|
||||||
|
Array.from({ length: 10 }, (_, i) => ({
|
||||||
|
left: (i * 6173 + 137) % 96,
|
||||||
|
size: 16 + (i % 4) * 6,
|
||||||
|
duration: 16 + (i % 5) * 2.5,
|
||||||
|
delay: (i * 1.7) % 16,
|
||||||
|
uri: LEAF_URIS[i % LEAF_URIS.length],
|
||||||
|
opacity: 0.45 + (i % 3) * 0.12,
|
||||||
|
})),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tiny drifting seeds / spores — small, faint, slow.
|
||||||
|
const seeds = useMemo(
|
||||||
|
() =>
|
||||||
|
Array.from({ length: 8 }, (_, i) => ({
|
||||||
|
left: (i * 4099 + 53) % 98,
|
||||||
|
size: 2 + (i % 2),
|
||||||
|
duration: 18 + (i % 4) * 3,
|
||||||
|
delay: (i * 2.3) % 18,
|
||||||
|
})),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Glowing pollen motes rising from below, catching the light.
|
||||||
|
const pollen = useMemo(
|
||||||
|
() =>
|
||||||
|
Array.from({ length: 12 }, (_, i) => ({
|
||||||
|
left: (i * 5279 + 89) % 100,
|
||||||
|
bottom: (i * 2731 + 31) % 32,
|
||||||
|
size: 3 + (i % 3),
|
||||||
|
duration: 13 + (i % 6) * 2,
|
||||||
|
delay: (i * 0.9) % 13,
|
||||||
|
twinkle: 2.6 + (i % 5) * 0.5,
|
||||||
|
})),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sun rays fanning down from the top — a few soft angled beams.
|
||||||
|
const rays = useMemo(
|
||||||
|
() =>
|
||||||
|
Array.from({ length: 5 }, (_, i) => ({
|
||||||
|
left: 12 + i * 18,
|
||||||
|
rotate: -14 + i * 7,
|
||||||
|
width: 60 + (i % 3) * 26,
|
||||||
|
duration: 8 + (i % 3) * 2,
|
||||||
|
delay: i * 1.3,
|
||||||
|
opacity: 0.32 + (i % 3) * 0.08,
|
||||||
|
})),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* ── Base wash: layered green/sky gradients for verdant depth ── */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
contain: 'layout paint style',
|
||||||
|
backgroundImage: [
|
||||||
|
// warm sun glow spilling from top-center
|
||||||
|
`radial-gradient(60vmax 42vmax at 50% -8%, ${SUN_SOFT} 0%, transparent 60%)`,
|
||||||
|
// verdant canopy glow rising from the lower-left
|
||||||
|
`radial-gradient(52vmax 52vmax at 14% 100%, ${LEAF_GREEN_SOFT} 0%, transparent 62%)`,
|
||||||
|
// lime highlight upper-right for freshness
|
||||||
|
`radial-gradient(40vmax 40vmax at 86% 18%, ${LEAF_LIME_SOFT} 0%, transparent 58%)`,
|
||||||
|
// cool sky tint at the very top to pair with the Earth
|
||||||
|
`radial-gradient(70vmax 30vmax at 70% 4%, ${SKY_BLUE_SOFT} 0%, transparent 64%)`,
|
||||||
|
].join(', '),
|
||||||
|
opacity: 0.9,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── Green aurora veil drifting near the top ── */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: '-12%',
|
||||||
|
right: '-12%',
|
||||||
|
top: '-8%',
|
||||||
|
height: '46vh',
|
||||||
|
contain: 'layout paint style',
|
||||||
|
backgroundImage: `radial-gradient(60% 100% at 50% 0%, ${AURORA_TINT} 0%, transparent 72%)`,
|
||||||
|
filter: 'blur(26px)',
|
||||||
|
willChange: reduced ? undefined : 'transform, opacity',
|
||||||
|
transformOrigin: '50% 0%',
|
||||||
|
opacity: reduced ? 0.55 : undefined,
|
||||||
|
transform: reduced ? 'translate3d(0, 0, 0) scale(1.15)' : undefined,
|
||||||
|
animation: reduced ? 'none' : `${animAuroraSway} 24s ease-in-out infinite`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── Soft sun rays fanning down from above ── */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
contain: 'layout paint style',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rays.map((r, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '-10%',
|
||||||
|
left: `${r.left}%`,
|
||||||
|
width: `${r.width}px`,
|
||||||
|
height: '95vh',
|
||||||
|
transformOrigin: '50% 0%',
|
||||||
|
transform: `rotate(${r.rotate}deg)`,
|
||||||
|
backgroundImage: `linear-gradient(180deg, ${SUN_WARM} 0%, transparent 70%)`,
|
||||||
|
filter: 'blur(8px)',
|
||||||
|
mixBlendMode: 'screen',
|
||||||
|
opacity: r.opacity,
|
||||||
|
willChange: reduced ? undefined : 'transform, opacity',
|
||||||
|
animation: reduced
|
||||||
|
? 'none'
|
||||||
|
: `${animRayBreathe} ${r.duration}s ease-in-out ${r.delay}s infinite`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Blue-marble Earth tucked into the bottom-right corner ── */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: '-6%',
|
||||||
|
bottom: '-10%',
|
||||||
|
width: '300px',
|
||||||
|
height: '300px',
|
||||||
|
contain: 'layout paint style',
|
||||||
|
willChange: reduced ? undefined : 'transform, opacity',
|
||||||
|
transform: reduced ? 'scale(1.02)' : undefined,
|
||||||
|
opacity: reduced ? 0.9 : undefined,
|
||||||
|
animation: reduced ? 'none' : `${animEarthRespire} 18s ease-in-out infinite`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* atmospheric rim halo */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: '-14%',
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundImage: `radial-gradient(circle at 50% 50%, transparent 58%, ${SKY_BLUE} 68%, transparent 80%)`,
|
||||||
|
filter: 'blur(10px)',
|
||||||
|
opacity: 0.5,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* the globe itself — oceans, land, soft terminator shadow */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundImage: [
|
||||||
|
// continents (green landmasses)
|
||||||
|
`radial-gradient(26% 30% at 38% 40%, ${LEAF_GREEN} 0%, transparent 60%)`,
|
||||||
|
`radial-gradient(22% 26% at 64% 58%, ${LEAF_DEEP} 0%, transparent 62%)`,
|
||||||
|
`radial-gradient(16% 18% at 50% 74%, ${LEAF_LIME} 0%, transparent 65%)`,
|
||||||
|
// ocean base
|
||||||
|
`radial-gradient(circle at 42% 38%, ${SKY_BLUE} 0%, ${OCEAN_BLUE} 55%, oklch(0.40 0.10 250) 100%)`,
|
||||||
|
].join(', '),
|
||||||
|
// soft day/night terminator from the lower-right
|
||||||
|
boxShadow: 'inset -22px -26px 50px oklch(0.18 0.05 250 / 0.7)',
|
||||||
|
opacity: 0.42,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Rising, glowing pollen motes ── */}
|
||||||
|
{pollen.map((p, i) => (
|
||||||
|
<div
|
||||||
|
key={`p${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${p.left}%`,
|
||||||
|
bottom: `${p.bottom}%`,
|
||||||
|
willChange: reduced ? undefined : 'transform, opacity',
|
||||||
|
transform: reduced ? 'scale(0.95)' : undefined,
|
||||||
|
opacity: reduced ? 0.6 : undefined,
|
||||||
|
animation: reduced
|
||||||
|
? 'none'
|
||||||
|
: `${animPollenFloat} ${p.duration}s ease-in ${p.delay}s infinite`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
width: `${p.size}px`,
|
||||||
|
height: `${p.size}px`,
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: POLLEN_GOLD,
|
||||||
|
boxShadow: `0 0 ${p.size * 2.6}px ${POLLEN_GOLD}`,
|
||||||
|
animation: reduced ? 'none' : `${animPollenGlow} ${p.twinkle}s ease-in-out infinite`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* ── Drifting seeds / spores (skip entirely when reduced) ── */}
|
||||||
|
{!reduced &&
|
||||||
|
seeds.map((s, i) => (
|
||||||
|
<div
|
||||||
|
key={`s${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '-6%',
|
||||||
|
left: `${s.left}%`,
|
||||||
|
width: `${s.size}px`,
|
||||||
|
height: `${s.size}px`,
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: 'oklch(0.96 0.02 120 / 0.85)',
|
||||||
|
boxShadow: '0 0 6px oklch(0.92 0.04 120 / 0.6)',
|
||||||
|
willChange: 'transform, opacity',
|
||||||
|
animation: `${animSeedDrift} ${s.duration}s linear ${s.delay}s infinite`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* ── Tumbling leaves ── */}
|
||||||
|
{leaves.map((l, i) => (
|
||||||
|
<div
|
||||||
|
key={`l${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '-10%',
|
||||||
|
left: `${l.left}%`,
|
||||||
|
width: `${l.size}px`,
|
||||||
|
height: `${l.size}px`,
|
||||||
|
backgroundImage: l.uri,
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
backgroundSize: 'contain',
|
||||||
|
willChange: reduced ? undefined : 'transform, opacity',
|
||||||
|
opacity: l.opacity,
|
||||||
|
// Static leaves are scattered down the column so the still scene
|
||||||
|
// reads as a gentle leaf-fall frozen mid-air.
|
||||||
|
transform: reduced
|
||||||
|
? `translate3d(${(i % 2 ? 1 : -1) * 3}vw, ${6 + i * 9}vh, 0) rotate(${
|
||||||
|
(i * 47) % 360
|
||||||
|
}deg)`
|
||||||
|
: undefined,
|
||||||
|
animation: reduced
|
||||||
|
? 'none'
|
||||||
|
: `${animLeafTumble} ${l.duration}s ease-in ${l.delay}s infinite`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { keyframes } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Halloween overlay keyframes. Every animation touches ONLY `transform` and
|
||||||
|
* `opacity` so the compositor can run them on the GPU without layout/paint.
|
||||||
|
* keyframes() returns the generated animation-name string, applied inline.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Slow breathing of the sickly moon-glow vignette. */
|
||||||
|
export const animMoonPulse = keyframes({
|
||||||
|
'0%': { transform: 'scale(1)', opacity: '0.55' },
|
||||||
|
'50%': { transform: 'scale(1.06)', opacity: '0.8' },
|
||||||
|
'100%': { transform: 'scale(1)', opacity: '0.55' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Low fog band: drifts sideways while gently rising and swelling. */
|
||||||
|
export const animFogDrift = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(-12%, 6%, 0) scale(1.1)', opacity: '0' },
|
||||||
|
'15%': { opacity: '0.5' },
|
||||||
|
'50%': { transform: 'translate3d(6%, -2%, 0) scale(1.25)', opacity: '0.65' },
|
||||||
|
'85%': { opacity: '0.45' },
|
||||||
|
'100%': { transform: 'translate3d(18%, 4%, 0) scale(1.1)', opacity: '0' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A bat flaps slowly across the sky in a shallow arc. */
|
||||||
|
export const animBatGlide = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(-12vw, 8vh, 0) scale(0.9)', opacity: '0' },
|
||||||
|
'10%': { opacity: '0.7' },
|
||||||
|
'45%': { transform: 'translate3d(45vw, -4vh, 0) scale(1)' },
|
||||||
|
'80%': { transform: 'translate3d(85vw, 6vh, 0) scale(0.95)', opacity: '0.6' },
|
||||||
|
'100%': { transform: 'translate3d(112vw, 2vh, 0) scale(0.9)', opacity: '0' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The bat's wings beat — fast vertical squash of the wing element. */
|
||||||
|
export const animWingFlap = keyframes({
|
||||||
|
'0%': { transform: 'scaleY(1) scaleX(1)' },
|
||||||
|
'50%': { transform: 'scaleY(0.35) scaleX(1.08)' },
|
||||||
|
'100%': { transform: 'scaleY(1) scaleX(1)' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Will-o'-wisp ember: floats upward, swaying, pulsing in brightness. */
|
||||||
|
export const animEmberFloat = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(0, 0, 0) scale(0.7)', opacity: '0' },
|
||||||
|
'12%': { opacity: '0.85' },
|
||||||
|
'35%': { transform: 'translate3d(14px, -28vh, 0) scale(1)' },
|
||||||
|
'65%': { transform: 'translate3d(-12px, -55vh, 0) scale(0.9)', opacity: '0.7' },
|
||||||
|
'90%': { opacity: '0.25' },
|
||||||
|
'100%': { transform: 'translate3d(8px, -82vh, 0) scale(0.6)', opacity: '0' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Soft twinkle for embers — independent opacity flicker layered on top. */
|
||||||
|
export const animEmberTwinkle = keyframes({
|
||||||
|
'0%': { opacity: '0.6' },
|
||||||
|
'50%': { opacity: '1' },
|
||||||
|
'100%': { opacity: '0.6' },
|
||||||
|
});
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { SeasonalOverlayProps } from '../types';
|
||||||
|
import {
|
||||||
|
animMoonPulse,
|
||||||
|
animFogDrift,
|
||||||
|
animBatGlide,
|
||||||
|
animWingFlap,
|
||||||
|
animEmberFloat,
|
||||||
|
animEmberTwinkle,
|
||||||
|
} from './Halloween.css';
|
||||||
|
|
||||||
|
// ─── Palette (oklch) ──────────────────────────────────────────────────────────
|
||||||
|
// Deep haunted indigo, sickly toxic-green moon glow, warm ember orange.
|
||||||
|
const PURPLE_DEEP = 'oklch(0.20 0.12 300)';
|
||||||
|
const PURPLE_FAINT = 'oklch(0.28 0.10 300 / 0.45)';
|
||||||
|
const TOXIC_GREEN = 'oklch(0.80 0.18 150)';
|
||||||
|
const TOXIC_GREEN_SOFT = 'oklch(0.72 0.16 150 / 0.35)';
|
||||||
|
const EMBER_ORANGE = 'oklch(0.70 0.18 50)';
|
||||||
|
const FOG_TINT = 'oklch(0.45 0.06 280 / 0.32)';
|
||||||
|
|
||||||
|
// A corner cobweb, drawn once as an inline SVG data-URI (CSP-safe, no assets).
|
||||||
|
// strokeWidth kept hairline so it reads as gossamer thread, not a cage.
|
||||||
|
const cobwebUri = (() => {
|
||||||
|
const svg =
|
||||||
|
`<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180' viewBox='0 0 180 180'>` +
|
||||||
|
`<g fill='none' stroke='rgba(196,176,224,0.32)' stroke-width='0.8'>` +
|
||||||
|
// radial threads
|
||||||
|
`<line x1='0' y1='0' x2='180' y2='180'/>` +
|
||||||
|
`<line x1='0' y1='0' x2='180' y2='90'/>` +
|
||||||
|
`<line x1='0' y1='0' x2='90' y2='180'/>` +
|
||||||
|
`<line x1='0' y1='0' x2='180' y2='40'/>` +
|
||||||
|
`<line x1='0' y1='0' x2='40' y2='180'/>` +
|
||||||
|
// concentric catch-threads (gentle sag via quadratic curves)
|
||||||
|
`<path d='M40 0 Q22 22 0 40'/>` +
|
||||||
|
`<path d='M85 0 Q48 48 0 85'/>` +
|
||||||
|
`<path d='M130 0 Q74 74 0 130'/>` +
|
||||||
|
`<path d='M180 0 Q104 104 0 180'/>` +
|
||||||
|
`<path d='M180 60 Q120 120 60 180'/>` +
|
||||||
|
`</g></svg>`;
|
||||||
|
return `url("data:image/svg+xml;utf8,${encodeURIComponent(svg)}")`;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// A single silhouetted bat, inline SVG. Wings are separate so the wrapper can
|
||||||
|
// glide while an inner element flaps independently — we re-use one body shape.
|
||||||
|
function BatSilhouette() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="46"
|
||||||
|
height="22"
|
||||||
|
viewBox="0 0 46 22"
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{ display: 'block', overflow: 'visible' }}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="oklch(0.12 0.04 300 / 0.85)"
|
||||||
|
d="M23 6c1.6 0 2.7 1.3 3 3 .9-1.4 2.4-2.6 4.2-2.6-.5 1-.4 2.1.2 2.9 2-2.4 5.4-4 8.6-3.7-1.5 1-2.3 2.6-2.4 4.3 1.3-.8 3-1 4.4-.4-2.2.8-3.9 2.5-5.2 4.5-2 3-4.8 5-8.3 4.4-1.9-.3-3.4-1.6-4.5-3.2-1.1 1.6-2.6 2.9-4.5 3.2-3.5.6-6.3-1.4-8.3-4.4-1.3-2-3-3.7-5.2-4.5 1.4-.6 3.1-.4 4.4.4-.1-1.7-.9-3.3-2.4-4.3 3.2-.3 6.6 1.3 8.6 3.7.6-.8.7-1.9.2-2.9 1.8 0 3.3 1.2 4.2 2.6.3-1.7 1.4-3 3-3z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HalloweenOverlay({ reduced }: SeasonalOverlayProps) {
|
||||||
|
// Deterministic per-mount generation — never per-frame React state.
|
||||||
|
const embers = useMemo(
|
||||||
|
() =>
|
||||||
|
Array.from({ length: 12 }, (_, i) => {
|
||||||
|
const green = i % 3 === 0; // ~1/3 toxic-green wisps, rest warm embers
|
||||||
|
return {
|
||||||
|
left: (i * 6151 + 113) % 100,
|
||||||
|
bottom: (i * 3137 + 47) % 28, // start near floor
|
||||||
|
size: 3 + (i % 4),
|
||||||
|
duration: 11 + (i % 6) * 2.2,
|
||||||
|
delay: (i * 0.83) % 11,
|
||||||
|
twinkle: 2.4 + (i % 5) * 0.6,
|
||||||
|
color: green ? TOXIC_GREEN : EMBER_ORANGE,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const bats = useMemo(
|
||||||
|
() =>
|
||||||
|
Array.from({ length: 3 }, (_, i) => ({
|
||||||
|
top: 8 + i * 13,
|
||||||
|
duration: 22 + i * 7,
|
||||||
|
delay: i * 6.5,
|
||||||
|
flap: 0.5 + i * 0.12,
|
||||||
|
scale: 0.7 + i * 0.18,
|
||||||
|
})),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const fogBands = useMemo(
|
||||||
|
() =>
|
||||||
|
Array.from({ length: 3 }, (_, i) => ({
|
||||||
|
bottom: -6 + i * 9,
|
||||||
|
duration: 26 + i * 8,
|
||||||
|
delay: i * 5,
|
||||||
|
height: 130 + i * 30,
|
||||||
|
})),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* ── Sky: layered indigo→black gradient with toxic-green moon vignette ── */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
contain: 'layout paint style',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
backgroundImage: [
|
||||||
|
// sickly moon glow, upper-right
|
||||||
|
`radial-gradient(38vmax 38vmax at 78% 14%, ${TOXIC_GREEN_SOFT} 0%, transparent 58%)`,
|
||||||
|
// cold counter-glow lower-left for depth
|
||||||
|
`radial-gradient(46vmax 46vmax at 12% 92%, ${PURPLE_FAINT} 0%, transparent 60%)`,
|
||||||
|
// overall indigo→black wash, darker toward edges (vignette)
|
||||||
|
`radial-gradient(120% 120% at 50% 30%, transparent 32%, ${PURPLE_DEEP} 100%)`,
|
||||||
|
`linear-gradient(180deg, ${PURPLE_DEEP} 0%, transparent 45%)`,
|
||||||
|
].join(', '),
|
||||||
|
opacity: 0.5,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── Moon disc + breathing halo (the only backdrop-filter, kept cheap) ── */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '8%',
|
||||||
|
right: '12%',
|
||||||
|
width: '160px',
|
||||||
|
height: '160px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
willChange: 'transform, opacity',
|
||||||
|
backgroundImage: `radial-gradient(circle at 42% 40%, ${TOXIC_GREEN} 0%, oklch(0.55 0.14 150 / 0.5) 38%, transparent 72%)`,
|
||||||
|
filter: 'blur(2px)',
|
||||||
|
backdropFilter: 'saturate(1.15)',
|
||||||
|
animation: reduced ? 'none' : `${animMoonPulse} 9s ease-in-out infinite`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── Low drifting fog bands ── */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
contain: 'layout paint style',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{fogBands.map((f, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: '-15%',
|
||||||
|
right: '-15%',
|
||||||
|
bottom: `${f.bottom}%`,
|
||||||
|
height: `${f.height}px`,
|
||||||
|
backgroundImage: `radial-gradient(60% 100% at 50% 100%, ${FOG_TINT} 0%, transparent 75%)`,
|
||||||
|
filter: 'blur(14px)',
|
||||||
|
willChange: 'transform, opacity',
|
||||||
|
opacity: reduced ? 0.5 : undefined,
|
||||||
|
transform: reduced ? 'translate3d(2%, 0, 0) scale(1.18)' : undefined,
|
||||||
|
animation: reduced
|
||||||
|
? 'none'
|
||||||
|
: `${animFogDrift} ${f.duration}s ease-in-out ${f.delay}s infinite`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Will-o'-wisps / floating embers ── */}
|
||||||
|
{embers.map((e, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${e.left}%`,
|
||||||
|
bottom: `${e.bottom}%`,
|
||||||
|
willChange: reduced ? undefined : 'transform, opacity',
|
||||||
|
transform: reduced ? 'scale(0.9)' : undefined,
|
||||||
|
opacity: reduced ? 0.4 : undefined,
|
||||||
|
animation: reduced
|
||||||
|
? 'none'
|
||||||
|
: `${animEmberFloat} ${e.duration}s ease-in ${e.delay}s infinite`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
width: `${e.size}px`,
|
||||||
|
height: `${e.size}px`,
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: e.color,
|
||||||
|
boxShadow: `0 0 ${e.size * 2.5}px ${e.color}`,
|
||||||
|
animation: reduced
|
||||||
|
? 'none'
|
||||||
|
: `${animEmberTwinkle} ${e.twinkle}s ease-in-out infinite`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* ── Silhouetted bats gliding across (skip entirely when reduced) ── */}
|
||||||
|
{!reduced &&
|
||||||
|
bats.map((b, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: `${b.top}%`,
|
||||||
|
left: 0,
|
||||||
|
willChange: 'transform, opacity',
|
||||||
|
animation: `${animBatGlide} ${b.duration}s linear ${b.delay}s infinite`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
transform: `scale(${b.scale})`,
|
||||||
|
animation: `${animWingFlap} ${b.flap}s ease-in-out infinite`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BatSilhouette />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* ── Cobwebs tucked into two corners (top-left, top-right mirrored) ── */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '180px',
|
||||||
|
height: '180px',
|
||||||
|
backgroundImage: cobwebUri,
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
opacity: 0.7,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
width: '180px',
|
||||||
|
height: '180px',
|
||||||
|
backgroundImage: cobwebUri,
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
transform: 'scaleX(-1)',
|
||||||
|
opacity: 0.7,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { keyframes } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lunar New Year overlay keyframes — red paper lanterns, drifting gold plum
|
||||||
|
* blossoms, and a coiling dragon. Every animation touches ONLY `transform` and
|
||||||
|
* `opacity`, so the compositor runs them on the GPU with zero layout/paint.
|
||||||
|
* keyframes() returns the generated animation-name string, applied inline by the
|
||||||
|
* component. Static structure (gradients, SVG data-URIs, geometry) lives in the
|
||||||
|
* component; this module is motion only.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lantern bob — a hung lantern rises a touch and sinks again on a long, lazy
|
||||||
|
* cycle, as if buoyed by warm air. translateY + a whisper of scale only; the
|
||||||
|
* per-lantern duration/delay desynchronise the swarm.
|
||||||
|
*/
|
||||||
|
export const animLanternBob = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(0, 0, 0) scale(1)' },
|
||||||
|
'50%': { transform: 'translate3d(0, -2.2vh, 0) scale(1.015)' },
|
||||||
|
'100%': { transform: 'translate3d(0, 0, 0) scale(1)' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lantern pendulum — a gentle rotational sway about the top mount, so each
|
||||||
|
* lantern rocks like it hangs from a string. Pairs with the bob on a different
|
||||||
|
* period to read as organic drift rather than a metronome.
|
||||||
|
*/
|
||||||
|
export const animLanternSway = keyframes({
|
||||||
|
'0%': { transform: 'rotate(-2.4deg)' },
|
||||||
|
'50%': { transform: 'rotate(2.4deg)' },
|
||||||
|
'100%': { transform: 'rotate(-2.4deg)' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tassel sway — the silk tassel under a lantern trails its parent's motion with
|
||||||
|
* a wider, slightly lagging swing. transformOrigin is the top of the tassel.
|
||||||
|
*/
|
||||||
|
export const animTasselSway = keyframes({
|
||||||
|
'0%': { transform: 'rotate(5deg)' },
|
||||||
|
'50%': { transform: 'rotate(-5deg)' },
|
||||||
|
'100%': { transform: 'rotate(5deg)' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lantern inner glow — the warm light inside each lantern swells and dims, like
|
||||||
|
* a candle breathing. Opacity + scale only.
|
||||||
|
*/
|
||||||
|
export const animGlowBreathe = keyframes({
|
||||||
|
'0%': { transform: 'scale(0.94)', opacity: '0.55' },
|
||||||
|
'50%': { transform: 'scale(1.06)', opacity: '0.9' },
|
||||||
|
'100%': { transform: 'scale(0.94)', opacity: '0.55' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Petal drift — a gold plum-blossom petal falls the full height while spinning
|
||||||
|
* and swaying. A tall translateY lets one keyframe set serve every petal;
|
||||||
|
* per-petal duration/delay/scale create the parallax variety.
|
||||||
|
*/
|
||||||
|
export const animPetalFall = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(0, -10vh, 0) rotateZ(0deg) rotateY(0deg)', opacity: '0' },
|
||||||
|
'10%': { opacity: '0.9' },
|
||||||
|
'50%': { transform: 'translate3d(3vw, 52vh, 0) rotateZ(190deg) rotateY(180deg)' },
|
||||||
|
'90%': { opacity: '0.8' },
|
||||||
|
'100%': {
|
||||||
|
transform: 'translate3d(-2.4vw, 114vh, 0) rotateZ(380deg) rotateY(360deg)',
|
||||||
|
opacity: '0',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lateral petal sway on the wrapper, decoupled from the fall so the two combine
|
||||||
|
* into an organic wind-borne path rather than a straight drop.
|
||||||
|
*/
|
||||||
|
export const animPetalSway = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(0, 0, 0)' },
|
||||||
|
'50%': { transform: 'translate3d(2.8vw, 0, 0)' },
|
||||||
|
'100%': { transform: 'translate3d(0, 0, 0)' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dragon drift — the gold dragon silhouette breathes and undulates almost
|
||||||
|
* imperceptibly across the scene. translate + scale + opacity only, very slow.
|
||||||
|
*/
|
||||||
|
export const animDragonDrift = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(-2%, 0, 0) scale(1)', opacity: '0.42' },
|
||||||
|
'50%': { transform: 'translate3d(2%, -1%, 0) scale(1.04)', opacity: '0.6' },
|
||||||
|
'100%': { transform: 'translate3d(-2%, 0, 0) scale(1)', opacity: '0.42' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lacquer-tint breathing — a barely-there pulse of the warm red ambient wash so
|
||||||
|
* the static base feels alive without distracting motion.
|
||||||
|
*/
|
||||||
|
export const animLacquerPulse = keyframes({
|
||||||
|
'0%': { opacity: '0.82' },
|
||||||
|
'50%': { opacity: '1' },
|
||||||
|
'100%': { opacity: '0.82' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gold ember rise — tiny sparks of lantern light float gently upward and fade,
|
||||||
|
* like motes drifting off the flames. translateY + opacity only.
|
||||||
|
*/
|
||||||
|
export const animEmberRise = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(0, 0, 0) scale(0.6)', opacity: '0' },
|
||||||
|
'15%': { opacity: '0.85' },
|
||||||
|
'80%': { opacity: '0.5' },
|
||||||
|
'100%': { transform: 'translate3d(0.6vw, -26vh, 0) scale(1)', opacity: '0' },
|
||||||
|
});
|
||||||
@@ -0,0 +1,483 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { SeasonalOverlayProps } from '../types';
|
||||||
|
import {
|
||||||
|
animLanternBob,
|
||||||
|
animLanternSway,
|
||||||
|
animTasselSway,
|
||||||
|
animGlowBreathe,
|
||||||
|
animPetalFall,
|
||||||
|
animPetalSway,
|
||||||
|
animDragonDrift,
|
||||||
|
animLacquerPulse,
|
||||||
|
animEmberRise,
|
||||||
|
} from './LunarNewYear.css';
|
||||||
|
|
||||||
|
// Deterministic pseudo-random so the scene is identical every mount (no React
|
||||||
|
// state per frame). Large primes keep the distribution well spread.
|
||||||
|
const rand = (seed: number): number => {
|
||||||
|
const x = Math.sin(seed * 127.1 + 311.7) * 43758.5453;
|
||||||
|
return x - Math.floor(x);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Core oklch palette — auspicious crimson/vermilion lanterns, imperial gold
|
||||||
|
// trim and blossoms, over a deep lacquer-red ambient tint. Kept luminous and
|
||||||
|
// gentle so everything reads as soft ambient glow, never solid paint.
|
||||||
|
const CRIMSON = 'oklch(0.50 0.20 25)';
|
||||||
|
const VERMILION = 'oklch(0.58 0.21 30)';
|
||||||
|
const GOLD = 'oklch(0.82 0.14 85)';
|
||||||
|
const GOLD_HI = 'oklch(0.92 0.10 92)';
|
||||||
|
|
||||||
|
// A coiling dragon silhouette in imperial gold, rendered once as an inline SVG
|
||||||
|
// data-URI so it costs a single GPU-composited layer (no DOM weight). The curve
|
||||||
|
// is intentionally abstract and very subtle — a calligraphic ribbon-body with a
|
||||||
|
// suggestion of a head, mane and tail arcing across the upper scene.
|
||||||
|
const dragonUri = ((): string => {
|
||||||
|
const svg =
|
||||||
|
`<svg xmlns='http://www.w3.org/2000/svg' width='760' height='320' viewBox='0 0 760 320'>` +
|
||||||
|
`<defs>` +
|
||||||
|
`<linearGradient id='g' x1='0' y1='0' x2='1' y2='0'>` +
|
||||||
|
`<stop offset='0' stop-color='oklch(0.86 0.13 88)' stop-opacity='0.85'/>` +
|
||||||
|
`<stop offset='0.55' stop-color='oklch(0.82 0.14 85)' stop-opacity='0.7'/>` +
|
||||||
|
`<stop offset='1' stop-color='oklch(0.78 0.13 80)' stop-opacity='0.45'/>` +
|
||||||
|
`</linearGradient>` +
|
||||||
|
`</defs>` +
|
||||||
|
`<g fill='none' stroke='url(%23g)' stroke-linecap='round' stroke-linejoin='round'>` +
|
||||||
|
// Sinuous body — a thick tapering serpentine ribbon.
|
||||||
|
`<path d='M30 180 C120 90 200 250 300 170 S470 60 560 150 S700 240 740 150' ` +
|
||||||
|
`stroke-width='26' opacity='0.5'/>` +
|
||||||
|
// Inner highlight running along the body for a calligraphic sheen.
|
||||||
|
`<path d='M30 180 C120 90 200 250 300 170 S470 60 560 150 S700 240 740 150' ` +
|
||||||
|
`stroke-width='7' opacity='0.7'/>` +
|
||||||
|
// Head + horn flourish at the leading end.
|
||||||
|
`<path d='M30 180 C10 160 8 130 26 120 M26 120 C36 112 50 116 52 130' ` +
|
||||||
|
`stroke-width='9' opacity='0.6'/>` +
|
||||||
|
// Mane / whisker strokes flaring back from the head.
|
||||||
|
`<path d='M44 134 C70 120 96 132 110 152 M40 150 C66 148 92 160 104 180' ` +
|
||||||
|
`stroke-width='5' opacity='0.45'/>` +
|
||||||
|
// Tail wisps.
|
||||||
|
`<path d='M740 150 C754 138 758 160 748 172 M726 158 C742 168 744 186 732 196' ` +
|
||||||
|
`stroke-width='5' opacity='0.45'/>` +
|
||||||
|
`</g></svg>`;
|
||||||
|
return `url("data:image/svg+xml;utf8,${encodeURIComponent(svg)}")`;
|
||||||
|
})();
|
||||||
|
|
||||||
|
type Lantern = {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
scale: number;
|
||||||
|
bobDuration: number;
|
||||||
|
swayDuration: number;
|
||||||
|
delay: number;
|
||||||
|
opacity: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Petal = {
|
||||||
|
left: number;
|
||||||
|
size: number;
|
||||||
|
duration: number;
|
||||||
|
delay: number;
|
||||||
|
swayDuration: number;
|
||||||
|
opacity: number;
|
||||||
|
blur: number;
|
||||||
|
hue: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Ember = {
|
||||||
|
left: number;
|
||||||
|
bottom: number;
|
||||||
|
size: number;
|
||||||
|
duration: number;
|
||||||
|
delay: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// A single five-petal plum blossom (gold), inline SVG so each petal sliver is
|
||||||
|
// one cheap element. Returned as a data-URI background painted on a square.
|
||||||
|
const blossomUri = ((): string => {
|
||||||
|
const petals = Array.from({ length: 5 }, (_, i) => {
|
||||||
|
const a = (i * 72 * Math.PI) / 180;
|
||||||
|
const cx = 16 + Math.cos(a - Math.PI / 2) * 8;
|
||||||
|
const cy = 16 + Math.sin(a - Math.PI / 2) * 8;
|
||||||
|
return `<circle cx='${cx.toFixed(1)}' cy='${cy.toFixed(1)}' r='5.4' />`;
|
||||||
|
}).join('');
|
||||||
|
const svg =
|
||||||
|
`<svg xmlns='http://www.w3.org/2000/svg' width='32' height='32' viewBox='0 0 32 32'>` +
|
||||||
|
`<g fill='oklch(0.86 0.13 88)' opacity='0.92'>${petals}</g>` +
|
||||||
|
`<circle cx='16' cy='16' r='3.2' fill='oklch(0.94 0.10 95)'/>` +
|
||||||
|
`</svg>`;
|
||||||
|
return `url("data:image/svg+xml;utf8,${encodeURIComponent(svg)}")`;
|
||||||
|
})();
|
||||||
|
|
||||||
|
export function LunarNewYearOverlay({ reduced }: SeasonalOverlayProps) {
|
||||||
|
// Paper lanterns strung across the upper third, gently staggered in depth.
|
||||||
|
const lanterns = useMemo<Lantern[]>(() => {
|
||||||
|
const slots = [
|
||||||
|
{ left: 9, top: 7, scale: 1.0 },
|
||||||
|
{ left: 27, top: 13, scale: 0.82 },
|
||||||
|
{ left: 46, top: 6, scale: 1.12 },
|
||||||
|
{ left: 64, top: 15, scale: 0.78 },
|
||||||
|
{ left: 82, top: 9, scale: 0.95 },
|
||||||
|
{ left: 92, top: 20, scale: 0.7 },
|
||||||
|
];
|
||||||
|
return slots.map((s, i) => ({
|
||||||
|
left: s.left,
|
||||||
|
top: s.top,
|
||||||
|
scale: s.scale,
|
||||||
|
bobDuration: 7 + rand(i + 1) * 4,
|
||||||
|
swayDuration: 5.5 + rand(i + 4) * 3,
|
||||||
|
delay: -rand(i + 7) * 6,
|
||||||
|
opacity: 0.78 + rand(i + 2) * 0.18,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Drifting gold plum-blossom petals — two parallax bands (far small/dim/slow,
|
||||||
|
// near large/bright/fast) for depth.
|
||||||
|
const petals = useMemo<Petal[]>(() => {
|
||||||
|
const bands = [
|
||||||
|
{ count: 9, size: [9, 14], dur: [15, 21], op: [0.4, 0.6], blur: 0.6 },
|
||||||
|
{ count: 8, size: [15, 24], dur: [10, 14], op: [0.6, 0.85], blur: 0 },
|
||||||
|
];
|
||||||
|
const out: Petal[] = [];
|
||||||
|
let s = 1;
|
||||||
|
bands.forEach((b) => {
|
||||||
|
for (let i = 0; i < b.count; i += 1) {
|
||||||
|
const r1 = rand(s);
|
||||||
|
const r2 = rand(s + 0.37);
|
||||||
|
const r3 = rand(s + 0.71);
|
||||||
|
const r4 = rand(s + 0.91);
|
||||||
|
out.push({
|
||||||
|
left: r1 * 100,
|
||||||
|
size: b.size[0] + r2 * (b.size[1] - b.size[0]),
|
||||||
|
duration: b.dur[0] + r3 * (b.dur[1] - b.dur[0]),
|
||||||
|
delay: -r4 * (b.dur[1] + 4),
|
||||||
|
swayDuration: 5 + r2 * 5,
|
||||||
|
opacity: b.op[0] + r3 * (b.op[1] - b.op[0]),
|
||||||
|
blur: b.blur,
|
||||||
|
hue: 82 + r4 * 10,
|
||||||
|
});
|
||||||
|
s += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// A few gold embers rising from the lanterns (motion scene only).
|
||||||
|
const embers = useMemo<Ember[]>(
|
||||||
|
() =>
|
||||||
|
Array.from({ length: 7 }, (_, i) => ({
|
||||||
|
left: 8 + rand(i + 11) * 84,
|
||||||
|
bottom: 8 + rand(i + 21) * 30,
|
||||||
|
size: 1.6 + rand(i + 31) * 2.2,
|
||||||
|
duration: 9 + rand(i + 41) * 6,
|
||||||
|
delay: -rand(i + 51) * 12,
|
||||||
|
})),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Deep lacquer-red ambient wash — layered radial + linear oklch gradients
|
||||||
|
for depth and a warm crimson lantern-glow from above. Low-opacity so
|
||||||
|
chat text stays legible (WCAG-AA). */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
contain: 'layout paint style',
|
||||||
|
backgroundImage: [
|
||||||
|
`radial-gradient(120% 80% at 50% -8%, ${CRIMSON.replace(')', ' / 0.16)')} 0%, transparent 56%)`,
|
||||||
|
`radial-gradient(90% 70% at 50% 112%, oklch(0.42 0.17 28 / 0.1) 0%, transparent 60%)`,
|
||||||
|
`linear-gradient(180deg, oklch(0.55 0.20 28 / 0.07) 0%, transparent 26%, transparent 82%, oklch(0.40 0.16 28 / 0.08) 100%)`,
|
||||||
|
].join(','),
|
||||||
|
animation: reduced ? 'none' : `${animLacquerPulse} 13s ease-in-out infinite`,
|
||||||
|
willChange: reduced ? undefined : 'opacity',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Imperial-gold dragon silhouette arcing across the upper scene — a
|
||||||
|
single composited SVG layer, blurred and screen-blended so it reads as
|
||||||
|
an ethereal gilt apparition, never a hard graphic. */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '8%',
|
||||||
|
left: '-6%',
|
||||||
|
right: '-6%',
|
||||||
|
height: '46%',
|
||||||
|
contain: 'layout paint style',
|
||||||
|
backgroundImage: dragonUri,
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
backgroundSize: 'contain',
|
||||||
|
mixBlendMode: 'screen',
|
||||||
|
filter: 'blur(1.1px)',
|
||||||
|
opacity: reduced ? 0.52 : undefined,
|
||||||
|
animation: reduced ? 'none' : `${animDragonDrift} 30s ease-in-out infinite`,
|
||||||
|
willChange: reduced ? undefined : 'transform, opacity',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Warm vignette frame — crimson edges, clear center, with a faint cheap
|
||||||
|
backdrop-filter for a silken haze around the rim. */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
contain: 'layout paint style',
|
||||||
|
backdropFilter: 'blur(0.4px) saturate(1.05)',
|
||||||
|
WebkitBackdropFilter: 'blur(0.4px) saturate(1.05)',
|
||||||
|
backgroundImage:
|
||||||
|
'radial-gradient(135% 120% at 50% 40%, transparent 54%, oklch(0.55 0.16 28 / 0.06) 76%, oklch(0.40 0.16 28 / 0.16) 100%)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* The garland string the lanterns hang from — a faint warm line. */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: '6%',
|
||||||
|
contain: 'layout paint style',
|
||||||
|
backgroundImage: `radial-gradient(140% 80% at 50% -40%, ${GOLD.replace(
|
||||||
|
')',
|
||||||
|
' / 0.14)',
|
||||||
|
)} 0%, transparent 70%)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Paper lanterns. Each is a hung group: a sway wrapper rotating about its
|
||||||
|
mount, an inner bob, then the lantern body (glow + ribs + caps) and a
|
||||||
|
trailing tassel. */}
|
||||||
|
{lanterns.map((l, i) => {
|
||||||
|
const W = 30 * l.scale;
|
||||||
|
const H = 38 * l.scale;
|
||||||
|
const cap = Math.max(8, W * 0.5);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`lantern-${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${l.left}%`,
|
||||||
|
top: `${l.top}%`,
|
||||||
|
marginLeft: `${-W / 2}px`,
|
||||||
|
transformOrigin: 'top center',
|
||||||
|
opacity: l.opacity,
|
||||||
|
animation: reduced
|
||||||
|
? 'none'
|
||||||
|
: `${animLanternSway} ${l.swayDuration}s ease-in-out ${l.delay}s infinite`,
|
||||||
|
willChange: reduced ? undefined : 'transform',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
animation: reduced
|
||||||
|
? 'none'
|
||||||
|
: `${animLanternBob} ${l.bobDuration}s ease-in-out ${l.delay}s infinite`,
|
||||||
|
willChange: reduced ? undefined : 'transform',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* short cord from the string to the top cap */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '2px',
|
||||||
|
height: `${10 * l.scale}px`,
|
||||||
|
margin: '0 auto',
|
||||||
|
background: `linear-gradient(${GOLD}, ${GOLD.replace(')', ' / 0.3)')})`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* top gold cap */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: `${cap}px`,
|
||||||
|
height: `${4 * l.scale}px`,
|
||||||
|
margin: '0 auto',
|
||||||
|
borderRadius: `${2 * l.scale}px`,
|
||||||
|
background: `linear-gradient(90deg, ${GOLD.replace(
|
||||||
|
')',
|
||||||
|
' / 0.6)',
|
||||||
|
)}, ${GOLD_HI}, ${GOLD.replace(')', ' / 0.6)')})`,
|
||||||
|
boxShadow: `0 0 ${5 * l.scale}px ${GOLD.replace(')', ' / 0.55)')}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* lantern body */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
width: `${W}px`,
|
||||||
|
height: `${H}px`,
|
||||||
|
margin: `${1 * l.scale}px auto`,
|
||||||
|
borderRadius: '50% / 42%',
|
||||||
|
background: `radial-gradient(circle at 38% 32%, ${VERMILION.replace(
|
||||||
|
')',
|
||||||
|
' / 0.95)',
|
||||||
|
)} 0%, ${CRIMSON} 58%, oklch(0.40 0.18 26 / 0.95) 100%)`,
|
||||||
|
border: `${1.2 * l.scale}px solid ${GOLD.replace(')', ' / 0.8)')}`,
|
||||||
|
boxShadow: `0 0 ${16 * l.scale}px ${CRIMSON.replace(
|
||||||
|
')',
|
||||||
|
' / 0.5)',
|
||||||
|
)}, inset 0 0 ${10 * l.scale}px oklch(0.78 0.16 60 / 0.35)`,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* breathing inner candle glow */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: '50%',
|
||||||
|
top: '52%',
|
||||||
|
width: `${W * 0.6}px`,
|
||||||
|
height: `${H * 0.55}px`,
|
||||||
|
marginLeft: `${-W * 0.3}px`,
|
||||||
|
marginTop: `${-H * 0.275}px`,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: `radial-gradient(circle, ${GOLD_HI.replace(
|
||||||
|
')',
|
||||||
|
' / 0.9)',
|
||||||
|
)} 0%, oklch(0.80 0.16 65 / 0.5) 45%, transparent 75%)`,
|
||||||
|
filter: 'blur(1px)',
|
||||||
|
animation: reduced
|
||||||
|
? 'none'
|
||||||
|
: `${animGlowBreathe} ${l.bobDuration * 0.7}s ease-in-out ${
|
||||||
|
l.delay
|
||||||
|
}s infinite`,
|
||||||
|
willChange: reduced ? undefined : 'transform, opacity',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* vertical paper ribs */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
backgroundImage: `repeating-linear-gradient(90deg, transparent 0, transparent ${
|
||||||
|
W / 6 - 0.6
|
||||||
|
}px, ${GOLD.replace(')', ' / 0.18)')} ${W / 6 - 0.6}px, ${GOLD.replace(
|
||||||
|
')',
|
||||||
|
' / 0.18)',
|
||||||
|
)} ${W / 6}px)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* bottom gold cap */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: `${cap}px`,
|
||||||
|
height: `${4 * l.scale}px`,
|
||||||
|
margin: '0 auto',
|
||||||
|
borderRadius: `${2 * l.scale}px`,
|
||||||
|
background: `linear-gradient(90deg, ${GOLD.replace(
|
||||||
|
')',
|
||||||
|
' / 0.6)',
|
||||||
|
)}, ${GOLD_HI}, ${GOLD.replace(')', ' / 0.6)')})`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* swaying silk tassel */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: `${2 * l.scale}px`,
|
||||||
|
height: `${16 * l.scale}px`,
|
||||||
|
margin: '0 auto',
|
||||||
|
transformOrigin: 'top center',
|
||||||
|
background: `linear-gradient(${CRIMSON}, ${GOLD.replace(')', ' / 0.8)')})`,
|
||||||
|
borderRadius: '1px',
|
||||||
|
animation: reduced
|
||||||
|
? 'none'
|
||||||
|
: `${animTasselSway} ${l.swayDuration * 0.8}s ease-in-out ${l.delay}s infinite`,
|
||||||
|
willChange: reduced ? undefined : 'transform',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Drifting gold plum-blossom petals (motion only). Static settled
|
||||||
|
blossoms render below for the reduced/preview scene. */}
|
||||||
|
{!reduced &&
|
||||||
|
petals.map((p, i) => (
|
||||||
|
<div
|
||||||
|
key={`petal-${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: `${p.left}%`,
|
||||||
|
width: `${p.size}px`,
|
||||||
|
height: `${p.size}px`,
|
||||||
|
animation: `${animPetalSway} ${p.swayDuration}s ease-in-out ${p.delay}s infinite`,
|
||||||
|
willChange: 'transform',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
backgroundImage: blossomUri,
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
backgroundSize: 'contain',
|
||||||
|
opacity: p.opacity,
|
||||||
|
filter: p.blur ? `blur(${p.blur}px)` : undefined,
|
||||||
|
animation: `${animPetalFall} ${p.duration}s linear ${p.delay}s infinite`,
|
||||||
|
willChange: 'transform, opacity',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Static settled blossoms for the reduced-motion / preview scene — a
|
||||||
|
serene scatter so the thumbnail still reads as a blossom drift. */}
|
||||||
|
{reduced &&
|
||||||
|
petals.slice(0, 12).map((p, i) => {
|
||||||
|
const py = rand(i + 0.5) * 92 + 4;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`petal-static-${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${p.left}%`,
|
||||||
|
top: `${py}%`,
|
||||||
|
width: `${p.size}px`,
|
||||||
|
height: `${p.size}px`,
|
||||||
|
backgroundImage: blossomUri,
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
backgroundSize: 'contain',
|
||||||
|
opacity: p.opacity,
|
||||||
|
transform: `rotate(${rand(i + 3) * 360}deg)`,
|
||||||
|
filter: p.blur ? `blur(${p.blur}px)` : undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Gold embers rising off the lanterns (motion only). */}
|
||||||
|
{!reduced &&
|
||||||
|
embers.map((e, i) => (
|
||||||
|
<div
|
||||||
|
key={`ember-${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${e.left}%`,
|
||||||
|
bottom: `${e.bottom}%`,
|
||||||
|
width: `${e.size}px`,
|
||||||
|
height: `${e.size}px`,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: `radial-gradient(circle, ${GOLD_HI} 0%, ${GOLD.replace(
|
||||||
|
')',
|
||||||
|
' / 0.7)',
|
||||||
|
)} 50%, transparent 80%)`,
|
||||||
|
boxShadow: `0 0 5px ${GOLD.replace(')', ' / 0.6)')}`,
|
||||||
|
animation: `${animEmberRise} ${e.duration}s ease-in ${e.delay}s infinite`,
|
||||||
|
willChange: 'transform, opacity',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { keyframes } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* New Year overlay keyframes — a midnight celebration. Every animation touches
|
||||||
|
* ONLY `transform` and `opacity` so the compositor runs them on the GPU with no
|
||||||
|
* layout/paint. keyframes() returns the generated animation-name string, which
|
||||||
|
* is applied inline by the component. Heavy/static structure (gradients, SVG
|
||||||
|
* data-URIs, geometry) lives in the component; this module is motion only.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Firework burst — a thin spark ring expands from a pinpoint, brightens, then
|
||||||
|
* fades as it grows. Scale + opacity only; the ring is a radial-gradient border
|
||||||
|
* supplied inline. Long pauses between bursts come from a low keyframe-duty:
|
||||||
|
* the ring spends most of the cycle collapsed and invisible.
|
||||||
|
*/
|
||||||
|
export const animBurst = keyframes({
|
||||||
|
'0%': { transform: 'scale(0.05)', opacity: '0' },
|
||||||
|
'4%': { transform: 'scale(0.12)', opacity: '0.95' },
|
||||||
|
'22%': { transform: 'scale(1)', opacity: '0.55' },
|
||||||
|
'34%': { transform: 'scale(1.25)', opacity: '0' },
|
||||||
|
'100%': { transform: 'scale(1.25)', opacity: '0' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Burst core flash — the bright pinpoint at a firework's origin pops just before
|
||||||
|
* the ring blooms, then quickly dims. Pairs with animBurst on the same cadence.
|
||||||
|
*/
|
||||||
|
export const animCoreFlash = keyframes({
|
||||||
|
'0%': { transform: 'scale(0.2)', opacity: '0' },
|
||||||
|
'3%': { transform: 'scale(1)', opacity: '1' },
|
||||||
|
'14%': { transform: 'scale(0.6)', opacity: '0' },
|
||||||
|
'100%': { transform: 'scale(0.6)', opacity: '0' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Champagne shimmer sweep — a wide soft gold band glides diagonally across the
|
||||||
|
* scene and breathes in brightness. translateX + opacity (never
|
||||||
|
* background-position) keep it on the compositor.
|
||||||
|
*/
|
||||||
|
export const animShimmer = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(-120%, 0, 0) skewX(-12deg)', opacity: '0' },
|
||||||
|
'12%': { opacity: '0.7' },
|
||||||
|
'50%': { opacity: '0.5' },
|
||||||
|
'88%': { opacity: '0.6' },
|
||||||
|
'100%': { transform: 'translate3d(120%, 0, 0) skewX(-12deg)', opacity: '0' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confetti fall — a small sliver tumbles the full height while spinning on two
|
||||||
|
* axes, fading in at the top and out at the bottom. A tall translateY lets one
|
||||||
|
* keyframe set serve every sliver; per-piece duration/delay/scale add variety.
|
||||||
|
*/
|
||||||
|
export const animConfettiFall = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(0, -10vh, 0) rotateZ(0deg) rotateX(0deg)', opacity: '0' },
|
||||||
|
'8%': { opacity: '0.9' },
|
||||||
|
'50%': { transform: 'translate3d(2.2vw, 52vh, 0) rotateZ(220deg) rotateX(180deg)' },
|
||||||
|
'92%': { opacity: '0.85' },
|
||||||
|
'100%': {
|
||||||
|
transform: 'translate3d(-1.8vw, 114vh, 0) rotateZ(440deg) rotateX(360deg)',
|
||||||
|
opacity: '0',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lateral confetti sway on the wrapper, decoupled from the fall so the two
|
||||||
|
* combine into an organic drifting path rather than a straight drop.
|
||||||
|
*/
|
||||||
|
export const animConfettiSway = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(0, 0, 0)' },
|
||||||
|
'50%': { transform: 'translate3d(2.4vw, 0, 0)' },
|
||||||
|
'100%': { transform: 'translate3d(0, 0, 0)' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Star twinkle — a sparkle pulses in brightness and size, like a glint. */
|
||||||
|
export const animTwinkle = keyframes({
|
||||||
|
'0%': { transform: 'scale(0.5) rotate(0deg)', opacity: '0.2' },
|
||||||
|
'50%': { transform: 'scale(1) rotate(45deg)', opacity: '0.95' },
|
||||||
|
'100%': { transform: 'scale(0.5) rotate(0deg)', opacity: '0.2' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Barely-there breathing of the midnight tint so the static base feels alive. */
|
||||||
|
export const animSkyPulse = keyframes({
|
||||||
|
'0%': { opacity: '0.82' },
|
||||||
|
'50%': { opacity: '1' },
|
||||||
|
'100%': { opacity: '0.82' },
|
||||||
|
});
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { SeasonalOverlayProps } from '../types';
|
||||||
|
import {
|
||||||
|
animBurst,
|
||||||
|
animCoreFlash,
|
||||||
|
animShimmer,
|
||||||
|
animConfettiFall,
|
||||||
|
animConfettiSway,
|
||||||
|
animTwinkle,
|
||||||
|
animSkyPulse,
|
||||||
|
} from './NewYear.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* New Year overlay — a midnight celebration. Layered oklch gradients sink the
|
||||||
|
* app into a deep navy night; fireworks bloom as expanding spark rings, a
|
||||||
|
* champagne-gold shimmer sweeps across, confetti slivers tumble down, and
|
||||||
|
* sparkle stars twinkle. All motion is transform/opacity only.
|
||||||
|
*
|
||||||
|
* Palette (oklch): midnight navy oklch(0.20 0.07 260), champagne gold
|
||||||
|
* oklch(0.85 0.13 90), bursts in magenta oklch(0.7 0.22 350), cyan
|
||||||
|
* oklch(0.8 0.15 200), and gold.
|
||||||
|
*
|
||||||
|
* RENDERING CONTRACT: the parent supplies a fixed inset:0 overflow:hidden
|
||||||
|
* pointer-events:none container at the right z-index. We only return
|
||||||
|
* absolutely-positioned aria-hidden children at low opacity — no z-index,
|
||||||
|
* position:fixed, or pointer-events here — kept well below opaque so chat text
|
||||||
|
* stays WCAG-AA legible.
|
||||||
|
*
|
||||||
|
* REDUCED MOTION: when `reduced`, render a static but gorgeous scene (a frozen
|
||||||
|
* firework bloom mid-burst, scattered gold confetti, a still shimmer band) with
|
||||||
|
* no `animation` at all. The settings preview always passes reduced=true.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BURST_HUES = [
|
||||||
|
// [ring oklch, core oklch]
|
||||||
|
['oklch(0.7 0.22 350)', 'oklch(0.88 0.14 350)'], // magenta
|
||||||
|
['oklch(0.8 0.15 200)', 'oklch(0.92 0.1 200)'], // cyan
|
||||||
|
['oklch(0.85 0.13 90)', 'oklch(0.95 0.09 95)'], // gold
|
||||||
|
['oklch(0.75 0.2 30)', 'oklch(0.9 0.12 40)'], // warm coral
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const CONFETTI_COLORS = [
|
||||||
|
'oklch(0.85 0.13 90)', // champagne gold
|
||||||
|
'oklch(0.7 0.22 350)', // magenta
|
||||||
|
'oklch(0.8 0.15 200)', // cyan
|
||||||
|
'oklch(0.9 0.06 90)', // pale gold
|
||||||
|
'oklch(0.78 0.18 30)', // coral
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type Burst = {
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
size: number;
|
||||||
|
ring: string;
|
||||||
|
core: string;
|
||||||
|
duration: number;
|
||||||
|
delay: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Confetto = {
|
||||||
|
left: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
color: string;
|
||||||
|
round: boolean;
|
||||||
|
fallDur: number;
|
||||||
|
swayDur: number;
|
||||||
|
delay: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Star = {
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
size: number;
|
||||||
|
color: string;
|
||||||
|
duration: number;
|
||||||
|
delay: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Deterministic pseudo-random so the memoized scene is stable across renders.
|
||||||
|
const rand = (seed: number) => {
|
||||||
|
const x = Math.sin(seed * 12.9898 + 78.233) * 43758.5453;
|
||||||
|
return x - Math.floor(x);
|
||||||
|
};
|
||||||
|
|
||||||
|
// A four-point sparkle (gleam) as an inline SVG data-URI — CSP-safe, no assets.
|
||||||
|
const sparkleUri = (color: string) =>
|
||||||
|
`url("data:image/svg+xml,${encodeURIComponent(
|
||||||
|
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M12 0 L14 10 L24 12 L14 14 L12 24 L10 14 L0 12 L10 10 Z' fill='${color}'/></svg>`,
|
||||||
|
)}")`;
|
||||||
|
|
||||||
|
export function NewYearOverlay({ reduced }: SeasonalOverlayProps) {
|
||||||
|
const bursts = useMemo<Burst[]>(
|
||||||
|
() =>
|
||||||
|
// Bursts cluster in the upper two-thirds of the sky, away from typical text.
|
||||||
|
Array.from({ length: 7 }, (_, i) => {
|
||||||
|
const hue = BURST_HUES[i % BURST_HUES.length];
|
||||||
|
return {
|
||||||
|
top: 8 + rand(i + 1) * 48,
|
||||||
|
left: 8 + rand(i + 11) * 84,
|
||||||
|
size: 130 + Math.floor(rand(i + 21) * 110),
|
||||||
|
ring: hue[0],
|
||||||
|
core: hue[1],
|
||||||
|
duration: 6.5 + rand(i + 31) * 4,
|
||||||
|
delay: rand(i + 41) * 9,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const confetti = useMemo<Confetto[]>(
|
||||||
|
() =>
|
||||||
|
Array.from({ length: 20 }, (_, i) => ({
|
||||||
|
left: rand(i + 101) * 100,
|
||||||
|
w: 4 + Math.floor(rand(i + 111) * 4),
|
||||||
|
h: 7 + Math.floor(rand(i + 121) * 7),
|
||||||
|
color: CONFETTI_COLORS[i % CONFETTI_COLORS.length],
|
||||||
|
round: i % 4 === 0,
|
||||||
|
fallDur: 9 + rand(i + 131) * 7,
|
||||||
|
swayDur: 3 + rand(i + 141) * 3,
|
||||||
|
delay: rand(i + 151) * 10,
|
||||||
|
})),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const stars = useMemo<Star[]>(
|
||||||
|
() =>
|
||||||
|
Array.from({ length: 9 }, (_, i) => ({
|
||||||
|
top: 4 + rand(i + 201) * 64,
|
||||||
|
left: 4 + rand(i + 211) * 92,
|
||||||
|
size: 8 + Math.floor(rand(i + 221) * 10),
|
||||||
|
color: i % 2 === 0 ? 'oklch(0.85 0.13 90)' : 'oklch(0.92 0.06 200)',
|
||||||
|
duration: 3 + rand(i + 231) * 3,
|
||||||
|
delay: rand(i + 241) * 4,
|
||||||
|
})),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Midnight sky — layered oklch gradients for depth, with a faint breathe. */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
contain: 'layout paint style',
|
||||||
|
backgroundColor: 'oklch(0.2 0.07 260 / 0.12)',
|
||||||
|
backgroundImage: [
|
||||||
|
'radial-gradient(120% 90% at 50% -10%, oklch(0.32 0.1 280 / 0.16) 0%, transparent 60%)',
|
||||||
|
'radial-gradient(90% 70% at 18% 8%, oklch(0.7 0.22 350 / 0.07) 0%, transparent 55%)',
|
||||||
|
'radial-gradient(90% 70% at 84% 4%, oklch(0.8 0.15 200 / 0.07) 0%, transparent 55%)',
|
||||||
|
'radial-gradient(140% 120% at 50% 120%, oklch(0.2 0.07 260 / 0.14) 0%, transparent 70%)',
|
||||||
|
].join(','),
|
||||||
|
animation: reduced ? 'none' : `${animSkyPulse} 9s ease-in-out infinite`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Champagne-gold shimmer sweep. */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '55%',
|
||||||
|
contain: 'layout paint style',
|
||||||
|
backgroundImage:
|
||||||
|
'linear-gradient(100deg, transparent 0%, oklch(0.85 0.13 90 / 0.05) 38%, oklch(0.95 0.09 95 / 0.1) 50%, oklch(0.85 0.13 90 / 0.05) 62%, transparent 100%)',
|
||||||
|
transform: reduced ? 'translate3d(30%, 0, 0) skewX(-12deg)' : undefined,
|
||||||
|
opacity: reduced ? 0.45 : undefined,
|
||||||
|
animation: reduced ? 'none' : `${animShimmer} 11s ease-in-out infinite`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Fireworks — expanding spark rings + a core flash. In reduced mode we
|
||||||
|
freeze the first burst mid-bloom and drop the rest. */}
|
||||||
|
{(reduced ? bursts.slice(0, 1) : bursts).map((b, i) => (
|
||||||
|
<div
|
||||||
|
key={`b${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: `${b.top}%`,
|
||||||
|
left: `${b.left}%`,
|
||||||
|
width: `${b.size}px`,
|
||||||
|
height: `${b.size}px`,
|
||||||
|
marginLeft: `${-b.size / 2}px`,
|
||||||
|
marginTop: `${-b.size / 2}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Spark ring */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
borderRadius: '50%',
|
||||||
|
willChange: reduced ? undefined : 'transform, opacity',
|
||||||
|
background: `radial-gradient(circle, transparent 56%, ${b.ring} 64%, transparent 74%)`,
|
||||||
|
transform: reduced ? 'scale(0.82)' : undefined,
|
||||||
|
opacity: reduced ? 0.6 : undefined,
|
||||||
|
animation: reduced
|
||||||
|
? 'none'
|
||||||
|
: `${animBurst} ${b.duration}s ease-out ${b.delay}s infinite`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Inner secondary ring for a fuller bloom */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: '18%',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: `radial-gradient(circle, transparent 50%, ${b.ring} 60%, transparent 72%)`,
|
||||||
|
transform: reduced ? 'scale(0.7)' : undefined,
|
||||||
|
opacity: reduced ? 0.4 : undefined,
|
||||||
|
animation: reduced
|
||||||
|
? 'none'
|
||||||
|
: `${animBurst} ${b.duration}s ease-out ${b.delay + 0.12}s infinite`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Core flash */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: '50%',
|
||||||
|
top: '50%',
|
||||||
|
width: '14%',
|
||||||
|
height: '14%',
|
||||||
|
marginLeft: '-7%',
|
||||||
|
marginTop: '-7%',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: `radial-gradient(circle, ${b.core} 0%, transparent 70%)`,
|
||||||
|
transform: reduced ? 'scale(0.9)' : undefined,
|
||||||
|
opacity: reduced ? 0.85 : undefined,
|
||||||
|
animation: reduced
|
||||||
|
? 'none'
|
||||||
|
: `${animCoreFlash} ${b.duration}s ease-out ${b.delay}s infinite`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Twinkling sparkle stars. */}
|
||||||
|
{stars.map((s, i) => (
|
||||||
|
<div
|
||||||
|
key={`s${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: `${s.top}%`,
|
||||||
|
left: `${s.left}%`,
|
||||||
|
width: `${s.size}px`,
|
||||||
|
height: `${s.size}px`,
|
||||||
|
backgroundImage: sparkleUri(s.color),
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
backgroundSize: 'contain',
|
||||||
|
transform: reduced ? 'scale(0.9) rotate(30deg)' : undefined,
|
||||||
|
opacity: reduced ? 0.75 : undefined,
|
||||||
|
animation: reduced
|
||||||
|
? 'none'
|
||||||
|
: `${animTwinkle} ${s.duration}s ease-in-out ${s.delay}s infinite`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Falling confetti slivers. In reduced mode, a still scatter at varied
|
||||||
|
heights so the static thumbnail reads as a celebration in progress. */}
|
||||||
|
{confetti.map((c, i) => {
|
||||||
|
const staticTop = reduced ? 6 + rand(i + 301) * 78 : undefined;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`c${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: reduced ? `${staticTop}%` : 0,
|
||||||
|
left: `${c.left}%`,
|
||||||
|
willChange: reduced ? undefined : 'transform',
|
||||||
|
animation: reduced
|
||||||
|
? 'none'
|
||||||
|
: `${animConfettiSway} ${c.swayDur}s ease-in-out ${c.delay}s infinite`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: `${c.w}px`,
|
||||||
|
height: reduced && c.round ? `${c.w}px` : `${c.h}px`,
|
||||||
|
borderRadius: c.round ? '50%' : '1px',
|
||||||
|
backgroundColor: c.color,
|
||||||
|
opacity: reduced ? 0.8 : 0.85,
|
||||||
|
transform: reduced ? `rotate(${Math.floor(rand(i + 311) * 360)}deg)` : undefined,
|
||||||
|
animation: reduced
|
||||||
|
? 'none'
|
||||||
|
: `${animConfettiFall} ${c.fallDur}s ease-in ${c.delay}s infinite`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { keyframes } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clover tumble — a shamrock silhouette drifts down while tumbling on two axes.
|
||||||
|
* GPU-only: a single tall translateY plus rotate; per-clover duration/delay and
|
||||||
|
* a decoupled sway (below) create organic, non-repeating paths. The horizontal
|
||||||
|
* offsets stay small so clovers fall roughly in their column.
|
||||||
|
*/
|
||||||
|
export const animCloverTumble = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(0, -10vh, 0) rotate(0deg)', opacity: '0' },
|
||||||
|
'8%': { opacity: '1' },
|
||||||
|
'50%': { transform: 'translate3d(12px, 50vh, 0) rotate(220deg)' },
|
||||||
|
'92%': { opacity: '0.8' },
|
||||||
|
'100%': { transform: 'translate3d(-8px, 114vh, 0) rotate(420deg)', opacity: '0' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lateral sway applied to a clover's wrapper so the descent reads as a leaf
|
||||||
|
* caught by a breeze, decoupled from the fall for an organic combined path.
|
||||||
|
*/
|
||||||
|
export const animCloverSway = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(0, 0, 0)' },
|
||||||
|
'50%': { transform: 'translate3d(20px, 0, 0)' },
|
||||||
|
'100%': { transform: 'translate3d(0, 0, 0)' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verdant ambiance breathe — the emerald wash and vignette gently swell so the
|
||||||
|
* static tint feels alive without distracting motion. Opacity only.
|
||||||
|
*/
|
||||||
|
export const animVerdantBreathe = keyframes({
|
||||||
|
'0%': { opacity: '0.8' },
|
||||||
|
'50%': { opacity: '1' },
|
||||||
|
'100%': { opacity: '0.8' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rainbow shimmer — the soft arc in the corner slowly slides and breathes.
|
||||||
|
* Uses translate + scale + opacity (never background-position) so it stays on
|
||||||
|
* the compositor.
|
||||||
|
*/
|
||||||
|
export const animRainbowShimmer = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(-3%, 1%, 0) scale(1)', opacity: '0.45' },
|
||||||
|
'50%': { transform: 'translate3d(3%, -1%, 0) scale(1.04)', opacity: '0.7' },
|
||||||
|
'100%': { transform: 'translate3d(-3%, 1%, 0) scale(1)', opacity: '0.45' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gold coin glint — a metallic disc tilts and brightens as a struck-light
|
||||||
|
* flicker, then settles. Transform + opacity only so it composites cheaply.
|
||||||
|
*/
|
||||||
|
export const animCoinGlint = keyframes({
|
||||||
|
'0%': { transform: 'scale(0.9) rotate(-8deg)', opacity: '0.35' },
|
||||||
|
'20%': { transform: 'scale(1.06) rotate(0deg)', opacity: '0.9' },
|
||||||
|
'45%': { transform: 'scale(0.94) rotate(6deg)', opacity: '0.5' },
|
||||||
|
'100%': { transform: 'scale(0.9) rotate(-8deg)', opacity: '0.35' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sparkle mote twinkle — a tiny golden point pulses in scale and brightness
|
||||||
|
* like a struck spark of luck. Opacity + transform only.
|
||||||
|
*/
|
||||||
|
export const animMoteTwinkle = keyframes({
|
||||||
|
'0%': { transform: 'scale(0.5)', opacity: '0.1' },
|
||||||
|
'50%': { transform: 'scale(1.25)', opacity: '0.95' },
|
||||||
|
'100%': { transform: 'scale(0.5)', opacity: '0.1' },
|
||||||
|
});
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { SeasonalOverlayProps } from '../types';
|
||||||
|
import {
|
||||||
|
animCloverTumble,
|
||||||
|
animCloverSway,
|
||||||
|
animVerdantBreathe,
|
||||||
|
animRainbowShimmer,
|
||||||
|
animCoinGlint,
|
||||||
|
animMoteTwinkle,
|
||||||
|
} from './StPatricks.css';
|
||||||
|
|
||||||
|
// Deterministic pseudo-random so the scene is identical every mount (no React
|
||||||
|
// state per frame). Large primes keep the distribution well spread.
|
||||||
|
const rand = (seed: number) => {
|
||||||
|
const x = Math.sin(seed * 127.1 + 311.7) * 43758.5453;
|
||||||
|
return x - Math.floor(x);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Shamrock (three-leaf) and lucky four-leaf clover silhouettes as inline SVG
|
||||||
|
// data-URIs — pure CSS, no external assets, Tauri/CSP-safe. The `fill` color is
|
||||||
|
// baked per-variant in oklch-adjacent sRGB (data-URIs can't carry oklch), kept
|
||||||
|
// luminous green so the glyphs read as foliage even at low opacity.
|
||||||
|
const cloverSvg = (leaves: 3 | 4, fill: string) => {
|
||||||
|
// Each leaf is a heart-ish lobe; petals arranged radially around the stem.
|
||||||
|
const heart = 'M0,-2 C5,-12 18,-9 14,2 C12,8 4,9 0,3 C-4,9 -12,8 -14,2 C-18,-9 -5,-12 0,-2 Z';
|
||||||
|
// Rotations for the lobes; 3-leaf = 120° spread, 4-leaf = 90° spread.
|
||||||
|
const rots = leaves === 4 ? [0, 90, 180, 270] : [-90, 30, 150];
|
||||||
|
const lobes = rots
|
||||||
|
.map((r) => `<path d="${heart}" transform="rotate(${r}) translate(0 -12)"/>`)
|
||||||
|
.join('');
|
||||||
|
const stem = `<path d="M0,8 C-1,18 2,26 0,34" stroke="${
|
||||||
|
fill
|
||||||
|
}" stroke-width="2.4" fill="none" stroke-linecap="round"/>`;
|
||||||
|
const svg =
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="-26 -26 52 64">` +
|
||||||
|
`<g fill="${fill}">${lobes}</g>${stem}</svg>`;
|
||||||
|
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Three foliage greens for parallax depth — far/dim through near/bright. These
|
||||||
|
// are the sRGB siblings of the brief's oklch emerald / shamrock-green targets.
|
||||||
|
const CLOVER_FILLS = [
|
||||||
|
'#1f9e54', // deep shamrock (far)
|
||||||
|
'#2db866', // emerald (mid)
|
||||||
|
'#48d97f', // bright clover (near)
|
||||||
|
];
|
||||||
|
|
||||||
|
type Clover = {
|
||||||
|
left: number;
|
||||||
|
size: number;
|
||||||
|
duration: number;
|
||||||
|
delay: number;
|
||||||
|
swayDuration: number;
|
||||||
|
opacity: number;
|
||||||
|
blur: number;
|
||||||
|
fill: string;
|
||||||
|
leaves: 3 | 4;
|
||||||
|
// Resting position + tilt for the static (reduced) settled scene.
|
||||||
|
restTop: number;
|
||||||
|
restRot: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Coin = {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
size: number;
|
||||||
|
duration: number;
|
||||||
|
delay: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Mote = {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
size: number;
|
||||||
|
duration: number;
|
||||||
|
delay: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StPatricksOverlay({ reduced }: SeasonalOverlayProps) {
|
||||||
|
// Three parallax bands of clovers: far (small/slow/dim) -> near (large/fast).
|
||||||
|
// ~22 clovers total; one lucky four-leaf seeded in for charm.
|
||||||
|
const clovers = useMemo<Clover[]>(() => {
|
||||||
|
const bands = [
|
||||||
|
{ count: 8, size: [12, 18], dur: [20, 26], op: [0.22, 0.34], blur: 0.8, fill: 0 },
|
||||||
|
{ count: 8, size: [18, 26], dur: [15, 20], op: [0.34, 0.5], blur: 0.4, fill: 1 },
|
||||||
|
{ count: 6, size: [26, 38], dur: [11, 15], op: [0.46, 0.62], blur: 0, fill: 2 },
|
||||||
|
];
|
||||||
|
const out: Clover[] = [];
|
||||||
|
let s = 1;
|
||||||
|
bands.forEach((b) => {
|
||||||
|
for (let i = 0; i < b.count; i += 1) {
|
||||||
|
const r1 = rand(s);
|
||||||
|
const r2 = rand(s + 0.37);
|
||||||
|
const r3 = rand(s + 0.71);
|
||||||
|
const r4 = rand(s + 0.91);
|
||||||
|
const r5 = rand(s + 1.13);
|
||||||
|
out.push({
|
||||||
|
left: r1 * 100,
|
||||||
|
size: b.size[0] + r2 * (b.size[1] - b.size[0]),
|
||||||
|
duration: b.dur[0] + r3 * (b.dur[1] - b.dur[0]),
|
||||||
|
delay: -r4 * (b.dur[1] + 5),
|
||||||
|
swayDuration: 5 + r2 * 6,
|
||||||
|
opacity: b.op[0] + r3 * (b.op[1] - b.op[0]),
|
||||||
|
blur: b.blur,
|
||||||
|
// The single lucky four-leaf: one mid-band clover.
|
||||||
|
leaves: s === 10 ? 4 : 3,
|
||||||
|
fill: CLOVER_FILLS[b.fill],
|
||||||
|
restTop: 6 + r5 * 88,
|
||||||
|
restRot: (r4 - 0.5) * 80,
|
||||||
|
});
|
||||||
|
s += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Gold-coin glints scattered low — a faint pot-of-gold sparkle. ~5 discs.
|
||||||
|
const coins = useMemo<Coin[]>(() => {
|
||||||
|
const count = 5;
|
||||||
|
const out: Coin[] = [];
|
||||||
|
for (let i = 0; i < count; i += 1) {
|
||||||
|
out.push({
|
||||||
|
left: 8 + rand(i + 40) * 84,
|
||||||
|
top: 58 + rand(i + 47) * 36,
|
||||||
|
size: 8 + rand(i + 51) * 9,
|
||||||
|
duration: 4 + rand(i + 55) * 3,
|
||||||
|
delay: -rand(i + 61) * 6,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Golden sparkle motes drifting through the scene. ~7 points.
|
||||||
|
const motes = useMemo<Mote[]>(() => {
|
||||||
|
const count = 7;
|
||||||
|
const out: Mote[] = [];
|
||||||
|
for (let i = 0; i < count; i += 1) {
|
||||||
|
out.push({
|
||||||
|
left: rand(i + 70) * 100,
|
||||||
|
top: 8 + rand(i + 77) * 82,
|
||||||
|
size: 2 + rand(i + 83) * 3,
|
||||||
|
duration: 3 + rand(i + 89) * 3.5,
|
||||||
|
delay: -rand(i + 97) * 6,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Emerald ambient wash — layered radial + linear oklch gradients for
|
||||||
|
depth. Kept low-opacity so chat text stays legible (WCAG-AA). */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
contain: 'layout paint style',
|
||||||
|
backgroundImage: [
|
||||||
|
'radial-gradient(120% 85% at 50% -12%, oklch(0.60 0.16 150 / 0.16) 0%, transparent 56%)',
|
||||||
|
'radial-gradient(90% 65% at 12% 112%, oklch(0.55 0.15 145 / 0.12) 0%, transparent 60%)',
|
||||||
|
'radial-gradient(80% 60% at 92% 108%, oklch(0.82 0.14 90 / 0.07) 0%, transparent 62%)',
|
||||||
|
'linear-gradient(180deg, oklch(0.62 0.15 150 / 0.05) 0%, transparent 24%, transparent 82%, oklch(0.5 0.14 148 / 0.08) 100%)',
|
||||||
|
].join(','),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Verdant vignette frame — green edges, clear center. A single cheap
|
||||||
|
backdrop-filter adds a faint warm-emerald haze around the rim. */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
contain: 'layout paint style',
|
||||||
|
backdropFilter: 'blur(0.4px) saturate(1.05)',
|
||||||
|
WebkitBackdropFilter: 'blur(0.4px) saturate(1.05)',
|
||||||
|
backgroundImage:
|
||||||
|
'radial-gradient(140% 125% at 50% 44%, transparent 50%, oklch(0.6 0.13 150 / 0.07) 74%, oklch(0.48 0.14 148 / 0.17) 100%)',
|
||||||
|
animation: reduced ? 'none' : `${animVerdantBreathe} 13s ease-in-out infinite`,
|
||||||
|
willChange: reduced ? undefined : 'opacity',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Soft rainbow shimmer arc tucked into the top-right corner — a faint
|
||||||
|
luck-of-the-Irish band. Heavily blurred + screen-blended so it reads
|
||||||
|
as light, never as a hard stripe over chat. */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '-22%',
|
||||||
|
right: '-18%',
|
||||||
|
width: '62%',
|
||||||
|
height: '62%',
|
||||||
|
contain: 'layout paint style',
|
||||||
|
mixBlendMode: 'screen',
|
||||||
|
filter: 'blur(30px)',
|
||||||
|
opacity: reduced ? 0.6 : undefined,
|
||||||
|
// Concentric arc bands — red through violet, all low alpha.
|
||||||
|
backgroundImage: [
|
||||||
|
'radial-gradient(closest-side at 78% 28%, transparent 58%, oklch(0.7 0.18 28 / 0.16) 62%, transparent 67%)',
|
||||||
|
'radial-gradient(closest-side at 78% 28%, transparent 63%, oklch(0.82 0.16 80 / 0.16) 67%, transparent 72%)',
|
||||||
|
'radial-gradient(closest-side at 78% 28%, transparent 68%, oklch(0.85 0.17 130 / 0.16) 72%, transparent 77%)',
|
||||||
|
'radial-gradient(closest-side at 78% 28%, transparent 73%, oklch(0.72 0.15 230 / 0.15) 77%, transparent 82%)',
|
||||||
|
'radial-gradient(closest-side at 78% 28%, transparent 78%, oklch(0.6 0.16 300 / 0.13) 82%, transparent 87%)',
|
||||||
|
].join(','),
|
||||||
|
animation: reduced ? 'none' : `${animRainbowShimmer} 20s ease-in-out infinite`,
|
||||||
|
willChange: reduced ? undefined : 'transform, opacity',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Gold-coin glints — small metallic discs that catch the light. */}
|
||||||
|
{coins.map((c, i) => (
|
||||||
|
<div
|
||||||
|
key={`coin-${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${c.left}%`,
|
||||||
|
top: `${c.top}%`,
|
||||||
|
width: `${c.size}px`,
|
||||||
|
height: `${c.size}px`,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background:
|
||||||
|
'radial-gradient(circle at 36% 32%, oklch(0.97 0.06 95 / 0.95) 0%, oklch(0.82 0.14 90 / 0.85) 45%, oklch(0.68 0.13 78 / 0.4) 78%, transparent 100%)',
|
||||||
|
boxShadow: `0 0 ${c.size * 0.9}px ${c.size * 0.35}px oklch(0.82 0.14 90 / 0.4)`,
|
||||||
|
opacity: reduced ? 0.85 : undefined,
|
||||||
|
animation: reduced
|
||||||
|
? 'none'
|
||||||
|
: `${animCoinGlint} ${c.duration}s ease-in-out ${c.delay}s infinite`,
|
||||||
|
willChange: reduced ? undefined : 'transform, opacity',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Golden sparkle motes — tiny four-point glints of luck. */}
|
||||||
|
{motes.map((m, i) => (
|
||||||
|
<div
|
||||||
|
key={`mote-${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${m.left}%`,
|
||||||
|
top: `${m.top}%`,
|
||||||
|
width: `${m.size}px`,
|
||||||
|
height: `${m.size}px`,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background:
|
||||||
|
'radial-gradient(circle, oklch(0.98 0.05 95 / 0.95) 0%, oklch(0.85 0.13 88 / 0.6) 50%, transparent 100%)',
|
||||||
|
boxShadow: '0 0 6px oklch(0.85 0.13 88 / 0.6)',
|
||||||
|
opacity: reduced ? 0.9 : undefined,
|
||||||
|
animation: reduced
|
||||||
|
? 'none'
|
||||||
|
: `${animMoteTwinkle} ${m.duration}s ease-in-out ${m.delay}s infinite`,
|
||||||
|
willChange: reduced ? undefined : 'transform, opacity',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Drifting clovers (motion only) — three parallax bands tumbling down.
|
||||||
|
Settled static scatter is rendered below for reduced/preview. */}
|
||||||
|
{!reduced &&
|
||||||
|
clovers.map((c, i) => (
|
||||||
|
<div
|
||||||
|
key={`clover-${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: `${c.left}%`,
|
||||||
|
width: `${c.size}px`,
|
||||||
|
height: `${c.size * 1.2}px`,
|
||||||
|
animation: `${animCloverSway} ${c.swayDuration}s ease-in-out ${c.delay}s infinite`,
|
||||||
|
willChange: 'transform',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
backgroundImage: cloverSvg(c.leaves, c.fill),
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
backgroundSize: 'contain',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
opacity: c.opacity,
|
||||||
|
filter: `drop-shadow(0 0 3px oklch(0.55 0.15 145 / 0.4))${
|
||||||
|
c.blur ? ` blur(${c.blur}px)` : ''
|
||||||
|
}`,
|
||||||
|
animation: `${animCloverTumble} ${c.duration}s linear ${c.delay}s infinite`,
|
||||||
|
willChange: 'transform, opacity',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Static settled clovers for the reduced-motion / preview scene — a
|
||||||
|
gentle scatter resting at varied tilts so the thumbnail reads as a
|
||||||
|
lucky, still field of shamrocks. */}
|
||||||
|
{reduced &&
|
||||||
|
clovers.map((c, i) => (
|
||||||
|
<div
|
||||||
|
key={`clover-static-${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${c.left}%`,
|
||||||
|
top: `${c.restTop}%`,
|
||||||
|
width: `${c.size}px`,
|
||||||
|
height: `${c.size * 1.2}px`,
|
||||||
|
backgroundImage: cloverSvg(c.leaves, c.fill),
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
backgroundSize: 'contain',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
transform: `rotate(${c.restRot}deg)`,
|
||||||
|
opacity: c.opacity,
|
||||||
|
filter: `drop-shadow(0 0 3px oklch(0.55 0.15 145 / 0.4))${
|
||||||
|
c.blur ? ` blur(${c.blur}px)` : ''
|
||||||
|
}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { keyframes } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Heart rise — a soft heart drifts gently upward while bobbing sideways and
|
||||||
|
* breathing in scale, like a balloon caught in a warm draft. GPU-only: animates
|
||||||
|
* transform + opacity exclusively. The tall translateY lets one keyframe set
|
||||||
|
* serve every heart; per-heart duration/delay/scale supply the variety.
|
||||||
|
*/
|
||||||
|
export const animHeartRise = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(0, 8vh, 0) scale(0.7) rotate(-6deg)', opacity: '0' },
|
||||||
|
'10%': { opacity: '1' },
|
||||||
|
'50%': { transform: 'translate3d(18px, -46vh, 0) scale(1) rotate(5deg)' },
|
||||||
|
'88%': { opacity: '0.85' },
|
||||||
|
'100%': { transform: 'translate3d(-12px, -108vh, 0) scale(1.12) rotate(-4deg)', opacity: '0' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Heart bob — a small lateral sway applied to each heart's wrapper so the rise
|
||||||
|
* reads as a wandering draft, decoupled from the vertical travel so the two
|
||||||
|
* combine into an organic path. Transform only.
|
||||||
|
*/
|
||||||
|
export const animHeartBob = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(0, 0, 0)' },
|
||||||
|
'50%': { transform: 'translate3d(16px, 0, 0)' },
|
||||||
|
'100%': { transform: 'translate3d(0, 0, 0)' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Petal tumble — a rose petal falls while swaying horizontally and tumbling on
|
||||||
|
* its own axis, the way a real petal flutters. Opacity + transform only.
|
||||||
|
*/
|
||||||
|
export const animPetalTumble = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(0, -8vh, 0) rotate(0deg)', opacity: '0' },
|
||||||
|
'8%': { opacity: '0.9' },
|
||||||
|
'30%': { transform: 'translate3d(30px, 28vh, 0) rotate(120deg)' },
|
||||||
|
'60%': { transform: 'translate3d(-26px, 62vh, 0) rotate(250deg)' },
|
||||||
|
'92%': { opacity: '0.7' },
|
||||||
|
'100%': { transform: 'translate3d(14px, 112vh, 0) rotate(380deg)', opacity: '0' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bokeh breathe — dreamy blush orbs softly pulse in scale and brightness, like
|
||||||
|
* soft-focus lights drifting in and out of focus. Opacity + transform only.
|
||||||
|
*/
|
||||||
|
export const animBokehBreathe = keyframes({
|
||||||
|
'0%': { transform: 'translate3d(0, 0, 0) scale(0.9)', opacity: '0.45' },
|
||||||
|
'50%': { transform: 'translate3d(0, -10px, 0) scale(1.12)', opacity: '0.9' },
|
||||||
|
'100%': { transform: 'translate3d(0, 0, 0) scale(0.9)', opacity: '0.45' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blush pulse — a barely-there breathing of the warm vignette so the static
|
||||||
|
* tint feels alive and tender without distracting motion. Opacity only.
|
||||||
|
*/
|
||||||
|
export const animBlushPulse = keyframes({
|
||||||
|
'0%': { opacity: '0.82' },
|
||||||
|
'50%': { opacity: '1' },
|
||||||
|
'100%': { opacity: '0.82' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sparkle glint — a faint highlight winks on and off with a gentle scale, a
|
||||||
|
* romantic twinkle that never strobes. Transform + opacity only.
|
||||||
|
*/
|
||||||
|
export const animSparkle = keyframes({
|
||||||
|
'0%': { transform: 'scale(0.4) rotate(0deg)', opacity: '0' },
|
||||||
|
'15%': { transform: 'scale(1) rotate(45deg)', opacity: '0.9' },
|
||||||
|
'35%': { transform: 'scale(0.55) rotate(90deg)', opacity: '0' },
|
||||||
|
'100%': { transform: 'scale(0.4) rotate(90deg)', opacity: '0' },
|
||||||
|
});
|
||||||
@@ -0,0 +1,405 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { SeasonalOverlayProps } from '../types';
|
||||||
|
import {
|
||||||
|
animHeartRise,
|
||||||
|
animHeartBob,
|
||||||
|
animPetalTumble,
|
||||||
|
animBokehBreathe,
|
||||||
|
animBlushPulse,
|
||||||
|
animSparkle,
|
||||||
|
} from './Valentines.css';
|
||||||
|
|
||||||
|
// Deterministic pseudo-random so the scene is identical every mount (no React
|
||||||
|
// state per frame). Large primes keep the distribution well spread.
|
||||||
|
const rand = (seed: number) => {
|
||||||
|
const x = Math.sin(seed * 127.1 + 311.7) * 43758.5453;
|
||||||
|
return x - Math.floor(x);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Romantic oklch palette — rose, blush pink, warm red, soft cream. Kept
|
||||||
|
// luminous and gentle so everything reads as soft ambient glow over chat.
|
||||||
|
const ROSE = 'oklch(0.7 0.15 10)';
|
||||||
|
const BLUSH = 'oklch(0.9 0.06 350)';
|
||||||
|
const WARM_RED = 'oklch(0.6 0.18 20)';
|
||||||
|
const CREAM = 'oklch(0.96 0.03 60)';
|
||||||
|
|
||||||
|
const HEART_COLORS = [ROSE, BLUSH, WARM_RED, 'oklch(0.78 0.13 5)'];
|
||||||
|
const PETAL_COLORS = [
|
||||||
|
'oklch(0.66 0.16 12)', // rose
|
||||||
|
'oklch(0.74 0.13 6)', // lighter rose
|
||||||
|
'oklch(0.6 0.18 20)', // warm red
|
||||||
|
];
|
||||||
|
|
||||||
|
// Inline SVG (data-URI) so it is fully Tauri/CSP-safe — no external assets.
|
||||||
|
// A soft heart with a gradient fill and a cream highlight glint.
|
||||||
|
const heartSvg = (fill: string, glint: string) => {
|
||||||
|
const svg = `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'>
|
||||||
|
<defs><radialGradient id='g' cx='38%' cy='32%' r='75%'>
|
||||||
|
<stop offset='0%' stop-color='${glint}'/><stop offset='55%' stop-color='${fill}'/>
|
||||||
|
<stop offset='100%' stop-color='${fill}' stop-opacity='0.85'/></radialGradient></defs>
|
||||||
|
<path fill='url(%23g)' d='M16 28C16 28 3 19.5 3 11.2 3 6.8 6.4 4 10 4c2.6 0 4.7 1.5 6 3.6C17.3 5.5 19.4 4 22 4c3.6 0 7 2.8 7 7.2C29 19.5 16 28 16 28z'/></svg>`;
|
||||||
|
return `url("data:image/svg+xml,${svg.replace(/\n/g, '').replace(/#/g, '%23')}")`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// A single rose petal — a soft teardrop/ovate shape with an inner crease,
|
||||||
|
// gently asymmetric so the tumble reads as a real petal.
|
||||||
|
const petalSvg = (fill: string) => {
|
||||||
|
const svg = `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 32'>
|
||||||
|
<defs><linearGradient id='p' x1='0' y1='0' x2='1' y2='1'>
|
||||||
|
<stop offset='0%' stop-color='${fill}' stop-opacity='0.6'/>
|
||||||
|
<stop offset='100%' stop-color='${fill}'/></linearGradient></defs>
|
||||||
|
<path fill='url(%23p)' d='M12 1C5 8 2 16 4 24c1.4 5.4 6 7 8 7s6.6-1.6 8-7C22 16 19 8 12 1z'/>
|
||||||
|
<path d='M12 4C9 11 8 18 11 30' stroke='${fill}' stroke-opacity='0.35' stroke-width='1' fill='none'/></svg>`;
|
||||||
|
return `url("data:image/svg+xml,${svg.replace(/\n/g, '').replace(/#/g, '%23')}")`;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Heart = {
|
||||||
|
left: number;
|
||||||
|
size: number;
|
||||||
|
duration: number;
|
||||||
|
delay: number;
|
||||||
|
bobDuration: number;
|
||||||
|
opacity: number;
|
||||||
|
blur: number;
|
||||||
|
image: string;
|
||||||
|
restTop: number; // static resting position for reduced scene
|
||||||
|
};
|
||||||
|
|
||||||
|
type Petal = {
|
||||||
|
left: number;
|
||||||
|
size: number;
|
||||||
|
duration: number;
|
||||||
|
delay: number;
|
||||||
|
opacity: number;
|
||||||
|
image: string;
|
||||||
|
rotate: number;
|
||||||
|
restTop: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Bokeh = {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
size: number;
|
||||||
|
color: string;
|
||||||
|
duration: number;
|
||||||
|
delay: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Sparkle = {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
size: number;
|
||||||
|
duration: number;
|
||||||
|
delay: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ValentinesOverlay({ reduced }: SeasonalOverlayProps) {
|
||||||
|
// Three parallax bands of hearts: far (small/slow/dim) -> near (large/fast).
|
||||||
|
const hearts = useMemo<Heart[]>(() => {
|
||||||
|
const bands = [
|
||||||
|
{ count: 4, size: [12, 18], dur: [20, 26], op: [0.3, 0.5], blur: 0.8 },
|
||||||
|
{ count: 4, size: [18, 26], dur: [15, 19], op: [0.5, 0.72], blur: 0.3 },
|
||||||
|
{ count: 3, size: [26, 38], dur: [12, 15], op: [0.62, 0.85], blur: 0 },
|
||||||
|
];
|
||||||
|
const out: Heart[] = [];
|
||||||
|
let s = 1;
|
||||||
|
bands.forEach((b) => {
|
||||||
|
for (let i = 0; i < b.count; i += 1) {
|
||||||
|
const r1 = rand(s);
|
||||||
|
const r2 = rand(s + 0.37);
|
||||||
|
const r3 = rand(s + 0.71);
|
||||||
|
const r4 = rand(s + 0.91);
|
||||||
|
const fill = HEART_COLORS[Math.floor(r4 * HEART_COLORS.length) % HEART_COLORS.length];
|
||||||
|
out.push({
|
||||||
|
left: r1 * 96 + 2,
|
||||||
|
size: b.size[0] + r2 * (b.size[1] - b.size[0]),
|
||||||
|
duration: b.dur[0] + r3 * (b.dur[1] - b.dur[0]),
|
||||||
|
delay: -r4 * (b.dur[1] + 5),
|
||||||
|
bobDuration: 5 + r2 * 5,
|
||||||
|
opacity: b.op[0] + r3 * (b.op[1] - b.op[0]),
|
||||||
|
blur: b.blur,
|
||||||
|
image: heartSvg(fill, CREAM),
|
||||||
|
restTop: 6 + r3 * 86,
|
||||||
|
});
|
||||||
|
s += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Drifting rose petals tumbling down — a gentle counter-motion to the hearts.
|
||||||
|
const petals = useMemo<Petal[]>(() => {
|
||||||
|
const count = 8;
|
||||||
|
const out: Petal[] = [];
|
||||||
|
for (let i = 0; i < count; i += 1) {
|
||||||
|
const r1 = rand(i + 40);
|
||||||
|
const r2 = rand(i + 40.5);
|
||||||
|
const r3 = rand(i + 40.9);
|
||||||
|
const fill = PETAL_COLORS[i % PETAL_COLORS.length];
|
||||||
|
out.push({
|
||||||
|
left: r1 * 98,
|
||||||
|
size: 9 + r2 * 9,
|
||||||
|
duration: 14 + r3 * 9,
|
||||||
|
delay: -r1 * 22,
|
||||||
|
opacity: 0.45 + r2 * 0.35,
|
||||||
|
image: petalSvg(fill),
|
||||||
|
rotate: r3 * 360,
|
||||||
|
restTop: 4 + r2 * 90,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Dreamy blush bokeh orbs scattered across the scene, softly breathing.
|
||||||
|
const bokeh = useMemo<Bokeh[]>(() => {
|
||||||
|
const count = 7;
|
||||||
|
const out: Bokeh[] = [];
|
||||||
|
for (let i = 0; i < count; i += 1) {
|
||||||
|
const r1 = rand(i + 70);
|
||||||
|
const r2 = rand(i + 70.4);
|
||||||
|
const r3 = rand(i + 70.8);
|
||||||
|
out.push({
|
||||||
|
left: r1 * 94 + 3,
|
||||||
|
top: r2 * 88 + 4,
|
||||||
|
size: 70 + r3 * 130,
|
||||||
|
color: i % 2 === 0 ? BLUSH : 'oklch(0.82 0.1 355)',
|
||||||
|
duration: 9 + r3 * 7,
|
||||||
|
delay: -r1 * 10,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Faint sparkle glints — sparse, never strobing.
|
||||||
|
const sparkles = useMemo<Sparkle[]>(() => {
|
||||||
|
const count = 5;
|
||||||
|
const out: Sparkle[] = [];
|
||||||
|
for (let i = 0; i < count; i += 1) {
|
||||||
|
const r1 = rand(i + 200);
|
||||||
|
const r2 = rand(i + 200.5);
|
||||||
|
const r3 = rand(i + 200.9);
|
||||||
|
out.push({
|
||||||
|
left: r1 * 92 + 4,
|
||||||
|
top: r2 * 80 + 6,
|
||||||
|
size: 6 + r3 * 8,
|
||||||
|
duration: 5 + r3 * 4,
|
||||||
|
delay: -r1 * 9,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Warm romantic ambient wash — layered radial + linear oklch gradients
|
||||||
|
for depth. Low opacity so chat text stays legible (WCAG-AA). */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
contain: 'layout paint style',
|
||||||
|
backgroundImage: [
|
||||||
|
'radial-gradient(120% 80% at 50% 112%, oklch(0.7 0.15 10 / 0.12) 0%, transparent 58%)',
|
||||||
|
'radial-gradient(90% 70% at 15% -8%, oklch(0.9 0.06 350 / 0.1) 0%, transparent 60%)',
|
||||||
|
'radial-gradient(90% 70% at 88% 0%, oklch(0.6 0.18 20 / 0.07) 0%, transparent 62%)',
|
||||||
|
'linear-gradient(180deg, oklch(0.96 0.03 60 / 0.04) 0%, transparent 30%, transparent 72%, oklch(0.66 0.16 12 / 0.07) 100%)',
|
||||||
|
].join(','),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Blush vignette frame — soft warm edges, clear center. A single cheap
|
||||||
|
backdrop-filter layer for a faint dreamy haze around the rim. */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
contain: 'layout paint style',
|
||||||
|
backdropFilter: 'saturate(1.05) brightness(1.01)',
|
||||||
|
WebkitBackdropFilter: 'saturate(1.05) brightness(1.01)',
|
||||||
|
backgroundImage:
|
||||||
|
'radial-gradient(135% 120% at 50% 46%, transparent 50%, oklch(0.85 0.1 355 / 0.06) 74%, oklch(0.62 0.16 12 / 0.14) 100%)',
|
||||||
|
animation: reduced ? 'none' : `${animBlushPulse} 13s ease-in-out infinite`,
|
||||||
|
willChange: reduced ? undefined : 'opacity',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Dreamy bokeh orbs — soft blurred blush lights that breathe. */}
|
||||||
|
{bokeh.map((b, i) => (
|
||||||
|
<div
|
||||||
|
key={`bokeh-${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${b.left}%`,
|
||||||
|
top: `${b.top}%`,
|
||||||
|
width: `${b.size}px`,
|
||||||
|
height: `${b.size}px`,
|
||||||
|
marginLeft: `${-b.size / 2}px`,
|
||||||
|
marginTop: `${-b.size / 2}px`,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: `radial-gradient(circle at 42% 38%, ${b.color.replace(
|
||||||
|
')',
|
||||||
|
' / 0.5)',
|
||||||
|
)} 0%, ${b.color.replace(')', ' / 0.18)')} 45%, transparent 72%)`,
|
||||||
|
filter: 'blur(10px)',
|
||||||
|
mixBlendMode: 'screen',
|
||||||
|
opacity: reduced ? 0.7 : undefined,
|
||||||
|
animation: reduced
|
||||||
|
? 'none'
|
||||||
|
: `${animBokehBreathe} ${b.duration}s ease-in-out ${b.delay}s infinite`,
|
||||||
|
willChange: reduced ? undefined : 'transform, opacity',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Floating hearts (motion) — three parallax bands rising and bobbing.
|
||||||
|
The wrapper carries the lateral bob; the inner carries the rise so the
|
||||||
|
two combine into a wandering draft. */}
|
||||||
|
{!reduced &&
|
||||||
|
hearts.map((h, i) => (
|
||||||
|
<div
|
||||||
|
key={`heart-${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: `${h.left}%`,
|
||||||
|
width: `${h.size}px`,
|
||||||
|
height: `${h.size}px`,
|
||||||
|
animation: `${animHeartBob} ${h.bobDuration}s ease-in-out ${h.delay}s infinite`,
|
||||||
|
willChange: 'transform',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
backgroundImage: h.image,
|
||||||
|
backgroundSize: 'contain',
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
filter: `drop-shadow(0 0 ${h.size * 0.2}px oklch(0.7 0.15 10 / 0.45))${
|
||||||
|
h.blur ? ` blur(${h.blur}px)` : ''
|
||||||
|
}`,
|
||||||
|
opacity: h.opacity,
|
||||||
|
animation: `${animHeartRise} ${h.duration}s ease-in-out ${h.delay}s infinite`,
|
||||||
|
willChange: 'transform, opacity',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Drifting rose petals (motion) — tumbling down through the scene. */}
|
||||||
|
{!reduced &&
|
||||||
|
petals.map((p, i) => (
|
||||||
|
<div
|
||||||
|
key={`petal-${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: `${p.left}%`,
|
||||||
|
width: `${p.size}px`,
|
||||||
|
height: `${p.size * 1.33}px`,
|
||||||
|
backgroundImage: p.image,
|
||||||
|
backgroundSize: 'contain',
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
opacity: p.opacity,
|
||||||
|
animation: `${animPetalTumble} ${p.duration}s linear ${p.delay}s infinite`,
|
||||||
|
willChange: 'transform, opacity',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Faint sparkle glints (motion) — sparse romantic twinkle. */}
|
||||||
|
{!reduced &&
|
||||||
|
sparkles.map((s, i) => (
|
||||||
|
<div
|
||||||
|
key={`sparkle-${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${s.left}%`,
|
||||||
|
top: `${s.top}%`,
|
||||||
|
width: `${s.size}px`,
|
||||||
|
height: `${s.size}px`,
|
||||||
|
background: `radial-gradient(circle, ${CREAM.replace(
|
||||||
|
')',
|
||||||
|
' / 0.9)',
|
||||||
|
)} 0%, oklch(0.9 0.06 350 / 0.5) 40%, transparent 70%)`,
|
||||||
|
borderRadius: '50%',
|
||||||
|
animation: `${animSparkle} ${s.duration}s ease-in-out ${s.delay}s infinite`,
|
||||||
|
willChange: 'transform, opacity',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Static reduced-motion / preview scene — settled hearts at rest, a
|
||||||
|
scatter of fallen petals, and still sparkle glints. Tender and still,
|
||||||
|
so the judged thumbnail stands on its own without any animation. */}
|
||||||
|
{reduced &&
|
||||||
|
hearts.map((h, i) => (
|
||||||
|
<div
|
||||||
|
key={`heart-static-${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${h.left}%`,
|
||||||
|
top: `${h.restTop}%`,
|
||||||
|
width: `${h.size}px`,
|
||||||
|
height: `${h.size}px`,
|
||||||
|
backgroundImage: h.image,
|
||||||
|
backgroundSize: 'contain',
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
filter: `drop-shadow(0 0 ${h.size * 0.2}px oklch(0.7 0.15 10 / 0.4))${
|
||||||
|
h.blur ? ` blur(${h.blur}px)` : ''
|
||||||
|
}`,
|
||||||
|
opacity: h.opacity,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{reduced &&
|
||||||
|
petals.map((p, i) => (
|
||||||
|
<div
|
||||||
|
key={`petal-static-${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${p.left}%`,
|
||||||
|
top: `${p.restTop}%`,
|
||||||
|
width: `${p.size}px`,
|
||||||
|
height: `${p.size * 1.33}px`,
|
||||||
|
backgroundImage: p.image,
|
||||||
|
backgroundSize: 'contain',
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
transform: `rotate(${p.rotate}deg)`,
|
||||||
|
opacity: p.opacity,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{reduced &&
|
||||||
|
sparkles.map((s, i) => (
|
||||||
|
<div
|
||||||
|
key={`sparkle-static-${i}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${s.left}%`,
|
||||||
|
top: `${s.top}%`,
|
||||||
|
width: `${s.size}px`,
|
||||||
|
height: `${s.size}px`,
|
||||||
|
background: `radial-gradient(circle, ${CREAM.replace(
|
||||||
|
')',
|
||||||
|
' / 0.85)',
|
||||||
|
)} 0%, oklch(0.9 0.06 350 / 0.45) 40%, transparent 70%)`,
|
||||||
|
borderRadius: '50%',
|
||||||
|
opacity: 0.7,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// Shared seasonal types. Kept in a leaf module so the schedule, the overlay
|
||||||
|
// components (one per theme under ./themes/), and the settings UI can all import
|
||||||
|
// them without circular dependencies.
|
||||||
|
|
||||||
|
export type SeasonTheme =
|
||||||
|
| 'halloween'
|
||||||
|
| 'christmas'
|
||||||
|
| 'newyear'
|
||||||
|
| 'autumn'
|
||||||
|
| 'aprilfools'
|
||||||
|
| 'lunar'
|
||||||
|
| 'valentines'
|
||||||
|
| 'stpatricks'
|
||||||
|
| 'earthday'
|
||||||
|
| 'deepspace'
|
||||||
|
| 'arcade';
|
||||||
|
|
||||||
|
// Props every per-theme overlay component receives. `reduced` mirrors
|
||||||
|
// `prefers-reduced-motion`: when true the overlay must render a static (no
|
||||||
|
// animation) but still beautiful ambient version. The settings preview always
|
||||||
|
// passes reduced=true, so the static form has to stand on its own.
|
||||||
|
export type SeasonalOverlayProps = {
|
||||||
|
reduced: boolean;
|
||||||
|
};
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import React, { MouseEventHandler, useState } from 'react';
|
||||||
|
import { Box, Button, config, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text } from 'folds';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
|
||||||
|
export type SettingsSelectOption<T extends string> = {
|
||||||
|
value: T;
|
||||||
|
label: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A folds-native dropdown (Button + PopOut + Menu) matching Cinny's select
|
||||||
|
* pattern — used instead of a raw `<select>`, which renders OS-styled and
|
||||||
|
* breaks under non-default themes.
|
||||||
|
*/
|
||||||
|
export function SettingsSelect<T extends string>({
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
'aria-label': ariaLabel,
|
||||||
|
}: {
|
||||||
|
value: T;
|
||||||
|
options: SettingsSelectOption<T>[];
|
||||||
|
onChange: (v: T) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
'aria-label'?: string;
|
||||||
|
}) {
|
||||||
|
const [menuCords, setMenuCords] = useState<RectCords>();
|
||||||
|
const selectedLabel = options.find((o) => o.value === value)?.label ?? value;
|
||||||
|
|
||||||
|
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
|
setMenuCords(evt.currentTarget.getBoundingClientRect());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelect = (v: T) => {
|
||||||
|
onChange(v);
|
||||||
|
setMenuCords(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Secondary"
|
||||||
|
outlined
|
||||||
|
fill="Soft"
|
||||||
|
radii="300"
|
||||||
|
after={<Icon size="300" src={Icons.ChevronBottom} />}
|
||||||
|
onClick={handleMenu}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={!!menuCords}
|
||||||
|
>
|
||||||
|
<Text size="T300">{selectedLabel}</Text>
|
||||||
|
</Button>
|
||||||
|
<PopOut
|
||||||
|
anchor={menuCords}
|
||||||
|
offset={5}
|
||||||
|
position="Bottom"
|
||||||
|
align="End"
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: () => setMenuCords(undefined),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
isKeyForward: (evt: KeyboardEvent) =>
|
||||||
|
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||||||
|
isKeyBackward: (evt: KeyboardEvent) =>
|
||||||
|
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu>
|
||||||
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||||
|
{options.map((opt) => (
|
||||||
|
<MenuItem
|
||||||
|
key={opt.value}
|
||||||
|
size="300"
|
||||||
|
variant={opt.value === value ? 'Primary' : 'Surface'}
|
||||||
|
radii="300"
|
||||||
|
disabled={opt.disabled}
|
||||||
|
onClick={() => !opt.disabled && handleSelect(opt.value)}
|
||||||
|
>
|
||||||
|
<Text size="T300">{opt.label}</Text>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,10 +17,10 @@ export const Sidebar = style([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
export const SidebarGlass = style({
|
export const SidebarGlass = style({
|
||||||
backgroundColor: 'rgba(3, 5, 8, 0.55)',
|
backgroundColor: `color-mix(in srgb, ${color.Surface.Container} 55%, transparent)`,
|
||||||
backdropFilter: 'blur(12px)',
|
backdropFilter: 'blur(12px)',
|
||||||
WebkitBackdropFilter: 'blur(12px)',
|
WebkitBackdropFilter: 'blur(12px)',
|
||||||
borderRight: '1px solid rgba(255,255,255,0.06)',
|
borderRight: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const SidebarStack = style([
|
export const SidebarStack = style([
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import React, { useCallback, useMemo } from 'react';
|
||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
import { usePowerLevels } from '../../hooks/usePowerLevels';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { SoundboardPackEditor } from './SoundboardPackEditor';
|
||||||
|
import { SoundboardContent, SoundboardPack } from '../../plugins/soundboard';
|
||||||
|
import { StateEvent } from '../../../types/matrix/room';
|
||||||
|
import { useRoomSoundboardPack } from '../../hooks/useSoundboardPacks';
|
||||||
|
import { PackAddress } from '../../plugins/custom-emoji/PackAddress';
|
||||||
|
import { randomStr } from '../../utils/common';
|
||||||
|
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||||
|
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||||
|
|
||||||
|
type RoomSoundboardPackProps = {
|
||||||
|
room: Room;
|
||||||
|
stateKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RoomSoundboardPack({ room, stateKey }: RoomSoundboardPackProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const userId = mx.getUserId()!;
|
||||||
|
const powerLevels = usePowerLevels(room);
|
||||||
|
const creators = useRoomCreators(room);
|
||||||
|
const permissions = useRoomPermissions(creators, powerLevels);
|
||||||
|
const canEdit = permissions.stateEvent(
|
||||||
|
StateEvent.LotusSoundboardRoom as unknown as keyof import('matrix-js-sdk').StateEvents,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const fallbackPack = useMemo(
|
||||||
|
() => new SoundboardPack(randomStr(4), {}, new PackAddress(room.roomId, stateKey)),
|
||||||
|
[room.roomId, stateKey],
|
||||||
|
);
|
||||||
|
const pack = useRoomSoundboardPack(room, stateKey) ?? fallbackPack;
|
||||||
|
|
||||||
|
const handleUpdate = useCallback(
|
||||||
|
async (content: SoundboardContent) => {
|
||||||
|
await mx.sendStateEvent(
|
||||||
|
room.roomId,
|
||||||
|
StateEvent.LotusSoundboardRoom as unknown as keyof import('matrix-js-sdk').StateEvents,
|
||||||
|
content as never,
|
||||||
|
stateKey,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[mx, room.roomId, stateKey],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <SoundboardPackEditor pack={pack} canEdit={canEdit} onUpdate={handleUpdate} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Icons,
|
||||||
|
Input,
|
||||||
|
PopOut,
|
||||||
|
Spinner,
|
||||||
|
Text,
|
||||||
|
color,
|
||||||
|
config,
|
||||||
|
toRem,
|
||||||
|
} from 'folds';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { EmojiBoard } from '../emoji-board';
|
||||||
|
import { SoundboardClip, SoundboardContent, SoundboardPack } from '../../plugins/soundboard';
|
||||||
|
import { uniqueShortcode } from '../../plugins/soundboard/utils';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
|
import {
|
||||||
|
playClipLocally,
|
||||||
|
resolveClipObjectUrl,
|
||||||
|
SOUNDBOARD_ACCEPT,
|
||||||
|
SOUNDBOARD_MAX_CLIP_BYTES,
|
||||||
|
SOUNDBOARD_MAX_CLIPS,
|
||||||
|
} from '../../utils/soundboardClips';
|
||||||
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
|
||||||
|
type ClipDraft = {
|
||||||
|
url: string;
|
||||||
|
body: string;
|
||||||
|
emoji: string;
|
||||||
|
volume: number;
|
||||||
|
info?: SoundboardClip['info'];
|
||||||
|
};
|
||||||
|
|
||||||
|
type SoundboardPackEditorProps = {
|
||||||
|
pack: SoundboardPack;
|
||||||
|
canEdit?: boolean;
|
||||||
|
onUpdate: (content: SoundboardContent) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reusable single-pack soundboard manager (used by the settings page and the
|
||||||
|
* in-call management mode). Mirrors image-pack-view/ImagePackContent's staged-
|
||||||
|
* edit + batched-save pattern, but per-clip fields are name + emoji + volume.
|
||||||
|
*/
|
||||||
|
export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPackEditorProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
|
||||||
|
// Staged, unsaved state:
|
||||||
|
const [drafts, setDrafts] = useState<Map<string, ClipDraft>>(new Map()); // shortcode -> edits
|
||||||
|
const [deleted, setDeleted] = useState<Set<string>>(new Set());
|
||||||
|
const [uploads, setUploads] = useState<Array<{ shortcode: string } & ClipDraft>>([]);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
const [emojiFor, setEmojiFor] = useState<string>(); // shortcode currently picking an emoji
|
||||||
|
const [busyPreview, setBusyPreview] = useState<string>();
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const emojiAnchorRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
const existing = useMemo(() => pack.getClips(), [pack]);
|
||||||
|
const clipCount = existing.filter((c) => !deleted.has(c.shortcode)).length + uploads.length;
|
||||||
|
|
||||||
|
const dirty = drafts.size > 0 || deleted.size > 0 || uploads.length > 0;
|
||||||
|
|
||||||
|
const draftFor = (shortcode: string, base: { body: string; emoji: string; volume: number }) =>
|
||||||
|
drafts.get(shortcode) ?? { url: '', ...base };
|
||||||
|
|
||||||
|
const setDraft = (shortcode: string, patch: Partial<ClipDraft>, base: ClipDraft) => {
|
||||||
|
setDrafts((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(shortcode, { ...base, ...(next.get(shortcode) ?? {}), ...patch });
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const preview = useCallback(
|
||||||
|
async (id: string, mxc: string, volume: number) => {
|
||||||
|
setBusyPreview(id);
|
||||||
|
try {
|
||||||
|
const url = await resolveClipObjectUrl(mx, mxc);
|
||||||
|
playClipLocally(url, volume / 100);
|
||||||
|
} catch {
|
||||||
|
/* ignore preview errors */
|
||||||
|
} finally {
|
||||||
|
setBusyPreview(undefined);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[mx],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFiles = useCallback(
|
||||||
|
async (files: FileList | null) => {
|
||||||
|
if (!files || files.length === 0) return;
|
||||||
|
setUploading(true);
|
||||||
|
setError(undefined);
|
||||||
|
try {
|
||||||
|
const taken = new Set<string>([
|
||||||
|
...existing.map((c) => c.shortcode),
|
||||||
|
...uploads.map((u) => u.shortcode),
|
||||||
|
]);
|
||||||
|
for (let i = 0; i < files.length; i += 1) {
|
||||||
|
const file = files[i];
|
||||||
|
if (clipCount + uploads.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(`"${file.name}" is too large (max 1 MB).`);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
const res = await mx.uploadContent(file, { type: file.type || 'audio/mpeg' });
|
||||||
|
const mxc = res.content_uri;
|
||||||
|
if (!mxc) throw new Error('Upload failed.');
|
||||||
|
const name = file.name.replace(/\.[^/.]+$/, '');
|
||||||
|
const shortcode = uniqueShortcode(name, taken);
|
||||||
|
taken.add(shortcode);
|
||||||
|
setUploads((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
shortcode,
|
||||||
|
url: mxc,
|
||||||
|
body: name,
|
||||||
|
emoji: '',
|
||||||
|
volume: 100,
|
||||||
|
info: { mimetype: file.type || undefined, size: file.size },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Upload failed.');
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[mx, existing, uploads, clipCount],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [saveState, save] = useAsyncCallback(
|
||||||
|
useCallback(async () => {
|
||||||
|
const clips: Record<string, SoundboardClip> = {};
|
||||||
|
existing.forEach((c) => {
|
||||||
|
if (deleted.has(c.shortcode)) return;
|
||||||
|
const d = drafts.get(c.shortcode);
|
||||||
|
clips[c.shortcode] = {
|
||||||
|
url: c.url,
|
||||||
|
body: d ? d.body : c.body,
|
||||||
|
emoji: d ? d.emoji || undefined : c.emoji,
|
||||||
|
volume: d ? d.volume : c.volume,
|
||||||
|
info: c.info,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
uploads.forEach((u) => {
|
||||||
|
clips[u.shortcode] = {
|
||||||
|
url: u.url,
|
||||||
|
body: u.body,
|
||||||
|
emoji: u.emoji || undefined,
|
||||||
|
volume: u.volume,
|
||||||
|
info: u.info,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await onUpdate({ pack: pack.meta.content, clips });
|
||||||
|
setDrafts(new Map());
|
||||||
|
setDeleted(new Set());
|
||||||
|
setUploads([]);
|
||||||
|
}, [existing, deleted, drafts, uploads, onUpdate, pack]),
|
||||||
|
);
|
||||||
|
const saving = saveState.status === AsyncStatus.Loading;
|
||||||
|
|
||||||
|
const renderRow = (key: string, base: ClipDraft, isUpload: boolean, markedDeleted: boolean) => {
|
||||||
|
const d = isUpload ? base : draftFor(key, base);
|
||||||
|
const rowVolume = isUpload ? base.volume : d.volume;
|
||||||
|
const rowBody = isUpload ? base.body : d.body;
|
||||||
|
const rowEmoji = isUpload ? base.emoji : d.emoji;
|
||||||
|
const commit = (patch: Partial<ClipDraft>) => {
|
||||||
|
if (isUpload) {
|
||||||
|
setUploads((prev) => prev.map((u) => (u.shortcode === key ? { ...u, ...patch } : u)));
|
||||||
|
} else {
|
||||||
|
setDraft(key, patch, base);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={key}
|
||||||
|
alignItems="Center"
|
||||||
|
gap="200"
|
||||||
|
style={{
|
||||||
|
padding: config.space.S200,
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
background: color.SurfaceVariant.Container,
|
||||||
|
opacity: markedDeleted ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="Secondary"
|
||||||
|
disabled={busyPreview === key}
|
||||||
|
onClick={() => preview(key, base.url, rowVolume)}
|
||||||
|
aria-label={`Preview ${rowBody}`}
|
||||||
|
>
|
||||||
|
{busyPreview === key ? <Spinner size="100" /> : <Icon size="100" src={Icons.Play} />}
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="Secondary"
|
||||||
|
disabled={!canEdit || markedDeleted}
|
||||||
|
onClick={(evt: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
emojiAnchorRef.current = evt.currentTarget;
|
||||||
|
setEmojiFor(key);
|
||||||
|
}}
|
||||||
|
aria-label="Pick emoji"
|
||||||
|
>
|
||||||
|
<Text size="T400">{rowEmoji || '🔊'}</Text>
|
||||||
|
</IconButton>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Input
|
||||||
|
variant="Surface"
|
||||||
|
size="300"
|
||||||
|
defaultValue={rowBody}
|
||||||
|
readOnly={!canEdit || markedDeleted}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => commit({ body: e.target.value })}
|
||||||
|
aria-label="Clip name"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box alignItems="Center" gap="100" shrink="No" style={{ width: toRem(120) }}>
|
||||||
|
<Icon size="50" src={Icons.VolumeHigh} />
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={5}
|
||||||
|
defaultValue={rowVolume}
|
||||||
|
disabled={!canEdit || markedDeleted}
|
||||||
|
onChange={(e) => commit({ volume: parseInt(e.target.value, 10) })}
|
||||||
|
style={{ flexGrow: 1 }}
|
||||||
|
aria-label="Clip volume"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{canEdit && !isUpload && (
|
||||||
|
<IconButton
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant={markedDeleted ? 'Success' : 'Critical'}
|
||||||
|
onClick={() =>
|
||||||
|
setDeleted((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) next.delete(key);
|
||||||
|
else next.add(key);
|
||||||
|
return next;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
aria-label={markedDeleted ? 'Undo delete' : 'Delete clip'}
|
||||||
|
>
|
||||||
|
<Icon size="100" src={markedDeleted ? Icons.Plus : Icons.Delete} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
{canEdit && isUpload && (
|
||||||
|
<IconButton
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="Critical"
|
||||||
|
onClick={() => setUploads((prev) => prev.filter((u) => u.shortcode !== key))}
|
||||||
|
aria-label="Remove upload"
|
||||||
|
>
|
||||||
|
<Icon size="100" src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box direction="Column" gap="300">
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
aria-label="Upload soundboard clip"
|
||||||
|
type="file"
|
||||||
|
accept={SOUNDBOARD_ACCEPT}
|
||||||
|
multiple
|
||||||
|
hidden
|
||||||
|
onChange={(e) => {
|
||||||
|
handleFiles(e.target.files);
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
|
||||||
|
<Text size="H4">{pack.meta.name ?? 'Soundboard'}</Text>
|
||||||
|
{canEdit && (
|
||||||
|
<Chip
|
||||||
|
variant="Secondary"
|
||||||
|
radii="Pill"
|
||||||
|
disabled={uploading || clipCount >= 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>
|
||||||
|
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
{existing.map((c) =>
|
||||||
|
renderRow(
|
||||||
|
c.shortcode,
|
||||||
|
{ url: c.url, body: c.body ?? c.shortcode, emoji: c.emoji ?? '', volume: c.volume },
|
||||||
|
false,
|
||||||
|
deleted.has(c.shortcode),
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
{uploads.map((u) => renderRow(u.shortcode, u, true, false))}
|
||||||
|
{existing.length === 0 && uploads.length === 0 && (
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
No clips yet. Upload a short audio clip (max 1 MB){canEdit ? '' : ' — ask an admin'}.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canEdit && dirty && (
|
||||||
|
<Box gap="200">
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Success"
|
||||||
|
radii="300"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => save()}
|
||||||
|
before={saving ? <Spinner size="100" fill="Solid" /> : undefined}
|
||||||
|
>
|
||||||
|
<Text size="B300">Save changes</Text>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
radii="300"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => {
|
||||||
|
setDrafts(new Map());
|
||||||
|
setDeleted(new Set());
|
||||||
|
setUploads([]);
|
||||||
|
setError(undefined);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="B300">Reset</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PopOut
|
||||||
|
anchor={emojiFor ? emojiAnchorRef.current?.getBoundingClientRect() : undefined}
|
||||||
|
position="Bottom"
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: () => setEmojiFor(undefined),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EmojiBoard
|
||||||
|
imagePackRooms={[]}
|
||||||
|
returnFocusOnDeactivate={false}
|
||||||
|
onEmojiSelect={(unicode: string) => {
|
||||||
|
const key = emojiFor;
|
||||||
|
setEmojiFor(undefined);
|
||||||
|
if (!key) return;
|
||||||
|
const up = uploads.find((u) => u.shortcode === key);
|
||||||
|
if (up) {
|
||||||
|
setUploads((prev) =>
|
||||||
|
prev.map((u) => (u.shortcode === key ? { ...u, emoji: unicode } : u)),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const c = existing.find((x) => x.shortcode === key);
|
||||||
|
if (c)
|
||||||
|
setDraft(
|
||||||
|
key,
|
||||||
|
{ emoji: unicode },
|
||||||
|
{
|
||||||
|
url: c.url,
|
||||||
|
body: c.body ?? c.shortcode,
|
||||||
|
emoji: c.emoji ?? '',
|
||||||
|
volume: c.volume,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
requestClose={() => setEmojiFor(undefined)}
|
||||||
|
/>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span />
|
||||||
|
</PopOut>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import React, { useCallback, useMemo } from 'react';
|
||||||
|
import { SoundboardPackEditor } from './SoundboardPackEditor';
|
||||||
|
import { SoundboardContent, SoundboardPack } from '../../plugins/soundboard';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { AccountDataEvent } from '../../../types/matrix/accountData';
|
||||||
|
import { useUserSoundboardPack } from '../../hooks/useSoundboardPacks';
|
||||||
|
|
||||||
|
export function UserSoundboardPack() {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const defaultPack = useMemo(
|
||||||
|
() =>
|
||||||
|
new SoundboardPack(
|
||||||
|
mx.getUserId() ?? '',
|
||||||
|
{ pack: { display_name: 'My Soundboard' } },
|
||||||
|
undefined,
|
||||||
|
),
|
||||||
|
[mx],
|
||||||
|
);
|
||||||
|
const pack = useUserSoundboardPack() ?? defaultPack;
|
||||||
|
|
||||||
|
const handleUpdate = useCallback(
|
||||||
|
async (content: SoundboardContent) => {
|
||||||
|
await mx.setAccountData(
|
||||||
|
AccountDataEvent.LotusSoundboard as unknown as keyof import('matrix-js-sdk').AccountDataEvents,
|
||||||
|
content as never,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[mx],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <SoundboardPackEditor pack={pack} canEdit onUpdate={handleUpdate} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './SoundboardPackEditor';
|
||||||
|
export * from './RoomSoundboardPack';
|
||||||
|
export * from './UserSoundboardPack';
|
||||||
@@ -61,10 +61,7 @@ export function PasswordStage({
|
|||||||
gap="400"
|
gap="400"
|
||||||
>
|
>
|
||||||
<Box direction="Column" gap="400">
|
<Box direction="Column" gap="400">
|
||||||
<Text size="T200">
|
<Text size="T200">{t('Organisms.PasswordStage.authenticate_prompt')}</Text>
|
||||||
To perform this action you need to authenticate yourself by entering you account
|
|
||||||
password.
|
|
||||||
</Text>
|
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Text size="L400">{t('Organisms.PasswordStage.password')}</Text>
|
<Text size="L400">{t('Organisms.PasswordStage.password')}</Text>
|
||||||
<PasswordInput size="400" name="passwordInput" outlined autoFocus required />
|
<PasswordInput size="400" name="passwordInput" outlined autoFocus required />
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ function PreviewVideo({ fileItem }: PreviewVideoProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<video
|
<video
|
||||||
|
aria-label="Video attachment preview"
|
||||||
style={{
|
style={{
|
||||||
objectFit: 'contain',
|
objectFit: 'contain',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
|
|||||||
@@ -260,6 +260,7 @@ export function UserModeration({ userId, canKick, canBan, canInvite }: UserModer
|
|||||||
<Input
|
<Input
|
||||||
ref={reasonInputRef}
|
ref={reasonInputRef}
|
||||||
placeholder="Reason"
|
placeholder="Reason"
|
||||||
|
aria-label="Moderation reason"
|
||||||
size="300"
|
size="300"
|
||||||
variant="Background"
|
variant="Background"
|
||||||
radii="300"
|
radii="300"
|
||||||
|
|||||||
@@ -91,10 +91,10 @@ function UserDeviceRow({ userId, device }: UserDeviceRowProps) {
|
|||||||
{(status) => {
|
{(status) => {
|
||||||
const deviceColor =
|
const deviceColor =
|
||||||
status === VerificationStatus.Verified
|
status === VerificationStatus.Verified
|
||||||
? 'var(--tc-positive-normal, #5effc4)'
|
? color.Success.Main
|
||||||
: status === VerificationStatus.Unverified
|
: status === VerificationStatus.Unverified
|
||||||
? 'var(--tc-warning-normal, #ffcc55)'
|
? color.Warning.Main
|
||||||
: 'var(--tc-surface-low-contrast)';
|
: color.SurfaceVariant.OnContainer;
|
||||||
return (
|
return (
|
||||||
<Box alignItems="Center" gap="200">
|
<Box alignItems="Center" gap="200">
|
||||||
<Icon size="100" src={Icons.ShieldUser} style={{ color: deviceColor, flexShrink: 0 }} />
|
<Icon size="100" src={Icons.ShieldUser} style={{ color: deviceColor, flexShrink: 0 }} />
|
||||||
@@ -106,7 +106,7 @@ function UserDeviceRow({ userId, device }: UserDeviceRowProps) {
|
|||||||
<Text
|
<Text
|
||||||
size="T200"
|
size="T200"
|
||||||
truncate
|
truncate
|
||||||
style={{ color: 'var(--tc-surface-low-contrast)', fontFamily: 'monospace' }}
|
style={{ color: color.SurfaceVariant.OnContainer, fontFamily: 'monospace' }}
|
||||||
>
|
>
|
||||||
{device.deviceId}
|
{device.deviceId}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -160,7 +160,7 @@ function UserDeviceSessions({ userId }: UserDeviceSessionsProps) {
|
|||||||
direction="Column"
|
direction="Column"
|
||||||
gap="100"
|
gap="100"
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--bg-surface-variant)',
|
background: color.SurfaceVariant.Container,
|
||||||
borderRadius: config.radii.R300,
|
borderRadius: config.radii.R300,
|
||||||
padding: config.space.S300,
|
padding: config.space.S300,
|
||||||
}}
|
}}
|
||||||
@@ -171,7 +171,7 @@ function UserDeviceSessions({ userId }: UserDeviceSessionsProps) {
|
|||||||
<Text size="T300">
|
<Text size="T300">
|
||||||
<b>Sessions</b>
|
<b>Sessions</b>
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="T200" style={{ color: 'var(--tc-surface-low-contrast)' }}>
|
<Text size="T200" style={{ color: color.SurfaceVariant.OnContainer }}>
|
||||||
{devices.length}
|
{devices.length}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -253,6 +253,7 @@ function UserPrivateNotes({ userId }: { userId: string }) {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<textarea
|
<textarea
|
||||||
|
aria-label="Private note about this user"
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
maxLength={USER_NOTE_MAX_LENGTH}
|
maxLength={USER_NOTE_MAX_LENGTH}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { getOidcIssuer, AutoDiscoveryInfo } from './cs-api';
|
||||||
|
|
||||||
|
const info = (extra: Record<string, unknown>): AutoDiscoveryInfo =>
|
||||||
|
({ 'm.homeserver': { base_url: 'https://hs' }, ...extra }) as AutoDiscoveryInfo;
|
||||||
|
|
||||||
|
test('getOidcIssuer reads the stable m.authentication key', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
getOidcIssuer(info({ 'm.authentication': { issuer: 'https://i', account: 'https://a' } })),
|
||||||
|
{ issuer: 'https://i', account: 'https://a' },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getOidcIssuer falls back to the unstable msc2965 key', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
getOidcIssuer(info({ 'org.matrix.msc2965.authentication': { issuer: 'https://u' } })),
|
||||||
|
{ issuer: 'https://u', account: undefined },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getOidcIssuer prefers stable over unstable when both present', () => {
|
||||||
|
assert.equal(
|
||||||
|
getOidcIssuer(
|
||||||
|
info({
|
||||||
|
'm.authentication': { issuer: 'https://stable' },
|
||||||
|
'org.matrix.msc2965.authentication': { issuer: 'https://unstable' },
|
||||||
|
}),
|
||||||
|
).issuer,
|
||||||
|
'https://stable',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getOidcIssuer returns {} for non-OIDC servers', () => {
|
||||||
|
assert.deepEqual(getOidcIssuer(info({})), {});
|
||||||
|
assert.deepEqual(getOidcIssuer(info({ 'm.authentication': {} })), {}); // present but no issuer
|
||||||
|
});
|
||||||
@@ -20,6 +20,13 @@ export type AutoDiscoveryInfo = Record<string, unknown> & {
|
|||||||
'm.identity_server'?: {
|
'm.identity_server'?: {
|
||||||
base_url: string;
|
base_url: string;
|
||||||
};
|
};
|
||||||
|
// v1.15 stable next-gen-auth (MSC2965) discovery key — emitted by servers that
|
||||||
|
// delegate to a Matrix Authentication Service (e.g. mozilla.org). The
|
||||||
|
// `org.matrix.msc2965.authentication` key below is the unstable predecessor.
|
||||||
|
'm.authentication'?: {
|
||||||
|
issuer?: string;
|
||||||
|
account?: string;
|
||||||
|
};
|
||||||
'org.matrix.msc2965.authentication'?: {
|
'org.matrix.msc2965.authentication'?: {
|
||||||
account?: string;
|
account?: string;
|
||||||
issuer?: string;
|
issuer?: string;
|
||||||
@@ -32,6 +39,24 @@ export type AutoDiscoveryInfo = Record<string, unknown> & {
|
|||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the OIDC issuer (and account-management URL) advertised by a homeserver
|
||||||
|
* in its `.well-known/matrix/client`, preferring the v1.15 stable
|
||||||
|
* `m.authentication` key over the unstable `org.matrix.msc2965.authentication`.
|
||||||
|
* Returns `{}` when the server is not OIDC-native (e.g. matrix.lotusguild.org).
|
||||||
|
*/
|
||||||
|
export const getOidcIssuer = (info: AutoDiscoveryInfo): { issuer?: string; account?: string } => {
|
||||||
|
const stable = info['m.authentication'];
|
||||||
|
if (stable && typeof stable.issuer === 'string') {
|
||||||
|
return { issuer: stable.issuer, account: stable.account };
|
||||||
|
}
|
||||||
|
const unstable = info['org.matrix.msc2965.authentication'];
|
||||||
|
if (unstable && typeof unstable.issuer === 'string') {
|
||||||
|
return { issuer: unstable.issuer, account: unstable.account };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
};
|
||||||
|
|
||||||
export const autoDiscovery = async (
|
export const autoDiscovery = async (
|
||||||
request: typeof fetch,
|
request: typeof fetch,
|
||||||
server: string,
|
server: string,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { ChangeEvent, useCallback, useEffect, useState } from 'react';
|
import React, { ChangeEvent, ReactNode, useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
@@ -16,6 +17,8 @@ import {
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { useBookmarks, Bookmark } from '../../hooks/useBookmarks';
|
import { useBookmarks, Bookmark } from '../../hooks/useBookmarks';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { useRoomEvent } from '../../hooks/useRoomEvent';
|
||||||
|
import { MessageDeletedContent } from '../../components/message/content/FallbackContent';
|
||||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { RoomAvatar } from '../../components/room-avatar';
|
import { RoomAvatar } from '../../components/room-avatar';
|
||||||
@@ -42,9 +45,11 @@ type BookmarkItemProps = {
|
|||||||
bookmark: Bookmark;
|
bookmark: Bookmark;
|
||||||
onJump: (roomId: string, eventId: string) => void;
|
onJump: (roomId: string, eventId: string) => void;
|
||||||
onRemove: (eventId: string) => void;
|
onRemove: (eventId: string) => void;
|
||||||
|
// Optional live-rendered preview node; falls back to the stored snapshot when absent.
|
||||||
|
preview?: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
function BookmarkItem({ bookmark, onJump, onRemove }: BookmarkItemProps) {
|
function BookmarkItem({ bookmark, onJump, onRemove, preview }: BookmarkItemProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const room = mx.getRoom(bookmark.roomId) ?? undefined;
|
const room = mx.getRoom(bookmark.roomId) ?? undefined;
|
||||||
@@ -104,18 +109,50 @@ function BookmarkItem({ bookmark, onJump, onRemove }: BookmarkItemProps) {
|
|||||||
style={{ justifyContent: 'flex-start', height: 'unset', padding: config.space.S200 }}
|
style={{ justifyContent: 'flex-start', height: 'unset', padding: config.space.S200 }}
|
||||||
>
|
>
|
||||||
<Text className={css.BookmarkPreview} size="T200" priority="400">
|
<Text className={css.BookmarkPreview} size="T200" priority="400">
|
||||||
{bookmark.previewText || '(no preview)'}
|
{preview ?? (bookmark.previewText || '(no preview)')}
|
||||||
</Text>
|
</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LiveBookmarkItemProps = BookmarkItemProps & { room: Room };
|
||||||
|
|
||||||
|
// Renders the same layout as BookmarkItem, but resolves the message body live so
|
||||||
|
// edits (m.replace, applied by useRoomEvent) and redactions are reflected. The
|
||||||
|
// stored snapshot (previewText) remains the fallback for loading/failed/empty states.
|
||||||
|
function LiveBookmarkItem({ room, bookmark, onJump, onRemove }: LiveBookmarkItemProps) {
|
||||||
|
const liveEvent = useRoomEvent(room, bookmark.eventId, () =>
|
||||||
|
room.findEventById(bookmark.eventId),
|
||||||
|
);
|
||||||
|
|
||||||
|
const snapshot = bookmark.previewText || '(no preview)';
|
||||||
|
let preview: ReactNode = snapshot;
|
||||||
|
|
||||||
|
// undefined (loading) and null (fetch failed / not found) both keep the snapshot.
|
||||||
|
if (liveEvent) {
|
||||||
|
if (liveEvent.isRedacted()) {
|
||||||
|
preview = (
|
||||||
|
<MessageDeletedContent
|
||||||
|
reason={liveEvent.getUnsigned().redacted_because?.content?.reason as string | undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// body is already the edited text since useRoomEvent applied m.replace.
|
||||||
|
const { body } = liveEvent.getContent();
|
||||||
|
preview = typeof body === 'string' && body ? body : snapshot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <BookmarkItem bookmark={bookmark} onJump={onJump} onRemove={onRemove} preview={preview} />;
|
||||||
|
}
|
||||||
|
|
||||||
type BookmarksPanelProps = {
|
type BookmarksPanelProps = {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
|
export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
const { bookmarks, removeBookmark } = useBookmarks();
|
const { bookmarks, removeBookmark } = useBookmarks();
|
||||||
const { navigateRoom } = useRoomNavigate();
|
const { navigateRoom } = useRoomNavigate();
|
||||||
const [filter, setFilter] = useState('');
|
const [filter, setFilter] = useState('');
|
||||||
@@ -228,14 +265,27 @@ export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
|
|||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<Box className={css.BookmarksContent} direction="Column" gap="200">
|
<Box className={css.BookmarksContent} direction="Column" gap="200">
|
||||||
{filtered.map((bk) => (
|
{filtered.map((bk) => {
|
||||||
|
// Live render when the room is joined (useRoomEvent needs a non-null Room);
|
||||||
|
// otherwise fall back to the stored snapshot for rooms we've left.
|
||||||
|
const room = mx.getRoom(bk.roomId);
|
||||||
|
return room ? (
|
||||||
|
<LiveBookmarkItem
|
||||||
|
key={bk.eventId}
|
||||||
|
room={room}
|
||||||
|
bookmark={bk}
|
||||||
|
onJump={handleJump}
|
||||||
|
onRemove={removeBookmark}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<BookmarkItem
|
<BookmarkItem
|
||||||
key={bk.eventId}
|
key={bk.eventId}
|
||||||
bookmark={bk}
|
bookmark={bk}
|
||||||
onJump={handleJump}
|
onJump={handleJump}
|
||||||
onRemove={removeBookmark}
|
onRemove={removeBookmark}
|
||||||
/>
|
/>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Scroll>
|
</Scroll>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Chip,
|
Chip,
|
||||||
|
color,
|
||||||
config,
|
config,
|
||||||
Icon,
|
Icon,
|
||||||
IconButton,
|
IconButton,
|
||||||
@@ -36,6 +37,10 @@ import { stopPropagation } from '../../utils/keyboard';
|
|||||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
import { useCallEmbedRef } from '../../hooks/useCallEmbed';
|
import { useCallEmbedRef } from '../../hooks/useCallEmbed';
|
||||||
import { useAfkAutoMute } from '../../hooks/useAfkAutoMute';
|
import { useAfkAutoMute } from '../../hooks/useAfkAutoMute';
|
||||||
|
import { CallSoundboard } from './CallSoundboard';
|
||||||
|
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||||
|
import { StateEvent } from '../../../types/matrix/room';
|
||||||
|
import { RoomQualityContent } from '../../utils/callQuality';
|
||||||
|
|
||||||
type CallControlsProps = {
|
type CallControlsProps = {
|
||||||
callEmbed: CallEmbed;
|
callEmbed: CallEmbed;
|
||||||
@@ -87,6 +92,19 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||||||
const [pttMode] = useSetting(settingsAtom, 'pttMode');
|
const [pttMode] = useSetting(settingsAtom, 'pttMode');
|
||||||
const [pttKey] = useSetting(settingsAtom, 'pttKey');
|
const [pttKey] = useSetting(settingsAtom, 'pttKey');
|
||||||
const [deafenKey] = useSetting(settingsAtom, 'deafenKey');
|
const [deafenKey] = useSetting(settingsAtom, 'deafenKey');
|
||||||
|
const [soundboardEnabled] = useSetting(settingsAtom, 'soundboardEnabled');
|
||||||
|
|
||||||
|
// [P5-31] Hard room publish policy — hide controls the server will refuse so
|
||||||
|
// users don't click dead buttons. Absent/true = allowed.
|
||||||
|
const roomQualityEvent = useStateEvent(callEmbed.room, StateEvent.LotusRoomQuality);
|
||||||
|
const roomQuality = roomQualityEvent?.getContent<RoomQualityContent>();
|
||||||
|
const cameraAllowed = roomQuality?.allow_camera !== false;
|
||||||
|
const screenshareAllowed = roomQuality?.allow_screenshare !== false;
|
||||||
|
// Keep a forbidden control visible while its track is still live (so the user
|
||||||
|
// can stop it); otherwise hide it entirely.
|
||||||
|
const showCamera = cameraAllowed || video;
|
||||||
|
const showScreenshare = screenshareAllowed || screenshare;
|
||||||
|
const showVideoGroup = showCamera || showScreenshare || !!document.fullscreenEnabled;
|
||||||
const [pttActive, setPttActive] = useState(false);
|
const [pttActive, setPttActive] = useState(false);
|
||||||
|
|
||||||
// Track microphone via ref so the PTT effect doesn't need it as a dep (avoids listener churn)
|
// Track microphone via ref so the PTT effect doesn't need it as a dep (avoids listener churn)
|
||||||
@@ -276,8 +294,8 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||||||
bottom: '110%',
|
bottom: '110%',
|
||||||
left: '50%',
|
left: '50%',
|
||||||
transform: 'translateX(-50%)',
|
transform: 'translateX(-50%)',
|
||||||
background: 'var(--bg-surface)',
|
background: color.Surface.Container,
|
||||||
border: '1px solid var(--bg-surface-border)',
|
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||||
borderRadius: '0.75rem',
|
borderRadius: '0.75rem',
|
||||||
padding: '1rem 1.25rem',
|
padding: '1rem 1.25rem',
|
||||||
zIndex: 100,
|
zIndex: 100,
|
||||||
@@ -333,29 +351,40 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||||||
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
||||||
<MicrophoneButton enabled={microphone} onToggle={handleMicrophoneToggle} />
|
<MicrophoneButton enabled={microphone} onToggle={handleMicrophoneToggle} />
|
||||||
<SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} />
|
<SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} />
|
||||||
<ScreenshareAudioButton
|
|
||||||
muted={screenshareAudioMuted}
|
|
||||||
onToggle={() => callEmbed.control.toggleScreenshareAudio()}
|
|
||||||
/>
|
|
||||||
</Box>
|
</Box>
|
||||||
{!compact && <ControlDivider />}
|
{!compact && showVideoGroup && <ControlDivider />}
|
||||||
|
{showVideoGroup && (
|
||||||
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
||||||
<VideoButton enabled={video} onToggle={handleVideoToggle} />
|
{/* Show a forbidden control while its track is still live so the
|
||||||
|
user can stop it; once stopped it hides and can't be restarted. */}
|
||||||
|
{showCamera && <VideoButton enabled={video} onToggle={handleVideoToggle} />}
|
||||||
|
{showScreenshare && (
|
||||||
|
<>
|
||||||
<ScreenShareButton
|
<ScreenShareButton
|
||||||
enabled={screenshare}
|
enabled={screenshare}
|
||||||
onToggle={() =>
|
onToggle={() =>
|
||||||
screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
|
screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{/* Mute-screenshare-audio sits directly next to the screenshare
|
||||||
|
control since they're the same concern. */}
|
||||||
|
<ScreenshareAudioButton
|
||||||
|
muted={screenshareAudioMuted}
|
||||||
|
onToggle={() => callEmbed.control.toggleScreenshareAudio()}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{!!document.fullscreenEnabled && (
|
{!!document.fullscreenEnabled && (
|
||||||
<FullscreenButton isFullscreen={isFullscreen} onToggle={handleFullscreen} />
|
<FullscreenButton isFullscreen={isFullscreen} onToggle={handleFullscreen} />
|
||||||
)}
|
)}
|
||||||
</Box>
|
</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,254 @@
|
|||||||
|
import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Icons,
|
||||||
|
Menu,
|
||||||
|
PopOut,
|
||||||
|
RectCords,
|
||||||
|
Scroll,
|
||||||
|
Spinner,
|
||||||
|
Switch,
|
||||||
|
Text,
|
||||||
|
Tooltip,
|
||||||
|
TooltipProvider,
|
||||||
|
color,
|
||||||
|
config,
|
||||||
|
toRem,
|
||||||
|
} from 'folds';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { CallEmbed } from '../../plugins/call';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
|
import { settingsAtom } from '../../state/settings';
|
||||||
|
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
||||||
|
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
|
||||||
|
import { useRelevantSoundboardPacks } from '../../hooks/useSoundboardPacks';
|
||||||
|
import { SoundboardClipReader } from '../../plugins/soundboard';
|
||||||
|
import { UserSoundboardPack, RoomSoundboardPack } from '../../components/soundboard-pack-view';
|
||||||
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
import { playClipLocally, resolveClipObjectUrl } from '../../utils/soundboardClips';
|
||||||
|
|
||||||
|
type CallSoundboardProps = {
|
||||||
|
callEmbed: CallEmbed;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FlatClip = {
|
||||||
|
key: string; // packId|shortcode
|
||||||
|
packId: string;
|
||||||
|
packName: string;
|
||||||
|
clip: SoundboardClipReader;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [P5-15 v2] In-call soundboard. Clips come from the aggregated soundboard packs
|
||||||
|
* relevant to the call room (the room + parent spaces ∪ the user's personal
|
||||||
|
* pack), just like custom emoji. Playing a clip publishes it into the call via
|
||||||
|
* the EC fork (`io.lotus.inject_audio`, max one at a time) and plays it locally.
|
||||||
|
* A management toggle reveals the pack editors (personal + this room, if
|
||||||
|
* permitted). Space-wide packs are managed from Space settings.
|
||||||
|
*/
|
||||||
|
export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const { room } = callEmbed;
|
||||||
|
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||||
|
const packRooms = useImagePackRooms(room.roomId, roomToParents);
|
||||||
|
const packs = useRelevantSoundboardPacks(packRooms);
|
||||||
|
const [soundboardVolume] = useSetting(settingsAtom, 'soundboardVolume');
|
||||||
|
const master = Math.max(0, Math.min(1, soundboardVolume / 100));
|
||||||
|
|
||||||
|
const [cords, setCords] = useState<RectCords>();
|
||||||
|
const [manage, setManage] = useState(false);
|
||||||
|
const [playingKey, setPlayingKey] = useState<string>(); // host-side spam guard
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
|
||||||
|
const groups = useMemo(
|
||||||
|
() =>
|
||||||
|
packs
|
||||||
|
.map((pack) => ({
|
||||||
|
id: pack.id,
|
||||||
|
name: pack.meta.name ?? 'Soundboard',
|
||||||
|
clips: pack.getClips(),
|
||||||
|
}))
|
||||||
|
.filter((g) => g.clips.length > 0),
|
||||||
|
[packs],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOpen: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
|
setError(undefined);
|
||||||
|
setCords(evt.currentTarget.getBoundingClientRect());
|
||||||
|
};
|
||||||
|
|
||||||
|
const play = useCallback(
|
||||||
|
async (flat: FlatClip) => {
|
||||||
|
if (playingKey) return; // one at a time (fork also enforces this)
|
||||||
|
setPlayingKey(flat.key);
|
||||||
|
setError(undefined);
|
||||||
|
const done = () => setPlayingKey((k) => (k === flat.key ? undefined : k));
|
||||||
|
try {
|
||||||
|
const url = await resolveClipObjectUrl(mx, flat.clip.url);
|
||||||
|
const vol = (flat.clip.volume / 100) * master;
|
||||||
|
callEmbed.control.injectAudio(url, vol);
|
||||||
|
const audio = playClipLocally(url, vol);
|
||||||
|
if (audio) {
|
||||||
|
audio.addEventListener('ended', done, { once: true });
|
||||||
|
audio.addEventListener('error', done, { once: true });
|
||||||
|
} else {
|
||||||
|
done();
|
||||||
|
}
|
||||||
|
// Safety: clear the guard even if the audio never signals end.
|
||||||
|
window.setTimeout(done, 30_000);
|
||||||
|
} catch {
|
||||||
|
setError('Could not play that clip.');
|
||||||
|
done();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[mx, callEmbed, master, playingKey],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PopOut
|
||||||
|
anchor={cords}
|
||||||
|
position="Top"
|
||||||
|
align="Center"
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: () => setCords(undefined),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu style={{ maxWidth: manage ? toRem(420) : toRem(340), maxHeight: '70vh' }}>
|
||||||
|
<Box direction="Column" style={{ maxHeight: '70vh' }}>
|
||||||
|
<Box
|
||||||
|
shrink="No"
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="SpaceBetween"
|
||||||
|
gap="200"
|
||||||
|
style={{
|
||||||
|
padding: config.space.S200,
|
||||||
|
borderBottom: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="L400">Soundboard</Text>
|
||||||
|
<Box as="label" alignItems="Center" gap="200" style={{ cursor: 'pointer' }}>
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
Manage
|
||||||
|
</Text>
|
||||||
|
<Switch variant="Primary" value={manage} onChange={setManage} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Scroll size="300" hideTrack visibility="Hover">
|
||||||
|
<Box direction="Column" gap="300" style={{ padding: config.space.S200 }}>
|
||||||
|
{manage ? (
|
||||||
|
<>
|
||||||
|
<RoomSoundboardPack room={room} stateKey="" />
|
||||||
|
<UserSoundboardPack />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{groups.length === 0 && (
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
No soundboard clips here yet. Turn on <b>Manage</b> to upload some, or add
|
||||||
|
a pack in Space settings.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{groups.map((g) => (
|
||||||
|
<Box key={g.id} direction="Column" gap="100">
|
||||||
|
<Text size="L400">{g.name}</Text>
|
||||||
|
<Box wrap="Wrap" gap="200">
|
||||||
|
{g.clips.map((clip) => {
|
||||||
|
const key = `${g.id}|${clip.shortcode}`;
|
||||||
|
const flat: FlatClip = {
|
||||||
|
key,
|
||||||
|
packId: g.id,
|
||||||
|
packName: g.name,
|
||||||
|
clip,
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={key}
|
||||||
|
as="button"
|
||||||
|
direction="Column"
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="Center"
|
||||||
|
gap="100"
|
||||||
|
disabled={!!playingKey}
|
||||||
|
onClick={() => play(flat)}
|
||||||
|
aria-label={`Play ${clip.name}`}
|
||||||
|
style={{
|
||||||
|
width: toRem(76),
|
||||||
|
height: toRem(76),
|
||||||
|
padding: config.space.S100,
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||||
|
background:
|
||||||
|
playingKey === key
|
||||||
|
? color.Primary.Container
|
||||||
|
: color.SurfaceVariant.Container,
|
||||||
|
cursor: playingKey ? 'default' : 'pointer',
|
||||||
|
opacity: playingKey && playingKey !== key ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="H4">
|
||||||
|
{playingKey === key ? (
|
||||||
|
<Spinner size="200" />
|
||||||
|
) : (
|
||||||
|
clip.emoji || '🔊'
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<Text size="T200" truncate style={{ maxWidth: '100%' }}>
|
||||||
|
{clip.name}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Scroll>
|
||||||
|
</Box>
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TooltipProvider
|
||||||
|
tooltip={
|
||||||
|
<Tooltip>
|
||||||
|
<Text>Soundboard</Text>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(triggerRef) => (
|
||||||
|
<IconButton
|
||||||
|
ref={triggerRef}
|
||||||
|
variant="Surface"
|
||||||
|
fill="Soft"
|
||||||
|
radii="400"
|
||||||
|
size="400"
|
||||||
|
onClick={handleOpen}
|
||||||
|
outlined
|
||||||
|
aria-label="Soundboard"
|
||||||
|
aria-expanded={!!cords}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
>
|
||||||
|
<Icon size="400" src={Icons.BellRing} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</TooltipProvider>
|
||||||
|
</PopOut>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
|||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { StateEvent } from '../../../types/matrix/room';
|
import { StateEvent } from '../../../types/matrix/room';
|
||||||
import { useCallMembers, useCallSession } from '../../hooks/useCall';
|
import { useCallMembers, useCallSession } from '../../hooks/useCall';
|
||||||
|
import { LotusDecorationPusher } from '../lotus/LotusDecorationPusher';
|
||||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||||
import { VoiceLimitContent } from '../common-settings/general/RoomVoiceLimit';
|
import { VoiceLimitContent } from '../common-settings/general/RoomVoiceLimit';
|
||||||
import { CallMemberRenderer } from './CallMemberCard';
|
import { CallMemberRenderer } from './CallMemberCard';
|
||||||
@@ -164,7 +165,7 @@ function CallLoadErrorMessage() {
|
|||||||
const setCallEmbed = useSetAtom(callEmbedAtom);
|
const setCallEmbed = useSetAtom(callEmbedAtom);
|
||||||
|
|
||||||
// Disposing the embed tears down the hung iframe and returns the user to the
|
// Disposing the embed tears down the hung iframe and returns the user to the
|
||||||
// prescreen, from which they can join again ("Retry") or simply walk away.
|
// prescreen, where they can choose to join again.
|
||||||
const dismiss = () => setCallEmbed(undefined);
|
const dismiss = () => setCallEmbed(undefined);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -180,11 +181,8 @@ function CallLoadErrorMessage() {
|
|||||||
The call failed to load. Check your connection and try again.
|
The call failed to load. Check your connection and try again.
|
||||||
</Text>
|
</Text>
|
||||||
<Box gap="200" alignItems="Center">
|
<Box gap="200" alignItems="Center">
|
||||||
<Button variant="Primary" size="300" radii="400" onClick={dismiss}>
|
|
||||||
<Text size="B400">Retry</Text>
|
|
||||||
</Button>
|
|
||||||
<Button variant="Secondary" fill="Soft" size="300" radii="400" onClick={dismiss}>
|
<Button variant="Secondary" fill="Soft" size="300" radii="400" onClick={dismiss}>
|
||||||
<Text size="B400">Leave</Text>
|
<Text size="B400">Back</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -202,6 +200,8 @@ function CallJoined({ joined, containerRef }: CallJoinedProps) {
|
|||||||
<Box grow="Yes" direction="Column">
|
<Box grow="Yes" direction="Column">
|
||||||
<Box grow="Yes" ref={containerRef} />
|
<Box grow="Yes" ref={containerRef} />
|
||||||
{callEmbed && joined && <CallControls callEmbed={callEmbed} />}
|
{callEmbed && joined && <CallControls callEmbed={callEmbed} />}
|
||||||
|
{/* [lotus #6] push avatar decorations to EC's in-call tiles (post-join) */}
|
||||||
|
{callEmbed && joined && <LotusDecorationPusher callEmbed={callEmbed} />}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,13 +166,13 @@ export function ScreenShareButton({ enabled, onToggle }: ScreenShareButtonProps)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const FullscreenIcon = () => (
|
export const FullscreenIcon = () => (
|
||||||
<svg width="1em" height="1em" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="1em" height="1em" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M3 3h6v2H5v4H3V3zm12 0h6v6h-2V5h-4V3zM3 15h2v4h4v2H3v-6zm16 4h-4v2h6v-6h-2v4z" />
|
<path d="M3 3h6v2H5v4H3V3zm12 0h6v6h-2V5h-4V3zM3 15h2v4h4v2H3v-6zm16 4h-4v2h6v-6h-2v4z" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
const ExitFullscreenIcon = () => (
|
export const ExitFullscreenIcon = () => (
|
||||||
<svg width="1em" height="1em" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="1em" height="1em" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M9 3H7v4H3v2h6V3zm6 0v6h6V7h-4V3h-2zM3 13v2h4v4h2v-6H3zm14 4v-4h2v6h-6v-2h4z" />
|
<path d="M9 3H7v4H3v2h6V3zm6 0v6h6V7h-4V3h-2zM3 13v2h4v4h2v-6H3zm14 4v-4h2v6h-6v-2h4z" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { Box, Button, Icon, Icons, Spinner, Text } from 'folds';
|
import { Box, Button, color, Icon, Icons, Spinner, Text } from 'folds';
|
||||||
import { SequenceCard } from '../../components/sequence-card';
|
import { SequenceCard } from '../../components/sequence-card';
|
||||||
import * as css from './styles.css';
|
import * as css from './styles.css';
|
||||||
import { ChatButton, ControlDivider, MicrophoneButton, SoundButton, VideoButton } from './Controls';
|
import { ChatButton, ControlDivider, MicrophoneButton, SoundButton, VideoButton } from './Controls';
|
||||||
@@ -78,10 +78,7 @@ export function PrescreenControls({ canJoin }: PrescreenControlsProps) {
|
|||||||
</Box>
|
</Box>
|
||||||
<Box grow="Yes" direction="Column" gap="200">
|
<Box grow="Yes" direction="Column" gap="200">
|
||||||
{micDenied && (
|
{micDenied && (
|
||||||
<Text
|
<Text size="T200" style={{ color: color.Critical.Main, textAlign: 'center' }}>
|
||||||
size="T200"
|
|
||||||
style={{ color: 'var(--tc-critical-high, #e53e3e)', textAlign: 'center' }}
|
|
||||||
>
|
|
||||||
Microphone access is blocked. Enable it in your browser settings to join.
|
Microphone access is blocked. Enable it in your browser settings to join.
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Box, Switch, Text } from 'folds';
|
||||||
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
|
import { SequenceCardStyle } from '../../room-settings/styles.css';
|
||||||
|
import { SettingTile } from '../../../components/setting-tile';
|
||||||
|
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect';
|
||||||
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { useRoom } from '../../../hooks/useRoom';
|
||||||
|
import { StateEvent } from '../../../../types/matrix/room';
|
||||||
|
import { useStateEvent } from '../../../hooks/useStateEvent';
|
||||||
|
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
|
||||||
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
|
import {
|
||||||
|
AUDIO_BITRATE_OPTIONS,
|
||||||
|
RoomQualityContent,
|
||||||
|
SCREENSHARE_BITRATE_OPTIONS,
|
||||||
|
SCREENSHARE_FRAMERATE_OPTIONS,
|
||||||
|
} from '../../../utils/callQuality';
|
||||||
|
|
||||||
|
// Only the numeric cap keys are edited via `update`; the boolean policy keys
|
||||||
|
// are handled by `setAllow`.
|
||||||
|
type CapKey = 'audio_max_kbps' | 'screenshare_max_kbps' | 'screenshare_max_fps';
|
||||||
|
|
||||||
|
// String <-> numeric bridge for SettingsSelect (which needs string values).
|
||||||
|
const toValue = (n?: number): string => (typeof n === 'number' ? String(n) : 'auto');
|
||||||
|
|
||||||
|
const CAP_KEYS: (keyof RoomQualityContent)[] = [
|
||||||
|
'audio_max_kbps',
|
||||||
|
'screenshare_max_kbps',
|
||||||
|
'screenshare_max_fps',
|
||||||
|
'allow_screenshare',
|
||||||
|
'allow_camera',
|
||||||
|
];
|
||||||
|
const capsEqual = (a: RoomQualityContent, b: RoomQualityContent): boolean =>
|
||||||
|
CAP_KEYS.every((k) => a[k] === b[k]);
|
||||||
|
|
||||||
|
type RoomQualityProps = {
|
||||||
|
permissions: RoomPermissionsAPI;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* [P5-31] Room-admin quality ceiling. Writes `io.lotus.room_quality`; every
|
||||||
|
* Lotus client clamps its per-user quality to these caps. Hard enforcement for
|
||||||
|
* ALL Matrix clients is a server-side follow-up (see LOTUS_TODO.md P5-31).
|
||||||
|
*/
|
||||||
|
export function RoomQuality({ permissions }: RoomQualityProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const room = useRoom();
|
||||||
|
|
||||||
|
const canEdit = permissions.stateEvent(StateEvent.LotusRoomQuality, mx.getSafeUserId());
|
||||||
|
|
||||||
|
const event = useStateEvent(room, StateEvent.LotusRoomQuality);
|
||||||
|
const caps = useMemo<RoomQualityContent>(() => event?.getContent() ?? {}, [event]);
|
||||||
|
|
||||||
|
const [submitState, submit] = useAsyncCallback(
|
||||||
|
useCallback(
|
||||||
|
async (next: RoomQualityContent) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
await mx.sendStateEvent(room.roomId, StateEvent.LotusRoomQuality as any, next);
|
||||||
|
},
|
||||||
|
[mx, room.roomId],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const submitting = submitState.status === AsyncStatus.Loading;
|
||||||
|
|
||||||
|
// Optimistic mirror: `useStateEvent` only refreshes when the write echoes
|
||||||
|
// back via /sync (not when sendStateEvent resolves), so consecutive edits
|
||||||
|
// must build on the pending write — otherwise a second edit spreads a stale
|
||||||
|
// `caps` and silently drops the first. `effective` is what the UI shows and
|
||||||
|
// what each edit merges into; it's reconciled below once the echo lands.
|
||||||
|
const [pending, setPending] = useState<RoomQualityContent | null>(null);
|
||||||
|
const effective = pending ?? caps;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pending) return;
|
||||||
|
// Revert the optimistic view if the write failed…
|
||||||
|
if (submitState.status === AsyncStatus.Error) {
|
||||||
|
setPending(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// …or drop it once the synced state actually reflects it.
|
||||||
|
if (capsEqual(caps, pending)) setPending(null);
|
||||||
|
}, [caps, pending, submitState.status]);
|
||||||
|
|
||||||
|
const commit = (next: RoomQualityContent) => {
|
||||||
|
setPending(next);
|
||||||
|
submit(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const update = (key: CapKey, value: string) => {
|
||||||
|
const next: RoomQualityContent = { ...effective };
|
||||||
|
if (value === 'auto') delete next[key];
|
||||||
|
else next[key] = parseInt(value, 10);
|
||||||
|
commit(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setAllow = (key: 'allow_screenshare' | 'allow_camera', allowed: boolean) => {
|
||||||
|
const next: RoomQualityContent = { ...effective };
|
||||||
|
// Absent = allowed, so only persist the key when forbidding.
|
||||||
|
if (allowed) delete next[key];
|
||||||
|
else next[key] = false;
|
||||||
|
commit(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Absent/true = allowed.
|
||||||
|
const screenshareAllowed = effective.allow_screenshare !== false;
|
||||||
|
const cameraAllowed = effective.allow_camera !== false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SequenceCard
|
||||||
|
className={SequenceCardStyle}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
direction="Column"
|
||||||
|
gap="400"
|
||||||
|
>
|
||||||
|
<SettingTile
|
||||||
|
title="Call Permissions"
|
||||||
|
description={
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
Control what participants may share in this room. These are enforced on the server for
|
||||||
|
every Matrix client (Element, FluffyChat, Lotus Chat, …).
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Box direction="Column" gap="300">
|
||||||
|
<SettingTile
|
||||||
|
title="Allow Screen Sharing"
|
||||||
|
description="When off, no one can share their screen in this room."
|
||||||
|
after={
|
||||||
|
<Switch
|
||||||
|
variant="Primary"
|
||||||
|
value={screenshareAllowed}
|
||||||
|
onChange={(v) => setAllow('allow_screenshare', v)}
|
||||||
|
disabled={!canEdit || submitting}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SettingTile
|
||||||
|
title="Allow Camera"
|
||||||
|
description="When off, this is an audio-only room — no one can turn on their camera. Microphones are always allowed."
|
||||||
|
after={
|
||||||
|
<Switch
|
||||||
|
variant="Primary"
|
||||||
|
value={cameraAllowed}
|
||||||
|
onChange={(v) => setAllow('allow_camera', v)}
|
||||||
|
disabled={!canEdit || submitting}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<SettingTile
|
||||||
|
title="Call Quality Caps"
|
||||||
|
description={
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
Set a maximum microphone bitrate, screenshare bitrate, and screenshare framerate for
|
||||||
|
this room. Lotus Chat clamps each participant to these ceilings (best-effort — applies
|
||||||
|
to Lotus Chat clients). Auto = no cap.
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Box direction="Column" gap="300">
|
||||||
|
<SettingTile
|
||||||
|
title="Max Microphone Bitrate"
|
||||||
|
after={
|
||||||
|
<SettingsSelect
|
||||||
|
value={toValue(effective.audio_max_kbps)}
|
||||||
|
onChange={(v) => update('audio_max_kbps', v)}
|
||||||
|
options={AUDIO_BITRATE_OPTIONS}
|
||||||
|
disabled={!canEdit || submitting}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SettingTile
|
||||||
|
title="Max Screenshare Bitrate"
|
||||||
|
after={
|
||||||
|
<SettingsSelect
|
||||||
|
value={toValue(effective.screenshare_max_kbps)}
|
||||||
|
onChange={(v) => update('screenshare_max_kbps', v)}
|
||||||
|
options={SCREENSHARE_BITRATE_OPTIONS}
|
||||||
|
disabled={!canEdit || submitting}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SettingTile
|
||||||
|
title="Max Screenshare Framerate"
|
||||||
|
after={
|
||||||
|
<SettingsSelect
|
||||||
|
value={toValue(effective.screenshare_max_fps)}
|
||||||
|
onChange={(v) => update('screenshare_max_fps', v)}
|
||||||
|
options={SCREENSHARE_FRAMERATE_OPTIONS}
|
||||||
|
disabled={!canEdit || submitting}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</SequenceCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ export * from './RoomHistoryVisibility';
|
|||||||
export * from './RoomJoinRules';
|
export * from './RoomJoinRules';
|
||||||
export * from './RoomProfile';
|
export * from './RoomProfile';
|
||||||
export * from './RoomPublish';
|
export * from './RoomPublish';
|
||||||
|
export * from './RoomQuality';
|
||||||
export * from './RoomShareInvite';
|
export * from './RoomShareInvite';
|
||||||
export * from './RoomUpgrade';
|
export * from './RoomUpgrade';
|
||||||
export * from './RoomVoiceLimit';
|
export * from './RoomVoiceLimit';
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ function EditPower({ maxPower, power, tag, onSave, onClose }: EditPowerProps) {
|
|||||||
<Text size="L400">Name</Text>
|
<Text size="L400">Name</Text>
|
||||||
<Input
|
<Input
|
||||||
name="nameInput"
|
name="nameInput"
|
||||||
|
aria-label="Power level name"
|
||||||
defaultValue={tag?.name}
|
defaultValue={tag?.name}
|
||||||
placeholder="Bot"
|
placeholder="Bot"
|
||||||
size="300"
|
size="300"
|
||||||
@@ -160,6 +161,7 @@ function EditPower({ maxPower, power, tag, onSave, onClose }: EditPowerProps) {
|
|||||||
<Input
|
<Input
|
||||||
defaultValue={power}
|
defaultValue={power}
|
||||||
name="powerInput"
|
name="powerInput"
|
||||||
|
aria-label="Power level value"
|
||||||
size="300"
|
size="300"
|
||||||
variant={typeof power === 'number' ? 'SurfaceVariant' : 'Secondary'}
|
variant={typeof power === 'number' ? 'SurfaceVariant' : 'Secondary'}
|
||||||
radii="300"
|
radii="300"
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds';
|
||||||
|
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||||
|
import { useRoom } from '../../../hooks/useRoom';
|
||||||
|
import { RoomSoundboardPack, UserSoundboardPack } from '../../../components/soundboard-pack-view';
|
||||||
|
|
||||||
|
type SoundboardProps = {
|
||||||
|
requestClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soundboard management page (Room/Space settings). Mirrors the Emojis &
|
||||||
|
* Stickers page: a shared room/space pack (admin-editable, inherited by child
|
||||||
|
* rooms like emoji packs) plus the user's personal pack. A single default room
|
||||||
|
* pack (state key "") is used per room/space.
|
||||||
|
*/
|
||||||
|
export function Soundboard({ requestClose }: SoundboardProps) {
|
||||||
|
const room = useRoom();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<PageHeader outlined={false}>
|
||||||
|
<Box grow="Yes" gap="200">
|
||||||
|
<Box grow="Yes" alignItems="Center" gap="200">
|
||||||
|
<Text as="h2" size="H3" truncate>
|
||||||
|
Soundboard
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box shrink="No">
|
||||||
|
<IconButton onClick={requestClose} variant="Surface" aria-label="Close">
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</PageHeader>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Scroll hideTrack visibility="Hover">
|
||||||
|
<PageContent>
|
||||||
|
<Box direction="Column" gap="700">
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
|
<Text size="L400">This room / space (shared)</Text>
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
Clips here are shared with everyone, and inherited by every room under this space
|
||||||
|
— just like emoji/sticker packs. Only members with permission can edit.
|
||||||
|
</Text>
|
||||||
|
{room && <RoomSoundboardPack room={room} stateKey="" />}
|
||||||
|
</Box>
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
|
<Text size="L400">Personal</Text>
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
Your own clips, available in every call and synced across your devices.
|
||||||
|
</Text>
|
||||||
|
<UserSoundboardPack />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</PageContent>
|
||||||
|
</Scroll>
|
||||||
|
</Box>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './Soundboard';
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { DefaultReset, color, config, toRem } from 'folds';
|
||||||
|
|
||||||
|
const BAR_HEIGHT = toRem(32);
|
||||||
|
const CONTROL_WIDTH = toRem(46);
|
||||||
|
|
||||||
|
export const TitleBar = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'stretch',
|
||||||
|
flexShrink: 0,
|
||||||
|
height: BAR_HEIGHT,
|
||||||
|
width: '100%',
|
||||||
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
|
color: color.SurfaceVariant.OnContainer,
|
||||||
|
borderBottom: `${toRem(1)} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||||
|
// Sit above app content but never intercept scroll etc. below the bar.
|
||||||
|
userSelect: 'none',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// The draggable region carries `data-tauri-drag-region`; it must expand to fill
|
||||||
|
// the free space so most of the bar is grabbable.
|
||||||
|
export const DragRegion = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexGrow: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
gap: config.space.S200,
|
||||||
|
paddingInline: config.space.S300,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Brand = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: config.space.S200,
|
||||||
|
// Children shouldn't swallow the drag; the region itself owns the attribute.
|
||||||
|
pointerEvents: 'none',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Controls = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'stretch',
|
||||||
|
flexShrink: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ControlButton = style([
|
||||||
|
DefaultReset,
|
||||||
|
{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: CONTROL_WIDTH,
|
||||||
|
height: '100%',
|
||||||
|
padding: 0,
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: 'inherit',
|
||||||
|
transition: 'background-color 100ms ease',
|
||||||
|
selectors: {
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: color.SurfaceVariant.ContainerLine,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const ControlButtonClose = style({
|
||||||
|
selectors: {
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: color.Critical.Main,
|
||||||
|
color: color.Critical.OnMain,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import React, { MouseEvent, ReactNode } from 'react';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { Text } from 'folds';
|
||||||
|
import { customWindowChromeAtom } from '../../state/customWindowChrome';
|
||||||
|
import { invokeTauri, isTauri } from '../../hooks/useTauri';
|
||||||
|
import * as css from './TitleBar.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect macOS from the web side (no `tauri-plugin-os` dependency). We only need
|
||||||
|
* a coarse "is this a Mac" signal to decide which side the window controls sit
|
||||||
|
* on, so the UA/platform sniff is sufficient and stays cross-platform.
|
||||||
|
*/
|
||||||
|
const isMacOS = (): boolean => {
|
||||||
|
const platform =
|
||||||
|
(
|
||||||
|
navigator as unknown as {
|
||||||
|
userAgentData?: { platform?: string };
|
||||||
|
}
|
||||||
|
).userAgentData?.platform ??
|
||||||
|
navigator.platform ??
|
||||||
|
navigator.userAgent;
|
||||||
|
return /mac/i.test(platform);
|
||||||
|
};
|
||||||
|
|
||||||
|
const MIN_GLYPH = (
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden>
|
||||||
|
<rect x="1" y="4.5" width="8" height="1" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const MAX_GLYPH = (
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden>
|
||||||
|
<rect x="1" y="1" width="8" height="8" fill="none" stroke="currentColor" strokeWidth="1" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CLOSE_GLYPH = (
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden>
|
||||||
|
<path d="M1 1 L9 9 M9 1 L1 9" stroke="currentColor" strokeWidth="1" fill="none" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
type ControlButtonProps = {
|
||||||
|
label: string;
|
||||||
|
glyph: ReactNode;
|
||||||
|
onClick: () => void;
|
||||||
|
close?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ControlButton({ label, glyph, onClick, close }: ControlButtonProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={label}
|
||||||
|
title={label}
|
||||||
|
onClick={onClick}
|
||||||
|
className={`${css.ControlButton}${close ? ` ${css.ControlButtonClose}` : ''}`}
|
||||||
|
>
|
||||||
|
{glyph}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P5-47 — TDS Custom Window Chrome titlebar.
|
||||||
|
*
|
||||||
|
* Renders `null` unless we're inside Tauri **and** the user opted into custom
|
||||||
|
* window chrome. Otherwise it draws a thin (~32px) folds/TDS-styled titlebar: a
|
||||||
|
* draggable region (explicit `window_start_drag` on mousedown, double-press to
|
||||||
|
* maximize) with the app brand, plus minimize / maximize / close controls that
|
||||||
|
* call the native window commands.
|
||||||
|
*
|
||||||
|
* OS-aware: Windows/Linux put the controls on the right; macOS mirrors them to
|
||||||
|
* the left (the native traffic-light position) since decorations — and thus the
|
||||||
|
* real traffic lights — are stripped while custom chrome is on.
|
||||||
|
*/
|
||||||
|
export function TitleBar() {
|
||||||
|
const enabled = useAtomValue(customWindowChromeAtom);
|
||||||
|
|
||||||
|
if (!isTauri() || !enabled) return null;
|
||||||
|
|
||||||
|
const mac = isMacOS();
|
||||||
|
|
||||||
|
// Official Tauri custom-titlebar recipe: primary-button mousedown starts an
|
||||||
|
// OS window drag; a double press (detail === 2) toggles maximize instead. An
|
||||||
|
// explicit `window_start_drag` invoke is used rather than
|
||||||
|
// `data-tauri-drag-region` because the attribute only fires when the exact
|
||||||
|
// element is the event target (children like the brand text wouldn't drag).
|
||||||
|
const handleDragMouseDown = (evt: MouseEvent<HTMLDivElement>): void => {
|
||||||
|
if (evt.button !== 0) return;
|
||||||
|
if (evt.detail === 2) {
|
||||||
|
invokeTauri('window_toggle_maximize');
|
||||||
|
} else {
|
||||||
|
invokeTauri('window_start_drag');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const controls = (
|
||||||
|
<div className={css.Controls}>
|
||||||
|
<ControlButton
|
||||||
|
label="Minimize"
|
||||||
|
glyph={MIN_GLYPH}
|
||||||
|
onClick={() => invokeTauri('window_minimize')}
|
||||||
|
/>
|
||||||
|
<ControlButton
|
||||||
|
label="Maximize"
|
||||||
|
glyph={MAX_GLYPH}
|
||||||
|
onClick={() => invokeTauri('window_toggle_maximize')}
|
||||||
|
/>
|
||||||
|
<ControlButton
|
||||||
|
label="Close"
|
||||||
|
glyph={CLOSE_GLYPH}
|
||||||
|
onClick={() => invokeTauri('window_close')}
|
||||||
|
close
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const dragRegion = (
|
||||||
|
<div className={css.DragRegion} onMouseDown={handleDragMouseDown}>
|
||||||
|
<span className={css.Brand}>
|
||||||
|
<Text as="span" size="T200" truncate>
|
||||||
|
Lotus Chat
|
||||||
|
</Text>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className={css.TitleBar}>
|
||||||
|
{mac ? (
|
||||||
|
<>
|
||||||
|
{controls}
|
||||||
|
{dragRegion}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{dragRegion}
|
||||||
|
{controls}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useRef, type ReactElement } from 'react';
|
||||||
|
import { type CallEmbed } from '../../plugins/call';
|
||||||
|
import { useCallMembers, useCallSession } from '../../hooks/useCall';
|
||||||
|
import { useAvatarDecoration } from '../../hooks/useAvatarDecoration';
|
||||||
|
import { decorationUrl } from './avatarDecorations';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [lotus #6] Pushes each call participant's avatar-decoration image URL to the
|
||||||
|
* forked Element Call (`io.lotus.decorations`), which renders it on the in-call
|
||||||
|
* video-tile avatars. Mounted only while joined, so the EC-side handler exists.
|
||||||
|
*
|
||||||
|
* The decoration roster is per-user slugs resolved via `useAvatarDecoration`;
|
||||||
|
* we render one invisible probe per member to reuse that hook + its cache, then
|
||||||
|
* debounce-send the aggregated `{ userId: url }` map whenever it changes.
|
||||||
|
*/
|
||||||
|
function DecorationProbe({
|
||||||
|
userId,
|
||||||
|
onResolve,
|
||||||
|
}: {
|
||||||
|
userId: string;
|
||||||
|
onResolve: (userId: string, url: string | null) => void;
|
||||||
|
}): null {
|
||||||
|
const slug = useAvatarDecoration(userId);
|
||||||
|
useEffect(() => {
|
||||||
|
onResolve(userId, slug ? decorationUrl(slug) : null);
|
||||||
|
}, [userId, slug, onResolve]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LotusDecorationPusher({ callEmbed }: { callEmbed: CallEmbed }): ReactElement {
|
||||||
|
const session = useCallSession(callEmbed.room);
|
||||||
|
const members = useCallMembers(session);
|
||||||
|
const map = useRef<Map<string, string>>(new Map());
|
||||||
|
const pushTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
|
|
||||||
|
const userIds = useMemo(
|
||||||
|
() => Array.from(new Set(members.map((m) => m.userId).filter((u): u is string => !!u))),
|
||||||
|
[members],
|
||||||
|
);
|
||||||
|
|
||||||
|
const push = useCallback(() => {
|
||||||
|
const decorations: Record<string, string> = {};
|
||||||
|
map.current.forEach((url, userId) => {
|
||||||
|
decorations[userId] = url;
|
||||||
|
});
|
||||||
|
callEmbed.call.transport.send('io.lotus.decorations', { decorations }).catch(() => undefined);
|
||||||
|
}, [callEmbed]);
|
||||||
|
|
||||||
|
const schedulePush = useCallback(() => {
|
||||||
|
if (pushTimer.current) clearTimeout(pushTimer.current);
|
||||||
|
pushTimer.current = setTimeout(push, 300);
|
||||||
|
}, [push]);
|
||||||
|
|
||||||
|
const onResolve = useCallback(
|
||||||
|
(userId: string, url: string | null) => {
|
||||||
|
const prev = map.current.get(userId);
|
||||||
|
if (url) {
|
||||||
|
if (prev !== url) {
|
||||||
|
map.current.set(userId, url);
|
||||||
|
schedulePush();
|
||||||
|
}
|
||||||
|
} else if (prev !== undefined) {
|
||||||
|
map.current.delete(userId);
|
||||||
|
schedulePush();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[schedulePush],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Drop decorations for participants who left the call.
|
||||||
|
useEffect(() => {
|
||||||
|
const present = new Set(userIds);
|
||||||
|
let changed = false;
|
||||||
|
map.current.forEach((_url, userId) => {
|
||||||
|
if (!present.has(userId)) {
|
||||||
|
map.current.delete(userId);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (changed) schedulePush();
|
||||||
|
}, [userIds, schedulePush]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (pushTimer.current) clearTimeout(pushTimer.current);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{userIds.map((userId) => (
|
||||||
|
<DecorationProbe key={userId} userId={userId} onResolve={onResolve} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
DECORATION_CDN,
|
||||||
|
DECORATION_CATEGORIES,
|
||||||
|
ALL_DECORATIONS,
|
||||||
|
decorationUrl,
|
||||||
|
} from './avatarDecorations';
|
||||||
|
|
||||||
|
test('decorationUrl builds a CDN png url from the slug', () => {
|
||||||
|
assert.equal(decorationUrl('joystick'), `${DECORATION_CDN}/joystick.png`);
|
||||||
|
assert.equal(decorationUrl('lotus_flower'), `${DECORATION_CDN}/lotus_flower.png`);
|
||||||
|
// slug is used verbatim, no encoding/normalisation
|
||||||
|
assert.equal(decorationUrl(''), `${DECORATION_CDN}/.png`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DECORATION_CDN is an https url with no trailing slash', () => {
|
||||||
|
assert.match(DECORATION_CDN, /^https:\/\//);
|
||||||
|
assert.equal(DECORATION_CDN.endsWith('/'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ALL_DECORATIONS is the flattened set of every category decoration', () => {
|
||||||
|
const expectedCount = DECORATION_CATEGORIES.reduce((n, c) => n + c.decorations.length, 0);
|
||||||
|
assert.equal(ALL_DECORATIONS.length, expectedCount);
|
||||||
|
|
||||||
|
// every decoration in a category is present (by reference) in ALL_DECORATIONS
|
||||||
|
DECORATION_CATEGORIES.forEach((category) => {
|
||||||
|
category.decorations.forEach((decoration) => {
|
||||||
|
assert.ok(ALL_DECORATIONS.includes(decoration));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every category has a non-empty id, label and decorations list', () => {
|
||||||
|
DECORATION_CATEGORIES.forEach((category) => {
|
||||||
|
assert.equal(typeof category.id, 'string');
|
||||||
|
assert.ok(category.id.length > 0);
|
||||||
|
assert.equal(typeof category.label, 'string');
|
||||||
|
assert.ok(category.label.length > 0);
|
||||||
|
assert.ok(Array.isArray(category.decorations));
|
||||||
|
assert.ok(category.decorations.length > 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('category ids are unique', () => {
|
||||||
|
const ids = DECORATION_CATEGORIES.map((c) => c.id);
|
||||||
|
assert.equal(new Set(ids).size, ids.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every decoration slug is unique across all categories', () => {
|
||||||
|
const slugs = ALL_DECORATIONS.map((d) => d.slug);
|
||||||
|
assert.equal(new Set(slugs).size, slugs.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every decoration has a non-empty slug and name', () => {
|
||||||
|
ALL_DECORATIONS.forEach((decoration) => {
|
||||||
|
assert.equal(typeof decoration.slug, 'string');
|
||||||
|
assert.ok(decoration.slug.length > 0);
|
||||||
|
assert.equal(typeof decoration.name, 'string');
|
||||||
|
assert.ok(decoration.name.length > 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('slugs use the snake_case charset (lowercase, digits, underscore)', () => {
|
||||||
|
ALL_DECORATIONS.forEach((decoration) => {
|
||||||
|
assert.match(decoration.slug, /^[a-z0-9_]+$/, `bad slug: ${decoration.slug}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,20 @@
|
|||||||
|
// Single source of truth for the avatar-decoration CDN base URL.
|
||||||
|
// scripts/syncDecorations.mjs reads this exact `DECORATION_CDN` declaration out
|
||||||
|
// of this file at runtime (by regex) instead of re-declaring it, so the two can
|
||||||
|
// never drift. If you migrate the CDN, change it here ONLY — keep the
|
||||||
|
// `export const DECORATION_CDN = '...'` shape so the sync script can still parse it.
|
||||||
export const DECORATION_CDN =
|
export const DECORATION_CDN =
|
||||||
'https://drive.lotusguild.org/public.php/dav/files/bHswJ9pNKp2t26N/cinny-decorations';
|
'https://drive.lotusguild.org/public.php/dav/files/bHswJ9pNKp2t26N/cinny-decorations';
|
||||||
|
|
||||||
|
// Runtime base. A deployment can repoint the decorations at a different host
|
||||||
|
// without editing the catalog literal above (which scripts/syncDecorations.mjs
|
||||||
|
// and the build read) by setting `VITE_DECORATION_CDN`; otherwise it falls back
|
||||||
|
// to DECORATION_CDN. `import.meta.env` is undefined under the tsx test runner,
|
||||||
|
// hence the guard. Trailing slashes are trimmed so `decorationUrl` stays clean.
|
||||||
|
const envDecorationCdn = (import.meta as unknown as { env?: Record<string, string | undefined> })
|
||||||
|
.env?.VITE_DECORATION_CDN;
|
||||||
|
const RESOLVED_DECORATION_CDN = (envDecorationCdn || DECORATION_CDN).replace(/\/+$/, '');
|
||||||
|
|
||||||
export type AvatarDecoration = {
|
export type AvatarDecoration = {
|
||||||
slug: string;
|
slug: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -175,5 +189,5 @@ export const ALL_DECORATIONS: AvatarDecoration[] = DECORATION_CATEGORIES.flatMap
|
|||||||
);
|
);
|
||||||
|
|
||||||
export function decorationUrl(slug: string): string {
|
export function decorationUrl(slug: string): string {
|
||||||
return `${DECORATION_CDN}/${slug}.png`;
|
return `${RESOLVED_DECORATION_CDN}/${slug}.png`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { keyframes } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
// Aurora Flow — a SLOW, gentle pan of layered soft aurora ribbons.
|
||||||
|
//
|
||||||
|
// The living-aurora illusion is a pure `background-position` drift: each
|
||||||
|
// comma-separated gradient layer is authored larger than the viewport
|
||||||
|
// (backgroundSize 200%–300%, see animAurora.ts) so there is slack to slide it
|
||||||
|
// around. Panning several broad blurred bands by DIFFERENT
|
||||||
|
// amounts and along DIFFERENT paths makes the ribbons appear to curl and cross
|
||||||
|
// like real northern lights — no single layer ever moves in lockstep.
|
||||||
|
//
|
||||||
|
// LAYER ORDER (must match animAurora.ts exactly — one position value per layer):
|
||||||
|
// 1. green ribbon (drifts a wide, lazy horizontal arc)
|
||||||
|
// 2. teal ribbon (drifts on a slower, offset diagonal)
|
||||||
|
// 3. violet ribbon (drifts vertically, the "curtain" fold)
|
||||||
|
// 4. sky/aqua highlight (small counter-drift for shimmer)
|
||||||
|
// 5. calm reading core (STATIC — kept at 50% 50% so the center never moves)
|
||||||
|
// 6. vignette (STATIC — kept at 50% 50% so edges never move)
|
||||||
|
//
|
||||||
|
// SEAMLESS LOOP: every animated layer starts and ends on the SAME position
|
||||||
|
// ('0%'/'100%' being identical sample points of the repeating gradient tile),
|
||||||
|
// so one period returns each band to its origin with no visible jump. The two
|
||||||
|
// static layers list their fixed position at every stop so they never pan.
|
||||||
|
//
|
||||||
|
// SLOW & GENTLE: paired with a long duration + ease-in-out in animAurora.ts, the
|
||||||
|
// motion reads as a barely-perceptible breathing drift, keeping the reading
|
||||||
|
// center calm and text crisp.
|
||||||
|
//
|
||||||
|
// getChatBg adds `willChange: 'background-position'` here and STRIPS the whole
|
||||||
|
// `animation` for prefers-reduced-motion / pause-animations, at which point the
|
||||||
|
// static `backgroundPosition` authored in animAurora.ts is what shows — already
|
||||||
|
// a finished, gorgeous aurora.
|
||||||
|
export const auroraFlow = keyframes({
|
||||||
|
'0%': {
|
||||||
|
backgroundPosition: '0% 30%, 100% 70%, 50% 0%, 20% 80%, 50% 50%, 50% 50%',
|
||||||
|
},
|
||||||
|
'25%': {
|
||||||
|
backgroundPosition: '35% 45%, 70% 55%, 55% 35%, 45% 60%, 50% 50%, 50% 50%',
|
||||||
|
},
|
||||||
|
'50%': {
|
||||||
|
backgroundPosition: '65% 60%, 40% 40%, 45% 70%, 70% 35%, 50% 50%, 50% 50%',
|
||||||
|
},
|
||||||
|
'75%': {
|
||||||
|
backgroundPosition: '35% 45%, 70% 55%, 55% 35%, 45% 60%, 50% 50%, 50% 50%',
|
||||||
|
},
|
||||||
|
'100%': {
|
||||||
|
backgroundPosition: '0% 30%, 100% 70%, 50% 0%, 20% 80%, 50% 50%, 50% 50%',
|
||||||
|
},
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user