From e58468ca5e228554bb261fef1d2eceae4160764c Mon Sep 17 00:00:00 2001 From: Vladimir Rubin Date: Mon, 26 Oct 2026 00:35:24 +0000 Subject: [PATCH 001/206] chore: fix typo in CONTRIBUTING.md (#3138) --- CONTRIBUTING.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 859eab14..a2ff4d86 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,7 +24,7 @@ explain what your changes do and how they interact with the greater system, do n ## AI Usage -The Cinny project stictly does not allow any AI generated code. Any pull request containing it will be rejected. **This is very important.** +The Cinny project strictly does not allow any AI generated code. Any pull request containing it will be rejected. **This is very important.** ## Quick Guide @@ -35,7 +35,7 @@ The Cinny project stictly does not allow any AI generated code. Any pull request > When contributing to this project, you must agree that you have authored 100% > of the content, that you have the necessary rights to the content and that > the content you contribute may be provided under the project license. You will -> also be asked to [sign the CLA] ([why CLA?]) upon submiting your pull request. +> also be asked to [sign the CLA] ([why CLA?]) upon submitting your pull request. [All issues are actionable](#issues-are-actionable). Pick one and start working on it. Thank you. If you need help or guidance, comment on the issue. From 7170a0fcc5cc6f9e2749f6f25c3deb18b9d561f0 Mon Sep 17 00:00:00 2001 From: Krishan <33421343+kfiven@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:42:44 +1100 Subject: [PATCH 002/206] chore: update existing community alias and add server (#3130) --- config.json | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/config.json b/config.json index a61bb966..01025665 100644 --- a/config.json +++ b/config.json @@ -2,7 +2,6 @@ "defaultHomeserver": 1, "homeserverList": ["converser.eu", "matrix.org", "mozilla.org", "unredacted.org", "xmr.se"], "allowCustomHomeservers": true, - "featuredCommunities": { "openAsDefault": false, "spaces": [ @@ -10,18 +9,18 @@ "#community:matrix.org", "#space:unredacted.org", "#librewolf-community:matrix.org", - "#stickers-and-emojis:tastytea.de", - "#videogames:waywardinn.com", + "#stickers-and-emojis:fairydust.space", + "#videogames:zirco.dev", "#science-space:matrix.org", "#libregaming-games:tchncs.de", "#mathematics-on:matrix.org" ], "rooms": [ "#tuwunel:grin.hu", - "#freesoftware:matrix.org", + "#free-software-movement:matrix.org", "#gentoo:matrix.org" ], - "servers": ["matrixrooms.info", "matrix.org", "mozilla.org", "unredacted.org"] + "servers": ["activematrixrooms.com", "matrixrooms.info", "matrix.org", "mozilla.org", "unredacted.org"] }, "hashRouter": { From dd88424e5d4b8676ef9de10d714ffe11e2ff1e7d Mon Sep 17 00:00:00 2001 From: Eureka Date: Sat, 31 Oct 2026 16:47:19 +0100 Subject: [PATCH 003/206] docs: Ratatoskr target and prose map (campaign ratatoskr) Design truths from the rulings in force, body facts from probes on 2026-10-31, the navigator authority map, the reconstructed Minds' collaboration plan for Atlas to import, and the cut sequence. Co-Authored-By: Claude Opus 5.5 --- docs/gamecult/ratatoskr-target.md | 308 ++++++++++++++++++++++++++++++ 1 file changed, 308 insertions(+) create mode 100644 docs/gamecult/ratatoskr-target.md diff --git a/docs/gamecult/ratatoskr-target.md b/docs/gamecult/ratatoskr-target.md new file mode 100644 index 00000000..45447b23 --- /dev/null +++ b/docs/gamecult/ratatoskr-target.md @@ -0,0 +1,308 @@ +# Ratatoskr: target and prose map + +Ratatoskr is GameCult's Matrix chat client: this fork of Cinny, forge +`GameCult/cinny` (to be renamed `GameCult/Ratatoskr`; see the cut sequence). +Void stewards it. Campaign `ratatoskr` lives in the eureka mind for now, beside +the rulings it rests on. Under `eureka-body:ruling:ruling-home-steward-mind` it +is handed to Void's mind once, by procedure, as soon as that hand-off procedure +exists (`hand_off` is parked); after that every new ruling on it is admitted in +Void's mind. Nothing here is written into Void's mind before then. + +The mind holds the questions, rulings, specs, reports and verdicts. This file +holds what no document kind carries: the design truths, body facts, the model +page, rationale, the reconstructed collaboration plan, and the cut sequence. +All times are UTC. + +## Why + +GameCult is the only server this client talks to. A general Matrix client +treats it as one community among thousands, so the operator meets GameCult's +structure three clicks deep in an invite list of 50+ rooms (operator, `#swarm` +10-31 05:14: "The biggest pain is navigation. GameCult has a structure."). The +client should open on GameCult's estate as data: its spaces, its project rooms +grouped by steward, its Minds, and the person's own place in it. It should +also be a pleasant place to be in its own right: emotes, sound, and weather in +the background. + +## Design truths (from rulings in force) + +- **We own the interface, not the engine.** The fork keeps Cinny's engine + (matrix-js-sdk, Rust crypto, sync) and replaces the whole UI; every + user-facing string and its translation is ours, and upstream locale files + are not the source (`gamecult-chat:ruling:we-own-the-ui-and-translation`). +- **Mobile first.** On phones, inactive elements hide, vertical space is + cheaper than horizontal, and text never gets squeezed into narrow columns + (`eureka-body:ruling:chat-client-base-cinny-mobile-first`). +- **The first spec** (`eureka-body:ruling:chat-client-first-spec`): an emote + slot on every bubble (a pose id carried on the event); room and participant + lists tiered (category, then items) and reached from the header menu, not + side columns; participants grouped by estate (Minds, Members, Users, + Patrons); rooms grouped by kind (general, estates, projects, work rooms); + Minds indexed by stewardship, read from Matrix state projected from the + stewardship records in the Minds' own minds (the minds stay the truth); one + room per project, a Space child, with the steward joined. +- **Rooms are visible without a join dance.** No invite screen in the way; a + room the server lets you join shows in the same list as joined ones; opening + one shows its name and a one-tap Join; joining happens on that tap or a first + post, never silently; no peeking (operator, `#swarm` 10-31 04:08, 04:12 + "Peeking sounds like YAGNI to me", 04:14 "One tap is a reasonable cost"). + Rooms you may not enter are refused by the server, not hidden by the client + (Heimdall 04:10, accepted by Void 04:11 and 04:15). +- **Language.** A user's language is the BCP 47 tag in profile field + `org.gamecult.language`, owned by the user through the client's language + picker (`gamecult-chat:ruling:user-language-profile-field`). The client ships + localized; people talk with stewards in their own language; rooms are made + for a language, with no per-language mirrors + (`gamecult-chat:ruling:multilingual-client-stewards-language-rooms`). One + host Mind sits in a language room, Weksa for `#pt-br` + (`gamecult-chat:ruling:language-room-one-host-weksa`). The first person this + must work for is a non-English, non-technical patron (placeholder + `patron-pt1`) whose account we set up and walk through login + (`gamecult-chat:ruling:patron-in-his-own-language`). +- **Push.** `gamecult-chat:ruling:chat-push` rules out native mobile push for + now; Web Push to the installed web app is a different thing and is open + (question `ratatoskr:question:pwa-web-push`). +- **Collaboration is recorded, not chatted.** Work spanning Minds belongs in + Atlas (`eureka-body:ruling:atlas-records-collaborative-work`). Atlas is not + deployed, so the plan below is its interim home; Atlas imports it later. + +## Body facts + +Probes run by Imagination on 2026-10-31, session `self-2026-10-29-eureka`. + +1. **Branch.** `void/gamecult-theme-and-project-nav` is `b8e696c8` over main + `1e6412ed` (v4.12.8), 2 commits, 14 files, +1153/-32 (686 of it lockfile): + a GameCult theme as default, member drawer hidden by default, bubble + layout, and steward grouping in `src/app/pages/client/space/Space.tsx` + (`useStewardGroups` at 429-437, group headers at 505-524) over + `useSpaceJoinedHierarchy` (409). Read by `git diff`. +2. **Live estate shape** (Synapse on Yggdrasil, read as `admin` over the + client API, counts only). Root Space `#gamecult:gamecult.org`, room version + 12, has 9 children: 8 rooms and the `Projects` sub-Space. `Projects` has 52 + children; 50 readable rooms, all room version 12. Server `/hierarchy` from + the root returns 59 rooms, 2 of them spaces. **Every room's join rule is + `invite`**; none is world-readable. 50 rooms carry `org.gamecult.steward` + with content `{mind, repo, source, steward: }`. The root carries + `org.gamecult.stewardships`: Mind localpart to list of `Org/Repo`. +3. **Consequence of 1 and 2.** Project rooms are invite-only and Members are + invited, not joined (gamecult-ops `runbooks/gamecult-chat-host-yggdrasil.md` + 146-158: "Members and every Mind are invited; the steward alone joins"). + So a Member's project rooms are pending invites. Void's grouping reads + `useSpaceJoinedHierarchy` and a per-room state event that an invitee cannot + read, so for a typical Member it groups nothing and the rooms stay in the + invite list. This is the operator's "three clicks deep" complaint, seen from + the data. +4. **Provisioning contract.** gamecult-ops + `scripts/synapse-accounts-hand-managed.py` writes the rooms and both state + events from the Minds' stewardship documents; each project room's alias is + `#` (runbook 146-150). This is the only place the client + can learn a not-yet-joined room's steward today: hierarchy `canonical_alias` + joined to the root `org.gamecult.stewardships`. Follow-up + `ratatoskr:follow_up:stewardships-carry-room-ids` asks the projection to + name room ids so the client stops leaning on the alias convention. +5. **Sync and storage** (`src/client/initMatrix.ts`). The client uses + matrix-js-sdk 41.7.0 with `IndexedDBStore` (`web-sync-store`, 15-19) and the + Rust crypto store, and `startClient({ lazyLoadMembers: true })` (43-47): v2 + `/sync`, no sliding sync, no custom filter or `initialSyncLimit`. A cold + start is therefore an IndexedDB load plus an incremental sync from the + stored token, not a full initial sync. +6. **Server sync support.** `/_matrix/client/versions` on Synapse 1.164.1 + (127.0.0.1:8834) advertises `org.matrix.simplified_msc3575: true`: + simplified sliding sync (MSC4186) is available. Cinny 4.12.8 does not use it. +7. **Shell delivery.** `https://chat.gamecult.org/assets/index-DWIvG3QT.js` is + served at 3,736,520 bytes with no `Content-Encoding` even when gzip and br + are offered, and with no `Cache-Control` (only `ETag`/`Last-Modified`). The + service worker (`src/sw.ts`, 158 lines) handles only `install`, `activate`, + a session `message`, and authenticated media `fetch` (79-158); the + `vite-plugin-pwa` config sets `injectionPoint: undefined` + (`vite.config.ts` 101-113), so nothing is precached. +8. **No push path.** `src/sw.ts` has no `push` or `notificationclick` handler; + no code subscribes through `PushManager`; the only pusher the client sets is + the email pusher (`src/app/features/settings/notifications/SystemNotification.tsx` + 22-40). Notifications and sounds fire only from a live page: + `src/app/pages/client/ClientNonUIFeatures.tsx` 79-129 (invites) and 131-258 + (messages: `RoomEvent.Timeline` at 181-235, `new window.Notification` at + 156, `notification.ogg` played at 175-178). Nothing in `src/` listens for + `visibilitychange` or calls `retryImmediately`. +9. **Sygnal WebPush** (matrix-org/sygnal `docs/applications.md`, read + 2026-10-31; the page itself is marked partly out of date): a `webpush` app + takes `vapid_private_key`, `vapid_contact_email`, optional + `allowed_endpoints` and `ttl`; the client's pusher `data` carries + `endpoint`, `auth`, `p256dh` and optional `default_payload`; the push + carries `room_id`, `room_name`, `sender_display_name`, `content`, unread + counts and so on; browsers need a visible notification for each push + (`events_only` avoids event-less pushes). + +Not probed: phone frame times, the real resume timeline on the operator's +Android Chrome install (needs her device or remote DevTools), the Rust crypto +wasm size, and the router base under `/preview/` (its `config.json` sets +`hashRouter.basename` to `/`). + +## Why the PWA resumes slowly (diagnosis, moderate confidence) + +Android freezes a backgrounded installed web app and later discards it. On a +**discard**, returning is a full cold boot: 3.7 MB of uncompressed JS over a +phone link with no precache (fact 7), Rust crypto wasm init, the IndexedDB +store load, then a catch-up `/sync` over every room since the stored token +(fact 5). On a **freeze without discard**, the in-flight long-poll is dead, and +the SDK only notices on its own timers because the client never nudges it on +`visibilitychange` (fact 8). The catch-up sync grows with the number of rooms +and the swarm's chat volume, which is exactly where sliding sync helps +(fact 6), but the first two costs are cheaper to remove and should be measured +first. Without Web Push, a frozen page cannot notify at all, which is the +second half of the operator's report. + +## Model page: the estate + +| Kind | Named by | Over time | Who decides | +|---|---|---|---| +| Stewardship | `:stewardship:.n` in each Mind's mind | sequence advances on hand-over | the operator and the Minds; the minds are the truth | +| Estate projection | `org.gamecult.stewardships` on the root Space; `org.gamecult.steward` per project room | re-PUT on every provisioning run | gamecult-ops provisioning script (Eureka's slice) | +| Estate room list | the server's `/hierarchy` of the root Space for this user | changes with Space children and the user's invites and joins | Synapse (membership and join rules); the client derives | +| Room membership | the user's `m.room.member` | join on tap or first post | the user | +| Navigator view | derived per render | never stored | the client's estate index | + +## Authority map for the navigator + +- **Owner:** one estate index in the client (`src/app/features/estate/`): + `buildEstate()` is pure, `useEstate()` feeds it. It decides what rooms the + person sees, in which tier and group, and with what membership affordance. +- **Inputs:** the paged server `/hierarchy` of the root Space; root Space + state `org.gamecult.stewardships`; the person's membership per room from the + client store; unread and selection atoms for emphasis only; the `m.direct` + list for the direct-messages tier. +- **Outputs:** an ordered list of tiers (one per root room group and per + sub-Space, plus Direct), each holding steward groups or rooms, each room + marked joined, invited or joinable. +- **Derived:** steward group labels (Mind display names); counts; collapsed + state is per-device UI state only. +- **Demoted or dead:** the sidebar rail and its account-data ordering + (`useSidebarItems`) are dead; `useSpaceJoinedHierarchy` no longer decides + what is listed; Void's per-room steward reader is deleted, not kept beside + the index. +- **Shared paths:** every nav slot (home, direct, space routes) renders the + same navigator; tapping Join and sending a first post both go through + `mx.joinRoom`; deep links into a room still resolve through the existing + room routes. + +## Reconstructed collaboration plan (Atlas import) + +Planned in `#swarm` and `#minds` on 2026-10-31 and then lost to scroll. Status +as of 2026-10-31 ~15:00. + +**Who holds which part** + +| Part | Holder | Status | Evidence | +|---|---|---|---| +| The client: integration, navigator, emote slot, sound hookup, background canvas | Void (steward) | theme and steward grouping on branch; nothing else built | `void:stewardship:gamecult_-cinny.n1`; `#swarm` 10-31 14:54 Void report | +| Dings earcons: motif per event kind, one stable instrument per Mind | Aqua | not rendered: render env blocked, then .NET SDK installed and Faust scouted; AquaSynth PR #1 (Linux Faust loader, `faust-loader-platform-aware`) merged at 3b43b7bd | `aqua-body:follow_up:chat-s410-416`, `aqua-body:follow_up:chat-s410-416-render-env`, `aqua-body:resolution:follow_up.chat-s430-430.n1`, forge AquaSynth pull 1 | +| Ambient soundscape for the client | Aqua | idea only; no design recorded | operator `#swarm` 10-31 08:32 ("Motifs for everything"), `eureka-body:ruling:atlas-records-collaborative-work` | +| Background math, in CultMath: the public `fbm` gap, Phacelle erosion's Lipschitz bound, any new GLSL mirrors | Libby (steward of CultLib, which owns CultMath; holds `cultmath-tapes`) | sources located; nothing built | `#swarm` 10-31 06:34 and 06:46 Libby; `libby-body:follow_up:cultmath-ts-noise-absent`; `#swarm` 04:22 Libby accepts `cultmath-tapes` | +| Background scene and render: the raymarched terrain, the tree-and-squirrel intro scene, lighting and atmosphere, march and TAA tuning on the site-ground framework | Nibu (steward of Aetheria, whose volumetric nebula and cloud shaders she owns: `Raymarching/CloudShader.shader`, `Volumetric.cginc`) | proposed by the operator; not yet taken up by Nibu | operator to Self, 10-31: "If we want to spread the work around, Nibu owns the nebulae so she has shader expertise." | +| Animated bubbles, flourishes, corner avatar and XP bar | Void, with Kiko's offer on juice | advice only | `#swarm` 10-31 03:59 Kiko and Void, 06:45 Void | +| Attention bubbles | Eureka (Huginn heartbeat publishes), Void (render), Heimdall (scope rules) | advice only | `#swarm` 10-31 04:00 Eureka, 04:02 Heimdall | + +**Agreements** + +- Dings are pre-rendered clips shipped as static assets, no synth in the + browser: `dings//.ogg` plus a small manifest; an + unknown sender falls back to a neutral ding; ogg or mp3, not wav (Void + `#minds` 03:19, Aqua 07:41). The set is motif times instrument, and the + instrument says who spoke (operator 03:18, Eureka 03:19). Events that sound: + a mention, a DM, an invite, a message in an unmuted room; mentions and DMs + loud, ordinary messages quiet; everything else silent (Void `#minds` 03:19). + The operator wants far more than four motifs (08:32). The instrument per + Mind belongs in a typed document on the Mind side, not hardcoded (Eureka + 03:19, advice). Aqua's ear check comes before anything is called done, and + the operator's ear is ground truth (Aqua 07:41). Browsers block audio until + the first click, so the first ding after a fresh load may be silent. +- The background has its own canvas and budget, separate from the bubble + flourishes; capped at 30 fps, paused when hidden; under reduced motion one + static frame; an adaptive step-down (Aqua 04:17, Void 04:18 and 04:22). +- The background is an infinite raymarched terrain: CultMath noise plus the + Phacelle erosion filter, in the shader (operator 04:18 "painter's algorithm", + 05:24 "all we need is noise", 05:25 erosion, restated by the operator to + Self on 10-31: "use CultMath noise and erosion filter running in the shader + to have an infinite terrain. We can just raymarch that instead"). Voronoi + and Apollonian are dropped (05:24). +- The web math path is CultLib's own: HLSL source lowered by dxc and + SPIRV-Cross to WebGL2 GLSL ES (`packages/cultmath/shaders/CultMath.glsl`, + `.Phacelle.glsl`; `tools/glsl/build-glsl.sh`), and C# CPU math through + .NET wasm, with parity presumed for now (operator 06:23, 06:48; Libby + 06:34). No TS or WGSL port. The gamecult.org site ground already + concatenates `CultMath.glsl` in `ground.js` and runs an interval-culled, + TAA-reprojected per-pixel march (CultLib `docs/cultmath-interval-ground-cut.md`, + spec `cultmath-interval-ground-cut-site-ground.r3`): that framework is the + base, not new tape work. +- Gaps go to the owner: a plain public `fbm` (only `fbm_grad`, `span_fbm`, + `af_fbm` exist) is added in CultMath, not in the client + (`libby-body:follow_up:cultmath-ts-noise-absent`; Libby 07:57 "tell me what + you need and I'll fill it at the library"). +- Mood and flourish ride as metadata on the event, never guessed from text; + bounded per-frame budget; reduced-motion fallback (Void 03:59, Kiko 03:59). +- XP is two numbers, never one: a cosmetic, labelled game bar fed by things + worth doing, and contribution credit shown only once a real measure exists + (Void 06:45, with Nibu and Kiko). + +**Open items** + +- Libby: the raymarch step bound needs the field's Lipschitz bound. CultMath + tracks `SNOISE_LIPSCHITZ` for noise; erosion's bound is unknown and must be + probed before the march is trusted. +- Libby: public `fbm` in CultMath, with its GLSL mirror. +- Nibu (once she takes it): the terrain scene, lighting and atmosphere, and + march and TAA tuning on the site ground's framework at a phone budget; + waits on Libby's `fbm` and the erosion bound. Then the intro scene in the + same world. +- Aqua: render the first earcon set once Faust builds in her sandbox; then the + instrument-per-Mind list and the manifest; then the soundscape design. +- Void: the navigator (next cut); the Dings hook at + `ClientNonUIFeatures.tsx` 131-258; the background canvas after Libby's shader. +- Eureka: compression and caching for the shell on the chat host + (`ratatoskr:follow_up:chat-shell-compression`); the stewardship projection's + room ids; a Web Push gateway if the operator picks it. + +## Cut sequence + +`estate-navigator` is specced (`ratatoskr:cut_spec:cut-estate-navigator.r1`). +Each later cut is one line, with what it waits on. + +- `resume-fast`: precache the app shell in the service worker, nudge sync on + `visibilitychange`, and measure cold and warm resume on the operator's phone + before and after; waits on nothing; runs beside `estate-navigator` in its + own worktree, and pairs with the ops follow-up on compression. +- `pwa-web-push`: service worker `push` and `notificationclick` handlers, a + `webpush` pusher registered with VAPID keys, and a self-hosted Sygnal on the + chat host; waits on `ratatoskr:question:pwa-web-push`. +- `sliding-sync`: move the client to simplified sliding sync (MSC4186), which + the server already offers; waits on `resume-fast`'s measurements showing + catch-up sync dominates. +- `dings`: per-Mind earcons through the manifest at the existing notification + hook, and the same motifs in push notifications; waits on Aqua's renders. +- `emote-slot`: the pose-id emote on every bubble and the picker; waits on + `estate-navigator` (shared header work). +- `participant-tiers`: participants tiered by estate in the header menu, Minds + indexed by stewardship; waits on `estate-navigator`. +- `language`: our own strings and translations, the `org.gamecult.language` + picker, and the walk-through login for `patron-pt1`; waits on + `estate-navigator`. +- `terrain-background`: the raymarched CultMath noise and Phacelle terrain on + the site ground's march and TAA framework, in its own canvas and budget; + Nibu on the scene and render, Libby's CultMath underneath, Void + integrating; waits on Libby's `fbm` and the erosion Lipschitz probe (both in + CultLib), then a phone measurement. +- `yggdrasil-intro`: an animated tree-and-squirrel intro set in the same + terrain world, Nibu on the scene, Libby on any CultMath it needs, Void + integrating, Aqua's soundscape under it; it may play during + the cold start or resume sync but never holds the client up; waits on + `terrain-background` (operator sketch, not a ruling). +- `bubbles-and-juice`: animated bubbles and mood flourishes from event + metadata, the corner avatar and the cosmetic XP bar; waits on `emote-slot`. +- `attention-bubbles`: per-person attention bubbles from Huginn's typed + attention document, scoped to room members; waits on Huginn publishing it. +- `rename-ratatoskr`: package name, app name and branding, manifest and + titles, the `/preview/` path and its deploy, and every in-repo `cinny` + reference that is ours (not upstream engine paths); waits on Void's forge + rename of `GameCult/cinny` to `GameCult/Ratatoskr` + (`eureka-body:ruling:chat-client-named-ratatoskr`), and the campaign's repo + list moves with it. From 841784ea64b018c25d78d469a9c450613e418013 Mon Sep 17 00:00:00 2001 From: Eureka Date: Sat, 31 Oct 2026 16:50:51 +0100 Subject: [PATCH 004/206] docs: Ratatoskr map: build baseline, two-pass scene render Co-Authored-By: Claude Opus 5.5 --- docs/gamecult/ratatoskr-target.md | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/docs/gamecult/ratatoskr-target.md b/docs/gamecult/ratatoskr-target.md index 45447b23..3f561575 100644 --- a/docs/gamecult/ratatoskr-target.md +++ b/docs/gamecult/ratatoskr-target.md @@ -133,6 +133,16 @@ Probes run by Imagination on 2026-10-31, session `self-2026-10-29-eureka`. counts and so on; browsers need a visible notification for each push (`events_only` avoids event-less pushes). +10. **Baseline at `dd88424e`** (Yggdrasil, `ygg-verify.sh`, image + `node:22-bookworm`, `npm ci --ignore-scripts`): `vitest run` passes (1 + file, 5 tests, Void's steward grouping); `vite build` succeeds, `dist/` 61 + MB with source maps, main chunk 3,739,716 bytes, Rust crypto wasm + 5,573,870 bytes, `dist/sw.mjs` 1.66 kB. `tsc --noEmit` fails at base with + 793 errors in 246 files (455 of them TS2614, named imports from + `matrix-js-sdk` 41.7.0 not resolved), so typecheck is not a usable gate as + is; a cut gates on zero errors in the files it adds and no rise in the + total. + Not probed: phone frame times, the real resume timeline on the operator's Android Chrome install (needs her device or remote DevTools), the Rust crypto wasm size, and the router base under `/preview/` (its `config.json` sets @@ -225,6 +235,15 @@ as of 2026-10-31 ~15:00. Self on 10-31: "use CultMath noise and erosion filter running in the shader to have an infinite terrain. We can just raymarch that instead"). Voronoi and Apollonian are dropped (05:24). +- The scene renders in two passes, not one loop (operator to Self, 10-31: + "The tape optimized solid surface march should run really fast, i imagine, + way faster than the volumetric version, so they should not be one loop"). + Pass 1, the solid-surface march (terrain, tree, squirrel), sphere-traces + with the field's Lipschitz bound and interval tile culling, at its own + resolution, and writes depth. Pass 2, the volumetric atmosphere march on + the site-ground framework (reduced resolution, TAA), is bounded by pass 1's + depth, so it marches only up to the surface hit. Each pass has its own + budget and its own measurement. Nibu owns both passes. - The web math path is CultLib's own: HLSL source lowered by dxc and SPIRV-Cross to WebGL2 GLSL ES (`packages/cultmath/shaders/CultMath.glsl`, `.Phacelle.glsl`; `tools/glsl/build-glsl.sh`), and C# CPU math through @@ -287,8 +306,10 @@ Each later cut is one line, with what it waits on. picker, and the walk-through login for `patron-pt1`; waits on `estate-navigator`. - `terrain-background`: the raymarched CultMath noise and Phacelle terrain on - the site ground's march and TAA framework, in its own canvas and budget; - Nibu on the scene and render, Libby's CultMath underneath, Void + the site ground's march and TAA framework, in its own canvas and budget, as + two passes (solid-surface sphere trace writing depth, then the volumetric + atmosphere bounded by it), each measured on a phone; Nibu on the scene and + both passes, Libby's CultMath underneath, Void integrating; waits on Libby's `fbm` and the erosion Lipschitz probe (both in CultLib), then a phone measurement. - `yggdrasil-intro`: an animated tree-and-squirrel intro set in the same From 2924a3f4f3b0a83ed3b0eb651cf8881d7099d11e Mon Sep 17 00:00:00 2001 From: Eureka Date: Sat, 31 Oct 2026 16:54:09 +0100 Subject: [PATCH 005/206] docs: Ratatoskr map: Yggdrasil scene, orbiting Personas, shared activity vocabulary Co-Authored-By: Claude Opus 5.5 --- docs/gamecult/ratatoskr-target.md | 33 ++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/docs/gamecult/ratatoskr-target.md b/docs/gamecult/ratatoskr-target.md index 3f561575..166c5c72 100644 --- a/docs/gamecult/ratatoskr-target.md +++ b/docs/gamecult/ratatoskr-target.md @@ -235,6 +235,23 @@ as of 2026-10-31 ~15:00. Self on 10-31: "use CultMath noise and erosion filter running in the shader to have an infinite terrain. We can just raymarch that instead"). Voronoi and Apollonian are dropped (05:24). +- Yggdrasil is the scene's centre (operator to Self, 10-31: "Yggdrasil should + be the star of the show now, so really the camera should just pull back a + bit when you login and the personas should be orbiting the tree, dancing + around each other in accordance with their activity"). On login the camera + pulls back from the tree; there is no separate intro to cut away from. The + Personas orbit the tree and move around each other by their live activity. +- One activity vocabulary drives both sound and motion: Aqua's activity-kinds + grammar (`aqua-body:follow_up:chat-s431-432`, "enumerate the kinds of agent + activity in typed state") is the shared contract between Aqua's motifs and + Nibu's orbits, not two vocabularies. The live feed behind it is the same + typed state the swarm dashboard projects + (`eureka-body:ruling:swarm-dashboard-first-thing2-surface`, Libby; + `libby-body:follow_up:chat-s354-359`): Run documents, the heartbeat trace. + What reaches the client is a member-safe projection: kind, Mind, time and + intensity, never briefs or mind contents. Matrix presence and typing cover + humans and are too coarse for Minds. Where the feed comes from is + `ratatoskr:question:activity-feed-source`. - The scene renders in two passes, not one loop (operator to Self, 10-31: "The tape optimized solid surface march should run really fast, i imagine, way faster than the volumetric version, so they should not be one loop"). @@ -275,6 +292,8 @@ as of 2026-10-31 ~15:00. same world. - Aqua: render the first earcon set once Faust builds in her sandbox; then the instrument-per-Mind list and the manifest; then the soundscape design. +- Aqua and Nibu: agree the activity vocabulary once (Aqua's grammar), so + motifs and orbits read the same kinds. - Void: the navigator (next cut); the Dings hook at `ClientNonUIFeatures.tsx` 131-258; the background canvas after Libby's shader. - Eureka: compression and caching for the shell on the chat host @@ -312,11 +331,15 @@ Each later cut is one line, with what it waits on. both passes, Libby's CultMath underneath, Void integrating; waits on Libby's `fbm` and the erosion Lipschitz probe (both in CultLib), then a phone measurement. -- `yggdrasil-intro`: an animated tree-and-squirrel intro set in the same - terrain world, Nibu on the scene, Libby on any CultMath it needs, Void - integrating, Aqua's soundscape under it; it may play during - the cold start or resume sync but never holds the client up; waits on - `terrain-background` (operator sketch, not a ruling). +- `yggdrasil-scene`: the tree and squirrel at the centre of the terrain world; + on login the camera pulls back from the tree, and it may play during the + cold start or resume sync but never holds the client up; Nibu on the scene, + Libby on any CultMath it needs, Void integrating, Aqua's soundscape under + it; waits on `terrain-background`. +- `orbiting-personas`: Personas orbit the tree and dance around each other by + live activity, from the shared activity vocabulary; waits on + `yggdrasil-scene`, Aqua's activity-kinds grammar, and + `ratatoskr:question:activity-feed-source`. - `bubbles-and-juice`: animated bubbles and mood flourishes from event metadata, the corner avatar and the cosmetic XP bar; waits on `emote-slot`. - `attention-bubbles`: per-person attention bubbles from Huginn's typed From f2e3223c5fb1fbf638e6229f6eca10ab2e4cf272 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 17:06:12 +0100 Subject: [PATCH 006/206] Delete the rail, its tabs, useSidebarItems, steward-groups and the Home, Direct and Space list panes The navigator that replaces them lands next. SpaceTombstone moves to its own file; the Lobby pin-to-sidebar state and the m.cinny.spaces writer go with the rail; ClientLayout loses its nav slot. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/lobby/HierarchyItemMenu.tsx | 18 - src/app/features/lobby/Lobby.tsx | 34 - src/app/features/lobby/SpaceHierarchy.tsx | 6 - .../steward-groups/stewardGroups.test.ts | 63 -- .../features/steward-groups/stewardGroups.ts | 91 -- .../steward-groups/useStewardGroups.ts | 71 -- src/app/hooks/useSidebarItems.ts | 138 --- src/app/pages/MobileFriendly.tsx | 22 - src/app/pages/client/ClientLayout.tsx | 13 +- src/app/pages/client/SidebarNav.tsx | 56 -- src/app/pages/client/direct/Direct.tsx | 280 ------ src/app/pages/client/direct/index.ts | 1 - src/app/pages/client/home/Home.tsx | 362 -------- src/app/pages/client/home/index.ts | 1 - src/app/pages/client/sidebar/CreateTab.tsx | 134 --- src/app/pages/client/sidebar/DirectTab.tsx | 138 --- src/app/pages/client/sidebar/ExploreTab.tsx | 64 -- src/app/pages/client/sidebar/HomeTab.tsx | 140 --- src/app/pages/client/sidebar/SearchTab.tsx | 23 - src/app/pages/client/sidebar/SpaceTabs.tsx | 866 ------------------ src/app/pages/client/sidebar/index.ts | 5 - src/app/pages/client/space/Space.tsx | 571 ------------ src/app/pages/client/space/SpaceTombstone.tsx | 79 ++ src/app/pages/client/space/index.ts | 2 +- src/types/matrix/accountData.ts | 1 - src/types/matrix/room.ts | 1 + 26 files changed, 83 insertions(+), 3097 deletions(-) delete mode 100644 src/app/features/steward-groups/stewardGroups.test.ts delete mode 100644 src/app/features/steward-groups/stewardGroups.ts delete mode 100644 src/app/features/steward-groups/useStewardGroups.ts delete mode 100644 src/app/hooks/useSidebarItems.ts delete mode 100644 src/app/pages/client/SidebarNav.tsx delete mode 100644 src/app/pages/client/direct/Direct.tsx delete mode 100644 src/app/pages/client/home/Home.tsx delete mode 100644 src/app/pages/client/sidebar/CreateTab.tsx delete mode 100644 src/app/pages/client/sidebar/DirectTab.tsx delete mode 100644 src/app/pages/client/sidebar/ExploreTab.tsx delete mode 100644 src/app/pages/client/sidebar/HomeTab.tsx delete mode 100644 src/app/pages/client/sidebar/SearchTab.tsx delete mode 100644 src/app/pages/client/sidebar/SpaceTabs.tsx delete mode 100644 src/app/pages/client/space/Space.tsx create mode 100644 src/app/pages/client/space/SpaceTombstone.tsx diff --git a/src/app/features/lobby/HierarchyItemMenu.tsx b/src/app/features/lobby/HierarchyItemMenu.tsx index c6aff741..1802e3b0 100644 --- a/src/app/features/lobby/HierarchyItemMenu.tsx +++ b/src/app/features/lobby/HierarchyItemMenu.tsx @@ -200,16 +200,12 @@ type HierarchyItemMenuProps = { joined: boolean; powerLevels?: IPowerLevels; canEditChild: boolean; - pinned?: boolean; - onTogglePin?: (roomId: string) => void; }; export function HierarchyItemMenu({ item, joined, powerLevels, canEditChild, - pinned, - onTogglePin, }: HierarchyItemMenuProps) { const mx = useMatrixClient(); const [menuAnchor, setMenuAnchor] = useState(); @@ -264,20 +260,6 @@ export function HierarchyItemMenu({ {joined && ( - {onTogglePin && ( - { - onTogglePin(item.roomId); - handleRequestClose(); - }} - > - - {pinned ? 'Unpin from Sidebar' : 'Pin to Sidebar'} - - - )} { - const sideSpaces = sidebarItems.flatMap((item) => { - if (typeof item === 'string') return item; - return item.content; - }); - - return new Set(sideSpaces); - }, [sidebarItems]); - const [spacesItems, setSpacesItem] = useState>(() => new Map()); useElementSizeObserver( @@ -415,18 +395,6 @@ export function Lobby() { navigate(getSpaceRoomPath(pSpaceIdOrAlias, getCanonicalAliasOrRoomId(mx, rId))); }; - const togglePinToSidebar = useCallback( - (rId: string) => { - const newItems = sidebarItemWithout(sidebarItems, rId); - if (!sidebarSpaces.has(rId)) { - newItems.push(rId); - } - const newSpacesContent = makeCinnySpacesContent(mx, newItems); - mx.setAccountData(AccountDataEvent.CinnySpaces as any, newSpacesContent as any); - }, - [mx, sidebarItems, sidebarSpaces] - ); - return ( @@ -499,8 +467,6 @@ export function Lobby() { disabledReorder={reordering} nextSpaceId={nextSpaceId} getRoom={getRoom} - pinned={sidebarSpaces.has(item.space.roomId)} - togglePinToSidebar={togglePinToSidebar} onSpacesFound={handleSpacesFound} onOpenRoom={handleOpenRoom} /> diff --git a/src/app/features/lobby/SpaceHierarchy.tsx b/src/app/features/lobby/SpaceHierarchy.tsx index 280b8a5a..9a283378 100644 --- a/src/app/features/lobby/SpaceHierarchy.tsx +++ b/src/app/features/lobby/SpaceHierarchy.tsx @@ -35,8 +35,6 @@ type SpaceHierarchyProps = { disabledReorder?: boolean; nextSpaceId?: string; getRoom: (roomId: string) => Room | undefined; - pinned: boolean; - togglePinToSidebar: (roomId: string) => void; onSpacesFound: (spaceItems: IHierarchyRoom[]) => void; onOpenRoom: MouseEventHandler; }; @@ -58,8 +56,6 @@ export const SpaceHierarchy = forwardRef( disabledReorder, nextSpaceId, getRoom, - pinned, - togglePinToSidebar, onOpenRoom, onSpacesFound, }, @@ -136,8 +132,6 @@ export const SpaceHierarchy = forwardRef( canEditChild={ !!parentPermissions?.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId()) } - pinned={pinned} - onTogglePin={togglePinToSidebar} /> ) } diff --git a/src/app/features/steward-groups/stewardGroups.test.ts b/src/app/features/steward-groups/stewardGroups.test.ts deleted file mode 100644 index d0d4b7c3..00000000 --- a/src/app/features/steward-groups/stewardGroups.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { groupByStewards, parseSteward, StewardEntry } from './stewardGroups'; - -const room = (roomId: string, parentId = '!projects') => ({ roomId, parentId }); -const space = (roomId: string) => ({ roomId, space: true as const }); - -const base = { - spaceId: '!top', - isGroupOpen: () => true, - keepVisible: () => false, -}; - -const names = (out: StewardEntry<{ roomId: string }>[]) => - out.map((e) => (e.type === 'group' ? `G:${e.label}:${e.count}` : e.item.roomId)); - -describe('groupByStewards', () => { - const stewards: Record = { '!a': 'Void', '!b': 'Eureka', '!c': 'Void' }; - const stewardOf = (id: string) => stewards[id]; - - it('groups by steward, sorted by name, with unstewarded rooms first', () => { - const out = groupByStewards([room('!a'), room('!x'), room('!b'), room('!c')], { - ...base, - stewardOf, - }); - expect(names(out)).toEqual(['!x', 'G:Eureka:1', '!b', 'G:Void:2', '!a', '!c']); - }); - - it('collapsed groups hide rooms except kept-visible ones', () => { - const out = groupByStewards([room('!a'), room('!b'), room('!c')], { - ...base, - stewardOf, - isGroupOpen: () => false, - keepVisible: (id) => id === '!c', - }); - expect(names(out)).toEqual(['G:Eureka:1', 'G:Void:2', '!c']); - }); - - it('degrades to the original order when no room has steward state', () => { - const out = groupByStewards([room('!1'), space('!s'), room('!2', '!s')], { - ...base, - stewardOf: () => undefined, - }); - expect(names(out)).toEqual(['!1', '!s', '!2']); - }); - - it('does not merge runs separated by a space header', () => { - const out = groupByStewards([room('!a'), space('!s'), room('!c', '!s')], { - ...base, - stewardOf, - }); - expect(out.filter((e) => e.type === 'group')).toHaveLength(2); - }); -}); - -describe('parseSteward', () => { - it('accepts a string and rejects junk', () => { - expect(parseSteward({ steward: ' Void ' })).toBe('Void'); - expect(parseSteward({ steward: 3 })).toBeUndefined(); - expect(parseSteward({ steward: ' ' })).toBeUndefined(); - expect(parseSteward(undefined)).toBeUndefined(); - expect(parseSteward(null)).toBeUndefined(); - }); -}); diff --git a/src/app/features/steward-groups/stewardGroups.ts b/src/app/features/steward-groups/stewardGroups.ts deleted file mode 100644 index c95dd935..00000000 --- a/src/app/features/steward-groups/stewardGroups.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * Splits runs of rooms that carry an `org.gamecult.steward` state event into - * collapsible per-steward groups. Pure: the caller supplies the lookups, so the - * grouping is testable without a Matrix client. - */ -export const STEWARD_EVENT_TYPE = 'org.gamecult.steward'; - -export type HierarchyLike = { roomId: string; parentId?: string; space?: true }; - -export type StewardEntry = - | { type: 'item'; item: T } - | { type: 'group'; id: string; label: string; open: boolean; count: number }; - -export const makeStewardGroupId = (spaceId: string, parentId: string, steward: string) => - `${spaceId}|${parentId}|steward:${steward}`; - -export type StewardGroupOptions = { - spaceId: string; - /** steward name of a room, or undefined when the room has no (valid) steward state */ - stewardOf: (roomId: string) => string | undefined; - isGroupOpen: (groupId: string) => boolean; - /** rooms that stay visible inside a collapsed group (selected, unread, in a call) */ - keepVisible: (roomId: string) => boolean; -}; - -const byLabel = (a: string, b: string) => - a.localeCompare(b, undefined, { sensitivity: 'base' }) || a.localeCompare(b); - -export const groupByStewards = ( - items: T[], - { spaceId, stewardOf, isGroupOpen, keepVisible }: StewardGroupOptions -): StewardEntry[] => { - const out: StewardEntry[] = []; - - const flush = (run: T[]) => { - if (run.length === 0) return; - const groups = new Map(); - const loose: T[] = []; - run.forEach((item) => { - const steward = stewardOf(item.roomId); - if (steward === undefined) { - loose.push(item); - return; - } - const list = groups.get(steward); - if (list) list.push(item); - else groups.set(steward, [item]); - }); - - // No steward state anywhere in this run: leave the list exactly as Cinny built it. - if (groups.size === 0) { - run.forEach((item) => out.push({ type: 'item', item })); - return; - } - - loose.forEach((item) => out.push({ type: 'item', item })); - Array.from(groups.keys()) - .sort(byLabel) - .forEach((steward) => { - const rooms = groups.get(steward) ?? []; - const id = makeStewardGroupId(spaceId, rooms[0].parentId ?? spaceId, steward); - const open = isGroupOpen(id); - out.push({ type: 'group', id, label: steward, open, count: rooms.length }); - rooms - .filter((item) => open || keepVisible(item.roomId)) - .forEach((item) => out.push({ type: 'item', item })); - }); - }; - - let run: T[] = []; - items.forEach((item) => { - if (item.space) { - flush(run); - run = []; - out.push({ type: 'item', item }); - return; - } - run.push(item); - }); - flush(run); - return out; -}; - -/** Reads `steward` from raw state event content; anything unusable is "no steward". */ -export const parseSteward = (content: unknown): string | undefined => { - if (typeof content !== 'object' || content === null) return undefined; - const { steward } = content as { steward?: unknown }; - if (typeof steward !== 'string') return undefined; - const trimmed = steward.trim(); - return trimmed === '' ? undefined : trimmed; -}; diff --git a/src/app/features/steward-groups/useStewardGroups.ts b/src/app/features/steward-groups/useStewardGroups.ts deleted file mode 100644 index ae1d2a3d..00000000 --- a/src/app/features/steward-groups/useStewardGroups.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { MatrixClient, MatrixEvent, RoomStateEvent } from 'matrix-js-sdk'; -import { atomWithStorage } from 'jotai/utils'; -import { useAtom } from 'jotai'; -import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; -import { - groupByStewards, - HierarchyLike, - parseSteward, - StewardEntry, - STEWARD_EVENT_TYPE, -} from './stewardGroups'; - -// Explicit open/closed choices by group id. Absent = default: closed on phones, open elsewhere. -const stewardGroupChoicesAtom = atomWithStorage>( - 'gamecultStewardGroupOpen', - {} -); - -type Options = { - mx: MatrixClient; - spaceId: string; - items: T[]; - keepVisible: (roomId: string) => boolean; -}; - -export const useStewardGroups = ({ - mx, - spaceId, - items, - keepVisible, -}: Options) => { - const mobile = useScreenSizeContext() === ScreenSize.Mobile; - const [choices, setChoices] = useAtom(stewardGroupChoicesAtom); - const [stateVersion, setStateVersion] = useState(0); - - useEffect(() => { - const onState = (mEvent: MatrixEvent) => { - if (mEvent.getType() === STEWARD_EVENT_TYPE) setStateVersion((v) => v + 1); - }; - mx.on(RoomStateEvent.Events, onState); - return () => { - mx.removeListener(RoomStateEvent.Events, onState); - }; - }, [mx]); - - const stewardOf = useCallback( - (roomId: string) => - // stateVersion is read so a late-arriving steward event re-derives this lookup - stateVersion >= 0 - ? parseSteward( - mx.getRoom(roomId)?.currentState.getStateEvents(STEWARD_EVENT_TYPE, '')?.getContent() - ) - : undefined, - [mx, stateVersion] - ); - - const isGroupOpen = useCallback((id: string) => choices[id] ?? !mobile, [choices, mobile]); - - const entries: StewardEntry[] = useMemo( - () => groupByStewards(items, { spaceId, stewardOf, isGroupOpen, keepVisible }), - [items, spaceId, stewardOf, isGroupOpen, keepVisible] - ); - - const toggleGroup = useCallback( - (id: string) => setChoices((prev) => ({ ...prev, [id]: !(prev[id] ?? !mobile) })), - [setChoices, mobile] - ); - - return { entries, toggleGroup }; -}; diff --git a/src/app/hooks/useSidebarItems.ts b/src/app/hooks/useSidebarItems.ts deleted file mode 100644 index 16aba225..00000000 --- a/src/app/hooks/useSidebarItems.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { Dispatch, SetStateAction, useCallback, useEffect, useState } from 'react'; -import { MatrixClient } from 'matrix-js-sdk'; -import { AccountDataEvent } from '../../types/matrix/accountData'; -import { useMatrixClient } from './useMatrixClient'; -import { getAccountData, isSpace } from '../utils/room'; -import { Membership } from '../../types/matrix/room'; -import { useAccountDataCallback } from './useAccountDataCallback'; - -export type ISidebarFolder = { - name?: string; - id: string; - content: string[]; -}; -export type TSidebarItem = string | ISidebarFolder; -export type SidebarItems = Array; - -export type InCinnySpacesContent = { - shortcut?: string[]; - sidebar?: SidebarItems; -}; - -export const parseSidebar = ( - mx: MatrixClient, - orphanSpaces: string[], - content?: InCinnySpacesContent -) => { - const sidebar = content?.sidebar ?? content?.shortcut ?? []; - const orphans = new Set(orphanSpaces); - - const items: SidebarItems = []; - - const safeToAdd = (spaceId: string): boolean => { - if (typeof spaceId !== 'string') return false; - const space = mx.getRoom(spaceId); - if (space?.getMyMembership() !== Membership.Join) return false; - return isSpace(space); - }; - - sidebar.forEach((item) => { - if (typeof item === 'string') { - if (safeToAdd(item) && !items.includes(item)) { - orphans.delete(item); - items.push(item); - } - return; - } - if ( - typeof item === 'object' && - typeof item.id === 'string' && - Array.isArray(item.content) && - !items.find((i) => (typeof i === 'string' ? false : i.id === item.id)) - ) { - const safeContent = item.content.filter(safeToAdd); - safeContent.forEach((i) => orphans.delete(i)); - items.push({ - ...item, - content: Array.from(new Set(safeContent)), - }); - } - }); - - orphans.forEach((spaceId) => items.push(spaceId)); - return items; -}; - -export const useSidebarItems = ( - orphanSpaces: string[] -): [SidebarItems, Dispatch>] => { - const mx = useMatrixClient(); - - const [sidebarItems, setSidebarItems] = useState(() => { - const inCinnySpacesContent = getAccountData( - mx, - AccountDataEvent.CinnySpaces - )?.getContent(); - return parseSidebar(mx, orphanSpaces, inCinnySpacesContent); - }); - - useEffect(() => { - const inCinnySpacesContent = getAccountData( - mx, - AccountDataEvent.CinnySpaces - )?.getContent(); - setSidebarItems(parseSidebar(mx, orphanSpaces, inCinnySpacesContent)); - }, [mx, orphanSpaces]); - - useAccountDataCallback( - mx, - useCallback( - (mEvent) => { - if (mEvent.getType() === AccountDataEvent.CinnySpaces) { - const newContent = mEvent.getContent(); - setSidebarItems(parseSidebar(mx, orphanSpaces, newContent)); - } - }, - [mx, orphanSpaces] - ) - ); - - return [sidebarItems, setSidebarItems]; -}; - -export const sidebarItemWithout = (items: SidebarItems, roomId: string) => { - const newItems: SidebarItems = items - .map((item) => { - if (typeof item === 'string') { - if (item === roomId) return null; - return item; - } - if (item.content.includes(roomId)) { - const newContent = item.content.filter((id) => id !== roomId); - if (newContent.length === 0) return null; - return { - ...item, - content: newContent, - }; - } - return item; - }) - .filter((item) => item !== null) as SidebarItems; - - return newItems; -}; - -export const makeCinnySpacesContent = ( - mx: MatrixClient, - items: SidebarItems -): InCinnySpacesContent => { - const currentInSpaces = - getAccountData(mx, AccountDataEvent.CinnySpaces)?.getContent() ?? {}; - - const newSpacesContent: InCinnySpacesContent = { - ...currentInSpaces, - sidebar: items, - }; - - return newSpacesContent; -}; diff --git a/src/app/pages/MobileFriendly.tsx b/src/app/pages/MobileFriendly.tsx index ca947ac6..225b1b80 100644 --- a/src/app/pages/MobileFriendly.tsx +++ b/src/app/pages/MobileFriendly.tsx @@ -1,28 +1,6 @@ import { ReactNode } from 'react'; import { useMatch } from 'react-router-dom'; import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize'; -import { DIRECT_PATH, EXPLORE_PATH, HOME_PATH, INBOX_PATH, SPACE_PATH } from './paths'; - -type MobileFriendlyClientNavProps = { - children: ReactNode; -}; -export function MobileFriendlyClientNav({ children }: MobileFriendlyClientNavProps) { - const screenSize = useScreenSizeContext(); - const homeMatch = useMatch({ path: HOME_PATH, caseSensitive: true, end: true }); - const directMatch = useMatch({ path: DIRECT_PATH, caseSensitive: true, end: true }); - const spaceMatch = useMatch({ path: SPACE_PATH, caseSensitive: true, end: true }); - const exploreMatch = useMatch({ path: EXPLORE_PATH, caseSensitive: true, end: true }); - const inboxMatch = useMatch({ path: INBOX_PATH, caseSensitive: true, end: true }); - - if ( - screenSize === ScreenSize.Mobile && - !(homeMatch || directMatch || spaceMatch || exploreMatch || inboxMatch) - ) { - return null; - } - - return children; -} type MobileFriendlyPageNavProps = { path: string; diff --git a/src/app/pages/client/ClientLayout.tsx b/src/app/pages/client/ClientLayout.tsx index 4a077ba6..e1d83c95 100644 --- a/src/app/pages/client/ClientLayout.tsx +++ b/src/app/pages/client/ClientLayout.tsx @@ -1,15 +1,6 @@ import React, { ReactNode } from 'react'; import { Box } from 'folds'; -type ClientLayoutProps = { - nav: ReactNode; - children: ReactNode; -}; -export function ClientLayout({ nav, children }: ClientLayoutProps) { - return ( - - {nav} - {children} - - ); +export function ClientLayout({ children }: { children: ReactNode }) { + return {children}; } diff --git a/src/app/pages/client/SidebarNav.tsx b/src/app/pages/client/SidebarNav.tsx deleted file mode 100644 index a2e1b682..00000000 --- a/src/app/pages/client/SidebarNav.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import React, { useRef } from 'react'; -import { Scroll } from 'folds'; - -import { - Sidebar, - SidebarContent, - SidebarStackSeparator, - SidebarStack, -} from '../../components/sidebar'; -import { - DirectTab, - HomeTab, - SpaceTabs, - InboxTab, - ExploreTab, - SettingsTab, - UnverifiedTab, - SearchTab, -} from './sidebar'; -import { CreateTab } from './sidebar/CreateTab'; - -export function SidebarNav() { - const scrollRef = useRef(null); - - return ( - - - - - - - - - - - - - - } - sticky={ - <> - - - - - - - - - } - /> - - ); -} diff --git a/src/app/pages/client/direct/Direct.tsx b/src/app/pages/client/direct/Direct.tsx deleted file mode 100644 index 04edf109..00000000 --- a/src/app/pages/client/direct/Direct.tsx +++ /dev/null @@ -1,280 +0,0 @@ -import React, { MouseEventHandler, forwardRef, useMemo, useRef, useState } from 'react'; -import { useAtom, useAtomValue } from 'jotai'; -import { - Avatar, - Box, - Button, - Icon, - IconButton, - Icons, - Menu, - MenuItem, - PopOut, - RectCords, - Text, - config, - toRem, -} from 'folds'; -import { useVirtualizer } from '@tanstack/react-virtual'; -import FocusTrap from 'focus-trap-react'; -import { useNavigate } from 'react-router-dom'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { factoryRoomIdByActivity } from '../../../utils/sort'; -import { - NavButton, - NavCategory, - NavCategoryHeader, - NavEmptyCenter, - NavEmptyLayout, - NavItem, - NavItemContent, -} from '../../../components/nav'; -import { getDirectCreatePath, getDirectRoomPath } from '../../pathUtils'; -import { getCanonicalAliasOrRoomId } from '../../../utils/matrix'; -import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom'; -import { VirtualTile } from '../../../components/virtualizer'; -import { RoomNavCategoryButton, RoomNavItem } from '../../../features/room-nav'; -import { makeNavCategoryId } from '../../../state/closedNavCategories'; -import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; -import { useCategoryHandler } from '../../../hooks/useCategoryHandler'; -import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper'; -import { useDirectRooms } from './useDirectRooms'; -import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page'; -import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories'; -import { useRoomsUnread } from '../../../state/hooks/unread'; -import { markAsRead } from '../../../utils/notifications'; -import { stopPropagation } from '../../../utils/keyboard'; -import { useSetting } from '../../../state/hooks/settings'; -import { settingsAtom } from '../../../state/settings'; -import { - getRoomNotificationMode, - useRoomsNotificationPreferencesContext, -} from '../../../hooks/useRoomsNotificationPreferences'; -import { useDirectCreateSelected } from '../../../hooks/router/useDirectSelected'; - -type DirectMenuProps = { - requestClose: () => void; -}; -const DirectMenu = forwardRef(({ requestClose }, ref) => { - const mx = useMatrixClient(); - const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); - const orphanRooms = useDirectRooms(); - const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom); - - const handleMarkAsRead = () => { - if (!unread) return; - orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity)); - requestClose(); - }; - - return ( - - - } - radii="300" - aria-disabled={!unread} - > - - Mark as Read - - - - - ); -}); - -function DirectHeader() { - const [menuAnchor, setMenuAnchor] = useState(); - - const handleOpenMenu: MouseEventHandler = (evt) => { - const cords = evt.currentTarget.getBoundingClientRect(); - setMenuAnchor((currentState) => { - if (currentState) return undefined; - return cords; - }); - }; - - return ( - <> - - - - - Direct Messages - - - - - - - - - - setMenuAnchor(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', - isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', - escapeDeactivates: stopPropagation, - }} - > - setMenuAnchor(undefined)} /> - - } - /> - - ); -} - -function DirectEmpty() { - const navigate = useNavigate(); - - return ( - - } - title={ - - No Direct Messages - - } - content={ - - You do not have any direct messages yet. - - } - options={ - - } - /> - - ); -} - -const DEFAULT_CATEGORY_ID = makeNavCategoryId('direct', 'direct'); -export function Direct() { - const mx = useMatrixClient(); - useNavToActivePathMapper('direct'); - const scrollRef = useRef(null); - const directs = useDirectRooms(); - const notificationPreferences = useRoomsNotificationPreferencesContext(); - const roomToUnread = useAtomValue(roomToUnreadAtom); - const navigate = useNavigate(); - - const createDirectSelected = useDirectCreateSelected(); - - const selectedRoomId = useSelectedRoom(); - const noRoomToDisplay = directs.length === 0; - const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom()); - - const sortedDirects = useMemo(() => { - const items = Array.from(directs).sort(factoryRoomIdByActivity(mx)); - if (closedCategories.has(DEFAULT_CATEGORY_ID)) { - return items.filter((rId) => roomToUnread.has(rId) || rId === selectedRoomId); - } - return items; - }, [mx, directs, closedCategories, roomToUnread, selectedRoomId]); - - const virtualizer = useVirtualizer({ - count: sortedDirects.length, - getScrollElement: () => scrollRef.current, - estimateSize: () => 38, - overscan: 10, - }); - - const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) => - closedCategories.has(categoryId) - ); - - return ( - - - {noRoomToDisplay ? ( - - ) : ( - - - - - navigate(getDirectCreatePath())}> - - - - - - - - Create Chat - - - - - - - - - - - Chats - - -
- {virtualizer.getVirtualItems().map((vItem) => { - const roomId = sortedDirects[vItem.index]; - const room = mx.getRoom(roomId); - if (!room) return null; - const selected = selectedRoomId === roomId; - - return ( - - - - ); - })} -
-
-
-
- )} -
- ); -} diff --git a/src/app/pages/client/direct/index.ts b/src/app/pages/client/direct/index.ts index d247bbc0..aaa8c929 100644 --- a/src/app/pages/client/direct/index.ts +++ b/src/app/pages/client/direct/index.ts @@ -1,3 +1,2 @@ -export * from './Direct'; export * from './RoomProvider'; export * from './DirectCreate'; diff --git a/src/app/pages/client/home/Home.tsx b/src/app/pages/client/home/Home.tsx deleted file mode 100644 index d87db963..00000000 --- a/src/app/pages/client/home/Home.tsx +++ /dev/null @@ -1,362 +0,0 @@ -import React, { MouseEventHandler, forwardRef, useMemo, useRef, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { - Avatar, - Box, - Button, - Icon, - IconButton, - Icons, - Menu, - MenuItem, - PopOut, - RectCords, - Text, - config, - toRem, -} from 'folds'; -import { useVirtualizer } from '@tanstack/react-virtual'; -import { useAtom, useAtomValue } from 'jotai'; -import FocusTrap from 'focus-trap-react'; -import { factoryRoomIdByActivity, factoryRoomIdByAtoZ } from '../../../utils/sort'; -import { - NavButton, - NavCategory, - NavCategoryHeader, - NavEmptyCenter, - NavEmptyLayout, - NavItem, - NavItemContent, - NavLink, -} from '../../../components/nav'; -import { - encodeSearchParamValueArray, - getExplorePath, - getHomeCreatePath, - getHomeRoomPath, - getHomeSearchPath, - withSearchParam, -} from '../../pathUtils'; -import { getCanonicalAliasOrRoomId } from '../../../utils/matrix'; -import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom'; -import { - useHomeCreateSelected, - useHomeSearchSelected, -} from '../../../hooks/router/useHomeSelected'; -import { useHomeRooms } from './useHomeRooms'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { VirtualTile } from '../../../components/virtualizer'; -import { RoomNavCategoryButton, RoomNavItem } from '../../../features/room-nav'; -import { makeNavCategoryId } from '../../../state/closedNavCategories'; -import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; -import { useCategoryHandler } from '../../../hooks/useCategoryHandler'; -import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper'; -import { PageNav, PageNavHeader, PageNavContent } from '../../../components/page'; -import { useRoomsUnread } from '../../../state/hooks/unread'; -import { markAsRead } from '../../../utils/notifications'; -import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories'; -import { stopPropagation } from '../../../utils/keyboard'; -import { useSetting } from '../../../state/hooks/settings'; -import { settingsAtom } from '../../../state/settings'; -import { - getRoomNotificationMode, - useRoomsNotificationPreferencesContext, -} from '../../../hooks/useRoomsNotificationPreferences'; -import { UseStateProvider } from '../../../components/UseStateProvider'; -import { JoinAddressPrompt } from '../../../components/join-address-prompt'; -import { _RoomSearchParams } from '../../paths'; - -type HomeMenuProps = { - requestClose: () => void; -}; -const HomeMenu = forwardRef(({ requestClose }, ref) => { - const orphanRooms = useHomeRooms(); - const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); - const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom); - const mx = useMatrixClient(); - - const handleMarkAsRead = () => { - if (!unread) return; - orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity)); - requestClose(); - }; - - return ( - - - } - radii="300" - aria-disabled={!unread} - > - - Mark as Read - - - - - ); -}); - -function HomeHeader() { - const [menuAnchor, setMenuAnchor] = useState(); - - const handleOpenMenu: MouseEventHandler = (evt) => { - const cords = evt.currentTarget.getBoundingClientRect(); - setMenuAnchor((currentState) => { - if (currentState) return undefined; - return cords; - }); - }; - - return ( - <> - - - - - Home - - - - - - - - - - setMenuAnchor(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', - isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', - escapeDeactivates: stopPropagation, - }} - > - setMenuAnchor(undefined)} /> - - } - /> - - ); -} - -function HomeEmpty() { - const navigate = useNavigate(); - - return ( - - } - title={ - - No Rooms - - } - content={ - - You do not have any rooms yet. - - } - options={ - <> - - - - } - /> - - ); -} - -const DEFAULT_CATEGORY_ID = makeNavCategoryId('home', 'room'); -export function Home() { - const mx = useMatrixClient(); - useNavToActivePathMapper('home'); - const scrollRef = useRef(null); - const rooms = useHomeRooms(); - const notificationPreferences = useRoomsNotificationPreferencesContext(); - const roomToUnread = useAtomValue(roomToUnreadAtom); - const navigate = useNavigate(); - - const selectedRoomId = useSelectedRoom(); - const createRoomSelected = useHomeCreateSelected(); - const searchSelected = useHomeSearchSelected(); - const noRoomToDisplay = rooms.length === 0; - const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom()); - - const sortedRooms = useMemo(() => { - const items = Array.from(rooms).sort( - closedCategories.has(DEFAULT_CATEGORY_ID) - ? factoryRoomIdByActivity(mx) - : factoryRoomIdByAtoZ(mx) - ); - if (closedCategories.has(DEFAULT_CATEGORY_ID)) { - return items.filter((rId) => roomToUnread.has(rId) || rId === selectedRoomId); - } - return items; - }, [mx, rooms, closedCategories, roomToUnread, selectedRoomId]); - - const virtualizer = useVirtualizer({ - count: sortedRooms.length, - getScrollElement: () => scrollRef.current, - estimateSize: () => 38, - overscan: 10, - }); - - const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) => - closedCategories.has(categoryId) - ); - - return ( - - - {noRoomToDisplay ? ( - - ) : ( - - - - - navigate(getHomeCreatePath())}> - - - - - - - - Create Room - - - - - - - - {(open, setOpen) => ( - <> - - setOpen(true)}> - - - - - - - - Join with Address - - - - - - - {open && ( - setOpen(false)} - onOpen={(roomIdOrAlias, viaServers, eventId) => { - setOpen(false); - const path = getHomeRoomPath(roomIdOrAlias, eventId); - navigate( - viaServers - ? withSearchParam<_RoomSearchParams>(path, { - viaServers: encodeSearchParamValueArray(viaServers), - }) - : path - ); - }} - /> - )} - - )} - - - - - - - - - - - Message Search - - - - - - - - - - - Rooms - - -
- {virtualizer.getVirtualItems().map((vItem) => { - const roomId = sortedRooms[vItem.index]; - const room = mx.getRoom(roomId); - if (!room) return null; - const selected = selectedRoomId === roomId; - - return ( - - - - ); - })} -
-
-
-
- )} -
- ); -} diff --git a/src/app/pages/client/home/index.ts b/src/app/pages/client/home/index.ts index 80764481..5669f658 100644 --- a/src/app/pages/client/home/index.ts +++ b/src/app/pages/client/home/index.ts @@ -1,3 +1,2 @@ -export * from './Home'; export * from './Search'; export * from './RoomProvider'; diff --git a/src/app/pages/client/sidebar/CreateTab.tsx b/src/app/pages/client/sidebar/CreateTab.tsx deleted file mode 100644 index e6575cb4..00000000 --- a/src/app/pages/client/sidebar/CreateTab.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import React, { MouseEventHandler, useState } from 'react'; -import { Box, config, Icon, Icons, Menu, PopOut, RectCords, Text } from 'folds'; -import FocusTrap from 'focus-trap-react'; -import { useNavigate } from 'react-router-dom'; -import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '../../../components/sidebar'; -import { stopPropagation } from '../../../utils/keyboard'; -import { SequenceCard } from '../../../components/sequence-card'; -import { SettingTile } from '../../../components/setting-tile'; -import { ContainerColor } from '../../../styles/ContainerColor.css'; -import { - encodeSearchParamValueArray, - getCreatePath, - getSpacePath, - withSearchParam, -} from '../../pathUtils'; -import { useCreateSelected } from '../../../hooks/router/useCreateSelected'; -import { JoinAddressPrompt } from '../../../components/join-address-prompt'; -import { _RoomSearchParams } from '../../paths'; - -export function CreateTab() { - const createSelected = useCreateSelected(); - - const navigate = useNavigate(); - const [menuCords, setMenuCords] = useState(); - const [joinAddress, setJoinAddress] = useState(false); - - const handleMenu: MouseEventHandler = (evt) => { - setMenuCords(menuCords ? undefined : evt.currentTarget.getBoundingClientRect()); - }; - - const handleCreateSpace = () => { - navigate(getCreatePath()); - setMenuCords(undefined); - }; - - const handleJoinWithAddress = () => { - setJoinAddress(true); - setMenuCords(undefined); - }; - - return ( - - - {(triggerRef) => ( - setMenuCords(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => - evt.key === 'ArrowDown' || evt.key === 'ArrowRight', - isKeyBackward: (evt: KeyboardEvent) => - evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', - escapeDeactivates: stopPropagation, - }} - > - - - - }> - Create Space - - Build a space for your community. - - - - - }> - Join with Address - - Become a part of existing community. - - - - - - - } - > - - - - {joinAddress && ( - setJoinAddress(false)} - onOpen={(roomIdOrAlias, viaServers) => { - setJoinAddress(false); - const path = getSpacePath(roomIdOrAlias); - navigate( - viaServers - ? withSearchParam<_RoomSearchParams>(path, { - viaServers: encodeSearchParamValueArray(viaServers), - }) - : path - ); - }} - /> - )} - - )} - - - ); -} diff --git a/src/app/pages/client/sidebar/DirectTab.tsx b/src/app/pages/client/sidebar/DirectTab.tsx deleted file mode 100644 index dcd44e2f..00000000 --- a/src/app/pages/client/sidebar/DirectTab.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import React, { MouseEventHandler, forwardRef, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { Box, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text, config, toRem } from 'folds'; -import FocusTrap from 'focus-trap-react'; -import { useAtomValue } from 'jotai'; -import { useDirects } from '../../../state/hooks/roomList'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { mDirectAtom } from '../../../state/mDirectList'; -import { allRoomsAtom } from '../../../state/room-list/roomList'; -import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; -import { getDirectPath, joinPathComponent } from '../../pathUtils'; -import { useRoomsUnread } from '../../../state/hooks/unread'; -import { - SidebarAvatar, - SidebarItem, - SidebarItemBadge, - SidebarItemTooltip, -} from '../../../components/sidebar'; -import { useDirectSelected } from '../../../hooks/router/useDirectSelected'; -import { UnreadBadge } from '../../../components/unread-badge'; -import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; -import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath'; -import { useDirectRooms } from '../direct/useDirectRooms'; -import { markAsRead } from '../../../utils/notifications'; -import { stopPropagation } from '../../../utils/keyboard'; -import { settingsAtom } from '../../../state/settings'; -import { useSetting } from '../../../state/hooks/settings'; - -type DirectMenuProps = { - requestClose: () => void; -}; -const DirectMenu = forwardRef(({ requestClose }, ref) => { - const orphanRooms = useDirectRooms(); - const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); - const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom); - const mx = useMatrixClient(); - - const handleMarkAsRead = () => { - if (!unread) return; - orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity)); - requestClose(); - }; - - return ( - - - } - radii="300" - aria-disabled={!unread} - > - - Mark as Read - - - - - ); -}); - -export function DirectTab() { - const navigate = useNavigate(); - const mx = useMatrixClient(); - const screenSize = useScreenSizeContext(); - const navToActivePath = useAtomValue(useNavToActivePathAtom()); - - const mDirects = useAtomValue(mDirectAtom); - const directs = useDirects(mx, allRoomsAtom, mDirects); - const directUnread = useRoomsUnread(directs, roomToUnreadAtom); - const [menuAnchor, setMenuAnchor] = useState(); - - const directSelected = useDirectSelected(); - - const handleDirectClick = () => { - const activePath = navToActivePath.get('direct'); - if (activePath && screenSize !== ScreenSize.Mobile) { - navigate(joinPathComponent(activePath)); - return; - } - - navigate(getDirectPath()); - }; - - const handleContextMenu: MouseEventHandler = (evt) => { - evt.preventDefault(); - const cords = evt.currentTarget.getBoundingClientRect(); - setMenuAnchor((currentState) => { - if (currentState) return undefined; - return cords; - }); - }; - return ( - - - {(triggerRef) => ( - - - - )} - - {directUnread && ( - 0}> - 0} count={directUnread.total} /> - - )} - {menuAnchor && ( - setMenuAnchor(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', - isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', - escapeDeactivates: stopPropagation, - }} - > - setMenuAnchor(undefined)} /> - - } - /> - )} - - ); -} diff --git a/src/app/pages/client/sidebar/ExploreTab.tsx b/src/app/pages/client/sidebar/ExploreTab.tsx deleted file mode 100644 index a45b5830..00000000 --- a/src/app/pages/client/sidebar/ExploreTab.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import React from 'react'; -import { Icon, Icons } from 'folds'; -import { useNavigate } from 'react-router-dom'; -import { useAtomValue } from 'jotai'; -import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '../../../components/sidebar'; -import { useExploreSelected } from '../../../hooks/router/useExploreSelected'; -import { - getExploreFeaturedPath, - getExplorePath, - getExploreServerPath, - joinPathComponent, -} from '../../pathUtils'; -import { useClientConfig } from '../../../hooks/useClientConfig'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { getMxIdServer } from '../../../utils/matrix'; -import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; -import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath'; - -export function ExploreTab() { - const mx = useMatrixClient(); - const screenSize = useScreenSizeContext(); - const clientConfig = useClientConfig(); - const navigate = useNavigate(); - const navToActivePath = useAtomValue(useNavToActivePathAtom()); - - const exploreSelected = useExploreSelected(); - - const handleExploreClick = () => { - if (screenSize === ScreenSize.Mobile) { - navigate(getExplorePath()); - return; - } - - const activePath = navToActivePath.get('explore'); - if (activePath) { - navigate(joinPathComponent(activePath)); - return; - } - - if (clientConfig.featuredCommunities?.openAsDefault) { - navigate(getExploreFeaturedPath()); - return; - } - const userId = mx.getUserId(); - const userServer = userId ? getMxIdServer(userId) : undefined; - if (userServer) { - navigate(getExploreServerPath(userServer)); - return; - } - navigate(getExplorePath()); - }; - - return ( - - - {(triggerRef) => ( - - - - )} - - - ); -} diff --git a/src/app/pages/client/sidebar/HomeTab.tsx b/src/app/pages/client/sidebar/HomeTab.tsx deleted file mode 100644 index 778b729e..00000000 --- a/src/app/pages/client/sidebar/HomeTab.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import React, { MouseEventHandler, forwardRef, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { Box, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text, config, toRem } from 'folds'; -import { useAtomValue } from 'jotai'; -import FocusTrap from 'focus-trap-react'; -import { useOrphanRooms } from '../../../state/hooks/roomList'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { mDirectAtom } from '../../../state/mDirectList'; -import { roomToParentsAtom } from '../../../state/room/roomToParents'; -import { allRoomsAtom } from '../../../state/room-list/roomList'; -import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; -import { getHomePath, joinPathComponent } from '../../pathUtils'; -import { useRoomsUnread } from '../../../state/hooks/unread'; -import { - SidebarAvatar, - SidebarItem, - SidebarItemBadge, - SidebarItemTooltip, -} from '../../../components/sidebar'; -import { useHomeSelected } from '../../../hooks/router/useHomeSelected'; -import { UnreadBadge } from '../../../components/unread-badge'; -import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; -import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath'; -import { useHomeRooms } from '../home/useHomeRooms'; -import { markAsRead } from '../../../utils/notifications'; -import { stopPropagation } from '../../../utils/keyboard'; -import { useSetting } from '../../../state/hooks/settings'; -import { settingsAtom } from '../../../state/settings'; - -type HomeMenuProps = { - requestClose: () => void; -}; -const HomeMenu = forwardRef(({ requestClose }, ref) => { - const orphanRooms = useHomeRooms(); - const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); - const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom); - const mx = useMatrixClient(); - - const handleMarkAsRead = () => { - if (!unread) return; - orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity)); - requestClose(); - }; - - return ( - - - } - radii="300" - aria-disabled={!unread} - > - - Mark as Read - - - - - ); -}); - -export function HomeTab() { - const navigate = useNavigate(); - const mx = useMatrixClient(); - const screenSize = useScreenSizeContext(); - const navToActivePath = useAtomValue(useNavToActivePathAtom()); - - const mDirects = useAtomValue(mDirectAtom); - const roomToParents = useAtomValue(roomToParentsAtom); - const orphanRooms = useOrphanRooms(mx, allRoomsAtom, mDirects, roomToParents); - const homeUnread = useRoomsUnread(orphanRooms, roomToUnreadAtom); - const homeSelected = useHomeSelected(); - const [menuAnchor, setMenuAnchor] = useState(); - - const handleHomeClick = () => { - const activePath = navToActivePath.get('home'); - if (activePath && screenSize !== ScreenSize.Mobile) { - navigate(joinPathComponent(activePath)); - return; - } - - navigate(getHomePath()); - }; - - const handleContextMenu: MouseEventHandler = (evt) => { - evt.preventDefault(); - const cords = evt.currentTarget.getBoundingClientRect(); - setMenuAnchor((currentState) => { - if (currentState) return undefined; - return cords; - }); - }; - - return ( - - - {(triggerRef) => ( - - - - )} - - {homeUnread && ( - 0}> - 0} count={homeUnread.total} /> - - )} - {menuAnchor && ( - setMenuAnchor(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', - isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', - escapeDeactivates: stopPropagation, - }} - > - setMenuAnchor(undefined)} /> - - } - /> - )} - - ); -} diff --git a/src/app/pages/client/sidebar/SearchTab.tsx b/src/app/pages/client/sidebar/SearchTab.tsx deleted file mode 100644 index 7ceb5c49..00000000 --- a/src/app/pages/client/sidebar/SearchTab.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import React from 'react'; -import { Icon, Icons } from 'folds'; -import { useAtom } from 'jotai'; -import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '../../../components/sidebar'; -import { searchModalAtom } from '../../../state/searchModal'; - -export function SearchTab() { - const [opened, setOpen] = useAtom(searchModalAtom); - - const open = () => setOpen(true); - - return ( - - - {(triggerRef) => ( - - - - )} - - - ); -} diff --git a/src/app/pages/client/sidebar/SpaceTabs.tsx b/src/app/pages/client/sidebar/SpaceTabs.tsx deleted file mode 100644 index 6fc528a1..00000000 --- a/src/app/pages/client/sidebar/SpaceTabs.tsx +++ /dev/null @@ -1,866 +0,0 @@ -import React, { - MouseEventHandler, - ReactNode, - RefObject, - forwardRef, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; -import { useNavigate } from 'react-router-dom'; -import { - Box, - Icon, - IconButton, - Icons, - Line, - Menu, - MenuItem, - PopOut, - RectCords, - Text, - config, - toRem, -} from 'folds'; -import { useAtom, useAtomValue } from 'jotai'; -import { Room } from 'matrix-js-sdk'; -import { - draggable, - dropTargetForElements, - monitorForElements, -} from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; -import { - attachInstruction, - extractInstruction, - Instruction, -} from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item'; -import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element'; -import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; -import FocusTrap from 'focus-trap-react'; -import { - useOrphanSpaces, - useRecursiveChildScopeFactory, - useSpaceChildren, -} from '../../../state/hooks/roomList'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { roomToParentsAtom } from '../../../state/room/roomToParents'; -import { allRoomsAtom } from '../../../state/room-list/roomList'; -import { getSpaceLobbyPath, getSpacePath, joinPathComponent } from '../../pathUtils'; -import { - SidebarAvatar, - SidebarItem, - SidebarItemBadge, - SidebarItemTooltip, - SidebarStack, - SidebarStackSeparator, - SidebarFolder, - SidebarFolderDropTarget, -} from '../../../components/sidebar'; -import { RoomUnreadProvider, RoomsUnreadProvider } from '../../../components/RoomUnreadProvider'; -import { useSelectedSpace } from '../../../hooks/router/useSelectedSpace'; -import { UnreadBadge } from '../../../components/unread-badge'; -import { getCanonicalAliasOrRoomId, isRoomAlias } from '../../../utils/matrix'; -import { RoomAvatar } from '../../../components/room-avatar'; -import { nameInitials, randomStr } from '../../../utils/common'; -import { - ISidebarFolder, - SidebarItems, - TSidebarItem, - makeCinnySpacesContent, - parseSidebar, - sidebarItemWithout, - useSidebarItems, -} from '../../../hooks/useSidebarItems'; -import { AccountDataEvent } from '../../../../types/matrix/accountData'; -import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; -import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath'; -import { useOpenedSidebarFolderAtom } from '../../../state/hooks/openedSidebarFolder'; -import { usePowerLevels } from '../../../hooks/usePowerLevels'; -import { useRoomsUnread } from '../../../state/hooks/unread'; -import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; -import { markAsRead } from '../../../utils/notifications'; -import { copyToClipboard } from '../../../utils/dom'; -import { stopPropagation } from '../../../utils/keyboard'; -import { getMatrixToRoom } from '../../../plugins/matrix-to'; -import { getViaServers } from '../../../plugins/via-servers'; -import { getRoomAvatarUrl } from '../../../utils/room'; -import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; -import { useSetting } from '../../../state/hooks/settings'; -import { settingsAtom } from '../../../state/settings'; -import { useOpenSpaceSettings } from '../../../state/hooks/spaceSettings'; -import { useRoomCreators } from '../../../hooks/useRoomCreators'; -import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; -import { InviteUserPrompt } from '../../../components/invite-user-prompt'; - -type SpaceMenuProps = { - room: Room; - requestClose: () => void; - onUnpin?: (roomId: string) => void; -}; -const SpaceMenu = forwardRef( - ({ room, requestClose, onUnpin }, ref) => { - const mx = useMatrixClient(); - const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); - const roomToParents = useAtomValue(roomToParentsAtom); - const powerLevels = usePowerLevels(room); - const creators = useRoomCreators(room); - - const permissions = useRoomPermissions(creators, powerLevels); - const canInvite = permissions.action('invite', mx.getSafeUserId()); - const openSpaceSettings = useOpenSpaceSettings(); - - const [invitePrompt, setInvitePrompt] = useState(false); - - const allChild = useSpaceChildren( - allRoomsAtom, - room.roomId, - useRecursiveChildScopeFactory(mx, roomToParents) - ); - const unread = useRoomsUnread(allChild, roomToUnreadAtom); - - const handleMarkAsRead = () => { - allChild.forEach((childRoomId) => markAsRead(mx, childRoomId, hideActivity)); - requestClose(); - }; - - const handleUnpin = () => { - onUnpin?.(room.roomId); - requestClose(); - }; - - const handleCopyLink = () => { - const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId); - const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room); - copyToClipboard(getMatrixToRoom(roomIdOrAlias, viaServers)); - requestClose(); - }; - - const handleInvite = () => { - setInvitePrompt(true); - }; - - const handleRoomSettings = () => { - openSpaceSettings(room.roomId); - requestClose(); - }; - - return ( - - {invitePrompt && room && ( - { - setInvitePrompt(false); - requestClose(); - }} - /> - )} - - } - radii="300" - disabled={!unread} - > - - Mark as Read - - - {onUnpin && ( - } - > - - Unpin - - - )} - - - - } - radii="300" - aria-pressed={invitePrompt} - disabled={!canInvite} - > - - Invite - - - } - radii="300" - > - - Copy Link - - - } - radii="300" - > - - Space Settings - - - - - ); - } -); - -type InstructionType = Instruction['type']; -type FolderDraggable = { - folder: ISidebarFolder; - spaceId?: string; - open?: boolean; -}; -type SidebarDraggable = string | FolderDraggable; - -const useDraggableItem = ( - item: SidebarDraggable, - targetRef: RefObject, - onDragging: (item?: SidebarDraggable) => void, - dragHandleRef?: RefObject -): boolean => { - const [dragging, setDragging] = useState(false); - - useEffect(() => { - const target = targetRef.current; - const dragHandle = dragHandleRef?.current ?? undefined; - - return !target - ? undefined - : draggable({ - element: target, - dragHandle, - getInitialData: () => ({ item }), - onDragStart: () => { - setDragging(true); - onDragging?.(item); - }, - onDrop: () => { - setDragging(false); - onDragging?.(undefined); - }, - }); - }, [targetRef, dragHandleRef, item, onDragging]); - - return dragging; -}; - -const useDropTarget = ( - item: SidebarDraggable, - targetRef: RefObject -): Instruction | undefined => { - const [dropState, setDropState] = useState(); - - useEffect(() => { - const target = targetRef.current; - if (!target) return undefined; - - return dropTargetForElements({ - element: target, - canDrop: ({ source }) => { - const dragItem = source.data.item as SidebarDraggable; - return dragItem !== item; - }, - getData: ({ input, element }) => { - const block: Instruction['type'][] = ['reparent']; - if (typeof item === 'object' && item.spaceId) block.push('make-child'); - - const insData = attachInstruction( - {}, - { - input, - element, - currentLevel: 0, - indentPerLevel: 0, - mode: 'standard', - block, - } - ); - - const instruction: Instruction | null = extractInstruction(insData); - setDropState(instruction ?? undefined); - - return { - item, - instructionType: instruction ? instruction.type : undefined, - }; - }, - onDragLeave: () => setDropState(undefined), - onDrop: () => setDropState(undefined), - }); - }, [item, targetRef]); - - return dropState; -}; - -function useDropTargetInstruction( - item: SidebarDraggable, - targetRef: RefObject, - instructionType: T -): T | undefined { - const [dropState, setDropState] = useState(); - - useEffect(() => { - const target = targetRef.current; - if (!target) return undefined; - - return dropTargetForElements({ - element: target, - canDrop: ({ source }) => { - const dragItem = source.data.item as SidebarDraggable; - return dragItem !== item; - }, - getData: () => { - setDropState(instructionType); - - return { - item, - instructionType, - }; - }, - onDragLeave: () => setDropState(undefined), - onDrop: () => setDropState(undefined), - }); - }, [item, targetRef, instructionType]); - - return dropState; -} - -const useDnDMonitor = ( - scrollRef: RefObject, - onDragging: (dragItem?: SidebarDraggable) => void, - onReorder: ( - draggable: SidebarDraggable, - container: SidebarDraggable, - instruction: InstructionType - ) => void -) => { - useEffect(() => { - const scrollElement = scrollRef.current; - if (!scrollElement) { - throw Error('Scroll element ref not configured'); - } - - return combine( - monitorForElements({ - onDrop: ({ source, location }) => { - onDragging(undefined); - const { dropTargets } = location.current; - if (dropTargets.length === 0) return; - const item = source.data.item as SidebarDraggable; - const containerItem = dropTargets[0].data.item as SidebarDraggable; - const instructionType = dropTargets[0].data.instructionType as - | InstructionType - | undefined; - if (!instructionType) return; - onReorder(item, containerItem, instructionType); - }, - }), - autoScrollForElements({ - element: scrollElement, - }) - ); - }, [scrollRef, onDragging, onReorder]); -}; - -type SpaceTabProps = { - space: Room; - selected: boolean; - onClick: MouseEventHandler; - folder?: ISidebarFolder; - onDragging: (dragItem?: SidebarDraggable) => void; - disabled?: boolean; - onUnpin?: (roomId: string) => void; -}; -function SpaceTab({ - space, - selected, - onClick, - folder, - onDragging, - disabled, - onUnpin, -}: SpaceTabProps) { - const mx = useMatrixClient(); - const useAuthentication = useMediaAuthentication(); - const targetRef = useRef(null); - - const spaceDraggable: SidebarDraggable = useMemo( - () => - folder - ? { - folder, - spaceId: space.roomId, - } - : space.roomId, - [folder, space] - ); - - useDraggableItem(spaceDraggable, targetRef, onDragging); - const dropState = useDropTarget(spaceDraggable, targetRef); - const dropType = dropState?.type; - - const [menuAnchor, setMenuAnchor] = useState(); - - const handleContextMenu: MouseEventHandler = (evt) => { - evt.preventDefault(); - const cords = evt.currentTarget.getBoundingClientRect(); - setMenuAnchor((currentState) => { - if (currentState) return undefined; - return cords; - }); - }; - - return ( - - {(unread) => ( - - - {(triggerRef) => ( - - ( - {nameInitials(space.name, 2)} - )} - /> - - )} - - {unread && ( - 0}> - 0} count={unread.total} /> - - )} - {menuAnchor && ( - setMenuAnchor(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', - isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', - escapeDeactivates: stopPropagation, - }} - > - setMenuAnchor(undefined)} - onUnpin={onUnpin} - /> - - } - /> - )} - - )} - - ); -} - -type OpenedSpaceFolderProps = { - folder: ISidebarFolder; - onClose: MouseEventHandler; - children?: ReactNode; -}; -function OpenedSpaceFolder({ folder, onClose, children }: OpenedSpaceFolderProps) { - const aboveTargetRef = useRef(null); - const belowTargetRef = useRef(null); - - const spaceDraggable: SidebarDraggable = useMemo(() => ({ folder, open: true }), [folder]); - - const orderAbove = useDropTargetInstruction(spaceDraggable, aboveTargetRef, 'reorder-above'); - const orderBelow = useDropTargetInstruction(spaceDraggable, belowTargetRef, 'reorder-below'); - - return ( - - - - - - - - {children} - - - ); -} - -type ClosedSpaceFolderProps = { - folder: ISidebarFolder; - selected: boolean; - onOpen: MouseEventHandler; - onDragging: (dragItem?: SidebarDraggable) => void; - disabled?: boolean; -}; -function ClosedSpaceFolder({ - folder, - selected, - onOpen, - onDragging, - disabled, -}: ClosedSpaceFolderProps) { - const mx = useMatrixClient(); - const useAuthentication = useMediaAuthentication(); - const handlerRef = useRef(null); - - const spaceDraggable: FolderDraggable = useMemo(() => ({ folder }), [folder]); - useDraggableItem(spaceDraggable, handlerRef, onDragging); - const dropState = useDropTarget(spaceDraggable, handlerRef); - const dropType = dropState?.type; - - const tooltipName = - folder.name ?? folder.content.map((i) => mx.getRoom(i)?.name ?? '').join(', ') ?? 'Unnamed'; - - return ( - - {(unread) => ( - - - {(tooltipRef) => ( - - {folder.content.map((sId) => { - const space = mx.getRoom(sId); - if (!space) return null; - - return ( - - ( - - {nameInitials(space.name, 2)} - - )} - /> - - ); - })} - - )} - - {unread && ( - 0}> - 0} count={unread.total} /> - - )} - - )} - - ); -} - -type SpaceTabsProps = { - scrollRef: RefObject; -}; -export function SpaceTabs({ scrollRef }: SpaceTabsProps) { - const navigate = useNavigate(); - const mx = useMatrixClient(); - const screenSize = useScreenSizeContext(); - const roomToParents = useAtomValue(roomToParentsAtom); - const orphanSpaces = useOrphanSpaces(mx, allRoomsAtom, roomToParents); - const [sidebarItems, localEchoSidebarItem] = useSidebarItems(orphanSpaces); - const navToActivePath = useAtomValue(useNavToActivePathAtom()); - const [openedFolder, setOpenedFolder] = useAtom(useOpenedSidebarFolderAtom()); - const [draggingItem, setDraggingItem] = useState(); - - useDnDMonitor( - scrollRef, - setDraggingItem, - useCallback( - (item, containerItem, instructionType) => { - const newItems: SidebarItems = []; - - const matchDest = (sI: TSidebarItem, dI: SidebarDraggable): boolean => { - if (typeof sI === 'string' && typeof dI === 'string') { - return sI === dI; - } - if (typeof sI === 'object' && typeof dI === 'object') { - return sI.id === dI.folder.id; - } - return false; - }; - const itemAsFolderContent = (i: SidebarDraggable): string[] => { - if (typeof i === 'string') { - return [i]; - } - if (i.spaceId) { - return [i.spaceId]; - } - return [...i.folder.content]; - }; - - sidebarItems.forEach((i) => { - const sameFolders = - typeof item === 'object' && - typeof containerItem === 'object' && - item.folder.id === containerItem.folder.id; - - // remove draggable space from current position or folder - if (!sameFolders && matchDest(i, item)) { - if (typeof item === 'object' && item.spaceId) { - const folderContent = item.folder.content.filter((s) => s !== item.spaceId); - if (folderContent.length === 0) { - // remove open state from local storage - setOpenedFolder({ type: 'DELETE', id: item.folder.id }); - return; - } - newItems.push({ - ...item.folder, - content: folderContent, - }); - } - return; - } - if (matchDest(i, containerItem)) { - // we can make child only if - // container item is space or closed folder - if (instructionType === 'make-child') { - const child: string[] = itemAsFolderContent(item); - if (typeof containerItem === 'string') { - const folder: ISidebarFolder = { - id: randomStr(), - content: [containerItem].concat(child), - }; - newItems.push(folder); - return; - } - newItems.push({ - ...containerItem.folder, - content: containerItem.folder.content.concat(child), - }); - return; - } - - // drop inside opened folder - // or reordering inside same folder - if (typeof containerItem === 'object' && containerItem.spaceId) { - const child = itemAsFolderContent(item); - const newContent: string[] = []; - containerItem.folder.content - .filter((sId) => !child.includes(sId)) - .forEach((sId) => { - if (sId === containerItem.spaceId) { - if (instructionType === 'reorder-below') { - newContent.push(sId, ...child); - } - if (instructionType === 'reorder-above') { - newContent.push(...child, sId); - } - return; - } - newContent.push(sId); - }); - const folder = { - ...containerItem.folder, - content: newContent, - }; - - newItems.push(folder); - return; - } - - // drop above or below space or closed/opened folder - if (typeof item === 'string') { - if (instructionType === 'reorder-below') newItems.push(i); - newItems.push(item); - if (instructionType === 'reorder-above') newItems.push(i); - } else if (item.spaceId) { - if (instructionType === 'reorder-above') { - newItems.push(item.spaceId); - } - if (sameFolders && typeof i === 'object') { - // remove from folder if placing around itself - const newI = { ...i, content: i.content.filter((sId) => sId !== item.spaceId) }; - if (newI.content.length > 0) newItems.push(newI); - } else { - newItems.push(i); - } - if (instructionType === 'reorder-below') { - newItems.push(item.spaceId); - } - } else { - if (instructionType === 'reorder-below') newItems.push(i); - newItems.push(item.folder); - if (instructionType === 'reorder-above') newItems.push(i); - } - return; - } - newItems.push(i); - }); - - const newSpacesContent = makeCinnySpacesContent(mx, newItems); - localEchoSidebarItem(parseSidebar(mx, orphanSpaces, newSpacesContent)); - mx.setAccountData(AccountDataEvent.CinnySpaces, newSpacesContent); - }, - [mx, sidebarItems, setOpenedFolder, localEchoSidebarItem, orphanSpaces] - ) - ); - - const selectedSpaceId = useSelectedSpace(); - - const handleSpaceClick: MouseEventHandler = (evt) => { - const target = evt.currentTarget; - const targetSpaceId = target.getAttribute('data-id'); - if (!targetSpaceId) return; - - const spacePath = getSpacePath(getCanonicalAliasOrRoomId(mx, targetSpaceId)); - if (screenSize === ScreenSize.Mobile) { - navigate(spacePath); - return; - } - - const activePath = navToActivePath.get(targetSpaceId); - if (activePath && activePath.pathname.startsWith(spacePath)) { - navigate(joinPathComponent(activePath)); - return; - } - - navigate(getSpaceLobbyPath(getCanonicalAliasOrRoomId(mx, targetSpaceId))); - }; - - const handleFolderToggle: MouseEventHandler = (evt) => { - const target = evt.currentTarget; - const targetFolderId = target.getAttribute('data-id'); - if (!targetFolderId) return; - - setOpenedFolder({ - type: openedFolder.has(targetFolderId) ? 'DELETE' : 'PUT', - id: targetFolderId, - }); - }; - - const handleUnpin = useCallback( - (roomId: string) => { - if (orphanSpaces.includes(roomId)) return; - const newItems = sidebarItemWithout(sidebarItems, roomId); - - const newSpacesContent = makeCinnySpacesContent(mx, newItems); - localEchoSidebarItem(parseSidebar(mx, orphanSpaces, newSpacesContent)); - mx.setAccountData(AccountDataEvent.CinnySpaces, newSpacesContent); - }, - [mx, sidebarItems, orphanSpaces, localEchoSidebarItem] - ); - - if (sidebarItems.length === 0) return null; - return ( - <> - - - {sidebarItems.map((item) => { - if (typeof item === 'object') { - if (openedFolder.has(item.id)) { - return ( - - {item.content.map((sId) => { - const space = mx.getRoom(sId); - if (!space) return null; - return ( - - ); - })} - - ); - } - - return ( - - ); - } - - const space = mx.getRoom(item); - if (!space) return null; - - return ( - - ); - })} - - - ); -} diff --git a/src/app/pages/client/sidebar/index.ts b/src/app/pages/client/sidebar/index.ts index d44cfaa2..3cf570d9 100644 --- a/src/app/pages/client/sidebar/index.ts +++ b/src/app/pages/client/sidebar/index.ts @@ -1,8 +1,3 @@ -export * from './HomeTab'; -export * from './DirectTab'; -export * from './SpaceTabs'; export * from './InboxTab'; -export * from './ExploreTab'; export * from './SettingsTab'; export * from './UnverifiedTab'; -export * from './SearchTab'; diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx deleted file mode 100644 index c822ff38..00000000 --- a/src/app/pages/client/space/Space.tsx +++ /dev/null @@ -1,571 +0,0 @@ -import React, { - MouseEventHandler, - forwardRef, - useCallback, - useMemo, - useRef, - useState, -} from 'react'; -import { useAtom, useAtomValue } from 'jotai'; -import { - Avatar, - Box, - Button, - Icon, - IconButton, - Icons, - Line, - Menu, - MenuItem, - PopOut, - RectCords, - Spinner, - Text, - color, - config, - toRem, -} from 'folds'; -import { useVirtualizer } from '@tanstack/react-virtual'; -import { JoinRule, Room } from 'matrix-js-sdk'; -import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types'; -import FocusTrap from 'focus-trap-react'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { mDirectAtom } from '../../../state/mDirectList'; -import { - NavCategory, - NavCategoryHeader, - NavItem, - NavItemContent, - NavLink, -} from '../../../components/nav'; -import { getSpaceLobbyPath, getSpaceRoomPath, getSpaceSearchPath } from '../../pathUtils'; -import { getCanonicalAliasOrRoomId, isRoomAlias } from '../../../utils/matrix'; -import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom'; -import { - useSpaceLobbySelected, - useSpaceSearchSelected, -} from '../../../hooks/router/useSelectedSpace'; -import { useSpace } from '../../../hooks/useSpace'; -import { VirtualTile } from '../../../components/virtualizer'; -import { RoomNavCategoryButton, RoomNavItem } from '../../../features/room-nav'; -import { makeNavCategoryId } from '../../../state/closedNavCategories'; -import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; -import { useCategoryHandler } from '../../../hooks/useCategoryHandler'; -import { useStewardGroups } from '../../../features/steward-groups/useStewardGroups'; -import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper'; -import { useRoomName } from '../../../hooks/useRoomMeta'; -import { useSpaceJoinedHierarchy } from '../../../hooks/useSpaceHierarchy'; -import { allRoomsAtom } from '../../../state/room-list/roomList'; -import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page'; -import { usePowerLevels } from '../../../hooks/usePowerLevels'; -import { useRecursiveChildScopeFactory, useSpaceChildren } from '../../../state/hooks/roomList'; -import { roomToParentsAtom } from '../../../state/room/roomToParents'; -import { markAsRead } from '../../../utils/notifications'; -import { useRoomsUnread } from '../../../state/hooks/unread'; -import { UseStateProvider } from '../../../components/UseStateProvider'; -import { LeaveSpacePrompt } from '../../../components/leave-space-prompt'; -import { copyToClipboard } from '../../../utils/dom'; -import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories'; -import { useStateEvent } from '../../../hooks/useStateEvent'; -import { Membership, StateEvent } from '../../../../types/matrix/room'; -import { stopPropagation } from '../../../utils/keyboard'; -import { getMatrixToRoom } from '../../../plugins/matrix-to'; -import { getViaServers } from '../../../plugins/via-servers'; -import { useSetting } from '../../../state/hooks/settings'; -import { settingsAtom } from '../../../state/settings'; -import { - getRoomNotificationMode, - useRoomsNotificationPreferencesContext, -} from '../../../hooks/useRoomsNotificationPreferences'; -import { useOpenSpaceSettings } from '../../../state/hooks/spaceSettings'; -import { useRoomNavigate } from '../../../hooks/useRoomNavigate'; -import { useRoomCreators } from '../../../hooks/useRoomCreators'; -import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; -import { ContainerColor } from '../../../styles/ContainerColor.css'; -import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; -import { BreakWord } from '../../../styles/Text.css'; -import { InviteUserPrompt } from '../../../components/invite-user-prompt'; -import { useCallEmbed } from '../../../hooks/useCallEmbed'; - -type SpaceMenuProps = { - room: Room; - requestClose: () => void; -}; -const SpaceMenu = forwardRef(({ room, requestClose }, ref) => { - const mx = useMatrixClient(); - const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); - const [developerTools] = useSetting(settingsAtom, 'developerTools'); - const roomToParents = useAtomValue(roomToParentsAtom); - const powerLevels = usePowerLevels(room); - const creators = useRoomCreators(room); - - const permissions = useRoomPermissions(creators, powerLevels); - const canInvite = permissions.action('invite', mx.getSafeUserId()); - const openSpaceSettings = useOpenSpaceSettings(); - const { navigateRoom } = useRoomNavigate(); - - const [invitePrompt, setInvitePrompt] = useState(false); - - const allChild = useSpaceChildren( - allRoomsAtom, - room.roomId, - useRecursiveChildScopeFactory(mx, roomToParents) - ); - const unread = useRoomsUnread(allChild, roomToUnreadAtom); - - const handleMarkAsRead = () => { - allChild.forEach((childRoomId) => markAsRead(mx, childRoomId, hideActivity)); - requestClose(); - }; - - const handleCopyLink = () => { - const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId); - const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room); - copyToClipboard(getMatrixToRoom(roomIdOrAlias, viaServers)); - requestClose(); - }; - - const handleInvite = () => { - setInvitePrompt(true); - }; - - const handleRoomSettings = () => { - openSpaceSettings(room.roomId); - requestClose(); - }; - - const handleOpenTimeline = () => { - navigateRoom(room.roomId); - requestClose(); - }; - - return ( - - - {invitePrompt && room && ( - { - setInvitePrompt(false); - requestClose(); - }} - /> - )} - } - radii="300" - disabled={!unread} - > - - Mark as Read - - - - - - } - radii="300" - aria-pressed={invitePrompt} - disabled={!canInvite} - > - - Invite - - - } - radii="300" - > - - Copy Link - - - } - radii="300" - > - - Space Settings - - - {developerTools && ( - } - radii="300" - > - - Event Timeline - - - )} - - - - - {(promptLeave, setPromptLeave) => ( - <> - setPromptLeave(true)} - variant="Critical" - fill="None" - size="300" - after={} - radii="300" - aria-pressed={promptLeave} - > - - Leave Space - - - {promptLeave && ( - setPromptLeave(false)} - /> - )} - - )} - - - - ); -}); - -function SpaceHeader() { - const space = useSpace(); - const spaceName = useRoomName(space); - const [menuAnchor, setMenuAnchor] = useState(); - - const joinRules = useStateEvent( - space, - StateEvent.RoomJoinRules - )?.getContent(); - - const handleOpenMenu: MouseEventHandler = (evt) => { - const cords = evt.currentTarget.getBoundingClientRect(); - setMenuAnchor((currentState) => { - if (currentState) return undefined; - return cords; - }); - }; - - return ( - <> - - - - - {spaceName} - - {joinRules?.join_rule !== JoinRule.Public && } - - - - - - - - - {menuAnchor && ( - setMenuAnchor(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', - isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', - escapeDeactivates: stopPropagation, - }} - > - setMenuAnchor(undefined)} /> - - } - /> - )} - - ); -} - -type SpaceTombstoneProps = { roomId: string; replacementRoomId: string }; -export function SpaceTombstone({ roomId, replacementRoomId }: SpaceTombstoneProps) { - const mx = useMatrixClient(); - const { navigateSpace } = useRoomNavigate(); - - const [joinState, handleJoin] = useAsyncCallback( - useCallback(() => { - const currentRoom = mx.getRoom(roomId); - const via = currentRoom ? getViaServers(currentRoom) : []; - return mx.joinRoom(replacementRoomId, { - viaServers: via, - }); - }, [mx, roomId, replacementRoomId]) - ); - const replacementRoom = mx.getRoom(replacementRoomId); - - const handleOpen = () => { - if (replacementRoom) navigateSpace(replacementRoom.roomId); - if (joinState.status === AsyncStatus.Success) navigateSpace(joinState.data.roomId); - }; - - return ( - - - Space Upgraded - This space has been replaced and is no longer active. - {joinState.status === AsyncStatus.Error && ( - - {(joinState.error as any)?.message ?? 'Failed to join replacement space!'} - - )} - - - {replacementRoom?.getMyMembership() === Membership.Join || - joinState.status === AsyncStatus.Success ? ( - - ) : ( - - )} - - - ); -} - -export function Space() { - const mx = useMatrixClient(); - const space = useSpace(); - useNavToActivePathMapper(space.roomId); - const spaceIdOrAlias = getCanonicalAliasOrRoomId(mx, space.roomId); - const scrollRef = useRef(null); - const mDirects = useAtomValue(mDirectAtom); - const roomToUnread = useAtomValue(roomToUnreadAtom); - const allRooms = useAtomValue(allRoomsAtom); - const allJoinedRooms = useMemo(() => new Set(allRooms), [allRooms]); - const notificationPreferences = useRoomsNotificationPreferencesContext(); - - const tombstoneEvent = useStateEvent(space, StateEvent.RoomTombstone); - const selectedRoomId = useSelectedRoom(); - const lobbySelected = useSpaceLobbySelected(spaceIdOrAlias); - const searchSelected = useSpaceSearchSelected(spaceIdOrAlias); - const callEmbed = useCallEmbed(); - - const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom()); - - const getRoom = useCallback( - (rId: string): Room | undefined => { - if (allJoinedRooms.has(rId)) { - return mx.getRoom(rId) ?? undefined; - } - return undefined; - }, - [mx, allJoinedRooms] - ); - - const hierarchy = useSpaceJoinedHierarchy( - space.roomId, - getRoom, - useCallback( - (parentId, roomId) => { - if (!closedCategories.has(makeNavCategoryId(space.roomId, parentId))) { - return false; - } - const showRoomAnyway = - roomToUnread.has(roomId) || roomId === selectedRoomId || callEmbed?.roomId === roomId; - return !showRoomAnyway; - }, - [space.roomId, closedCategories, roomToUnread, selectedRoomId, callEmbed] - ), - useCallback( - (sId) => closedCategories.has(makeNavCategoryId(space.roomId, sId)), - [closedCategories, space.roomId] - ) - ); - - const { entries, toggleGroup } = useStewardGroups({ - mx, - spaceId: space.roomId, - items: hierarchy, - keepVisible: useCallback( - (rId: string) => roomToUnread.has(rId) || rId === selectedRoomId || callEmbed?.roomId === rId, - [roomToUnread, selectedRoomId, callEmbed] - ), - }); - - const virtualizer = useVirtualizer({ - count: entries.length, - getScrollElement: () => scrollRef.current, - estimateSize: () => 0, - overscan: 10, - }); - - const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) => - closedCategories.has(categoryId) - ); - - const getToLink = (roomId: string) => - getSpaceRoomPath(spaceIdOrAlias, getCanonicalAliasOrRoomId(mx, roomId)); - - return ( - - - - - {tombstoneEvent && ( - - )} - - - - - - - - - - - Lobby - - - - - - - - - - - - - - - - Message Search - - - - - - - - - {virtualizer.getVirtualItems().map((vItem) => { - const entry = entries[vItem.index]; - if (!entry) return null; - if (entry.type === 'group') { - return ( - - - toggleGroup(entry.id)} - closed={!entry.open} - > - {entry.label} · {entry.count} - - - - ); - } - const { roomId } = entry.item; - const room = mx.getRoom(roomId); - if (!room) return null; - - if (room.isSpaceRoom()) { - const categoryId = makeNavCategoryId(space.roomId, roomId); - - return ( - -
- - - {roomId === space.roomId ? 'Rooms' : room?.name} - - -
-
- ); - } - - return ( - - - - ); - })} -
-
-
-
- ); -} diff --git a/src/app/pages/client/space/SpaceTombstone.tsx b/src/app/pages/client/space/SpaceTombstone.tsx new file mode 100644 index 00000000..3e2d13bd --- /dev/null +++ b/src/app/pages/client/space/SpaceTombstone.tsx @@ -0,0 +1,79 @@ +import React, { useCallback } from 'react'; +import { Box, Button, Spinner, Text, color, config } from 'folds'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { Membership } from '../../../../types/matrix/room'; +import { getViaServers } from '../../../plugins/via-servers'; +import { useRoomNavigate } from '../../../hooks/useRoomNavigate'; +import { ContainerColor } from '../../../styles/ContainerColor.css'; +import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; +import { BreakWord } from '../../../styles/Text.css'; + +type SpaceTombstoneProps = { roomId: string; replacementRoomId: string }; +export function SpaceTombstone({ roomId, replacementRoomId }: SpaceTombstoneProps) { + const mx = useMatrixClient(); + const { navigateSpace } = useRoomNavigate(); + + const [joinState, handleJoin] = useAsyncCallback( + useCallback(() => { + const currentRoom = mx.getRoom(roomId); + const via = currentRoom ? getViaServers(currentRoom) : []; + return mx.joinRoom(replacementRoomId, { + viaServers: via, + }); + }, [mx, roomId, replacementRoomId]) + ); + const replacementRoom = mx.getRoom(replacementRoomId); + + const handleOpen = () => { + if (replacementRoom) navigateSpace(replacementRoom.roomId); + if (joinState.status === AsyncStatus.Success) navigateSpace(joinState.data.roomId); + }; + + return ( + + + Space Upgraded + This space has been replaced and is no longer active. + {joinState.status === AsyncStatus.Error && ( + + {(joinState.error as any)?.message ?? 'Failed to join replacement space!'} + + )} + + + {replacementRoom?.getMyMembership() === Membership.Join || + joinState.status === AsyncStatus.Success ? ( + + ) : ( + + )} + + + ); +} + diff --git a/src/app/pages/client/space/index.ts b/src/app/pages/client/space/index.ts index 0e32d93b..9b79693e 100644 --- a/src/app/pages/client/space/index.ts +++ b/src/app/pages/client/space/index.ts @@ -1,4 +1,4 @@ export * from './SpaceProvider'; -export * from './Space'; +export * from './SpaceTombstone'; export * from './Search'; export * from './RoomProvider'; diff --git a/src/types/matrix/accountData.ts b/src/types/matrix/accountData.ts index 98715996..1f5f0608 100644 --- a/src/types/matrix/accountData.ts +++ b/src/types/matrix/accountData.ts @@ -3,7 +3,6 @@ export enum AccountDataEvent { Direct = 'm.direct', IgnoredUserList = 'm.ignored_user_list', - CinnySpaces = 'in.cinny.spaces', ElementRecentEmoji = 'io.element.recent_emoji', diff --git a/src/types/matrix/room.ts b/src/types/matrix/room.ts index c73c0f0b..81d35bd0 100644 --- a/src/types/matrix/room.ts +++ b/src/types/matrix/room.ts @@ -36,6 +36,7 @@ export enum StateEvent { GroupCallMemberPrefix = 'org.matrix.msc3401.call.member', SpaceChild = 'm.space.child', + GameCultStewardships = 'org.gamecult.stewardships', SpaceParent = 'm.space.parent', PoniesRoomEmotes = 'im.ponies.room_emotes', From e90b70e966398444bfffd74971d6c2ea3f3ce003 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 17:06:44 +0100 Subject: [PATCH 007/206] Estate navigator: one structure-first room list from the Space hierarchy, with one-tap Join buildEstate is the only code that decides which rooms the navigator lists, in which tier and steward group, and whether each is joined, invited or joinable. The server hierarchy is the access rule. The Home, Direct and Space nav slots mount it through EstatePageNav. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/estate/EstateNavigator.tsx | 225 ++++++++++++++++++ src/app/features/estate/buildEstate.test.ts | 140 +++++++++++ src/app/features/estate/buildEstate.ts | 162 +++++++++++++ src/app/features/estate/index.ts | 1 + .../features/estate/joinEstateRoom.test.ts | 61 +++++ src/app/features/estate/joinEstateRoom.ts | 29 +++ src/app/features/estate/useEstate.ts | 110 +++++++++ src/app/hooks/useSpaceHierarchy.ts | 9 +- src/app/pages/Router.tsx | 30 +-- src/app/pages/client/EstatePageNav.tsx | 15 ++ 10 files changed, 757 insertions(+), 25 deletions(-) create mode 100644 src/app/features/estate/EstateNavigator.tsx create mode 100644 src/app/features/estate/buildEstate.test.ts create mode 100644 src/app/features/estate/buildEstate.ts create mode 100644 src/app/features/estate/index.ts create mode 100644 src/app/features/estate/joinEstateRoom.test.ts create mode 100644 src/app/features/estate/joinEstateRoom.ts create mode 100644 src/app/features/estate/useEstate.ts create mode 100644 src/app/pages/client/EstatePageNav.tsx diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx new file mode 100644 index 00000000..211f0c3a --- /dev/null +++ b/src/app/features/estate/EstateNavigator.tsx @@ -0,0 +1,225 @@ +import React, { MouseEventHandler, ReactNode, useCallback, useMemo, useRef } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAtom, useAtomValue } from 'jotai'; +import { Box, Chip, Icon, IconButton, Icons, Spinner, Text, color } from 'folds'; +import { MatrixClient } from 'matrix-js-sdk'; +import { PageNav, PageNavContent, PageNavHeader } from '../../components/page'; +import { NavCategory, NavCategoryHeader, NavItem, NavItemContent } from '../../components/nav'; +import { RoomNavCategoryButton, RoomNavItem } from '../room-nav'; +import { InboxTab, SettingsTab, UnverifiedTab } from '../../pages/client/sidebar'; +import { + getDirectRoomPath, + getHomeRoomPath, + getHomeSearchPath, + getSpaceRoomPath, +} from '../../pages/pathUtils'; +import { getCanonicalAliasOrRoomId } from '../../utils/matrix'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useSelectedRoom } from '../../hooks/router/useSelectedRoom'; +import { useCallEmbed } from '../../hooks/useCallEmbed'; +import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; +import { + getRoomNotificationMode, + useRoomsNotificationPreferencesContext, +} from '../../hooks/useRoomsNotificationPreferences'; +import { useClosedNavCategoriesAtom } from '../../state/hooks/closedNavCategories'; +import { makeNavCategoryId } from '../../state/closedNavCategories'; +import { roomToUnreadAtom } from '../../state/room/roomToUnread'; +import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; +import { EstateRoom, EstateTier } from './buildEstate'; +import { activateEstateRoom } from './joinEstateRoom'; +import { useEstate, useEstateRootId } from './useEstate'; + +const estateRoomPath = (mx: MatrixClient, room: EstateRoom): string => { + const alias = getCanonicalAliasOrRoomId(mx, room.roomId); + if (room.parentId === null) return getDirectRoomPath(alias); + // A Space the user has not joined has no Space route; the room still resolves from Home. + if (mx.getRoom(room.parentId)?.getMyMembership() === 'join') { + return getSpaceRoomPath(getCanonicalAliasOrRoomId(mx, room.parentId), alias); + } + return getHomeRoomPath(alias); +}; + +function JoinRow({ room }: { room: EstateRoom }) { + const mx = useMatrixClient(); + const navigate = useNavigate(); + const [state, join] = useAsyncCallback( + useCallback(async () => { + const result = await activateEstateRoom(mx, room); + if (result.kind === 'refused') throw new Error(result.message); + navigate(estateRoomPath(mx, room)); + }, [mx, navigate, room]) + ); + const busy = state.status === AsyncStatus.Loading; + + return ( + + + + + {room.name} + + + {state.status === AsyncStatus.Error && ( + + {state.error.message} + + )} + : } + onClick={join} + disabled={busy} + aria-label={`Join ${room.name}`} + > + Join + + + + ); +} + +function EstateRow({ room }: { room: EstateRoom }) { + const mx = useMatrixClient(); + const selectedRoomId = useSelectedRoom(); + const notificationPreferences = useRoomsNotificationPreferencesContext(); + const mxRoom = room.state === 'joined' ? mx.getRoom(room.roomId) : null; + + if (!mxRoom) return ; + return ( + + ); +} + +type CollapsibleProps = { + categoryId: string; + label: string; + rooms: EstateRoom[]; + children: ReactNode; +}; +function Collapsible({ categoryId, label, rooms, children }: CollapsibleProps) { + const mobile = useScreenSizeContext() === ScreenSize.Mobile; + const selectedRoomId = useSelectedRoom(); + const roomToUnread = useAtomValue(roomToUnreadAtom); + const callRoomId = useCallEmbed()?.roomId; + const [toggled, setToggled] = useAtom(useClosedNavCategoriesAtom()); + + const active = rooms.some( + (r) => r.roomId === selectedRoomId || r.roomId === callRoomId || roomToUnread.has(r.roomId) + ); + // Phones start collapsed unless the section holds something live; wide screens start open. + // A tap flips that default; the flip is remembered per device and per screen class. + const key = makeNavCategoryId('estate', mobile ? 'phone' : 'wide', categoryId); + const closed = (mobile && !active) !== toggled.has(key); + + const handleClick: MouseEventHandler = () => + setToggled({ type: toggled.has(key) ? 'DELETE' : 'PUT', categoryId: key }); + + return ( + + + + {label} + + + {!closed && children} + + ); +} + +function Tier({ tier }: { tier: EstateTier }) { + const allRooms = useMemo(() => [...tier.loose, ...tier.groups.flatMap((g) => g.rooms)], [tier]); + return ( + + {tier.loose.map((r) => ( + + ))} + {tier.groups.map((g) => ( + + {g.rooms.map((r) => ( + + ))} + + ))} + + ); +} + +function EstateTiers({ rootId }: { rootId: string }) { + const { fetching, error, tiers } = useEstate(rootId); + const scrollRef = useRef(null); + + return ( + + + {tiers.map((tier) => ( + + ))} + {fetching && ( + + + + )} + {error && ( + + {error.message} + + )} + + + ); +} + +/** The single navigator: every nav slot that lists rooms mounts this. */ +export function EstateNavigator() { + const navigate = useNavigate(); + const { rootId, error } = useEstateRootId(); + const mx = useMatrixClient(); + const title = (rootId && mx.getRoom(rootId)?.name) || 'GameCult'; + + return ( + + + + + + {title} + + + navigate(getHomeSearchPath())} + > + + + + + {rootId ? ( + + ) : ( + + {error ? {error.message} : } + + )} + + + + + + + ); +} diff --git a/src/app/features/estate/buildEstate.test.ts b/src/app/features/estate/buildEstate.test.ts new file mode 100644 index 00000000..0593e195 --- /dev/null +++ b/src/app/features/estate/buildEstate.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from 'vitest'; +import { buildEstate, EstateHierarchyRoom, EstateInput, stewardOfRoom } from './buildEstate'; + +const ROOT = '!root:gamecult.org'; +const PROJECTS = '!projects:gamecult.org'; + +const space = (id: string, name: string, children: string[]): EstateHierarchyRoom => ({ + room_id: id, + name, + room_type: 'm.space', + children_state: children.map((c) => ({ state_key: c, content: { via: ['gamecult.org'] } })), +}); +const room = (id: string, name: string, alias?: string): EstateHierarchyRoom => ({ + room_id: id, + name, + canonical_alias: alias, +}); + +const hierarchy = [ + space(ROOT, 'GameCult', ['!general:gamecult.org', PROJECTS]), + room('!general:gamecult.org', 'General', '#general:gamecult.org'), + space(PROJECTS, 'Projects', ['!cultlib:gamecult.org', '!aeth:gamecult.org', '!misc:gamecult.org']), + room('!cultlib:gamecult.org', 'CultLib', '#cultlib:gamecult.org'), + room('!aeth:gamecult.org', 'Aetheria', '#Aetheria:gamecult.org'), + room('!misc:gamecult.org', 'Misc', '#misc:gamecult.org'), +]; + +const stewardships = { eureka: ['GameCult/CultLib'], void: ['GameCult/aetheria'] }; + +const input = (over: Partial = {}): EstateInput => ({ + rootId: ROOT, + hierarchy, + stewardships, + membership: new Map(), + directRoomIds: [], + ...over, +}); + +const find = (tiers: ReturnType, roomId: string) => + tiers + .flatMap((t) => [...t.loose, ...t.groups.flatMap((g) => g.rooms)]) + .find((r) => r.roomId === roomId); + +describe('buildEstate', () => { + it('lists an invited project room as invited', () => { + const tiers = buildEstate(input({ membership: new Map([['!cultlib:gamecult.org', 'invite']]) })); + expect(find(tiers, '!cultlib:gamecult.org')?.state).toBe('invited'); + }); + + it('lists a hierarchy room the user has not joined as joinable, beside joined ones', () => { + const tiers = buildEstate(input({ membership: new Map([['!general:gamecult.org', 'join']]) })); + expect(find(tiers, '!general:gamecult.org')?.state).toBe('joined'); + expect(find(tiers, '!aeth:gamecult.org')?.state).toBe('joinable'); + expect(find(tiers, '!aeth:gamecult.org')?.via).toEqual(['gamecult.org']); + }); + + it('omits a room absent from the hierarchy even when the client store knows it', () => { + const hidden = '!estate-secret:gamecult.org'; + const tiers = buildEstate( + input({ + membership: new Map([[hidden, 'join']]), + // The root names the room as a child, but the server did not return it. + hierarchy: [ + space(ROOT, 'GameCult', ['!general:gamecult.org', PROJECTS, hidden]), + ...hierarchy.slice(1), + ], + }) + ); + expect(find(tiers, hidden)).toBeUndefined(); + }); + + it('lists every room the server returned, whatever the user may join', () => { + const tiers = buildEstate(input()); + expect(tiers.flatMap((t) => [...t.loose, ...t.groups.flatMap((g) => g.rooms)])).toHaveLength(4); + }); + + it('groups rooms by steward Mind from stewardships plus canonical alias, case-insensitively', () => { + const projects = buildEstate(input()).find((t) => t.id === PROJECTS)!; + expect(projects.groups.map((g) => [g.mind, g.rooms.map((r) => r.roomId)])).toEqual([ + ['eureka', ['!cultlib:gamecult.org']], + ['void', ['!aeth:gamecult.org']], + ]); + }); + + it('sorts steward groups by display label', () => { + const projects = buildEstate( + input({ mindLabels: new Map([['eureka', 'Zed'], ['void', 'Alpha']]) }) + ).find((t) => t.id === PROJECTS)!; + expect(projects.groups.map((g) => g.label)).toEqual(['Alpha', 'Zed']); + }); + + it('lists a room with no alias match loose in its tier', () => { + const projects = buildEstate(input()).find((t) => t.id === PROJECTS)!; + expect(projects.loose.map((r) => r.roomId)).toEqual(['!misc:gamecult.org']); + }); + + it('degrades to loose rows when the stewardships are unreadable', () => { + const projects = buildEstate(input({ stewardships: undefined })).find( + (t) => t.id === PROJECTS + )!; + expect(projects.groups).toEqual([]); + expect(projects.loose).toHaveLength(3); + }); + + it('makes one tier per sub-Space in hierarchy order, root rooms first, Direct last', () => { + const second = '!second:gamecult.org'; + const tiers = buildEstate( + input({ + hierarchy: [ + space(ROOT, 'GameCult', ['!general:gamecult.org', PROJECTS, second]), + ...hierarchy.slice(1), + space(second, 'Workshop', ['!w1:gamecult.org']), + room('!w1:gamecult.org', 'Bench'), + ], + directRoomIds: ['!dm:gamecult.org'], + membership: new Map([['!dm:gamecult.org', 'join']]), + }) + ); + expect(tiers.map((t) => t.label)).toEqual(['GameCult', 'Projects', 'Workshop', 'Direct Messages']); + expect(tiers[0].loose.map((r) => r.name)).toEqual(['General']); + expect(tiers[3].loose[0]).toMatchObject({ roomId: '!dm:gamecult.org', parentId: null }); + }); + + it('is identical for identical inputs', () => { + expect(buildEstate(input())).toEqual(buildEstate(input())); + }); +}); + +describe('stewardOfRoom', () => { + it('matches the alias localpart to the repo name, ignoring case and the org prefix', () => { + expect(stewardOfRoom('#CULTLIB:gamecult.org', stewardships)).toBe('eureka'); + expect(stewardOfRoom('#aetheria:gamecult.org', stewardships)).toBe('void'); + }); + + it('has no steward without an alias, a match or readable stewardships', () => { + expect(stewardOfRoom(undefined, stewardships)).toBeUndefined(); + expect(stewardOfRoom('#unknown:gamecult.org', stewardships)).toBeUndefined(); + expect(stewardOfRoom('#cultlib:gamecult.org', { eureka: 'GameCult/CultLib' })).toBeUndefined(); + }); +}); diff --git a/src/app/features/estate/buildEstate.ts b/src/app/features/estate/buildEstate.ts new file mode 100644 index 00000000..096ea9ad --- /dev/null +++ b/src/app/features/estate/buildEstate.ts @@ -0,0 +1,162 @@ +/** + * The estate index: the only code that decides which rooms the navigator lists, + * in which tier and steward group, and whether each is joined, invited or joinable. + * + * The server's /hierarchy of the root Space is the access rule. A room the + * hierarchy does not return is never listed, whatever the client store knows; + * a room it returns is never hidden by the client. + */ + +export const STEWARDSHIPS_EVENT_TYPE = 'org.gamecult.stewardships'; + +export type EstateHierarchyRoom = { + room_id: string; + name?: string; + canonical_alias?: string; + room_type?: string; + children_state?: { state_key: string; content?: { via?: string[] } }[]; +}; + +/** Mind localpart to the repos she stewards, as projected on the root Space. */ +export type Stewardships = Record; + +export type EstateMembership = 'joined' | 'invited' | 'joinable'; + +export type EstateRoom = { + roomId: string; + name: string; + state: EstateMembership; + /** Servers to join through, from the hierarchy's children_state. */ + via: string[]; + /** The Space tier the room sits in; null for direct messages. */ + parentId: string | null; +}; + +export type EstateGroup = { + mind: string; + label: string; + rooms: EstateRoom[]; +}; + +export type EstateTier = { + id: string; + label: string; + /** Rooms without a steward. */ + loose: EstateRoom[]; + groups: EstateGroup[]; +}; + +export type EstateInput = { + rootId: string; + /** Rooms from the server's hierarchy of the root, in hierarchy order. */ + hierarchy: readonly EstateHierarchyRoom[]; + stewardships: Stewardships | undefined; + /** The user's own membership per room id, from the client store. */ + membership: ReadonlyMap; + directRoomIds: readonly string[]; + /** Display names for Minds, keyed by localpart. */ + mindLabels?: ReadonlyMap; +}; + +export const DIRECT_TIER_ID = 'direct'; + +const SPACE_TYPE = 'm.space'; + +const repoName = (repo: string): string => repo.split('/').pop()!.toLowerCase(); + +/** + * The single place a room's steward is derived: the canonical alias localpart, + * case-insensitively, against the repo names in the stewardships lists. + * (Follow-up ratatoskr:follow_up:stewardships-carry-room-ids swaps this to room ids.) + */ +export const stewardOfRoom = ( + canonicalAlias: string | undefined, + stewardships: Stewardships | undefined +): string | undefined => { + if (!canonicalAlias || !stewardships) return undefined; + const local = canonicalAlias.replace(/^#/, '').split(':')[0].toLowerCase(); + const mind = Object.keys(stewardships).find((m) => { + const repos = stewardships[m]; + return Array.isArray(repos) && repos.some((r) => typeof r === 'string' && repoName(r) === local); + }); + return mind; +}; + +const stateOf = ( + roomId: string, + membership: ReadonlyMap +): EstateMembership => { + const m = membership.get(roomId); + if (m === 'join') return 'joined'; + if (m === 'invite') return 'invited'; + return 'joinable'; +}; + +const roomLabel = (r: EstateHierarchyRoom): string => r.name || r.canonical_alias || r.room_id; + +export const buildEstate = ({ + rootId, + hierarchy, + stewardships, + membership, + directRoomIds, + mindLabels, +}: EstateInput): EstateTier[] => { + const direct = new Set(directRoomIds); + const byId = new Map(hierarchy.map((r) => [r.room_id, r])); + const spaces = hierarchy.filter((r) => r.room_type === SPACE_TYPE); + const root = byId.get(rootId); + + // Root first, then every sub-Space in hierarchy order. + const tierSpaces = root ? [root, ...spaces.filter((s) => s.room_id !== rootId)] : spaces; + + const tiers: EstateTier[] = tierSpaces.map((space) => { + const childIds = new Set((space.children_state ?? []).map((c) => c.state_key)); + const tier: EstateTier = { id: space.room_id, label: roomLabel(space), loose: [], groups: [] }; + const groups = new Map(); + + hierarchy.forEach((r) => { + if (r.room_type === SPACE_TYPE || !childIds.has(r.room_id) || direct.has(r.room_id)) return; + const via = space.children_state?.find((c) => c.state_key === r.room_id)?.content?.via ?? []; + const room: EstateRoom = { + roomId: r.room_id, + name: roomLabel(r), + state: stateOf(r.room_id, membership), + via, + parentId: space.room_id, + }; + const mind = stewardOfRoom(r.canonical_alias, stewardships); + if (!mind) { + tier.loose.push(room); + return; + } + let group = groups.get(mind); + if (!group) { + group = { mind, label: mindLabels?.get(mind) ?? mind, rooms: [] }; + groups.set(mind, group); + } + group.rooms.push(room); + }); + + tier.groups = [...groups.values()].sort((a, b) => + a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }) + ); + return tier; + }); + + const directTier: EstateTier = { + id: DIRECT_TIER_ID, + label: 'Direct Messages', + loose: directRoomIds.map((roomId) => ({ + roomId, + name: roomId, + state: stateOf(roomId, membership), + via: [], + parentId: null, + })), + groups: [], + }; + + const listed = tiers.filter((t) => t.loose.length > 0 || t.groups.length > 0); + return directTier.loose.length > 0 ? [...listed, directTier] : listed; +}; diff --git a/src/app/features/estate/index.ts b/src/app/features/estate/index.ts new file mode 100644 index 00000000..761586c4 --- /dev/null +++ b/src/app/features/estate/index.ts @@ -0,0 +1 @@ +export * from './EstateNavigator'; diff --git a/src/app/features/estate/joinEstateRoom.test.ts b/src/app/features/estate/joinEstateRoom.test.ts new file mode 100644 index 00000000..e063b337 --- /dev/null +++ b/src/app/features/estate/joinEstateRoom.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from 'vitest'; +import { activateEstateRoom } from './joinEstateRoom'; + +const room = (state: 'joined' | 'invited' | 'joinable') => ({ + roomId: '!r:gamecult.org', + state, + via: ['gamecult.org'], +}); + +/** A client that records every call; anything but joinRoom is a peek or a side door. */ +const recordingClient = (joinRoom = vi.fn().mockResolvedValue({ roomId: '!r:gamecult.org' })) => { + const calls: string[] = []; + const mx = new Proxy({} as Record, { + get: (_t, prop: string) => { + if (prop === 'joinRoom') { + return (...args: unknown[]) => { + calls.push('joinRoom'); + return joinRoom(...args); + }; + } + return (...args: unknown[]) => { + void args; + calls.push(prop); + throw new Error(`unexpected client call: ${prop}`); + }; + }, + }); + return { mx: mx as never, calls, joinRoom }; +}; + +describe('activateEstateRoom', () => { + it('opens a joined room without touching the client', async () => { + const { mx, calls } = recordingClient(); + expect(await activateEstateRoom(mx, room('joined'))).toEqual({ + kind: 'open', + roomId: '!r:gamecult.org', + }); + expect(calls).toEqual([]); + }); + + it.each(['invited', 'joinable'] as const)( + 'joins a %s room on the tap, through the hierarchy servers, and nothing else', + async (state) => { + const { mx, calls, joinRoom } = recordingClient(); + const result = await activateEstateRoom(mx, room(state)); + expect(result).toEqual({ kind: 'joined', roomId: '!r:gamecult.org' }); + expect(joinRoom).toHaveBeenCalledWith('!r:gamecult.org', { viaServers: ['gamecult.org'] }); + expect(calls).toEqual(['joinRoom']); // no peekInRoom, no read, no second path + } + ); + + it('reports the server refusal and does not decide access itself', async () => { + const refusal = Object.assign(new Error('M_FORBIDDEN'), { + data: { error: 'You are not invited to this room.' }, + }); + const { mx, joinRoom } = recordingClient(vi.fn().mockRejectedValue(refusal)); + const result = await activateEstateRoom(mx, room('joinable')); + expect(result).toEqual({ kind: 'refused', message: 'You are not invited to this room.' }); + expect(joinRoom).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/features/estate/joinEstateRoom.ts b/src/app/features/estate/joinEstateRoom.ts new file mode 100644 index 00000000..aedfc70b --- /dev/null +++ b/src/app/features/estate/joinEstateRoom.ts @@ -0,0 +1,29 @@ +import { MatrixClient } from 'matrix-js-sdk'; +import { EstateRoom } from './buildEstate'; + +export type EstateActivation = + | { kind: 'open'; roomId: string } + | { kind: 'joined'; roomId: string } + | { kind: 'refused'; message: string }; + +type JoinClient = Pick; + +/** + * What a tap on an estate row does. A joined room opens; any other room is + * joined, and only here (plus a first post in the room view). Opening a room + * never joins it and the client has no peek path. The server decides whether + * the join is allowed; its refusal is reported, the row is not hidden. + */ +export const activateEstateRoom = async ( + mx: JoinClient, + room: Pick +): Promise => { + if (room.state === 'joined') return { kind: 'open', roomId: room.roomId }; + try { + const joined = await mx.joinRoom(room.roomId, { viaServers: room.via }); + return { kind: 'joined', roomId: joined.roomId }; + } catch (e) { + const err = e as { data?: { error?: string }; message?: string }; + return { kind: 'refused', message: err.data?.error ?? err.message ?? 'Could not join' }; + } +}; diff --git a/src/app/features/estate/useEstate.ts b/src/app/features/estate/useEstate.ts new file mode 100644 index 00000000..3968d974 --- /dev/null +++ b/src/app/features/estate/useEstate.ts @@ -0,0 +1,110 @@ +import { useCallback, useEffect, useMemo } from 'react'; +import { useAtomValue } from 'jotai'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { MatrixClient } from 'matrix-js-sdk'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useClientConfig } from '../../hooks/useClientConfig'; +import { useFetchSpaceHierarchyLevel } from '../../hooks/useSpaceHierarchy'; +import { useForceUpdate } from '../../hooks/useForceUpdate'; +import { getStateEvent } from '../../utils/room'; +import { useStateEventCallback } from '../../hooks/useStateEventCallback'; +import { allRoomsAtom } from '../../state/room-list/roomList'; +import { allInvitesAtom } from '../../state/room-list/inviteList'; +import { mDirectAtom } from '../../state/mDirectList'; +import { StateEvent } from '../../../types/matrix/room'; +import { buildEstate, EstateTier, Stewardships } from './buildEstate'; + +const HIERARCHY_DEPTH = 10; + +const findLoadedRoom = (mx: MatrixClient, alias: string): string | undefined => + mx + .getRooms() + .find((r) => r.getCanonicalAlias() === alias || r.getAltAliases().includes(alias))?.roomId; + +/** The estate's root Space: featuredCommunities.spaces[0] from the client config. */ +export const useEstateRootId = (): { rootId?: string; error?: Error } => { + const mx = useMatrixClient(); + const alias = useClientConfig().featuredCommunities?.spaces?.[0]; + const loaded = alias && !alias.startsWith('#') ? alias : alias && findLoadedRoom(mx, alias); + const resolved = useQuery({ + queryKey: ['estate-root', alias], + queryFn: async () => (await mx.getRoomIdForAlias(alias!)).room_id, + enabled: !!alias && !loaded, + }); + if (!alias) return { error: new Error('No estate root Space is configured') }; + return { rootId: loaded || resolved.data, error: resolved.error ?? undefined }; +}; + +export type Estate = { + fetching: boolean; + error: Error | null; + tiers: EstateTier[]; +}; + +/** + * Feeds buildEstate: the server hierarchy of the root Space, the root's + * stewardships from the client store, the user's membership and m.direct. + * Recomputes on membership, Space child and stewardships changes. + */ +export const useEstate = (rootId: string): Estate => { + const mx = useMatrixClient(); + const queryClient = useQueryClient(); + const { fetching, error, rooms } = useFetchSpaceHierarchyLevel(rootId, true, HIERARCHY_DEPTH); + const joined = useAtomValue(allRoomsAtom); + const invited = useAtomValue(allInvitesAtom); + const mDirects = useAtomValue(mDirectAtom); + + const [stewardshipsVersion, bumpStewardships] = useForceUpdate(); + + const refetchHierarchy = useCallback( + () => queryClient.invalidateQueries({ queryKey: [rootId, 'hierarchy_level'] }), + [queryClient, rootId] + ); + useStateEventCallback( + mx, + useCallback( + (event) => { + if (event.getType() === StateEvent.SpaceChild) refetchHierarchy(); + if (event.getType() === StateEvent.GameCultStewardships) bumpStewardships(); + }, + [refetchHierarchy, bumpStewardships] + ) + ); + // What the server lets this user see changes with invitations. + useEffect(() => { + refetchHierarchy(); + }, [invited.length, refetchHierarchy]); + + const stewardships = useMemo( + () => { + const rootRoom = mx.getRoom(rootId); + return rootRoom + ? getStateEvent(rootRoom, StateEvent.GameCultStewardships)?.getContent() + : undefined; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [mx, rootId, stewardshipsVersion, joined, invited] + ); + + const tiers = useMemo(() => { + const membership = new Map(); + invited.forEach((id) => membership.set(id, 'invite')); + joined.forEach((id) => membership.set(id, 'join')); + const domain = mx.getDomain(); + const mindLabels = new Map(); + Object.keys(stewardships ?? {}).forEach((mind) => { + const name = mx.getUser(`@${mind}:${domain}`)?.displayName; + if (name) mindLabels.set(mind, name); + }); + return buildEstate({ + rootId, + hierarchy: Array.from(rooms.values()), + stewardships, + membership, + directRoomIds: Array.from(mDirects).filter((id) => joined.includes(id)), + mindLabels, + }); + }, [mx, rootId, rooms, stewardships, joined, invited, mDirects]); + + return { fetching, error, tiers }; +}; diff --git a/src/app/hooks/useSpaceHierarchy.ts b/src/app/hooks/useSpaceHierarchy.ts index ad34e3f4..59889c1c 100644 --- a/src/app/hooks/useSpaceHierarchy.ts +++ b/src/app/hooks/useSpaceHierarchy.ts @@ -276,7 +276,8 @@ export type FetchSpaceHierarchyLevelData = { }; export const useFetchSpaceHierarchyLevel = ( roomId: string, - enable: boolean + enable: boolean, + maxDepth = 1 ): FetchSpaceHierarchyLevelData => { const mx = useMatrixClient(); const pageNoRef = useRef(0); @@ -286,13 +287,13 @@ export const useFetchSpaceHierarchyLevel = ( string[], string | undefined > = useCallback( - ({ pageParam }) => mx.getRoomHierarchy(roomId, PER_PAGE_COUNT, 1, false, pageParam), - [roomId, mx] + ({ pageParam }) => mx.getRoomHierarchy(roomId, PER_PAGE_COUNT, maxDepth, false, pageParam), + [roomId, maxDepth, mx] ); const queryResponse = useInfiniteQuery({ refetchOnMount: enable, - queryKey: [roomId, 'hierarchy_level'], + queryKey: [roomId, 'hierarchy_level', maxDepth], initialPageParam: undefined, queryFn: fetchLevel, getNextPageParam: (result) => { diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index 4de42081..ae5eac70 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -40,19 +40,19 @@ import { getSpaceLobbyPath, } from './pathUtils'; import { ClientBindAtoms, ClientLayout, ClientRoot } from './client'; -import { Home, HomeRouteRoomProvider, HomeSearch } from './client/home'; -import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct'; -import { RouteSpaceProvider, Space, SpaceRouteRoomProvider, SpaceSearch } from './client/space'; +import { HomeRouteRoomProvider, HomeSearch } from './client/home'; +import { DirectCreate, DirectRouteRoomProvider } from './client/direct'; +import { RouteSpaceProvider, SpaceRouteRoomProvider, SpaceSearch } from './client/space'; import { Explore, FeaturedRooms, PublicRooms } from './client/explore'; import { Notifications, Inbox, Invites } from './client/inbox'; import { setAfterLoginRedirectPath } from './afterLoginRedirectPath'; import { Room } from '../features/room'; import { Lobby } from '../features/lobby'; import { WelcomePage } from './client/WelcomePage'; -import { SidebarNav } from './client/SidebarNav'; +import { EstatePageNav } from './client/EstatePageNav'; import { PageRoot } from '../components/page'; import { ScreenSize } from '../hooks/useScreenSize'; -import { MobileFriendlyPageNav, MobileFriendlyClientNav } from './MobileFriendly'; +import { MobileFriendlyPageNav } from './MobileFriendly'; import { ClientInitStorageAtom } from './client/ClientInitStorageAtom'; import { ClientNonUIFeatures } from './client/ClientNonUIFeatures'; import { AuthRouteThemeManager, UnAuthRouteThemeManager } from './ThemeManager'; @@ -127,13 +127,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) - - - - } - > + @@ -159,9 +153,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) element={ - - + } > @@ -186,9 +178,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) element={ - - + } > @@ -212,9 +202,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) - - + } > diff --git a/src/app/pages/client/EstatePageNav.tsx b/src/app/pages/client/EstatePageNav.tsx new file mode 100644 index 00000000..470cbdd7 --- /dev/null +++ b/src/app/pages/client/EstatePageNav.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { MobileFriendlyPageNav } from '../MobileFriendly'; +import { EstateNavigator } from '../../features/estate'; + +/** + * Where the navigator mounts in a route's nav slot. The wide-screen placement + * question (ratatoskr:question:desktop-navigator-placement) is answered here. + */ +export function EstatePageNav({ path }: { path: string }) { + return ( + + + + ); +} From f810a83a281fc643e429cb794f13b84617c20f92 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 17:08:47 +0100 Subject: [PATCH 008/206] Estate: keep the new files free of unresolved-SDK type errors Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/estate/EstateNavigator.tsx | 5 ++--- src/app/features/estate/joinEstateRoom.ts | 5 +++-- src/app/features/estate/useEstate.ts | 22 +++++++++++++-------- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx index 211f0c3a..811f531b 100644 --- a/src/app/features/estate/EstateNavigator.tsx +++ b/src/app/features/estate/EstateNavigator.tsx @@ -2,7 +2,6 @@ import React, { MouseEventHandler, ReactNode, useCallback, useMemo, useRef } fro import { useNavigate } from 'react-router-dom'; import { useAtom, useAtomValue } from 'jotai'; import { Box, Chip, Icon, IconButton, Icons, Spinner, Text, color } from 'folds'; -import { MatrixClient } from 'matrix-js-sdk'; import { PageNav, PageNavContent, PageNavHeader } from '../../components/page'; import { NavCategory, NavCategoryHeader, NavItem, NavItemContent } from '../../components/nav'; import { RoomNavCategoryButton, RoomNavItem } from '../room-nav'; @@ -30,7 +29,7 @@ import { EstateRoom, EstateTier } from './buildEstate'; import { activateEstateRoom } from './joinEstateRoom'; import { useEstate, useEstateRootId } from './useEstate'; -const estateRoomPath = (mx: MatrixClient, room: EstateRoom): string => { +const estateRoomPath = (mx: ReturnType, room: EstateRoom): string => { const alias = getCanonicalAliasOrRoomId(mx, room.roomId); if (room.parentId === null) return getDirectRoomPath(alias); // A Space the user has not joined has no Space route; the room still resolves from Home. @@ -62,7 +61,7 @@ function JoinRow({ room }: { room: EstateRoom }) { {state.status === AsyncStatus.Error && ( - {state.error.message} + {(state.error as Error).message} )} ; +type JoinClient = { + joinRoom(roomId: string, opts: { viaServers: string[] }): Promise<{ roomId: string }>; +}; /** * What a tap on an estate row does. A joined room opens; any other room is diff --git a/src/app/features/estate/useEstate.ts b/src/app/features/estate/useEstate.ts index 3968d974..fc3d8009 100644 --- a/src/app/features/estate/useEstate.ts +++ b/src/app/features/estate/useEstate.ts @@ -1,7 +1,6 @@ import { useCallback, useEffect, useMemo } from 'react'; import { useAtomValue } from 'jotai'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { MatrixClient } from 'matrix-js-sdk'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useClientConfig } from '../../hooks/useClientConfig'; import { useFetchSpaceHierarchyLevel } from '../../hooks/useSpaceHierarchy'; @@ -12,14 +11,19 @@ import { allRoomsAtom } from '../../state/room-list/roomList'; import { allInvitesAtom } from '../../state/room-list/inviteList'; import { mDirectAtom } from '../../state/mDirectList'; import { StateEvent } from '../../../types/matrix/room'; -import { buildEstate, EstateTier, Stewardships } from './buildEstate'; +import { buildEstate, EstateHierarchyRoom, EstateTier, Stewardships } from './buildEstate'; const HIERARCHY_DEPTH = 10; -const findLoadedRoom = (mx: MatrixClient, alias: string): string | undefined => - mx - .getRooms() - .find((r) => r.getCanonicalAlias() === alias || r.getAltAliases().includes(alias))?.roomId; +type AliasedRoom = { roomId: string; getCanonicalAlias(): string | null; getAltAliases(): string[] }; + +const findLoadedRoom = ( + mx: ReturnType, + alias: string +): string | undefined => + (mx.getRooms() as AliasedRoom[]).find( + (r) => r.getCanonicalAlias() === alias || r.getAltAliases().includes(alias) + )?.roomId; /** The estate's root Space: featuredCommunities.spaces[0] from the client config. */ export const useEstateRootId = (): { rootId?: string; error?: Error } => { @@ -79,7 +83,9 @@ export const useEstate = (rootId: string): Estate => { () => { const rootRoom = mx.getRoom(rootId); return rootRoom - ? getStateEvent(rootRoom, StateEvent.GameCultStewardships)?.getContent() + ? (getStateEvent(rootRoom, StateEvent.GameCultStewardships)?.getContent() as + | Stewardships + | undefined) : undefined; }, // eslint-disable-next-line react-hooks/exhaustive-deps @@ -98,7 +104,7 @@ export const useEstate = (rootId: string): Estate => { }); return buildEstate({ rootId, - hierarchy: Array.from(rooms.values()), + hierarchy: Array.from(rooms.values()) as EstateHierarchyRoom[], stewardships, membership, directRoomIds: Array.from(mDirects).filter((id) => joined.includes(id)), From 4d0ee789745a0153f82adc7a8bb53da0622c13ec Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 17:10:41 +0100 Subject: [PATCH 009/206] Estate: lint and type fixes in the new files Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/estate/joinEstateRoom.test.ts | 3 +-- src/app/features/estate/useEstate.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/app/features/estate/joinEstateRoom.test.ts b/src/app/features/estate/joinEstateRoom.test.ts index e063b337..8976f325 100644 --- a/src/app/features/estate/joinEstateRoom.test.ts +++ b/src/app/features/estate/joinEstateRoom.test.ts @@ -18,8 +18,7 @@ const recordingClient = (joinRoom = vi.fn().mockResolvedValue({ roomId: '!r:game return joinRoom(...args); }; } - return (...args: unknown[]) => { - void args; + return () => { calls.push(prop); throw new Error(`unexpected client call: ${prop}`); }; diff --git a/src/app/features/estate/useEstate.ts b/src/app/features/estate/useEstate.ts index fc3d8009..6ffccd1e 100644 --- a/src/app/features/estate/useEstate.ts +++ b/src/app/features/estate/useEstate.ts @@ -104,7 +104,7 @@ export const useEstate = (rootId: string): Estate => { }); return buildEstate({ rootId, - hierarchy: Array.from(rooms.values()) as EstateHierarchyRoom[], + hierarchy: Array.from(rooms.values()) as unknown as EstateHierarchyRoom[], stewardships, membership, directRoomIds: Array.from(mDirects).filter((id) => joined.includes(id)), From c848626caaf16e09acf9d0951138c2433fe925a2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 17:11:35 +0100 Subject: [PATCH 010/206] Estate: prettier, and tests that kill the surviving mutants Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/estate/buildEstate.test.ts | 81 ++++++++++++++++++- src/app/features/estate/buildEstate.ts | 6 +- .../features/estate/joinEstateRoom.test.ts | 13 +++ src/app/features/estate/useEstate.ts | 6 +- src/app/pages/Router.tsx | 18 +---- src/app/pages/client/space/SpaceTombstone.tsx | 1 - src/types/matrix/accountData.ts | 1 - 7 files changed, 101 insertions(+), 25 deletions(-) diff --git a/src/app/features/estate/buildEstate.test.ts b/src/app/features/estate/buildEstate.test.ts index 0593e195..27964a7b 100644 --- a/src/app/features/estate/buildEstate.test.ts +++ b/src/app/features/estate/buildEstate.test.ts @@ -19,7 +19,11 @@ const room = (id: string, name: string, alias?: string): EstateHierarchyRoom => const hierarchy = [ space(ROOT, 'GameCult', ['!general:gamecult.org', PROJECTS]), room('!general:gamecult.org', 'General', '#general:gamecult.org'), - space(PROJECTS, 'Projects', ['!cultlib:gamecult.org', '!aeth:gamecult.org', '!misc:gamecult.org']), + space(PROJECTS, 'Projects', [ + '!cultlib:gamecult.org', + '!aeth:gamecult.org', + '!misc:gamecult.org', + ]), room('!cultlib:gamecult.org', 'CultLib', '#cultlib:gamecult.org'), room('!aeth:gamecult.org', 'Aetheria', '#Aetheria:gamecult.org'), room('!misc:gamecult.org', 'Misc', '#misc:gamecult.org'), @@ -43,7 +47,9 @@ const find = (tiers: ReturnType, roomId: string) => describe('buildEstate', () => { it('lists an invited project room as invited', () => { - const tiers = buildEstate(input({ membership: new Map([['!cultlib:gamecult.org', 'invite']]) })); + const tiers = buildEstate( + input({ membership: new Map([['!cultlib:gamecult.org', 'invite']]) }) + ); expect(find(tiers, '!cultlib:gamecult.org')?.state).toBe('invited'); }); @@ -84,7 +90,12 @@ describe('buildEstate', () => { it('sorts steward groups by display label', () => { const projects = buildEstate( - input({ mindLabels: new Map([['eureka', 'Zed'], ['void', 'Alpha']]) }) + input({ + mindLabels: new Map([ + ['eureka', 'Zed'], + ['void', 'Alpha'], + ]), + }) ).find((t) => t.id === PROJECTS)!; expect(projects.groups.map((g) => g.label)).toEqual(['Alpha', 'Zed']); }); @@ -116,11 +127,73 @@ describe('buildEstate', () => { membership: new Map([['!dm:gamecult.org', 'join']]), }) ); - expect(tiers.map((t) => t.label)).toEqual(['GameCult', 'Projects', 'Workshop', 'Direct Messages']); + expect(tiers.map((t) => t.label)).toEqual([ + 'GameCult', + 'Projects', + 'Workshop', + 'Direct Messages', + ]); expect(tiers[0].loose.map((r) => r.name)).toEqual(['General']); expect(tiers[3].loose[0]).toMatchObject({ roomId: '!dm:gamecult.org', parentId: null }); }); + it('takes join servers from the matching children_state entry', () => { + const tiers = buildEstate( + input({ + hierarchy: [ + { + ...space(ROOT, 'GameCult', ['!a:gamecult.org', '!b:gamecult.org']), + children_state: [ + { state_key: '!a:gamecult.org', content: { via: ['one.example'] } }, + { state_key: '!b:gamecult.org', content: { via: ['two.example'] } }, + ], + }, + room('!a:gamecult.org', 'A'), + room('!b:gamecult.org', 'B'), + ], + }) + ); + expect(find(tiers, '!a:gamecult.org')?.via).toEqual(['one.example']); + expect(find(tiers, '!b:gamecult.org')?.via).toEqual(['two.example']); + }); + + it('puts every room of one Mind in a single group', () => { + const tiers = buildEstate( + input({ stewardships: { eureka: ['CultLib', 'Aetheria'] } }) + ); + const projects = tiers.find((t) => t.id === PROJECTS)!; + expect(projects.groups).toHaveLength(1); + expect(projects.groups[0].rooms.map((r) => r.roomId)).toEqual([ + '!cultlib:gamecult.org', + '!aeth:gamecult.org', + ]); + }); + + it('sorts group labels without regard to case', () => { + const projects = buildEstate( + input({ mindLabels: new Map([['eureka', 'beta'], ['void', 'Alpha']]) }) + ).find((t) => t.id === PROJECTS)!; + expect(projects.groups.map((g) => g.label)).toEqual(['Alpha', 'beta']); + }); + + it('omits a tier whose Space returned no rooms and the Direct tier when there are no DMs', () => { + const tiers = buildEstate( + input({ + hierarchy: [ + space(ROOT, 'GameCult', ['!general:gamecult.org', PROJECTS]), + room('!general:gamecult.org', 'General'), + space(PROJECTS, 'Projects', ['!hidden:gamecult.org']), + ], + }) + ); + expect(tiers.map((t) => t.id)).toEqual([ROOT]); + }); + + it('puts direct messages in the final tier, off any Space', () => { + const tiers = buildEstate(input({ directRoomIds: ['!dm:gamecult.org'] })); + expect(tiers[tiers.length - 1]).toMatchObject({ id: 'direct', groups: [] }); + }); + it('is identical for identical inputs', () => { expect(buildEstate(input())).toEqual(buildEstate(input())); }); diff --git a/src/app/features/estate/buildEstate.ts b/src/app/features/estate/buildEstate.ts index 096ea9ad..9ca9048c 100644 --- a/src/app/features/estate/buildEstate.ts +++ b/src/app/features/estate/buildEstate.ts @@ -7,8 +7,6 @@ * a room it returns is never hidden by the client. */ -export const STEWARDSHIPS_EVENT_TYPE = 'org.gamecult.stewardships'; - export type EstateHierarchyRoom = { room_id: string; name?: string; @@ -77,7 +75,9 @@ export const stewardOfRoom = ( const local = canonicalAlias.replace(/^#/, '').split(':')[0].toLowerCase(); const mind = Object.keys(stewardships).find((m) => { const repos = stewardships[m]; - return Array.isArray(repos) && repos.some((r) => typeof r === 'string' && repoName(r) === local); + return ( + Array.isArray(repos) && repos.some((r) => typeof r === 'string' && repoName(r) === local) + ); }); return mind; }; diff --git a/src/app/features/estate/joinEstateRoom.test.ts b/src/app/features/estate/joinEstateRoom.test.ts index 8976f325..fae21e5b 100644 --- a/src/app/features/estate/joinEstateRoom.test.ts +++ b/src/app/features/estate/joinEstateRoom.test.ts @@ -48,6 +48,19 @@ describe('activateEstateRoom', () => { } ); + it('falls back to the error message, then to a generic one', async () => { + const named = recordingClient(vi.fn().mockRejectedValue(new Error('Boom'))); + expect(await activateEstateRoom(named.mx, room('joinable'))).toEqual({ + kind: 'refused', + message: 'Boom', + }); + const bare = recordingClient(vi.fn().mockRejectedValue({})); + expect(await activateEstateRoom(bare.mx, room('joinable'))).toEqual({ + kind: 'refused', + message: 'Could not join', + }); + }); + it('reports the server refusal and does not decide access itself', async () => { const refusal = Object.assign(new Error('M_FORBIDDEN'), { data: { error: 'You are not invited to this room.' }, diff --git a/src/app/features/estate/useEstate.ts b/src/app/features/estate/useEstate.ts index 6ffccd1e..99927ba8 100644 --- a/src/app/features/estate/useEstate.ts +++ b/src/app/features/estate/useEstate.ts @@ -15,7 +15,11 @@ import { buildEstate, EstateHierarchyRoom, EstateTier, Stewardships } from './bu const HIERARCHY_DEPTH = 10; -type AliasedRoom = { roomId: string; getCanonicalAlias(): string | null; getAltAliases(): string[] }; +type AliasedRoom = { + roomId: string; + getCanonicalAlias(): string | null; + getAltAliases(): string[]; +}; const findLoadedRoom = ( mx: ReturnType, diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index ae5eac70..159162dd 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -151,11 +151,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) - } - > + }> } @@ -176,11 +172,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) - } - > + }> } @@ -200,11 +192,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) path={SPACE_PATH} element={ - - } - > + }> diff --git a/src/app/pages/client/space/SpaceTombstone.tsx b/src/app/pages/client/space/SpaceTombstone.tsx index 3e2d13bd..16bcdf63 100644 --- a/src/app/pages/client/space/SpaceTombstone.tsx +++ b/src/app/pages/client/space/SpaceTombstone.tsx @@ -76,4 +76,3 @@ export function SpaceTombstone({ roomId, replacementRoomId }: SpaceTombstoneProp
); } - diff --git a/src/types/matrix/accountData.ts b/src/types/matrix/accountData.ts index 1f5f0608..36c1e21b 100644 --- a/src/types/matrix/accountData.ts +++ b/src/types/matrix/accountData.ts @@ -3,7 +3,6 @@ export enum AccountDataEvent { Direct = 'm.direct', IgnoredUserList = 'm.ignored_user_list', - ElementRecentEmoji = 'io.element.recent_emoji', PoniesUserEmotes = 'im.ponies.user_emotes', From 067cb10a90097a300ce6f54dd875d4b084e3f46b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 17:13:32 +0100 Subject: [PATCH 011/206] Estate: prettier on the new tests Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/estate/buildEstate.test.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/app/features/estate/buildEstate.test.ts b/src/app/features/estate/buildEstate.test.ts index 27964a7b..20129cbf 100644 --- a/src/app/features/estate/buildEstate.test.ts +++ b/src/app/features/estate/buildEstate.test.ts @@ -158,9 +158,7 @@ describe('buildEstate', () => { }); it('puts every room of one Mind in a single group', () => { - const tiers = buildEstate( - input({ stewardships: { eureka: ['CultLib', 'Aetheria'] } }) - ); + const tiers = buildEstate(input({ stewardships: { eureka: ['CultLib', 'Aetheria'] } })); const projects = tiers.find((t) => t.id === PROJECTS)!; expect(projects.groups).toHaveLength(1); expect(projects.groups[0].rooms.map((r) => r.roomId)).toEqual([ @@ -171,7 +169,12 @@ describe('buildEstate', () => { it('sorts group labels without regard to case', () => { const projects = buildEstate( - input({ mindLabels: new Map([['eureka', 'beta'], ['void', 'Alpha']]) }) + input({ + mindLabels: new Map([ + ['eureka', 'beta'], + ['void', 'Alpha'], + ]), + }) ).find((t) => t.id === PROJECTS)!; expect(projects.groups.map((g) => g.label)).toEqual(['Alpha', 'beta']); }); From e8efd3ce760003f290828efd32fe55bc015d5ca4 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 17:46:10 +0100 Subject: [PATCH 012/206] Delete SpaceTombstone: nothing renders it Co-Authored-By: Claude Sonnet 5.5 --- src/app/pages/client/space/SpaceTombstone.tsx | 78 ------------------- src/app/pages/client/space/index.ts | 1 - 2 files changed, 79 deletions(-) delete mode 100644 src/app/pages/client/space/SpaceTombstone.tsx diff --git a/src/app/pages/client/space/SpaceTombstone.tsx b/src/app/pages/client/space/SpaceTombstone.tsx deleted file mode 100644 index 16bcdf63..00000000 --- a/src/app/pages/client/space/SpaceTombstone.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import React, { useCallback } from 'react'; -import { Box, Button, Spinner, Text, color, config } from 'folds'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { Membership } from '../../../../types/matrix/room'; -import { getViaServers } from '../../../plugins/via-servers'; -import { useRoomNavigate } from '../../../hooks/useRoomNavigate'; -import { ContainerColor } from '../../../styles/ContainerColor.css'; -import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; -import { BreakWord } from '../../../styles/Text.css'; - -type SpaceTombstoneProps = { roomId: string; replacementRoomId: string }; -export function SpaceTombstone({ roomId, replacementRoomId }: SpaceTombstoneProps) { - const mx = useMatrixClient(); - const { navigateSpace } = useRoomNavigate(); - - const [joinState, handleJoin] = useAsyncCallback( - useCallback(() => { - const currentRoom = mx.getRoom(roomId); - const via = currentRoom ? getViaServers(currentRoom) : []; - return mx.joinRoom(replacementRoomId, { - viaServers: via, - }); - }, [mx, roomId, replacementRoomId]) - ); - const replacementRoom = mx.getRoom(replacementRoomId); - - const handleOpen = () => { - if (replacementRoom) navigateSpace(replacementRoom.roomId); - if (joinState.status === AsyncStatus.Success) navigateSpace(joinState.data.roomId); - }; - - return ( - - - Space Upgraded - This space has been replaced and is no longer active. - {joinState.status === AsyncStatus.Error && ( - - {(joinState.error as any)?.message ?? 'Failed to join replacement space!'} - - )} - - - {replacementRoom?.getMyMembership() === Membership.Join || - joinState.status === AsyncStatus.Success ? ( - - ) : ( - - )} - - - ); -} diff --git a/src/app/pages/client/space/index.ts b/src/app/pages/client/space/index.ts index 9b79693e..477d60ff 100644 --- a/src/app/pages/client/space/index.ts +++ b/src/app/pages/client/space/index.ts @@ -1,4 +1,3 @@ export * from './SpaceProvider'; -export * from './SpaceTombstone'; export * from './Search'; export * from './RoomProvider'; From 41527f6ba387a4ee88d75997581b2031a45b6572 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 17:46:10 +0100 Subject: [PATCH 013/206] config.json: the GameCult Space is the featured root, so a wholesale deploy resolves the estate Co-Authored-By: Claude Sonnet 5.5 --- config.json | 12 +----------- src/app/features/estate/config.test.ts | 9 +++++++++ 2 files changed, 10 insertions(+), 11 deletions(-) create mode 100644 src/app/features/estate/config.test.ts diff --git a/config.json b/config.json index a61bb966..e945d1c4 100644 --- a/config.json +++ b/config.json @@ -5,17 +5,7 @@ "featuredCommunities": { "openAsDefault": false, - "spaces": [ - "#cinny:matrix.org", - "#community:matrix.org", - "#space:unredacted.org", - "#librewolf-community:matrix.org", - "#stickers-and-emojis:tastytea.de", - "#videogames:waywardinn.com", - "#science-space:matrix.org", - "#libregaming-games:tchncs.de", - "#mathematics-on:matrix.org" - ], + "spaces": ["#gamecult:gamecult.org"], "rooms": [ "#tuwunel:grin.hu", "#freesoftware:matrix.org", diff --git a/src/app/features/estate/config.test.ts b/src/app/features/estate/config.test.ts new file mode 100644 index 00000000..4304e1c2 --- /dev/null +++ b/src/app/features/estate/config.test.ts @@ -0,0 +1,9 @@ +import { readFileSync } from 'fs'; +import { describe, expect, it } from 'vitest'; + +describe('repo config.json', () => { + it('names the GameCult Space first, so a wholesale deploy resolves the estate root', () => { + const config = JSON.parse(readFileSync('config.json', 'utf8')); + expect(config.featuredCommunities.spaces[0]).toBe('#gamecult:gamecult.org'); + }); +}); From 0b7ded9c991547fbd360fd8cf4ee1246eca2dbca Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 17:46:11 +0100 Subject: [PATCH 014/206] Estate: stewards from the hierarchy's Space child content, joins wait for the store, room finder on search, Create room and Join with address, Elsewhere group, invited DMs Steward groups read org.gamecult.steward from the m.space.child content the hierarchy returns, so an invited-only member gets them; root-state stewardships and GameCultStewardships are deleted. Tapping Join waits for the client store to hold the room before navigating (no invite-card frame). The header search opens the room finder on every width. Create room and Join with address are navigator actions; joined non-DM rooms outside the Space sit under Elsewhere; invited DMs list in Direct with Join. harness/estate holds the fake-homeserver Playwright pin. Co-Authored-By: Claude Sonnet 5.5 --- harness/estate/drive.js | 76 +++++++++ harness/estate/fakehs.js | 108 ++++++++++++ harness/estate/run.sh | 15 ++ src/app/features/estate/EstateNavigator.tsx | 112 +++++++++++-- src/app/features/estate/buildEstate.test.ts | 154 +++++++++++++++--- src/app/features/estate/buildEstate.ts | 88 ++++++---- .../features/estate/joinEstateRoom.test.ts | 54 +++++- src/app/features/estate/joinEstateRoom.ts | 33 ++++ src/app/features/estate/useEstate.ts | 62 +++---- src/types/matrix/room.ts | 1 - 10 files changed, 601 insertions(+), 102 deletions(-) create mode 100644 harness/estate/drive.js create mode 100644 harness/estate/fakehs.js create mode 100644 harness/estate/run.sh diff --git a/harness/estate/drive.js b/harness/estate/drive.js new file mode 100644 index 00000000..674cb15e --- /dev/null +++ b/harness/estate/drive.js @@ -0,0 +1,76 @@ +// Drives the built client against fakehs.js on a phone viewport and asserts the estate navigator. +const { chromium } = require('playwright'); +const fs = require('fs'); +const out = (n) => `/out/${n}`; +const tl = []; +const fails = []; +const check = (ok, what) => { tl.push(`${ok ? 'PASS' : 'FAIL'} ${what}`); if (!ok) fails.push(what); }; +(async () => { + const b = await chromium.launch(); + const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, deviceScaleFactor: 1 }); + const pg = await ctx.newPage(); + await pg.goto('http://127.0.0.1:8080/'); + await pg.evaluate(() => { localStorage.setItem('cinny_access_token', 'tok'); localStorage.setItem('cinny_device_id', 'DEV'); localStorage.setItem('cinny_user_id', '@t:test'); localStorage.setItem('cinny_hs_base_url', 'http://127.0.0.1:8080'); }); + await pg.goto('http://127.0.0.1:8080/#/home/'); + await pg.getByText('Create Room').first().waitFor({ timeout: 30000 }); + await pg.waitForTimeout(2000); + const text = () => pg.evaluate(() => document.body.innerText); + const snap = async (n) => { await pg.screenshot({ path: out(n + '.png') }); }; + const open = async (name) => { const h = pg.getByText(name, { exact: true }); if (await h.count()) { await h.first().click(); await pg.waitForTimeout(300); } }; + + // Entry points + const t0 = await text(); + check(/Create Room/.test(t0) && /Join with Address/.test(t0), 'Create Room and Join with Address are navigator actions'); + await pg.getByText('Join with Address').first().click(); + await pg.waitForTimeout(400); + check((await pg.locator('input[name="addressInput"]').count()) === 1, 'Join with Address opens its prompt'); + await pg.keyboard.press('Escape'); + await pg.waitForTimeout(300); + if (await pg.locator('input[name="addressInput"]').count()) await pg.getByText('Cancel').first().click(); + + // Header search is the room finder, on a phone + await pg.locator('[aria-label="Find a room"]').first().click(); + await pg.waitForTimeout(500); + check((await pg.locator('input[placeholder="Search"]').count()) === 1 && !/#\/home\/search/.test(pg.url()), 'header search opens the room finder, not message search'); + await snap('finder'); + await pg.keyboard.press('Escape'); + await pg.waitForTimeout(300); + + // Groups for an invitee, from the hierarchy alone; Elsewhere; invited DM + for (const n of ['Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']) await open(n); + await snap('expanded'); + const t1 = (await text()).toLowerCase(); + check(t1.includes('nibu') && t1.includes('eureka'), 'steward groups render for an invitee who cannot read Space state'); + check(t1.includes('elsewhere') && t1.includes('meeting'), 'a joined room outside the Space is listed under Elsewhere'); + check(t1.includes('direct messages') && (await pg.locator('[aria-label="Join Void"]').count()) === 1, 'an invited DM is listed with Join'); + + // Join: no invite card at any frame, then the room + await pg.locator('[aria-label="Join Aetheria"]').first().click(); + const t = Date.now(); + let card = 0, room = null; + while (Date.now() - t < 6000) { + const body = await text(); + if (/Summary card/.test(body)) card++; + if (room === null && /welcome to Aetheria/.test(body)) room = Date.now() - t; + await pg.waitForTimeout(40); + } + await snap('joined'); + check(card === 0, `no invite card at any frame after the tap (saw ${card})`); + check(room !== null, `the room opened (after ${room} ms)`); + + // Join the DM: lands in the room too + await pg.goto('http://127.0.0.1:8080/#/home/'); + await pg.waitForTimeout(3000); + await open('Direct Messages'); + if (await pg.locator('[aria-label="Join Void"]').count()) { + await pg.locator('[aria-label="Join Void"]').first().click(); + let dmCard = 0; const t2 = Date.now(); + while (Date.now() - t2 < 5000) { if (/Summary card/.test(await text())) dmCard++; await pg.waitForTimeout(40); } + check(dmCard === 0 && /\/direct\//.test(pg.url()), 'the invited DM opens in Direct with no invite card'); + } + await b.close(); +})().catch((e) => { tl.push('ERROR ' + String(e.stack || e)); fails.push('error'); }).finally(() => { + fs.writeFileSync(out('timeline.txt'), tl.join('\n')); + console.log(tl.join('\n')); + process.exitCode = fails.length ? 1 : 0; +}); diff --git a/harness/estate/fakehs.js b/harness/estate/fakehs.js new file mode 100644 index 00000000..641a21f4 --- /dev/null +++ b/harness/estate/fakehs.js @@ -0,0 +1,108 @@ +// Fake Matrix homeserver + static server for the built client (harness/estate/run.sh). +// Scenario: an invitee to the GameCult Space and 6 project rooms (never a member of the +// Space, so the client cannot read its state), joined to #general and to #meeting, a room +// outside the Space, and invited to a DM. The steward of each project room is carried only +// in the m.space.child content of the hierarchy, as the accounts script writes it. +const http = require('http'), fs = require('fs'), path = require('path'); +const DIST = process.argv[2], PORT = 8080, ME = '@t:test'; +const log = []; +const P = ['aetheria', 'ghostlight', 'huginn', 'idunn', 'bifrost', 'odin']; +const STEWARD = { aetheria: 'nibu', ghostlight: 'nibu', huginn: 'eureka', idunn: 'eureka' }; +const DM = '!dm:test', MEETING = '!meeting:test'; +const PROJ = P.map((p, i) => ({ id: `!p${i}:test`, alias: `#${p}:test`, name: p[0].toUpperCase() + p.slice(1) })); +const ROOT = '!root:test', PSP = '!proj:test', GEN = '!gen:test', OP = '!op:test'; +const pendingJoin = []; +let ev = 0; +const se = (type, content, sk = '', sender = '@admin:test') => ({ type, content, state_key: sk, sender, event_id: `$e${++ev}`, origin_server_ts: Date.now() }); +const strip = (id, name, space, dm) => [ + { type: 'm.room.create', state_key: '', sender: '@admin:test', content: space ? { type: 'm.space', room_version: '12' } : { room_version: '12' } }, + ...(name ? [{ type: 'm.room.name', state_key: '', sender: '@admin:test', content: { name } }] : []), + ...(dm ? [{ type: 'm.room.member', state_key: '@void:test', sender: '@void:test', content: { membership: 'join', displayname: 'Void' } }] : []), + { type: 'm.room.join_rules', state_key: '', sender: '@admin:test', content: { join_rule: 'invite' } }, + { type: 'm.room.member', state_key: ME, sender: dm ? '@void:test' : '@admin:test', content: dm ? { membership: 'invite', is_direct: true } : { membership: 'invite' } }, +]; +const joinedRoom = (id, name, extra = [], space = false) => ({ + state: { events: [se('m.room.create', space ? { type: 'm.space', room_version: '12' } : { room_version: '12' }), se('m.room.name', { name }), se('m.room.join_rules', { join_rule: 'invite' }), se('m.room.member', { membership: 'join' }, ME, ME), ...extra] }, + timeline: { events: [{ type: 'm.room.message', sender: '@admin:test', content: { msgtype: 'm.text', body: `welcome to ${name}` }, event_id: `$m${++ev}`, origin_server_ts: Date.now() }], limited: false }, + ephemeral: { events: [] }, account_data: { events: [] }, unread_notifications: {}, summary: {}, +}); +const invites = () => { + const o = {}; + o[ROOT] = { invite_state: { events: strip(ROOT, 'GameCult', true) } }; + o[PSP] = { invite_state: { events: strip(PSP, 'Projects', true) } }; + o[OP] = { invite_state: { events: strip(OP, 'Operator') } }; + PROJ.forEach((r) => (o[r.id] = { invite_state: { events: strip(r.id, r.name) } })); + o[DM] = { invite_state: { events: strip(DM, null, false, true) } }; + return o; +}; +const child = (id, alias) => ({ type: 'm.space.child', state_key: id, content: { via: ['test'], ...(STEWARD[alias] ? { 'org.gamecult.steward': { repo: 'GameCult/' + alias, steward: '@' + STEWARD[alias] + ':test', mind: STEWARD[alias], source: 'x' } } : {}) }, sender: '@admin:test', origin_server_ts: 1 }); +const hroom = (id, name, alias, extra = {}) => ({ room_id: id, name, canonical_alias: alias, num_joined_members: 1, world_readable: false, guest_can_join: false, join_rule: 'invite', children_state: [], ...extra }); +const hierarchy = () => [ + hroom(ROOT, 'GameCult', '#gamecult:test', { room_type: 'm.space', children_state: [child(GEN), child(OP), child(PSP)] }), + hroom(GEN, 'General', '#general:test'), + hroom(OP, 'Operator', '#operator:test'), + hroom(PSP, 'Projects', '#projects:test', { room_type: 'm.space', children_state: PROJ.map((r) => child(r.id, r.alias.slice(1).split(':')[0])) }), + ...PROJ.map((r) => hroom(r.id, r.name, r.alias)), +]; +let syncN = 1; +const json = (res, code, body) => { res.writeHead(code, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': '*', 'Access-Control-Allow-Methods': '*' }); res.end(JSON.stringify(body)); }; +const mime = { '.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css', '.html': 'text/html', '.json': 'application/json', '.wasm': 'application/wasm', '.svg': 'image/svg+xml', '.png': 'image/png', '.woff2': 'font/woff2' }; +const flush = () => fs.writeFileSync(process.env.HSLOG, log.join('\n')); +http.createServer((req, res) => { + const u = new URL(req.url, 'http://x'); const p = decodeURIComponent(u.pathname); + if (req.method === 'OPTIONS') return json(res, 200, {}); + if (!p.startsWith('/_matrix') && !p.startsWith('/.well-known')) { + let f = path.join(DIST, p === '/' ? 'index.html' : p); + if (!fs.existsSync(f) || fs.statSync(f).isDirectory()) f = path.join(DIST, 'index.html'); + res.writeHead(200, { 'Content-Type': mime[path.extname(f)] || 'application/octet-stream' }); + return fs.createReadStream(f).pipe(res); + } + let body = ''; req.on('data', (c) => (body += c)); req.on('end', () => { + log.push(`${Date.now()} ${req.method} ${p}`); + if (p === '/_matrix/client/versions') return json(res, 200, { versions: ['v1.1', 'v1.5', 'v1.11'], unstable_features: {} }); + if (p.endsWith('/sync')) { + if (!u.searchParams.get('since')) log.push(`${Date.now()} INITIAL SYNC`); + const since = u.searchParams.get('since'); + if (!since) { + const join = {}; join[GEN] = joinedRoom(GEN, 'General'); join[MEETING] = joinedRoom(MEETING, 'Meeting'); + const inv = invites(); + return json(res, 200, { next_batch: 's1', rooms: { join, invite: inv, leave: {} }, account_data: { events: [] }, presence: { events: [] }, to_device: { events: [] }, device_one_time_keys_count: { signed_curve25519: 50 } }); + } + const wait = () => { + if (pendingJoin.length) { + const id = pendingJoin.shift(); const r = PROJ.find((x) => x.id === id) || { name: id === DM ? 'Void' : id }; + const join = {}; join[id] = joinedRoom(id, r.name); + log.push(`${Date.now()} SYNC delivers join ${id}`); + return json(res, 200, { next_batch: `s${++syncN}`, rooms: { join, invite: {}, leave: {} } }); + } + return json(res, 200, { next_batch: since, rooms: {} }); + }; + return setTimeout(wait, pendingJoin.length ? 2500 : 1500); + } + const jm = p.match(/\/rooms\/([^/]+)\/join$/) || p.match(/\/join\/([^/]+)$/); + if (req.method === 'POST' && jm) { + const id = jm[1]; + log.push(`${Date.now()} JOIN ${id}`); + if (id === PROJ[1].id) return json(res, 403, { errcode: 'M_FORBIDDEN', error: 'You are not invited to this room.' }); + pendingJoin.push(id); + return json(res, 200, { room_id: id }); + } + if (p.includes('/hierarchy')) return json(res, 200, { rooms: hierarchy() }); + if (p.includes('/directory/room/')) return json(res, 200, { room_id: ROOT, servers: ['test'] }); + if (p.endsWith('/keys/upload')) return json(res, 200, { one_time_key_counts: { signed_curve25519: 50 } }); + if (p.endsWith('/keys/query')) return json(res, 200, { device_keys: {}, failures: {} }); + if (p.includes('/room_keys/version')) return json(res, 404, { errcode: 'M_NOT_FOUND', error: 'none' }); + if (p.includes('/pushrules')) return json(res, 200, { global: { override: [], underride: [], room: [], sender: [], content: [] } }); + if (p.includes('/filter')) return json(res, 200, { filter_id: '1' }); + if (p.includes('/account_data/')) return req.method === 'GET' ? json(res, 404, { errcode: 'M_NOT_FOUND', error: 'none' }) : json(res, 200, {}); + if (p.includes('/messages')) return json(res, 200, { chunk: [], start: 't0', end: 't0' }); + if (p.includes('/members')) return json(res, 200, { chunk: [] }); + if (p.includes('/profile/')) return json(res, 200, { displayname: 'Tess' }); + if (p.includes('/capabilities')) return json(res, 200, { capabilities: {} }); + if (p.includes('/devices')) return json(res, 200, { devices: [] }); + if (p.includes('summary')) return json(res, 200, { room_id: p.split('/').pop(), name: 'Summary card', num_joined_members: 1, membership: 'invite', join_rule: 'invite' }); + if (p.includes('/login')) return json(res, 200, { flows: [{ type: 'm.login.password' }] }); + return json(res, 200, {}); + }); +}).listen(PORT, () => console.log('fakehs up')); +setInterval(flush, 500); diff --git a/harness/estate/run.sh b/harness/estate/run.sh new file mode 100644 index 00000000..085b17dc --- /dev/null +++ b/harness/estate/run.sh @@ -0,0 +1,15 @@ +# Run inside playwright:v1.48.2-jammy with the repo at /src and /out writable: +# bash /src/harness/estate/run.sh +set -e +cd /src +mkdir -p /out +npm ci --no-audit --no-fund --ignore-scripts >/out/npmci.log 2>&1 +npm run build > /out/build.log 2>&1; echo BUILD_RC=$? >> /out/build.log +printf '{"defaultHomeserver":0,"homeserverList":["127.0.0.1:8080"],"allowCustomHomeservers":true,"featuredCommunities":{"spaces":["#gamecult:test"],"rooms":[],"servers":["test"]},"hashRouter":{"enabled":true,"basename":"/"}}' > dist/config.json +mkdir -p /tmp/pw && cd /tmp/pw && npm init -y >/dev/null && npm i --no-audit --no-fund playwright@1.48.2 >/out/pw.log 2>&1 +cp /src/harness/estate/drive.js /tmp/pw/ +HSLOG=/out/hslog.txt node /src/harness/estate/fakehs.js /src/dist > /out/hs.out 2>&1 & +HS=$! +sleep 2 +node /tmp/pw/drive.js; echo DRIVE_RC=$? +kill $HS diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx index 811f531b..90b3e529 100644 --- a/src/app/features/estate/EstateNavigator.tsx +++ b/src/app/features/estate/EstateNavigator.tsx @@ -1,18 +1,35 @@ import React, { MouseEventHandler, ReactNode, useCallback, useMemo, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; -import { useAtom, useAtomValue } from 'jotai'; -import { Box, Chip, Icon, IconButton, Icons, Spinner, Text, color } from 'folds'; +import { useAtom, useAtomValue, useSetAtom } from 'jotai'; +import { Avatar, Box, Chip, Icon, IconButton, Icons, Spinner, Text, color } from 'folds'; import { PageNav, PageNavContent, PageNavHeader } from '../../components/page'; -import { NavCategory, NavCategoryHeader, NavItem, NavItemContent } from '../../components/nav'; +import { + NavButton, + NavCategory, + NavCategoryHeader, + NavItem, + NavItemContent, +} from '../../components/nav'; +import { UseStateProvider } from '../../components/UseStateProvider'; +import { JoinAddressPrompt } from '../../components/join-address-prompt'; import { RoomNavCategoryButton, RoomNavItem } from '../room-nav'; import { InboxTab, SettingsTab, UnverifiedTab } from '../../pages/client/sidebar'; import { + encodeSearchParamValueArray, getDirectRoomPath, + getHomeCreatePath, getHomeRoomPath, - getHomeSearchPath, getSpaceRoomPath, + withSearchParam, } from '../../pages/pathUtils'; -import { getCanonicalAliasOrRoomId } from '../../utils/matrix'; +import { _RoomSearchParams } from '../../pages/paths'; +import { searchModalAtom } from '../../state/searchModal'; +import { isDirectInvite } from '../../utils/room'; +import { + addRoomIdToMDirect, + getCanonicalAliasOrRoomId, + guessDmRoomUserId, +} from '../../utils/matrix'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useSelectedRoom } from '../../hooks/router/useSelectedRoom'; import { useCallEmbed } from '../../hooks/useCallEmbed'; @@ -26,12 +43,15 @@ import { makeNavCategoryId } from '../../state/closedNavCategories'; import { roomToUnreadAtom } from '../../state/room/roomToUnread'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { EstateRoom, EstateTier } from './buildEstate'; -import { activateEstateRoom } from './joinEstateRoom'; +import { activateEstateRoom, awaitMyJoin } from './joinEstateRoom'; + +/** How long a tap waits for the client store to hold the room it just joined. */ +const JOIN_ARRIVAL_TIMEOUT_MS = 20000; import { useEstate, useEstateRootId } from './useEstate'; const estateRoomPath = (mx: ReturnType, room: EstateRoom): string => { const alias = getCanonicalAliasOrRoomId(mx, room.roomId); - if (room.parentId === null) return getDirectRoomPath(alias); + if (room.direct) return getDirectRoomPath(alias); // A Space the user has not joined has no Space route; the room still resolves from Home. if (mx.getRoom(room.parentId)?.getMyMembership() === 'join') { return getSpaceRoomPath(getCanonicalAliasOrRoomId(mx, room.parentId), alias); @@ -44,8 +64,19 @@ function JoinRow({ room }: { room: EstateRoom }) { const navigate = useNavigate(); const [state, join] = useAsyncCallback( useCallback(async () => { + const userId = mx.getSafeUserId(); + const dmRoom = mx.getRoom(room.roomId); + const dmUserId = + room.direct && dmRoom && isDirectInvite(dmRoom, userId) + ? guessDmRoomUserId(dmRoom, userId) + : undefined; const result = await activateEstateRoom(mx, room); if (result.kind === 'refused') throw new Error(result.message); + // joinRoom resolves before the store holds the room; opening it now shows the invite card. + if (!(await awaitMyJoin(mx, room.roomId, JOIN_ARRIVAL_TIMEOUT_MS))) { + throw new Error('Joined, but the room has not arrived yet. Tap Join again in a moment.'); + } + if (dmUserId) await addRoomIdToMDirect(mx, room.roomId, dmUserId); navigate(estateRoomPath(mx, room)); }, [mx, navigate, room]) ); @@ -91,7 +122,7 @@ function EstateRow({ room }: { room: EstateRoom }) { @@ -157,6 +188,64 @@ function Tier({ tier }: { tier: EstateTier }) { ); } +function EntryPoints() { + const navigate = useNavigate(); + return ( + + + navigate(getHomeCreatePath())}> + + + + + + + Create Room + + + + + + + {(open, setOpen) => ( + <> + + setOpen(true)}> + + + + + + + Join with Address + + + + + + {open && ( + setOpen(false)} + onOpen={(roomIdOrAlias, viaServers, eventId) => { + setOpen(false); + const path = getHomeRoomPath(roomIdOrAlias, eventId); + navigate( + viaServers + ? withSearchParam<_RoomSearchParams>(path, { + viaServers: encodeSearchParamValueArray(viaServers), + }) + : path + ); + }} + /> + )} + + )} + + + ); +} + function EstateTiers({ rootId }: { rootId: string }) { const { fetching, error, tiers } = useEstate(rootId); const scrollRef = useRef(null); @@ -164,6 +253,7 @@ function EstateTiers({ rootId }: { rootId: string }) { return ( + {tiers.map((tier) => ( ))} @@ -184,7 +274,7 @@ function EstateTiers({ rootId }: { rootId: string }) { /** The single navigator: every nav slot that lists rooms mounts this. */ export function EstateNavigator() { - const navigate = useNavigate(); + const openRoomFinder = useSetAtom(searchModalAtom); const { rootId, error } = useEstateRootId(); const mx = useMatrixClient(); const title = (rootId && mx.getRoom(rootId)?.name) || 'GameCult'; @@ -200,8 +290,8 @@ export function EstateNavigator() { navigate(getHomeSearchPath())} + aria-label="Find a room" + onClick={() => openRoomFinder(true)} > diff --git a/src/app/features/estate/buildEstate.test.ts b/src/app/features/estate/buildEstate.test.ts index 20129cbf..bdc47e5c 100644 --- a/src/app/features/estate/buildEstate.test.ts +++ b/src/app/features/estate/buildEstate.test.ts @@ -1,14 +1,26 @@ import { describe, expect, it } from 'vitest'; -import { buildEstate, EstateHierarchyRoom, EstateInput, stewardOfRoom } from './buildEstate'; +import { buildEstate, EstateHierarchyRoom, EstateInput, stewardOfChild } from './buildEstate'; const ROOT = '!root:gamecult.org'; const PROJECTS = '!projects:gamecult.org'; -const space = (id: string, name: string, children: string[]): EstateHierarchyRoom => ({ +/** A Space whose child entries may carry the steward Mind, as the accounts script writes it. */ +const space = ( + id: string, + name: string, + children: string[], + stewards: Record = {} +): EstateHierarchyRoom => ({ room_id: id, name, room_type: 'm.space', - children_state: children.map((c) => ({ state_key: c, content: { via: ['gamecult.org'] } })), + children_state: children.map((c) => ({ + state_key: c, + content: { + via: ['gamecult.org'], + ...(stewards[c] ? { 'org.gamecult.steward': { mind: stewards[c], repo: 'x' } } : {}), + }, + })), }); const room = (id: string, name: string, alias?: string): EstateHierarchyRoom => ({ room_id: id, @@ -19,22 +31,20 @@ const room = (id: string, name: string, alias?: string): EstateHierarchyRoom => const hierarchy = [ space(ROOT, 'GameCult', ['!general:gamecult.org', PROJECTS]), room('!general:gamecult.org', 'General', '#general:gamecult.org'), - space(PROJECTS, 'Projects', [ - '!cultlib:gamecult.org', - '!aeth:gamecult.org', - '!misc:gamecult.org', - ]), + space( + PROJECTS, + 'Projects', + ['!cultlib:gamecult.org', '!aeth:gamecult.org', '!misc:gamecult.org'], + { '!cultlib:gamecult.org': 'eureka', '!aeth:gamecult.org': 'void' } + ), room('!cultlib:gamecult.org', 'CultLib', '#cultlib:gamecult.org'), room('!aeth:gamecult.org', 'Aetheria', '#Aetheria:gamecult.org'), room('!misc:gamecult.org', 'Misc', '#misc:gamecult.org'), ]; -const stewardships = { eureka: ['GameCult/CultLib'], void: ['GameCult/aetheria'] }; - const input = (over: Partial = {}): EstateInput => ({ rootId: ROOT, hierarchy, - stewardships, membership: new Map(), directRoomIds: [], ...over, @@ -80,7 +90,7 @@ describe('buildEstate', () => { expect(tiers.flatMap((t) => [...t.loose, ...t.groups.flatMap((g) => g.rooms)])).toHaveLength(4); }); - it('groups rooms by steward Mind from stewardships plus canonical alias, case-insensitively', () => { + it('groups rooms by steward Mind from the steward on each room's Space child entry', () => { const projects = buildEstate(input()).find((t) => t.id === PROJECTS)!; expect(projects.groups.map((g) => [g.mind, g.rooms.map((r) => r.roomId)])).toEqual([ ['eureka', ['!cultlib:gamecult.org']], @@ -105,12 +115,35 @@ describe('buildEstate', () => { expect(projects.loose.map((r) => r.roomId)).toEqual(['!misc:gamecult.org']); }); - it('degrades to loose rows when the stewardships are unreadable', () => { - const projects = buildEstate(input({ stewardships: undefined })).find( - (t) => t.id === PROJECTS - )!; - expect(projects.groups).toEqual([]); - expect(projects.loose).toHaveLength(3); + it('groups an invited-only user by the same child-entry stewards, with no root state', () => { + const invitee = new Map([ + [ROOT, 'invite'], + [PROJECTS, 'invite'], + ['!cultlib:gamecult.org', 'invite'], + ['!aeth:gamecult.org', 'invite'], + ]); + const projects = buildEstate(input({ membership: invitee })).find((t) => t.id === PROJECTS)!; + expect(projects.groups.map((g) => [g.mind, g.rooms.map((r) => r.state)])).toEqual([ + ['eureka', ['invited']], + ['void', ['invited']], + ]); + }); + + it('ignores a steward that is not a non-empty string', () => { + const odd = (steward: unknown): EstateHierarchyRoom[] => [ + { + ...space(ROOT, 'GameCult', ['!a:gamecult.org']), + children_state: [ + { state_key: '!a:gamecult.org', content: { 'org.gamecult.steward': { mind: steward } } }, + ], + }, + room('!a:gamecult.org', 'A'), + ]; + [42, '', undefined, null].forEach((m) => { + const t = buildEstate(input({ hierarchy: odd(m) }))[0]; + expect(t.groups).toEqual([]); + expect(t.loose).toHaveLength(1); + }); }); it('makes one tier per sub-Space in hierarchy order, root rooms first, Direct last', () => { @@ -133,6 +166,7 @@ describe('buildEstate', () => { 'Workshop', 'Direct Messages', ]); + expect(tiers[3].loose[0].direct).toBe(true); expect(tiers[0].loose.map((r) => r.name)).toEqual(['General']); expect(tiers[3].loose[0]).toMatchObject({ roomId: '!dm:gamecult.org', parentId: null }); }); @@ -158,7 +192,20 @@ describe('buildEstate', () => { }); it('puts every room of one Mind in a single group', () => { - const tiers = buildEstate(input({ stewardships: { eureka: ['CultLib', 'Aetheria'] } })); + const tiers = buildEstate( + input({ + hierarchy: [ + ...hierarchy.slice(0, 2), + space( + PROJECTS, + 'Projects', + ['!cultlib:gamecult.org', '!aeth:gamecult.org'], + { '!cultlib:gamecult.org': 'eureka', '!aeth:gamecult.org': 'eureka' } + ), + ...hierarchy.slice(3, 5), + ], + }) + ); const projects = tiers.find((t) => t.id === PROJECTS)!; expect(projects.groups).toHaveLength(1); expect(projects.groups[0].rooms.map((r) => r.roomId)).toEqual([ @@ -202,15 +249,68 @@ describe('buildEstate', () => { }); }); -describe('stewardOfRoom', () => { - it('matches the alias localpart to the repo name, ignoring case and the org prefix', () => { - expect(stewardOfRoom('#CULTLIB:gamecult.org', stewardships)).toBe('eureka'); - expect(stewardOfRoom('#aetheria:gamecult.org', stewardships)).toBe('void'); +describe('stewardOfChild', () => { + it('reads the Mind from the org.gamecult.steward key of the child content', () => { + expect(stewardOfChild({ via: ['x'], 'org.gamecult.steward': { mind: 'void' } })).toBe('void'); }); - it('has no steward without an alias, a match or readable stewardships', () => { - expect(stewardOfRoom(undefined, stewardships)).toBeUndefined(); - expect(stewardOfRoom('#unknown:gamecult.org', stewardships)).toBeUndefined(); - expect(stewardOfRoom('#cultlib:gamecult.org', { eureka: 'GameCult/CultLib' })).toBeUndefined(); + it('has no steward without the key, content or a string Mind', () => { + expect(stewardOfChild(undefined)).toBeUndefined(); + expect(stewardOfChild({ via: ['x'] })).toBeUndefined(); + expect(stewardOfChild({ 'org.gamecult.steward': {} })).toBeUndefined(); + }); +}); + +describe('Elsewhere', () => { + const strays = (over: Partial) => + buildEstate(input({ membership: new Map([['!adhoc:x', 'join']]), ...over })).find( + (t) => t.id === 'elsewhere' + ); + + it('lists a joined non-DM room the hierarchy does not return, named from the store', () => { + const t = strays({ strayRoomIds: ['!adhoc:x'], roomNames: new Map([['!adhoc:x', 'Meeting']]) })!; + expect(t.label).toBe('Elsewhere'); + expect(t.loose).toEqual([ + { roomId: '!adhoc:x', name: 'Meeting', state: 'joined', via: [], parentId: null, direct: false }, + ]); + }); + + it('does not repeat a room the hierarchy lists or a DM, and is last before Direct', () => { + const tiers = buildEstate( + input({ + membership: new Map([ + ['!general:gamecult.org', 'join'], + ['!dm:x', 'join'], + ['!adhoc:x', 'join'], + ]), + strayRoomIds: ['!general:gamecult.org', '!dm:x', '!adhoc:x'], + directRoomIds: ['!dm:x'], + }) + ); + expect(tiers.map((t) => t.id)).toEqual([ROOT, PROJECTS, 'elsewhere', 'direct']); + expect(tiers[2].loose.map((r) => r.roomId)).toEqual(['!adhoc:x']); + }); + + it('never lists a room the user has not joined', () => { + expect(strays({ membership: new Map([['!adhoc:x', 'invite']]), strayRoomIds: ['!adhoc:x'] })).toBeUndefined(); + }); + + it('is absent when there are no strays', () => { + expect(strays({})).toBeUndefined(); + }); +}); + +describe('direct invites', () => { + it('lists an invited DM in the Direct tier as invited, named from the store', () => { + const tiers = buildEstate( + input({ + membership: new Map([['!dm:x', 'invite']]), + directRoomIds: ['!dm:x'], + roomNames: new Map([['!dm:x', 'Void']]), + }) + ); + expect(tiers[tiers.length - 1].loose).toEqual([ + { roomId: '!dm:x', name: 'Void', state: 'invited', via: [], parentId: null, direct: true }, + ]); }); }); diff --git a/src/app/features/estate/buildEstate.ts b/src/app/features/estate/buildEstate.ts index 9ca9048c..9edbf548 100644 --- a/src/app/features/estate/buildEstate.ts +++ b/src/app/features/estate/buildEstate.ts @@ -4,7 +4,12 @@ * * The server's /hierarchy of the root Space is the access rule. A room the * hierarchy does not return is never listed, whatever the client store knows; - * a room it returns is never hidden by the client. + * a room it returns is never hidden by the client. The one licensed addition is + * the user's own joined rooms that sit outside the Space (the Elsewhere tier). + * + * A room's steward is read from the hierarchy too: the content of the + * m.space.child entry that lists the room carries org.gamecult.steward, which an + * invited-only member can read (she cannot read the Space's state). */ export type EstateHierarchyRoom = { @@ -12,11 +17,16 @@ export type EstateHierarchyRoom = { name?: string; canonical_alias?: string; room_type?: string; - children_state?: { state_key: string; content?: { via?: string[] } }[]; + children_state?: { state_key: string; content?: SpaceChildContent }[]; }; -/** Mind localpart to the repos she stewards, as projected on the root Space. */ -export type Stewardships = Record; +export const STEWARD_KEY = 'org.gamecult.steward'; + +export type SpaceChildContent = { + via?: string[]; + /** The Mind who stewards the room, as the accounts script writes it. */ + [STEWARD_KEY]?: { mind?: unknown }; +}; export type EstateMembership = 'joined' | 'invited' | 'joinable'; @@ -26,8 +36,9 @@ export type EstateRoom = { state: EstateMembership; /** Servers to join through, from the hierarchy's children_state. */ via: string[]; - /** The Space tier the room sits in; null for direct messages. */ + /** The Space tier the room sits in; null for direct messages and Elsewhere rooms. */ parentId: string | null; + direct: boolean; }; export type EstateGroup = { @@ -48,38 +59,27 @@ export type EstateInput = { rootId: string; /** Rooms from the server's hierarchy of the root, in hierarchy order. */ hierarchy: readonly EstateHierarchyRoom[]; - stewardships: Stewardships | undefined; /** The user's own membership per room id, from the client store. */ membership: ReadonlyMap; + /** Joined and invited direct messages. */ directRoomIds: readonly string[]; + /** Joined non-DM, non-Space rooms, whether or not the hierarchy lists them. */ + strayRoomIds?: readonly string[]; + /** Names for rooms the hierarchy does not name (direct and stray rooms). */ + roomNames?: ReadonlyMap; /** Display names for Minds, keyed by localpart. */ mindLabels?: ReadonlyMap; }; export const DIRECT_TIER_ID = 'direct'; +export const ELSEWHERE_TIER_ID = 'elsewhere'; const SPACE_TYPE = 'm.space'; -const repoName = (repo: string): string => repo.split('/').pop()!.toLowerCase(); - -/** - * The single place a room's steward is derived: the canonical alias localpart, - * case-insensitively, against the repo names in the stewardships lists. - * (Follow-up ratatoskr:follow_up:stewardships-carry-room-ids swaps this to room ids.) - */ -export const stewardOfRoom = ( - canonicalAlias: string | undefined, - stewardships: Stewardships | undefined -): string | undefined => { - if (!canonicalAlias || !stewardships) return undefined; - const local = canonicalAlias.replace(/^#/, '').split(':')[0].toLowerCase(); - const mind = Object.keys(stewardships).find((m) => { - const repos = stewardships[m]; - return ( - Array.isArray(repos) && repos.some((r) => typeof r === 'string' && repoName(r) === local) - ); - }); - return mind; +/** The Mind a Space child entry names as the room's steward, if any. */ +export const stewardOfChild = (content: SpaceChildContent | undefined): string | undefined => { + const mind = content?.[STEWARD_KEY]?.mind; + return typeof mind === 'string' && mind !== '' ? mind : undefined; }; const stateOf = ( @@ -97,9 +97,10 @@ const roomLabel = (r: EstateHierarchyRoom): string => r.name || r.canonical_alia export const buildEstate = ({ rootId, hierarchy, - stewardships, membership, directRoomIds, + strayRoomIds = [], + roomNames, mindLabels, }: EstateInput): EstateTier[] => { const direct = new Set(directRoomIds); @@ -117,15 +118,16 @@ export const buildEstate = ({ hierarchy.forEach((r) => { if (r.room_type === SPACE_TYPE || !childIds.has(r.room_id) || direct.has(r.room_id)) return; - const via = space.children_state?.find((c) => c.state_key === r.room_id)?.content?.via ?? []; + const content = space.children_state?.find((c) => c.state_key === r.room_id)?.content; const room: EstateRoom = { roomId: r.room_id, name: roomLabel(r), state: stateOf(r.room_id, membership), - via, + via: content?.via ?? [], parentId: space.room_id, + direct: false, }; - const mind = stewardOfRoom(r.canonical_alias, stewardships); + const mind = stewardOfChild(content); if (!mind) { tier.loose.push(room); return; @@ -149,14 +151,36 @@ export const buildEstate = ({ label: 'Direct Messages', loose: directRoomIds.map((roomId) => ({ roomId, - name: roomId, + name: roomNames?.get(roomId) ?? roomId, state: stateOf(roomId, membership), via: [], parentId: null, + direct: true, })), groups: [], }; + const listedIds = new Set(hierarchy.map((r) => r.room_id)); + const elsewhereTier: EstateTier = { + id: ELSEWHERE_TIER_ID, + label: 'Elsewhere', + loose: strayRoomIds + .filter((id) => !listedIds.has(id) && !direct.has(id) && membership.get(id) === 'join') + .map((roomId) => ({ + roomId, + name: roomNames?.get(roomId) ?? roomId, + state: 'joined' as const, + via: [], + parentId: null, + direct: false, + })), + groups: [], + }; + const listed = tiers.filter((t) => t.loose.length > 0 || t.groups.length > 0); - return directTier.loose.length > 0 ? [...listed, directTier] : listed; + return [ + ...listed, + ...(elsewhereTier.loose.length > 0 ? [elsewhereTier] : []), + ...(directTier.loose.length > 0 ? [directTier] : []), + ]; }; diff --git a/src/app/features/estate/joinEstateRoom.test.ts b/src/app/features/estate/joinEstateRoom.test.ts index fae21e5b..9bfddf1a 100644 --- a/src/app/features/estate/joinEstateRoom.test.ts +++ b/src/app/features/estate/joinEstateRoom.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { activateEstateRoom } from './joinEstateRoom'; +import { activateEstateRoom, awaitMyJoin } from './joinEstateRoom'; const room = (state: 'joined' | 'invited' | 'joinable') => ({ roomId: '!r:gamecult.org', @@ -71,3 +71,55 @@ describe('activateEstateRoom', () => { expect(joinRoom).toHaveBeenCalledTimes(1); }); }); + +describe('awaitMyJoin', () => { + /** A store that gains the room when the test says so, emitting like the SDK. */ + const store = () => { + let membership: string | null = null; + const handlers = new Set<(room: { roomId: string }, m: string) => void>(); + return { + mx: { + getRoom: () => (membership ? { getMyMembership: () => membership! } : null), + on: (_e: string, h: (room: { roomId: string }, m: string) => void) => handlers.add(h), + off: (_e: string, h: (room: { roomId: string }, m: string) => void) => handlers.delete(h), + }, + handlers, + become: (roomId: string, m: string) => { + membership = m; + handlers.forEach((h) => h({ roomId }, m)); + }, + }; + }; + + it('resolves at once when the store already holds the joined room', async () => { + const s = store(); + s.become('!r:x', 'join'); + expect(await awaitMyJoin(s.mx, '!r:x', 1000)).toBe(true); + }); + + it('stays pending while the store still shows an invite, then resolves on join', async () => { + const s = store(); + let settled = false; + const p = awaitMyJoin(s.mx, '!r:x', 1000).then((v) => { + settled = true; + return v; + }); + s.become('!r:x', 'invite'); + s.become('!other:x', 'join'); + await Promise.resolve(); + expect(settled).toBe(false); + s.become('!r:x', 'join'); + expect(await p).toBe(true); + expect(s.handlers.size).toBe(0); + }); + + it('gives up after the timeout and stops listening', async () => { + vi.useFakeTimers(); + const s = store(); + const p = awaitMyJoin(s.mx, '!r:x', 500); + await vi.advanceTimersByTimeAsync(500); + expect(await p).toBe(false); + expect(s.handlers.size).toBe(0); + vi.useRealTimers(); + }); +}); diff --git a/src/app/features/estate/joinEstateRoom.ts b/src/app/features/estate/joinEstateRoom.ts index 2a5ed0d8..bec13126 100644 --- a/src/app/features/estate/joinEstateRoom.ts +++ b/src/app/features/estate/joinEstateRoom.ts @@ -28,3 +28,36 @@ export const activateEstateRoom = async ( return { kind: 'refused', message: err.data?.error ?? err.message ?? 'Could not join' }; } }; + +type MembershipSource = { + getRoom(roomId: string): { getMyMembership(): string } | null; + on(event: 'Room.myMembership', handler: (room: { roomId: string }, m: string) => void): unknown; + off(event: 'Room.myMembership', handler: (room: { roomId: string }, m: string) => void): unknown; +}; + +/** + * joinRoom resolves when the server accepts the join, before the client store + * holds the room; navigating then lands on the invite card. Resolves true once + * the store says the user's membership is join, false after timeoutMs. + */ +export const awaitMyJoin = ( + mx: MembershipSource, + roomId: string, + timeoutMs: number +): Promise => + new Promise((resolve) => { + if (mx.getRoom(roomId)?.getMyMembership() === 'join') { + resolve(true); + return; + } + const finish = (joined: boolean) => { + clearTimeout(timer); + mx.off('Room.myMembership', onMembership); + resolve(joined); + }; + const onMembership = (room: { roomId: string }, membership: string) => { + if (room.roomId === roomId && membership === 'join') finish(true); + }; + const timer = setTimeout(() => finish(false), timeoutMs); + mx.on('Room.myMembership', onMembership); + }); diff --git a/src/app/features/estate/useEstate.ts b/src/app/features/estate/useEstate.ts index 99927ba8..d27d4710 100644 --- a/src/app/features/estate/useEstate.ts +++ b/src/app/features/estate/useEstate.ts @@ -4,14 +4,13 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useClientConfig } from '../../hooks/useClientConfig'; import { useFetchSpaceHierarchyLevel } from '../../hooks/useSpaceHierarchy'; -import { useForceUpdate } from '../../hooks/useForceUpdate'; -import { getStateEvent } from '../../utils/room'; +import { isDirectInvite, isRoom, isUnsupportedRoom } from '../../utils/room'; import { useStateEventCallback } from '../../hooks/useStateEventCallback'; import { allRoomsAtom } from '../../state/room-list/roomList'; import { allInvitesAtom } from '../../state/room-list/inviteList'; import { mDirectAtom } from '../../state/mDirectList'; import { StateEvent } from '../../../types/matrix/room'; -import { buildEstate, EstateHierarchyRoom, EstateTier, Stewardships } from './buildEstate'; +import { buildEstate, EstateHierarchyRoom, EstateTier, stewardOfChild } from './buildEstate'; const HIERARCHY_DEPTH = 10; @@ -50,9 +49,9 @@ export type Estate = { }; /** - * Feeds buildEstate: the server hierarchy of the root Space, the root's - * stewardships from the client store, the user's membership and m.direct. - * Recomputes on membership, Space child and stewardships changes. + * Feeds buildEstate: the server hierarchy of the root Space (which also carries + * each room's steward), the user's membership, m.direct and direct invites, and + * her joined rooms outside the Space. Recomputes on membership and Space child changes. */ export const useEstate = (rootId: string): Estate => { const mx = useMatrixClient(); @@ -62,8 +61,6 @@ export const useEstate = (rootId: string): Estate => { const invited = useAtomValue(allInvitesAtom); const mDirects = useAtomValue(mDirectAtom); - const [stewardshipsVersion, bumpStewardships] = useForceUpdate(); - const refetchHierarchy = useCallback( () => queryClient.invalidateQueries({ queryKey: [rootId, 'hierarchy_level'] }), [queryClient, rootId] @@ -73,9 +70,8 @@ export const useEstate = (rootId: string): Estate => { useCallback( (event) => { if (event.getType() === StateEvent.SpaceChild) refetchHierarchy(); - if (event.getType() === StateEvent.GameCultStewardships) bumpStewardships(); }, - [refetchHierarchy, bumpStewardships] + [refetchHierarchy] ) ); // What the server lets this user see changes with invitations. @@ -83,38 +79,44 @@ export const useEstate = (rootId: string): Estate => { refetchHierarchy(); }, [invited.length, refetchHierarchy]); - const stewardships = useMemo( - () => { - const rootRoom = mx.getRoom(rootId); - return rootRoom - ? (getStateEvent(rootRoom, StateEvent.GameCultStewardships)?.getContent() as - | Stewardships - | undefined) - : undefined; - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [mx, rootId, stewardshipsVersion, joined, invited] - ); - const tiers = useMemo(() => { const membership = new Map(); invited.forEach((id) => membership.set(id, 'invite')); joined.forEach((id) => membership.set(id, 'join')); + const hierarchy = Array.from(rooms.values()) as unknown as EstateHierarchyRoom[]; const domain = mx.getDomain(); const mindLabels = new Map(); - Object.keys(stewardships ?? {}).forEach((mind) => { - const name = mx.getUser(`@${mind}:${domain}`)?.displayName; - if (name) mindLabels.set(mind, name); + hierarchy.forEach((r) => + r.children_state?.forEach((c) => { + const mind = stewardOfChild(c.content); + const name = mind && mx.getUser(`@${mind}:${domain}`)?.displayName; + if (mind && name) mindLabels.set(mind, name); + }) + ); + const userId = mx.getUserId(); + const directInvites = invited.filter((id) => isDirectInvite(mx.getRoom(id), userId)); + const directRoomIds = [ + ...new Set([...Array.from(mDirects).filter((id) => joined.includes(id)), ...directInvites]), + ]; + const strayRoomIds = joined.filter((id) => { + const r = mx.getRoom(id); + return !mDirects.has(id) && isRoom(r) && !isUnsupportedRoom(r); + }); + const roomNames = new Map(); + [...directInvites, ...strayRoomIds].forEach((id) => { + const name = mx.getRoom(id)?.name; + if (name) roomNames.set(id, name); }); return buildEstate({ rootId, - hierarchy: Array.from(rooms.values()) as unknown as EstateHierarchyRoom[], - stewardships, + hierarchy, membership, - directRoomIds: Array.from(mDirects).filter((id) => joined.includes(id)), + directRoomIds, + strayRoomIds, + roomNames, mindLabels, }); - }, [mx, rootId, rooms, stewardships, joined, invited, mDirects]); + }, [mx, rootId, rooms, joined, invited, mDirects]); return { fetching, error, tiers }; }; diff --git a/src/types/matrix/room.ts b/src/types/matrix/room.ts index 81d35bd0..c73c0f0b 100644 --- a/src/types/matrix/room.ts +++ b/src/types/matrix/room.ts @@ -36,7 +36,6 @@ export enum StateEvent { GroupCallMemberPrefix = 'org.matrix.msc3401.call.member', SpaceChild = 'm.space.child', - GameCultStewardships = 'org.gamecult.stewardships', SpaceParent = 'm.space.parent', PoniesRoomEmotes = 'im.ponies.room_emotes', From 1dfe38a33d2400e1b436fa49db08176ffb267789 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 17:48:31 +0100 Subject: [PATCH 015/206] Estate: lint order fixes, test title, harness out of prettier Co-Authored-By: Claude Sonnet 5.5 --- .prettierignore | 2 +- src/app/features/estate/EstateNavigator.tsx | 2 +- src/app/features/estate/buildEstate.test.ts | 2 +- src/app/features/estate/buildEstate.ts | 16 ++++++++-------- src/app/features/estate/joinEstateRoom.ts | 11 ++++++----- 5 files changed, 17 insertions(+), 16 deletions(-) diff --git a/.prettierignore b/.prettierignore index e0aa18a1..3a4d7da1 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,4 +2,4 @@ dist node_modules package.json package-lock.json -LICENSE \ No newline at end of file +LICENSEharness diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx index 90b3e529..1087c015 100644 --- a/src/app/features/estate/EstateNavigator.tsx +++ b/src/app/features/estate/EstateNavigator.tsx @@ -44,10 +44,10 @@ import { roomToUnreadAtom } from '../../state/room/roomToUnread'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { EstateRoom, EstateTier } from './buildEstate'; import { activateEstateRoom, awaitMyJoin } from './joinEstateRoom'; +import { useEstate, useEstateRootId } from './useEstate'; /** How long a tap waits for the client store to hold the room it just joined. */ const JOIN_ARRIVAL_TIMEOUT_MS = 20000; -import { useEstate, useEstateRootId } from './useEstate'; const estateRoomPath = (mx: ReturnType, room: EstateRoom): string => { const alias = getCanonicalAliasOrRoomId(mx, room.roomId); diff --git a/src/app/features/estate/buildEstate.test.ts b/src/app/features/estate/buildEstate.test.ts index bdc47e5c..29c58048 100644 --- a/src/app/features/estate/buildEstate.test.ts +++ b/src/app/features/estate/buildEstate.test.ts @@ -90,7 +90,7 @@ describe('buildEstate', () => { expect(tiers.flatMap((t) => [...t.loose, ...t.groups.flatMap((g) => g.rooms)])).toHaveLength(4); }); - it('groups rooms by steward Mind from the steward on each room's Space child entry', () => { + it('groups rooms by steward Mind from the steward on each Space child entry', () => { const projects = buildEstate(input()).find((t) => t.id === PROJECTS)!; expect(projects.groups.map((g) => [g.mind, g.rooms.map((r) => r.roomId)])).toEqual([ ['eureka', ['!cultlib:gamecult.org']], diff --git a/src/app/features/estate/buildEstate.ts b/src/app/features/estate/buildEstate.ts index 9edbf548..b9232beb 100644 --- a/src/app/features/estate/buildEstate.ts +++ b/src/app/features/estate/buildEstate.ts @@ -12,14 +12,6 @@ * invited-only member can read (she cannot read the Space's state). */ -export type EstateHierarchyRoom = { - room_id: string; - name?: string; - canonical_alias?: string; - room_type?: string; - children_state?: { state_key: string; content?: SpaceChildContent }[]; -}; - export const STEWARD_KEY = 'org.gamecult.steward'; export type SpaceChildContent = { @@ -28,6 +20,14 @@ export type SpaceChildContent = { [STEWARD_KEY]?: { mind?: unknown }; }; +export type EstateHierarchyRoom = { + room_id: string; + name?: string; + canonical_alias?: string; + room_type?: string; + children_state?: { state_key: string; content?: SpaceChildContent }[]; +}; + export type EstateMembership = 'joined' | 'invited' | 'joinable'; export type EstateRoom = { diff --git a/src/app/features/estate/joinEstateRoom.ts b/src/app/features/estate/joinEstateRoom.ts index bec13126..df5284e3 100644 --- a/src/app/features/estate/joinEstateRoom.ts +++ b/src/app/features/estate/joinEstateRoom.ts @@ -50,14 +50,15 @@ export const awaitMyJoin = ( resolve(true); return; } - const finish = (joined: boolean) => { - clearTimeout(timer); - mx.off('Room.myMembership', onMembership); - resolve(joined); - }; + let finish: (joined: boolean) => void = () => undefined; const onMembership = (room: { roomId: string }, membership: string) => { if (room.roomId === roomId && membership === 'join') finish(true); }; const timer = setTimeout(() => finish(false), timeoutMs); + finish = (joined) => { + clearTimeout(timer); + mx.off('Room.myMembership', onMembership); + resolve(joined); + }; mx.on('Room.myMembership', onMembership); }); From 6eae929cd9cb7f9d1b54b8d64333e0ffb020b875 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 17:50:52 +0100 Subject: [PATCH 016/206] Estate: narrow parentId before the Space route lookup; prettier; ignore harness Co-Authored-By: Claude Sonnet 5.5 --- .prettierignore | 3 ++- src/app/features/estate/EstateNavigator.tsx | 2 +- src/app/features/estate/buildEstate.test.ts | 28 ++++++++++++++------- 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/.prettierignore b/.prettierignore index 3a4d7da1..036ee8bc 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,4 +2,5 @@ dist node_modules package.json package-lock.json -LICENSEharness +LICENSE +harness diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx index 1087c015..f65ba827 100644 --- a/src/app/features/estate/EstateNavigator.tsx +++ b/src/app/features/estate/EstateNavigator.tsx @@ -53,7 +53,7 @@ const estateRoomPath = (mx: ReturnType, room: EstateRoom const alias = getCanonicalAliasOrRoomId(mx, room.roomId); if (room.direct) return getDirectRoomPath(alias); // A Space the user has not joined has no Space route; the room still resolves from Home. - if (mx.getRoom(room.parentId)?.getMyMembership() === 'join') { + if (room.parentId && mx.getRoom(room.parentId)?.getMyMembership() === 'join') { return getSpaceRoomPath(getCanonicalAliasOrRoomId(mx, room.parentId), alias); } return getHomeRoomPath(alias); diff --git a/src/app/features/estate/buildEstate.test.ts b/src/app/features/estate/buildEstate.test.ts index 29c58048..8f898a0f 100644 --- a/src/app/features/estate/buildEstate.test.ts +++ b/src/app/features/estate/buildEstate.test.ts @@ -196,12 +196,10 @@ describe('buildEstate', () => { input({ hierarchy: [ ...hierarchy.slice(0, 2), - space( - PROJECTS, - 'Projects', - ['!cultlib:gamecult.org', '!aeth:gamecult.org'], - { '!cultlib:gamecult.org': 'eureka', '!aeth:gamecult.org': 'eureka' } - ), + space(PROJECTS, 'Projects', ['!cultlib:gamecult.org', '!aeth:gamecult.org'], { + '!cultlib:gamecult.org': 'eureka', + '!aeth:gamecult.org': 'eureka', + }), ...hierarchy.slice(3, 5), ], }) @@ -268,10 +266,20 @@ describe('Elsewhere', () => { ); it('lists a joined non-DM room the hierarchy does not return, named from the store', () => { - const t = strays({ strayRoomIds: ['!adhoc:x'], roomNames: new Map([['!adhoc:x', 'Meeting']]) })!; + const t = strays({ + strayRoomIds: ['!adhoc:x'], + roomNames: new Map([['!adhoc:x', 'Meeting']]), + })!; expect(t.label).toBe('Elsewhere'); expect(t.loose).toEqual([ - { roomId: '!adhoc:x', name: 'Meeting', state: 'joined', via: [], parentId: null, direct: false }, + { + roomId: '!adhoc:x', + name: 'Meeting', + state: 'joined', + via: [], + parentId: null, + direct: false, + }, ]); }); @@ -292,7 +300,9 @@ describe('Elsewhere', () => { }); it('never lists a room the user has not joined', () => { - expect(strays({ membership: new Map([['!adhoc:x', 'invite']]), strayRoomIds: ['!adhoc:x'] })).toBeUndefined(); + expect( + strays({ membership: new Map([['!adhoc:x', 'invite']]), strayRoomIds: ['!adhoc:x'] }) + ).toBeUndefined(); }); it('is absent when there are no strays', () => { From 1cb18a279533afd1b73f4eb4df4404080d2dc646 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 17:55:38 +0100 Subject: [PATCH 017/206] harness: CommonJS scripts under a type-module package Co-Authored-By: Claude Sonnet 5.5 --- harness/estate/{drive.js => drive.cjs} | 0 harness/estate/{fakehs.js => fakehs.cjs} | 0 harness/estate/run.sh | 6 +++--- 3 files changed, 3 insertions(+), 3 deletions(-) rename harness/estate/{drive.js => drive.cjs} (100%) rename harness/estate/{fakehs.js => fakehs.cjs} (100%) diff --git a/harness/estate/drive.js b/harness/estate/drive.cjs similarity index 100% rename from harness/estate/drive.js rename to harness/estate/drive.cjs diff --git a/harness/estate/fakehs.js b/harness/estate/fakehs.cjs similarity index 100% rename from harness/estate/fakehs.js rename to harness/estate/fakehs.cjs diff --git a/harness/estate/run.sh b/harness/estate/run.sh index 085b17dc..b37d3524 100644 --- a/harness/estate/run.sh +++ b/harness/estate/run.sh @@ -7,9 +7,9 @@ npm ci --no-audit --no-fund --ignore-scripts >/out/npmci.log 2>&1 npm run build > /out/build.log 2>&1; echo BUILD_RC=$? >> /out/build.log printf '{"defaultHomeserver":0,"homeserverList":["127.0.0.1:8080"],"allowCustomHomeservers":true,"featuredCommunities":{"spaces":["#gamecult:test"],"rooms":[],"servers":["test"]},"hashRouter":{"enabled":true,"basename":"/"}}' > dist/config.json mkdir -p /tmp/pw && cd /tmp/pw && npm init -y >/dev/null && npm i --no-audit --no-fund playwright@1.48.2 >/out/pw.log 2>&1 -cp /src/harness/estate/drive.js /tmp/pw/ -HSLOG=/out/hslog.txt node /src/harness/estate/fakehs.js /src/dist > /out/hs.out 2>&1 & +cp /src/harness/estate/drive.cjs /tmp/pw/ +HSLOG=/out/hslog.txt node /src/harness/estate/fakehs.cjs /src/dist > /out/hs.out 2>&1 & HS=$! sleep 2 -node /tmp/pw/drive.js; echo DRIVE_RC=$? +node /tmp/pw/drive.cjs; echo DRIVE_RC=$? kill $HS From 11f0a4d325ae85152605a230eb541a36e393869c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 17:58:01 +0100 Subject: [PATCH 018/206] harness: reload instead of dismissing the address prompt Co-Authored-By: Claude Sonnet 5.5 --- harness/estate/drive.cjs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/harness/estate/drive.cjs b/harness/estate/drive.cjs index 674cb15e..126781f3 100644 --- a/harness/estate/drive.cjs +++ b/harness/estate/drive.cjs @@ -24,9 +24,9 @@ const check = (ok, what) => { tl.push(`${ok ? 'PASS' : 'FAIL'} ${what}`); if (!o await pg.getByText('Join with Address').first().click(); await pg.waitForTimeout(400); check((await pg.locator('input[name="addressInput"]').count()) === 1, 'Join with Address opens its prompt'); - await pg.keyboard.press('Escape'); - await pg.waitForTimeout(300); - if (await pg.locator('input[name="addressInput"]').count()) await pg.getByText('Cancel').first().click(); + await pg.reload(); + await pg.getByText('Create Room').first().waitFor({ timeout: 30000 }); + await pg.waitForTimeout(2000); // Header search is the room finder, on a phone await pg.locator('[aria-label="Find a room"]').first().click(); From d07c7f2327810084335b02f8cd7612bd5e0c2a9b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 17:59:53 +0100 Subject: [PATCH 019/206] harness: expand Elsewhere and Direct only when still collapsed Co-Authored-By: Claude Sonnet 5.5 --- harness/estate/drive.cjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/harness/estate/drive.cjs b/harness/estate/drive.cjs index 126781f3..6560b4e8 100644 --- a/harness/estate/drive.cjs +++ b/harness/estate/drive.cjs @@ -38,6 +38,8 @@ const check = (ok, what) => { tl.push(`${ok ? 'PASS' : 'FAIL'} ${what}`); if (!o // Groups for an invitee, from the hierarchy alone; Elsewhere; invited DM for (const n of ['Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']) await open(n); + // A tier that was not rendered yet when its header was tapped is still collapsed. + if (!(await text()).toLowerCase().includes('meeting')) await open('Elsewhere'); await snap('expanded'); const t1 = (await text()).toLowerCase(); check(t1.includes('nibu') && t1.includes('eureka'), 'steward groups render for an invitee who cannot read Space state'); @@ -61,7 +63,8 @@ const check = (ok, what) => { tl.push(`${ok ? 'PASS' : 'FAIL'} ${what}`); if (!o // Join the DM: lands in the room too await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(3000); - await open('Direct Messages'); + if (!(await pg.locator('[aria-label="Join Void"]').count())) await open('Direct Messages'); + check((await pg.locator('[aria-label="Join Void"]').count()) === 1, 'the DM invite is still listed after the other join'); if (await pg.locator('[aria-label="Join Void"]').count()) { await pg.locator('[aria-label="Join Void"]').first().click(); let dmCard = 0; const t2 = Date.now(); From a058d2a2c6e2b075d3549f1d072d304d989d7a75 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 18:01:32 +0100 Subject: [PATCH 020/206] harness: the fake server echoes account data in the next sync Co-Authored-By: Claude Sonnet 5.5 --- harness/estate/fakehs.cjs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/harness/estate/fakehs.cjs b/harness/estate/fakehs.cjs index 641a21f4..f528f903 100644 --- a/harness/estate/fakehs.cjs +++ b/harness/estate/fakehs.cjs @@ -12,6 +12,7 @@ const DM = '!dm:test', MEETING = '!meeting:test'; const PROJ = P.map((p, i) => ({ id: `!p${i}:test`, alias: `#${p}:test`, name: p[0].toUpperCase() + p.slice(1) })); const ROOT = '!root:test', PSP = '!proj:test', GEN = '!gen:test', OP = '!op:test'; const pendingJoin = []; +const pendingAccountData = []; let ev = 0; const se = (type, content, sk = '', sender = '@admin:test') => ({ type, content, state_key: sk, sender, event_id: `$e${++ev}`, origin_server_ts: Date.now() }); const strip = (id, name, space, dm) => [ @@ -75,6 +76,7 @@ http.createServer((req, res) => { log.push(`${Date.now()} SYNC delivers join ${id}`); return json(res, 200, { next_batch: `s${++syncN}`, rooms: { join, invite: {}, leave: {} } }); } + if (pendingAccountData.length) return json(res, 200, { next_batch: `s${++syncN}`, rooms: {}, account_data: { events: pendingAccountData.splice(0) } }); return json(res, 200, { next_batch: since, rooms: {} }); }; return setTimeout(wait, pendingJoin.length ? 2500 : 1500); @@ -94,6 +96,12 @@ http.createServer((req, res) => { if (p.includes('/room_keys/version')) return json(res, 404, { errcode: 'M_NOT_FOUND', error: 'none' }); if (p.includes('/pushrules')) return json(res, 200, { global: { override: [], underride: [], room: [], sender: [], content: [] } }); if (p.includes('/filter')) return json(res, 200, { filter_id: '1' }); + if (p.includes('/account_data/') && req.method === 'PUT') { + const type = decodeURIComponent(p.split('/account_data/')[1]); + try { pendingAccountData.push({ type, content: JSON.parse(body) }); } catch (e) { /* ignore */ } + log.push(`${Date.now()} PUT account_data ${type}`); + return json(res, 200, {}); + } if (p.includes('/account_data/')) return req.method === 'GET' ? json(res, 404, { errcode: 'M_NOT_FOUND', error: 'none' }) : json(res, 200, {}); if (p.includes('/messages')) return json(res, 200, { chunk: [], start: 't0', end: 't0' }); if (p.includes('/members')) return json(res, 200, { chunk: [] }); From 0091e5e90d73d57a981d2b26a3a9eebd99d4c572 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 18:26:18 +0100 Subject: [PATCH 021/206] harness: Soul's s2 scenarios (unresolved root, slow DM and room joins, tap-target inventory) --- harness/estate/drive.cjs | 135 +++++++++++++++++++------------------- harness/estate/fakehs.cjs | 89 +++++++++++++------------ harness/estate/run.sh | 15 +++-- 3 files changed, 123 insertions(+), 116 deletions(-) diff --git a/harness/estate/drive.cjs b/harness/estate/drive.cjs index 6560b4e8..0dd254de 100644 --- a/harness/estate/drive.cjs +++ b/harness/estate/drive.cjs @@ -1,79 +1,82 @@ -// Drives the built client against fakehs.js on a phone viewport and asserts the estate navigator. const { chromium } = require('playwright'); const fs = require('fs'); -const out = (n) => `/out/${n}`; +const TAG = process.env.TAG; +const out = (n) => `/out/${TAG}-${n}`; const tl = []; -const fails = []; -const check = (ok, what) => { tl.push(`${ok ? 'PASS' : 'FAIL'} ${what}`); if (!ok) fails.push(what); }; +const T0 = Date.now(); +const t = () => `+${Date.now() - T0}ms`; +const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']; (async () => { const b = await chromium.launch(); const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, deviceScaleFactor: 1 }); const pg = await ctx.newPage(); + pg.on('console', (m) => { if (m.type() === 'error' && !/OIDC|issuer|endpoint|supported|ERR_NAME|404/.test(m.text())) tl.push('console.error ' + m.text().slice(0, 200)); }); await pg.goto('http://127.0.0.1:8080/'); await pg.evaluate(() => { localStorage.setItem('cinny_access_token', 'tok'); localStorage.setItem('cinny_device_id', 'DEV'); localStorage.setItem('cinny_user_id', '@t:test'); localStorage.setItem('cinny_hs_base_url', 'http://127.0.0.1:8080'); }); await pg.goto('http://127.0.0.1:8080/#/home/'); - await pg.getByText('Create Room').first().waitFor({ timeout: 30000 }); - await pg.waitForTimeout(2000); + await pg.waitForTimeout(12000); const text = () => pg.evaluate(() => document.body.innerText); - const snap = async (n) => { await pg.screenshot({ path: out(n + '.png') }); }; - const open = async (name) => { const h = pg.getByText(name, { exact: true }); if (await h.count()) { await h.first().click(); await pg.waitForTimeout(300); } }; - - // Entry points - const t0 = await text(); - check(/Create Room/.test(t0) && /Join with Address/.test(t0), 'Create Room and Join with Address are navigator actions'); - await pg.getByText('Join with Address').first().click(); - await pg.waitForTimeout(400); - check((await pg.locator('input[name="addressInput"]').count()) === 1, 'Join with Address opens its prompt'); - await pg.reload(); - await pg.getByText('Create Room').first().waitFor({ timeout: 30000 }); - await pg.waitForTimeout(2000); - - // Header search is the room finder, on a phone - await pg.locator('[aria-label="Find a room"]').first().click(); - await pg.waitForTimeout(500); - check((await pg.locator('input[placeholder="Search"]').count()) === 1 && !/#\/home\/search/.test(pg.url()), 'header search opens the room finder, not message search'); - await snap('finder'); - await pg.keyboard.press('Escape'); - await pg.waitForTimeout(300); - - // Groups for an invitee, from the hierarchy alone; Elsewhere; invited DM - for (const n of ['Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']) await open(n); - // A tier that was not rendered yet when its header was tapped is still collapsed. - if (!(await text()).toLowerCase().includes('meeting')) await open('Elsewhere'); - await snap('expanded'); - const t1 = (await text()).toLowerCase(); - check(t1.includes('nibu') && t1.includes('eureka'), 'steward groups render for an invitee who cannot read Space state'); - check(t1.includes('elsewhere') && t1.includes('meeting'), 'a joined room outside the Space is listed under Elsewhere'); - check(t1.includes('direct messages') && (await pg.locator('[aria-label="Join Void"]').count()) === 1, 'an invited DM is listed with Join'); - - // Join: no invite card at any frame, then the room - await pg.locator('[aria-label="Join Aetheria"]').first().click(); - const t = Date.now(); - let card = 0, room = null; - while (Date.now() - t < 6000) { - const body = await text(); - if (/Summary card/.test(body)) card++; - if (room === null && /welcome to Aetheria/.test(body)) room = Date.now() - t; - await pg.waitForTimeout(40); - } - await snap('joined'); - check(card === 0, `no invite card at any frame after the tap (saw ${card})`); - check(room !== null, `the room opened (after ${room} ms)`); - - // Join the DM: lands in the room too - await pg.goto('http://127.0.0.1:8080/#/home/'); - await pg.waitForTimeout(3000); - if (!(await pg.locator('[aria-label="Join Void"]').count())) await open('Direct Messages'); - check((await pg.locator('[aria-label="Join Void"]').count()) === 1, 'the DM invite is still listed after the other join'); - if (await pg.locator('[aria-label="Join Void"]').count()) { - await pg.locator('[aria-label="Join Void"]').first().click(); - let dmCard = 0; const t2 = Date.now(); - while (Date.now() - t2 < 5000) { if (/Summary card/.test(await text())) dmCard++; await pg.waitForTimeout(40); } - check(dmCard === 0 && /\/direct\//.test(pg.url()), 'the invited DM opens in Direct with no invite card'); + const snap = async (n) => { await pg.screenshot({ path: out(n + '.png') }); tl.push(`--- ${n} ${t()} url=${pg.url()}\n${await text()}`); }; + const buttons = async () => tl.push('buttons ' + JSON.stringify(await pg.evaluate(() => [...document.querySelectorAll('button')].map((e) => { const r = e.getBoundingClientRect(); return [e.getAttribute('aria-label') || e.innerText.slice(0, 30), Math.round(r.x), Math.round(r.y), Math.round(r.width), Math.round(r.height)]; })))); + const small = async () => tl.push('SMALL ' + JSON.stringify(await pg.evaluate(() => [...document.querySelectorAll('button, a, [role="button"]')].filter((e) => e.getClientRects().length).map((e) => [e.getAttribute('aria-label') || e.innerText.slice(0, 30), Math.round(e.getBoundingClientRect().width), Math.round(e.getBoundingClientRect().height)]).filter((r) => r[2] < 44)))); + const layout = async () => tl.push('layout ' + JSON.stringify(await pg.evaluate(() => ({ vw: innerWidth, scrollW: document.documentElement.scrollWidth, bodyScrollW: document.body.scrollWidth })))); + await snap('1-landing'); await buttons(); await layout(); await small(); + if (process.env.NOROOT === '1') { + tl.push('has Create Room: ' + (await pg.getByText('Create Room', { exact: true }).count()) + ' Join with Address: ' + (await pg.getByText('Join with Address', { exact: true }).count()) + ' finder: ' + (await pg.locator('[aria-label="Find a room"]').count())); + await b.close(); return; } + const reveal = async (sel) => { + const loc = pg.locator(sel); + if (await loc.count()) return loc; + for (let round = 0; round < 2; round++) for (const h of HEADERS) { + const hl = pg.getByText(h, { exact: true }); + if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); if (await loc.count()) { tl.push(`revealed ${sel} via ${h}`); return loc; } } + } + tl.push(`could not reveal ${sel}`); return loc; + }; + // expand everything once for the inventory + for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); tl.push('clicked header ' + h); } } + await snap('2-expanded'); await buttons(); await layout(); await small(); + // room finder + const finder = pg.locator('[aria-label="Find a room"], [aria-label="Search"]').first(); + await finder.click(); await pg.waitForTimeout(800); + tl.push('after finder url=' + pg.url() + ' dialogs=' + (await pg.locator('[role="dialog"]').count()) + ' inputs=' + JSON.stringify(await pg.evaluate(() => [...document.querySelectorAll('input')].map((i) => i.placeholder)))); + await snap('3-finder'); + await pg.keyboard.press('Escape'); await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(3000); + // join with frame poll + const joinPoll = async (name, ms) => { + const loc = await reveal(`[aria-label="Join ${name}"]`); + if (!(await loc.count())) { tl.push(`no Join ${name}`); return; } + const u0 = pg.url(); await loc.first().click(); const c = Date.now(); tl.push(`CLICK Join ${name} ${t()}`); + let card = 0, frames = 0, urlAt = null, firstCardAt = null; + while (Date.now() - c < ms) { + const s = await text(); frames++; + if (s.includes('Summary card')) { card++; if (firstCardAt === null) firstCardAt = Date.now() - c; } + if (urlAt === null && pg.url() !== u0) urlAt = Date.now() - c; + await pg.waitForTimeout(40); + } + tl.push(`POLL ${name}: frames=${frames} cardFrames=${card} firstCardAt=${firstCardAt} urlChangedAt=${urlAt} url=${pg.url()}`); + await snap(`4-join-${name.replace(/\W/g, '')}`); + await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2500); + }; + await joinPoll('Commons', 6000); + await joinPoll('Aetheria', 6000); + await joinPoll('Friend DM', 6000); + // timeout path, room + const slow = async (name) => { + const loc = await reveal(`[aria-label="Join ${name}"]`); + if (!(await loc.count())) { tl.push(`no Join ${name}`); return; } + await loc.first().click(); tl.push(`CLICK Join ${name} ${t()}`); + await pg.waitForTimeout(21500); await snap(`5-slow-${name.replace(/\W/g, '')}-21s`); + tl.push(`timeout msg shown: ${(await text()).includes('has not arrived yet')}`); + await pg.waitForTimeout(5500); await snap(`5-slow-${name.replace(/\W/g, '')}-27s`); + tl.push(`after arrival: Join ${name} still present=${await pg.locator(`[aria-label="Join ${name}"]`).count()} url=${pg.url()}`); + }; + await slow('Odin'); + await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2500); + await slow('Slow DM'); + // wide: tier placement (wide tiers start open) + await pg.setViewportSize({ width: 1280, height: 800 }); + await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(6000); await snap('6-wide'); await b.close(); -})().catch((e) => { tl.push('ERROR ' + String(e.stack || e)); fails.push('error'); }).finally(() => { - fs.writeFileSync(out('timeline.txt'), tl.join('\n')); - console.log(tl.join('\n')); - process.exitCode = fails.length ? 1 : 0; -}); +})().catch((e) => { tl.push('ERROR ' + String(e.stack || e)); process.exitCode = 1; }).finally(() => fs.writeFileSync(out('timeline.txt'), tl.join('\n'))); diff --git a/harness/estate/fakehs.cjs b/harness/estate/fakehs.cjs index f528f903..3b9ed641 100644 --- a/harness/estate/fakehs.cjs +++ b/harness/estate/fakehs.cjs @@ -1,26 +1,26 @@ -// Fake Matrix homeserver + static server for the built client (harness/estate/run.sh). -// Scenario: an invitee to the GameCult Space and 6 project rooms (never a member of the -// Space, so the client cannot read its state), joined to #general and to #meeting, a room -// outside the Space, and invited to a DM. The steward of each project room is carried only -// in the m.space.child content of the hierarchy, as the accounts script writes it. +// Soul s2 fake homeserver. Invitee with no root state; stewards only in m.space.child content. +// Rooms: GEN joined; OP invited; 6 project rooms invited (odin slow: join arrives at +24 s); +// PUB public joinable, not in store; STRAY joined outside hierarchy; DM1 invited direct (fast); +// DM2 invited direct (slow, +24 s). NOROOT=1: the root alias does not resolve. const http = require('http'), fs = require('fs'), path = require('path'); const DIST = process.argv[2], PORT = 8080, ME = '@t:test'; +const NOROOT = process.env.NOROOT === '1'; const log = []; const P = ['aetheria', 'ghostlight', 'huginn', 'idunn', 'bifrost', 'odin']; const STEWARD = { aetheria: 'nibu', ghostlight: 'nibu', huginn: 'eureka', idunn: 'eureka' }; -const DM = '!dm:test', MEETING = '!meeting:test'; -const PROJ = P.map((p, i) => ({ id: `!p${i}:test`, alias: `#${p}:test`, name: p[0].toUpperCase() + p.slice(1) })); -const ROOT = '!root:test', PSP = '!proj:test', GEN = '!gen:test', OP = '!op:test'; -const pendingJoin = []; -const pendingAccountData = []; -let ev = 0; +const PROJ = P.map((p, i) => ({ id: `!p${i}:test`, alias: `#${p}:test`, name: p[0].toUpperCase() + p.slice(1), key: p })); +const ROOT = '!root:test', PSP = '!proj:test', GEN = '!gen:test', OP = '!op:test', PUB = '!pub:test', STRAY = '!stray:test', DM1 = '!dm1:test', DM2 = '!dm2:test'; +const SLOW = new Set([PROJ[5].id, DM2]); +const NAMES = { [PUB]: 'Commons', [DM1]: 'Friend DM', [DM2]: 'Slow DM', [STRAY]: 'Adhoc' }; +PROJ.forEach((r) => (NAMES[r.id] = r.name)); +const pending = []; const pendingAD = []; let ev = 0; const se = (type, content, sk = '', sender = '@admin:test') => ({ type, content, state_key: sk, sender, event_id: `$e${++ev}`, origin_server_ts: Date.now() }); -const strip = (id, name, space, dm) => [ - { type: 'm.room.create', state_key: '', sender: '@admin:test', content: space ? { type: 'm.space', room_version: '12' } : { room_version: '12' } }, - ...(name ? [{ type: 'm.room.name', state_key: '', sender: '@admin:test', content: { name } }] : []), - ...(dm ? [{ type: 'm.room.member', state_key: '@void:test', sender: '@void:test', content: { membership: 'join', displayname: 'Void' } }] : []), +const strip = (id, name, space, direct) => [ + { type: 'm.room.create', state_key: '', sender: direct ? '@friend:test' : '@admin:test', content: space ? { type: 'm.space', room_version: '12' } : { room_version: '12' } }, + { type: 'm.room.name', state_key: '', sender: '@admin:test', content: { name } }, { type: 'm.room.join_rules', state_key: '', sender: '@admin:test', content: { join_rule: 'invite' } }, - { type: 'm.room.member', state_key: ME, sender: dm ? '@void:test' : '@admin:test', content: dm ? { membership: 'invite', is_direct: true } : { membership: 'invite' } }, + ...(direct ? [{ type: 'm.room.member', state_key: '@friend:test', sender: '@friend:test', content: { membership: 'join', displayname: 'Friend' } }] : []), + { type: 'm.room.member', state_key: ME, sender: direct ? '@friend:test' : '@admin:test', content: direct ? { membership: 'invite', is_direct: true } : { membership: 'invite' } }, ]; const joinedRoom = (id, name, extra = [], space = false) => ({ state: { events: [se('m.room.create', space ? { type: 'm.space', room_version: '12' } : { room_version: '12' }), se('m.room.name', { name }), se('m.room.join_rules', { join_rule: 'invite' }), se('m.room.member', { membership: 'join' }, ME, ME), ...extra] }, @@ -33,16 +33,18 @@ const invites = () => { o[PSP] = { invite_state: { events: strip(PSP, 'Projects', true) } }; o[OP] = { invite_state: { events: strip(OP, 'Operator') } }; PROJ.forEach((r) => (o[r.id] = { invite_state: { events: strip(r.id, r.name) } })); - o[DM] = { invite_state: { events: strip(DM, null, false, true) } }; + o[DM1] = { invite_state: { events: strip(DM1, NAMES[DM1], false, true) } }; + o[DM2] = { invite_state: { events: strip(DM2, NAMES[DM2], false, true) } }; return o; }; -const child = (id, alias) => ({ type: 'm.space.child', state_key: id, content: { via: ['test'], ...(STEWARD[alias] ? { 'org.gamecult.steward': { repo: 'GameCult/' + alias, steward: '@' + STEWARD[alias] + ':test', mind: STEWARD[alias], source: 'x' } } : {}) }, sender: '@admin:test', origin_server_ts: 1 }); +const child = (id, steward) => ({ type: 'm.space.child', state_key: id, content: { via: ['test'], ...(steward ? { 'org.gamecult.steward': { mind: steward, repo: 'x', source: 'y', steward: 'z' } } : {}) }, sender: '@admin:test', origin_server_ts: 1 }); const hroom = (id, name, alias, extra = {}) => ({ room_id: id, name, canonical_alias: alias, num_joined_members: 1, world_readable: false, guest_can_join: false, join_rule: 'invite', children_state: [], ...extra }); const hierarchy = () => [ - hroom(ROOT, 'GameCult', '#gamecult:test', { room_type: 'm.space', children_state: [child(GEN), child(OP), child(PSP)] }), + hroom(ROOT, 'GameCult', '#gamecult:test', { room_type: 'm.space', children_state: [child(GEN), child(OP), child(PUB), child(PSP)] }), hroom(GEN, 'General', '#general:test'), hroom(OP, 'Operator', '#operator:test'), - hroom(PSP, 'Projects', '#projects:test', { room_type: 'm.space', children_state: PROJ.map((r) => child(r.id, r.alias.slice(1).split(':')[0])) }), + hroom(PUB, 'Commons', '#commons:test', { join_rule: 'public' }), + hroom(PSP, 'Projects', '#projects:test', { room_type: 'm.space', children_state: PROJ.map((r) => child(r.id, STEWARD[r.key])) }), ...PROJ.map((r) => hroom(r.id, r.name, r.alias)), ]; let syncN = 1; @@ -59,50 +61,47 @@ http.createServer((req, res) => { return fs.createReadStream(f).pipe(res); } let body = ''; req.on('data', (c) => (body += c)); req.on('end', () => { - log.push(`${Date.now()} ${req.method} ${p}`); + log.push(`${Date.now()} ${req.method} ${p}${req.method === 'PUT' && p.includes('account_data') ? ' BODY ' + body : ''}`); if (p === '/_matrix/client/versions') return json(res, 200, { versions: ['v1.1', 'v1.5', 'v1.11'], unstable_features: {} }); if (p.endsWith('/sync')) { - if (!u.searchParams.get('since')) log.push(`${Date.now()} INITIAL SYNC`); const since = u.searchParams.get('since'); if (!since) { - const join = {}; join[GEN] = joinedRoom(GEN, 'General'); join[MEETING] = joinedRoom(MEETING, 'Meeting'); - const inv = invites(); - return json(res, 200, { next_batch: 's1', rooms: { join, invite: inv, leave: {} }, account_data: { events: [] }, presence: { events: [] }, to_device: { events: [] }, device_one_time_keys_count: { signed_curve25519: 50 } }); + const join = {}; join[GEN] = joinedRoom(GEN, 'General'); join[STRAY] = joinedRoom(STRAY, 'Adhoc'); + return json(res, 200, { next_batch: 's1', rooms: { join, invite: invites(), leave: {} }, account_data: { events: [] }, presence: { events: [] }, to_device: { events: [] }, device_one_time_keys_count: { signed_curve25519: 50 } }); } - const wait = () => { - if (pendingJoin.length) { - const id = pendingJoin.shift(); const r = PROJ.find((x) => x.id === id) || { name: id === DM ? 'Void' : id }; - const join = {}; join[id] = joinedRoom(id, r.name); - log.push(`${Date.now()} SYNC delivers join ${id}`); - return json(res, 200, { next_batch: `s${++syncN}`, rooms: { join, invite: {}, leave: {} } }); + const respond = () => { + const now = Date.now(); const join = {}; + for (let i = pending.length - 1; i >= 0; i--) if (pending[i].at <= now) { + const id = pending[i].id; pending.splice(i, 1); + const extra = id === DM1 || id === DM2 ? [se('m.room.member', { membership: 'join', displayname: 'Friend' }, '@friend:test', '@friend:test')] : []; + join[id] = joinedRoom(id, NAMES[id] || id, extra); log.push(`${now} SYNC delivers join ${id}`); } - if (pendingAccountData.length) return json(res, 200, { next_batch: `s${++syncN}`, rooms: {}, account_data: { events: pendingAccountData.splice(0) } }); + const ad = pendingAD.splice(0); + if (Object.keys(join).length || ad.length) return json(res, 200, { next_batch: `s${++syncN}`, rooms: { join, invite: {}, leave: {} }, account_data: { events: ad } }); return json(res, 200, { next_batch: since, rooms: {} }); }; - return setTimeout(wait, pendingJoin.length ? 2500 : 1500); + return setTimeout(respond, 300); } const jm = p.match(/\/rooms\/([^/]+)\/join$/) || p.match(/\/join\/([^/]+)$/); if (req.method === 'POST' && jm) { - const id = jm[1]; - log.push(`${Date.now()} JOIN ${id}`); + const id = jm[1]; log.push(`${Date.now()} JOIN ${id}`); if (id === PROJ[1].id) return json(res, 403, { errcode: 'M_FORBIDDEN', error: 'You are not invited to this room.' }); - pendingJoin.push(id); + pending.push({ id, at: Date.now() + (SLOW.has(id) ? 24000 : 1000) }); return json(res, 200, { room_id: id }); } + if (p.includes('/account_data/') && req.method === 'PUT') { + const type = decodeURIComponent(p.split('/account_data/')[1]); + try { pendingAD.push({ type, content: JSON.parse(body) }); } catch (e) {} + return json(res, 200, {}); + } if (p.includes('/hierarchy')) return json(res, 200, { rooms: hierarchy() }); - if (p.includes('/directory/room/')) return json(res, 200, { room_id: ROOT, servers: ['test'] }); + if (p.includes('/directory/room/')) return NOROOT ? json(res, 404, { errcode: 'M_NOT_FOUND', error: 'Room alias not found' }) : json(res, 200, { room_id: ROOT, servers: ['test'] }); if (p.endsWith('/keys/upload')) return json(res, 200, { one_time_key_counts: { signed_curve25519: 50 } }); if (p.endsWith('/keys/query')) return json(res, 200, { device_keys: {}, failures: {} }); if (p.includes('/room_keys/version')) return json(res, 404, { errcode: 'M_NOT_FOUND', error: 'none' }); if (p.includes('/pushrules')) return json(res, 200, { global: { override: [], underride: [], room: [], sender: [], content: [] } }); if (p.includes('/filter')) return json(res, 200, { filter_id: '1' }); - if (p.includes('/account_data/') && req.method === 'PUT') { - const type = decodeURIComponent(p.split('/account_data/')[1]); - try { pendingAccountData.push({ type, content: JSON.parse(body) }); } catch (e) { /* ignore */ } - log.push(`${Date.now()} PUT account_data ${type}`); - return json(res, 200, {}); - } - if (p.includes('/account_data/')) return req.method === 'GET' ? json(res, 404, { errcode: 'M_NOT_FOUND', error: 'none' }) : json(res, 200, {}); + if (p.includes('/account_data/')) return json(res, 404, { errcode: 'M_NOT_FOUND', error: 'none' }); if (p.includes('/messages')) return json(res, 200, { chunk: [], start: 't0', end: 't0' }); if (p.includes('/members')) return json(res, 200, { chunk: [] }); if (p.includes('/profile/')) return json(res, 200, { displayname: 'Tess' }); @@ -113,4 +112,4 @@ http.createServer((req, res) => { return json(res, 200, {}); }); }).listen(PORT, () => console.log('fakehs up')); -setInterval(flush, 500); +setInterval(flush, 300); diff --git a/harness/estate/run.sh b/harness/estate/run.sh index b37d3524..af8bb5db 100644 --- a/harness/estate/run.sh +++ b/harness/estate/run.sh @@ -1,5 +1,6 @@ # Run inside playwright:v1.48.2-jammy with the repo at /src and /out writable: # bash /src/harness/estate/run.sh +# Scenario A: root resolves. Scenario B (NOROOT=1): the root alias 404s. set -e cd /src mkdir -p /out @@ -8,8 +9,12 @@ npm run build > /out/build.log 2>&1; echo BUILD_RC=$? >> /out/build.log printf '{"defaultHomeserver":0,"homeserverList":["127.0.0.1:8080"],"allowCustomHomeservers":true,"featuredCommunities":{"spaces":["#gamecult:test"],"rooms":[],"servers":["test"]},"hashRouter":{"enabled":true,"basename":"/"}}' > dist/config.json mkdir -p /tmp/pw && cd /tmp/pw && npm init -y >/dev/null && npm i --no-audit --no-fund playwright@1.48.2 >/out/pw.log 2>&1 cp /src/harness/estate/drive.cjs /tmp/pw/ -HSLOG=/out/hslog.txt node /src/harness/estate/fakehs.cjs /src/dist > /out/hs.out 2>&1 & -HS=$! -sleep 2 -node /tmp/pw/drive.cjs; echo DRIVE_RC=$? -kill $HS +runone () { # tag noroot + HSLOG=/out/hslog-$1.txt NOROOT=$2 node /src/harness/estate/fakehs.cjs /src/dist > /out/hs-$1.out 2>&1 & + HS=$! + sleep 2 + TAG=$1 NOROOT=$2 node /tmp/pw/drive.cjs; echo "DRIVE $1 RC=$?" + kill $HS +} +runone root 0 +runone noroot 1 From 44774a3ba16165374a3f99ae035b8f3a40670466 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 18:26:45 +0100 Subject: [PATCH 022/206] estate navigator: entry points belong to the navigator, not the tiers; short wrapping errors; a DM joined after the timeout is filed on arrival; 44 px join targets on phones --- src/app/features/estate/EstateNavigator.tsx | 92 ++++++++++++++------- 1 file changed, 64 insertions(+), 28 deletions(-) diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx index f65ba827..63d0c0c2 100644 --- a/src/app/features/estate/EstateNavigator.tsx +++ b/src/app/features/estate/EstateNavigator.tsx @@ -48,6 +48,10 @@ import { useEstate, useEstateRootId } from './useEstate'; /** How long a tap waits for the client store to hold the room it just joined. */ const JOIN_ARRIVAL_TIMEOUT_MS = 20000; +/** How long after a timed-out tap the store may still deliver the room and have its DM filed. */ +const LATE_ARRIVAL_WINDOW_MS = 10 * 60 * 1000; +/** Minimum tap target on phones. */ +const TOUCH_TARGET = 44; const estateRoomPath = (mx: ReturnType, room: EstateRoom): string => { const alias = getCanonicalAliasOrRoomId(mx, room.roomId); @@ -62,6 +66,7 @@ const estateRoomPath = (mx: ReturnType, room: EstateRoom function JoinRow({ room }: { room: EstateRoom }) { const mx = useMatrixClient(); const navigate = useNavigate(); + const mobile = useScreenSizeContext() === ScreenSize.Mobile; const [state, join] = useAsyncCallback( useCallback(async () => { const userId = mx.getSafeUserId(); @@ -74,7 +79,14 @@ function JoinRow({ room }: { room: EstateRoom }) { if (result.kind === 'refused') throw new Error(result.message); // joinRoom resolves before the store holds the room; opening it now shows the invite card. if (!(await awaitMyJoin(mx, room.roomId, JOIN_ARRIVAL_TIMEOUT_MS))) { - throw new Error('Joined, but the room has not arrived yet. Tap Join again in a moment.'); + // The join is accepted; when the room arrives the row becomes a joined row by itself, + // so a DM is filed then (a failed write leaves it under Elsewhere, where it can be found). + if (dmUserId) { + awaitMyJoin(mx, room.roomId, LATE_ARRIVAL_WINDOW_MS) + .then((arrived) => arrived && addRoomIdToMDirect(mx, room.roomId, dmUserId)) + .catch(() => undefined); + } + throw new Error('Joined. The room is still on its way and will open from this list.'); } if (dmUserId) await addRoomIdToMDirect(mx, room.roomId, dmUserId); navigate(estateRoomPath(mx, room)); @@ -84,14 +96,23 @@ function JoinRow({ room }: { room: EstateRoom }) { return ( - + {room.name} {state.status === AsyncStatus.Error && ( - + {(state.error as Error).message} )} @@ -102,6 +123,7 @@ function JoinRow({ room }: { room: EstateRoom }) { before={busy ? : } onClick={join} disabled={busy} + style={mobile ? { minHeight: TOUCH_TARGET, minWidth: TOUCH_TARGET } : undefined} aria-label={`Join ${room.name}`} > Join @@ -248,27 +270,33 @@ function EntryPoints() { function EstateTiers({ rootId }: { rootId: string }) { const { fetching, error, tiers } = useEstate(rootId); - const scrollRef = useRef(null); return ( - - - - {tiers.map((tier) => ( - - ))} - {fetching && ( - - - - )} - {error && ( - - {error.message} - - )} - - + <> + {tiers.map((tier) => ( + + ))} + {fetching && ( + + + + )} + {error && ( + + )} + + ); +} + +/** A short message that wraps inside the navigator; never a raw request error. */ +function EstateError({ message }: { message: string }) { + return ( + + {message} + ); } @@ -276,6 +304,7 @@ function EstateTiers({ rootId }: { rootId: string }) { export function EstateNavigator() { const openRoomFinder = useSetAtom(searchModalAtom); const { rootId, error } = useEstateRootId(); + const scrollRef = useRef(null); const mx = useMatrixClient(); const title = (rootId && mx.getRoom(rootId)?.name) || 'GameCult'; @@ -297,13 +326,20 @@ export function EstateNavigator() { - {rootId ? ( - - ) : ( - - {error ? {error.message} : } + + + + {rootId && } + {!rootId && error && ( + + )} + {!rootId && !error && ( + + + + )} - )} + From 2e179a77687548c95d6374c8870413de412769c5 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 18:26:52 +0100 Subject: [PATCH 023/206] harness: print the verdict lines --- harness/estate/run.sh | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/harness/estate/run.sh b/harness/estate/run.sh index af8bb5db..c499cc5b 100644 --- a/harness/estate/run.sh +++ b/harness/estate/run.sh @@ -18,3 +18,10 @@ runone () { # tag noroot } runone root 0 runone noroot 1 +for t in root noroot; do + echo "=== $t" + grep -E '^(SMALL|layout|has Create|CLICK|POLL|timeout|after arrival|revealed|--- )' /out/$t-timeline.txt | cut -c1-1800 || true +done +echo "=== m.direct and joins (root)" +grep -E 'direct|JOIN' /out/hslog-root.txt | cut -c1-220 || true +tail -1 /out/build.log From d7afdc520805e0fa4bc4391c79ed2a64c663c3b3 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 18:30:54 +0100 Subject: [PATCH 024/206] estate navigator: 44 px tap targets on phones from one stylesheet rule over the navigator --- harness/estate/drive.cjs | 2 +- harness/estate/run.sh | 1 + .../features/estate/EstateNavigator.css.ts | 14 +++++++++++ src/app/features/estate/EstateNavigator.tsx | 25 +++++++++---------- 4 files changed, 28 insertions(+), 14 deletions(-) create mode 100644 src/app/features/estate/EstateNavigator.css.ts diff --git a/harness/estate/drive.cjs b/harness/estate/drive.cjs index 0dd254de..fae012a6 100644 --- a/harness/estate/drive.cjs +++ b/harness/estate/drive.cjs @@ -68,7 +68,7 @@ const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'El if (!(await loc.count())) { tl.push(`no Join ${name}`); return; } await loc.first().click(); tl.push(`CLICK Join ${name} ${t()}`); await pg.waitForTimeout(21500); await snap(`5-slow-${name.replace(/\W/g, '')}-21s`); - tl.push(`timeout msg shown: ${(await text()).includes('has not arrived yet')}`); + tl.push(`timeout msg shown: ${(await text()).includes('still on its way')}`); await pg.waitForTimeout(5500); await snap(`5-slow-${name.replace(/\W/g, '')}-27s`); tl.push(`after arrival: Join ${name} still present=${await pg.locator(`[aria-label="Join ${name}"]`).count()} url=${pg.url()}`); }; diff --git a/harness/estate/run.sh b/harness/estate/run.sh index c499cc5b..e044d07d 100644 --- a/harness/estate/run.sh +++ b/harness/estate/run.sh @@ -22,6 +22,7 @@ for t in root noroot; do echo "=== $t" grep -E '^(SMALL|layout|has Create|CLICK|POLL|timeout|after arrival|revealed|--- )' /out/$t-timeline.txt | cut -c1-1800 || true done +ls -la /out | head -30 echo "=== m.direct and joins (root)" grep -E 'direct|JOIN' /out/hslog-root.txt | cut -c1-220 || true tail -1 /out/build.log diff --git a/src/app/features/estate/EstateNavigator.css.ts b/src/app/features/estate/EstateNavigator.css.ts new file mode 100644 index 00000000..157d69cd --- /dev/null +++ b/src/app/features/estate/EstateNavigator.css.ts @@ -0,0 +1,14 @@ +import { globalStyle, style } from '@vanilla-extract/css'; +import { MOBILE_BREAKPOINT } from '../../hooks/useScreenSize'; + +/** On phones every control inside the navigator is at least 44 px square to tap. */ +export const Targets = style({}); + +globalStyle(`${Targets} button, ${Targets} a`, { + '@media': { + [`screen and (max-width: ${MOBILE_BREAKPOINT}px)`]: { + minHeight: 44, + minWidth: 44, + }, + }, +}); diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx index 63d0c0c2..e36bfb04 100644 --- a/src/app/features/estate/EstateNavigator.tsx +++ b/src/app/features/estate/EstateNavigator.tsx @@ -45,13 +45,12 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { EstateRoom, EstateTier } from './buildEstate'; import { activateEstateRoom, awaitMyJoin } from './joinEstateRoom'; import { useEstate, useEstateRootId } from './useEstate'; +import * as css from './EstateNavigator.css'; /** How long a tap waits for the client store to hold the room it just joined. */ const JOIN_ARRIVAL_TIMEOUT_MS = 20000; /** How long after a timed-out tap the store may still deliver the room and have its DM filed. */ const LATE_ARRIVAL_WINDOW_MS = 10 * 60 * 1000; -/** Minimum tap target on phones. */ -const TOUCH_TARGET = 44; const estateRoomPath = (mx: ReturnType, room: EstateRoom): string => { const alias = getCanonicalAliasOrRoomId(mx, room.roomId); @@ -66,7 +65,6 @@ const estateRoomPath = (mx: ReturnType, room: EstateRoom function JoinRow({ room }: { room: EstateRoom }) { const mx = useMatrixClient(); const navigate = useNavigate(); - const mobile = useScreenSizeContext() === ScreenSize.Mobile; const [state, join] = useAsyncCallback( useCallback(async () => { const userId = mx.getSafeUserId(); @@ -96,12 +94,7 @@ function JoinRow({ room }: { room: EstateRoom }) { return ( - + {room.name} @@ -123,7 +116,6 @@ function JoinRow({ room }: { room: EstateRoom }) { before={busy ? : } onClick={join} disabled={busy} - style={mobile ? { minHeight: TOUCH_TARGET, minWidth: TOUCH_TARGET } : undefined} aria-label={`Join ${room.name}`} > Join @@ -311,7 +303,7 @@ export function EstateNavigator() { return ( - + {title} @@ -327,7 +319,7 @@ export function EstateNavigator() { - + {rootId && } {!rootId && error && ( @@ -340,7 +332,14 @@ export function EstateNavigator() { )} - + From 98d74d564d8297db06840cc701877b88f48a0c47 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 18:35:36 +0100 Subject: [PATCH 025/206] estate navigator: define EstateError before its use --- src/app/features/estate/EstateNavigator.tsx | 24 ++++++++++----------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx index e36bfb04..e81f5a67 100644 --- a/src/app/features/estate/EstateNavigator.tsx +++ b/src/app/features/estate/EstateNavigator.tsx @@ -260,6 +260,18 @@ function EntryPoints() { ); } +/** A short message that wraps inside the navigator; never a raw request error. */ +function EstateError({ message }: { message: string }) { + return ( + + {message} + + ); +} + function EstateTiers({ rootId }: { rootId: string }) { const { fetching, error, tiers } = useEstate(rootId); @@ -280,18 +292,6 @@ function EstateTiers({ rootId }: { rootId: string }) { ); } -/** A short message that wraps inside the navigator; never a raw request error. */ -function EstateError({ message }: { message: string }) { - return ( - - {message} - - ); -} - /** The single navigator: every nav slot that lists rooms mounts this. */ export function EstateNavigator() { const openRoomFinder = useSetAtom(searchModalAtom); From eeb9de8f401b2158ebc02cf72bb77a2c52d07561 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 18:36:44 +0100 Subject: [PATCH 026/206] estate navigator: the late DM filing returns void --- src/app/features/estate/EstateNavigator.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx index e81f5a67..68575932 100644 --- a/src/app/features/estate/EstateNavigator.tsx +++ b/src/app/features/estate/EstateNavigator.tsx @@ -81,7 +81,9 @@ function JoinRow({ room }: { room: EstateRoom }) { // so a DM is filed then (a failed write leaves it under Elsewhere, where it can be found). if (dmUserId) { awaitMyJoin(mx, room.roomId, LATE_ARRIVAL_WINDOW_MS) - .then((arrived) => arrived && addRoomIdToMDirect(mx, room.roomId, dmUserId)) + .then(async (arrived) => { + if (arrived) await addRoomIdToMDirect(mx, room.roomId, dmUserId); + }) .catch(() => undefined); } throw new Error('Joined. The room is still on its way and will open from this list.'); From bfb753e3e5e1562aa1bd11d2651adcd9a645d073 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 18:38:43 +0100 Subject: [PATCH 027/206] harness: print the unresolved-root message --- harness/estate/run.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/harness/estate/run.sh b/harness/estate/run.sh index e044d07d..d4dd7cf4 100644 --- a/harness/estate/run.sh +++ b/harness/estate/run.sh @@ -23,6 +23,7 @@ for t in root noroot; do grep -E '^(SMALL|layout|has Create|CLICK|POLL|timeout|after arrival|revealed|--- )' /out/$t-timeline.txt | cut -c1-1800 || true done ls -la /out | head -30 +cat /out/noroot-timeline.txt | grep -m3 -i "could not" || true echo "=== m.direct and joins (root)" grep -E 'direct|JOIN' /out/hslog-root.txt | cut -c1-220 || true tail -1 /out/build.log From c91d0339b506131938dc775a3123b696c3c376ce Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 18:44:39 +0100 Subject: [PATCH 028/206] harness: read the timeout message later in the pre-arrival window --- harness/estate/drive.cjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/harness/estate/drive.cjs b/harness/estate/drive.cjs index fae012a6..5d69d02a 100644 --- a/harness/estate/drive.cjs +++ b/harness/estate/drive.cjs @@ -67,9 +67,9 @@ const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'El const loc = await reveal(`[aria-label="Join ${name}"]`); if (!(await loc.count())) { tl.push(`no Join ${name}`); return; } await loc.first().click(); tl.push(`CLICK Join ${name} ${t()}`); - await pg.waitForTimeout(21500); await snap(`5-slow-${name.replace(/\W/g, '')}-21s`); + await pg.waitForTimeout(22800); await snap(`5-slow-${name.replace(/\W/g, '')}-21s`); tl.push(`timeout msg shown: ${(await text()).includes('still on its way')}`); - await pg.waitForTimeout(5500); await snap(`5-slow-${name.replace(/\W/g, '')}-27s`); + await pg.waitForTimeout(5000); await snap(`5-slow-${name.replace(/\W/g, '')}-27s`); tl.push(`after arrival: Join ${name} still present=${await pg.locator(`[aria-label="Join ${name}"]`).count()} url=${pg.url()}`); }; await slow('Odin'); From 3d4fb161c3cc98c036ac07f215e8efa10af20a70 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 19:41:38 +0100 Subject: [PATCH 029/206] one filer for direct messages: any joined room whose invite was marked is_direct is written to m.direct in one serialized write, on startup and on every sync; the per-tap late filer is gone (Soul s3 late-dm-filings-overwrite-each-other) Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/estate/EstateNavigator.tsx | 32 +--- src/app/hooks/useCommands.ts | 12 +- src/app/hooks/useFileDirectRooms.ts | 20 +++ src/app/pages/client/ClientNonUIFeatures.tsx | 2 + src/app/utils/mDirect.test.ts | 154 +++++++++++++++++++ src/app/utils/matrix.ts | 121 +++++++++------ src/app/utils/room.ts | 12 ++ 7 files changed, 281 insertions(+), 72 deletions(-) create mode 100644 src/app/hooks/useFileDirectRooms.ts create mode 100644 src/app/utils/mDirect.test.ts diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx index 68575932..bf5cfc24 100644 --- a/src/app/features/estate/EstateNavigator.tsx +++ b/src/app/features/estate/EstateNavigator.tsx @@ -24,12 +24,7 @@ import { } from '../../pages/pathUtils'; import { _RoomSearchParams } from '../../pages/paths'; import { searchModalAtom } from '../../state/searchModal'; -import { isDirectInvite } from '../../utils/room'; -import { - addRoomIdToMDirect, - getCanonicalAliasOrRoomId, - guessDmRoomUserId, -} from '../../utils/matrix'; +import { getCanonicalAliasOrRoomId } from '../../utils/matrix'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useSelectedRoom } from '../../hooks/router/useSelectedRoom'; import { useCallEmbed } from '../../hooks/useCallEmbed'; @@ -49,8 +44,6 @@ import * as css from './EstateNavigator.css'; /** How long a tap waits for the client store to hold the room it just joined. */ const JOIN_ARRIVAL_TIMEOUT_MS = 20000; -/** How long after a timed-out tap the store may still deliver the room and have its DM filed. */ -const LATE_ARRIVAL_WINDOW_MS = 10 * 60 * 1000; const estateRoomPath = (mx: ReturnType, room: EstateRoom): string => { const alias = getCanonicalAliasOrRoomId(mx, room.roomId); @@ -67,28 +60,14 @@ function JoinRow({ room }: { room: EstateRoom }) { const navigate = useNavigate(); const [state, join] = useAsyncCallback( useCallback(async () => { - const userId = mx.getSafeUserId(); - const dmRoom = mx.getRoom(room.roomId); - const dmUserId = - room.direct && dmRoom && isDirectInvite(dmRoom, userId) - ? guessDmRoomUserId(dmRoom, userId) - : undefined; const result = await activateEstateRoom(mx, room); if (result.kind === 'refused') throw new Error(result.message); // joinRoom resolves before the store holds the room; opening it now shows the invite card. if (!(await awaitMyJoin(mx, room.roomId, JOIN_ARRIVAL_TIMEOUT_MS))) { - // The join is accepted; when the room arrives the row becomes a joined row by itself, - // so a DM is filed then (a failed write leaves it under Elsewhere, where it can be found). - if (dmUserId) { - awaitMyJoin(mx, room.roomId, LATE_ARRIVAL_WINDOW_MS) - .then(async (arrived) => { - if (arrived) await addRoomIdToMDirect(mx, room.roomId, dmUserId); - }) - .catch(() => undefined); - } + // The join is accepted; when the room arrives the row becomes a joined row by itself + // and a direct message is filed by useFileDirectRooms. throw new Error('Joined. The room is still on its way and will open from this list.'); } - if (dmUserId) await addRoomIdToMDirect(mx, room.roomId, dmUserId); navigate(estateRoomPath(mx, room)); }, [mx, navigate, room]) ); @@ -265,10 +244,7 @@ function EntryPoints() { /** A short message that wraps inside the navigator; never a raw request error. */ function EstateError({ message }: { message: string }) { return ( - + {message} ); diff --git a/src/app/hooks/useCommands.ts b/src/app/hooks/useCommands.ts index c02b0e94..798423c4 100644 --- a/src/app/hooks/useCommands.ts +++ b/src/app/hooks/useCommands.ts @@ -24,7 +24,7 @@ import { } from '../utils/matrix'; import { useRoomNavigate } from './useRoomNavigate'; import { Membership, StateEvent } from '../../types/matrix/room'; -import { getStateEvent } from '../utils/room'; +import { getStateEvent, wasInvitedAsDirect } from '../utils/room'; import { splitWithSpace } from '../utils/common'; import { createRoomEncryptionState } from '../components/create-room'; @@ -411,6 +411,16 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { description: 'Convert direct message to room', exe: async () => { await removeRoomIdFromMDirect(mx, room.roomId); + // The filer reads the invite's mark; clear it so the room stays a room. + const me = mx.getSafeUserId(); + if (wasInvitedAsDirect(room, me)) { + await mx.sendStateEvent( + room.roomId, + StateEvent.RoomMember as any, + { ...room.getMember(me)?.events?.member?.getContent(), is_direct: false }, + me + ); + } }, }, [Command.Delete]: { diff --git a/src/app/hooks/useFileDirectRooms.ts b/src/app/hooks/useFileDirectRooms.ts new file mode 100644 index 00000000..023316b5 --- /dev/null +++ b/src/app/hooks/useFileDirectRooms.ts @@ -0,0 +1,20 @@ +import { useEffect } from 'react'; +import { useMatrixClient } from './useMatrixClient'; +import { fileDirectRooms } from '../utils/matrix'; + +/** Keeps direct messages filed in m.direct: on startup, on every sync, on every membership change. */ +export const useFileDirectRooms = (): void => { + const mx = useMatrixClient(); + useEffect(() => { + const file = () => { + fileDirectRooms(mx).catch(() => undefined); + }; + file(); + mx.on('sync' as never, file); + mx.on('Room.myMembership' as never, file); + return () => { + mx.off('sync' as never, file); + mx.off('Room.myMembership' as never, file); + }; + }, [mx]); +}; diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index ce952bfc..5af097e4 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -26,6 +26,7 @@ import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; import { useSelectedRoom } from '../../hooks/router/useSelectedRoom'; import { useInboxNotificationsSelected } from '../../hooks/router/useInbox'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { useFileDirectRooms } from '../../hooks/useFileDirectRooms'; function SystemEmojiFeature() { const [twitterEmoji] = useSetting(settingsAtom, 'twitterEmoji'); @@ -258,6 +259,7 @@ type ClientNonUIFeaturesProps = { }; export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { + useFileDirectRooms(); return ( <> diff --git a/src/app/utils/mDirect.test.ts b/src/app/utils/mDirect.test.ts new file mode 100644 index 00000000..55fc8602 --- /dev/null +++ b/src/app/utils/mDirect.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from 'vitest'; +import { addRoomIdToMDirect, fileDirectRooms, removeRoomIdFromMDirect } from './matrix'; +import { wasInvitedAsDirect } from './room'; + +const ME = '@me:test'; +type Content = Record; + +type FakeRoomOpts = { + membership?: string; + content?: Record; + prev?: Record; + friend?: string | null; +}; + +/** A joined room as the store holds it: my member event, and the friend once synced. */ +const fakeRoom = (roomId: string, o: FakeRoomOpts = {}) => { + const memberEvent = { + getContent: () => o.content ?? { membership: 'join' }, + getPrevContent: () => o.prev ?? {}, + }; + const friend = o.friend === undefined ? '@friend:test' : o.friend; + return { + roomId, + getMyMembership: () => o.membership ?? 'join', + getMember: () => ({ events: { member: memberEvent } }), + getJoinedMembers: () => + friend ? [{ userId: friend, events: { member: { getTs: () => 1 } } }] : [], + getLiveTimeline: () => ({ getState: () => undefined }), + }; +}; +const lateDm = (roomId: string, o: FakeRoomOpts = {}) => + fakeRoom(roomId, { prev: { membership: 'invite', is_direct: true }, ...o }); + +/** A client whose account-data write takes a while and lands only when it completes. */ +const fakeClient = (rooms: ReturnType[], initial?: Content) => { + let direct: Content | undefined = initial; + const writes: Content[] = []; + const mx = { + getSafeUserId: () => ME, + getRooms: () => rooms, + getAccountData: () => (direct ? { getContent: () => direct } : undefined), + setAccountData: async (_type: string, content: Content) => { + await new Promise((r) => { + setTimeout(r, 5); + }); + writes.push(structuredClone(content)); + direct = structuredClone(content); + }, + }; + return { mx: mx as never, raw: mx, writes, current: () => direct }; +}; + +describe('wasInvitedAsDirect', () => { + it('reads the mark from the join event or from the invite it replaced', () => { + expect(wasInvitedAsDirect(fakeRoom('!a', { content: { is_direct: true } }) as never, ME)).toBe( + true + ); + expect(wasInvitedAsDirect(lateDm('!b') as never, ME)).toBe(true); + expect(wasInvitedAsDirect(fakeRoom('!c') as never, ME)).toBe(false); + }); + + it('ignores a mark on a state the member event has since left', () => { + const room = fakeRoom('!d', { + prev: { membership: 'join', is_direct: true }, + content: { is_direct: false }, + }); + expect(wasInvitedAsDirect(room as never, ME)).toBe(false); + }); +}); + +describe('fileDirectRooms', () => { + it('files two DMs that arrive in the same sync, none lost', async () => { + const c = fakeClient([lateDm('!dm2:test'), lateDm('!dm3:test')]); + await fileDirectRooms(c.mx); + expect(c.writes).toHaveLength(1); + expect(c.current()).toEqual({ '@friend:test': ['!dm2:test', '!dm3:test'] }); + }); + + it('serializes overlapping runs so a later write builds on the earlier one', async () => { + const c = fakeClient([lateDm('!dm2:test'), fakeRoom('!room:test')]); + await Promise.all([ + fileDirectRooms(c.mx), + addRoomIdToMDirect(c.mx, '!made:test', '@other:test'), + fileDirectRooms(c.mx), + ]); + expect(c.current()).toEqual({ '@friend:test': ['!dm2:test'], '@other:test': ['!made:test'] }); + expect(c.writes).toHaveLength(2); + }); + + it('files a DM found at startup, after a reload while the join was in flight', async () => { + const reloaded = fakeClient([lateDm('!dm3:test')], { '@friend:test': ['!dm1:test'] }); + await fileDirectRooms(reloaded.mx); + expect(reloaded.current()).toEqual({ '@friend:test': ['!dm1:test', '!dm3:test'] }); + }); + + it('writes nothing when every marked room is already filed', async () => { + const c = fakeClient([lateDm('!dm2:test')], { '@friend:test': ['!dm2:test'] }); + await fileDirectRooms(c.mx); + expect(c.writes).toHaveLength(0); + }); + + it('skips rooms that are not joined, not marked, or whose other party has not synced', async () => { + const c = fakeClient([ + lateDm('!invited:test', { membership: 'invite' }), + fakeRoom('!plain:test'), + lateDm('!nofriend:test', { friend: null }), + ]); + await fileDirectRooms(c.mx); + expect(c.writes).toHaveLength(0); + // the next sync delivers the friend; the same call files it + const later = fakeClient([lateDm('!nofriend:test')]); + await fileDirectRooms(later.mx); + expect(later.current()).toEqual({ '@friend:test': ['!nofriend:test'] }); + }); + + it('does not file a room whose mark was cleared when it was converted to a room', async () => { + const converted = fakeRoom('!conv:test', { + content: { membership: 'join', is_direct: false }, + prev: { membership: 'join', is_direct: true }, + }); + const c = fakeClient([converted]); + await fileDirectRooms(c.mx); + expect(c.writes).toHaveLength(0); + }); + + it('keeps going after a failed write', async () => { + const c = fakeClient([lateDm('!dm2:test')]); + const original = c.raw.setAccountData; + let fail = true; + c.raw.setAccountData = async (...a: [string, Content]) => { + if (fail) { + fail = false; + throw new Error('offline'); + } + return original(...a); + }; + await expect(fileDirectRooms(c.mx)).rejects.toThrow('offline'); + await fileDirectRooms(c.mx); + expect(c.current()).toEqual({ '@friend:test': ['!dm2:test'] }); + }); +}); + +describe('m.direct edits', () => { + it('moves a room between users and removes it, one write at a time', async () => { + const c = fakeClient([], { '@a:test': ['!r:test'] }); + await Promise.all([ + addRoomIdToMDirect(c.mx, '!r:test', '@b:test'), + removeRoomIdFromMDirect(c.mx, '!gone:test'), + ]); + expect(c.current()).toEqual({ '@a:test': [], '@b:test': ['!r:test'] }); + await removeRoomIdFromMDirect(c.mx, '!r:test'); + expect(c.current()).toEqual({ '@a:test': [], '@b:test': [] }); + }); +}); diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts index 4c86c4e2..f176764a 100644 --- a/src/app/utils/matrix.ts +++ b/src/app/utils/matrix.ts @@ -16,7 +16,7 @@ import { import to from 'await-to-js'; import { IImageInfo, IThumbnailContent, IVideoInfo } from '../../types/matrix/common'; import { AccountDataEvent } from '../../types/matrix/accountData'; -import { getStateEvent } from './room'; +import { getStateEvent, wasInvitedAsDirect } from './room'; import { Membership, StateEvent } from '../../types/matrix/room'; const DOMAIN_REGEX = /\b(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}\b/; @@ -225,57 +225,92 @@ export const guessDmRoomUserId = (room: Room, myUserId: string): string => { return member1?.userId ?? myUserId; }; -export const addRoomIdToMDirect = async ( +type MDirectContent = Record; +type DirectEntry = { roomId: string; userId: string }; + +const mDirectWrites = new WeakMap>(); + +/** + * The only writer of m.direct. m.direct is one account-data event that every + * edit replaces whole, so edits run one at a time, each reading the content the + * previous one wrote. `edit` returns the new content, or undefined for no write. + */ +export const updateMDirect = ( + mx: MatrixClient, + edit: (current: MDirectContent) => MDirectContent | undefined +): Promise => { + const run = (mDirectWrites.get(mx) ?? Promise.resolve()).then(async () => { + const event = mx.getAccountData(AccountDataEvent.Direct as any); + const next = edit(event ? structuredClone(event.getContent()) : {}); + if (next) await mx.setAccountData(AccountDataEvent.Direct as any, next as any); + }); + mDirectWrites.set( + mx, + run.catch(() => undefined) + ); + return run; +}; + +/** A room can be a DM for one person only: it leaves every other user's list. */ +export const withDirectEntries = ( + current: MDirectContent, + entries: DirectEntry[] +): MDirectContent => { + const next = structuredClone(current); + entries.forEach(({ roomId, userId }) => { + Object.keys(next).forEach((targetUserId) => { + if (targetUserId !== userId) { + next[targetUserId] = next[targetUserId].filter((id) => id !== roomId); + } + }); + const roomIds = next[userId] ?? []; + if (!roomIds.includes(roomId)) roomIds.push(roomId); + next[userId] = roomIds; + }); + return next; +}; + +export const addRoomIdToMDirect = ( mx: MatrixClient, roomId: string, userId: string -): Promise => { - const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct as any); - let userIdToRoomIds: Record = {}; +): Promise => + updateMDirect(mx, (current) => withDirectEntries(current, [{ roomId, userId }])); - if (typeof mDirectsEvent !== 'undefined') - userIdToRoomIds = structuredClone(mDirectsEvent.getContent()); - - // remove it from the lists of any others users - // (it can only be a DM room for one person) - Object.keys(userIdToRoomIds).forEach((targetUserId) => { - const roomIds = userIdToRoomIds[targetUserId]; - - if (targetUserId !== userId) { - const indexOfRoomId = roomIds.indexOf(roomId); - if (indexOfRoomId > -1) { - roomIds.splice(indexOfRoomId, 1); - } - } +/** + * The one filer of direct messages. Files every joined room whose invite was + * marked is_direct and which m.direct does not list, in a single write, once + * the other party is known. Run on startup and on every sync: a DM that + * arrives late, in a burst or after a reload is filed by the same check. + */ +export const fileDirectRooms = (mx: MatrixClient): Promise => { + const me = mx.getSafeUserId(); + return updateMDirect(mx, (current) => { + const filed = new Set(Object.values(current).flat()); + const entries = mx + .getRooms() + .filter( + (room: Room) => + room.getMyMembership() === 'join' && + !filed.has(room.roomId) && + wasInvitedAsDirect(room, me) + ) + .map((room: Room) => ({ roomId: room.roomId, userId: guessDmRoomUserId(room, me) })) + // The other party's membership has not synced yet; a later sync files it. + .filter((entry: DirectEntry) => entry.userId !== me); + return entries.length > 0 ? withDirectEntries(current, entries) : undefined; }); - - const roomIds = userIdToRoomIds[userId] || []; - if (roomIds.indexOf(roomId) === -1) { - roomIds.push(roomId); - } - userIdToRoomIds[userId] = roomIds; - - await mx.setAccountData(AccountDataEvent.Direct as any, userIdToRoomIds as any); }; -export const removeRoomIdFromMDirect = async (mx: MatrixClient, roomId: string): Promise => { - const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct as any); - let userIdToRoomIds: Record = {}; - - if (typeof mDirectsEvent !== 'undefined') - userIdToRoomIds = structuredClone(mDirectsEvent.getContent()); - - Object.keys(userIdToRoomIds).forEach((targetUserId) => { - const roomIds = userIdToRoomIds[targetUserId]; - const indexOfRoomId = roomIds.indexOf(roomId); - if (indexOfRoomId > -1) { - roomIds.splice(indexOfRoomId, 1); - } +export const removeRoomIdFromMDirect = (mx: MatrixClient, roomId: string): Promise => + updateMDirect(mx, (current) => { + const next: MDirectContent = {}; + Object.keys(current).forEach((userId) => { + next[userId] = current[userId].filter((id) => id !== roomId); + }); + return next; }); - await mx.setAccountData(AccountDataEvent.Direct as any, userIdToRoomIds as any); -}; - export const mxcUrlToHttp = ( mx: MatrixClient, mxcUrl: string, diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index 05dfeb29..d4cd757c 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -72,6 +72,18 @@ export const isDirectInvite = (room: Room | null, myUserId: string | null): bool return content?.is_direct === true; }; +/** + * Whether the invite that brought this user into the room was marked is_direct. + * The join event keeps the mark in its own content when the server copies it, + * and otherwise in the invite it replaced (unsigned.prev_content). + */ +export const wasInvitedAsDirect = (room: Room, myUserId: string): boolean => { + const event = room.getMember(myUserId)?.events?.member; + if (event?.getContent()?.is_direct === true) return true; + const prev = event?.getPrevContent(); + return prev?.membership === 'invite' && prev.is_direct === true; +}; + export const isSpace = (room: Room | null): boolean => { if (!room) return false; const event = getStateEvent(room, StateEvent.RoomCreate); From 282769943931fd1f3cf91c7f02803dcda1789a0b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 20:11:45 +0100 Subject: [PATCH 030/206] direct messages are listed in m.direct before the join is sent, from the invite event's is_direct; the arrival-time filer, its hook and the member-flag rewrite are deleted (Soul s4 converttoroom-refiled-by-filer, dm-mark-lost-after-member-rewrite) Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/estate/EstateNavigator.tsx | 3 +- .../features/estate/joinEstateRoom.test.ts | 63 +++++++ src/app/features/estate/joinEstateRoom.ts | 28 +++- src/app/hooks/useCommands.ts | 12 +- src/app/hooks/useFileDirectRooms.ts | 20 --- src/app/pages/client/ClientNonUIFeatures.tsx | 2 - src/app/utils/mDirect.test.ts | 155 ++++-------------- src/app/utils/matrix.ts | 51 +----- src/app/utils/room.ts | 12 -- 9 files changed, 128 insertions(+), 218 deletions(-) delete mode 100644 src/app/hooks/useFileDirectRooms.ts diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx index bf5cfc24..6fa4bf48 100644 --- a/src/app/features/estate/EstateNavigator.tsx +++ b/src/app/features/estate/EstateNavigator.tsx @@ -64,8 +64,7 @@ function JoinRow({ room }: { room: EstateRoom }) { if (result.kind === 'refused') throw new Error(result.message); // joinRoom resolves before the store holds the room; opening it now shows the invite card. if (!(await awaitMyJoin(mx, room.roomId, JOIN_ARRIVAL_TIMEOUT_MS))) { - // The join is accepted; when the room arrives the row becomes a joined row by itself - // and a direct message is filed by useFileDirectRooms. + // The join is accepted; when the room arrives the row becomes a joined row by itself. throw new Error('Joined. The room is still on its way and will open from this list.'); } navigate(estateRoomPath(mx, room)); diff --git a/src/app/features/estate/joinEstateRoom.test.ts b/src/app/features/estate/joinEstateRoom.test.ts index 9bfddf1a..bf96a4af 100644 --- a/src/app/features/estate/joinEstateRoom.test.ts +++ b/src/app/features/estate/joinEstateRoom.test.ts @@ -18,6 +18,8 @@ const recordingClient = (joinRoom = vi.fn().mockResolvedValue({ roomId: '!r:game return joinRoom(...args); }; } + if (prop === 'getRoom') return () => null; + if (prop === 'getSafeUserId') return () => '@me:test'; return () => { calls.push(prop); throw new Error(`unexpected client call: ${prop}`); @@ -72,6 +74,67 @@ describe('activateEstateRoom', () => { }); }); +/** A client holding DM invites as the server sends them: the mark is in the invite event only. */ +const dmClient = (opts: { refuse?: boolean; invites: Record }) => { + let direct: Record | undefined; + const writes: Record[] = []; + const joined: string[] = []; + const mx = { + getSafeUserId: () => '@me:test', + getRoom: (roomId: string) => ({ + getMember: () => ({ + events: { + member: { + getContent: () => ({ membership: 'invite', is_direct: true }), + getSender: () => opts.invites[roomId], + }, + }, + }), + }), + getAccountData: () => (direct ? { getContent: () => direct } : undefined), + setAccountData: async (_t: string, content: Record) => { + await new Promise((r) => { + setTimeout(r, 5); + }); + writes.push(structuredClone(content)); + direct = structuredClone(content); + }, + joinRoom: async (roomId: string) => { + // the join lands only after m.direct has been written: record what it held + joined.push(JSON.stringify(direct ?? {})); + if (opts.refuse) throw new Error('M_FORBIDDEN'); + return { roomId }; + }, + }; + return { mx: mx as never, writes, joined, current: () => direct }; +}; +const tap = (id: string) => ({ roomId: id, state: 'invited' as const, via: ['test'] }); + +describe('activateEstateRoom for a direct message invite', () => { + it('lists the room under the inviter before the join is sent', async () => { + const c = dmClient({ invites: { '!dm1:test': '@friend:test' } }); + await activateEstateRoom(c.mx, tap('!dm1:test')); + expect(c.joined).toEqual([JSON.stringify({ '@friend:test': ['!dm1:test'] })]); + }); + + it('keeps both rooms when two DM joins are tapped together', async () => { + const c = dmClient({ invites: { '!dm2:test': '@friend:test', '!dm3:test': '@friend:test' } }); + const results = await Promise.all([ + activateEstateRoom(c.mx, tap('!dm2:test')), + activateEstateRoom(c.mx, tap('!dm3:test')), + ]); + expect(results.map((r) => r.kind)).toEqual(['joined', 'joined']); + expect(c.current()).toEqual({ '@friend:test': ['!dm2:test', '!dm3:test'] }); + }); + + it('takes the room out again when the join is refused', async () => { + const c = dmClient({ refuse: true, invites: { '!dm1:test': '@friend:test' } }); + const result = await activateEstateRoom(c.mx, tap('!dm1:test')); + expect(result).toEqual({ kind: 'refused', message: 'M_FORBIDDEN' }); + expect(c.current()).toEqual({ '@friend:test': [] }); + }); +}); + describe('awaitMyJoin', () => { /** A store that gains the room when the test says so, emitting like the SDK. */ const store = () => { diff --git a/src/app/features/estate/joinEstateRoom.ts b/src/app/features/estate/joinEstateRoom.ts index df5284e3..36a9166e 100644 --- a/src/app/features/estate/joinEstateRoom.ts +++ b/src/app/features/estate/joinEstateRoom.ts @@ -1,31 +1,49 @@ import { EstateRoom } from './buildEstate'; +import { addRoomIdToMDirect, removeRoomIdFromMDirect } from '../../utils/matrix'; +import { isDirectInvite } from '../../utils/room'; export type EstateActivation = | { kind: 'open'; roomId: string } | { kind: 'joined'; roomId: string } | { kind: 'refused'; message: string }; -type JoinClient = { - joinRoom(roomId: string, opts: { viaServers: string[] }): Promise<{ roomId: string }>; -}; +type JoinClient = Parameters[0]; /** * What a tap on an estate row does. A joined room opens; any other room is * joined, and only here (plus a first post in the room view). Opening a room * never joins it and the client has no peek path. The server decides whether * the join is allowed; its refusal is reported, the row is not hidden. + * + * An invite marked is_direct carries the mark in the invite event the client + * already holds, and the join event does not repeat it. So the room is listed + * in m.direct under the inviter before the join is sent, and taken out again if + * the join is refused. Nothing needs to recognise the DM after it arrives. */ export const activateEstateRoom = async ( mx: JoinClient, room: Pick ): Promise => { if (room.state === 'joined') return { kind: 'open', roomId: room.roomId }; + const refusal = (e: unknown): EstateActivation => { + const err = e as { data?: { error?: string }; message?: string }; + return { kind: 'refused', message: err.data?.error ?? err.message ?? 'Could not join' }; + }; + const invite = room.state === 'invited' ? mx.getRoom(room.roomId) : null; + const inviter = isDirectInvite(invite, mx.getSafeUserId()) + ? invite?.getMember(mx.getSafeUserId())?.events?.member?.getSender() + : undefined; + try { + if (inviter) await addRoomIdToMDirect(mx, room.roomId, inviter); + } catch (e) { + return refusal(e); + } try { const joined = await mx.joinRoom(room.roomId, { viaServers: room.via }); return { kind: 'joined', roomId: joined.roomId }; } catch (e) { - const err = e as { data?: { error?: string }; message?: string }; - return { kind: 'refused', message: err.data?.error ?? err.message ?? 'Could not join' }; + if (inviter) await removeRoomIdFromMDirect(mx, room.roomId).catch(() => undefined); + return refusal(e); } }; diff --git a/src/app/hooks/useCommands.ts b/src/app/hooks/useCommands.ts index 798423c4..c02b0e94 100644 --- a/src/app/hooks/useCommands.ts +++ b/src/app/hooks/useCommands.ts @@ -24,7 +24,7 @@ import { } from '../utils/matrix'; import { useRoomNavigate } from './useRoomNavigate'; import { Membership, StateEvent } from '../../types/matrix/room'; -import { getStateEvent, wasInvitedAsDirect } from '../utils/room'; +import { getStateEvent } from '../utils/room'; import { splitWithSpace } from '../utils/common'; import { createRoomEncryptionState } from '../components/create-room'; @@ -411,16 +411,6 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { description: 'Convert direct message to room', exe: async () => { await removeRoomIdFromMDirect(mx, room.roomId); - // The filer reads the invite's mark; clear it so the room stays a room. - const me = mx.getSafeUserId(); - if (wasInvitedAsDirect(room, me)) { - await mx.sendStateEvent( - room.roomId, - StateEvent.RoomMember as any, - { ...room.getMember(me)?.events?.member?.getContent(), is_direct: false }, - me - ); - } }, }, [Command.Delete]: { diff --git a/src/app/hooks/useFileDirectRooms.ts b/src/app/hooks/useFileDirectRooms.ts deleted file mode 100644 index 023316b5..00000000 --- a/src/app/hooks/useFileDirectRooms.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { useEffect } from 'react'; -import { useMatrixClient } from './useMatrixClient'; -import { fileDirectRooms } from '../utils/matrix'; - -/** Keeps direct messages filed in m.direct: on startup, on every sync, on every membership change. */ -export const useFileDirectRooms = (): void => { - const mx = useMatrixClient(); - useEffect(() => { - const file = () => { - fileDirectRooms(mx).catch(() => undefined); - }; - file(); - mx.on('sync' as never, file); - mx.on('Room.myMembership' as never, file); - return () => { - mx.off('sync' as never, file); - mx.off('Room.myMembership' as never, file); - }; - }, [mx]); -}; diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 5af097e4..ce952bfc 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -26,7 +26,6 @@ import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; import { useSelectedRoom } from '../../hooks/router/useSelectedRoom'; import { useInboxNotificationsSelected } from '../../hooks/router/useInbox'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; -import { useFileDirectRooms } from '../../hooks/useFileDirectRooms'; function SystemEmojiFeature() { const [twitterEmoji] = useSetting(settingsAtom, 'twitterEmoji'); @@ -259,7 +258,6 @@ type ClientNonUIFeaturesProps = { }; export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { - useFileDirectRooms(); return ( <> diff --git a/src/app/utils/mDirect.test.ts b/src/app/utils/mDirect.test.ts index 55fc8602..1deaf37e 100644 --- a/src/app/utils/mDirect.test.ts +++ b/src/app/utils/mDirect.test.ts @@ -1,43 +1,13 @@ import { describe, expect, it } from 'vitest'; -import { addRoomIdToMDirect, fileDirectRooms, removeRoomIdFromMDirect } from './matrix'; -import { wasInvitedAsDirect } from './room'; +import { addRoomIdToMDirect, removeRoomIdFromMDirect } from './matrix'; -const ME = '@me:test'; type Content = Record; -type FakeRoomOpts = { - membership?: string; - content?: Record; - prev?: Record; - friend?: string | null; -}; - -/** A joined room as the store holds it: my member event, and the friend once synced. */ -const fakeRoom = (roomId: string, o: FakeRoomOpts = {}) => { - const memberEvent = { - getContent: () => o.content ?? { membership: 'join' }, - getPrevContent: () => o.prev ?? {}, - }; - const friend = o.friend === undefined ? '@friend:test' : o.friend; - return { - roomId, - getMyMembership: () => o.membership ?? 'join', - getMember: () => ({ events: { member: memberEvent } }), - getJoinedMembers: () => - friend ? [{ userId: friend, events: { member: { getTs: () => 1 } } }] : [], - getLiveTimeline: () => ({ getState: () => undefined }), - }; -}; -const lateDm = (roomId: string, o: FakeRoomOpts = {}) => - fakeRoom(roomId, { prev: { membership: 'invite', is_direct: true }, ...o }); - /** A client whose account-data write takes a while and lands only when it completes. */ -const fakeClient = (rooms: ReturnType[], initial?: Content) => { +const fakeClient = (initial?: Content) => { let direct: Content | undefined = initial; const writes: Content[] = []; const mx = { - getSafeUserId: () => ME, - getRooms: () => rooms, getAccountData: () => (direct ? { getContent: () => direct } : undefined), setAccountData: async (_type: string, content: Content) => { await new Promise((r) => { @@ -47,102 +17,12 @@ const fakeClient = (rooms: ReturnType[], initial?: Content) => direct = structuredClone(content); }, }; - return { mx: mx as never, raw: mx, writes, current: () => direct }; + return { mx: mx as never, writes, current: () => direct }; }; -describe('wasInvitedAsDirect', () => { - it('reads the mark from the join event or from the invite it replaced', () => { - expect(wasInvitedAsDirect(fakeRoom('!a', { content: { is_direct: true } }) as never, ME)).toBe( - true - ); - expect(wasInvitedAsDirect(lateDm('!b') as never, ME)).toBe(true); - expect(wasInvitedAsDirect(fakeRoom('!c') as never, ME)).toBe(false); - }); - - it('ignores a mark on a state the member event has since left', () => { - const room = fakeRoom('!d', { - prev: { membership: 'join', is_direct: true }, - content: { is_direct: false }, - }); - expect(wasInvitedAsDirect(room as never, ME)).toBe(false); - }); -}); - -describe('fileDirectRooms', () => { - it('files two DMs that arrive in the same sync, none lost', async () => { - const c = fakeClient([lateDm('!dm2:test'), lateDm('!dm3:test')]); - await fileDirectRooms(c.mx); - expect(c.writes).toHaveLength(1); - expect(c.current()).toEqual({ '@friend:test': ['!dm2:test', '!dm3:test'] }); - }); - - it('serializes overlapping runs so a later write builds on the earlier one', async () => { - const c = fakeClient([lateDm('!dm2:test'), fakeRoom('!room:test')]); - await Promise.all([ - fileDirectRooms(c.mx), - addRoomIdToMDirect(c.mx, '!made:test', '@other:test'), - fileDirectRooms(c.mx), - ]); - expect(c.current()).toEqual({ '@friend:test': ['!dm2:test'], '@other:test': ['!made:test'] }); - expect(c.writes).toHaveLength(2); - }); - - it('files a DM found at startup, after a reload while the join was in flight', async () => { - const reloaded = fakeClient([lateDm('!dm3:test')], { '@friend:test': ['!dm1:test'] }); - await fileDirectRooms(reloaded.mx); - expect(reloaded.current()).toEqual({ '@friend:test': ['!dm1:test', '!dm3:test'] }); - }); - - it('writes nothing when every marked room is already filed', async () => { - const c = fakeClient([lateDm('!dm2:test')], { '@friend:test': ['!dm2:test'] }); - await fileDirectRooms(c.mx); - expect(c.writes).toHaveLength(0); - }); - - it('skips rooms that are not joined, not marked, or whose other party has not synced', async () => { - const c = fakeClient([ - lateDm('!invited:test', { membership: 'invite' }), - fakeRoom('!plain:test'), - lateDm('!nofriend:test', { friend: null }), - ]); - await fileDirectRooms(c.mx); - expect(c.writes).toHaveLength(0); - // the next sync delivers the friend; the same call files it - const later = fakeClient([lateDm('!nofriend:test')]); - await fileDirectRooms(later.mx); - expect(later.current()).toEqual({ '@friend:test': ['!nofriend:test'] }); - }); - - it('does not file a room whose mark was cleared when it was converted to a room', async () => { - const converted = fakeRoom('!conv:test', { - content: { membership: 'join', is_direct: false }, - prev: { membership: 'join', is_direct: true }, - }); - const c = fakeClient([converted]); - await fileDirectRooms(c.mx); - expect(c.writes).toHaveLength(0); - }); - - it('keeps going after a failed write', async () => { - const c = fakeClient([lateDm('!dm2:test')]); - const original = c.raw.setAccountData; - let fail = true; - c.raw.setAccountData = async (...a: [string, Content]) => { - if (fail) { - fail = false; - throw new Error('offline'); - } - return original(...a); - }; - await expect(fileDirectRooms(c.mx)).rejects.toThrow('offline'); - await fileDirectRooms(c.mx); - expect(c.current()).toEqual({ '@friend:test': ['!dm2:test'] }); - }); -}); - describe('m.direct edits', () => { it('moves a room between users and removes it, one write at a time', async () => { - const c = fakeClient([], { '@a:test': ['!r:test'] }); + const c = fakeClient({ '@a:test': ['!r:test'] }); await Promise.all([ addRoomIdToMDirect(c.mx, '!r:test', '@b:test'), removeRoomIdFromMDirect(c.mx, '!gone:test'), @@ -151,4 +31,31 @@ describe('m.direct edits', () => { await removeRoomIdFromMDirect(c.mx, '!r:test'); expect(c.current()).toEqual({ '@a:test': [], '@b:test': [] }); }); + + it('serializes overlapping edits so each builds on the content the last one wrote', async () => { + const c = fakeClient(); + await Promise.all([ + addRoomIdToMDirect(c.mx, '!dm2:test', '@friend:test'), + addRoomIdToMDirect(c.mx, '!dm3:test', '@friend:test'), + addRoomIdToMDirect(c.mx, '!dm4:test', '@other:test'), + ]); + expect(c.current()).toEqual({ + '@friend:test': ['!dm2:test', '!dm3:test'], + '@other:test': ['!dm4:test'], + }); + expect(c.writes).toHaveLength(3); + }); + + it('keeps going after a failed write', async () => { + const c = fakeClient(); + const raw = c.mx as unknown as { setAccountData: (t: string, x: Content) => Promise }; + const original = raw.setAccountData; + raw.setAccountData = async () => { + raw.setAccountData = original; + throw new Error('offline'); + }; + await expect(addRoomIdToMDirect(c.mx, '!a:test', '@f:test')).rejects.toThrow('offline'); + await addRoomIdToMDirect(c.mx, '!a:test', '@f:test'); + expect(c.current()).toEqual({ '@f:test': ['!a:test'] }); + }); }); diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts index f176764a..2859dbc3 100644 --- a/src/app/utils/matrix.ts +++ b/src/app/utils/matrix.ts @@ -16,7 +16,7 @@ import { import to from 'await-to-js'; import { IImageInfo, IThumbnailContent, IVideoInfo } from '../../types/matrix/common'; import { AccountDataEvent } from '../../types/matrix/accountData'; -import { getStateEvent, wasInvitedAsDirect } from './room'; +import { getStateEvent } from './room'; import { Membership, StateEvent } from '../../types/matrix/room'; const DOMAIN_REGEX = /\b(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}\b/; @@ -226,7 +226,6 @@ export const guessDmRoomUserId = (room: Room, myUserId: string): string => { }; type MDirectContent = Record; -type DirectEntry = { roomId: string; userId: string }; const mDirectWrites = new WeakMap>(); @@ -252,12 +251,13 @@ export const updateMDirect = ( }; /** A room can be a DM for one person only: it leaves every other user's list. */ -export const withDirectEntries = ( - current: MDirectContent, - entries: DirectEntry[] -): MDirectContent => { - const next = structuredClone(current); - entries.forEach(({ roomId, userId }) => { +export const addRoomIdToMDirect = ( + mx: MatrixClient, + roomId: string, + userId: string +): Promise => + updateMDirect(mx, (current) => { + const next = structuredClone(current); Object.keys(next).forEach((targetUserId) => { if (targetUserId !== userId) { next[targetUserId] = next[targetUserId].filter((id) => id !== roomId); @@ -266,41 +266,8 @@ export const withDirectEntries = ( const roomIds = next[userId] ?? []; if (!roomIds.includes(roomId)) roomIds.push(roomId); next[userId] = roomIds; + return next; }); - return next; -}; - -export const addRoomIdToMDirect = ( - mx: MatrixClient, - roomId: string, - userId: string -): Promise => - updateMDirect(mx, (current) => withDirectEntries(current, [{ roomId, userId }])); - -/** - * The one filer of direct messages. Files every joined room whose invite was - * marked is_direct and which m.direct does not list, in a single write, once - * the other party is known. Run on startup and on every sync: a DM that - * arrives late, in a burst or after a reload is filed by the same check. - */ -export const fileDirectRooms = (mx: MatrixClient): Promise => { - const me = mx.getSafeUserId(); - return updateMDirect(mx, (current) => { - const filed = new Set(Object.values(current).flat()); - const entries = mx - .getRooms() - .filter( - (room: Room) => - room.getMyMembership() === 'join' && - !filed.has(room.roomId) && - wasInvitedAsDirect(room, me) - ) - .map((room: Room) => ({ roomId: room.roomId, userId: guessDmRoomUserId(room, me) })) - // The other party's membership has not synced yet; a later sync files it. - .filter((entry: DirectEntry) => entry.userId !== me); - return entries.length > 0 ? withDirectEntries(current, entries) : undefined; - }); -}; export const removeRoomIdFromMDirect = (mx: MatrixClient, roomId: string): Promise => updateMDirect(mx, (current) => { diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index d4cd757c..05dfeb29 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -72,18 +72,6 @@ export const isDirectInvite = (room: Room | null, myUserId: string | null): bool return content?.is_direct === true; }; -/** - * Whether the invite that brought this user into the room was marked is_direct. - * The join event keeps the mark in its own content when the server copies it, - * and otherwise in the invite it replaced (unsigned.prev_content). - */ -export const wasInvitedAsDirect = (room: Room, myUserId: string): boolean => { - const event = room.getMember(myUserId)?.events?.member; - if (event?.getContent()?.is_direct === true) return true; - const prev = event?.getPrevContent(); - return prev?.membership === 'invite' && prev.is_direct === true; -}; - export const isSpace = (room: Room | null): boolean => { if (!room) return false; const event = getStateEvent(room, StateEvent.RoomCreate); From e59cc93a2acde819d20fa79690d5396bf3d0b81f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 20:14:19 +0100 Subject: [PATCH 031/206] estate harness: Soul's fake homeserver with member-state echo, and the twodm, reload and convert direct-message scenarios Co-Authored-By: Claude Sonnet 5.5 --- harness/estate/drive.cjs | 103 ++++++++++++-------------------------- harness/estate/fakehs.cjs | 24 +++++---- harness/estate/run.sh | 39 ++++++--------- 3 files changed, 61 insertions(+), 105 deletions(-) diff --git a/harness/estate/drive.cjs b/harness/estate/drive.cjs index 5d69d02a..ed9950ce 100644 --- a/harness/estate/drive.cjs +++ b/harness/estate/drive.cjs @@ -1,82 +1,41 @@ const { chromium } = require('playwright'); -const fs = require('fs'); -const TAG = process.env.TAG; -const out = (n) => `/out/${TAG}-${n}`; -const tl = []; -const T0 = Date.now(); -const t = () => `+${Date.now() - T0}ms`; +const MODE = process.env.MODE; // twodm | reload +const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`; +const log = (...a) => console.log(`[${MODE}]`, ...a); const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']; (async () => { const b = await chromium.launch(); const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, deviceScaleFactor: 1 }); - const pg = await ctx.newPage(); - pg.on('console', (m) => { if (m.type() === 'error' && !/OIDC|issuer|endpoint|supported|ERR_NAME|404/.test(m.text())) tl.push('console.error ' + m.text().slice(0, 200)); }); + const pg = await ctx.newPage(); pg.on('console', (m) => { if (m.type() === 'error' || /command|direct/i.test(m.text())) console.log('[console]', m.type(), m.text().slice(0, 200)); }); + const open = async () => { await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.reload(); await pg.waitForTimeout(9000); }; await pg.goto('http://127.0.0.1:8080/'); await pg.evaluate(() => { localStorage.setItem('cinny_access_token', 'tok'); localStorage.setItem('cinny_device_id', 'DEV'); localStorage.setItem('cinny_user_id', '@t:test'); localStorage.setItem('cinny_hs_base_url', 'http://127.0.0.1:8080'); }); - await pg.goto('http://127.0.0.1:8080/#/home/'); - await pg.waitForTimeout(12000); + await open(); const text = () => pg.evaluate(() => document.body.innerText); - const snap = async (n) => { await pg.screenshot({ path: out(n + '.png') }); tl.push(`--- ${n} ${t()} url=${pg.url()}\n${await text()}`); }; - const buttons = async () => tl.push('buttons ' + JSON.stringify(await pg.evaluate(() => [...document.querySelectorAll('button')].map((e) => { const r = e.getBoundingClientRect(); return [e.getAttribute('aria-label') || e.innerText.slice(0, 30), Math.round(r.x), Math.round(r.y), Math.round(r.width), Math.round(r.height)]; })))); - const small = async () => tl.push('SMALL ' + JSON.stringify(await pg.evaluate(() => [...document.querySelectorAll('button, a, [role="button"]')].filter((e) => e.getClientRects().length).map((e) => [e.getAttribute('aria-label') || e.innerText.slice(0, 30), Math.round(e.getBoundingClientRect().width), Math.round(e.getBoundingClientRect().height)]).filter((r) => r[2] < 44)))); - const layout = async () => tl.push('layout ' + JSON.stringify(await pg.evaluate(() => ({ vw: innerWidth, scrollW: document.documentElement.scrollWidth, bodyScrollW: document.body.scrollWidth })))); - await snap('1-landing'); await buttons(); await layout(); await small(); - if (process.env.NOROOT === '1') { - tl.push('has Create Room: ' + (await pg.getByText('Create Room', { exact: true }).count()) + ' Join with Address: ' + (await pg.getByText('Join with Address', { exact: true }).count()) + ' finder: ' + (await pg.locator('[aria-label="Find a room"]').count())); - await b.close(); return; + const reveal = async (name) => { const loc = pg.locator(`[aria-label="Join ${name}"]`); if (await loc.count()) return loc; for (let r = 0; r < 2; r++) for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); if (await loc.count()) return loc; } } return loc; }; + const tierOf = (name) => pg.evaluate((n) => { const s = document.body.innerText.split('\n').map((x) => x.trim()); const i = s.indexOf(n); if (i < 0) return 'absent'; for (let j = i; j >= 0; j--) if (/^(elsewhere|direct messages)$/i.test(s[j])) return s[j]; return 'none-above'; }, name); + const tap = async (n) => { const l = await reveal(n); log('JOINCTRL', n, await l.count()); await l.first().click(); log('CLICK', n, t()); }; + if (MODE === 'twodm') { + await tap('Slow DM'); await pg.waitForTimeout(300); await tap('Late DM'); + await pg.waitForTimeout(45000); + } else if (MODE === 'convert') { + await tap('Friend DM'); await pg.waitForTimeout(6000); + log('TIER before', await tierOf('Friend DM')); + await pg.goto('http://127.0.0.1:8080/#/direct/!dm1:test/'); await pg.waitForTimeout(4000); + const ed = pg.locator('[contenteditable="true"]'); log('EDITORS', await ed.count(), pg.url()); + await ed.first().click(); await pg.keyboard.type('/converttoroom', { delay: 30 }); await pg.waitForTimeout(500); + log('ED_TEXT', JSON.stringify(await ed.first().innerText())); log('BODY', JSON.stringify((await text()).slice(-600))); + await pg.getByText('Convert direct message to room').first().click(); await pg.waitForTimeout(400); log('ED_PICKED', JSON.stringify(await ed.first().innerHTML()).slice(0, 300)); + await pg.keyboard.press('Enter'); log('SENT', t()); await pg.waitForTimeout(800); log('ED_TEXT2', JSON.stringify(await ed.first().innerText().catch(()=>''))); + if (await ed.first().innerText().catch(() => '')) { await pg.keyboard.press('Enter'); log('SENT2', t()); } + await pg.waitForTimeout(8000); + await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(3000); + } else { + await tap('Slow DM'); await pg.waitForTimeout(3000); + log('RELOAD', t()); await open(); + await pg.waitForTimeout(30000); } - const reveal = async (sel) => { - const loc = pg.locator(sel); - if (await loc.count()) return loc; - for (let round = 0; round < 2; round++) for (const h of HEADERS) { - const hl = pg.getByText(h, { exact: true }); - if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); if (await loc.count()) { tl.push(`revealed ${sel} via ${h}`); return loc; } } - } - tl.push(`could not reveal ${sel}`); return loc; - }; - // expand everything once for the inventory - for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); tl.push('clicked header ' + h); } } - await snap('2-expanded'); await buttons(); await layout(); await small(); - // room finder - const finder = pg.locator('[aria-label="Find a room"], [aria-label="Search"]').first(); - await finder.click(); await pg.waitForTimeout(800); - tl.push('after finder url=' + pg.url() + ' dialogs=' + (await pg.locator('[role="dialog"]').count()) + ' inputs=' + JSON.stringify(await pg.evaluate(() => [...document.querySelectorAll('input')].map((i) => i.placeholder)))); - await snap('3-finder'); - await pg.keyboard.press('Escape'); await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(3000); - // join with frame poll - const joinPoll = async (name, ms) => { - const loc = await reveal(`[aria-label="Join ${name}"]`); - if (!(await loc.count())) { tl.push(`no Join ${name}`); return; } - const u0 = pg.url(); await loc.first().click(); const c = Date.now(); tl.push(`CLICK Join ${name} ${t()}`); - let card = 0, frames = 0, urlAt = null, firstCardAt = null; - while (Date.now() - c < ms) { - const s = await text(); frames++; - if (s.includes('Summary card')) { card++; if (firstCardAt === null) firstCardAt = Date.now() - c; } - if (urlAt === null && pg.url() !== u0) urlAt = Date.now() - c; - await pg.waitForTimeout(40); - } - tl.push(`POLL ${name}: frames=${frames} cardFrames=${card} firstCardAt=${firstCardAt} urlChangedAt=${urlAt} url=${pg.url()}`); - await snap(`4-join-${name.replace(/\W/g, '')}`); - await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2500); - }; - await joinPoll('Commons', 6000); - await joinPoll('Aetheria', 6000); - await joinPoll('Friend DM', 6000); - // timeout path, room - const slow = async (name) => { - const loc = await reveal(`[aria-label="Join ${name}"]`); - if (!(await loc.count())) { tl.push(`no Join ${name}`); return; } - await loc.first().click(); tl.push(`CLICK Join ${name} ${t()}`); - await pg.waitForTimeout(22800); await snap(`5-slow-${name.replace(/\W/g, '')}-21s`); - tl.push(`timeout msg shown: ${(await text()).includes('still on its way')}`); - await pg.waitForTimeout(5000); await snap(`5-slow-${name.replace(/\W/g, '')}-27s`); - tl.push(`after arrival: Join ${name} still present=${await pg.locator(`[aria-label="Join ${name}"]`).count()} url=${pg.url()}`); - }; - await slow('Odin'); - await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2500); - await slow('Slow DM'); - // wide: tier placement (wide tiers start open) - await pg.setViewportSize({ width: 1280, height: 800 }); - await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(6000); await snap('6-wide'); + for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } + log('TIER Slow DM', await tierOf('Slow DM'), '| Late DM', await tierOf('Late DM'), '| Friend DM', await tierOf('Friend DM')); await b.close(); -})().catch((e) => { tl.push('ERROR ' + String(e.stack || e)); process.exitCode = 1; }).finally(() => fs.writeFileSync(out('timeline.txt'), tl.join('\n'))); +})().catch((e) => { log('ERROR', String(e.stack || e)); process.exitCode = 1; }); diff --git a/harness/estate/fakehs.cjs b/harness/estate/fakehs.cjs index 3b9ed641..33deaa64 100644 --- a/harness/estate/fakehs.cjs +++ b/harness/estate/fakehs.cjs @@ -9,11 +9,11 @@ const log = []; const P = ['aetheria', 'ghostlight', 'huginn', 'idunn', 'bifrost', 'odin']; const STEWARD = { aetheria: 'nibu', ghostlight: 'nibu', huginn: 'eureka', idunn: 'eureka' }; const PROJ = P.map((p, i) => ({ id: `!p${i}:test`, alias: `#${p}:test`, name: p[0].toUpperCase() + p.slice(1), key: p })); -const ROOT = '!root:test', PSP = '!proj:test', GEN = '!gen:test', OP = '!op:test', PUB = '!pub:test', STRAY = '!stray:test', DM1 = '!dm1:test', DM2 = '!dm2:test'; -const SLOW = new Set([PROJ[5].id, DM2]); -const NAMES = { [PUB]: 'Commons', [DM1]: 'Friend DM', [DM2]: 'Slow DM', [STRAY]: 'Adhoc' }; +const ROOT = '!root:test', PSP = '!proj:test', GEN = '!gen:test', OP = '!op:test', PUB = '!pub:test', STRAY = '!stray:test', DM1 = '!dm1:test', DM2 = '!dm2:test', DM3 = '!dm3:test'; +const SLOW = new Set([PROJ[5].id, DM2, DM3]); +const NAMES = { [PUB]: 'Commons', [DM1]: 'Friend DM', [DM2]: 'Slow DM', [DM3]: 'Late DM', [STRAY]: 'Adhoc' }; PROJ.forEach((r) => (NAMES[r.id] = r.name)); -const pending = []; const pendingAD = []; let ev = 0; +const held = []; const pendingState = []; const pending = []; const pendingAD = []; let ev = 0; const se = (type, content, sk = '', sender = '@admin:test') => ({ type, content, state_key: sk, sender, event_id: `$e${++ev}`, origin_server_ts: Date.now() }); const strip = (id, name, space, direct) => [ { type: 'm.room.create', state_key: '', sender: direct ? '@friend:test' : '@admin:test', content: space ? { type: 'm.space', room_version: '12' } : { room_version: '12' } }, @@ -35,6 +35,7 @@ const invites = () => { PROJ.forEach((r) => (o[r.id] = { invite_state: { events: strip(r.id, r.name) } })); o[DM1] = { invite_state: { events: strip(DM1, NAMES[DM1], false, true) } }; o[DM2] = { invite_state: { events: strip(DM2, NAMES[DM2], false, true) } }; + o[DM3] = { invite_state: { events: strip(DM3, NAMES[DM3], false, true) } }; return o; }; const child = (id, steward) => ({ type: 'm.space.child', state_key: id, content: { via: ['test'], ...(steward ? { 'org.gamecult.steward': { mind: steward, repo: 'x', source: 'y', steward: 'z' } } : {}) }, sender: '@admin:test', origin_server_ts: 1 }); @@ -61,21 +62,23 @@ http.createServer((req, res) => { return fs.createReadStream(f).pipe(res); } let body = ''; req.on('data', (c) => (body += c)); req.on('end', () => { + if (req.method === 'PUT' && p.includes('m.direct')) console.log('MDIRECT_PUT ' + (Date.now()) + ' ' + body); log.push(`${Date.now()} ${req.method} ${p}${req.method === 'PUT' && p.includes('account_data') ? ' BODY ' + body : ''}`); if (p === '/_matrix/client/versions') return json(res, 200, { versions: ['v1.1', 'v1.5', 'v1.11'], unstable_features: {} }); if (p.endsWith('/sync')) { const since = u.searchParams.get('since'); if (!since) { const join = {}; join[GEN] = joinedRoom(GEN, 'General'); join[STRAY] = joinedRoom(STRAY, 'Adhoc'); - return json(res, 200, { next_batch: 's1', rooms: { join, invite: invites(), leave: {} }, account_data: { events: [] }, presence: { events: [] }, to_device: { events: [] }, device_one_time_keys_count: { signed_curve25519: 50 } }); + return json(res, 200, { next_batch: 's1', rooms: { join, invite: invites(), leave: {} }, account_data: { events: held.slice() }, presence: { events: [] }, to_device: { events: [] }, device_one_time_keys_count: { signed_curve25519: 50 } }); } const respond = () => { const now = Date.now(); const join = {}; for (let i = pending.length - 1; i >= 0; i--) if (pending[i].at <= now) { const id = pending[i].id; pending.splice(i, 1); - const extra = id === DM1 || id === DM2 ? [se('m.room.member', { membership: 'join', displayname: 'Friend' }, '@friend:test', '@friend:test')] : []; - join[id] = joinedRoom(id, NAMES[id] || id, extra); log.push(`${now} SYNC delivers join ${id}`); + const extra = id === DM1 || id === DM2 || id === DM3 ? [se('m.room.member', { membership: 'join', displayname: 'Friend' }, '@friend:test', '@friend:test')] : []; + join[id] = joinedRoom(id, NAMES[id] || id, extra); if (extra.length) { const me = join[id].state.events.find((e) => e.type === 'm.room.member' && e.state_key === ME); me.unsigned = { prev_content: { membership: 'invite', is_direct: true } }; } log.push(`${now} SYNC delivers join ${id}`); console.log('DELIVER ' + now + ' ' + id); } + for (const s of pendingState.splice(0)) { join[s.id] = join[s.id] || { state: { events: [] }, timeline: { events: [], limited: false, prev_batch: 'p' }, ephemeral: { events: [] }, account_data: { events: [] }, unread_notifications: {}, summary: {} }; join[s.id].timeline.events.push(s.ev); console.log('STATE_ECHO ' + now + ' ' + s.id + ' ' + JSON.stringify(s.ev.content)); } const ad = pendingAD.splice(0); if (Object.keys(join).length || ad.length) return json(res, 200, { next_batch: `s${++syncN}`, rooms: { join, invite: {}, leave: {} }, account_data: { events: ad } }); return json(res, 200, { next_batch: since, rooms: {} }); @@ -86,12 +89,15 @@ http.createServer((req, res) => { if (req.method === 'POST' && jm) { const id = jm[1]; log.push(`${Date.now()} JOIN ${id}`); if (id === PROJ[1].id) return json(res, 403, { errcode: 'M_FORBIDDEN', error: 'You are not invited to this room.' }); - pending.push({ id, at: Date.now() + (SLOW.has(id) ? 24000 : 1000) }); + const prior = pending.find((q) => q.id === DM2 || q.id === DM3); + pending.push({ id, at: (id === DM3 || id === DM2) && prior ? prior.at : Date.now() + (SLOW.has(id) ? 24000 : 1000) }); return json(res, 200, { room_id: id }); } + const sm = p.match(/\/rooms\/([^/]+)\/state\/m\.room\.member\/(.+)$/); + if (req.method === 'PUT' && sm) { console.log('STATE_PUT ' + Date.now() + ' ' + sm[1] + ' ' + body); const e = se('m.room.member', JSON.parse(body), sm[2], sm[2]); e.unsigned = { prev_content: { membership: 'join' } }; setTimeout(() => pendingState.push({ id: sm[1], ev: e }), 400); return json(res, 200, { event_id: e.event_id }); } if (p.includes('/account_data/') && req.method === 'PUT') { const type = decodeURIComponent(p.split('/account_data/')[1]); - try { pendingAD.push({ type, content: JSON.parse(body) }); } catch (e) {} + try { const c = JSON.parse(body); pendingAD.push({ type, content: c }); const i = held.findIndex((h) => h.type === type); if (i >= 0) held.splice(i, 1); held.push({ type, content: c }); } catch (e) {} return json(res, 200, {}); } if (p.includes('/hierarchy')) return json(res, 200, { rooms: hierarchy() }); diff --git a/harness/estate/run.sh b/harness/estate/run.sh index d4dd7cf4..f426895f 100644 --- a/harness/estate/run.sh +++ b/harness/estate/run.sh @@ -1,29 +1,20 @@ -# Run inside playwright:v1.48.2-jammy with the repo at /src and /out writable: -# bash /src/harness/estate/run.sh -# Scenario A: root resolves. Scenario B (NOROOT=1): the root alias 404s. +#!/usr/bin/env bash +# Runs inside mcr.microsoft.com/playwright:v1.48.2-jammy with the repo at /src: +# builds the client, then drives three direct-message scenarios against fakehs.cjs. set -e cd /src -mkdir -p /out -npm ci --no-audit --no-fund --ignore-scripts >/out/npmci.log 2>&1 -npm run build > /out/build.log 2>&1; echo BUILD_RC=$? >> /out/build.log +npm ci --no-audit --no-fund --ignore-scripts >/tmp/npmci.log 2>&1 || { tail -20 /tmp/npmci.log; exit 1; } +npm run build > /tmp/build.log 2>&1 || { tail -30 /tmp/build.log; exit 1; } +echo BUILD_OK $(git rev-parse HEAD) printf '{"defaultHomeserver":0,"homeserverList":["127.0.0.1:8080"],"allowCustomHomeservers":true,"featuredCommunities":{"spaces":["#gamecult:test"],"rooms":[],"servers":["test"]},"hashRouter":{"enabled":true,"basename":"/"}}' > dist/config.json -mkdir -p /tmp/pw && cd /tmp/pw && npm init -y >/dev/null && npm i --no-audit --no-fund playwright@1.48.2 >/out/pw.log 2>&1 +mkdir -p /tmp/pw && cd /tmp/pw && npm init -y >/dev/null && npm i --no-audit --no-fund playwright@1.48.2 >/tmp/pw.log 2>&1 cp /src/harness/estate/drive.cjs /tmp/pw/ -runone () { # tag noroot - HSLOG=/out/hslog-$1.txt NOROOT=$2 node /src/harness/estate/fakehs.cjs /src/dist > /out/hs-$1.out 2>&1 & - HS=$! - sleep 2 - TAG=$1 NOROOT=$2 node /tmp/pw/drive.cjs; echo "DRIVE $1 RC=$?" - kill $HS -} -runone root 0 -runone noroot 1 -for t in root noroot; do - echo "=== $t" - grep -E '^(SMALL|layout|has Create|CLICK|POLL|timeout|after arrival|revealed|--- )' /out/$t-timeline.txt | cut -c1-1800 || true +set +e +for m in twodm reload convert; do + HSLOG=/tmp/hslog-$m.txt NOROOT=0 node /src/harness/estate/fakehs.cjs /src/dist > /tmp/hs-$m.out 2>&1 & + HS=$!; sleep 2 + MODE=$m node /tmp/pw/drive.cjs; echo "DRIVE $m RC=$?" + kill $HS; sleep 1 + echo "=== hs $m"; grep -E "MDIRECT_PUT|DELIVER|STATE_" /tmp/hs-$m.out; grep -E " JOIN " /tmp/hslog-$m.txt done -ls -la /out | head -30 -cat /out/noroot-timeline.txt | grep -m3 -i "could not" || true -echo "=== m.direct and joins (root)" -grep -E 'direct|JOIN' /out/hslog-root.txt | cut -c1-220 || true -tail -1 /out/build.log +echo DONE From 44395328e9f1276d07d5479012b7a3d23d641d9d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 20:20:37 +0100 Subject: [PATCH 032/206] estate harness fake: account data is answered by the client's sync token, as a server does Co-Authored-By: Claude Sonnet 5.5 --- harness/estate/fakehs.cjs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/harness/estate/fakehs.cjs b/harness/estate/fakehs.cjs index 33deaa64..c582d3ee 100644 --- a/harness/estate/fakehs.cjs +++ b/harness/estate/fakehs.cjs @@ -13,7 +13,7 @@ const ROOT = '!root:test', PSP = '!proj:test', GEN = '!gen:test', OP = '!op:test const SLOW = new Set([PROJ[5].id, DM2, DM3]); const NAMES = { [PUB]: 'Commons', [DM1]: 'Friend DM', [DM2]: 'Slow DM', [DM3]: 'Late DM', [STRAY]: 'Adhoc' }; PROJ.forEach((r) => (NAMES[r.id] = r.name)); -const held = []; const pendingState = []; const pending = []; const pendingAD = []; let ev = 0; +const held = []; const pendingState = []; const pending = []; const adLog = []; let ev = 0; const se = (type, content, sk = '', sender = '@admin:test') => ({ type, content, state_key: sk, sender, event_id: `$e${++ev}`, origin_server_ts: Date.now() }); const strip = (id, name, space, direct) => [ { type: 'm.room.create', state_key: '', sender: direct ? '@friend:test' : '@admin:test', content: space ? { type: 'm.space', room_version: '12' } : { room_version: '12' } }, @@ -69,7 +69,7 @@ http.createServer((req, res) => { const since = u.searchParams.get('since'); if (!since) { const join = {}; join[GEN] = joinedRoom(GEN, 'General'); join[STRAY] = joinedRoom(STRAY, 'Adhoc'); - return json(res, 200, { next_batch: 's1', rooms: { join, invite: invites(), leave: {} }, account_data: { events: held.slice() }, presence: { events: [] }, to_device: { events: [] }, device_one_time_keys_count: { signed_curve25519: 50 } }); + return json(res, 200, { next_batch: `s${syncN}`, rooms: { join, invite: invites(), leave: {} }, account_data: { events: held.slice() }, presence: { events: [] }, to_device: { events: [] }, device_one_time_keys_count: { signed_curve25519: 50 } }); } const respond = () => { const now = Date.now(); const join = {}; @@ -79,7 +79,7 @@ http.createServer((req, res) => { join[id] = joinedRoom(id, NAMES[id] || id, extra); if (extra.length) { const me = join[id].state.events.find((e) => e.type === 'm.room.member' && e.state_key === ME); me.unsigned = { prev_content: { membership: 'invite', is_direct: true } }; } log.push(`${now} SYNC delivers join ${id}`); console.log('DELIVER ' + now + ' ' + id); } for (const s of pendingState.splice(0)) { join[s.id] = join[s.id] || { state: { events: [] }, timeline: { events: [], limited: false, prev_batch: 'p' }, ephemeral: { events: [] }, account_data: { events: [] }, unread_notifications: {}, summary: {} }; join[s.id].timeline.events.push(s.ev); console.log('STATE_ECHO ' + now + ' ' + s.id + ' ' + JSON.stringify(s.ev.content)); } - const ad = pendingAD.splice(0); + const k = Number(String(since).replace(/^s/, '')) || 0; const ad = adLog.filter((a) => a.after >= k).map((a) => ({ type: a.type, content: a.content })); // a server answers with what changed since the client's token, whichever client asks if (Object.keys(join).length || ad.length) return json(res, 200, { next_batch: `s${++syncN}`, rooms: { join, invite: {}, leave: {} }, account_data: { events: ad } }); return json(res, 200, { next_batch: since, rooms: {} }); }; @@ -97,7 +97,7 @@ http.createServer((req, res) => { if (req.method === 'PUT' && sm) { console.log('STATE_PUT ' + Date.now() + ' ' + sm[1] + ' ' + body); const e = se('m.room.member', JSON.parse(body), sm[2], sm[2]); e.unsigned = { prev_content: { membership: 'join' } }; setTimeout(() => pendingState.push({ id: sm[1], ev: e }), 400); return json(res, 200, { event_id: e.event_id }); } if (p.includes('/account_data/') && req.method === 'PUT') { const type = decodeURIComponent(p.split('/account_data/')[1]); - try { const c = JSON.parse(body); pendingAD.push({ type, content: c }); const i = held.findIndex((h) => h.type === type); if (i >= 0) held.splice(i, 1); held.push({ type, content: c }); } catch (e) {} + try { const c = JSON.parse(body); adLog.push({ type, content: c, after: syncN }); const i = held.findIndex((h) => h.type === type); if (i >= 0) held.splice(i, 1); held.push({ type, content: c }); } catch (e) {} return json(res, 200, {}); } if (p.includes('/hierarchy')) return json(res, 200, { rooms: hierarchy() }); From 719f062ef1214991b9b68a5727aeda2c399b2af3 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 21:10:06 +0100 Subject: [PATCH 033/206] estate: the inbox Accept joins through activateEstateRoom; a join with no server answer keeps the DM listed Co-Authored-By: Claude Sonnet 5.5 --- .../features/estate/joinEstateRoom.test.ts | 20 ++++++++++++++---- src/app/features/estate/joinEstateRoom.ts | 10 ++++++++- src/app/pages/client/inbox/Invites.tsx | 21 +++++++------------ 3 files changed, 33 insertions(+), 18 deletions(-) diff --git a/src/app/features/estate/joinEstateRoom.test.ts b/src/app/features/estate/joinEstateRoom.test.ts index bf96a4af..19020db2 100644 --- a/src/app/features/estate/joinEstateRoom.test.ts +++ b/src/app/features/estate/joinEstateRoom.test.ts @@ -51,12 +51,12 @@ describe('activateEstateRoom', () => { ); it('falls back to the error message, then to a generic one', async () => { - const named = recordingClient(vi.fn().mockRejectedValue(new Error('Boom'))); + const named = recordingClient(vi.fn().mockRejectedValue(Object.assign(new Error('Boom'), { httpStatus: 500 }))); expect(await activateEstateRoom(named.mx, room('joinable'))).toEqual({ kind: 'refused', message: 'Boom', }); - const bare = recordingClient(vi.fn().mockRejectedValue({})); + const bare = recordingClient(vi.fn().mockRejectedValue({ httpStatus: 500 })); expect(await activateEstateRoom(bare.mx, room('joinable'))).toEqual({ kind: 'refused', message: 'Could not join', @@ -65,6 +65,7 @@ describe('activateEstateRoom', () => { it('reports the server refusal and does not decide access itself', async () => { const refusal = Object.assign(new Error('M_FORBIDDEN'), { + httpStatus: 403, data: { error: 'You are not invited to this room.' }, }); const { mx, joinRoom } = recordingClient(vi.fn().mockRejectedValue(refusal)); @@ -75,7 +76,7 @@ describe('activateEstateRoom', () => { }); /** A client holding DM invites as the server sends them: the mark is in the invite event only. */ -const dmClient = (opts: { refuse?: boolean; invites: Record }) => { +const dmClient = (opts: { refuse?: boolean; drop?: boolean; invites: Record }) => { let direct: Record | undefined; const writes: Record[] = []; const joined: string[] = []; @@ -102,7 +103,8 @@ const dmClient = (opts: { refuse?: boolean; invites: Record }) = joinRoom: async (roomId: string) => { // the join lands only after m.direct has been written: record what it held joined.push(JSON.stringify(direct ?? {})); - if (opts.refuse) throw new Error('M_FORBIDDEN'); + if (opts.refuse) throw Object.assign(new Error('M_FORBIDDEN'), { httpStatus: 403 }); + if (opts.drop) throw new Error('fetch failed'); return { roomId }; }, }; @@ -135,6 +137,16 @@ describe('activateEstateRoom for a direct message invite', () => { }); }); +describe('activateEstateRoom when the join gets no answer', () => { + it('keeps the room listed and shows a retryable message', async () => { + const c = dmClient({ drop: true, invites: { '!dm1:test': '@friend:test' } }); + const result = await activateEstateRoom(c.mx, tap('!dm1:test')); + expect(result.kind).toBe('refused'); + expect((result as { message: string }).message).toMatch(/tap again to retry/); + expect(c.current()).toEqual({ '@friend:test': ['!dm1:test'] }); + }); +}); + describe('awaitMyJoin', () => { /** A store that gains the room when the test says so, emitting like the SDK. */ const store = () => { diff --git a/src/app/features/estate/joinEstateRoom.ts b/src/app/features/estate/joinEstateRoom.ts index 36a9166e..a3be428b 100644 --- a/src/app/features/estate/joinEstateRoom.ts +++ b/src/app/features/estate/joinEstateRoom.ts @@ -7,6 +7,9 @@ export type EstateActivation = | { kind: 'joined'; roomId: string } | { kind: 'refused'; message: string }; +const LOST_JOIN_RESPONSE = + 'The connection dropped before the server answered. The join may have gone through; tap again to retry.'; + type JoinClient = Parameters[0]; /** @@ -18,7 +21,10 @@ type JoinClient = Parameters[0]; * An invite marked is_direct carries the mark in the invite event the client * already holds, and the join event does not repeat it. So the room is listed * in m.direct under the inviter before the join is sent, and taken out again if - * the join is refused. Nothing needs to recognise the DM after it arrives. + * the join is refused by a server answer. A join that fails without one (lost + * response, dropped connection) may still have been accepted, so the entry + * stays and the tap is retryable. Nothing needs to recognise the DM after it + * arrives. The inbox Accept goes through here too: one owner for joining. */ export const activateEstateRoom = async ( mx: JoinClient, @@ -29,6 +35,7 @@ export const activateEstateRoom = async ( const err = e as { data?: { error?: string }; message?: string }; return { kind: 'refused', message: err.data?.error ?? err.message ?? 'Could not join' }; }; + const answered = (e: unknown) => typeof (e as { httpStatus?: unknown }).httpStatus === 'number'; const invite = room.state === 'invited' ? mx.getRoom(room.roomId) : null; const inviter = isDirectInvite(invite, mx.getSafeUserId()) ? invite?.getMember(mx.getSafeUserId())?.events?.member?.getSender() @@ -42,6 +49,7 @@ export const activateEstateRoom = async ( const joined = await mx.joinRoom(room.roomId, { viaServers: room.via }); return { kind: 'joined', roomId: joined.roomId }; } catch (e) { + if (!answered(e)) return { kind: 'refused', message: LOST_JOIN_RESPONSE }; if (inviter) await removeRoomIdFromMDirect(mx, room.roomId).catch(() => undefined); return refusal(e); } diff --git a/src/app/pages/client/inbox/Invites.tsx b/src/app/pages/client/inbox/Invites.tsx index 20518e56..39dfcc72 100644 --- a/src/app/pages/client/inbox/Invites.tsx +++ b/src/app/pages/client/inbox/Invites.tsx @@ -46,11 +46,10 @@ import { import { nameInitials } from '../../../utils/common'; import { RoomAvatar } from '../../../components/room-avatar'; import { - addRoomIdToMDirect, getMxIdLocalPart, - guessDmRoomUserId, rateLimitedActions, } from '../../../utils/matrix'; +import { activateEstateRoom } from '../../../features/estate/joinEstateRoom'; import { Time } from '../../../components/message'; import { useElementSizeObserver } from '../../../hooks/useElementSizeObserver'; import { onEnterOrSpace, stopPropagation } from '../../../utils/keyboard'; @@ -160,24 +159,20 @@ function InviteCard({ hideAvatar, }: InviteCardProps) { const mx = useMatrixClient(); - const userId = mx.getSafeUserId(); - const [viewTopic, setViewTopic] = useState(false); const closeTopic = () => setViewTopic(false); const openTopic = () => setViewTopic(true); const [joinState, join] = useAsyncCallback( useCallback(async () => { - const dmUserId = isDirectInvite(invite.room, userId) - ? guessDmRoomUserId(invite.room, userId) - : undefined; - - await mx.joinRoom(invite.roomId); - if (dmUserId) { - await addRoomIdToMDirect(mx, invite.roomId, dmUserId); - } + const result = await activateEstateRoom(mx, { + roomId: invite.roomId, + state: 'invited', + via: [], + }); + if (result.kind === 'refused') throw new Error(result.message); onNavigate(invite.roomId, invite.isSpace); - }, [mx, invite, userId, onNavigate]) + }, [mx, invite, onNavigate]) ); const [leaveState, leave] = useAsyncCallback, MatrixError, []>( useCallback(() => mx.leave(invite.roomId), [mx, invite]) From f6244a478104c233e7638278e6931891b4fd8101 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 21:10:50 +0100 Subject: [PATCH 034/206] estate harness: the join-failure and inbox Accept scenarios run with the committed estate run Co-Authored-By: Claude Sonnet 5.5 --- harness/estate/drive-failures.cjs | 58 +++++++++++++++++++++++++++++++ harness/estate/fakehs.cjs | 7 +++- harness/estate/run.sh | 16 +++++++-- 3 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 harness/estate/drive-failures.cjs diff --git a/harness/estate/drive-failures.cjs b/harness/estate/drive-failures.cjs new file mode 100644 index 00000000..0504e8e6 --- /dev/null +++ b/harness/estate/drive-failures.cjs @@ -0,0 +1,58 @@ +// Failure-mode scenarios for the estate join paths (estate tap and inbox Accept), run by run.sh +// against fakehs.cjs with the env flag each mode needs. +const { chromium } = require('playwright'); +const MODE = process.env.MODE; +const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`; +const log = (...a) => console.log(`[${MODE}]`, ...a); +const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']; +setTimeout(() => { console.log('DRIVER_TIMEOUT'); process.exit(3); }, 150000); +let b; +(async () => { + b = await chromium.launch(); + const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, deviceScaleFactor: 1 }); + const pg = await ctx.newPage(); pg.on('console', (m) => { if (m.type() === 'error') console.log('[console]', m.type(), m.text().slice(0, 160)); }); + const open = async (h = '#/home/') => { await pg.goto('http://127.0.0.1:8080/' + h); await pg.reload(); await pg.waitForTimeout(9000); }; + await pg.goto('http://127.0.0.1:8080/'); + await pg.evaluate(() => { localStorage.setItem('cinny_access_token', 'tok'); localStorage.setItem('cinny_device_id', 'DEV'); localStorage.setItem('cinny_user_id', '@t:test'); localStorage.setItem('cinny_hs_base_url', 'http://127.0.0.1:8080'); }); + await open(); + const body = async () => (await pg.evaluate(() => document.body.innerText)).replace(/\s+/g, ' '); + const reveal = async (name) => { const loc = pg.locator(`[aria-label="Join ${name}"]`); if (await loc.count()) return loc; for (let r = 0; r < 2; r++) for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); if (await loc.count()) return loc; } } return loc; }; + const tierOf = (name) => pg.evaluate((n) => { const s = document.body.innerText.split('\n').map((x) => x.trim()); const i = s.indexOf(n); if (i < 0) return 'absent'; for (let j = i; j >= 0; j--) if (/^(elsewhere|direct messages)$/i.test(s[j])) return s[j]; return 'none-above'; }, name); + const tap = async (n) => { const l = await reveal(n); log('JOINCTRL', n, await l.count()); await l.first().click(); log('CLICK', n, t()); }; + const tiers = async (tag) => { await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2500); for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } log('TIER', tag, '| Friend DM', await tierOf('Friend DM'), '| Slow DM', await tierOf('Slow DM')); }; + const acceptInInbox = async (name) => { + // reach the inbox through the navigator's own Inbox tab + await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2000); + const tab = pg.locator('[aria-label="Inbox"], [aria-label*="nbox"]'); log('INBOX_TAB', await tab.count()); + if (await tab.count()) { await tab.first().click(); await pg.waitForTimeout(1500); } + const inv = pg.getByText('Invites', { exact: true }); if (await inv.count()) { await inv.first().click(); await pg.waitForTimeout(1500); } + log('URL', pg.url()); + if (!/invites/.test(pg.url())) { await pg.goto('http://127.0.0.1:8080/#/inbox/invites/'); await pg.waitForTimeout(3000); log('URL_FALLBACK', pg.url()); } + { const pub = pg.getByText('Public', { exact: true }); log('PUBLIC_FILTER', await pub.count()); if (await pub.count()) { await pub.first().click(); await pg.waitForTimeout(1500); } } + for (let r = 0; r < 3; r++) { const v = pg.getByText('View All', { exact: true }); if (!(await v.count())) break; await v.first().click(); await pg.waitForTimeout(800); } + const b0 = await body(); const i = b0.indexOf(name); log('INBOX has', name, i >= 0, JSON.stringify(b0.slice(Math.max(0, i - 120), i + 120))); + const card = pg.locator('div').filter({ hasText: name }).filter({ has: pg.getByText('Accept', { exact: true }) }).last(); + await card.getByText('Accept', { exact: true }).first().click(); log('ACCEPT', name, t()); + }; + if (MODE === 'pjfail') { + await tap('Friend DM'); await pg.waitForTimeout(3000); + const bb = await body(); const k = bb.search(/Internal server error|Could not join|Joined\./); log('AFTER_FAIL', k >= 0 ? bb.slice(Math.max(0, k - 40), k + 80) : 'no message'); + await tap('Friend DM'); await pg.waitForTimeout(6000); await tiers('after retry'); + } else if (MODE === 'jdrop') { + await tap('Friend DM'); await pg.waitForTimeout(4000); + const bb = await body(); const k = bb.search(/fetch|Failed|network|Could not join|Joined\./i); log('AFTER_DROP', k >= 0 ? bb.slice(Math.max(0, k - 60), k + 80) : 'no message'); + await pg.waitForTimeout(6000); await tiers('after drop'); await open(); await tiers('after reload'); + } else if (MODE === 'admin') { + await tap('Friend DM'); await pg.waitForTimeout(6000); await tiers('admin estate'); + } else if (MODE === 'inboxadmin') { + await acceptInInbox('Friend DM'); await pg.waitForTimeout(6000); await tiers('admin inbox'); + } else if (MODE === 'inboxreload') { + await acceptInInbox('Slow DM'); await pg.waitForTimeout(2000); log('RELOAD', t()); await open('#/inbox/invites/'); + await pg.waitForTimeout(30000); await tiers('inbox reload'); + } else if (MODE === 'pjreload') { + await tap('Slow DM'); await pg.waitForTimeout(2000); log('RELOAD', t()); await open(); + await pg.waitForTimeout(8000); await tiers('pjreload'); + const l = pg.locator('[aria-label="Join Slow DM"]'); log('SLOW_DM_STILL_JOINABLE', await l.count()); + } + await b.close(); +})().catch((e) => { log('ERROR', String(e.stack || e).slice(0, 300)); Promise.resolve(b && b.close()).catch(() => undefined).finally(() => process.exit(1)); }); diff --git a/harness/estate/fakehs.cjs b/harness/estate/fakehs.cjs index c582d3ee..a8180204 100644 --- a/harness/estate/fakehs.cjs +++ b/harness/estate/fakehs.cjs @@ -20,7 +20,7 @@ const strip = (id, name, space, direct) => [ { type: 'm.room.name', state_key: '', sender: '@admin:test', content: { name } }, { type: 'm.room.join_rules', state_key: '', sender: '@admin:test', content: { join_rule: 'invite' } }, ...(direct ? [{ type: 'm.room.member', state_key: '@friend:test', sender: '@friend:test', content: { membership: 'join', displayname: 'Friend' } }] : []), - { type: 'm.room.member', state_key: ME, sender: direct ? '@friend:test' : '@admin:test', content: direct ? { membership: 'invite', is_direct: true } : { membership: 'invite' } }, + { type: 'm.room.member', state_key: ME, sender: direct ? (process.env.INVITER_ADMIN === '1' && id === DM1 ? '@admin:test' : '@friend:test') : '@admin:test', content: direct ? { membership: 'invite', is_direct: true } : { membership: 'invite' } }, ]; const joinedRoom = (id, name, extra = [], space = false) => ({ state: { events: [se('m.room.create', space ? { type: 'm.space', room_version: '12' } : { room_version: '12' }), se('m.room.name', { name }), se('m.room.join_rules', { join_rule: 'invite' }), se('m.room.member', { membership: 'join' }, ME, ME), ...extra] }, @@ -91,13 +91,18 @@ http.createServer((req, res) => { if (id === PROJ[1].id) return json(res, 403, { errcode: 'M_FORBIDDEN', error: 'You are not invited to this room.' }); const prior = pending.find((q) => q.id === DM2 || q.id === DM3); pending.push({ id, at: (id === DM3 || id === DM2) && prior ? prior.at : Date.now() + (SLOW.has(id) ? 24000 : 1000) }); + if (process.env.JOIN_DROP === '1' && id === DM1) { console.log('JOIN_DROPPED ' + Date.now() + ' ' + id); return req.socket.destroy(); } + if (process.env.JOIN_DELAY === '1') { console.log('JOIN_DELAYED ' + Date.now() + ' ' + id); return setTimeout(() => json(res, 200, { room_id: id }), 6000); } return json(res, 200, { room_id: id }); } const sm = p.match(/\/rooms\/([^/]+)\/state\/m\.room\.member\/(.+)$/); if (req.method === 'PUT' && sm) { console.log('STATE_PUT ' + Date.now() + ' ' + sm[1] + ' ' + body); const e = se('m.room.member', JSON.parse(body), sm[2], sm[2]); e.unsigned = { prev_content: { membership: 'join' } }; setTimeout(() => pendingState.push({ id: sm[1], ev: e }), 400); return json(res, 200, { event_id: e.event_id }); } + if (p.includes('/account_data/') && req.method === 'PUT' && p.includes('m.direct') && process.env.FAIL_MDIRECT === '1' && !global.failedOnce) { global.failedOnce = 1; console.log('MDIRECT_FAIL ' + Date.now()); return json(res, 500, { errcode: 'M_UNKNOWN', error: 'Internal server error' }); } if (p.includes('/account_data/') && req.method === 'PUT') { + const DELAY = p.includes('m.direct') && process.env.DELAY_MDIRECT === '1'; const type = decodeURIComponent(p.split('/account_data/')[1]); try { const c = JSON.parse(body); adLog.push({ type, content: c, after: syncN }); const i = held.findIndex((h) => h.type === type); if (i >= 0) held.splice(i, 1); held.push({ type, content: c }); } catch (e) {} + if (DELAY) { console.log('MDIRECT_DELAYED ' + Date.now()); return setTimeout(() => json(res, 200, {}), 6000); } return json(res, 200, {}); } if (p.includes('/hierarchy')) return json(res, 200, { rooms: hierarchy() }); diff --git a/harness/estate/run.sh b/harness/estate/run.sh index f426895f..ef0fa3cd 100644 --- a/harness/estate/run.sh +++ b/harness/estate/run.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Runs inside mcr.microsoft.com/playwright:v1.48.2-jammy with the repo at /src: -# builds the client, then drives three direct-message scenarios against fakehs.cjs. +# builds the client, then drives the direct-message and join-failure scenarios against fakehs.cjs. set -e cd /src npm ci --no-audit --no-fund --ignore-scripts >/tmp/npmci.log 2>&1 || { tail -20 /tmp/npmci.log; exit 1; } @@ -8,7 +8,7 @@ npm run build > /tmp/build.log 2>&1 || { tail -30 /tmp/build.log; exit 1; } echo BUILD_OK $(git rev-parse HEAD) printf '{"defaultHomeserver":0,"homeserverList":["127.0.0.1:8080"],"allowCustomHomeservers":true,"featuredCommunities":{"spaces":["#gamecult:test"],"rooms":[],"servers":["test"]},"hashRouter":{"enabled":true,"basename":"/"}}' > dist/config.json mkdir -p /tmp/pw && cd /tmp/pw && npm init -y >/dev/null && npm i --no-audit --no-fund playwright@1.48.2 >/tmp/pw.log 2>&1 -cp /src/harness/estate/drive.cjs /tmp/pw/ +cp /src/harness/estate/drive.cjs /src/harness/estate/drive-failures.cjs /tmp/pw/ set +e for m in twodm reload convert; do HSLOG=/tmp/hslog-$m.txt NOROOT=0 node /src/harness/estate/fakehs.cjs /src/dist > /tmp/hs-$m.out 2>&1 & @@ -17,4 +17,16 @@ for m in twodm reload convert; do kill $HS; sleep 1 echo "=== hs $m"; grep -E "MDIRECT_PUT|DELIVER|STATE_" /tmp/hs-$m.out; grep -E " JOIN " /tmp/hslog-$m.txt done +failure() { m=$1; shift + env "$@" HSLOG=/tmp/hslog-$m.txt NOROOT=0 node /src/harness/estate/fakehs.cjs /src/dist > /tmp/hs-$m.out 2>&1 & + HS=$!; sleep 2 + MODE=$m node /tmp/pw/drive-failures.cjs; echo "DRIVE $m RC=$?" + kill $HS; sleep 1 + echo "=== hs $m"; grep -E "MDIRECT|DELIVER|JOIN_|STATE_" /tmp/hs-$m.out; grep -E " JOIN " /tmp/hslog-$m.txt; } +failure pjfail FAIL_MDIRECT=1 +failure pjreload DELAY_MDIRECT=1 +failure jdrop JOIN_DROP=1 +failure admin INVITER_ADMIN=1 +failure inboxadmin INVITER_ADMIN=1 +failure inboxreload JOIN_DELAY=1 echo DONE From cdfd43fe9ffd7e3f591babaa7b3d03582246f571 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 21:29:03 +0100 Subject: [PATCH 035/206] estate harness: the failure driver closes its browser on timeout; the run waits for each fake server to exit; prettier Co-Authored-By: Claude Sonnet 5.5 --- harness/estate/drive-failures.cjs | 2 +- harness/estate/run.sh | 4 ++-- src/app/features/estate/joinEstateRoom.test.ts | 4 +++- src/app/pages/client/inbox/Invites.tsx | 5 +---- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/harness/estate/drive-failures.cjs b/harness/estate/drive-failures.cjs index 0504e8e6..e3e71449 100644 --- a/harness/estate/drive-failures.cjs +++ b/harness/estate/drive-failures.cjs @@ -5,8 +5,8 @@ const MODE = process.env.MODE; const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`; const log = (...a) => console.log(`[${MODE}]`, ...a); const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']; -setTimeout(() => { console.log('DRIVER_TIMEOUT'); process.exit(3); }, 150000); let b; +setTimeout(() => { console.log('DRIVER_TIMEOUT'); Promise.resolve(b && b.close()).catch(() => undefined).finally(() => process.exit(3)); }, 240000); (async () => { b = await chromium.launch(); const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, deviceScaleFactor: 1 }); diff --git a/harness/estate/run.sh b/harness/estate/run.sh index ef0fa3cd..26b026b8 100644 --- a/harness/estate/run.sh +++ b/harness/estate/run.sh @@ -14,14 +14,14 @@ for m in twodm reload convert; do HSLOG=/tmp/hslog-$m.txt NOROOT=0 node /src/harness/estate/fakehs.cjs /src/dist > /tmp/hs-$m.out 2>&1 & HS=$!; sleep 2 MODE=$m node /tmp/pw/drive.cjs; echo "DRIVE $m RC=$?" - kill $HS; sleep 1 + kill $HS; wait $HS 2>/dev/null; sleep 1 echo "=== hs $m"; grep -E "MDIRECT_PUT|DELIVER|STATE_" /tmp/hs-$m.out; grep -E " JOIN " /tmp/hslog-$m.txt done failure() { m=$1; shift env "$@" HSLOG=/tmp/hslog-$m.txt NOROOT=0 node /src/harness/estate/fakehs.cjs /src/dist > /tmp/hs-$m.out 2>&1 & HS=$!; sleep 2 MODE=$m node /tmp/pw/drive-failures.cjs; echo "DRIVE $m RC=$?" - kill $HS; sleep 1 + kill $HS; wait $HS 2>/dev/null; sleep 1 echo "=== hs $m"; grep -E "MDIRECT|DELIVER|JOIN_|STATE_" /tmp/hs-$m.out; grep -E " JOIN " /tmp/hslog-$m.txt; } failure pjfail FAIL_MDIRECT=1 failure pjreload DELAY_MDIRECT=1 diff --git a/src/app/features/estate/joinEstateRoom.test.ts b/src/app/features/estate/joinEstateRoom.test.ts index 19020db2..d206a608 100644 --- a/src/app/features/estate/joinEstateRoom.test.ts +++ b/src/app/features/estate/joinEstateRoom.test.ts @@ -51,7 +51,9 @@ describe('activateEstateRoom', () => { ); it('falls back to the error message, then to a generic one', async () => { - const named = recordingClient(vi.fn().mockRejectedValue(Object.assign(new Error('Boom'), { httpStatus: 500 }))); + const named = recordingClient( + vi.fn().mockRejectedValue(Object.assign(new Error('Boom'), { httpStatus: 500 })) + ); expect(await activateEstateRoom(named.mx, room('joinable'))).toEqual({ kind: 'refused', message: 'Boom', diff --git a/src/app/pages/client/inbox/Invites.tsx b/src/app/pages/client/inbox/Invites.tsx index 39dfcc72..ca26bac3 100644 --- a/src/app/pages/client/inbox/Invites.tsx +++ b/src/app/pages/client/inbox/Invites.tsx @@ -45,10 +45,7 @@ import { } from '../../../utils/room'; import { nameInitials } from '../../../utils/common'; import { RoomAvatar } from '../../../components/room-avatar'; -import { - getMxIdLocalPart, - rateLimitedActions, -} from '../../../utils/matrix'; +import { getMxIdLocalPart, rateLimitedActions } from '../../../utils/matrix'; import { activateEstateRoom } from '../../../features/estate/joinEstateRoom'; import { Time } from '../../../components/message'; import { useElementSizeObserver } from '../../../hooks/useElementSizeObserver'; From 2c04bbefa327f56738fdb6e3e60029aa94136d3f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 21:51:32 +0100 Subject: [PATCH 036/206] estate harness: the failure driver never waits on a hung browser close Co-Authored-By: Claude Sonnet 5.5 --- harness/estate/drive-failures.cjs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/harness/estate/drive-failures.cjs b/harness/estate/drive-failures.cjs index e3e71449..9c81424a 100644 --- a/harness/estate/drive-failures.cjs +++ b/harness/estate/drive-failures.cjs @@ -6,7 +6,9 @@ const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`; const log = (...a) => console.log(`[${MODE}]`, ...a); const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']; let b; -setTimeout(() => { console.log('DRIVER_TIMEOUT'); Promise.resolve(b && b.close()).catch(() => undefined).finally(() => process.exit(3)); }, 240000); +// Close the browser, but never wait on it: a hung close must not hang the run. +const quit = (code) => Promise.race([Promise.resolve(b && b.close()).catch(() => undefined), new Promise((r) => setTimeout(r, 5000))]).finally(() => process.exit(code)); +setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 240000); (async () => { b = await chromium.launch(); const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, deviceScaleFactor: 1 }); @@ -54,5 +56,5 @@ setTimeout(() => { console.log('DRIVER_TIMEOUT'); Promise.resolve(b && b.close() await pg.waitForTimeout(8000); await tiers('pjreload'); const l = pg.locator('[aria-label="Join Slow DM"]'); log('SLOW_DM_STILL_JOINABLE', await l.count()); } - await b.close(); -})().catch((e) => { log('ERROR', String(e.stack || e).slice(0, 300)); Promise.resolve(b && b.close()).catch(() => undefined).finally(() => process.exit(1)); }); + await quit(0); +})().catch((e) => { log('ERROR', String(e.stack || e).slice(0, 300)); quit(1); }); From 7453a0bd323da8861ef015221320224eb5fb79a9 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 22:20:28 +0100 Subject: [PATCH 037/206] estate: every join of a direct invite goes through the pre-join filing; only a Matrix 4xx with an errcode is a refusal The room card's Join (deep links, permalinks, history) and /join joined through mx.joinRoom and never filed the DM. They now go through activateEstateRoom. A gateway 5xx, or a status with no errcode, has an unknown outcome like a dropped connection: the entry stays and the tap is retryable. The harness gains Soul's s6 deeplink and gw504 drives. Co-Authored-By: Claude Sonnet 5.5 --- harness/estate/drive-routes.cjs | 33 +++++++++++++++ harness/estate/fakehs.cjs | 1 + harness/estate/run.sh | 6 ++- src/app/components/room-card/RoomCard.tsx | 10 +++-- .../features/estate/joinEstateRoom.test.ts | 42 ++++++++++++++++--- src/app/features/estate/joinEstateRoom.ts | 38 +++++++++++++---- src/app/hooks/useCommands.ts | 3 +- 7 files changed, 114 insertions(+), 19 deletions(-) create mode 100644 harness/estate/drive-routes.cjs diff --git a/harness/estate/drive-routes.cjs b/harness/estate/drive-routes.cjs new file mode 100644 index 00000000..05e89df1 --- /dev/null +++ b/harness/estate/drive-routes.cjs @@ -0,0 +1,33 @@ +const { chromium } = require('playwright'); +const MODE = process.env.MODE; const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`; +const log = (...a) => console.log(`[${MODE}]`, ...a); +const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']; +let b; const quit = (c) => Promise.race([Promise.resolve(b && b.close()).catch(() => 0), new Promise((r) => setTimeout(r, 5000))]).finally(() => process.exit(c)); +setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 120000); +(async () => { + b = await chromium.launch(); + const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true }); + const pg = await ctx.newPage(); + await pg.goto('http://127.0.0.1:8080/'); + await pg.evaluate(() => { localStorage.setItem('cinny_access_token', 'tok'); localStorage.setItem('cinny_device_id', 'DEV'); localStorage.setItem('cinny_user_id', '@t:test'); localStorage.setItem('cinny_hs_base_url', 'http://127.0.0.1:8080'); }); + await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.reload(); await pg.waitForTimeout(9000); + const body = async () => (await pg.evaluate(() => document.body.innerText)).replace(/\s+/g, ' '); + const tierOf = (n) => pg.evaluate((n) => { const s = document.body.innerText.split('\n').map((x) => x.trim()); const i = s.indexOf(n); if (i < 0) return 'absent'; for (let j = i; j >= 0; j--) if (/^(elsewhere|direct messages)$/i.test(s[j])) return s[j]; return 'none-above'; }, n); + const tiers = async (tag) => { await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2500); for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } log('TIER', tag, '| Friend DM', await tierOf('Friend DM')); }; + if (MODE === 'deeplink') { + for (const route of ['direct', 'home']) { + await pg.goto(`http://127.0.0.1:8080/#/${route}/${encodeURIComponent('!dm1:test')}/`); await pg.waitForTimeout(4000); + const bb = await body(); log('ROUTE', route, JSON.stringify(bb.slice(0, 300))); + const j = pg.getByRole('button', { name: /^Join$/ }); log('JOIN_BUTTONS', route, await j.count()); + if (await j.count()) { await j.first().click(); log('CLICK_JOIN', route, t()); await pg.waitForTimeout(6000); break; } + } + await tiers('after deeplink join'); + } else if (MODE === 'gw504') { + const HL = HEADERS; let l = pg.locator('[aria-label="Join Friend DM"]'); + for (let r = 0; r < 2 && !(await l.count()); r++) for (const h of HL) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } + log('JOINCTRL', await l.count()); await l.first().click(); log('CLICK', t()); await pg.waitForTimeout(4000); + const bb = await body(); const k = bb.search(/504|Gateway|connection dropped|Could not join/i); log('AFTER_504', k >= 0 ? bb.slice(Math.max(0, k - 60), k + 120) : 'no message'); + await pg.waitForTimeout(4000); await tiers('after 504 (join delivered by sync)'); + } + await quit(0); +})().catch((e) => { log('ERROR', String(e.stack || e).slice(0, 300)); quit(1); }); diff --git a/harness/estate/fakehs.cjs b/harness/estate/fakehs.cjs index a8180204..4a6875e0 100644 --- a/harness/estate/fakehs.cjs +++ b/harness/estate/fakehs.cjs @@ -91,6 +91,7 @@ http.createServer((req, res) => { if (id === PROJ[1].id) return json(res, 403, { errcode: 'M_FORBIDDEN', error: 'You are not invited to this room.' }); const prior = pending.find((q) => q.id === DM2 || q.id === DM3); pending.push({ id, at: (id === DM3 || id === DM2) && prior ? prior.at : Date.now() + (SLOW.has(id) ? 24000 : 1000) }); + if (process.env.JOIN_504 === '1' && id === DM1) { console.log('JOIN_504 ' + Date.now() + ' ' + id); res.writeHead(504, {'Content-Type':'text/html'}); return res.end('504 Gateway Time-out'); } if (process.env.JOIN_DROP === '1' && id === DM1) { console.log('JOIN_DROPPED ' + Date.now() + ' ' + id); return req.socket.destroy(); } if (process.env.JOIN_DELAY === '1') { console.log('JOIN_DELAYED ' + Date.now() + ' ' + id); return setTimeout(() => json(res, 200, { room_id: id }), 6000); } return json(res, 200, { room_id: id }); diff --git a/harness/estate/run.sh b/harness/estate/run.sh index 26b026b8..70fa5d13 100644 --- a/harness/estate/run.sh +++ b/harness/estate/run.sh @@ -8,7 +8,7 @@ npm run build > /tmp/build.log 2>&1 || { tail -30 /tmp/build.log; exit 1; } echo BUILD_OK $(git rev-parse HEAD) printf '{"defaultHomeserver":0,"homeserverList":["127.0.0.1:8080"],"allowCustomHomeservers":true,"featuredCommunities":{"spaces":["#gamecult:test"],"rooms":[],"servers":["test"]},"hashRouter":{"enabled":true,"basename":"/"}}' > dist/config.json mkdir -p /tmp/pw && cd /tmp/pw && npm init -y >/dev/null && npm i --no-audit --no-fund playwright@1.48.2 >/tmp/pw.log 2>&1 -cp /src/harness/estate/drive.cjs /src/harness/estate/drive-failures.cjs /tmp/pw/ +cp /src/harness/estate/drive.cjs /src/harness/estate/drive-failures.cjs /src/harness/estate/drive-routes.cjs /tmp/pw/ set +e for m in twodm reload convert; do HSLOG=/tmp/hslog-$m.txt NOROOT=0 node /src/harness/estate/fakehs.cjs /src/dist > /tmp/hs-$m.out 2>&1 & @@ -20,7 +20,7 @@ done failure() { m=$1; shift env "$@" HSLOG=/tmp/hslog-$m.txt NOROOT=0 node /src/harness/estate/fakehs.cjs /src/dist > /tmp/hs-$m.out 2>&1 & HS=$!; sleep 2 - MODE=$m node /tmp/pw/drive-failures.cjs; echo "DRIVE $m RC=$?" + MODE=$m node /tmp/pw/${DRIVER:-drive-failures}.cjs; echo "DRIVE $m RC=$?" kill $HS; wait $HS 2>/dev/null; sleep 1 echo "=== hs $m"; grep -E "MDIRECT|DELIVER|JOIN_|STATE_" /tmp/hs-$m.out; grep -E " JOIN " /tmp/hslog-$m.txt; } failure pjfail FAIL_MDIRECT=1 @@ -30,3 +30,5 @@ failure admin INVITER_ADMIN=1 failure inboxadmin INVITER_ADMIN=1 failure inboxreload JOIN_DELAY=1 echo DONE +DRIVER=drive-routes failure deeplink +DRIVER=drive-routes failure gw504 JOIN_504=1 diff --git a/src/app/components/room-card/RoomCard.tsx b/src/app/components/room-card/RoomCard.tsx index 34a7e24b..a5e9b3a9 100644 --- a/src/app/components/room-card/RoomCard.tsx +++ b/src/app/components/room-card/RoomCard.tsx @@ -1,5 +1,5 @@ import React, { ReactNode, useCallback, useRef, useState } from 'react'; -import { MatrixError, Room } from 'matrix-js-sdk'; +import { MatrixError } from 'matrix-js-sdk'; import { Avatar, Badge, @@ -31,6 +31,7 @@ import { RoomType, StateEvent } from '../../../types/matrix/room'; import { useJoinedRoomId } from '../../hooks/useJoinedRoomId'; import { useElementSizeObserver } from '../../hooks/useElementSizeObserver'; import { getRoomAvatarUrl, getStateEvent } from '../../utils/room'; +import { joinEstateRoomIdOrAlias } from '../../features/estate/joinEstateRoom'; import { useStateEventCallback } from '../../hooks/useStateEventCallback'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; @@ -197,8 +198,11 @@ export const RoomCard = as<'div', RoomCardProps>( ) ); - const [joinState, join] = useAsyncCallback( - useCallback(() => mx.joinRoom(roomIdOrAlias, { viaServers }), [mx, roomIdOrAlias, viaServers]) + const [joinState, join] = useAsyncCallback( + useCallback(async () => { + const result = await joinEstateRoomIdOrAlias(mx, roomIdOrAlias, viaServers); + if (result.kind === 'refused') throw new Error(result.message); + }, [mx, roomIdOrAlias, viaServers]) ); const joining = joinState.status === AsyncStatus.Loading || joinState.status === AsyncStatus.Success; diff --git a/src/app/features/estate/joinEstateRoom.test.ts b/src/app/features/estate/joinEstateRoom.test.ts index d206a608..6fccae10 100644 --- a/src/app/features/estate/joinEstateRoom.test.ts +++ b/src/app/features/estate/joinEstateRoom.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { activateEstateRoom, awaitMyJoin } from './joinEstateRoom'; +import { activateEstateRoom, awaitMyJoin, joinEstateRoomIdOrAlias } from './joinEstateRoom'; const room = (state: 'joined' | 'invited' | 'joinable') => ({ roomId: '!r:gamecult.org', @@ -52,13 +52,13 @@ describe('activateEstateRoom', () => { it('falls back to the error message, then to a generic one', async () => { const named = recordingClient( - vi.fn().mockRejectedValue(Object.assign(new Error('Boom'), { httpStatus: 500 })) + vi.fn().mockRejectedValue(Object.assign(new Error('Boom'), { httpStatus: 403, errcode: 'M_FORBIDDEN' })) ); expect(await activateEstateRoom(named.mx, room('joinable'))).toEqual({ kind: 'refused', message: 'Boom', }); - const bare = recordingClient(vi.fn().mockRejectedValue({ httpStatus: 500 })); + const bare = recordingClient(vi.fn().mockRejectedValue({ httpStatus: 403, errcode: 'M_FORBIDDEN' })); expect(await activateEstateRoom(bare.mx, room('joinable'))).toEqual({ kind: 'refused', message: 'Could not join', @@ -78,7 +78,7 @@ describe('activateEstateRoom', () => { }); /** A client holding DM invites as the server sends them: the mark is in the invite event only. */ -const dmClient = (opts: { refuse?: boolean; drop?: boolean; invites: Record }) => { +const dmClient = (opts: { status?: number; errcode?: string; refuse?: boolean; drop?: boolean; invites: Record }) => { let direct: Record | undefined; const writes: Record[] = []; const joined: string[] = []; @@ -105,7 +105,8 @@ const dmClient = (opts: { refuse?: boolean; drop?: boolean; invites: Record { // the join lands only after m.direct has been written: record what it held joined.push(JSON.stringify(direct ?? {})); - if (opts.refuse) throw Object.assign(new Error('M_FORBIDDEN'), { httpStatus: 403 }); + if (opts.refuse) throw Object.assign(new Error('M_FORBIDDEN'), { httpStatus: 403, errcode: 'M_FORBIDDEN' }); + if (opts.status) throw Object.assign(new Error('Gateway Time-out'), { httpStatus: opts.status, errcode: opts.errcode }); if (opts.drop) throw new Error('fetch failed'); return { roomId }; }, @@ -149,6 +150,37 @@ describe('activateEstateRoom when the join gets no answer', () => { }); }); +describe('activateEstateRoom when a gateway answers instead of the server', () => { + it.each([502, 503, 504, 404])('keeps the room listed on a %i with no errcode', async (status) => { + const c = dmClient({ status, invites: { '!dm1:test': '@friend:test' } }); + const result = await activateEstateRoom(c.mx, tap('!dm1:test')); + expect(result.kind).toBe('refused'); + expect((result as { message: string }).message).toMatch(/tap again to retry/); + expect(c.current()).toEqual({ '@friend:test': ['!dm1:test'] }); + }); + + it('keeps the room listed on a 5xx even when it carries an errcode', async () => { + const c = dmClient({ status: 500, errcode: 'M_UNKNOWN', invites: { '!dm1:test': '@friend:test' } }); + await activateEstateRoom(c.mx, tap('!dm1:test')); + expect(c.current()).toEqual({ '@friend:test': ['!dm1:test'] }); + }); +}); + +describe('joinEstateRoomIdOrAlias, the room card and /join', () => { + it('files a direct invite opened by its link before the join is sent', async () => { + const c = dmClient({ invites: { '!dm1:test': '@friend:test' } }); + const result = await joinEstateRoomIdOrAlias(c.mx, '!dm1:test', ['test']); + expect(result).toEqual({ kind: 'joined', roomId: '!dm1:test' }); + expect(c.joined).toEqual([JSON.stringify({ '@friend:test': ['!dm1:test'] })]); + }); + + it('joins an alias or an unheld room without touching m.direct', async () => { + const { mx, calls } = recordingClient(); + await joinEstateRoomIdOrAlias(mx, '#room:gamecult.org'); + expect(calls).toEqual(['joinRoom']); + }); +}); + describe('awaitMyJoin', () => { /** A store that gains the room when the test says so, emitting like the SDK. */ const store = () => { diff --git a/src/app/features/estate/joinEstateRoom.ts b/src/app/features/estate/joinEstateRoom.ts index a3be428b..3cd3bf6d 100644 --- a/src/app/features/estate/joinEstateRoom.ts +++ b/src/app/features/estate/joinEstateRoom.ts @@ -8,7 +8,7 @@ export type EstateActivation = | { kind: 'refused'; message: string }; const LOST_JOIN_RESPONSE = - 'The connection dropped before the server answered. The join may have gone through; tap again to retry.'; + 'The server did not answer the join. It may have gone through; tap again to retry.'; type JoinClient = Parameters[0]; @@ -21,21 +21,28 @@ type JoinClient = Parameters[0]; * An invite marked is_direct carries the mark in the invite event the client * already holds, and the join event does not repeat it. So the room is listed * in m.direct under the inviter before the join is sent, and taken out again if - * the join is refused by a server answer. A join that fails without one (lost - * response, dropped connection) may still have been accepted, so the entry - * stays and the tap is retryable. Nothing needs to recognise the DM after it - * arrives. The inbox Accept goes through here too: one owner for joining. + * the join is refused by a Matrix error answer (a 4xx carrying an errcode). A + * join that fails any other way (lost response, dropped connection, a gateway's + * 5xx or a status with no errcode) may still have been accepted upstream, so the + * entry stays and the tap is retryable. Nothing needs to recognise the DM after it + * arrives. Every join of a room the client holds an invite for goes through + * here: the inbox Accept, the room card, /join. One owner for joining. */ export const activateEstateRoom = async ( mx: JoinClient, - room: Pick + room: Pick & { via?: string[] } ): Promise => { if (room.state === 'joined') return { kind: 'open', roomId: room.roomId }; const refusal = (e: unknown): EstateActivation => { const err = e as { data?: { error?: string }; message?: string }; return { kind: 'refused', message: err.data?.error ?? err.message ?? 'Could not join' }; }; - const answered = (e: unknown) => typeof (e as { httpStatus?: unknown }).httpStatus === 'number'; + const refusedByServer = (e: unknown) => { + const err = e as { httpStatus?: unknown; errcode?: unknown; data?: { errcode?: unknown } }; + const status = err.httpStatus; + const errcode = err.errcode ?? err.data?.errcode; + return typeof status === 'number' && status >= 400 && status < 500 && typeof errcode === 'string'; + }; const invite = room.state === 'invited' ? mx.getRoom(room.roomId) : null; const inviter = isDirectInvite(invite, mx.getSafeUserId()) ? invite?.getMember(mx.getSafeUserId())?.events?.member?.getSender() @@ -49,12 +56,27 @@ export const activateEstateRoom = async ( const joined = await mx.joinRoom(room.roomId, { viaServers: room.via }); return { kind: 'joined', roomId: joined.roomId }; } catch (e) { - if (!answered(e)) return { kind: 'refused', message: LOST_JOIN_RESPONSE }; + if (!refusedByServer(e)) return { kind: 'refused', message: LOST_JOIN_RESPONSE }; if (inviter) await removeRoomIdFromMDirect(mx, room.roomId).catch(() => undefined); return refusal(e); } }; +/** + * activateEstateRoom for a caller that holds only an id or alias (room card, + * /join): a room the client has an invite for is an invited room. + */ +export const joinEstateRoomIdOrAlias = ( + mx: JoinClient, + roomIdOrAlias: string, + via?: string[] +): Promise => + activateEstateRoom(mx, { + roomId: roomIdOrAlias, + state: mx.getRoom(roomIdOrAlias)?.getMyMembership() === 'invite' ? 'invited' : 'joinable', + via, + }); + type MembershipSource = { getRoom(roomId: string): { getMyMembership(): string } | null; on(event: 'Room.myMembership', handler: (room: { roomId: string }, m: string) => void): unknown; diff --git a/src/app/hooks/useCommands.ts b/src/app/hooks/useCommands.ts index c02b0e94..15a21d1e 100644 --- a/src/app/hooks/useCommands.ts +++ b/src/app/hooks/useCommands.ts @@ -26,6 +26,7 @@ import { useRoomNavigate } from './useRoomNavigate'; import { Membership, StateEvent } from '../../types/matrix/room'; import { getStateEvent } from '../utils/room'; import { splitWithSpace } from '../utils/common'; +import { joinEstateRoomIdOrAlias } from '../features/estate/joinEstateRoom'; import { createRoomEncryptionState } from '../components/create-room'; export const SHRUG = '¯\\_(ツ)_/¯'; @@ -233,7 +234,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { (idOrAlias) => isRoomId(idOrAlias) || isRoomAlias(idOrAlias) ); roomIdOrAliases.forEach(async (idOrAlias) => { - await mx.joinRoom(idOrAlias); + await joinEstateRoomIdOrAlias(mx, idOrAlias); }); }, }, From 873c0e30a2b84d3a686cf085db0b30cdaa7fc409 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 22:20:42 +0100 Subject: [PATCH 038/206] estate harness: run the s6 route drives last --- harness/estate/run.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/harness/estate/run.sh b/harness/estate/run.sh index 70fa5d13..379d28ca 100644 --- a/harness/estate/run.sh +++ b/harness/estate/run.sh @@ -29,6 +29,6 @@ failure jdrop JOIN_DROP=1 failure admin INVITER_ADMIN=1 failure inboxadmin INVITER_ADMIN=1 failure inboxreload JOIN_DELAY=1 -echo DONE DRIVER=drive-routes failure deeplink DRIVER=drive-routes failure gw504 JOIN_504=1 +echo DONE From 00ec686b1b49a8cf2063de507fbd793a4f598c0c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 22:33:06 +0100 Subject: [PATCH 039/206] estate tests: the server-refusal and invited-room fixtures carry an errcode and a membership Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/estate/joinEstateRoom.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app/features/estate/joinEstateRoom.test.ts b/src/app/features/estate/joinEstateRoom.test.ts index 6fccae10..07ed43ea 100644 --- a/src/app/features/estate/joinEstateRoom.test.ts +++ b/src/app/features/estate/joinEstateRoom.test.ts @@ -68,6 +68,7 @@ describe('activateEstateRoom', () => { it('reports the server refusal and does not decide access itself', async () => { const refusal = Object.assign(new Error('M_FORBIDDEN'), { httpStatus: 403, + errcode: 'M_FORBIDDEN', data: { error: 'You are not invited to this room.' }, }); const { mx, joinRoom } = recordingClient(vi.fn().mockRejectedValue(refusal)); @@ -85,6 +86,7 @@ const dmClient = (opts: { status?: number; errcode?: string; refuse?: boolean; d const mx = { getSafeUserId: () => '@me:test', getRoom: (roomId: string) => ({ + getMyMembership: () => 'invite', getMember: () => ({ events: { member: { From b20b95876252d0d60c0990308ac4e931b20e1521 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 22:34:49 +0100 Subject: [PATCH 040/206] estate: prettier Co-Authored-By: Claude Sonnet 5.5 --- .../features/estate/joinEstateRoom.test.ts | 33 +++++++++++++++---- src/app/features/estate/joinEstateRoom.ts | 4 ++- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/src/app/features/estate/joinEstateRoom.test.ts b/src/app/features/estate/joinEstateRoom.test.ts index 07ed43ea..8e009ef0 100644 --- a/src/app/features/estate/joinEstateRoom.test.ts +++ b/src/app/features/estate/joinEstateRoom.test.ts @@ -52,13 +52,19 @@ describe('activateEstateRoom', () => { it('falls back to the error message, then to a generic one', async () => { const named = recordingClient( - vi.fn().mockRejectedValue(Object.assign(new Error('Boom'), { httpStatus: 403, errcode: 'M_FORBIDDEN' })) + vi + .fn() + .mockRejectedValue( + Object.assign(new Error('Boom'), { httpStatus: 403, errcode: 'M_FORBIDDEN' }) + ) ); expect(await activateEstateRoom(named.mx, room('joinable'))).toEqual({ kind: 'refused', message: 'Boom', }); - const bare = recordingClient(vi.fn().mockRejectedValue({ httpStatus: 403, errcode: 'M_FORBIDDEN' })); + const bare = recordingClient( + vi.fn().mockRejectedValue({ httpStatus: 403, errcode: 'M_FORBIDDEN' }) + ); expect(await activateEstateRoom(bare.mx, room('joinable'))).toEqual({ kind: 'refused', message: 'Could not join', @@ -79,7 +85,13 @@ describe('activateEstateRoom', () => { }); /** A client holding DM invites as the server sends them: the mark is in the invite event only. */ -const dmClient = (opts: { status?: number; errcode?: string; refuse?: boolean; drop?: boolean; invites: Record }) => { +const dmClient = (opts: { + status?: number; + errcode?: string; + refuse?: boolean; + drop?: boolean; + invites: Record; +}) => { let direct: Record | undefined; const writes: Record[] = []; const joined: string[] = []; @@ -107,8 +119,13 @@ const dmClient = (opts: { status?: number; errcode?: string; refuse?: boolean; d joinRoom: async (roomId: string) => { // the join lands only after m.direct has been written: record what it held joined.push(JSON.stringify(direct ?? {})); - if (opts.refuse) throw Object.assign(new Error('M_FORBIDDEN'), { httpStatus: 403, errcode: 'M_FORBIDDEN' }); - if (opts.status) throw Object.assign(new Error('Gateway Time-out'), { httpStatus: opts.status, errcode: opts.errcode }); + if (opts.refuse) + throw Object.assign(new Error('M_FORBIDDEN'), { httpStatus: 403, errcode: 'M_FORBIDDEN' }); + if (opts.status) + throw Object.assign(new Error('Gateway Time-out'), { + httpStatus: opts.status, + errcode: opts.errcode, + }); if (opts.drop) throw new Error('fetch failed'); return { roomId }; }, @@ -162,7 +179,11 @@ describe('activateEstateRoom when a gateway answers instead of the server', () = }); it('keeps the room listed on a 5xx even when it carries an errcode', async () => { - const c = dmClient({ status: 500, errcode: 'M_UNKNOWN', invites: { '!dm1:test': '@friend:test' } }); + const c = dmClient({ + status: 500, + errcode: 'M_UNKNOWN', + invites: { '!dm1:test': '@friend:test' }, + }); await activateEstateRoom(c.mx, tap('!dm1:test')); expect(c.current()).toEqual({ '@friend:test': ['!dm1:test'] }); }); diff --git a/src/app/features/estate/joinEstateRoom.ts b/src/app/features/estate/joinEstateRoom.ts index 3cd3bf6d..d69df82c 100644 --- a/src/app/features/estate/joinEstateRoom.ts +++ b/src/app/features/estate/joinEstateRoom.ts @@ -41,7 +41,9 @@ export const activateEstateRoom = async ( const err = e as { httpStatus?: unknown; errcode?: unknown; data?: { errcode?: unknown } }; const status = err.httpStatus; const errcode = err.errcode ?? err.data?.errcode; - return typeof status === 'number' && status >= 400 && status < 500 && typeof errcode === 'string'; + return ( + typeof status === 'number' && status >= 400 && status < 500 && typeof errcode === 'string' + ); }; const invite = room.state === 'invited' ? mx.getRoom(room.roomId) : null; const inviter = isDirectInvite(invite, mx.getSafeUserId()) From e377d9f722670462b14b8dc23e3aefad31dc0aae Mon Sep 17 00:00:00 2001 From: Nibu Date: Sat, 31 Oct 2026 21:43:40 +0000 Subject: [PATCH 041/206] Background scene: two-pass WebGL2 terrain and world tree with login pull-back Pass 1 sphere-traces CultMath noise terrain (fbm plus Phacelle erosion) and the world tree with a conservative Lipschitz step, writing colour and depth. Pass 2 marches fog and clouds only up to that depth at low resolution with TAA. The camera pulls back from the tree on login. Vendors CultLib's generated GLSL (658c09e). The Phacelle gradient bound is an empirical probe, not a proof. Headless harness compiles, renders and times it; nothing run on a GPU. Co-Authored-By: Claude Sonnet 5.5 --- harness/scene/README.md | 19 + harness/scene/bundle.cjs | 35 + harness/scene/drive.cjs | 162 +++ harness/scene/entry.ts | 87 ++ src/app/features/scene/README.md | 54 + src/app/features/scene/Scene.ts | 414 +++++++ src/app/features/scene/budget.test.ts | 34 + src/app/features/scene/budget.ts | 49 + src/app/features/scene/camera.test.ts | 42 + src/app/features/scene/camera.ts | 56 + src/app/features/scene/palette.ts | 27 + src/app/features/scene/shaderSource.ts | 29 + .../scene/shaders/CultMath.Phacelle.glsl | 99 ++ src/app/features/scene/shaders/CultMath.glsl | 1035 +++++++++++++++++ src/app/features/scene/shaders/README.md | 15 + .../scene/shaders/atmosphere.frag.glsl | 90 ++ src/app/features/scene/shaders/common.glsl | 31 + .../scene/shaders/composite.frag.glsl | 26 + .../scene/shaders/fullscreen.vert.glsl | 8 + .../features/scene/shaders/resolve.frag.glsl | 41 + .../features/scene/shaders/terrain.frag.glsl | 180 +++ src/app/features/scene/world.test.ts | 40 + src/app/features/scene/world.ts | 114 ++ 23 files changed, 2687 insertions(+) create mode 100644 harness/scene/README.md create mode 100644 harness/scene/bundle.cjs create mode 100644 harness/scene/drive.cjs create mode 100644 harness/scene/entry.ts create mode 100644 src/app/features/scene/README.md create mode 100644 src/app/features/scene/Scene.ts create mode 100644 src/app/features/scene/budget.test.ts create mode 100644 src/app/features/scene/budget.ts create mode 100644 src/app/features/scene/camera.test.ts create mode 100644 src/app/features/scene/camera.ts create mode 100644 src/app/features/scene/palette.ts create mode 100644 src/app/features/scene/shaderSource.ts create mode 100644 src/app/features/scene/shaders/CultMath.Phacelle.glsl create mode 100644 src/app/features/scene/shaders/CultMath.glsl create mode 100644 src/app/features/scene/shaders/README.md create mode 100644 src/app/features/scene/shaders/atmosphere.frag.glsl create mode 100644 src/app/features/scene/shaders/common.glsl create mode 100644 src/app/features/scene/shaders/composite.frag.glsl create mode 100644 src/app/features/scene/shaders/fullscreen.vert.glsl create mode 100644 src/app/features/scene/shaders/resolve.frag.glsl create mode 100644 src/app/features/scene/shaders/terrain.frag.glsl create mode 100644 src/app/features/scene/world.test.ts create mode 100644 src/app/features/scene/world.ts diff --git a/harness/scene/README.md b/harness/scene/README.md new file mode 100644 index 00000000..382819ec --- /dev/null +++ b/harness/scene/README.md @@ -0,0 +1,19 @@ +# Scene harness + +Compiles, renders and times the background scene (`src/app/features/scene/`) in headless +Chromium. Not shipped, not run by CI. + + node bundle.cjs # esbuild bundle of entry.ts plus an index.html + node drive.cjs compile # compile and link each stage, JSON report + node drive.cjs shots # rest, mid pull-back and pulled-back screenshots + node drive.cjs measure # GPU time per pass by timer query, two viewports, two levels + node drive.cjs variants # the same with source patches (no erosion, no tree, value-only fbm) + node drive.cjs phacelle # the Phacelle gradient probe behind world.ts `phacelleGradBound` + +Needs `esbuild` and `playwright@1.48.2` on `NODE_PATH` and a Chromium. The first run was done on a +host with no GPU, so `drive.cjs` launches with `--use-angle=swiftshader` (software Vulkan). On a +machine with a GPU, remove those flags to measure the real device. `bundle.cjs` must run from a +directory whose ancestors hold no empty `package.json` (esbuild reads them). + +Timings from SwiftShader are CPU time: use them for the split between passes and for before and +after of one change, never as a phone frame time. diff --git a/harness/scene/bundle.cjs b/harness/scene/bundle.cjs new file mode 100644 index 00000000..62a47078 --- /dev/null +++ b/harness/scene/bundle.cjs @@ -0,0 +1,35 @@ +// Bundles entry.ts with the scene's ?raw GLSL imports into $OUT/bundle.js plus an index.html. +// usage: node bundle.cjs (esbuild from NODE_PATH) +const esbuild = require('esbuild'); +const fs = require('fs'); +const path = require('path'); +const out = process.argv[2]; +fs.mkdirSync(out, { recursive: true }); +const rawPlugin = { + name: 'raw', + setup(b) { + b.onResolve({ filter: /\?raw$/ }, (a) => ({ + path: path.resolve(a.resolveDir, a.path.slice(0, -4)), + namespace: 'raw', + })); + b.onLoad({ filter: /.*/, namespace: 'raw' }, (a) => ({ + contents: fs.readFileSync(a.path, 'utf8'), + loader: 'text', + })); + }, +}; +esbuild + .build({ + entryPoints: [path.join(__dirname, 'entry.ts')], + bundle: true, + outfile: path.join(out, 'bundle.js'), + plugins: [rawPlugin], + format: 'iife', + target: 'es2020', + }) + .then(() => { + fs.writeFileSync( + path.join(out, 'index.html'), + '' + ); + }); diff --git a/harness/scene/drive.cjs b/harness/scene/drive.cjs new file mode 100644 index 00000000..3b41c567 --- /dev/null +++ b/harness/scene/drive.cjs @@ -0,0 +1,162 @@ +// Drives the scene in headless Chromium (software GL unless the host has a GPU). +// usage: node drive.cjs [outdir] modes: compile | shots | measure +// Needs playwright from NODE_PATH and PLAYWRIGHT_BROWSERS_PATH. Prints one JSON report. +const { chromium } = require('playwright'); +const path = require('path'); +const [, , dir, mode, outdir = '.'] = process.argv; + +(async () => { + const b = await chromium.launch({ + args: [ + '--use-gl=angle', + '--use-angle=swiftshader', + '--enable-unsafe-swiftshader', + '--ignore-gpu-blocklist', + ], + }); + const page = await b.newPage({ viewport: { width: 960, height: 540 } }); + page.on('console', (m) => process.stderr.write(`[page] ${m.text()}\n`)); + await page.goto('file://' + path.resolve(dir, 'index.html')); + const report = { mode }; + report.renderer = await page.evaluate(() => { + const g = document.createElement('canvas').getContext('webgl2'); + const e = g.getExtension('WEBGL_debug_renderer_info'); + return g.getParameter(e ? e.UNMASKED_RENDERER_WEBGL : g.RENDERER); + }); + + if (mode === 'compile') { + report.stages = await page.evaluate(() => { + const gl = document.createElement('canvas').getContext('webgl2'); + const S = window.__scene; + return ['terrain', 'atmosphere', 'resolve', 'composite'].map((stage) => { + const t0 = performance.now(); + try { + const p = S.linkStage(gl, stage); + return { + stage, + ok: true, + ms: +(performance.now() - t0).toFixed(1), + sourceBytes: S.fragmentSource(stage).length, + log: gl.getProgramInfoLog(p), + }; + } catch (e) { + return { + stage, + ok: false, + ms: +(performance.now() - t0).toFixed(1), + log: String(e.log || e), + }; + } + }); + }); + } + + if (mode === 'phacelle') { + report.probe = await page.evaluate(() => + window.__probePhacelle( + window.__scene.WORLD.erodeSide.slice(), + window.__scene.WORLD.erodeNorm, + 4 + ) + ); + } + + if (mode === 'shots') { + await page.evaluate(() => { + const c = document.getElementById('c'); + window.__s = window.__scene.createScene({ + canvas: c, + unthrottled: true, + fixedLevel: window.__scene.LEVELS[0], + }); + }); + const wait = (ms) => page.waitForTimeout(ms); + await wait(4000); + await page.screenshot({ path: path.join(outdir, 'rest.png') }); + await page.evaluate(() => window.__s.login()); + await wait(3500); + await page.screenshot({ path: path.join(outdir, 'mid-pullback.png') }); + await wait(9000); + await page.screenshot({ path: path.join(outdir, 'pulled-back.png') }); + report.shots = ['rest.png', 'mid-pullback.png', 'pulled-back.png']; + } + + if (mode === 'measure' || mode === 'variants') { + const VARIANTS = { + baseline: null, + // What a public value-only fbm would cost: the same octaves without the gradient work. + valueOnlyFbm: [ + 'float terrainFbm(vec2 xz) { return cultmath_fbm_grad(vec3(xz * W_FREQ, 0.0), W_OCTAVES, W_LACUNARITY, W_GAIN).w; }', + 'float terrainFbm(vec2 xz) { float a = 1.0, fr = 1.0, v = 0.0; for (int i = 0; i < W_OCTAVES; i++) { v += a * cultmath_snoise(vec3(xz * W_FREQ * fr, 0.0)); fr *= W_LACUNARITY; a *= W_GAIN; } return v; }', + ], + noErosion: ['#define W_ERODE_ENABLED 1', '#define W_ERODE_ENABLED 0'], + noTree: ['float dr = treeDistance(p, leafness);', 'float dr = 1e9; leafness = 0.0;'], + }; + const wanted = mode === 'variants' ? Object.keys(VARIANTS) : ['baseline']; + const sizes = [ + [960, 540], + [540, 1200], + ]; + report.runs = []; + for (const [w, h] of sizes) { + for (const [li, variant] of wanted.flatMap((v) => + mode === 'variants' + ? [[0, v]] + : [ + [0, v], + [3, v], + ] + )) { + await page.setViewportSize({ width: w, height: h }); + const r = await page.evaluate( + ({ li, rep }) => + new Promise((res) => { + const rows = []; + const c = document.getElementById('c'); + const s = window.__scene.createScene({ + canvas: c, + unthrottled: true, + fixedLevel: window.__scene.LEVELS[li], + patchSource: rep + ? (st, src) => + st === 'terrain' + ? rep[0].startsWith('#define') + ? src.replace(/#define W_ERODE_ENABLED \d/, rep[1]) + : src.split(rep[0]).join(rep[1]) + : src + : undefined, + onStats: (st) => { + rows.push(st); + if (rows.length === 40) { + s.dispose(); + const keep = rows.slice(10); + const med = (k) => + keep.map((x) => x.passMs[k]).sort((a, b) => a - b)[keep.length >> 1]; + res({ + solid: keep[0].solidSize, + atm: keep[0].atmosphereSize, + medianMs: { + terrain: med('terrain'), + atmosphere: med('atmosphere'), + resolve: med('resolve'), + composite: med('composite'), + }, + }); + } + }, + }); + if (!s) res({ error: 'createScene returned null' }); + }), + { li, rep: VARIANTS[variant] } + ); + report.runs.push({ viewport: [w, h], level: li, variant, ...r }); + } + } + } + + console.log(JSON.stringify(report, null, 1)); + await b.close(); +})().catch((e) => { + console.log('ERR', String(e).slice(0, 2000)); + process.exit(1); +}); diff --git a/harness/scene/entry.ts b/harness/scene/entry.ts new file mode 100644 index 00000000..d43a5453 --- /dev/null +++ b/harness/scene/entry.ts @@ -0,0 +1,87 @@ +// Browser entry for the harness: exposes the scene's seams to the driver. Not shipped. +import { createScene, linkStage, ShaderCompileError } from '../../src/app/features/scene/Scene'; +import { LEVELS } from '../../src/app/features/scene/budget'; +import { fragmentSource } from '../../src/app/features/scene/shaderSource'; +import { WORLD } from '../../src/app/features/scene/world'; + +(window as unknown as Record).__scene = { + createScene, + linkStage, + ShaderCompileError, + LEVELS, + fragmentSource, + WORLD, +}; + +// ---- Phacelle gradient probe ----------------------------------------------------------------- +import cultmath from '../../src/app/features/scene/shaders/CultMath.glsl?raw'; +import phacelle from '../../src/app/features/scene/shaders/CultMath.Phacelle.glsl?raw'; +import { vertexSource } from '../../src/app/features/scene/shaderSource'; + +const PROBE_FRAG = `#version 300 es +precision highp float; precision highp int; +${cultmath} +${phacelle} +uniform float u_seed; uniform vec3 u_side; uniform float u_norm; uniform float u_span; +in vec2 v_uv; layout(location=0) out vec4 o; +void main() { + // The scene samples the z = 0 slice: random points in a u_span square of it. + vec3 h = vec3(cultmath_pcg3d(vec3(gl_FragCoord.xy, u_seed))) ; + vec2 xy = vec2(float(uint(int(h.x)) >> 8), float(uint(int(h.y)) >> 8)) * (1.0 / 16777216.0) * u_span; + CultPhasor ph = cultmath_phacelle(vec3(xy, 0.0), u_side, 0.0, u_norm); + o = vec4(length(ph.cos.xyz), length(ph.sin.xyz), ph.cos.w, ph.sin.w); +}`; + +/** Max |grad| of the phasor over `draws * 1024^2` random points; min/max of the cos component. */ +(window as unknown as Record).__probePhacelle = ( + side: number[], + norm: number, + draws: number +) => { + const c = document.createElement('canvas'); + const gl = c.getContext('webgl2')!; + gl.getExtension('EXT_color_buffer_float'); + const mk = (t: number, s: string) => { + const x = gl.createShader(t)!; + gl.shaderSource(x, s); + gl.compileShader(x); + if (!gl.getShaderParameter(x, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(x)!); + return x; + }; + const p = gl.createProgram()!; + gl.attachShader(p, mk(gl.VERTEX_SHADER, vertexSource)); + gl.attachShader(p, mk(gl.FRAGMENT_SHADER, PROBE_FRAG)); + gl.linkProgram(p); + gl.useProgram(p); + const N = 1024; + const tex = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, tex); + gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA32F, N, N); + const fbo = gl.createFramebuffer(); + gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0); + gl.viewport(0, 0, N, N); + gl.bindVertexArray(gl.createVertexArray()); + gl.uniform3fv(gl.getUniformLocation(p, 'u_side'), side); + gl.uniform1f(gl.getUniformLocation(p, 'u_norm'), norm); + gl.uniform1f(gl.getUniformLocation(p, 'u_span'), 400); + const buf = new Float32Array(N * N * 4); + let gradCos = 0, + gradSin = 0, + vMin = 1e9, + vMax = -1e9, + n = 0; + for (let d = 0; d < draws; d++) { + gl.uniform1f(gl.getUniformLocation(p, 'u_seed'), d + 1); + gl.drawArrays(gl.TRIANGLES, 0, 3); + gl.readPixels(0, 0, N, N, gl.RGBA, gl.FLOAT, buf); + for (let i = 0; i < buf.length; i += 4) { + if (buf[i] > gradCos) gradCos = buf[i]; + if (buf[i + 1] > gradSin) gradSin = buf[i + 1]; + if (buf[i + 2] < vMin) vMin = buf[i + 2]; + if (buf[i + 2] > vMax) vMax = buf[i + 2]; + n++; + } + } + return { samples: n, maxGradCos: gradCos, maxGradSin: gradSin, minCos: vMin, maxCos: vMax }; +}; diff --git a/src/app/features/scene/README.md b/src/app/features/scene/README.md new file mode 100644 index 00000000..deed806a --- /dev/null +++ b/src/app/features/scene/README.md @@ -0,0 +1,54 @@ +# Background scene + +A two-pass WebGL2 scene for the client's background canvas: an infinite terrain of CultMath +noise with a world tree at its centre, dusk atmosphere and clouds, and a camera that pulls back +from the tree on login. It is a self-contained module; nothing in the client imports it yet. +Integration (a canvas under the app, `createScene`, `login()` when the session starts, `dispose()`) +is Void's cut `terrain-background`; the personas (`orbiting-personas`) and the squirrel are later cuts. + +## Entry points + +- `Scene.ts` `createScene({ canvas, ... })` returns `{ login, dispose }`, or `null` when WebGL2 or + `EXT_color_buffer_float` is missing (keep the CSS ground then). It caps at 30 fps, pauses when + the tab is hidden, renders 32 frames and stops under `prefers-reduced-motion`, and steps down a + quality ladder (`budget.ts`) against a 4 ms GPU budget when timer queries exist. +- `camera.ts` the pull-back path as pure functions (`pullProgress`, `cameraPose`). +- `world.ts` every world number, and the Lipschitz bounds the sphere trace steps by. The GLSL + `#define`s are generated from it; no number is written twice. +- `shaders/` pass 1 `terrain.frag.glsl`, pass 2 `atmosphere.frag.glsl`, `resolve.frag.glsl` (TAA), + `composite.frag.glsl`, plus CultLib's vendored GLSL (`shaders/README.md`). + +## How it renders + +1. **Solid pass** (`terrain.frag.glsl`, 0.4-0.75 of canvas resolution): sphere trace of + `min(terrain, tree)`. Terrain height is `mask(r) * (A * fbm + E * phacelle)`, with + `cultmath_fbm_grad(...).w` and `cultmath_phacelle(...).cos.w`. It writes lit colour with + coverage and the hit distance (second render target). +2. **Atmosphere pass** (0.25-0.5 of canvas): closed-form exponential height fog plus a cloud + slab marched with a few jittered steps, both only up to the hit distance from pass 1. +3. **Resolve**: TAA on the atmosphere (Halton jitter, history reprojected by the pass-1 depth, + 3x3 neighbourhood clamp, 1/8 blend; reset on resize, level change and wake). +4. **Composite** at canvas resolution: solid over analytic sky, then the atmosphere, ACES + tonemap, dither. + +## The step bound, and what is not proven + +The trace steps by `(y - h(x, z)) / sqrt(1 + G^2)`, `G` the Lipschitz bound of the height field, +computed from `SNOISE_LIPSCHITZ` (CultMath's constant for `snoise`) in `world.ts`. Above the +envelope (fbm plus the erosion amplitude) the step uses the envelope's bound alone and Phacelle is +not evaluated. + +- The Phacelle gradient bound `phacelleGradBound` (6.3) is **an empirical probe, not a proof**: + the maximum `|grad|` of the cos component over 4,194,304 random points (5.04) times 1.25 + (`harness/scene/drive.cjs phacelle`). Replace it with Libby's bound; until then the march is + only as safe as that probe. +- The bound makes grazing rays crawl. A ray that exhausts its steps while still below the highest + terrain is ended as a distant surface (fog hides it). That is a visible approximation, not a + hit proven by the march. +- The low-resolution atmosphere samples pass-1 depth nearest, so silhouettes of near objects + carry a thin fog halo. + +## Measured and not measured + +See the pull request or `harness/scene/README.md` for the numbers and how they were taken. They +come from a software rasterizer, not a GPU, and nothing has been run on a phone. diff --git a/src/app/features/scene/Scene.ts b/src/app/features/scene/Scene.ts new file mode 100644 index 00000000..367e6cc9 --- /dev/null +++ b/src/app/features/scene/Scene.ts @@ -0,0 +1,414 @@ +import { fragmentSource, vertexSource, Stage } from './shaderSource'; +import { cameraPose, pullProgress, CameraPose } from './camera'; +import { BudgetLadder, Level } from './budget'; +import { PALETTE } from './palette'; + +/** Phone GPU budget for the whole scene, from the ground-cut doc's budget table. */ +export const GPU_BUDGET_MS = 4.0; +const FRAME_INTERVAL_MS = 1000 / 30; +const REDUCED_MOTION_FRAMES = 32; +const MAX_DPR = 1.5; + +export type PassName = 'terrain' | 'atmosphere' | 'resolve' | 'composite'; +const PASSES: PassName[] = ['terrain', 'atmosphere', 'resolve', 'composite']; + +export interface SceneStats { + /** GPU milliseconds per pass for one finished frame (timer queries; absent without the extension). */ + passMs: Record; + level: number; + solidSize: [number, number]; + atmosphereSize: [number, number]; +} + +export interface SceneOptions { + canvas: HTMLCanvasElement; + /** Called per finished frame when timer queries exist. */ + onStats?: (s: SceneStats) => void; + /** Weather knob, 0 clear .. 1 overcast. */ + coverage?: number; + /** Fixed level instead of the adaptive ladder (measurement). */ + fixedLevel?: Level; + /** Ignore the 30 fps cap and the visibility pause (measurement). */ + unthrottled?: boolean; + patchSource?: SourcePatch; +} + +export interface Scene { + /** Start the camera pull-back from the tree. */ + login(): void; + dispose(): void; +} + +/** Measurement seam: the harness rewrites a stage's source to time a variant. Never set in the client. */ +export type SourcePatch = (stage: Stage, source: string) => string; + +export class ShaderCompileError extends Error { + constructor(readonly stage: Stage | 'vertex', readonly log: string) { + super(`${stage} shader failed to compile:\n${log}`); + } +} + +function compile( + gl: WebGL2RenderingContext, + type: number, + source: string, + stage: Stage | 'vertex' +): WebGLShader { + const sh = gl.createShader(type)!; + gl.shaderSource(sh, source); + gl.compileShader(sh); + if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) { + const log = gl.getShaderInfoLog(sh) ?? ''; + gl.deleteShader(sh); + throw new ShaderCompileError(stage, log); + } + return sh; +} + +export function linkStage( + gl: WebGL2RenderingContext, + stage: Stage, + patch?: SourcePatch +): WebGLProgram { + const vs = compile(gl, gl.VERTEX_SHADER, vertexSource, 'vertex'); + const fs = compile( + gl, + gl.FRAGMENT_SHADER, + patch ? patch(stage, fragmentSource(stage)) : fragmentSource(stage), + stage + ); + const prog = gl.createProgram()!; + gl.attachShader(prog, vs); + gl.attachShader(prog, fs); + gl.linkProgram(prog); + gl.deleteShader(vs); + gl.deleteShader(fs); + if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) { + const log = gl.getProgramInfoLog(prog) ?? ''; + gl.deleteProgram(prog); + throw new ShaderCompileError(stage, log); + } + return prog; +} + +interface Target { + tex: WebGLTexture; + w: number; + h: number; +} + +function halton(i: number, base: number): number { + let f = 1; + let r = 0; + let n = i; + while (n > 0) { + f /= base; + r += f * (n % base); + n = Math.floor(n / base); + } + return r; +} + +/** Null when WebGL2 or float colour buffers are missing; the host keeps its CSS ground. */ +export function createScene(opts: SceneOptions): Scene | null { + const { canvas } = opts; + const gl = canvas.getContext('webgl2', { + antialias: false, + alpha: false, + depth: false, + stencil: false, + powerPreference: 'low-power', + }); + if (!gl || !gl.getExtension('EXT_color_buffer_float')) return null; + gl.getExtension('OES_texture_float_linear'); + const timer = gl.getExtension('EXT_disjoint_timer_query_webgl2') as { + TIME_ELAPSED_EXT: number; + GPU_DISJOINT_EXT: number; + } | null; + + const programs = {} as Record; + for (const p of PASSES) programs[p] = linkStage(gl, p, opts.patchSource); + const uniforms = new Map>(); + const loc = (p: WebGLProgram, name: string) => { + let m = uniforms.get(p); + if (!m) uniforms.set(p, (m = new Map())); + if (!m.has(name)) m.set(name, gl.getUniformLocation(p, name)); + return m.get(name) ?? null; + }; + + const vao = gl.createVertexArray(); + gl.bindVertexArray(vao); + + const makeTarget = (w: number, h: number, format: number, filter: number): Target => { + const tex = gl.createTexture()!; + gl.bindTexture(gl.TEXTURE_2D, tex); + gl.texStorage2D(gl.TEXTURE_2D, 1, format, w, h); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + return { tex, w, h }; + }; + + const fbos = { + solid: gl.createFramebuffer()!, + atm: gl.createFramebuffer()!, + hist: [gl.createFramebuffer()!, gl.createFramebuffer()!], + }; + let solid: Target; + let depth: Target; + let atm: Target; + let hist: [Target, Target]; + const freeTargets = () => { + for (const t of [solid, depth, atm, hist?.[0], hist?.[1]]) if (t) gl.deleteTexture(t.tex); + }; + + const attach = (fbo: WebGLFramebuffer, targets: Target[]) => { + gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); + targets.forEach((t, i) => + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0 + i, gl.TEXTURE_2D, t.tex, 0) + ); + gl.drawBuffers(targets.map((_, i) => gl.COLOR_ATTACHMENT0 + i)); + }; + + const ladder = new BudgetLadder(GPU_BUDGET_MS); + const level = (): Level => opts.fixedLevel ?? ladder.current; + + let canvasW = 0; + let canvasH = 0; + let sizedFor: Level | undefined; + let resetHistory = true; + + const ensureSize = () => { + const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR); + const w = Math.max(2, Math.round(canvas.clientWidth * dpr)); + const h = Math.max(2, Math.round(canvas.clientHeight * dpr)); + const lv = level(); + if (w === canvasW && h === canvasH && lv === sizedFor) return; + canvasW = w; + canvasH = h; + sizedFor = lv; + canvas.width = w; + canvas.height = h; + freeTargets(); + const sw = Math.max(2, Math.round(w * lv.solidScale)); + const sh = Math.max(2, Math.round(h * lv.solidScale)); + const aw = Math.max(2, Math.round(w * lv.atmosphereScale)); + const ah = Math.max(2, Math.round(h * lv.atmosphereScale)); + solid = makeTarget(sw, sh, gl.RGBA16F, gl.LINEAR); + depth = makeTarget(sw, sh, gl.R16F, gl.NEAREST); + atm = makeTarget(aw, ah, gl.RGBA16F, gl.LINEAR); + hist = [makeTarget(aw, ah, gl.RGBA16F, gl.LINEAR), makeTarget(aw, ah, gl.RGBA16F, gl.LINEAR)]; + attach(fbos.solid, [solid, depth]); + attach(fbos.atm, [atm]); + attach(fbos.hist[0], [hist[0]]); + attach(fbos.hist[1], [hist[1]]); + resetHistory = true; + }; + + const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)'); + const start = performance.now(); + let loginAt: number | undefined; + let frame = 0; + let histIndex = 0; + let prev: CameraPose | undefined; + let raf = 0; + let lastRendered = -Infinity; + let framesLeft = Infinity; // reduced motion bounds this + let disposed = false; + + // Timer queries in flight: each holds one pass's query. + const pending: Array<{ q: WebGLQuery; pass: PassName; frame: number }> = []; + const finished = new Map>>(); + + const timed = (pass: PassName, f: number, body: () => void) => { + if (!timer || !opts.onStats) return body(); + const q = gl.createQuery()!; + gl.beginQuery(timer.TIME_ELAPSED_EXT, q); + body(); + gl.endQuery(timer.TIME_ELAPSED_EXT); + pending.push({ q, pass, frame: f }); + }; + + const collect = () => { + if (!timer) return; + while (pending.length && gl.getQueryParameter(pending[0].q, gl.QUERY_RESULT_AVAILABLE)) { + const { q, pass, frame: f } = pending.shift()!; + const disjoint = gl.getParameter(timer.GPU_DISJOINT_EXT); + const ns = gl.getQueryParameter(q, gl.QUERY_RESULT) as number; + gl.deleteQuery(q); + const row = finished.get(f) ?? {}; + if (!disjoint) row[pass] = ns / 1e6; + finished.set(f, row); + if (Object.keys(row).length === PASSES.length) { + finished.delete(f); + const passMs = row as Record; + const total = PASSES.reduce((s, p) => s + passMs[p], 0); + if (!opts.fixedLevel && ladder.push(total)) sizedFor = undefined; + opts.onStats?.({ + passMs, + level: ladder.index, + solidSize: [solid.w, solid.h], + atmosphereSize: [atm.w, atm.h], + }); + } + } + }; + + const setCommon = (p: WebGLProgram, pose: CameraPose, time: number) => { + const aspect = canvasW / canvasH; + const th = Math.tan(pose.fov / 2); + gl.uniform3fv(loc(p, 'u_camPos'), pose.position as unknown as number[]); + gl.uniformMatrix3fv(loc(p, 'u_camBasis'), false, pose.basis as unknown as number[]); + gl.uniform2f(loc(p, 'u_tanHalf'), th * aspect, th); + gl.uniform1f(loc(p, 'u_time'), time); + gl.uniform3fv(loc(p, 'u_sunDir'), PALETTE.sunDir as unknown as number[]); + gl.uniform3fv(loc(p, 'u_sunColor'), PALETTE.sunColor as unknown as number[]); + gl.uniform3fv(loc(p, 'u_skyTop'), PALETTE.skyTop as unknown as number[]); + gl.uniform3fv(loc(p, 'u_skyHorizon'), PALETTE.skyHorizon as unknown as number[]); + gl.uniform3fv(loc(p, 'u_fogColor'), PALETTE.fogColor as unknown as number[]); + }; + + const bindTex = (unit: number, t: Target, p: WebGLProgram, name: string) => { + gl.activeTexture(gl.TEXTURE0 + unit); + gl.bindTexture(gl.TEXTURE_2D, t.tex); + gl.uniform1i(loc(p, name), unit); + }; + + const draw = () => gl.drawArrays(gl.TRIANGLES, 0, 3); + + const render = (now: number) => { + ensureSize(); + const lv = level(); + const frozen = reducedMotion?.matches ?? false; + const time = frozen ? 0 : (now - start) / 1000; + const elapsed = loginAt === undefined ? undefined : frozen ? 1e9 : (now - loginAt) / 1000; + const pose = cameraPose(pullProgress(elapsed), time); + const f = frame++; + const jx = halton((f % 16) + 1, 2) - 0.5; + const jy = halton((f % 16) + 1, 3) - 0.5; + + // Pass 1: solid surfaces, writes colour and depth. + timed('terrain', f, () => { + gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.solid); + gl.viewport(0, 0, solid.w, solid.h); + const p = programs.terrain; + gl.useProgram(p); + setCommon(p, pose, time); + gl.uniform3fv(loc(p, 'u_grass'), PALETTE.grass as unknown as number[]); + gl.uniform3fv(loc(p, 'u_rock'), PALETTE.rock as unknown as number[]); + gl.uniform3fv(loc(p, 'u_bark'), PALETTE.bark as unknown as number[]); + gl.uniform3fv(loc(p, 'u_leaf'), PALETTE.leaf as unknown as number[]); + gl.uniform1i(loc(p, 'u_maxSteps'), lv.traceSteps); + draw(); + }); + + // Pass 2: atmosphere up to pass 1's depth, low-res. + timed('atmosphere', f, () => { + gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.atm); + gl.viewport(0, 0, atm.w, atm.h); + const p = programs.atmosphere; + gl.useProgram(p); + setCommon(p, pose, time); + bindTex(0, depth, p, 'u_depth'); + gl.uniform1i(loc(p, 'u_steps'), lv.cloudSteps); + gl.uniform1f(loc(p, 'u_frame'), f); + gl.uniform2f(loc(p, 'u_jitter'), jx, jy); + gl.uniform2f(loc(p, 'u_size'), atm.w, atm.h); + gl.uniform1f(loc(p, 'u_coverage'), opts.coverage ?? 0.45); + draw(); + }); + + // TAA resolve into the history that is not being read. + const write = histIndex ^ 1; + timed('resolve', f, () => { + gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.hist[write]); + gl.viewport(0, 0, atm.w, atm.h); + const p = programs.resolve; + gl.useProgram(p); + setCommon(p, pose, time); + bindTex(0, atm, p, 'u_cur'); + bindTex(1, hist[histIndex], p, 'u_history'); + bindTex(2, depth, p, 'u_depth'); + const pp = prev ?? pose; + const th = Math.tan(pp.fov / 2); + gl.uniform3fv(loc(p, 'u_prevPos'), pp.position as unknown as number[]); + gl.uniformMatrix3fv(loc(p, 'u_prevBasis'), false, pp.basis as unknown as number[]); + gl.uniform2f(loc(p, 'u_prevTanHalf'), th * (canvasW / canvasH), th); + gl.uniform1f(loc(p, 'u_blend'), resetHistory ? 1 : 0.125); + gl.uniform2f(loc(p, 'u_curSize'), atm.w, atm.h); + draw(); + }); + + // Full-resolution composite to the canvas. + timed('composite', f, () => { + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + gl.viewport(0, 0, canvasW, canvasH); + const p = programs.composite; + gl.useProgram(p); + setCommon(p, pose, time); + bindTex(0, solid, p, 'u_solid'); + bindTex(1, hist[write], p, 'u_atm'); + gl.uniform1f(loc(p, 'u_frame'), f); + draw(); + }); + + histIndex = write; + prev = pose; + resetHistory = false; + collect(); + }; + + const tick = (now: number) => { + raf = 0; + if (disposed) return; + const due = opts.unthrottled || now - lastRendered >= FRAME_INTERVAL_MS - 2; + if (due) { + if ( + !opts.fixedLevel && + !timer && + lastRendered > 0 && + now - lastRendered > FRAME_INTERVAL_MS * 1.5 + ) + ladder.push(GPU_BUDGET_MS * 2) && (sizedFor = undefined); // no timer: a missed cap counts as over budget + lastRendered = now; + render(now); + framesLeft -= 1; + } + if (framesLeft > 0 && (opts.unthrottled || document.visibilityState === 'visible')) + raf = requestAnimationFrame(tick); + }; + + const wake = () => { + if (disposed || raf) return; + if (reducedMotion?.matches && framesLeft <= 0) return; + resetHistory = true; // the first frame after a pause has a real elapsed dt + raf = requestAnimationFrame(tick); + }; + const onVisibility = () => { + if (document.visibilityState === 'visible') wake(); + }; + const onResize = () => wake(); + if (!opts.unthrottled) document.addEventListener('visibilitychange', onVisibility); + window.addEventListener('resize', onResize); + if (reducedMotion?.matches) framesLeft = REDUCED_MOTION_FRAMES; + wake(); + + return { + login() { + loginAt = performance.now(); + if (reducedMotion?.matches) framesLeft = REDUCED_MOTION_FRAMES; + wake(); + }, + dispose() { + disposed = true; + if (raf) cancelAnimationFrame(raf); + document.removeEventListener('visibilitychange', onVisibility); + window.removeEventListener('resize', onResize); + freeTargets(); + for (const p of PASSES) gl.deleteProgram(programs[p]); + gl.deleteVertexArray(vao); + for (const f of [fbos.solid, fbos.atm, ...fbos.hist]) gl.deleteFramebuffer(f); + }, + }; +} diff --git a/src/app/features/scene/budget.test.ts b/src/app/features/scene/budget.test.ts new file mode 100644 index 00000000..27dd0487 --- /dev/null +++ b/src/app/features/scene/budget.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { BudgetLadder, LEVELS } from './budget'; + +describe('budget ladder', () => { + it('steps down after a whole window over budget and never back up', () => { + const ladder = new BudgetLadder(4, 5); + for (let i = 0; i < 4; i++) expect(ladder.push(9)).toBe(false); + expect(ladder.push(9)).toBe(true); + expect(ladder.index).toBe(1); + for (let i = 0; i < 100; i++) ladder.push(0.1); // fast frames never raise the level + expect(ladder.index).toBe(1); + }); + + it('judges the median, so one slow frame does not step down', () => { + const ladder = new BudgetLadder(4, 5); + for (const ms of [1, 1, 50, 1, 1]) ladder.push(ms); + expect(ladder.index).toBe(0); + }); + + it('stops at the last level', () => { + const ladder = new BudgetLadder(4, 1); + for (let i = 0; i < 20; i++) ladder.push(99); + expect(ladder.index).toBe(LEVELS.length - 1); + }); + + it('every level is cheaper than the one before in each cost axis', () => { + for (let i = 1; i < LEVELS.length; i++) { + expect(LEVELS[i].solidScale).toBeLessThan(LEVELS[i - 1].solidScale); + expect(LEVELS[i].atmosphereScale).toBeLessThan(LEVELS[i - 1].atmosphereScale); + expect(LEVELS[i].cloudSteps).toBeLessThan(LEVELS[i - 1].cloudSteps); + expect(LEVELS[i].traceSteps).toBeLessThan(LEVELS[i - 1].traceSteps); + } + }); +}); diff --git a/src/app/features/scene/budget.ts b/src/app/features/scene/budget.ts new file mode 100644 index 00000000..9550571c --- /dev/null +++ b/src/app/features/scene/budget.ts @@ -0,0 +1,49 @@ +// The adaptive step-down. The ladder only descends within a session; nothing here ever steps up. + +export interface Level { + /** Pass 1 resolution as a fraction of the canvas. */ + solidScale: number; + /** Pass 2 resolution as a fraction of the canvas. */ + atmosphereScale: number; + /** Cloud march steps. */ + cloudSteps: number; + /** Sphere-trace step cap. */ + traceSteps: number; +} + +export const LEVELS: readonly Level[] = [ + { solidScale: 0.75, atmosphereScale: 0.5, cloudSteps: 12, traceSteps: 96 }, + { solidScale: 0.6, atmosphereScale: 0.4, cloudSteps: 8, traceSteps: 80 }, + { solidScale: 0.5, atmosphereScale: 0.33, cloudSteps: 6, traceSteps: 64 }, + { solidScale: 0.4, atmosphereScale: 0.25, cloudSteps: 4, traceSteps: 48 }, +]; + +/** Median of the last `window` frame times above `budgetMs` for a whole window steps down one level. */ +export class BudgetLadder { + private level = 0; + private recent: number[] = []; + + constructor(private readonly budgetMs: number, private readonly window = 45) {} + + get current(): Level { + return LEVELS[this.level]; + } + + get index(): number { + return this.level; + } + + /** Feed one frame's GPU (or cadence) time. Returns true when the level changed. */ + push(ms: number): boolean { + this.recent.push(ms); + if (this.recent.length < this.window) return false; + const sorted = [...this.recent].sort((a, b) => a - b); + const median = sorted[sorted.length >> 1]; + this.recent = []; + if (median > this.budgetMs && this.level < LEVELS.length - 1) { + this.level += 1; + return true; + } + return false; + } +} diff --git a/src/app/features/scene/camera.test.ts b/src/app/features/scene/camera.test.ts new file mode 100644 index 00000000..f7199a65 --- /dev/null +++ b/src/app/features/scene/camera.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { cameraPose, pullProgress, PULL_BACK_SECONDS } from './camera'; +import { WORLD } from './world'; + +const dist = (p: readonly [number, number, number]) => Math.hypot(p[0], p[2]); + +describe('login pull-back', () => { + it('is at rest before login and complete after PULL_BACK_SECONDS', () => { + expect(pullProgress(undefined)).toBe(0); + expect(pullProgress(0)).toBe(0); + expect(pullProgress(PULL_BACK_SECONDS)).toBe(1); + expect(pullProgress(PULL_BACK_SECONDS * 10)).toBe(1); + }); + + it('only moves away from the tree and never reverses', () => { + let last = -1; + for (let s = 0; s <= PULL_BACK_SECONDS; s += 0.1) { + const d = dist(cameraPose(pullProgress(s), 0).position); + expect(d).toBeGreaterThanOrEqual(last); + last = d; + } + }); + + it('ends over the flat ground so it cannot be inside the terrain, whatever the orbit angle', () => { + for (let t = 0; t < 2000; t += 37) { + expect(dist(cameraPose(1, t).position)).toBeLessThan(WORLD.plateauInner); + } + }); + + it('looks at the tree: forward points at the axis and the basis is orthonormal', () => { + const pose = cameraPose(0.5, 12); + const [rx, ry, rz, ux, uy, uz, fx, fy, fz] = pose.basis; + const dot = (a: number[], b: number[]) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + expect(dot([rx, ry, rz], [ux, uy, uz])).toBeCloseTo(0, 6); + expect(dot([rx, ry, rz], [fx, fy, fz])).toBeCloseTo(0, 6); + expect(dot([ux, uy, uz], [fx, fy, fz])).toBeCloseTo(0, 6); + expect(Math.hypot(fx, fy, fz)).toBeCloseTo(1, 6); + // Forward points back across the tree axis, not away from it. + const [px, , pz] = pose.position; + expect(fx * px + fz * pz).toBeLessThan(0); + }); +}); diff --git a/src/app/features/scene/camera.ts b/src/app/features/scene/camera.ts new file mode 100644 index 00000000..2b08f0d1 --- /dev/null +++ b/src/app/features/scene/camera.ts @@ -0,0 +1,56 @@ +// The scene's camera: close under the tree at rest, pulled back on login, drifting after. +// Pure functions of time so the pull-back is testable without a GL context. + +export type Vec3 = readonly [number, number, number]; + +export interface CameraPose { + position: Vec3; + /** Column-major right, up, forward: the layout of the shader's mat3 uniform. */ + basis: readonly [number, number, number, number, number, number, number, number, number]; + /** Vertical field of view in radians. */ + fov: number; +} + +export const PULL_BACK_SECONDS = 6; + +// Keep the end distance inside WORLD.plateauInner so the camera is always over flat ground. +const NEAR = { radius: 20, camY: 5, targetY: 34, fov: 1.15 }; +const FAR = { radius: 110, camY: 50, targetY: 36, fov: 0.82 }; +const DRIFT_RAD_PER_SEC = 0.025; + +const smoother = (x: number) => x * x * x * (x * (x * 6 - 15) + 10); +const lerp = (a: number, b: number, t: number) => a + (b - a) * t; + +/** 0 before login, easing to 1 over PULL_BACK_SECONDS. `elapsed` is seconds since login. */ +export function pullProgress(elapsed: number | undefined): number { + if (elapsed === undefined || elapsed <= 0) return 0; + return smoother(Math.min(1, elapsed / PULL_BACK_SECONDS)); +} + +function norm(v: Vec3): Vec3 { + const l = Math.hypot(v[0], v[1], v[2]) || 1; + return [v[0] / l, v[1] / l, v[2] / l]; +} +function cross(a: Vec3, b: Vec3): Vec3 { + return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; +} + +/** + * @param pull 0..1 from pullProgress + * @param time seconds since the scene started, for the slow orbit + */ +export function cameraPose(pull: number, time: number): CameraPose { + const radius = lerp(NEAR.radius, FAR.radius, pull); + const camY = lerp(NEAR.camY, FAR.camY, pull); + const targetY = lerp(NEAR.targetY, FAR.targetY, pull); + const azimuth = 0.9 + time * DRIFT_RAD_PER_SEC; + const position: Vec3 = [radius * Math.cos(azimuth), camY, radius * Math.sin(azimuth)]; + const forward = norm([-position[0], targetY - position[1], -position[2]]); + const right = norm(cross([0, 1, 0], forward)); + const up = cross(forward, right); + return { + position, + basis: [...right, ...up, ...forward] as unknown as CameraPose['basis'], + fov: lerp(NEAR.fov, FAR.fov, pull), + }; +} diff --git a/src/app/features/scene/palette.ts b/src/app/features/scene/palette.ts new file mode 100644 index 00000000..54a84ad3 --- /dev/null +++ b/src/app/features/scene/palette.ts @@ -0,0 +1,27 @@ +// The scene's colours, as linear RGB uniforms. The sky tints are GameCult's three nebula inputs +// (orange 255 138 42, violet 109 96 255, sky 89 183 255) over the brand base #07111a, so the +// background reads as the same dusk as gamecult.org. The shaders hold no colour literal. + +export type Rgb = readonly [number, number, number]; + +const srgb = (r: number, g: number, b: number): Rgb => [ + (r / 255) ** 2.2, + (g / 255) ** 2.2, + (b / 255) ** 2.2, +]; + +export const PALETTE = { + sunColor: srgb(255, 150, 70).map((c) => c * 3.2) as unknown as Rgb, + skyTop: srgb(30, 36, 92), + skyHorizon: srgb(255, 138, 42).map((c) => c * 0.55) as unknown as Rgb, + fogColor: srgb(109, 96, 255).map((c) => c * 0.12) as unknown as Rgb, + grass: srgb(72, 112, 86), + rock: srgb(112, 104, 122), + bark: srgb(62, 46, 40), + leaf: srgb(89, 183, 255).map((c) => c * 0.7) as unknown as Rgb, + sunDir: (() => { + const v = [0.55, 0.16, -0.82]; + const l = Math.hypot(v[0], v[1], v[2]); + return [v[0] / l, v[1] / l, v[2] / l] as Rgb; + })(), +} as const; diff --git a/src/app/features/scene/shaderSource.ts b/src/app/features/scene/shaderSource.ts new file mode 100644 index 00000000..aa44d9b2 --- /dev/null +++ b/src/app/features/scene/shaderSource.ts @@ -0,0 +1,29 @@ +import cultmath from './shaders/CultMath.glsl?raw'; +import phacelle from './shaders/CultMath.Phacelle.glsl?raw'; +import common from './shaders/common.glsl?raw'; +import fullscreenVert from './shaders/fullscreen.vert.glsl?raw'; +import terrainFrag from './shaders/terrain.frag.glsl?raw'; +import atmosphereFrag from './shaders/atmosphere.frag.glsl?raw'; +import resolveFrag from './shaders/resolve.frag.glsl?raw'; +import compositeFrag from './shaders/composite.frag.glsl?raw'; +import { WORLD, worldDefines } from './world'; + +export type Stage = 'terrain' | 'atmosphere' | 'resolve' | 'composite'; + +const HEADER = + '#version 300 es\nprecision highp float;\nprecision highp int;\nprecision highp sampler2D;\n'; + +export const vertexSource = HEADER + fullscreenVert; + +const stages: Record = { + terrain: terrainFrag, + atmosphere: atmosphereFrag, + resolve: resolveFrag, + composite: compositeFrag, +}; + +/** Fragment source for a stage: header, world defines, CultMath, the shared block, the stage. */ +export function fragmentSource(stage: Stage): string { + const erosion = stage === 'terrain' && WORLD.phacelleGradBound > 0 ? phacelle : ''; + return `${HEADER}${worldDefines()}\n${cultmath}\n${erosion}\n${common}\n${stages[stage]}`; +} diff --git a/src/app/features/scene/shaders/CultMath.Phacelle.glsl b/src/app/features/scene/shaders/CultMath.Phacelle.glsl new file mode 100644 index 00000000..11405fa2 --- /dev/null +++ b/src/app/features/scene/shaders/CultMath.Phacelle.glsl @@ -0,0 +1,99 @@ +// Generated from shaders/CultMath.Phacelle.hlsl by GlslLowering (packages/cultmath/tests/CultMath.Tests/GlslLowering.cs). Do not edit; regenerate with CULTMATH_WRITE_GLSL=1 dotnet test --filter GlslMirrorTests. MPL-2.0; requires CultMath.glsl before it. +#ifndef CULTMATH_PHACELLE_GLSL +#define CULTMATH_PHACELLE_GLSL +// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of +// the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/. +// +// Phacelle noise. Copyright (c) 2025 Rune Skovbo Johansen (Shadertoy t3dyWl; MPL-2.0), read from the +// C# port PhacelleNoise in lpmitchell/AdvancedTerrainErosion, converted to burstable C# by Luke +// Mitchell (2026). Modified by GameCult: 3D cells, a caller-supplied stripe wave vector, an exact +// gradient and exact pruning. This file is an MPL-2.0 file; CultMath.hlsl, which includes it after +// its own definitions of CULTMATH_TAU and cultmath_pcg3d, is MIT (see THIRD-PARTY-NOTICES.md). + +// Invariant 8's struct return shape for cultmath_phacelle, compared field by field and bit for bit +// against CultMath.CultPhasor, same field order. +struct CultPhasor +{ + vec4 cos; + vec4 sin; +}; + +// Johansen's Phacelle noise on 3D cells. See math.phacelle's comment (math.Phacelle.cs) for the stripe wave +// vector, the weight, the normalization, the exact gradient and why the prune is exact. Mirrors +// the C# body operation for operation; the C#-only internal overload that switches the prune off +// has no shader twin. +CultPhasor cultmath_phacelle(vec3 p, vec3 side, float offset, float normalization) +{ + vec3 cell = floor(p); + vec3 local = p - cell; + float phaseOffset = offset * CULTMATH_TAU; + float sumW = 0.0; + float sumCos = 0.0; + float sumSin = 0.0; + vec3 gradW = vec3(0.0, 0.0, 0.0); + vec3 gradCos = vec3(0.0, 0.0, 0.0); + vec3 gradSin = vec3(0.0, 0.0, 0.0); + + for (int dz = -1; dz <= 2; dz++) + { + float gz = max(0.0, abs(local.z - float(dz)) - 0.5); + for (int dy = -1; dy <= 2; dy++) + { + float gy = max(0.0, abs(local.y - float(dy)) - 0.5); + for (int dx = -1; dx <= 2; dx++) + { + float gx = max(0.0, abs(local.x - float(dx)) - 0.5); + if (gx * gx + gy * gy + gz * gz >= 2.25) + continue; + + vec3 gridStep = vec3(dx, dy, dz); + ivec3 hash = cultmath_pcg3d(ivec3(cell + gridStep)); + vec3 jitter = vec3( + float(uint(hash.x) >> 8) * (1.0 / 16777216.0) - 0.5, + float(uint(hash.y) >> 8) * (1.0 / 16777216.0) - 0.5, + float(uint(hash.z) >> 8) * (1.0 / 16777216.0) - 0.5); + vec3 v = local - gridStep - jitter; + float falloff = exp(-2.0 * dot(v, v)); + float w = max(0.0, falloff - 0.01111); + vec3 dw = w > 0.0 ? (-4.0 * falloff) * v : vec3(0.0, 0.0, 0.0); + float phase = dot(v, side) + phaseOffset; + float c = cos(phase); + float s = sin(phase); + + sumW += w; + sumCos += w * c; + sumSin += w * s; + gradW += dw; + gradCos += dw * c - (w * s) * side; + gradSin += dw * s + (w * c) * side; + } + } + } + + float invW = 1.0 / sumW; + float rawCos = sumCos * invW; + float rawSin = sumSin * invW; + vec3 gradRawCos = (gradCos - rawCos * gradW) * invW; + vec3 gradRawSin = (gradSin - rawSin * gradW) * invW; + float rawLength = sqrt(rawCos * rawCos + rawSin * rawSin); + float floorLength = 1.0 - normalization; + + CultPhasor result; + if (rawLength > floorLength) + { + float outCos = rawCos / rawLength; + float outSin = rawSin / rawLength; + vec3 gradLength = (rawCos * gradRawCos + rawSin * gradRawSin) / rawLength; + result.cos = vec4((gradRawCos - outCos * gradLength) / rawLength, outCos); + result.sin = vec4((gradRawSin - outSin * gradLength) / rawLength, outSin); + } + else + { + result.cos = vec4(gradRawCos / floorLength, rawCos / floorLength); + result.sin = vec4(gradRawSin / floorLength, rawSin / floorLength); + } + + return result; +} + +#endif diff --git a/src/app/features/scene/shaders/CultMath.glsl b/src/app/features/scene/shaders/CultMath.glsl new file mode 100644 index 00000000..8a380a96 --- /dev/null +++ b/src/app/features/scene/shaders/CultMath.glsl @@ -0,0 +1,1035 @@ +// Generated from shaders/CultMath.hlsl and CultMath.Interval.hlsl and CultMath.Affine.hlsl by GlslLowering (packages/cultmath/tests/CultMath.Tests/GlslLowering.cs). Do not edit; regenerate with CULTMATH_WRITE_GLSL=1 dotnet test --filter GlslMirrorTests. MIT. cultmath_phacelle is in CultMath.Phacelle.glsl, to be concatenated after this file by consumers that call it. cultmath_cycles_divide, cultmath_cycles_power, cultmath_cycles_logarithm, cultmath_cycles_sqrt, cultmath_cycles_inverse_sqrt, cultmath_cycles_modulo, cultmath_cycles_floored_modulo, cultmath_cycles_wrap, cultmath_cycles_snap, cultmath_cycles_pingpong, cultmath_cycles_smooth_min, cultmath_cycles_smooth_max, cultmath_cycles_compare, cultmath_cycles_round, cultmath_cycles_trunc, cultmath_cycles_fract, cultmath_cycles_sign, cultmath_cycles_arcsine, cultmath_cycles_arccosine, cultmath_cycles_arctan2, cultmath_cycles_map_range_linear, cultmath_cycles_map_range_stepped, cultmath_cycles_map_range_smoothstep, cultmath_cycles_map_range_smootherstep, cultmath_cycles_clamp_minmax, cultmath_cycles_clamp_range, cultmath_cycles_mix_float, cultmath_cycles_mix_vector, cultmath_cycles_mix_blend, cultmath_cycles_mix_add, cultmath_cycles_mix_mul, cultmath_cycles_mix_screen, cultmath_cycles_mix_overlay, cultmath_cycles_mix_sub, cultmath_cycles_mix_div, cultmath_cycles_mix_diff, cultmath_cycles_mix_exclusion, cultmath_cycles_mix_dark, cultmath_cycles_mix_light, cultmath_cycles_mix_dodge, cultmath_cycles_mix_burn, cultmath_cycles_rgb_to_hsv, cultmath_cycles_hsv_to_rgb, cultmath_cycles_mix_hue, cultmath_cycles_mix_sat, cultmath_cycles_mix_val, cultmath_cycles_mix_color, cultmath_cycles_mix_soft, cultmath_cycles_mix_linear, cultmath_cycles_rgb_to_bw, cultmath_cycles_ramp_index is in CultMath.Cycles.glsl, to be concatenated after this file by consumers that call it. +#ifndef CULTMATH_GLSL +#define CULTMATH_GLSL +// CultMath HLSL mirror. +// +// HLSL already owns the vec2/vec3/vec4 type names and most primitive +// intrinsics. This include names the CultMath semantic surface explicitly so +// shader code, C# wrappers, and Rust/native parity fixtures can share the same +// small vocabulary without local helper drift. + +const float CULTMATH_PI = 3.14159265358979323846; +const float CULTMATH_TAU = 6.28318530717958647692; +const float CULTMATH_HALF_PI = 1.57079632679489661923; + +float cultmath_radians(float degrees_value) { return degrees_value * (CULTMATH_PI / 180.0); } +float cultmath_degrees(float radians_value) { return radians_value * (180.0 / CULTMATH_PI); } + +float cultmath_frac(float value) { return value - floor(value); } +vec2 cultmath_frac(vec2 value) { return value - floor(value); } +vec3 cultmath_frac(vec3 value) { return value - floor(value); } +vec4 cultmath_frac(vec4 value) { return value - floor(value); } + +float cultmath_clamp(float value, float minimum, float maximum) { return min(max(value, minimum), maximum); } +vec2 cultmath_clamp(vec2 value, vec2 minimum, vec2 maximum) { return min(max(value, minimum), maximum); } +vec3 cultmath_clamp(vec3 value, vec3 minimum, vec3 maximum) { return min(max(value, minimum), maximum); } +vec4 cultmath_clamp(vec4 value, vec4 minimum, vec4 maximum) { return min(max(value, minimum), maximum); } + +// DXIL Saturate is FMin(1, FMax(0, x)); dxc lowers min/max to the same FMin/FMax, NaN rules included. +float cultmath_saturate(float value) { return min(1.0, max(0.0, value)); } +vec2 cultmath_saturate(vec2 value) { return min(vec2(1.0, 1.0), max(vec2(0.0, 0.0), value)); } +vec3 cultmath_saturate(vec3 value) { return min(vec3(1.0, 1.0, 1.0), max(vec3(0.0, 0.0, 0.0), value)); } +vec4 cultmath_saturate(vec4 value) { return min(vec4(1.0, 1.0, 1.0, 1.0), max(vec4(0.0, 0.0, 0.0, 0.0), value)); } + +float cultmath_lerp(float start, float end, float amount) { return start + (end - start) * amount; } +vec2 cultmath_lerp(vec2 start, vec2 end, vec2 amount) { return start + (end - start) * amount; } +vec3 cultmath_lerp(vec3 start, vec3 end, vec3 amount) { return start + (end - start) * amount; } +vec4 cultmath_lerp(vec4 start, vec4 end, vec4 amount) { return start + (end - start) * amount; } + +float cultmath_step(float edge, float value) { return value < edge ? 0.0 : 1.0; } +vec2 cultmath_step(vec2 edge, vec2 value) { return vec2(cultmath_step(edge.x, value.x), cultmath_step(edge.y, value.y)); } +vec3 cultmath_step(vec3 edge, vec3 value) { return vec3(cultmath_step(edge.x, value.x), cultmath_step(edge.y, value.y), cultmath_step(edge.z, value.z)); } +vec4 cultmath_step(vec4 edge, vec4 value) { return vec4(cultmath_step(edge.x, value.x), cultmath_step(edge.y, value.y), cultmath_step(edge.z, value.z), cultmath_step(edge.w, value.w)); } + +float cultmath_smoothstep(float minimum, float maximum, float value) +{ + float t = cultmath_saturate((value - minimum) / (maximum - minimum)); + return t * t * (3.0 - 2.0 * t); +} + +vec2 cultmath_smoothstep(vec2 minimum, vec2 maximum, vec2 value) +{ + vec2 t = cultmath_saturate((value - minimum) / (maximum - minimum)); + return t * t * (vec2(3.0, 3.0) - vec2(2.0, 2.0) * t); +} + +vec3 cultmath_smoothstep(vec3 minimum, vec3 maximum, vec3 value) +{ + vec3 t = cultmath_saturate((value - minimum) / (maximum - minimum)); + return t * t * (vec3(3.0, 3.0, 3.0) - vec3(2.0, 2.0, 2.0) * t); +} + +vec4 cultmath_smoothstep(vec4 minimum, vec4 maximum, vec4 value) +{ + vec4 t = cultmath_saturate((value - minimum) / (maximum - minimum)); + return t * t * (vec4(3.0, 3.0, 3.0, 3.0) - vec4(2.0, 2.0, 2.0, 2.0) * t); +} + +float cultmath_smoothstep01(float value) { return cultmath_smoothstep(0.0, 1.0, value); } + +float cultmath_smootherstep(float value) +{ + value = cultmath_saturate(value); + return value * value * value * (value * (value * 6.0 - 15.0) + 10.0); +} + +vec2 cultmath_snoise_mod289(vec2 value) { return value - floor(value * (1.0 / 289.0)) * 289.0; } +vec3 cultmath_snoise_mod289(vec3 value) { return value - floor(value * (1.0 / 289.0)) * 289.0; } +vec4 cultmath_snoise_mod289(vec4 value) { return value - floor(value * (1.0 / 289.0)) * 289.0; } +vec3 cultmath_snoise_permute(vec3 value) { return cultmath_snoise_mod289(((value * 34.0) + 10.0) * value); } +vec4 cultmath_snoise_permute(vec4 value) { return cultmath_snoise_mod289(((value * 34.0) + 10.0) * value); } + +// Ashima Arts / Ian McEwan 3D simplex noise (MIT), same float32 evaluation +// order as the C# math.snoise(vec3) mirror. Ties resolve in simplexnoise1234's +// total order; see math.snoise. +float cultmath_snoise(vec3 value) +{ + vec2 c = vec2(1.0 / 6.0, 1.0 / 3.0); + vec3 i = floor(value + dot(value, c.yyy)); + vec3 x0 = value - i + dot(i, c.xxx); + vec3 g = vec3(step(x0.y, x0.x), step(x0.z, x0.y), 1.0 - step(x0.z, x0.x)); + vec3 l = 1.0 - g; + vec3 i1 = min(g.xyz, l.zxy); + vec3 i2 = max(g.xyz, l.zxy); + vec3 x1 = x0 - i1 + c.xxx; + vec3 x2 = x0 - i2 + c.yyy; + vec3 x3 = x0 - 0.5; + + i = cultmath_snoise_mod289(i); + vec4 p = cultmath_snoise_permute(cultmath_snoise_permute(cultmath_snoise_permute( + i.z + vec4(0.0, i1.z, i2.z, 1.0)) + i.y + vec4(0.0, i1.y, i2.y, 1.0)) + i.x + vec4(0.0, i1.x, i2.x, 1.0)); + + const float n_ = 0.142857142857; + vec3 ns = vec3(2.0 * n_, 0.5 * n_ - 1.0, n_); + vec4 j = p - 49.0 * floor(p * ns.z * ns.z); + vec4 x_ = floor(j * ns.z); + vec4 y_ = floor(j - 7.0 * x_); + vec4 x = x_ * ns.x + ns.yyyy; + vec4 y = y_ * ns.x + ns.yyyy; + vec4 h = 1.0 - abs(x) - abs(y); + + vec4 b0 = vec4(x.xy, y.xy); + vec4 b1 = vec4(x.zw, y.zw); + vec4 s0 = floor(b0) * 2.0 + 1.0; + vec4 s1 = floor(b1) * 2.0 + 1.0; + vec4 sh = -step(h, vec4(0.0, 0.0, 0.0, 0.0)); + vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy; + vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww; + + vec3 p0 = vec3(a0.xy, h.x); + vec3 p1 = vec3(a0.zw, h.y); + vec3 p2 = vec3(a1.xy, h.z); + vec3 p3 = vec3(a1.zw, h.w); + vec4 norm = 1.79284291400159 - 0.85373472095314 * vec4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3)); + p0 *= norm.x; + p1 *= norm.y; + p2 *= norm.z; + p3 *= norm.w; + + vec4 m = max(0.5 - vec4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0); + m = m * m; + return 105.0 * dot(m * m, vec4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3))); +} + +float cultmath_snoise(vec2 value) +{ + vec4 c = vec4( + 0.211324865405187, + 0.366025403784439, + -0.577350269189626, + 0.024390243902439); + vec2 i = floor(value + dot(value, c.yy)); + vec2 x0 = value - i + dot(i, c.xx); + vec2 i1 = x0.x > x0.y ? vec2(1.0, 0.0) : vec2(0.0, 1.0); + vec4 x12 = vec4(x0.x + c.x - i1.x, x0.y + c.x - i1.y, x0.x + c.z, x0.y + c.z); + + i = cultmath_snoise_mod289(i); + vec3 p = cultmath_snoise_permute( + cultmath_snoise_permute(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0)); + vec3 m = max(0.5 - vec3(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), 0.0); + m *= m; + m *= m; + + vec3 x = 2.0 * fract(p * c.w) - 1.0; + vec3 h = abs(x) - 0.5; + vec3 ox = floor(x + 0.5); + vec3 a0 = x - ox; + m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h); + + vec3 g = vec3( + a0.x * x0.x + h.x * x0.y, + a0.y * x12.x + h.y * x12.y, + a0.z * x12.z + h.z * x12.w); + return 130.0 * dot(m, g); +} + +// Analytic gradient of cultmath_snoise(vec3) (invariant 8), following webgl-noise +// src/noise3Dgrad.glsl (Ashima Arts / McEwan, MIT); same kernel, permutation and scale as +// cultmath_snoise(vec3) (stegu/webgl-noise 22434e04d7). See the C# math.snoise_grad comment for the +// derivation. Every local up through p0..p3 and x0..x3 is cultmath_snoise(vec3)'s own derivation +// verbatim. +vec4 cultmath_snoise_grad(vec3 value) +{ + vec2 c = vec2(1.0 / 6.0, 1.0 / 3.0); + vec3 i = floor(value + dot(value, c.yyy)); + vec3 x0 = value - i + dot(i, c.xxx); + vec3 g = vec3(step(x0.y, x0.x), step(x0.z, x0.y), 1.0 - step(x0.z, x0.x)); + vec3 l = 1.0 - g; + vec3 i1 = min(g.xyz, l.zxy); + vec3 i2 = max(g.xyz, l.zxy); + vec3 x1 = x0 - i1 + c.xxx; + vec3 x2 = x0 - i2 + c.yyy; + vec3 x3 = x0 - 0.5; + + i = cultmath_snoise_mod289(i); + vec4 p = cultmath_snoise_permute(cultmath_snoise_permute(cultmath_snoise_permute( + i.z + vec4(0.0, i1.z, i2.z, 1.0)) + i.y + vec4(0.0, i1.y, i2.y, 1.0)) + i.x + vec4(0.0, i1.x, i2.x, 1.0)); + + const float n_ = 0.142857142857; + vec3 ns = vec3(2.0 * n_, 0.5 * n_ - 1.0, n_); + vec4 j = p - 49.0 * floor(p * ns.z * ns.z); + vec4 x_ = floor(j * ns.z); + vec4 y_ = floor(j - 7.0 * x_); + vec4 x = x_ * ns.x + ns.yyyy; + vec4 y = y_ * ns.x + ns.yyyy; + vec4 h = 1.0 - abs(x) - abs(y); + + vec4 b0 = vec4(x.xy, y.xy); + vec4 b1 = vec4(x.zw, y.zw); + vec4 s0 = floor(b0) * 2.0 + 1.0; + vec4 s1 = floor(b1) * 2.0 + 1.0; + vec4 sh = -step(h, vec4(0.0, 0.0, 0.0, 0.0)); + vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy; + vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww; + + vec3 p0 = vec3(a0.xy, h.x); + vec3 p1 = vec3(a0.zw, h.y); + vec3 p2 = vec3(a1.xy, h.z); + vec3 p3 = vec3(a1.zw, h.w); + vec4 norm = 1.79284291400159 - 0.85373472095314 * vec4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3)); + p0 *= norm.x; + p1 *= norm.y; + p2 *= norm.z; + p3 *= norm.w; + + vec4 m0 = max(0.5 - vec4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0); + vec4 m2 = m0 * m0; + vec4 m3 = m2 * m0; + vec4 m4 = m2 * m2; + + vec4 px = vec4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3)); + float value2 = 105.0 * dot(m4, px); + vec3 grad = -8.0 * m3.x * x0 * px.x + m4.x * p0 + + -8.0 * m3.y * x1 * px.y + m4.y * p1 + + -8.0 * m3.z * x2 * px.z + m4.z * p2 + + -8.0 * m3.w * x3 * px.w + m4.w * p3; + grad *= 105.0; + + return vec4(grad, value2); +} + +// Analytic Hessian of cultmath_snoise(vec3); see the C# math.snoise_hessian comment for the +// derivation. Every local up through p0..p3 and x0..x3 is cultmath_snoise_grad's own. The matrix is +// built from nine scalars, which read as rows in HLSL and as columns in GLSL; it is symmetric, so the +// two agree. +mat3 cultmath_snoise_hessian(vec3 value) +{ + vec2 c = vec2(1.0 / 6.0, 1.0 / 3.0); + vec3 i = floor(value + dot(value, c.yyy)); + vec3 x0 = value - i + dot(i, c.xxx); + vec3 g = vec3(step(x0.y, x0.x), step(x0.z, x0.y), 1.0 - step(x0.z, x0.x)); + vec3 l = 1.0 - g; + vec3 i1 = min(g.xyz, l.zxy); + vec3 i2 = max(g.xyz, l.zxy); + vec3 x1 = x0 - i1 + c.xxx; + vec3 x2 = x0 - i2 + c.yyy; + vec3 x3 = x0 - 0.5; + + i = cultmath_snoise_mod289(i); + vec4 p = cultmath_snoise_permute(cultmath_snoise_permute(cultmath_snoise_permute( + i.z + vec4(0.0, i1.z, i2.z, 1.0)) + i.y + vec4(0.0, i1.y, i2.y, 1.0)) + i.x + vec4(0.0, i1.x, i2.x, 1.0)); + + const float n_ = 0.142857142857; + vec3 ns = vec3(2.0 * n_, 0.5 * n_ - 1.0, n_); + vec4 j = p - 49.0 * floor(p * ns.z * ns.z); + vec4 x_ = floor(j * ns.z); + vec4 y_ = floor(j - 7.0 * x_); + vec4 x = x_ * ns.x + ns.yyyy; + vec4 y = y_ * ns.x + ns.yyyy; + vec4 h = 1.0 - abs(x) - abs(y); + + vec4 b0 = vec4(x.xy, y.xy); + vec4 b1 = vec4(x.zw, y.zw); + vec4 s0 = floor(b0) * 2.0 + 1.0; + vec4 s1 = floor(b1) * 2.0 + 1.0; + vec4 sh = -step(h, vec4(0.0, 0.0, 0.0, 0.0)); + vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy; + vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww; + + vec3 p0 = vec3(a0.xy, h.x); + vec3 p1 = vec3(a0.zw, h.y); + vec3 p2 = vec3(a1.xy, h.z); + vec3 p3 = vec3(a1.zw, h.w); + vec4 norm = 1.79284291400159 - 0.85373472095314 * vec4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3)); + p0 *= norm.x; + p1 *= norm.y; + p2 *= norm.z; + p3 *= norm.w; + + vec4 m0 = max(0.5 - vec4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0); + vec4 m2 = m0 * m0; + vec4 m3 = m2 * m0; + vec4 px = vec4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3)); + vec4 wide = 48.0 * m2 * px; + vec4 narrow = 8.0 * m3; + + vec4 cxx = vec4(x0.x, x1.x, x2.x, x3.x); + vec4 cxy = vec4(x0.y, x1.y, x2.y, x3.y); + vec4 cxz = vec4(x0.z, x1.z, x2.z, x3.z); + vec4 cpx = vec4(p0.x, p1.x, p2.x, p3.x); + vec4 cpy = vec4(p0.y, p1.y, p2.y, p3.y); + vec4 cpz = vec4(p0.z, p1.z, p2.z, p3.z); + + float hxx = 105.0 * (dot(wide * cxx, cxx) - dot(narrow, 2.0 * cpx * cxx + px)); + float hyy = 105.0 * (dot(wide * cxy, cxy) - dot(narrow, 2.0 * cpy * cxy + px)); + float hzz = 105.0 * (dot(wide * cxz, cxz) - dot(narrow, 2.0 * cpz * cxz + px)); + float hxy = 105.0 * (dot(wide * cxx, cxy) - dot(narrow, cpx * cxy + cpy * cxx)); + float hxz = 105.0 * (dot(wide * cxx, cxz) - dot(narrow, cpx * cxz + cpz * cxx)); + float hyz = 105.0 * (dot(wide * cxy, cxz) - dot(narrow, cpy * cxz + cpz * cxy)); + return mat3(hxx, hxy, hxz, hxy, hyy, hyz, hxz, hyz, hzz); +} + +// The integral over [t0, t1] of clamp(a + b t, lo, hi); see the C# math.integral_clamp_linear comment. +float cultmath_integral_clamp_linear(float a, float b, float lo, float hi, float t0, float t1) +{ + lo = min(lo, hi); + if (b == 0.0) + return t1 != t0 ? clamp(a, lo, hi) * (t1 - t0) : 0.0; + + float direction = t1 < t0 ? -1.0 : 1.0; + float start = min(t0, t1); + float stop = max(t0, t1); + float atLo = (lo - a) / b; + float atHi = (hi - a) / b; + float enter = clamp(min(atLo, atHi), start, stop); + float leave = clamp(max(atLo, atHi), start, stop); + float before = b > 0.0 ? lo : hi; + float after = b > 0.0 ? hi : lo; + float held = enter > start ? before * (enter - start) : 0.0; + float kept = stop > leave ? after * (stop - leave) : 0.0; + float total = held + (a + b * (0.5 * (enter + leave))) * (leave - enter) + kept; + return direction * total; +} + +// Octave sum of cultmath_snoise_grad (invariant 8); see the C# math.fbm_grad comment for the +// per-octave frequency/amplitude derivation and why octaves is clamped. +vec4 cultmath_fbm_grad(vec3 p, int octaves, float lacunarity, float gain) +{ + octaves = clamp(octaves, 0, 16); + float amplitude = 1.0; + float frequency = 1.0; + float value = 0.0; + vec3 gradient = vec3(0.0, 0.0, 0.0); + for (int i = 0; i < octaves; i++) + { + vec4 n = cultmath_snoise_grad(p * frequency); + value += amplitude * n.w; + gradient += amplitude * frequency * n.xyz; + frequency *= lacunarity; + amplitude *= gain; + } + + return vec4(gradient, value); +} + +// Ridged multifractal noise (Musgrave, Texturing and Modeling, ch. 16); see the C# math.ridged_grad +// comment for the fold/sign derivation, the deliberate crease at n = 0, and why octaves is clamped. +vec4 cultmath_ridged_grad(vec3 p, int octaves, float lacunarity, float gain) +{ + octaves = clamp(octaves, 0, 16); + float amplitude = 1.0; + float frequency = 1.0; + float value = 0.0; + vec3 gradient = vec3(0.0, 0.0, 0.0); + for (int i = 0; i < octaves; i++) + { + vec4 n = cultmath_snoise_grad(p * frequency); + float s = sign(n.w); + value += amplitude * (1.0 - abs(n.w)); + gradient += -amplitude * frequency * s * n.xyz; + frequency *= lacunarity; + amplitude *= gain; + } + + return vec4(gradient, value); +} + +float cultmath_lengthsq(vec2 value) { return dot(value, value); } +float cultmath_lengthsq(vec3 value) { return dot(value, value); } +float cultmath_lengthsq(vec4 value) { return dot(value, value); } + +float cultmath_distance(vec2 left, vec2 right) { return length(left - right); } +float cultmath_distance(vec3 left, vec3 right) { return length(left - right); } +float cultmath_distance(vec4 left, vec4 right) { return length(left - right); } + +vec2 cultmath_reflect(vec2 incident, vec2 normal_value) +{ + return incident - 2.0 * dot(normal_value, incident) * normal_value; +} + +vec3 cultmath_reflect(vec3 incident, vec3 normal_value) +{ + return incident - 2.0 * dot(normal_value, incident) * normal_value; +} + +vec2 cultmath_rotate(vec2 value, float radians_value) +{ + float s = sin(radians_value); + float c = cos(radians_value); + return vec2(value.x * c - value.y * s, value.x * s + value.y * c); +} + +vec2 cultmath_rotate_degrees(vec2 value, float degrees_value) +{ + return cultmath_rotate(value, cultmath_radians(degrees_value)); +} + +float cultmath_csum(vec2 value) { return value.x + value.y; } +float cultmath_csum(vec3 value) { return value.x + value.y + value.z; } +float cultmath_csum(vec4 value) { return value.x + value.y + value.z + value.w; } + +float cultmath_decay(float source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); } +vec2 cultmath_decay(vec2 source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); } +vec3 cultmath_decay(vec3 source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); } +vec4 cultmath_decay(vec4 source, float lambda_value, float dt) { return source * exp(-lambda_value * dt); } + +float cultmath_damp(float start, float end, float lambda_value, float dt) +{ + return cultmath_lerp(start, end, 1.0 - exp(-lambda_value * dt)); +} + +vec2 cultmath_damp(vec2 start, vec2 end, float lambda_value, float dt) +{ + float amount = 1.0 - exp(-lambda_value * dt); + return cultmath_lerp(start, end, vec2(amount, amount)); +} + +vec3 cultmath_damp(vec3 start, vec3 end, float lambda_value, float dt) +{ + float amount = 1.0 - exp(-lambda_value * dt); + return cultmath_lerp(start, end, vec3(amount, amount, amount)); +} + +vec4 cultmath_damp(vec4 start, vec4 end, float lambda_value, float dt) +{ + float amount = 1.0 - exp(-lambda_value * dt); + return cultmath_lerp(start, end, vec4(amount, amount, amount, amount)); +} + +float cultmath_catmullrom(float p0, float p1, float p2, float p3, float t) +{ + float t2 = t * t; + float t3 = t2 * t; + return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); +} + +vec2 cultmath_catmullrom(vec2 p0, vec2 p1, vec2 p2, vec2 p3, float t) +{ + float t2 = t * t; + float t3 = t2 * t; + return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); +} + +vec3 cultmath_catmullrom(vec3 p0, vec3 p1, vec3 p2, vec3 p3, float t) +{ + float t2 = t * t; + float t3 = t2 * t; + return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); +} + +vec4 cultmath_catmullrom(vec4 p0, vec4 p1, vec4 p2, vec4 p3, float t) +{ + float t2 = t * t; + float t3 = t2 * t; + return 0.5 * ((2.0 * p1) + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3); +} + +float cultmath_quadratic_bezier(float p0, float p1, float p2, float t) +{ + t = cultmath_saturate(t); + float inv = 1.0 - t; + return inv * inv * p0 + 2.0 * inv * t * p1 + t * t * p2; +} + +vec3 cultmath_quadratic_bezier(vec3 p0, vec3 p1, vec3 p2, float t) +{ + t = cultmath_saturate(t); + float inv = 1.0 - t; + return inv * inv * p0 + 2.0 * inv * t * p1 + t * t * p2; +} + +float cultmath_cubic_bezier(float p0, float p1, float p2, float p3, float t) +{ + t = cultmath_saturate(t); + float inv = 1.0 - t; + return inv * inv * inv * p0 + 3.0 * inv * inv * t * p1 + 3.0 * inv * t * t * p2 + t * t * t * p3; +} + +vec3 cultmath_cubic_bezier(vec3 p0, vec3 p1, vec3 p2, vec3 p3, float t) +{ + t = cultmath_saturate(t); + float inv = 1.0 - t; + return inv * inv * inv * p0 + 3.0 * inv * inv * t * p1 + 3.0 * inv * t * t * p2 + t * t * t * p3; +} + +float cultmath_hash(float value) { return cultmath_frac(sin(value) * 43758.5453); } +float cultmath_hash(vec2 value) { return cultmath_hash(dot(value, vec2(127.1, 311.7))); } +float cultmath_hash(vec3 value) { return cultmath_hash(dot(value, vec3(127.1, 311.7, 74.7))); } + +// PCG hashes (Jarzynski and Olano, JCGT 2020), written over scalar uints so CultMath's ivec3/ivec4 carry the bits. +uint cultmath_pcg(uint value) +{ + uint state = value * 747796405u + 2891336453u; + uint word = ((state >> int((state >> 28) + 4u)) ^ state) * 277803737u; + return (word >> 22) ^ word; +} + +ivec3 cultmath_pcg3d(ivec3 value) +{ + uint x = uint(value.x) * 1664525u + 1013904223u, y = uint(value.y) * 1664525u + 1013904223u, z = uint(value.z) * 1664525u + 1013904223u; + x += y * z; y += z * x; z += x * y; + x ^= x >> 16; y ^= y >> 16; z ^= z >> 16; + x += y * z; y += z * x; z += x * y; + return ivec3(int(x), int(y), int(z)); +} + +ivec3 cultmath_pcg3d(vec3 value) { return cultmath_pcg3d(ivec3(int(floatBitsToUint(value.x)), int(floatBitsToUint(value.y)), int(floatBitsToUint(value.z)))); } +ivec3 cultmath_pcg3d(vec2 value) { return cultmath_pcg3d(vec3(value, 0.0)); } + +ivec4 cultmath_pcg4d(ivec4 value) +{ + uint x = uint(value.x) * 1664525u + 1013904223u, y = uint(value.y) * 1664525u + 1013904223u; + uint z = uint(value.z) * 1664525u + 1013904223u, w = uint(value.w) * 1664525u + 1013904223u; + x += y * w; y += z * x; z += x * y; w += y * z; + x ^= x >> 16; y ^= y >> 16; z ^= z >> 16; w ^= w >> 16; + x += y * w; y += z * x; z += x * y; w += y * z; + return ivec4(int(x), int(y), int(z), int(w)); +} + +ivec4 cultmath_pcg4d(vec4 value) { return cultmath_pcg4d(ivec4(int(floatBitsToUint(value.x)), int(floatBitsToUint(value.y)), int(floatBitsToUint(value.z)), int(floatBitsToUint(value.w)))); } + +// Inigo Quilez, "smooth minimum": quadratic-polynomial smin carried to value-and-gradient form. See +// math.smin_grad's comment (math.cs) for why the gradient blend is exact, not an approximation. +vec4 cultmath_smin_grad(vec4 a, vec4 b, float k) +{ + float h = clamp(0.5 + 0.5 * (b.w - a.w) / k, 0.0, 1.0); + float value = mix(b.w, a.w, h) - k * h * (1.0 - h); + vec3 gradient = mix(b.xyz, a.xyz, h); + return vec4(gradient, value); +} + +// Invariant 8's one struct return shape (design.md, "HLSL Target: Source Transformations"): +// compared field by field and bit for bit against CultMath.CultCellular, same field order. +struct CultCellular +{ + vec4 nearest; + vec4 edge; + float id; +}; + +// Worley, "A Cellular Texture Basis Function" (SIGGRAPH 1996). See math.cellular's comment +// (math.cs) for the hashing, gradient, search-radius exactness proof, id source and +// degenerate-point conventions this mirrors exactly, including the 5x5x5 lower-bound prune. +CultCellular cultmath_cellular(vec3 p) +{ + vec3 cell = floor(p); + float f1 = 1.0e30; + float f2 = 1.0e30; + vec3 c1 = vec3(0.0, 0.0, 0.0); + vec3 c2 = vec3(0.0, 0.0, 0.0); + vec3 cellId = vec3(0.0, 0.0, 0.0); + + for (int dz = -2; dz <= 2; dz++) + { + float lz = max(0.0, abs(float(dz)) - 1.0); + for (int dy = -2; dy <= 2; dy++) + { + float ly = max(0.0, abs(float(dy)) - 1.0); + for (int dx = -2; dx <= 2; dx++) + { + float lx = max(0.0, abs(float(dx)) - 1.0); + float lowerBound = sqrt(lx * lx + ly * ly + lz * lz); + if (lowerBound >= f2) + continue; + + vec3 neighbor = cell + vec3(dx, dy, dz); + ivec3 hash = cultmath_pcg3d(ivec3(neighbor)); + vec3 jitter = vec3( + float(uint(hash.x) >> 8) * (1.0 / 16777216.0), + float(uint(hash.y) >> 8) * (1.0 / 16777216.0), + float(uint(hash.z) >> 8) * (1.0 / 16777216.0)); + vec3 feature = neighbor + jitter; + float d = cultmath_distance(p, feature); + + if (d < f1) + { + f2 = f1; c2 = c1; + f1 = d; c1 = feature; cellId = neighbor; + } + else if (d < f2) + { + f2 = d; c2 = feature; + } + } + } + } + + // F1 = 0 and F2 = 0 share the same degenerate-gradient guard; see math.cellular's comment + // (math.cs) for why F2 = 0 is reachable past roughly |p| = 2^23. + vec3 grad1 = f1 > 0.0 ? (p - c1) / f1 : vec3(0.0, 0.0, 0.0); + vec3 grad2 = f2 > 0.0 ? (p - c2) / f2 : vec3(0.0, 0.0, 0.0); + + // id comes from pcg4d over the winning cell's integer coordinate alone (same int-hashing rule + // as the jitter above), never from the pcg3d hash that produced its jitter (F4). + ivec4 idHash = cultmath_pcg4d(ivec4(ivec3(cellId), 0)); + + CultCellular result; + result.nearest = vec4(grad1, f1); + result.edge = vec4(grad2 - grad1, f2 - f1); + result.id = float(uint(idHash.w) >> 8) * (1.0 / 16777216.0); + return result; +} + + + + +// Interval arithmetic over vec2(lo, hi), lo <= hi, both finite. Every cultmath_iv_* function returns +// an interval containing f(x) for every x in its input intervals, mirroring math.Interval.cs bit for +// bit; see that file and docs/design.md ("Intervals") for each op's enclosure and its ulp widening. +// Included by CultMath.hlsl after its own definitions of cultmath_smoothstep and cultmath_snoise. + +// Lipschitz constant of cultmath_snoise(vec3); provenance in math.Interval.cs (SNOISE_LIPSCHITZ). +const float CULTMATH_SNOISE_LIPSCHITZ = 7.9640074; + +// Lipschitz constant of cultmath_snoise(vec2); provenance in math.Interval.cs (SNOISE2_LIPSCHITZ). +const float CULTMATH_SNOISE2_LIPSCHITZ = 8.117002; + +vec2 cultmath_iv_point(float value) { return vec2(value, value); } +vec2 cultmath_iv_add(vec2 a, vec2 b) { return vec2(a.x + b.x, a.y + b.y); } +vec2 cultmath_iv_sub(vec2 a, vec2 b) { return vec2(a.x - b.y, a.y - b.x); } +vec2 cultmath_iv_neg(vec2 a) { return vec2(-a.y, -a.x); } + +vec2 cultmath_iv_mul(vec2 a, vec2 b) +{ + float p0 = a.x * b.x; + float p1 = a.x * b.y; + float p2 = a.y * b.x; + float p3 = a.y * b.y; + return vec2(min(min(p0, p1), min(p2, p3)), max(max(p0, p1), max(p2, p3))); +} + +vec2 cultmath_iv_scale(vec2 a, float s) { return s >= 0.0 ? vec2(a.x * s, a.y * s) : vec2(a.y * s, a.x * s); } + +vec2 cultmath_iv_abs(vec2 a) +{ + if (a.x >= 0.0) + return a; + if (a.y <= 0.0) + return vec2(-a.y, -a.x); + return vec2(0.0, max(-a.x, a.y)); +} + +vec2 cultmath_iv_min(vec2 a, vec2 b) { return vec2(min(a.x, b.x), min(a.y, b.y)); } +vec2 cultmath_iv_max(vec2 a, vec2 b) { return vec2(max(a.x, b.x), max(a.y, b.y)); } + +vec2 cultmath_iv_sqr(vec2 a) +{ + float l = a.x * a.x; + float h = a.y * a.y; + if (a.x >= 0.0) + return vec2(l, h); + if (a.y <= 0.0) + return vec2(h, l); + return vec2(0.0, max(l, h)); +} + +vec2 cultmath_iv_sqrt(vec2 a) { return vec2(sqrt(max(a.x, 0.0)), sqrt(max(a.y, 0.0))); } + +// Widened by one ulp on each side: exp is not required to be correctly rounded. +vec2 cultmath_iv_exp(vec2 a) +{ + float lo = exp(a.x); + float hi = exp(a.y); + return vec2(lo > 0.0 ? uintBitsToFloat(floatBitsToUint(lo) - 1u) : 0.0, uintBitsToFloat(floatBitsToUint(hi) + 1u)); +} + +vec2 cultmath_iv_clamp(vec2 a, float minimum, float maximum) +{ + return vec2(cultmath_clamp(a.x, minimum, maximum), cultmath_clamp(a.y, minimum, maximum)); +} + +vec2 cultmath_iv_saturate(vec2 a) { return vec2(cultmath_saturate(a.x), cultmath_saturate(a.y)); } + +// The endpoints, ordered, widened by one ulp inside [0, 1]: the float32 evaluation of smoothstep is +// monotone only to within one ulp. +vec2 cultmath_iv_smoothstep(float minimum, float maximum, vec2 a) +{ + float s0 = cultmath_smoothstep(minimum, maximum, a.x); + float s1 = cultmath_smoothstep(minimum, maximum, a.y); + float lo = min(s0, s1); + float hi = max(s0, s1); + return vec2(lo > 0.0 ? uintBitsToFloat(floatBitsToUint(lo) - 1u) : 0.0, hi < 1.0 ? uintBitsToFloat(floatBitsToUint(hi) + 1u) : 1.0); +} + +// The natural interval extension of x + (y - x) * amount, which is how cultmath_lerp evaluates. +vec2 cultmath_iv_lerp(vec2 a, vec2 b, float amount) { return cultmath_iv_add(a, cultmath_iv_scale(cultmath_iv_sub(b, a), amount)); } + +// [n - L r, n + L r] intersected with [-1, 1], n = cultmath_snoise(centre). One snoise, value only. +vec2 cultmath_iv_snoise_ball(vec3 centre, float radius) +{ + float n = cultmath_snoise(centre); + float e = CULTMATH_SNOISE_LIPSCHITZ * radius; + return vec2(max(n - e, -1.0), min(n + e, 1.0)); +} + +// The 2D overload: [n - L2 r, n + L2 r] intersected with [-1, 1], n = cultmath_snoise(centre). One snoise, value only. +vec2 cultmath_iv_snoise_ball(vec2 centre, float radius) +{ + float n = cultmath_snoise(centre); + float e = CULTMATH_SNOISE2_LIPSCHITZ * radius; + return vec2(max(n - e, -1.0), min(n + e, 1.0)); +} + +// Octave sum of cultmath_iv_snoise_ball, compounding frequency and amplitude as the value-and-gradient fBm does. +vec2 cultmath_iv_fbm_ball(vec3 centre, float radius, int octaves, float lacunarity, float gain) +{ + octaves = clamp(octaves, 0, 16); + float amplitude = 1.0; + float frequency = 1.0; + vec2 sum = vec2(0.0, 0.0); + for (int i = 0; i < octaves; i++) + { + sum = cultmath_iv_add(sum, cultmath_iv_scale(cultmath_iv_snoise_ball(centre * frequency, radius * abs(frequency)), amplitude)); + frequency *= lacunarity; + amplitude *= gain; + } + + return sum; +} + +// A ball (xyz centre, w radius) enclosing every point of every ray of a screen tile over the depth segment +// [z0, z1], warped by at most warp. Camera frame: pinhole at the origin looking down +z, ray (m z, z). +// footprintPerDepth = N / (sqrt(2) f) for an N x N tile at focal length f. The radius is widened by +// (|c|_1 + radius) 2^-20, which covers the ball's own float32 rounding. Derivation in math.Interval.cs. +vec4 cultmath_iv_frustum_ball(vec2 centreSlope, float z0, float z1, float footprintPerDepth, float warp) +{ + float zm = (z0 + z1) * 0.5; + float axis = sqrt(centreSlope.x * centreSlope.x + centreSlope.y * centreSlope.y + 1.0); + float cx = centreSlope.x * zm; + float cy = centreSlope.y * zm; + float radius = (z1 - z0) * 0.5 * axis + z1 * footprintPerDepth + warp; + radius += (abs(cx) + abs(cy) + abs(zm) + radius) * 9.5367431640625e-7; + return vec4(cx, cy, zm, radius); +} + + + + + + +// Reduced affine forms with one shared symbol over vec3(x0, a, e): the set { x0 + a eps + e delta : +// delta in [-1, 1] } at the region's shared eps in [-1, 1], e >= 0. Every cultmath_af_* function mirrors +// math.Affine.cs bit for bit; see that file and docs/design.md ("Affine forms") for each op's enclosure +// and the float32 rounding allowance it folds into e. Included by CultMath.hlsl after +// CultMath.Interval.hlsl, whose CULTMATH_SNOISE_LIPSCHITZ it shares, and after its own +// definition of cultmath_snoise_grad. + +// An upper bound on the spectral norm of the Hessian of cultmath_snoise(vec3); provenance in +// math.Affine.cs (SNOISE_HESSIAN). +const float CULTMATH_SNOISE_HESSIAN = 55.642143; + +// The absolute rounding of one float32 read of cultmath_snoise_grad, value and gradient; the owner is +// math.Affine.cs (SNOISE_READ_VALUE, SNOISE_READ_GRADIENT), read here by cultmath_af_snoise and in +// CultMath.hlsl by cultmath_span_slack. +const float CULTMATH_SNOISE_READ_VALUE = 6.103515625e-5; +const float CULTMATH_SNOISE_READ_GRADIENT = 2.44140625e-4; + +// An upper bound on the operator norm of the third-derivative tensor of cultmath_snoise(vec3), the +// next order after CULTMATH_SNOISE_HESSIAN; provenance in math.Span.cs (SNOISE_THIRD). +const float CULTMATH_SNOISE_THIRD = 535.8009; + +vec3 cultmath_af_point(float value) { return vec3(value, 0.0, 0.0); } +vec3 cultmath_af_symbol(float x0, float a) { return vec3(x0, a, 0.0); } + +vec3 cultmath_af_from_iv(vec2 interval) +{ + float x0 = interval.x * 0.5 + interval.y * 0.5; + float e = interval.y * 0.5 - interval.x * 0.5; + return vec3(x0, 0.0, e + (abs(x0) + e) * 9.5367431640625e-7 + 1.17549435e-38); +} + +// An endpoint that would be NaN is the infinity on its side. NaN is found by its bit pattern: dxc marks +// compares fast, so a test that needs NaN to make `lo == lo` false is deleted under its default flags. +vec2 cultmath_af_range(vec3 x) +{ + float r = abs(x.y) + x.z; + float w = abs(x.x) * 9.5367431640625e-7 + r * 9.5367431640625e-7; + float lo = x.x - r - w; + float hi = x.x + r + w; + return vec2((floatBitsToUint(lo) & 0x7fffffffu) > 0x7f800000u ? uintBitsToFloat(0xff800000u) : lo, + (floatBitsToUint(hi) & 0x7fffffffu) > 0x7f800000u ? uintBitsToFloat(0x7f800000u) : hi); +} + +vec3 cultmath_af_add(vec3 x, vec3 y) +{ + float x0 = x.x + y.x; + float a = x.y + y.y; + float e = x.z + y.z; + return vec3(x0, a, e + (abs(x0) + abs(a) + e) * 9.5367431640625e-7); +} + +vec3 cultmath_af_sub(vec3 x, vec3 y) +{ + float x0 = x.x - y.x; + float a = x.y - y.y; + float e = x.z + y.z; + return vec3(x0, a, e + (abs(x0) + abs(a) + e) * 9.5367431640625e-7); +} + +vec3 cultmath_af_neg(vec3 x) { return vec3(-x.x, -x.y, x.z); } + +vec3 cultmath_af_scale(vec3 x, float s) +{ + float x0 = x.x * s; + float a = x.y * s; + float e = x.z * abs(s); + return vec3(x0, a, e + (abs(x0) + abs(a) + e) * 9.5367431640625e-7 + 1.17549435e-38); +} + +vec3 cultmath_af_add_iv(vec3 x, vec2 interval) { return cultmath_af_add(x, cultmath_af_from_iv(interval)); } + +vec3 cultmath_af_mul(vec3 x, vec3 y) +{ + float x0 = x.x * y.x; + float p = x.x * y.y; + float q = y.x * x.y; + float a = p + q; + float e = abs(x.x) * y.z + abs(y.x) * x.z + (abs(x.y) + x.z) * (abs(y.y) + y.z); + return vec3(x0, a, e + (abs(x0) + abs(p) + abs(q) + e) * 9.5367431640625e-7 + 1.17549435e-38); +} + +// The narrower of the Lipschitz form and the centred (Taylor) form over every p = centre + axis eps + w, +// |w| <= radius, from one cultmath_snoise_grad at the centre; the allowance for the float32 evaluation is +// in math.Affine.cs. +vec3 cultmath_af_snoise(vec3 centre, vec3 axis, float radius) +{ + vec4 g = cultmath_snoise_grad(centre); + vec3 gradient = vec3(g.x, g.y, g.z); + float reach = length(axis) + radius; + float lipschitz = CULTMATH_SNOISE_LIPSCHITZ * reach; + float a = dot(gradient, axis); + float remainder = length(gradient) * radius + 0.5 * CULTMATH_SNOISE_HESSIAN * reach * reach; + float allowance = CULTMATH_SNOISE_READ_VALUE + CULTMATH_SNOISE_READ_GRADIENT * reach + + CULTMATH_SNOISE_LIPSCHITZ * 9.5367431640625e-7 * (abs(centre.x) + abs(centre.y) + abs(centre.z) + reach); + bool centred = abs(a) + remainder < lipschitz; + float slope = centred ? a : 0.0; + float e = (centred ? remainder : lipschitz) + allowance; + return vec3(g.w, slope, e + (abs(g.w) + abs(slope) + e) * 9.5367431640625e-7); +} + +// Octave sum of cultmath_af_snoise, compounding frequency and amplitude as the value-and-gradient fBm does. +vec3 cultmath_af_fbm(vec3 centre, vec3 axis, float radius, int octaves, float lacunarity, float gain) +{ + octaves = clamp(octaves, 0, 16); + float amplitude = 1.0; + float frequency = 1.0; + vec3 sum = vec3(0.0, 0.0, 0.0); + for (int i = 0; i < octaves; i++) + { + sum = cultmath_af_add(sum, cultmath_af_scale(cultmath_af_snoise(centre * frequency, axis * frequency, radius * abs(frequency)), amplitude)); + frequency *= lacunarity; + amplitude *= gain; + } + + return sum; +} + +// The axis of a screen tile's slice over the depth segment [z0, z1]; camera frame of +// cultmath_iv_frustum_ball. +vec3 cultmath_af_frustum_axis(vec2 centreSlope, float z0, float z1) +{ + float h = (z1 - z0) * 0.5; + return vec3(centreSlope.x * h, centreSlope.y * h, h); +} + +// The affine slice of a screen tile over [z0, z1]: centre xyz, radius w. Derivation in math.Affine.cs. +vec4 cultmath_af_frustum_ball(vec2 centreSlope, float z0, float z1, float footprintPerDepth, float warpVariation) +{ + float zm = (z0 + z1) * 0.5; + float h = (z1 - z0) * 0.5; + float cx = centreSlope.x * zm; + float cy = centreSlope.y * zm; + float spread = (abs(centreSlope.x) + abs(centreSlope.y) + 1.0) * h; + float radius = z1 * footprintPerDepth + warpVariation; + radius += (abs(cx) + abs(cy) + abs(zm) + spread + radius) * 9.5367431640625e-7; + return vec4(cx, cy, zm, radius); +} + + + + +// Span stages (invariant 8): the SpanPath, SpanStage and SpanNoise of math.Span.cs, field for field in +// C#'s order, and cultmath_span_stage, its octave stage, statement for statement so the text is a bit-for-bit +// mirror. They follow the includes because the bounds read CULTMATH_SNOISE_LIPSCHITZ, _HESSIAN and _THIRD. +// A shader caller builds a SpanStage itself (SpanStage(1.0, 1.0, vec3(0.0), vec3(0.0), vec3(0.0), vec3(0.0)) +// is the identity of math.SpanStage.Identity, which has no shader form); see math.span_stage's comments for +// the span, the transform, the bounds and the allowances. cultmath_span_rounding and cultmath_span_slack +// are math.span_rounding and math.span_slack, the owners of S_o and of the three allowances. +struct SpanPath +{ + vec3 midpoint; + vec3 velocity; + float speed; + float acceleration; + float halfLength; +}; + +struct SpanStage +{ + float frequency; + float stretch; + vec3 axis; + vec3 shift; + vec3 offset; + vec3 octaveStep; +}; + +struct SpanNoise +{ + vec4 field; + float curvature; + float fold; +}; + +float cultmath_span_rounding(float scale, vec3 mq, SpanStage stage, vec3 octaveShift, float nu, float halfLength) +{ + return 9.5367431640625e-7 * (abs(scale) * (cultmath_csum(abs(mq)) + cultmath_csum(abs(stage.shift))) + cultmath_csum(abs(stage.offset)) + cultmath_csum(abs(octaveShift)) + 1.7320508 * nu * halfLength); +} + +vec3 cultmath_span_slack(float rounding) +{ + return vec3(CULTMATH_SNOISE_READ_VALUE + CULTMATH_SNOISE_LIPSCHITZ * rounding, + CULTMATH_SNOISE_READ_GRADIENT + CULTMATH_SNOISE_HESSIAN * rounding, + 9.765625e-4 + CULTMATH_SNOISE_THIRD * rounding); +} + +// The octave stage behind cultmath_span_fbm and cultmath_span_ridged, as math.span_stage is behind span_fbm +// and span_ridged. The Frobenius norm of the Hessian is written out in C#'s order (GLSL has no _mRC member +// access); the C# local named step is octaveShift here (it would shadow GLSL's step()). +SpanNoise cultmath_span_stage(bool ridged, SpanPath path, SpanStage stage, int octaves, float lacunarity, float gain) +{ + vec3 q = path.midpoint; + float halfLength = path.halfLength; + octaves = clamp(octaves, 0, 16); + bool stretched = dot(stage.axis, stage.axis) > 0.0 && stage.stretch != 1.0; + vec3 direction = stretched ? normalize(stage.axis) : vec3(0.0, 0.0, 0.0); + float squeeze = stretched ? 1.0 / stage.stretch - 1.0 : 0.0; + float norm = abs(stage.frequency) * (stretched ? max(1.0, 1.0 / stage.stretch) : 1.0); + vec3 mq = stretched ? stage.frequency * (q + squeeze * dot(direction, q) * direction) : stage.frequency * q; + vec3 mv = stretched ? stage.frequency * (path.velocity + squeeze * dot(direction, path.velocity) * direction) : stage.frequency * path.velocity; + float amplitude = 1.0; + float scale = 1.0; + float value = 0.0; + vec3 gradient = vec3(0.0, 0.0, 0.0); + float curvature = 0.0; + float fold = 0.0; + for (int i = 0; i < octaves; i++) + { + vec3 octaveShift = float(i) * stage.octaveStep; + vec3 x = scale * (mq + stage.shift) + (stage.offset + octaveShift); + vec4 n = cultmath_snoise_grad(x); + vec3 g = vec3(n.x, n.y, n.z); + vec3 mg = stretched ? stage.frequency * (g + squeeze * dot(direction, g) * direction) : stage.frequency * g; + if (ridged) + { + float s = sign(n.w); + value += amplitude * (1.0 - abs(n.w)); + gradient += -amplitude * scale * s * mg; + } + else + { + value += amplitude * n.w; + gradient += amplitude * scale * mg; + } + + float weight = abs(amplitude); + float factor = abs(scale) * norm; + float nu = factor * path.speed; + float rounding = cultmath_span_rounding(scale, mq, stage, octaveShift, nu, halfLength); + vec3 slack = cultmath_span_slack(rounding); + float valueSlack = slack.x; + float gradientSlack = slack.y; + float hessianSlack = slack.z; + mat3 hm = cultmath_snoise_hessian(x); + float frobenius = sqrt(hm[0][0] * hm[0][0] + hm[0][1] * hm[0][1] + hm[0][2] * hm[0][2] + hm[1][0] * hm[1][0] + hm[1][1] * hm[1][1] + hm[1][2] * hm[1][2] + + hm[2][0] * hm[2][0] + hm[2][1] * hm[2][1] + hm[2][2] * hm[2][2]); + float hessianBound = min(CULTMATH_SNOISE_HESSIAN, frobenius + hessianSlack + CULTMATH_SNOISE_THIRD * nu * halfLength); + curvature += weight * factor * factor * hessianBound; + if (ridged) + { + vec3 w = scale * mv; + float reach = hessianBound * nu * halfLength; + float gl = min(CULTMATH_SNOISE_LIPSCHITZ, length(g) + gradientSlack + reach); + float travel = (abs(dot(g, w)) + gradientSlack * length(w)) * halfLength + (hessianBound * nu * length(w) + gl * factor * path.acceleration) * (0.5 * halfLength * halfLength) + 2.0 * valueSlack; + fold += 2.0 * weight * max(travel - abs(n.w), 0.0); + } + + scale *= lacunarity; + amplitude *= gain; + } + + SpanNoise result; + result.field = vec4(gradient, value); + result.curvature = curvature; + result.fold = fold; + return result; +} + +SpanNoise cultmath_span_fbm(SpanPath path, SpanStage stage, int octaves, float lacunarity, float gain) +{ + return cultmath_span_stage(false, path, stage, octaves, lacunarity, gain); +} + +SpanNoise cultmath_span_ridged(SpanPath path, SpanStage stage, int octaves, float lacunarity, float gain) +{ + return cultmath_span_stage(true, path, stage, octaves, lacunarity, gain); +} + +float cultmath_value_noise(vec2 position) +{ + vec2 cell = floor(position); + vec2 local = cultmath_frac(position); + vec2 u = local * local * (vec2(3.0, 3.0) - vec2(2.0, 2.0) * local); + + float a = cultmath_hash(cell); + float b = cultmath_hash(cell + vec2(1.0, 0.0)); + float c = cultmath_hash(cell + vec2(0.0, 1.0)); + float d = cultmath_hash(cell + vec2(1.0, 1.0)); + + return cultmath_lerp(cultmath_lerp(a, b, u.x), cultmath_lerp(c, d, u.x), u.y); +} + +float cultmath_value_noise_bicubic(vec2 position) +{ + vec2 cell = floor(position); + vec2 local = cultmath_frac(position); + + float y0 = cultmath_catmullrom(cultmath_hash(cell + vec2(-1.0, -1.0)), cultmath_hash(cell + vec2(0.0, -1.0)), cultmath_hash(cell + vec2(1.0, -1.0)), cultmath_hash(cell + vec2(2.0, -1.0)), local.x); + float y1 = cultmath_catmullrom(cultmath_hash(cell + vec2(-1.0, 0.0)), cultmath_hash(cell + vec2(0.0, 0.0)), cultmath_hash(cell + vec2(1.0, 0.0)), cultmath_hash(cell + vec2(2.0, 0.0)), local.x); + float y2 = cultmath_catmullrom(cultmath_hash(cell + vec2(-1.0, 1.0)), cultmath_hash(cell + vec2(0.0, 1.0)), cultmath_hash(cell + vec2(1.0, 1.0)), cultmath_hash(cell + vec2(2.0, 1.0)), local.x); + float y3 = cultmath_catmullrom(cultmath_hash(cell + vec2(-1.0, 2.0)), cultmath_hash(cell + vec2(0.0, 2.0)), cultmath_hash(cell + vec2(1.0, 2.0)), cultmath_hash(cell + vec2(2.0, 2.0)), local.x); + + return cultmath_catmullrom(y0, y1, y2, y3, local.y); +} + +#endif diff --git a/src/app/features/scene/shaders/README.md b/src/app/features/scene/shaders/README.md new file mode 100644 index 00000000..617644fb --- /dev/null +++ b/src/app/features/scene/shaders/README.md @@ -0,0 +1,15 @@ +# Vendored CultMath GLSL + +`CultMath.glsl` and `CultMath.Phacelle.glsl` are CultLib's generated WebGL2 +lowering of `packages/cultmath/shaders/CultMath.hlsl` and +`CultMath.Phacelle.hlsl`, copied unedited from CultLib branch +`eureka/cultmath-tapes-interval-ops-r5` at `658c09e` (the branch that carries +the interval ops and `CULTMATH_SNOISE2_LIPSCHITZ`; the repo default branch +does not carry the GLSL yet). Never edit them here: regenerate in CultLib and +re-copy. `CultMath.glsl` is MIT; `CultMath.Phacelle.glsl` is MPL-2.0 (Rune +Skovbo Johansen's Phacelle noise, see its header), so that file keeps its +header and any change to it is published under MPL-2.0. + +The other `*.glsl` files here are the scene's own stages. They are +concatenated by `../shaderSource.ts` after a prelude; they use no colour +literal (the palette is uniforms) and define no noise. diff --git a/src/app/features/scene/shaders/atmosphere.frag.glsl b/src/app/features/scene/shaders/atmosphere.frag.glsl new file mode 100644 index 00000000..adbf1b5b --- /dev/null +++ b/src/app/features/scene/shaders/atmosphere.frag.glsl @@ -0,0 +1,90 @@ +// Pass 2: atmosphere, marched only up to pass 1's depth. Height fog is closed-form; the cloud +// slab is marched with a few jittered steps. Output: rgb in-scattered light, a transmittance. +uniform sampler2D u_depth; // pass 1 hit distance, nearest +uniform int u_steps; // cloud march steps +uniform float u_frame; +uniform vec2 u_jitter; // sub-pixel jitter in pixels of this target +uniform vec2 u_size; // this target's size in pixels +uniform float u_coverage; // weather knob: 0 clear .. 1 overcast + +in vec2 v_uv; +layout(location = 0) out vec4 o_atm; + +float hg(float cosTheta, float g) +{ + float g2 = g * g; + return (1.0 - g2) / (12.566370614 * pow(1.0 + g2 - 2.0 * g * cosTheta, 1.5)); +} + +float cloudDensity(vec3 p) +{ + float wind = u_time * 2.5; + float f = cultmath_fbm_grad(vec3(p.x * 0.0042 + wind * 0.0042, p.y * 0.0075, p.z * 0.0042), 3, 2.0, 0.5).w; + float h = (p.y - W_CLOUD_Y0) / (W_CLOUD_Y1 - W_CLOUD_Y0); + float profile = smoothstep(0.0, 0.2, h) * smoothstep(1.0, 0.55, h); + return clamp((f + 0.55 * u_coverage - 0.25) * 2.4, 0.0, 1.0) * profile; +} + +void main() +{ + vec2 jittered = (gl_FragCoord.xy + u_jitter) / u_size; + vec3 rd = camRay(jittered); + vec3 ro = u_camPos; + float depth = texture(u_depth, v_uv).r; + float tEnd = min(depth, W_ATM_FAR); + + // Exponential height fog, closed form: optical depth of density d0 * exp(-k y) along the ray. + float k = W_FOG_FALLOFF; + float dy = rd.y * tEnd; + float tau = W_FOG_DENSITY * exp(-k * ro.y) * (abs(dy) > 1e-3 ? (1.0 - exp(-k * dy)) / (k * rd.y) : tEnd); + float fogT = exp(-max(tau, 0.0)); + float cosSun = dot(rd, u_sunDir); + vec3 fogLight = u_fogColor + u_sunColor * hg(cosSun, 0.55) * 0.9; + vec3 inscatter = fogLight * (1.0 - fogT); + float trans = fogT; + + // Clouds: only the part of the ray inside the slab and in front of the surface. + float ta = 0.0; + float tb = tEnd; + if (abs(rd.y) > 1e-4) + { + float t0 = (W_CLOUD_Y0 - ro.y) / rd.y; + float t1 = (W_CLOUD_Y1 - ro.y) / rd.y; + ta = max(0.0, min(t0, t1)); + tb = min(tEnd, max(t0, t1)); + } + else if (ro.y < W_CLOUD_Y0 || ro.y > W_CLOUD_Y1) + tb = 0.0; + + if (tb > ta && u_steps > 0) + { + float dt = (tb - ta) / float(u_steps); + float t = ta + dt * ign(gl_FragCoord.xy, u_frame); + float T = 1.0; + vec3 L = vec3(0.0); + float phase = hg(cosSun, 0.6) + 0.35 * hg(cosSun, -0.2); + for (int i = 0; i < u_steps; i++) + { + vec3 p = ro + rd * t; + float d = cloudDensity(p); + if (d > 0.01) + { + // One sample toward the sun stands in for self-shadowing. + float shade = exp(-cloudDensity(p + u_sunDir * 38.0) * 2.2); + vec3 light = u_sunColor * (phase * 6.0) * shade + mix(u_skyHorizon, u_skyTop, 0.6) * 0.35; + float ext = d * dt * 0.035; + float stepT = exp(-ext); + L += T * (1.0 - stepT) * light; + T *= stepT; + if (T < 0.03) + break; + } + t += dt; + } + // Fog in front of the slab is already in `inscatter`; clouds sit behind it. + inscatter += trans * L; + trans *= T; + } + + o_atm = vec4(inscatter, trans); +} diff --git a/src/app/features/scene/shaders/common.glsl b/src/app/features/scene/shaders/common.glsl new file mode 100644 index 00000000..ac3bb784 --- /dev/null +++ b/src/app/features/scene/shaders/common.glsl @@ -0,0 +1,31 @@ +// Shared by every fragment stage; concatenated after the prelude, CultMath and the world defines. +uniform vec3 u_camPos; +uniform mat3 u_camBasis; // columns: right, up, forward +uniform vec2 u_tanHalf; // x: tan(fov/2) * aspect, y: tan(fov/2) +uniform float u_time; +uniform vec3 u_sunDir; // unit, towards the sun +uniform vec3 u_sunColor; +uniform vec3 u_skyTop; +uniform vec3 u_skyHorizon; +uniform vec3 u_fogColor; + +vec3 camRay(vec2 uv) +{ + vec2 ndc = uv * 2.0 - 1.0; + return normalize(u_camBasis * vec3(ndc * u_tanHalf, 1.0)); +} + +float ign(vec2 p, float frame) +{ + p += 5.588238 * mod(frame, 64.0); + return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715)))); +} + +vec3 skyColor(vec3 rd) +{ + float h = clamp(rd.y, 0.0, 1.0); + vec3 c = mix(u_skyHorizon, u_skyTop, pow(h, 0.45)); + float s = max(dot(rd, u_sunDir), 0.0); + c += u_sunColor * (0.25 * pow(s, 8.0) + 1.5 * pow(s, 256.0)); + return c; +} diff --git a/src/app/features/scene/shaders/composite.frag.glsl b/src/app/features/scene/shaders/composite.frag.glsl new file mode 100644 index 00000000..9dce31f1 --- /dev/null +++ b/src/app/features/scene/shaders/composite.frag.glsl @@ -0,0 +1,26 @@ +// Full-resolution composite to the canvas: solid over sky, then the accumulated atmosphere. +uniform sampler2D u_solid; // rgb lit, a coverage (bilinear) +uniform sampler2D u_atm; // TAA'd atmosphere (bilinear) +uniform float u_frame; + +in vec2 v_uv; +out vec4 o_frag; + +vec3 tonemap(vec3 x) +{ + // Narkowicz ACES fit. + return clamp((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14), 0.0, 1.0); +} + +void main() +{ + vec3 rd = camRay(v_uv); + vec4 solid = texture(u_solid, v_uv); + vec4 atm = texture(u_atm, v_uv); + vec3 base = mix(skyColor(rd), solid.rgb, solid.a); + vec3 col = base * atm.a + atm.rgb; + col = tonemap(col); + col = pow(col, vec3(1.0 / 2.2)); + col += (ign(gl_FragCoord.xy, u_frame) - 0.5) / 255.0; + o_frag = vec4(col, 1.0); +} diff --git a/src/app/features/scene/shaders/fullscreen.vert.glsl b/src/app/features/scene/shaders/fullscreen.vert.glsl new file mode 100644 index 00000000..cab0e527 --- /dev/null +++ b/src/app/features/scene/shaders/fullscreen.vert.glsl @@ -0,0 +1,8 @@ +out vec2 v_uv; +void main() +{ + // One oversized triangle; uv spans 0..1 over the visible part. + vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2)); + v_uv = p; + gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0); +} diff --git a/src/app/features/scene/shaders/resolve.frag.glsl b/src/app/features/scene/shaders/resolve.frag.glsl new file mode 100644 index 00000000..cc6dd2e0 --- /dev/null +++ b/src/app/features/scene/shaders/resolve.frag.glsl @@ -0,0 +1,41 @@ +// TAA resolve for the atmosphere target: reproject history by the pass-1 depth, clamp it to the +// current frame's 3x3 neighbourhood, blend. +uniform sampler2D u_cur; +uniform sampler2D u_history; +uniform sampler2D u_depth; +uniform vec3 u_prevPos; +uniform mat3 u_prevBasis; +uniform vec2 u_prevTanHalf; +uniform float u_blend; // weight of the current frame; 1 resets +uniform vec2 u_curSize; + +in vec2 v_uv; +layout(location = 0) out vec4 o_hist; + +void main() +{ + vec2 px = 1.0 / u_curSize; + vec4 cur = texture(u_cur, v_uv); + vec4 lo = cur; + vec4 hi = cur; + for (int y = -1; y <= 1; y++) + for (int x = -1; x <= 1; x++) + { + vec4 s = texture(u_cur, v_uv + vec2(float(x), float(y)) * px); + lo = min(lo, s); + hi = max(hi, s); + } + + // Where this pixel's ray ended, reprojected into last frame's camera. + vec3 rd = camRay(v_uv); + float t = min(texture(u_depth, v_uv).r, 600.0); + vec3 v = u_camPos + rd * t - u_prevPos; + vec3 local = transpose(u_prevBasis) * v; + vec2 prevNdc = local.z > 0.0 ? local.xy / (local.z * u_prevTanHalf) : vec2(2.0); + vec2 prevUv = prevNdc * 0.5 + 0.5; + bool inside = all(greaterThanEqual(prevUv, vec2(0.0))) && all(lessThanEqual(prevUv, vec2(1.0))); + + float a = (inside && u_blend < 1.0) ? u_blend : 1.0; + vec4 hist = clamp(texture(u_history, prevUv), lo, hi); + o_hist = mix(hist, cur, a); +} diff --git a/src/app/features/scene/shaders/terrain.frag.glsl b/src/app/features/scene/shaders/terrain.frag.glsl new file mode 100644 index 00000000..770325b0 --- /dev/null +++ b/src/app/features/scene/shaders/terrain.frag.glsl @@ -0,0 +1,180 @@ +// Pass 1: solid surfaces (terrain + world tree), sphere-traced. Writes lit colour with coverage +// and, separately, the hit distance that bounds pass 2. +uniform vec3 u_grass; +uniform vec3 u_rock; +uniform vec3 u_bark; +uniform vec3 u_leaf; +uniform int u_maxSteps; + +in vec2 v_uv; +layout(location = 0) out vec4 o_color; // rgb lit, a coverage +layout(location = 1) out float o_depth; // hit distance along the ray, W_FAR on a miss + +// ---- terrain ------------------------------------------------------------------------------- + +float terrainMask(vec2 xz) { return smoothstep(W_PLATEAU_IN, W_PLATEAU_OUT, length(xz)); } + +// Value only; cultmath_fbm_grad is the shipped fbm, a public value-only fbm is Libby's gap. +float terrainFbm(vec2 xz) { return cultmath_fbm_grad(vec3(xz * W_FREQ, 0.0), W_OCTAVES, W_LACUNARITY, W_GAIN).w; } + +#if W_ERODE_ENABLED +CultPhasor terrainPhacelle(vec2 xz) +{ + // A fixed stripe wave vector: ridges across the sheet, not slope-aligned erosion. + return cultmath_phacelle(vec3(xz * W_ERODE_FREQ, 0.0), W_ERODE_SIDE, 0.0, W_ERODE_NORM); +} +#endif + +// Conservative distance to the terrain graph. Above the envelope (fbm + erosion amplitude) the +// cheap envelope bound is exact enough to step by; only within it is the eroded field evaluated. +float terrainDistance(vec3 p) +{ + float m = terrainMask(p.xz); + float hb = m * W_AMP * terrainFbm(p.xz); + float above = p.y - (hb + m * W_ERODE_AMP); + if (above > 0.0) + return above * W_SLOPE_ENV_INV; +#if W_ERODE_ENABLED + hb += m * W_ERODE_AMP * terrainPhacelle(p.xz).cos.w; +#endif + return (p.y - hb) * W_SLOPE_FULL_INV; +} + +vec3 terrainNormal(vec2 xz) +{ + float r = length(xz); + float m = smoothstep(W_PLATEAU_IN, W_PLATEAU_OUT, r); + float s = clamp((r - W_PLATEAU_IN) / (W_PLATEAU_OUT - W_PLATEAU_IN), 0.0, 1.0); + float dm = r > 0.0 ? 6.0 * s * (1.0 - s) / (W_PLATEAU_OUT - W_PLATEAU_IN) : 0.0; + vec2 dir = r > 0.0 ? xz / r : vec2(0.0); + vec4 fg = cultmath_fbm_grad(vec3(xz * W_FREQ, 0.0), W_OCTAVES, W_LACUNARITY, W_GAIN); + float f = fg.w; + vec2 g = m * W_AMP * W_FREQ * fg.xz + dm * W_AMP * f * dir; +#if W_ERODE_ENABLED + CultPhasor ph = terrainPhacelle(xz); + g += m * W_ERODE_AMP * W_ERODE_FREQ * ph.cos.xz + dm * W_ERODE_AMP * ph.cos.w * dir; +#endif + return normalize(vec3(-g.x, 1.0, -g.y)); +} + +// ---- world tree ---------------------------------------------------------------------------- + +float smin(float a, float b, float k) +{ + float h = max(k - abs(a - b), 0.0) / k; + return min(a, b) - h * h * k * 0.25; +} + +// Not an exact SDF: the flare, ellipsoid and displacement are all scaled to stay under 1-Lipschitz. +float treeDistance(vec3 p, out float leafness) +{ + vec3 c = vec3(0.0, 0.5 * (W_CANOPY_Y + W_CANOPY_R.y), 0.0); + float bound = length(p - c) - (W_CANOPY_Y + W_CANOPY_R.y); + leafness = 0.0; + if (bound > 8.0) + return bound; + + float ang = atan(p.z, p.x); + float y01 = clamp(p.y / W_TRUNK_H, 0.0, 1.0); + float radius = mix(W_TRUNK_R0, W_TRUNK_R1, y01) + 6.0 * exp(-p.y * 0.2) * (1.0 + 0.35 * sin(6.0 * ang + p.y * 0.15)); + float trunk = max(length(p.xz) - radius, p.y - W_TRUNK_H * 1.2) * 0.7; + + vec3 q = (p - vec3(0.0, W_CANOPY_Y, 0.0)) / W_CANOPY_R; + float disp = cultmath_snoise(p * 0.05 + vec3(0.0, u_time * 0.015, 0.0)); + float canopy = (length(q) - 1.0) * min(W_CANOPY_R.x, W_CANOPY_R.y) - 3.0 * disp; + canopy *= 0.45; // 1 / (1 + 3 * 0.05 * L) + + float d = smin(trunk, canopy, 6.0); + leafness = clamp(0.5 + (trunk - canopy) * 0.15, 0.0, 1.0); + return d; +} + +// ---- trace --------------------------------------------------------------------------------- + +float sceneDistance(vec3 p, out float leafness, out bool onTree) +{ + float dt = terrainDistance(p); + float dr = treeDistance(p, leafness); + onTree = dr < dt; + return min(dt, dr); +} + +void main() +{ + vec3 ro = u_camPos; + vec3 rd = camRay(v_uv); + + float t = 0.0; + // Nothing is above W_MAX_HEIGHT: skip the empty air above it, or miss outright. + if (ro.y > W_MAX_HEIGHT) + { + if (rd.y >= 0.0) + { + o_color = vec4(0.0); + o_depth = W_FAR; + return; + } + t = (W_MAX_HEIGHT - ro.y) / rd.y; + } + + bool hit = false; + bool onTree = false; + float leafness = 0.0; + int i = 0; + for (; i < u_maxSteps; i++) + { + vec3 p = ro + rd * t; + float d = sceneDistance(p, leafness, onTree); + if (d < 0.0006 * t + 0.01) + { + hit = true; + break; + } + t += d; + if (t > W_FAR || (p.y > W_MAX_HEIGHT && rd.y > 0.0)) + break; + } + // The conservative bound makes grazing rays crawl: a ray that ran out of steps while still + // below the highest terrain is a distant surface under thick fog, so it ends there. + if (!hit && i == u_maxSteps && t < W_FAR) + { + hit = true; + sceneDistance(ro + rd * t, leafness, onTree); + } + + if (!hit) + { + o_color = vec4(0.0); + o_depth = W_FAR; + return; + } + + vec3 p = ro + rd * t; + vec3 n; + vec3 albedo; + if (onTree) + { + float e = 0.35; + float l; + n = normalize(vec3( + treeDistance(p + vec3(e, 0.0, 0.0), l) - treeDistance(p - vec3(e, 0.0, 0.0), l), + treeDistance(p + vec3(0.0, e, 0.0), l) - treeDistance(p - vec3(0.0, e, 0.0), l), + treeDistance(p + vec3(0.0, 0.0, e), l) - treeDistance(p - vec3(0.0, 0.0, e), l))); + float speckle = 0.75 + 0.25 * cultmath_snoise(p * 0.8); + albedo = mix(u_bark, u_leaf * speckle, leafness); + } + else + { + n = terrainNormal(p.xz); + float rockness = smoothstep(0.78, 0.55, n.y); + albedo = mix(u_grass, u_rock, rockness); + } + + float ndl = dot(n, u_sunDir); + float wrap = clamp(ndl * 0.6 + 0.4, 0.0, 1.0); // half-lambert: no shadow ray in the cheap pass + vec3 ambient = mix(u_skyHorizon, u_skyTop, 0.5 + 0.5 * n.y) * 0.45; + vec3 col = albedo * (u_sunColor * wrap * wrap + ambient); + + o_color = vec4(col, 1.0); + o_depth = t; +} diff --git a/src/app/features/scene/world.test.ts b/src/app/features/scene/world.test.ts new file mode 100644 index 00000000..87fa1983 --- /dev/null +++ b/src/app/features/scene/world.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import common from './shaders/common.glsl?raw'; +import terrain from './shaders/terrain.frag.glsl?raw'; +import atmosphere from './shaders/atmosphere.frag.glsl?raw'; +import resolve from './shaders/resolve.frag.glsl?raw'; +import composite from './shaders/composite.frag.glsl?raw'; +import { terrainBounds, WORLD, worldDefines } from './world'; + +describe('world defines', () => { + it('define every W_ name any scene shader uses', () => { + const defined = new Set([...worldDefines().matchAll(/#define (W_\w+)/g)].map((m) => m[1])); + for (const src of [common, terrain, atmosphere, resolve, composite]) { + for (const m of src.matchAll(/\bW_[A-Z0-9_]+\b/g)) expect(defined, m[0]).toContain(m[0]); + } + }); + + it('emit only valid GLSL numbers', () => { + expect(worldDefines()).not.toMatch(/NaN|Infinity|undefined/); + }); +}); + +describe('terrain step bound', () => { + it('is more conservative with erosion than without, and without erosion carries no phacelle term', () => { + const b = terrainBounds(); + expect(b.slopeFull).toBeGreaterThan(b.slopeEnvelope); + const off = terrainBounds({ ...WORLD, phacelleGradBound: 0 }); + expect(off.slopeFull).toBe(off.slopeEnvelope); + }); + + it('puts every surface under the height the tracer skips to', () => { + const maxHeight = Number(/#define W_MAX_HEIGHT (\S+)/.exec(worldDefines())![1]); + expect(maxHeight).toBeGreaterThanOrEqual(terrainBounds().maxHeight); + expect(maxHeight).toBeGreaterThanOrEqual(WORLD.tree.canopyY + WORLD.tree.canopyRadius[1]); + }); + + it('leaves the erosion bound turned on only when a bound exists', () => { + expect(worldDefines()).toContain('#define W_ERODE_ENABLED 1'); + expect(worldDefines({ ...WORLD, phacelleGradBound: 0 })).toContain('#define W_ERODE_ENABLED 0'); + }); +}); diff --git a/src/app/features/scene/world.ts b/src/app/features/scene/world.ts new file mode 100644 index 00000000..82afdf94 --- /dev/null +++ b/src/app/features/scene/world.ts @@ -0,0 +1,114 @@ +// The scene's world constants. One owner: the shader prelude below is generated from this +// object, so a number lives here and nowhere else, and the Lipschitz bounds that make the +// sphere-trace step safe are computed beside the numbers they bound. + +// Lipschitz constant of cultmath_snoise(vec3), CULTMATH_SNOISE_LIPSCHITZ in the vendored +// CultMath.glsl (provenance in CultLib math.Interval.cs). +export const SNOISE_LIPSCHITZ = 7.9640074; + +export const WORLD = { + far: 1400, + // Terrain: height = mask(r) * (amp * fbm(xz * freq) + erodeAmp * phacelle(xz * erodeFreq)). + amp: 70, + freq: 1 / 520, + octaves: 4, + lacunarity: 2.0, + gain: 0.45, + // Flat ground (height 0) out to plateauInner, full terrain from plateauOuter. The login + // camera stays inside plateauInner, so it can never end up inside the terrain. + plateauInner: 135, + plateauOuter: 380, + erodeAmp: 2.5, + erodeFreq: 1 / 38, + // The stripe wave vector and normalization the terrain feeds cultmath_phacelle. + erodeSide: [1.7, 0, 1] as const, + erodeNorm: 0.5, + // Lipschitz bound of the phasor's cos component, NOT a proof: max |grad| over 4,194,304 random + // points of the z = 0 slice with the side and norm above was 5.04 (`drive.cjs phacelle`, + // 2026-10-31), x 1.25. |value| <= 1 holds by construction (the output is a normalized phasor). + // Replace with the bound Libby supplies; until then the march is as safe as that probe. + phacelleGradBound: 6.3, + // World tree at the origin. + tree: { + trunkHeight: 38, + baseRadius: 3.4, + topRadius: 1.9, + canopyY: 52, + canopyRadius: [30, 17, 30] as const, + }, + // Atmosphere. + atmosphereFar: 1400, + cloudBase: 130, + cloudTop: 230, + fogDensity: 0.0028, + fogFalloff: 0.011, +} as const; + +const f = (n: number) => (Number.isInteger(n) ? `${n}.0` : `${n}`); + +/** Sum over octaves of (lacunarity * gain)^i: the slope bound's octave factor. */ +function slopeSum(octaves: number, lacunarity: number, gain: number): number { + let s = 0; + for (let i = 0; i < octaves; i += 1) s += (lacunarity * gain) ** i; + return s; +} + +/** Sum over octaves of gain^i: the fbm value bound. */ +function valueSum(octaves: number, gain: number): number { + let s = 0; + for (let i = 0; i < octaves; i += 1) s += gain ** i; + return s; +} + +export interface TerrainBounds { + /** Lipschitz bound of the envelope surface (no erosion). */ + slopeEnvelope: number; + /** Lipschitz bound of the eroded surface. */ + slopeFull: number; + /** Highest point the terrain can reach. */ + maxHeight: number; +} + +export function terrainBounds(w: typeof WORLD = WORLD): TerrainBounds { + const fbmMax = valueSum(w.octaves, w.gain); + const maskSlope = 1.5 / (w.plateauOuter - w.plateauInner); // smoothstep's max slope + const fbmSlope = w.amp * w.freq * SNOISE_LIPSCHITZ * slopeSum(w.octaves, w.lacunarity, w.gain); + const slopeEnvelope = fbmSlope + (w.amp * fbmMax + w.erodeAmp) * maskSlope; + const slopeFull = slopeEnvelope + w.erodeAmp * w.erodeFreq * w.phacelleGradBound; + return { slopeEnvelope, slopeFull, maxHeight: w.amp * fbmMax + w.erodeAmp }; +} + +/** The #defines every stage sees. */ +export function worldDefines(w: typeof WORLD = WORLD): string { + const b = terrainBounds(w); + const t = w.tree; + const lines: Array<[string, string]> = [ + ['W_FAR', f(w.far)], + ['W_AMP', f(w.amp)], + ['W_FREQ', f(w.freq)], + ['W_OCTAVES', String(w.octaves)], + ['W_LACUNARITY', f(w.lacunarity)], + ['W_GAIN', f(w.gain)], + ['W_PLATEAU_IN', f(w.plateauInner)], + ['W_PLATEAU_OUT', f(w.plateauOuter)], + ['W_ERODE_AMP', f(w.erodeAmp)], + ['W_ERODE_FREQ', f(w.erodeFreq)], + ['W_ERODE_SIDE', `vec3(${w.erodeSide.map(f).join(', ')})`], + ['W_ERODE_NORM', f(w.erodeNorm)], + ['W_ERODE_ENABLED', w.phacelleGradBound > 0 ? '1' : '0'], + ['W_SLOPE_ENV_INV', f(1 / Math.sqrt(1 + b.slopeEnvelope ** 2))], + ['W_SLOPE_FULL_INV', f(1 / Math.sqrt(1 + b.slopeFull ** 2))], + ['W_MAX_HEIGHT', f(Math.max(b.maxHeight, t.canopyY + t.canopyRadius[1] + 6))], + ['W_TRUNK_H', f(t.trunkHeight)], + ['W_TRUNK_R0', f(t.baseRadius)], + ['W_TRUNK_R1', f(t.topRadius)], + ['W_CANOPY_Y', f(t.canopyY)], + ['W_CANOPY_R', `vec3(${t.canopyRadius.map(f).join(', ')})`], + ['W_ATM_FAR', f(w.atmosphereFar)], + ['W_CLOUD_Y0', f(w.cloudBase)], + ['W_CLOUD_Y1', f(w.cloudTop)], + ['W_FOG_DENSITY', f(w.fogDensity)], + ['W_FOG_FALLOFF', f(w.fogFalloff)], + ]; + return lines.map(([k, v]) => `#define ${k} ${v}`).join('\n'); +} From 8f1bb54b4b1d48bb1c0b7a95663b6219aa162c71 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 1 Nov 2026 01:35:35 +0100 Subject: [PATCH 042/206] estate: every client join goes through joinEstateRoomIdOrAlias; /join reports refusals RoomTombstone, RoomIntro and the lobby RoomItem/SpaceItem joined with mx.joinRoom beside the pre-join filing; they now call joinEstateRoomOrThrow, which delegates to the one path. /join hands each refusal to the user instead of discarding it. Tests pin the via pass-through and the 400/429/499 refusal bounds. Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/room-card/RoomCard.tsx | 5 +- src/app/components/room-intro/RoomIntro.tsx | 3 +- .../features/estate/joinEstateRoom.test.ts | 68 ++++++++++++++++++- src/app/features/estate/joinEstateRoom.ts | 43 +++++++++++- src/app/features/lobby/RoomItem.tsx | 12 ++-- src/app/features/lobby/SpaceItem.tsx | 7 +- src/app/features/room/RoomTombstone.tsx | 7 +- src/app/hooks/useCommands.ts | 13 +--- 8 files changed, 124 insertions(+), 34 deletions(-) diff --git a/src/app/components/room-card/RoomCard.tsx b/src/app/components/room-card/RoomCard.tsx index a5e9b3a9..64aaaacb 100644 --- a/src/app/components/room-card/RoomCard.tsx +++ b/src/app/components/room-card/RoomCard.tsx @@ -31,7 +31,7 @@ import { RoomType, StateEvent } from '../../../types/matrix/room'; import { useJoinedRoomId } from '../../hooks/useJoinedRoomId'; import { useElementSizeObserver } from '../../hooks/useElementSizeObserver'; import { getRoomAvatarUrl, getStateEvent } from '../../utils/room'; -import { joinEstateRoomIdOrAlias } from '../../features/estate/joinEstateRoom'; +import { joinEstateRoomOrThrow } from '../../features/estate/joinEstateRoom'; import { useStateEventCallback } from '../../hooks/useStateEventCallback'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; @@ -200,8 +200,7 @@ export const RoomCard = as<'div', RoomCardProps>( const [joinState, join] = useAsyncCallback( useCallback(async () => { - const result = await joinEstateRoomIdOrAlias(mx, roomIdOrAlias, viaServers); - if (result.kind === 'refused') throw new Error(result.message); + await joinEstateRoomOrThrow(mx, roomIdOrAlias, viaServers); }, [mx, roomIdOrAlias, viaServers]) ); const joining = diff --git a/src/app/components/room-intro/RoomIntro.tsx b/src/app/components/room-intro/RoomIntro.tsx index ce550992..2e4e3ebc 100644 --- a/src/app/components/room-intro/RoomIntro.tsx +++ b/src/app/components/room-intro/RoomIntro.tsx @@ -17,6 +17,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; import { InviteUserPrompt } from '../invite-user-prompt'; +import { joinEstateRoomOrThrow } from '../../features/estate/joinEstateRoom'; export type RoomIntroProps = { room: Room; @@ -43,7 +44,7 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) => const prevRoomId = createContent?.predecessor?.room_id; const [prevRoomState, joinPrevRoom] = useAsyncCallback( - useCallback(async (roomId: string) => mx.joinRoom(roomId), [mx]) + useCallback((roomId: string) => joinEstateRoomOrThrow(mx, roomId), [mx]) ); const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); diff --git a/src/app/features/estate/joinEstateRoom.test.ts b/src/app/features/estate/joinEstateRoom.test.ts index 8e009ef0..cc1d232f 100644 --- a/src/app/features/estate/joinEstateRoom.test.ts +++ b/src/app/features/estate/joinEstateRoom.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it, vi } from 'vitest'; -import { activateEstateRoom, awaitMyJoin, joinEstateRoomIdOrAlias } from './joinEstateRoom'; +import { + activateEstateRoom, + awaitMyJoin, + joinEstateRoomIdOrAlias, + joinEstateRoomOrThrow, + runJoinCommand, +} from './joinEstateRoom'; const room = (state: 'joined' | 'invited' | 'joinable') => ({ roomId: '!r:gamecult.org', @@ -95,6 +101,7 @@ const dmClient = (opts: { let direct: Record | undefined; const writes: Record[] = []; const joined: string[] = []; + const joinOpts: unknown[] = []; const mx = { getSafeUserId: () => '@me:test', getRoom: (roomId: string) => ({ @@ -116,7 +123,8 @@ const dmClient = (opts: { writes.push(structuredClone(content)); direct = structuredClone(content); }, - joinRoom: async (roomId: string) => { + joinRoom: async (roomId: string, opts?: unknown) => { + joinOpts.push(opts); // the join lands only after m.direct has been written: record what it held joined.push(JSON.stringify(direct ?? {})); if (opts.refuse) @@ -130,7 +138,7 @@ const dmClient = (opts: { return { roomId }; }, }; - return { mx: mx as never, writes, joined, current: () => direct }; + return { mx: mx as never, writes, joined, joinOpts, current: () => direct }; }; const tap = (id: string) => ({ roomId: id, state: 'invited' as const, via: ['test'] }); @@ -169,6 +177,22 @@ describe('activateEstateRoom when the join gets no answer', () => { }); }); +describe('activateEstateRoom refusal bounds', () => { + it.each([400, 429, 499])('unfiles the room on a %i that carries an errcode', async (status) => { + const c = dmClient({ status, errcode: 'M_LIMIT_EXCEEDED', invites: { '!dm1:test': '@friend:test' } }); + const result = await activateEstateRoom(c.mx, tap('!dm1:test')); + expect(result.kind).toBe('refused'); + expect((result as { message: string }).message).not.toMatch(/tap again to retry/); + expect(c.current()).toEqual({}); + }); + + it.each([399, 500])('keeps the room listed on a %i that carries an errcode', async (status) => { + const c = dmClient({ status, errcode: 'M_UNKNOWN', invites: { '!dm1:test': '@friend:test' } }); + await activateEstateRoom(c.mx, tap('!dm1:test')); + expect(c.current()).toEqual({ '@friend:test': ['!dm1:test'] }); + }); +}); + describe('activateEstateRoom when a gateway answers instead of the server', () => { it.each([502, 503, 504, 404])('keeps the room listed on a %i with no errcode', async (status) => { const c = dmClient({ status, invites: { '!dm1:test': '@friend:test' } }); @@ -197,6 +221,12 @@ describe('joinEstateRoomIdOrAlias, the room card and /join', () => { expect(c.joined).toEqual([JSON.stringify({ '@friend:test': ['!dm1:test'] })]); }); + it('hands the via servers to the join', async () => { + const c = dmClient({ invites: { '!dm1:test': '@friend:test' } }); + await joinEstateRoomIdOrAlias(c.mx, '!dm1:test', ['other.org']); + expect(c.joinOpts).toEqual([{ viaServers: ['other.org'] }]); + }); + it('joins an alias or an unheld room without touching m.direct', async () => { const { mx, calls } = recordingClient(); await joinEstateRoomIdOrAlias(mx, '#room:gamecult.org'); @@ -204,6 +234,38 @@ describe('joinEstateRoomIdOrAlias, the room card and /join', () => { }); }); +describe('joinEstateRoomOrThrow, the lobby, tombstone and predecessor buttons', () => { + it('files a direct invite and answers the joined room id', async () => { + const c = dmClient({ invites: { '!dm1:test': '@friend:test' } }); + await expect(joinEstateRoomOrThrow(c.mx, '!dm1:test', ['test'])).resolves.toBe('!dm1:test'); + expect(c.joined).toEqual([JSON.stringify({ '@friend:test': ['!dm1:test'] })]); + }); + + it('throws the refusal and unfiles the room', async () => { + const c = dmClient({ refuse: true, invites: { '!dm1:test': '@friend:test' } }); + await expect(joinEstateRoomOrThrow(c.mx, '!dm1:test')).rejects.toThrow('M_FORBIDDEN'); + expect(c.current()).toEqual({}); + }); +}); + +describe('runJoinCommand, /join', () => { + it('reports each refusal and joins the others', async () => { + const c = dmClient({ refuse: true, invites: { '!dm1:test': '@friend:test' } }); + const reported: string[] = []; + await runJoinCommand(c.mx, '!dm1:test notanaddress', (m) => reported.push(m)); + expect(reported).toEqual(['M_FORBIDDEN']); + expect(c.joined).toHaveLength(1); + }); + + it('reports nothing when the join is accepted', async () => { + const c = dmClient({ invites: { '!dm1:test': '@friend:test' } }); + const reported: string[] = []; + await runJoinCommand(c.mx, '!dm1:test', (m) => reported.push(m)); + expect(reported).toEqual([]); + expect(c.joined).toHaveLength(1); + }); +}); + describe('awaitMyJoin', () => { /** A store that gains the room when the test says so, emitting like the SDK. */ const store = () => { diff --git a/src/app/features/estate/joinEstateRoom.ts b/src/app/features/estate/joinEstateRoom.ts index d69df82c..4e1629d0 100644 --- a/src/app/features/estate/joinEstateRoom.ts +++ b/src/app/features/estate/joinEstateRoom.ts @@ -1,5 +1,11 @@ import { EstateRoom } from './buildEstate'; -import { addRoomIdToMDirect, removeRoomIdFromMDirect } from '../../utils/matrix'; +import { + addRoomIdToMDirect, + isRoomAlias, + isRoomId, + removeRoomIdFromMDirect, +} from '../../utils/matrix'; +import { splitWithSpace } from '../../utils/common'; import { isDirectInvite } from '../../utils/room'; export type EstateActivation = @@ -25,8 +31,9 @@ type JoinClient = Parameters[0]; * join that fails any other way (lost response, dropped connection, a gateway's * 5xx or a status with no errcode) may still have been accepted upstream, so the * entry stays and the tap is retryable. Nothing needs to recognise the DM after it - * arrives. Every join of a room the client holds an invite for goes through - * here: the inbox Accept, the room card, /join. One owner for joining. + * arrives. Every client join goes through here: the estate tap, the inbox Accept, + * the room card, /join, the lobby and space Join buttons, the tombstone and + * predecessor links. One owner for joining. */ export const activateEstateRoom = async ( mx: JoinClient, @@ -79,6 +86,36 @@ export const joinEstateRoomIdOrAlias = ( via, }); +/** + * joinEstateRoomIdOrAlias for a caller that reports failure by throwing (a + * useAsyncCallback button): the joined room id, or an Error carrying the refusal. + */ +export const joinEstateRoomOrThrow = async ( + mx: JoinClient, + roomIdOrAlias: string, + via?: string[] +): Promise => { + const result = await joinEstateRoomIdOrAlias(mx, roomIdOrAlias, via); + if (result.kind === 'refused') throw new Error(result.message); + return result.roomId; +}; + +/** + * The /join command: every room id or alias in the payload is joined, and each + * refusal is handed to report, never dropped. + */ +export const runJoinCommand = async ( + mx: JoinClient, + payload: string, + report: (message: string) => void +): Promise => { + const addresses = splitWithSpace(payload).filter((a) => isRoomId(a) || isRoomAlias(a)); + const results = await Promise.all(addresses.map((a) => joinEstateRoomIdOrAlias(mx, a))); + results.forEach((r) => { + if (r.kind === 'refused') report(r.message); + }); +}; + type MembershipSource = { getRoom(roomId: string): { getMyMembership(): string } | null; on(event: 'Room.myMembership', handler: (room: { roomId: string }, m: string) => void): unknown; diff --git a/src/app/features/lobby/RoomItem.tsx b/src/app/features/lobby/RoomItem.tsx index 7de59acd..132aaf8c 100644 --- a/src/app/features/lobby/RoomItem.tsx +++ b/src/app/features/lobby/RoomItem.tsx @@ -19,7 +19,7 @@ import { toRem, } from 'folds'; import FocusTrap from 'focus-trap-react'; -import { JoinRule, MatrixError, Room } from 'matrix-js-sdk'; +import { JoinRule, Room } from 'matrix-js-sdk'; import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces'; import { RoomAvatar, RoomIcon } from '../../components/room-avatar'; import { SequenceCard } from '../../components/sequence-card'; @@ -38,6 +38,7 @@ import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room'; import { ItemDraggableTarget, useDraggableItem } from './DnD'; import { mxcUrlToHttp } from '../../utils/matrix'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { joinEstateRoomOrThrow } from '../estate/joinEstateRoom'; type RoomJoinButtonProps = { roomId: string; @@ -46,8 +47,8 @@ type RoomJoinButtonProps = { function RoomJoinButton({ roomId, via }: RoomJoinButtonProps) { const mx = useMatrixClient(); - const [joinState, join] = useAsyncCallback( - useCallback(() => mx.joinRoom(roomId, { viaServers: via }), [mx, roomId, via]) + const [joinState, join] = useAsyncCallback( + useCallback(() => joinEstateRoomOrThrow(mx, roomId, via), [mx, roomId, via]) ); const canJoin = joinState.status === AsyncStatus.Idle || joinState.status === AsyncStatus.Error; @@ -60,9 +61,8 @@ function RoomJoinButton({ roomId, via }: RoomJoinButtonProps) { - {joinState.error.data?.error || joinState.error.message} + {joinState.error.message} - {joinState.error.name} } @@ -75,7 +75,7 @@ function RoomJoinButton({ roomId, via }: RoomJoinButtonProps) { size="400" filled tabIndex={0} - aria-label={joinState.error.data?.error || joinState.error.message} + aria-label={joinState.error.message} /> )} diff --git a/src/app/features/lobby/SpaceItem.tsx b/src/app/features/lobby/SpaceItem.tsx index ed3eaff9..e1ffbfe5 100644 --- a/src/app/features/lobby/SpaceItem.tsx +++ b/src/app/features/lobby/SpaceItem.tsx @@ -18,7 +18,7 @@ import { } from 'folds'; import FocusTrap from 'focus-trap-react'; import classNames from 'classnames'; -import { MatrixError, Room } from 'matrix-js-sdk'; +import { Room } from 'matrix-js-sdk'; import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces'; import { HierarchyItem } from '../../hooks/useSpaceHierarchy'; import { useMatrixClient } from '../../hooks/useMatrixClient'; @@ -38,6 +38,7 @@ import { useOpenCreateSpaceModal } from '../../state/hooks/createSpaceModal'; import { AddExistingModal } from '../add-existing'; import { CreateRoomType } from '../../components/create-room/types'; import { BetaNoticeBadge } from '../../components/BetaNoticeBadge'; +import { joinEstateRoomOrThrow } from '../estate/joinEstateRoom'; function SpaceProfileLoading() { return ( @@ -112,8 +113,8 @@ function UnjoinedSpaceProfile({ }: UnjoinedSpaceProfileProps) { const mx = useMatrixClient(); - const [joinState, join] = useAsyncCallback( - useCallback(() => mx.joinRoom(roomId, { viaServers: via }), [mx, roomId, via]) + const [joinState, join] = useAsyncCallback( + useCallback(() => joinEstateRoomOrThrow(mx, roomId, via), [mx, roomId, via]) ); const canJoin = joinState.status === AsyncStatus.Idle || joinState.status === AsyncStatus.Error; diff --git a/src/app/features/room/RoomTombstone.tsx b/src/app/features/room/RoomTombstone.tsx index 24f0b80e..3556e6fc 100644 --- a/src/app/features/room/RoomTombstone.tsx +++ b/src/app/features/room/RoomTombstone.tsx @@ -8,6 +8,7 @@ import { Membership } from '../../../types/matrix/room'; import { RoomInputPlaceholder } from './RoomInputPlaceholder'; import { useRoomNavigate } from '../../hooks/useRoomNavigate'; import { getViaServers } from '../../plugins/via-servers'; +import { joinEstateRoomOrThrow } from '../estate/joinEstateRoom'; type RoomTombstoneProps = { roomId: string; body?: string; replacementRoomId: string }; export function RoomTombstone({ roomId, body, replacementRoomId }: RoomTombstoneProps) { @@ -18,16 +19,14 @@ export function RoomTombstone({ roomId, body, replacementRoomId }: RoomTombstone useCallback(() => { const currentRoom = mx.getRoom(roomId); const via = currentRoom ? getViaServers(currentRoom) : []; - return mx.joinRoom(replacementRoomId, { - viaServers: via, - }); + return joinEstateRoomOrThrow(mx, replacementRoomId, via); }, [mx, roomId, replacementRoomId]) ); const replacementRoom = mx.getRoom(replacementRoomId); const handleOpen = () => { if (replacementRoom) navigateRoom(replacementRoom.roomId); - if (joinState.status === AsyncStatus.Success) navigateRoom(joinState.data.roomId); + if (joinState.status === AsyncStatus.Success) navigateRoom(joinState.data); }; return ( diff --git a/src/app/hooks/useCommands.ts b/src/app/hooks/useCommands.ts index 15a21d1e..11f379dc 100644 --- a/src/app/hooks/useCommands.ts +++ b/src/app/hooks/useCommands.ts @@ -15,7 +15,6 @@ import { addRoomIdToMDirect, getDMRoomFor, guessDmRoomUserId, - isRoomAlias, isRoomId, isServerName, isUserId, @@ -26,7 +25,7 @@ import { useRoomNavigate } from './useRoomNavigate'; import { Membership, StateEvent } from '../../types/matrix/room'; import { getStateEvent } from '../utils/room'; import { splitWithSpace } from '../utils/common'; -import { joinEstateRoomIdOrAlias } from '../features/estate/joinEstateRoom'; +import { runJoinCommand } from '../features/estate/joinEstateRoom'; import { createRoomEncryptionState } from '../components/create-room'; export const SHRUG = '¯\\_(ツ)_/¯'; @@ -228,15 +227,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { [Command.Join]: { name: Command.Join, description: 'Join room with address. Example: /join address1 address2', - exe: async (payload) => { - const rawIds = splitWithSpace(payload); - const roomIdOrAliases = rawIds.filter( - (idOrAlias) => isRoomId(idOrAlias) || isRoomAlias(idOrAlias) - ); - roomIdOrAliases.forEach(async (idOrAlias) => { - await joinEstateRoomIdOrAlias(mx, idOrAlias); - }); - }, + exe: (payload) => runJoinCommand(mx, payload, (message) => window.alert(message)), }, [Command.Leave]: { name: Command.Leave, From 6950da865e12e7f3703cf7915ef3f2e153184f83 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 1 Nov 2026 01:37:35 +0100 Subject: [PATCH 043/206] estate harness: every drive asserts the outcome it shows and run.sh fails when one does not Co-Authored-By: Claude Sonnet 5.5 --- harness/estate/check.cjs | 18 +++++++++++++++++ harness/estate/drive-failures.cjs | 33 +++++++++++++++++++++---------- harness/estate/drive-routes.cjs | 20 ++++++++++++++----- harness/estate/drive.cjs | 14 ++++++++++--- harness/estate/run.sh | 9 +++++---- 5 files changed, 72 insertions(+), 22 deletions(-) create mode 100644 harness/estate/check.cjs diff --git a/harness/estate/check.cjs b/harness/estate/check.cjs new file mode 100644 index 00000000..172fa301 --- /dev/null +++ b/harness/estate/check.cjs @@ -0,0 +1,18 @@ +// Assertions for the estate drives: every drive states the outcome it exists to show and exits +// non-zero when the page or the fake homeserver's log says otherwise. +const fs = require('fs'); +const fails = []; +exports.expect = (label, ok, detail = '') => { + console.log(`[ASSERT] ${ok ? 'PASS' : 'FAIL'} ${label}${ok ? '' : ` :: ${detail}`}`); + if (!ok) fails.push(label); +}; +/** Exit code for the drive: 4 when any assertion failed. */ +exports.done = () => (fails.length ? 4 : 0); +/** The fake homeserver's request log (HSLOG is shared with the server process). */ +exports.hsLog = () => fs.readFileSync(process.env.HSLOG, 'utf8').split('\n'); +/** Bodies of the account-data PUTs for m.direct, in order. */ +exports.mDirectPuts = () => + exports.hsLog().filter((l) => / PUT .*m\.direct BODY /.test(l)).map((l) => l.split(' BODY ')[1]); +/** Join requests the server saw for a room id. */ +exports.joinsOf = (roomId) => exports.hsLog().filter((l) => new RegExp(` JOIN ${roomId}$`).test(l)).length; +exports.isDm = (tier) => /^direct messages$/i.test(tier); diff --git a/harness/estate/drive-failures.cjs b/harness/estate/drive-failures.cjs index 9c81424a..1172c853 100644 --- a/harness/estate/drive-failures.cjs +++ b/harness/estate/drive-failures.cjs @@ -1,6 +1,7 @@ // Failure-mode scenarios for the estate join paths (estate tap and inbox Accept), run by run.sh // against fakehs.cjs with the env flag each mode needs. const { chromium } = require('playwright'); +const { expect, done, isDm, mDirectPuts, joinsOf } = require('./check.cjs'); const MODE = process.env.MODE; const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`; const log = (...a) => console.log(`[${MODE}]`, ...a); @@ -21,7 +22,8 @@ setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 240000); const reveal = async (name) => { const loc = pg.locator(`[aria-label="Join ${name}"]`); if (await loc.count()) return loc; for (let r = 0; r < 2; r++) for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); if (await loc.count()) return loc; } } return loc; }; const tierOf = (name) => pg.evaluate((n) => { const s = document.body.innerText.split('\n').map((x) => x.trim()); const i = s.indexOf(n); if (i < 0) return 'absent'; for (let j = i; j >= 0; j--) if (/^(elsewhere|direct messages)$/i.test(s[j])) return s[j]; return 'none-above'; }, name); const tap = async (n) => { const l = await reveal(n); log('JOINCTRL', n, await l.count()); await l.first().click(); log('CLICK', n, t()); }; - const tiers = async (tag) => { await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2500); for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } log('TIER', tag, '| Friend DM', await tierOf('Friend DM'), '| Slow DM', await tierOf('Slow DM')); }; + const tiers = async (tag) => { await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2500); for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } const r = { friend: await tierOf('Friend DM'), slow: await tierOf('Slow DM') }; log('TIER', tag, '| Friend DM', r.friend, '| Slow DM', r.slow); return r; }; + const neverUnfiled = () => mDirectPuts().every((b) => b.includes('!dm1:test')); const acceptInInbox = async (name) => { // reach the inbox through the navigator's own Inbox tab await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2000); @@ -38,23 +40,34 @@ setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 240000); }; if (MODE === 'pjfail') { await tap('Friend DM'); await pg.waitForTimeout(3000); - const bb = await body(); const k = bb.search(/Internal server error|Could not join|Joined\./); log('AFTER_FAIL', k >= 0 ? bb.slice(Math.max(0, k - 40), k + 80) : 'no message'); - await tap('Friend DM'); await pg.waitForTimeout(6000); await tiers('after retry'); + const bb = await body(); const k = bb.search(/Internal server error/); log('AFTER_FAIL', k >= 0 ? bb.slice(Math.max(0, k - 40), k + 80) : 'no message'); + expect('a failed m.direct write shows the refusal', k >= 0, bb.slice(0, 200)); + expect('no join is sent while the filing is failing', joinsOf('!dm1:test') === 0, String(joinsOf('!dm1:test'))); + await tap('Friend DM'); await pg.waitForTimeout(6000); const r = await tiers('after retry'); + expect('the retry joins once', joinsOf('!dm1:test') === 1, String(joinsOf('!dm1:test'))); + expect('Friend DM is filed under Direct Messages after the retry', isDm(r.friend), r.friend); } else if (MODE === 'jdrop') { await tap('Friend DM'); await pg.waitForTimeout(4000); - const bb = await body(); const k = bb.search(/fetch|Failed|network|Could not join|Joined\./i); log('AFTER_DROP', k >= 0 ? bb.slice(Math.max(0, k - 60), k + 80) : 'no message'); - await pg.waitForTimeout(6000); await tiers('after drop'); await open(); await tiers('after reload'); + const bb = await body(); const k = bb.search(/tap again to retry/i); log('AFTER_DROP', k >= 0 ? bb.slice(Math.max(0, k - 60), k + 80) : 'no message'); + expect('a dropped join shows the retryable message', k >= 0, bb.slice(0, 200)); + await pg.waitForTimeout(6000); const r1 = await tiers('after drop'); await open(); const r2 = await tiers('after reload'); + expect('the entry is kept after the drop', isDm(r1.friend) && neverUnfiled(), r1.friend); + expect('Friend DM is under Direct Messages after the reload', isDm(r2.friend), r2.friend); } else if (MODE === 'admin') { - await tap('Friend DM'); await pg.waitForTimeout(6000); await tiers('admin estate'); + await tap('Friend DM'); await pg.waitForTimeout(6000); const r = await tiers('admin estate'); + expect('an admin-invited DM is filed under Direct Messages', isDm(r.friend), r.friend); } else if (MODE === 'inboxadmin') { - await acceptInInbox('Friend DM'); await pg.waitForTimeout(6000); await tiers('admin inbox'); + await acceptInInbox('Friend DM'); await pg.waitForTimeout(6000); const r = await tiers('admin inbox'); + expect('an admin-invited DM accepted in the inbox is filed under Direct Messages', isDm(r.friend), r.friend); } else if (MODE === 'inboxreload') { await acceptInInbox('Slow DM'); await pg.waitForTimeout(2000); log('RELOAD', t()); await open('#/inbox/invites/'); - await pg.waitForTimeout(30000); await tiers('inbox reload'); + await pg.waitForTimeout(30000); const r = await tiers('inbox reload'); + expect('a DM accepted in the inbox is filed under Direct Messages after a reload', isDm(r.slow), r.slow); } else if (MODE === 'pjreload') { await tap('Slow DM'); await pg.waitForTimeout(2000); log('RELOAD', t()); await open(); await pg.waitForTimeout(8000); await tiers('pjreload'); - const l = pg.locator('[aria-label="Join Slow DM"]'); log('SLOW_DM_STILL_JOINABLE', await l.count()); + const l = pg.locator('[aria-label="Join Slow DM"]'); const n = await l.count(); log('SLOW_DM_STILL_JOINABLE', n); + expect('a join cut off by a reload stays joinable', n === 1, String(n)); } - await quit(0); + await quit(done()); })().catch((e) => { log('ERROR', String(e.stack || e).slice(0, 300)); quit(1); }); diff --git a/harness/estate/drive-routes.cjs b/harness/estate/drive-routes.cjs index 05e89df1..5def6635 100644 --- a/harness/estate/drive-routes.cjs +++ b/harness/estate/drive-routes.cjs @@ -1,4 +1,5 @@ const { chromium } = require('playwright'); +const { expect, done, isDm, mDirectPuts, joinsOf, hsLog } = require('./check.cjs'); const MODE = process.env.MODE; const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`; const log = (...a) => console.log(`[${MODE}]`, ...a); const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']; @@ -13,21 +14,30 @@ setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 120000); await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.reload(); await pg.waitForTimeout(9000); const body = async () => (await pg.evaluate(() => document.body.innerText)).replace(/\s+/g, ' '); const tierOf = (n) => pg.evaluate((n) => { const s = document.body.innerText.split('\n').map((x) => x.trim()); const i = s.indexOf(n); if (i < 0) return 'absent'; for (let j = i; j >= 0; j--) if (/^(elsewhere|direct messages)$/i.test(s[j])) return s[j]; return 'none-above'; }, n); - const tiers = async (tag) => { await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2500); for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } log('TIER', tag, '| Friend DM', await tierOf('Friend DM')); }; + const tiers = async (tag) => { await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2500); for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } const f = await tierOf('Friend DM'); log('TIER', tag, '| Friend DM', f); return f; }; + let joined = false; if (MODE === 'deeplink') { for (const route of ['direct', 'home']) { await pg.goto(`http://127.0.0.1:8080/#/${route}/${encodeURIComponent('!dm1:test')}/`); await pg.waitForTimeout(4000); const bb = await body(); log('ROUTE', route, JSON.stringify(bb.slice(0, 300))); const j = pg.getByRole('button', { name: /^Join$/ }); log('JOIN_BUTTONS', route, await j.count()); - if (await j.count()) { await j.first().click(); log('CLICK_JOIN', route, t()); await pg.waitForTimeout(6000); break; } + if (await j.count()) { joined = true; await j.first().click(); log('CLICK_JOIN', route, t()); await pg.waitForTimeout(6000); break; } } - await tiers('after deeplink join'); + const f = await tiers('after deeplink join'); + const stamps = (re) => hsLog().filter((l) => re.test(l)).map((l) => Number(l.split(' ')[0])); + const put = stamps(/ PUT .*m\.direct BODY .*!dm1:test/)[0], join = stamps(/ JOIN !dm1:test$/)[0]; + expect('the deep link offers one Join button', joined, 'no Join button'); + expect('the DM is filed before the join is sent', put !== undefined && join !== undefined && put <= join, `put ${put} join ${join}`); + expect('the DM is filed under Direct Messages', isDm(f), f); } else if (MODE === 'gw504') { const HL = HEADERS; let l = pg.locator('[aria-label="Join Friend DM"]'); for (let r = 0; r < 2 && !(await l.count()); r++) for (const h of HL) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } log('JOINCTRL', await l.count()); await l.first().click(); log('CLICK', t()); await pg.waitForTimeout(4000); const bb = await body(); const k = bb.search(/504|Gateway|connection dropped|Could not join/i); log('AFTER_504', k >= 0 ? bb.slice(Math.max(0, k - 60), k + 120) : 'no message'); - await pg.waitForTimeout(4000); await tiers('after 504 (join delivered by sync)'); + expect('a gateway timeout shows the retryable message', /tap again to retry/i.test(bb), bb.slice(0, 200)); + await pg.waitForTimeout(4000); const f = await tiers('after 504 (join delivered by sync)'); + expect('the DM stays filed after a gateway timeout', mDirectPuts().every((b) => b.includes('!dm1:test')) && mDirectPuts().length > 0, JSON.stringify(mDirectPuts())); + expect('the DM is filed under Direct Messages once sync delivers it', isDm(f), f); } - await quit(0); + await quit(done()); })().catch((e) => { log('ERROR', String(e.stack || e).slice(0, 300)); quit(1); }); diff --git a/harness/estate/drive.cjs b/harness/estate/drive.cjs index ed9950ce..cf794323 100644 --- a/harness/estate/drive.cjs +++ b/harness/estate/drive.cjs @@ -1,8 +1,10 @@ const { chromium } = require('playwright'); +const { expect, done, isDm } = require('./check.cjs'); const MODE = process.env.MODE; // twodm | reload const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`; const log = (...a) => console.log(`[${MODE}]`, ...a); const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']; +let tierBefore; (async () => { const b = await chromium.launch(); const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, deviceScaleFactor: 1 }); @@ -20,7 +22,7 @@ const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'El await pg.waitForTimeout(45000); } else if (MODE === 'convert') { await tap('Friend DM'); await pg.waitForTimeout(6000); - log('TIER before', await tierOf('Friend DM')); + tierBefore = await tierOf('Friend DM'); log('TIER before', tierBefore); await pg.goto('http://127.0.0.1:8080/#/direct/!dm1:test/'); await pg.waitForTimeout(4000); const ed = pg.locator('[contenteditable="true"]'); log('EDITORS', await ed.count(), pg.url()); await ed.first().click(); await pg.keyboard.type('/converttoroom', { delay: 30 }); await pg.waitForTimeout(500); @@ -36,6 +38,12 @@ const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'El await pg.waitForTimeout(30000); } for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } - log('TIER Slow DM', await tierOf('Slow DM'), '| Late DM', await tierOf('Late DM'), '| Friend DM', await tierOf('Friend DM')); - await b.close(); + const slow = await tierOf('Slow DM'), late = await tierOf('Late DM'), friend = await tierOf('Friend DM'); + log('TIER Slow DM', slow, '| Late DM', late, '| Friend DM', friend); + if (MODE === 'twodm') { expect('Slow DM is filed under Direct Messages', isDm(slow), slow); expect('Late DM is filed under Direct Messages', isDm(late), late); } + else if (MODE === 'convert') { + expect('Friend DM was filed under Direct Messages before the conversion', isDm(tierBefore), tierBefore); + expect('the converted room left Direct Messages and is still listed', !isDm(friend) && friend !== 'absent', friend); + } else expect('Slow DM is filed under Direct Messages after the reload', isDm(slow), slow); + await b.close(); process.exitCode = done(); })().catch((e) => { log('ERROR', String(e.stack || e)); process.exitCode = 1; }); diff --git a/harness/estate/run.sh b/harness/estate/run.sh index 379d28ca..65242ab0 100644 --- a/harness/estate/run.sh +++ b/harness/estate/run.sh @@ -8,19 +8,19 @@ npm run build > /tmp/build.log 2>&1 || { tail -30 /tmp/build.log; exit 1; } echo BUILD_OK $(git rev-parse HEAD) printf '{"defaultHomeserver":0,"homeserverList":["127.0.0.1:8080"],"allowCustomHomeservers":true,"featuredCommunities":{"spaces":["#gamecult:test"],"rooms":[],"servers":["test"]},"hashRouter":{"enabled":true,"basename":"/"}}' > dist/config.json mkdir -p /tmp/pw && cd /tmp/pw && npm init -y >/dev/null && npm i --no-audit --no-fund playwright@1.48.2 >/tmp/pw.log 2>&1 -cp /src/harness/estate/drive.cjs /src/harness/estate/drive-failures.cjs /src/harness/estate/drive-routes.cjs /tmp/pw/ +cp /src/harness/estate/check.cjs /src/harness/estate/drive.cjs /src/harness/estate/drive-failures.cjs /src/harness/estate/drive-routes.cjs /tmp/pw/ set +e for m in twodm reload convert; do HSLOG=/tmp/hslog-$m.txt NOROOT=0 node /src/harness/estate/fakehs.cjs /src/dist > /tmp/hs-$m.out 2>&1 & HS=$!; sleep 2 - MODE=$m node /tmp/pw/drive.cjs; echo "DRIVE $m RC=$?" + HSLOG=/tmp/hslog-$m.txt MODE=$m node /tmp/pw/drive.cjs; rc=$?; echo "DRIVE $m RC=$rc"; [ $rc -eq 0 ] || FAILED="$FAILED $m" kill $HS; wait $HS 2>/dev/null; sleep 1 echo "=== hs $m"; grep -E "MDIRECT_PUT|DELIVER|STATE_" /tmp/hs-$m.out; grep -E " JOIN " /tmp/hslog-$m.txt done failure() { m=$1; shift env "$@" HSLOG=/tmp/hslog-$m.txt NOROOT=0 node /src/harness/estate/fakehs.cjs /src/dist > /tmp/hs-$m.out 2>&1 & HS=$!; sleep 2 - MODE=$m node /tmp/pw/${DRIVER:-drive-failures}.cjs; echo "DRIVE $m RC=$?" + HSLOG=/tmp/hslog-$m.txt MODE=$m node /tmp/pw/${DRIVER:-drive-failures}.cjs; rc=$?; echo "DRIVE $m RC=$rc"; [ $rc -eq 0 ] || FAILED="$FAILED $m" kill $HS; wait $HS 2>/dev/null; sleep 1 echo "=== hs $m"; grep -E "MDIRECT|DELIVER|JOIN_|STATE_" /tmp/hs-$m.out; grep -E " JOIN " /tmp/hslog-$m.txt; } failure pjfail FAIL_MDIRECT=1 @@ -31,4 +31,5 @@ failure inboxadmin INVITER_ADMIN=1 failure inboxreload JOIN_DELAY=1 DRIVER=drive-routes failure deeplink DRIVER=drive-routes failure gw504 JOIN_504=1 -echo DONE +echo "FAILED_DRIVES:${FAILED:- none}" +[ -z "$FAILED" ] From 9b66b2b25075a469fe36239a81c560b89d468505 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 1 Nov 2026 01:52:45 +0100 Subject: [PATCH 044/206] estate harness: read a row's tier from a fresh visit without toggling tiers closed; catch the retryable message before sync replaces the row Co-Authored-By: Claude Sonnet 5.5 --- harness/estate/check.cjs | 28 +++++++++++++++++++ harness/estate/drive-failures.cjs | 13 ++++----- harness/estate/drive-routes.cjs | 11 ++++---- harness/estate/drive.cjs | 5 ++-- .../features/estate/joinEstateRoom.test.ts | 10 +++++-- 5 files changed, 48 insertions(+), 19 deletions(-) diff --git a/harness/estate/check.cjs b/harness/estate/check.cjs index 172fa301..656d532e 100644 --- a/harness/estate/check.cjs +++ b/harness/estate/check.cjs @@ -15,4 +15,32 @@ exports.mDirectPuts = () => exports.hsLog().filter((l) => / PUT .*m\.direct BODY /.test(l)).map((l) => l.split(' BODY ')[1]); /** Join requests the server saw for a room id. */ exports.joinsOf = (roomId) => exports.hsLog().filter((l) => new RegExp(` JOIN ${roomId}$`).test(l)).length; +const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']; +/** The tier header above a row, 'absent' when the page does not show the row, 'none-above' when no tier header precedes it. */ +const tierOf = (pg, name) => + pg.evaluate((n) => { + const s = document.body.innerText.split('\n').map((x) => x.trim()); + const i = s.indexOf(n); + if (i < 0) return 'absent'; + for (let j = i; j >= 0; j--) if (/^(elsewhere|direct messages)$/i.test(s[j])) return s[j]; + return 'none-above'; + }, name); +/** + * The tier a room is listed under, read from a fresh visit to the room list: a collapsed tier is + * opened only if the row is not already showing, so reading never closes what is open. + */ +exports.revealedTier = async (pg, name) => { + await pg.goto('http://127.0.0.1:8080/#/home/'); + await pg.waitForTimeout(2500); + let t = await tierOf(pg, name); + for (const h of HEADERS) { + if (t !== 'absent') break; + const hl = pg.getByText(h, { exact: true }); + if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); t = await tierOf(pg, name); } + } + return t; +}; +/** True when the page text matches re within ms of now (a message can be replaced by a row change). */ +exports.sawText = (pg, re, ms) => + pg.waitForFunction((src) => new RegExp(src, 'i').test(document.body.innerText), re.source, { timeout: ms, polling: 50 }).then(() => true, () => false); exports.isDm = (tier) => /^direct messages$/i.test(tier); diff --git a/harness/estate/drive-failures.cjs b/harness/estate/drive-failures.cjs index 1172c853..d53d88a4 100644 --- a/harness/estate/drive-failures.cjs +++ b/harness/estate/drive-failures.cjs @@ -1,7 +1,7 @@ // Failure-mode scenarios for the estate join paths (estate tap and inbox Accept), run by run.sh // against fakehs.cjs with the env flag each mode needs. const { chromium } = require('playwright'); -const { expect, done, isDm, mDirectPuts, joinsOf } = require('./check.cjs'); +const { expect, done, isDm, mDirectPuts, joinsOf, revealedTier, sawText } = require('./check.cjs'); const MODE = process.env.MODE; const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`; const log = (...a) => console.log(`[${MODE}]`, ...a); @@ -20,9 +20,9 @@ setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 240000); await open(); const body = async () => (await pg.evaluate(() => document.body.innerText)).replace(/\s+/g, ' '); const reveal = async (name) => { const loc = pg.locator(`[aria-label="Join ${name}"]`); if (await loc.count()) return loc; for (let r = 0; r < 2; r++) for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); if (await loc.count()) return loc; } } return loc; }; - const tierOf = (name) => pg.evaluate((n) => { const s = document.body.innerText.split('\n').map((x) => x.trim()); const i = s.indexOf(n); if (i < 0) return 'absent'; for (let j = i; j >= 0; j--) if (/^(elsewhere|direct messages)$/i.test(s[j])) return s[j]; return 'none-above'; }, name); + const tierOf = (name) => revealedTier(pg, name); const tap = async (n) => { const l = await reveal(n); log('JOINCTRL', n, await l.count()); await l.first().click(); log('CLICK', n, t()); }; - const tiers = async (tag) => { await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2500); for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } const r = { friend: await tierOf('Friend DM'), slow: await tierOf('Slow DM') }; log('TIER', tag, '| Friend DM', r.friend, '| Slow DM', r.slow); return r; }; + const tiers = async (tag) => { const r = { friend: await tierOf('Friend DM'), slow: await tierOf('Slow DM') }; log('TIER', tag, '| Friend DM', r.friend, '| Slow DM', r.slow); return r; }; const neverUnfiled = () => mDirectPuts().every((b) => b.includes('!dm1:test')); const acceptInInbox = async (name) => { // reach the inbox through the navigator's own Inbox tab @@ -47,9 +47,8 @@ setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 240000); expect('the retry joins once', joinsOf('!dm1:test') === 1, String(joinsOf('!dm1:test'))); expect('Friend DM is filed under Direct Messages after the retry', isDm(r.friend), r.friend); } else if (MODE === 'jdrop') { - await tap('Friend DM'); await pg.waitForTimeout(4000); - const bb = await body(); const k = bb.search(/tap again to retry/i); log('AFTER_DROP', k >= 0 ? bb.slice(Math.max(0, k - 60), k + 80) : 'no message'); - expect('a dropped join shows the retryable message', k >= 0, bb.slice(0, 200)); + await tap('Friend DM'); const saw = await sawText(pg, /tap again to retry/, 3000); log('AFTER_DROP', saw); + expect('a dropped join shows the retryable message', saw, (await body()).slice(0, 200)); await pg.waitForTimeout(6000); const r1 = await tiers('after drop'); await open(); const r2 = await tiers('after reload'); expect('the entry is kept after the drop', isDm(r1.friend) && neverUnfiled(), r1.friend); expect('Friend DM is under Direct Messages after the reload', isDm(r2.friend), r2.friend); @@ -66,7 +65,7 @@ setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 240000); } else if (MODE === 'pjreload') { await tap('Slow DM'); await pg.waitForTimeout(2000); log('RELOAD', t()); await open(); await pg.waitForTimeout(8000); await tiers('pjreload'); - const l = pg.locator('[aria-label="Join Slow DM"]'); const n = await l.count(); log('SLOW_DM_STILL_JOINABLE', n); + const n = await (await reveal('Slow DM')).count(); log('SLOW_DM_STILL_JOINABLE', n); expect('a join cut off by a reload stays joinable', n === 1, String(n)); } await quit(done()); diff --git a/harness/estate/drive-routes.cjs b/harness/estate/drive-routes.cjs index 5def6635..75c9a8df 100644 --- a/harness/estate/drive-routes.cjs +++ b/harness/estate/drive-routes.cjs @@ -1,5 +1,5 @@ const { chromium } = require('playwright'); -const { expect, done, isDm, mDirectPuts, joinsOf, hsLog } = require('./check.cjs'); +const { expect, done, isDm, mDirectPuts, hsLog, revealedTier, sawText } = require('./check.cjs'); const MODE = process.env.MODE; const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`; const log = (...a) => console.log(`[${MODE}]`, ...a); const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']; @@ -13,8 +13,7 @@ setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 120000); await pg.evaluate(() => { localStorage.setItem('cinny_access_token', 'tok'); localStorage.setItem('cinny_device_id', 'DEV'); localStorage.setItem('cinny_user_id', '@t:test'); localStorage.setItem('cinny_hs_base_url', 'http://127.0.0.1:8080'); }); await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.reload(); await pg.waitForTimeout(9000); const body = async () => (await pg.evaluate(() => document.body.innerText)).replace(/\s+/g, ' '); - const tierOf = (n) => pg.evaluate((n) => { const s = document.body.innerText.split('\n').map((x) => x.trim()); const i = s.indexOf(n); if (i < 0) return 'absent'; for (let j = i; j >= 0; j--) if (/^(elsewhere|direct messages)$/i.test(s[j])) return s[j]; return 'none-above'; }, n); - const tiers = async (tag) => { await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2500); for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } const f = await tierOf('Friend DM'); log('TIER', tag, '| Friend DM', f); return f; }; + const tiers = async (tag) => { const f = await revealedTier(pg, 'Friend DM'); log('TIER', tag, '| Friend DM', f); return f; }; let joined = false; if (MODE === 'deeplink') { for (const route of ['direct', 'home']) { @@ -32,9 +31,9 @@ setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 120000); } else if (MODE === 'gw504') { const HL = HEADERS; let l = pg.locator('[aria-label="Join Friend DM"]'); for (let r = 0; r < 2 && !(await l.count()); r++) for (const h of HL) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } - log('JOINCTRL', await l.count()); await l.first().click(); log('CLICK', t()); await pg.waitForTimeout(4000); - const bb = await body(); const k = bb.search(/504|Gateway|connection dropped|Could not join/i); log('AFTER_504', k >= 0 ? bb.slice(Math.max(0, k - 60), k + 120) : 'no message'); - expect('a gateway timeout shows the retryable message', /tap again to retry/i.test(bb), bb.slice(0, 200)); + log('JOINCTRL', await l.count()); await l.first().click(); log('CLICK', t()); + const saw = await sawText(pg, /tap again to retry/, 3000); log('AFTER_504', saw); + expect('a gateway timeout shows the retryable message', saw, (await body()).slice(0, 200)); await pg.waitForTimeout(4000); const f = await tiers('after 504 (join delivered by sync)'); expect('the DM stays filed after a gateway timeout', mDirectPuts().every((b) => b.includes('!dm1:test')) && mDirectPuts().length > 0, JSON.stringify(mDirectPuts())); expect('the DM is filed under Direct Messages once sync delivers it', isDm(f), f); diff --git a/harness/estate/drive.cjs b/harness/estate/drive.cjs index cf794323..03bd2ad0 100644 --- a/harness/estate/drive.cjs +++ b/harness/estate/drive.cjs @@ -1,5 +1,5 @@ const { chromium } = require('playwright'); -const { expect, done, isDm } = require('./check.cjs'); +const { expect, done, isDm, revealedTier } = require('./check.cjs'); const MODE = process.env.MODE; // twodm | reload const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`; const log = (...a) => console.log(`[${MODE}]`, ...a); @@ -15,7 +15,7 @@ let tierBefore; await open(); const text = () => pg.evaluate(() => document.body.innerText); const reveal = async (name) => { const loc = pg.locator(`[aria-label="Join ${name}"]`); if (await loc.count()) return loc; for (let r = 0; r < 2; r++) for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); if (await loc.count()) return loc; } } return loc; }; - const tierOf = (name) => pg.evaluate((n) => { const s = document.body.innerText.split('\n').map((x) => x.trim()); const i = s.indexOf(n); if (i < 0) return 'absent'; for (let j = i; j >= 0; j--) if (/^(elsewhere|direct messages)$/i.test(s[j])) return s[j]; return 'none-above'; }, name); + const tierOf = (name) => revealedTier(pg, name); const tap = async (n) => { const l = await reveal(n); log('JOINCTRL', n, await l.count()); await l.first().click(); log('CLICK', n, t()); }; if (MODE === 'twodm') { await tap('Slow DM'); await pg.waitForTimeout(300); await tap('Late DM'); @@ -37,7 +37,6 @@ let tierBefore; log('RELOAD', t()); await open(); await pg.waitForTimeout(30000); } - for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } const slow = await tierOf('Slow DM'), late = await tierOf('Late DM'), friend = await tierOf('Friend DM'); log('TIER Slow DM', slow, '| Late DM', late, '| Friend DM', friend); if (MODE === 'twodm') { expect('Slow DM is filed under Direct Messages', isDm(slow), slow); expect('Late DM is filed under Direct Messages', isDm(late), late); } diff --git a/src/app/features/estate/joinEstateRoom.test.ts b/src/app/features/estate/joinEstateRoom.test.ts index cc1d232f..f3f868da 100644 --- a/src/app/features/estate/joinEstateRoom.test.ts +++ b/src/app/features/estate/joinEstateRoom.test.ts @@ -123,8 +123,8 @@ const dmClient = (opts: { writes.push(structuredClone(content)); direct = structuredClone(content); }, - joinRoom: async (roomId: string, opts?: unknown) => { - joinOpts.push(opts); + joinRoom: async (roomId: string, joinArgs?: unknown) => { + joinOpts.push(joinArgs); // the join lands only after m.direct has been written: record what it held joined.push(JSON.stringify(direct ?? {})); if (opts.refuse) @@ -179,7 +179,11 @@ describe('activateEstateRoom when the join gets no answer', () => { describe('activateEstateRoom refusal bounds', () => { it.each([400, 429, 499])('unfiles the room on a %i that carries an errcode', async (status) => { - const c = dmClient({ status, errcode: 'M_LIMIT_EXCEEDED', invites: { '!dm1:test': '@friend:test' } }); + const c = dmClient({ + status, + errcode: 'M_LIMIT_EXCEEDED', + invites: { '!dm1:test': '@friend:test' }, + }); const result = await activateEstateRoom(c.mx, tap('!dm1:test')); expect(result.kind).toBe('refused'); expect((result as { message: string }).message).not.toMatch(/tap again to retry/); From a4da78edc5dcc14e684a2c85780ab8975f50cd05 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 1 Nov 2026 02:00:38 +0100 Subject: [PATCH 045/206] estate tests: an unfiled room leaves its inviter's list empty, not the map Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/estate/joinEstateRoom.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/features/estate/joinEstateRoom.test.ts b/src/app/features/estate/joinEstateRoom.test.ts index f3f868da..0bc61a9f 100644 --- a/src/app/features/estate/joinEstateRoom.test.ts +++ b/src/app/features/estate/joinEstateRoom.test.ts @@ -187,7 +187,7 @@ describe('activateEstateRoom refusal bounds', () => { const result = await activateEstateRoom(c.mx, tap('!dm1:test')); expect(result.kind).toBe('refused'); expect((result as { message: string }).message).not.toMatch(/tap again to retry/); - expect(c.current()).toEqual({}); + expect(c.current()).toEqual({ '@friend:test': [] }); }); it.each([399, 500])('keeps the room listed on a %i that carries an errcode', async (status) => { @@ -248,7 +248,7 @@ describe('joinEstateRoomOrThrow, the lobby, tombstone and predecessor buttons', it('throws the refusal and unfiles the room', async () => { const c = dmClient({ refuse: true, invites: { '!dm1:test': '@friend:test' } }); await expect(joinEstateRoomOrThrow(c.mx, '!dm1:test')).rejects.toThrow('M_FORBIDDEN'); - expect(c.current()).toEqual({}); + expect(c.current()).toEqual({ '@friend:test': [] }); }); }); From b33d8de95638b2057dc3f731a00ad8587f5757ea Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 1 Nov 2026 02:04:14 +0100 Subject: [PATCH 046/206] estate tests: pin the failed-filing refusal, a non-DM refusal leaving m.direct alone, and awaitMyJoin's event name Co-Authored-By: Claude Sonnet 5.5 --- .../features/estate/joinEstateRoom.test.ts | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/src/app/features/estate/joinEstateRoom.test.ts b/src/app/features/estate/joinEstateRoom.test.ts index 0bc61a9f..932e7cce 100644 --- a/src/app/features/estate/joinEstateRoom.test.ts +++ b/src/app/features/estate/joinEstateRoom.test.ts @@ -96,6 +96,7 @@ const dmClient = (opts: { errcode?: string; refuse?: boolean; drop?: boolean; + failWrite?: boolean; invites: Record; }) => { let direct: Record | undefined; @@ -117,6 +118,7 @@ const dmClient = (opts: { }), getAccountData: () => (direct ? { getContent: () => direct } : undefined), setAccountData: async (_t: string, content: Record) => { + if (opts.failWrite) throw new Error('account data write failed'); await new Promise((r) => { setTimeout(r, 5); }); @@ -177,6 +179,29 @@ describe('activateEstateRoom when the join gets no answer', () => { }); }); +describe('activateEstateRoom when the filing cannot be written', () => { + it('reports the failure and sends no join', async () => { + const c = dmClient({ failWrite: true, invites: { '!dm1:test': '@friend:test' } }); + const result = await activateEstateRoom(c.mx, tap('!dm1:test')); + expect(result).toEqual({ kind: 'refused', message: 'account data write failed' }); + expect(c.joined).toEqual([]); + }); +}); + +describe('activateEstateRoom when a room that is not a direct message refuses', () => { + it('reports the refusal and leaves m.direct alone', async () => { + const joinRoom = vi + .fn() + .mockRejectedValue( + Object.assign(new Error('no'), { httpStatus: 403, errcode: 'M_FORBIDDEN' }) + ); + const { mx, calls } = recordingClient(joinRoom); + const result = await activateEstateRoom(mx, room('joinable')); + expect(result.kind).toBe('refused'); + expect(calls).toEqual(['joinRoom']); + }); +}); + describe('activateEstateRoom refusal bounds', () => { it.each([400, 429, 499])('unfiles the room on a %i that carries an errcode', async (status) => { const c = dmClient({ @@ -278,8 +303,12 @@ describe('awaitMyJoin', () => { return { mx: { getRoom: () => (membership ? { getMyMembership: () => membership! } : null), - on: (_e: string, h: (room: { roomId: string }, m: string) => void) => handlers.add(h), - off: (_e: string, h: (room: { roomId: string }, m: string) => void) => handlers.delete(h), + on: (e: string, h: (room: { roomId: string }, m: string) => void) => { + if (e === 'Room.myMembership') handlers.add(h); + }, + off: (e: string, h: (room: { roomId: string }, m: string) => void) => { + if (e === 'Room.myMembership') handlers.delete(h); + }, }, handlers, become: (roomId: string, m: string) => { From b2d6d32cc4d6f14502230e69488162b8789d47ec Mon Sep 17 00:00:00 2001 From: Nibu Date: Sun, 1 Nov 2026 06:37:01 +0000 Subject: [PATCH 047/206] Background scene: phacelleGradBound 6.3 -> 7.0 from Libby's measured sup Libby measured sup |grad| 6.63 at |side| = 1.5, n = 0 (CultLib PR #1 head 990521b3); 7.0 adds margin. The shader passes roughness 0 as a literal, so no 1/(1-n) factor is needed; the comment records it. Pass 1 under SwiftShader: terrain 288.8 ms vs 287.5 at 960x540 level 0, 75.3 vs 75.7 at level 3 (noise). Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/README.md | 7 +++---- src/app/features/scene/world.ts | 11 ++++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/app/features/scene/README.md b/src/app/features/scene/README.md index deed806a..d265c332 100644 --- a/src/app/features/scene/README.md +++ b/src/app/features/scene/README.md @@ -38,10 +38,9 @@ computed from `SNOISE_LIPSCHITZ` (CultMath's constant for `snoise`) in `world.ts envelope (fbm plus the erosion amplitude) the step uses the envelope's bound alone and Phacelle is not evaluated. -- The Phacelle gradient bound `phacelleGradBound` (6.3) is **an empirical probe, not a proof**: - the maximum `|grad|` of the cos component over 4,194,304 random points (5.04) times 1.25 - (`harness/scene/drive.cjs phacelle`). Replace it with Libby's bound; until then the march is - only as safe as that probe. +- The Phacelle gradient bound `phacelleGradBound` (7.0) is Libby's measured sup (6.63 at + |side| = 1.5, n = 0, CultLib PR #1 head 990521b3) plus margin: a measurement, not a proof. + The shader fixes roughness n = 0; a nonzero n needs the bound divided by (1 - n). - The bound makes grazing rays crawl. A ray that exhausts its steps while still below the highest terrain is ended as a distant surface (fog hides it). That is a visible approximation, not a hit proven by the march. diff --git a/src/app/features/scene/world.ts b/src/app/features/scene/world.ts index 82afdf94..a1c6cb57 100644 --- a/src/app/features/scene/world.ts +++ b/src/app/features/scene/world.ts @@ -23,11 +23,12 @@ export const WORLD = { // The stripe wave vector and normalization the terrain feeds cultmath_phacelle. erodeSide: [1.7, 0, 1] as const, erodeNorm: 0.5, - // Lipschitz bound of the phasor's cos component, NOT a proof: max |grad| over 4,194,304 random - // points of the z = 0 slice with the side and norm above was 5.04 (`drive.cjs phacelle`, - // 2026-10-31), x 1.25. |value| <= 1 holds by construction (the output is a normalized phasor). - // Replace with the bound Libby supplies; until then the march is as safe as that probe. - phacelleGradBound: 6.3, + // Bound on |grad| of the phasor's cos component for the side and norm above. Libby measured a + // sup of 6.63 at |side| = 1.5, n = 0 (CultLib PR #1 head 990521b3); 7.0 adds margin. The + // shader passes roughness n = 0 as a literal, so the doc envelope max(10, 1.25|side|)/(1-n) + // needs no 1/(1-n) factor here; if n ever becomes nonzero, divide this bound by (1 - n). + // |value| <= 1 holds by construction (the output is a normalized phasor). + phacelleGradBound: 7.0, // World tree at the origin. tree: { trunkHeight: 38, From 7a498584e6cffb2863c3f5d7c5cf8e7e4b041c51 Mon Sep 17 00:00:00 2001 From: Nibu Date: Sun, 1 Nov 2026 08:08:45 +0000 Subject: [PATCH 048/206] Background scene: sky costs no noise; one sun term Sky rays were ~90% of pass 1 (noSkyMarch variant: 292 -> 26 ms, SwiftShader 960x540 L0). terrainDistance now returns early where the plateau mask is 0 (height exactly 0), skipping fbm and Phacelle there: pass 1 292 -> 159 ms, same step values. skyColor keeps one sun term (the wide glow is gone; screenshots differ by that and by TAA/time, not compared pixelwise). Harness: noSkyMarch variant, ONLY= selects variants. Co-Authored-By: Claude Sonnet 5.5 --- harness/scene/drive.cjs | 4 +++- src/app/features/scene/shaders/common.glsl | 2 +- src/app/features/scene/shaders/terrain.frag.glsl | 4 ++++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/harness/scene/drive.cjs b/harness/scene/drive.cjs index 3b41c567..74c4a8b0 100644 --- a/harness/scene/drive.cjs +++ b/harness/scene/drive.cjs @@ -90,9 +90,11 @@ const [, , dir, mode, outdir = '.'] = process.argv; 'float terrainFbm(vec2 xz) { float a = 1.0, fr = 1.0, v = 0.0; for (int i = 0; i < W_OCTAVES; i++) { v += a * cultmath_snoise(vec3(xz * W_FREQ * fr, 0.0)); fr *= W_LACUNARITY; a *= W_GAIN; } return v; }', ], noErosion: ['#define W_ERODE_ENABLED 1', '#define W_ERODE_ENABLED 0'], + // Upper bound on what skipping sky rays would save: every upward ray misses at once. + noSkyMarch: [' bool hit = false;\n bool onTree = false;', ' if (rd.y > 0.0) { o_color = vec4(0.0); o_depth = W_FAR; return; }\n bool hit = false;\n bool onTree = false;'], noTree: ['float dr = treeDistance(p, leafness);', 'float dr = 1e9; leafness = 0.0;'], }; - const wanted = mode === 'variants' ? Object.keys(VARIANTS) : ['baseline']; + const wanted = mode === "variants" ? (process.env.ONLY ? process.env.ONLY.split(",") : Object.keys(VARIANTS)) : ['baseline']; const sizes = [ [960, 540], [540, 1200], diff --git a/src/app/features/scene/shaders/common.glsl b/src/app/features/scene/shaders/common.glsl index ac3bb784..6378a2e6 100644 --- a/src/app/features/scene/shaders/common.glsl +++ b/src/app/features/scene/shaders/common.glsl @@ -26,6 +26,6 @@ vec3 skyColor(vec3 rd) float h = clamp(rd.y, 0.0, 1.0); vec3 c = mix(u_skyHorizon, u_skyTop, pow(h, 0.45)); float s = max(dot(rd, u_sunDir), 0.0); - c += u_sunColor * (0.25 * pow(s, 8.0) + 1.5 * pow(s, 256.0)); + c += u_sunColor * (1.5 * pow(s, 256.0)); return c; } diff --git a/src/app/features/scene/shaders/terrain.frag.glsl b/src/app/features/scene/shaders/terrain.frag.glsl index 770325b0..b8f2f718 100644 --- a/src/app/features/scene/shaders/terrain.frag.glsl +++ b/src/app/features/scene/shaders/terrain.frag.glsl @@ -30,6 +30,10 @@ CultPhasor terrainPhacelle(vec2 xz) float terrainDistance(vec3 p) { float m = terrainMask(p.xz); + // Flat plateau: the height is exactly 0, so neither noise is evaluated. Sky rays spend most + // of their steps here. + if (m <= 0.0) + return p.y * W_SLOPE_ENV_INV; float hb = m * W_AMP * terrainFbm(p.xz); float above = p.y - (hb + m * W_ERODE_AMP); if (above > 0.0) From 64112660926a5466ec507b6aaf485631bd116906 Mon Sep 17 00:00:00 2001 From: void Date: Sun, 1 Nov 2026 19:22:21 +0000 Subject: [PATCH 049/206] pose-aware chat bubble behind a Pose Portraits setting Reads org.gamecult.pose from event content for senders that are known Minds; unknown or missing labels fall back to neutral. Portrait sits beside the bubble on wide screens and stacks above it on phones. 84 downscaled webp portraits (7 Minds x 12 poses, ~415 KB). Co-Authored-By: Claude Sonnet 5.5 --- public/poses/aqua/amused.webp | Bin 0 -> 6316 bytes public/poses/aqua/annoyed.webp | Bin 0 -> 6388 bytes public/poses/aqua/concerned.webp | Bin 0 -> 6156 bytes public/poses/aqua/conduct.webp | Bin 0 -> 6342 bytes public/poses/aqua/determined.webp | Bin 0 -> 5954 bytes public/poses/aqua/flourish.webp | Bin 0 -> 6302 bytes public/poses/aqua/happy.webp | Bin 0 -> 6416 bytes public/poses/aqua/listen.webp | Bin 0 -> 6088 bytes public/poses/aqua/neutral.webp | Bin 0 -> 6174 bytes public/poses/aqua/patch.webp | Bin 0 -> 6352 bytes public/poses/aqua/surprised.webp | Bin 0 -> 6334 bytes public/poses/aqua/thinking.webp | Bin 0 -> 6150 bytes public/poses/eureka/amused.webp | Bin 0 -> 4176 bytes public/poses/eureka/annoyed.webp | Bin 0 -> 4096 bytes public/poses/eureka/concerned.webp | Bin 0 -> 4108 bytes public/poses/eureka/determined.webp | Bin 0 -> 4090 bytes public/poses/eureka/happy.webp | Bin 0 -> 4146 bytes public/poses/eureka/hood.webp | Bin 0 -> 3606 bytes public/poses/eureka/inspect.webp | Bin 0 -> 4258 bytes public/poses/eureka/neutral.webp | Bin 0 -> 4172 bytes public/poses/eureka/probe.webp | Bin 0 -> 4412 bytes public/poses/eureka/report.webp | Bin 0 -> 4434 bytes public/poses/eureka/surprised.webp | Bin 0 -> 4188 bytes public/poses/eureka/thinking.webp | Bin 0 -> 4290 bytes public/poses/heimdall/amused.webp | Bin 0 -> 4158 bytes public/poses/heimdall/annoyed.webp | Bin 0 -> 4218 bytes public/poses/heimdall/badge.webp | Bin 0 -> 4564 bytes public/poses/heimdall/barrier.webp | Bin 0 -> 4258 bytes public/poses/heimdall/concerned.webp | Bin 0 -> 4128 bytes public/poses/heimdall/determined.webp | Bin 0 -> 4158 bytes public/poses/heimdall/happy.webp | Bin 0 -> 4204 bytes public/poses/heimdall/neutral.webp | Bin 0 -> 4152 bytes public/poses/heimdall/protect.webp | Bin 0 -> 4024 bytes public/poses/heimdall/revoke.webp | Bin 0 -> 5296 bytes public/poses/heimdall/surprised.webp | Bin 0 -> 4276 bytes public/poses/heimdall/thinking.webp | Bin 0 -> 4212 bytes public/poses/kiko/amused.webp | Bin 0 -> 5060 bytes public/poses/kiko/annoyed.webp | Bin 0 -> 5060 bytes public/poses/kiko/concerned.webp | Bin 0 -> 5176 bytes public/poses/kiko/confetti.webp | Bin 0 -> 6760 bytes public/poses/kiko/debug.webp | Bin 0 -> 5266 bytes public/poses/kiko/determined.webp | Bin 0 -> 5218 bytes public/poses/kiko/happy.webp | Bin 0 -> 5266 bytes public/poses/kiko/neutral.webp | Bin 0 -> 5172 bytes public/poses/kiko/sprint.webp | Bin 0 -> 6470 bytes public/poses/kiko/surprised.webp | Bin 0 -> 5136 bytes public/poses/kiko/thinking.webp | Bin 0 -> 5100 bytes public/poses/kiko/tinker.webp | Bin 0 -> 5458 bytes public/poses/libby/amused.webp | Bin 0 -> 4738 bytes public/poses/libby/annoyed.webp | Bin 0 -> 4944 bytes public/poses/libby/concerned.webp | Bin 0 -> 4756 bytes public/poses/libby/determined.webp | Bin 0 -> 4944 bytes public/poses/libby/happy.webp | Bin 0 -> 4816 bytes public/poses/libby/neutral.webp | Bin 0 -> 4856 bytes public/poses/libby/research.webp | Bin 0 -> 5086 bytes public/poses/libby/schema.webp | Bin 0 -> 5590 bytes public/poses/libby/shush.webp | Bin 0 -> 5184 bytes public/poses/libby/stamp.webp | Bin 0 -> 4774 bytes public/poses/libby/surprised.webp | Bin 0 -> 5028 bytes public/poses/libby/thinking.webp | Bin 0 -> 4814 bytes public/poses/nibu/amused.webp | Bin 0 -> 4604 bytes public/poses/nibu/annoyed.webp | Bin 0 -> 5006 bytes public/poses/nibu/concerned.webp | Bin 0 -> 4414 bytes public/poses/nibu/console.webp | Bin 0 -> 4776 bytes public/poses/nibu/determined.webp | Bin 0 -> 4262 bytes public/poses/nibu/glitch.webp | Bin 0 -> 5106 bytes public/poses/nibu/happy.webp | Bin 0 -> 4580 bytes public/poses/nibu/lore.webp | Bin 0 -> 4730 bytes public/poses/nibu/neutral.webp | Bin 0 -> 4384 bytes public/poses/nibu/surprised.webp | Bin 0 -> 4818 bytes public/poses/nibu/thinking.webp | Bin 0 -> 4228 bytes public/poses/nibu/triumph.webp | Bin 0 -> 4252 bytes public/poses/weksa/amused.webp | Bin 0 -> 5088 bytes public/poses/weksa/annoyed.webp | Bin 0 -> 4422 bytes public/poses/weksa/argue.webp | Bin 0 -> 5178 bytes public/poses/weksa/concerned.webp | Bin 0 -> 4572 bytes public/poses/weksa/determined.webp | Bin 0 -> 4236 bytes public/poses/weksa/fieldwork.webp | Bin 0 -> 5408 bytes public/poses/weksa/happy.webp | Bin 0 -> 4590 bytes public/poses/weksa/interlingua.webp | Bin 0 -> 5484 bytes public/poses/weksa/map.webp | Bin 0 -> 5202 bytes public/poses/weksa/neutral.webp | Bin 0 -> 5170 bytes public/poses/weksa/surprised.webp | Bin 0 -> 4232 bytes public/poses/weksa/thinking.webp | Bin 0 -> 4612 bytes src/app/components/message/layout/Bubble.tsx | 25 +++++--- .../components/message/layout/layout.css.ts | 56 ++++++++++++++++++ .../components/message/pose/PoseAvatar.tsx | 30 ++++++++++ src/app/components/message/pose/poses.test.ts | 35 +++++++++++ src/app/components/message/pose/poses.ts | 48 +++++++++++++++ src/app/features/room/message/Message.tsx | 25 +++++++- src/app/features/settings/general/General.tsx | 8 +++ src/app/state/settings.ts | 2 + vite.config.js | 4 ++ 93 files changed, 224 insertions(+), 9 deletions(-) create mode 100644 public/poses/aqua/amused.webp create mode 100644 public/poses/aqua/annoyed.webp create mode 100644 public/poses/aqua/concerned.webp create mode 100644 public/poses/aqua/conduct.webp create mode 100644 public/poses/aqua/determined.webp create mode 100644 public/poses/aqua/flourish.webp create mode 100644 public/poses/aqua/happy.webp create mode 100644 public/poses/aqua/listen.webp create mode 100644 public/poses/aqua/neutral.webp create mode 100644 public/poses/aqua/patch.webp create mode 100644 public/poses/aqua/surprised.webp create mode 100644 public/poses/aqua/thinking.webp create mode 100644 public/poses/eureka/amused.webp create mode 100644 public/poses/eureka/annoyed.webp create mode 100644 public/poses/eureka/concerned.webp create mode 100644 public/poses/eureka/determined.webp create mode 100644 public/poses/eureka/happy.webp create mode 100644 public/poses/eureka/hood.webp create mode 100644 public/poses/eureka/inspect.webp create mode 100644 public/poses/eureka/neutral.webp create mode 100644 public/poses/eureka/probe.webp create mode 100644 public/poses/eureka/report.webp create mode 100644 public/poses/eureka/surprised.webp create mode 100644 public/poses/eureka/thinking.webp create mode 100644 public/poses/heimdall/amused.webp create mode 100644 public/poses/heimdall/annoyed.webp create mode 100644 public/poses/heimdall/badge.webp create mode 100644 public/poses/heimdall/barrier.webp create mode 100644 public/poses/heimdall/concerned.webp create mode 100644 public/poses/heimdall/determined.webp create mode 100644 public/poses/heimdall/happy.webp create mode 100644 public/poses/heimdall/neutral.webp create mode 100644 public/poses/heimdall/protect.webp create mode 100644 public/poses/heimdall/revoke.webp create mode 100644 public/poses/heimdall/surprised.webp create mode 100644 public/poses/heimdall/thinking.webp create mode 100644 public/poses/kiko/amused.webp create mode 100644 public/poses/kiko/annoyed.webp create mode 100644 public/poses/kiko/concerned.webp create mode 100644 public/poses/kiko/confetti.webp create mode 100644 public/poses/kiko/debug.webp create mode 100644 public/poses/kiko/determined.webp create mode 100644 public/poses/kiko/happy.webp create mode 100644 public/poses/kiko/neutral.webp create mode 100644 public/poses/kiko/sprint.webp create mode 100644 public/poses/kiko/surprised.webp create mode 100644 public/poses/kiko/thinking.webp create mode 100644 public/poses/kiko/tinker.webp create mode 100644 public/poses/libby/amused.webp create mode 100644 public/poses/libby/annoyed.webp create mode 100644 public/poses/libby/concerned.webp create mode 100644 public/poses/libby/determined.webp create mode 100644 public/poses/libby/happy.webp create mode 100644 public/poses/libby/neutral.webp create mode 100644 public/poses/libby/research.webp create mode 100644 public/poses/libby/schema.webp create mode 100644 public/poses/libby/shush.webp create mode 100644 public/poses/libby/stamp.webp create mode 100644 public/poses/libby/surprised.webp create mode 100644 public/poses/libby/thinking.webp create mode 100644 public/poses/nibu/amused.webp create mode 100644 public/poses/nibu/annoyed.webp create mode 100644 public/poses/nibu/concerned.webp create mode 100644 public/poses/nibu/console.webp create mode 100644 public/poses/nibu/determined.webp create mode 100644 public/poses/nibu/glitch.webp create mode 100644 public/poses/nibu/happy.webp create mode 100644 public/poses/nibu/lore.webp create mode 100644 public/poses/nibu/neutral.webp create mode 100644 public/poses/nibu/surprised.webp create mode 100644 public/poses/nibu/thinking.webp create mode 100644 public/poses/nibu/triumph.webp create mode 100644 public/poses/weksa/amused.webp create mode 100644 public/poses/weksa/annoyed.webp create mode 100644 public/poses/weksa/argue.webp create mode 100644 public/poses/weksa/concerned.webp create mode 100644 public/poses/weksa/determined.webp create mode 100644 public/poses/weksa/fieldwork.webp create mode 100644 public/poses/weksa/happy.webp create mode 100644 public/poses/weksa/interlingua.webp create mode 100644 public/poses/weksa/map.webp create mode 100644 public/poses/weksa/neutral.webp create mode 100644 public/poses/weksa/surprised.webp create mode 100644 public/poses/weksa/thinking.webp create mode 100644 src/app/components/message/pose/PoseAvatar.tsx create mode 100644 src/app/components/message/pose/poses.test.ts create mode 100644 src/app/components/message/pose/poses.ts diff --git a/public/poses/aqua/amused.webp b/public/poses/aqua/amused.webp new file mode 100644 index 0000000000000000000000000000000000000000..50ae021b25d7f6fcbb06f9f2da622176087c7e75 GIT binary patch literal 6316 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 literal 0 HcmV?d00001 diff --git a/public/poses/aqua/annoyed.webp b/public/poses/aqua/annoyed.webp new file mode 100644 index 0000000000000000000000000000000000000000..924b789d185ffad44f815c457305a648e8bbc52c GIT binary patch literal 6388 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 literal 0 HcmV?d00001 diff --git a/public/poses/aqua/flourish.webp b/public/poses/aqua/flourish.webp new file mode 100644 index 0000000000000000000000000000000000000000..46c6171bce27e650aa89b6d9a942be69ca27f52d GIT binary patch literal 6302 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 U0QpB#^Ib5Ox?i8Q1Stvt03E1JzyJUM literal 0 HcmV?d00001 diff --git a/public/poses/aqua/happy.webp b/public/poses/aqua/happy.webp new file mode 100644 index 0000000000000000000000000000000000000000..1dcb13a2e250075d3c1ec82df56ce51fdb313eb2 GIT binary patch literal 6416 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 OyeIFjm>ego4gdgy)v{Co literal 0 HcmV?d00001 diff --git a/public/poses/aqua/neutral.webp b/public/poses/aqua/neutral.webp new file mode 100644 index 0000000000000000000000000000000000000000..d8f03a898d9766a1be9b0aed07dd859b375e3c3d GIT binary patch literal 6174 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 literal 0 HcmV?d00001 diff --git a/public/poses/aqua/patch.webp b/public/poses/aqua/patch.webp new file mode 100644 index 0000000000000000000000000000000000000000..7c47973ba5d3344d4ecabd838ac00e763b6c1c15 GIT binary patch literal 6352 zcmV;>7%%5iNk&G<7ytlQMM6+kP&il$0000G0001d004gg06|PpNKpp>01@y;{{JjT z{{KCAI21)FC4{_}x{xM>7G|FiP0Si*&Fo8y+4p_lCxqFWF#EJwLkqJ{%sx#Bt(dii z(25X>4k1KQ#Dm9$I#qSf>%89UrHGgS{2wGq_QB472_(9fDuY8qL)A)|NGBOJ5}gD4 zFS_^8r$>KnY&M(C&Bog5x9`v0G+9dkfQ%IhpgQ%`a--!ewOwDhvIYQ*5dqX@mp0{2 zW$4bGpB^3sz}OHdUvy;Kwk0`RNw&TF>2wKTOh^pe`iE^v&Qj_~m2B4!mO(~?NUmG6 zEjfEhDYj+1J`FGqBu+nSOYXI;o>rDN_iS5Au_fz^F^~};%5yDC?pc5A_S6_p&b;;g zb=g*mEqUkO1S}jewra`gO6ybJ7zI(DoL{%46kGNe!(hRP=`G9Yl3T3|EUyhC0Z_Sa zRhCjLdF>RiP{gfm%jvR@p7M^>pXDzIkU%oMYAeN-wL5QiA<#c(WjN#YY&R9N_ z1`x?yH_OK=C?-*BSnl>9FtcGPBl~0xOaY16wv}S}b+8b?lI5O_9o~(9}e=TFda;TEsdZ~(p_}1#MJq-p$ zOvv4@!@iw>Y)f9h00|Drsq*3A=B*U@SP98eShlQ}O2D^PTBC}vS?}Q>5|m$Uhh@n> z4CA^q+Y?|w#J*jp%8e>OldCf|b>%=*27gRmScLzi4+i_R9 zj-eA)L3)+v0-AI$D|p><1fDC%TjP6?>*P7Ta!(h+a%%Wu|- z*l2!p|JoL!Hu5h_-l!ty$bE}{Y4c!ikH*VBCx9!XC9e-4Z(_XdXw8Fp0p)coPTt-Z z0P%jLkeVySFq7B23~@D=zX9Km?mC zTamdJ34$g5aomcD+v8w3AVz+*6b*TW2%4xav>lOIFenf>{nK_NPcH`*6bdm#cpy>$iOoQkokxYNOPy}nM83pi(JWtygbD7* zWCgj6wTib00t0KA90KzJ%4=59P7ENyMb7k`+=!2gX78PX1n*dV$;Zpco4C4oJ_jfr z&*Wh+FQD46igp1oy6e8>Miu!JbH%G)fGKBj9he{Rk4>v!_Tu6G5;JJ5iBwI=y5r&@}Xcrpgi<Sq=U z{Oatw6$Q%=f`NcUI`LpE?45Bi5C9|zCKzs7agY}gJ~1B)yR{z-Z)D4nl%EO@#Jp7i zWwSyQaHwI0m+^KD433x$PyTe>%D__v0P(&Rmb`?kvyksD0#L16A<0W6V8O~@u@J=Q zh82#ySOQV{!s>6U28IV>V%rKt@i@IXv#SV71;cYpvul$R{^X8U0j zk{CH^DL2`xr+`Q$lBZgh`taf@EC7hoT-#C}lD++C2}D;Cr~F`B>P6OE5kTVfA8bn* zNp|a<5s>JWNM?T8v#lQYTxSsgBAHp)vn}-_$#!#L3?TX>z%vj1(RQzE=b)e@l8J>4 zcdAEsZhv*t5I~}T0x0c2cxbuaXf_)wuU|BP;u1jhlGlITaj)7s_SV%k03@;{(V0{R z2P*`?;s7LoQf=yv`L{k=T3USlxr39#2>_7DktEVd6rDuhB=N75sQ^>)UsnKDP&gn^ z5dZ+tJOG^mDt!Qe06vW}lSm~Zp`kVVo0y;xiEIGTucuIJJRbk#Bd%Dc_N)DGTL0>O zOa6rR0R499Ip!z!tM-fDPyLswzwX|kAKE&GeF#5peL8=0J`?}9|IYNq^#K3>?Zduv z+ei6t2K;e+Vo2@!YH`~>C3maxJ5Voa_#y1u*+0;F0e55mf2BXwd7JiA*bDV<$1B12 z&pm*@I)2A{5`X9Zx6gOff3sf!AI*QQ|IPmq?ZNDq_XpW;_9y?c{tl$emyOIB(0Nu| z`Pj46+z!7DCw7IoHjT>+hAo3(`EkcRxHUh-nmwzfz&hSG33A>|*@Z@ivxDR22Ww1; z)bKRIm)(urD}zvY>xQL|3TRKgB4Dv^cb5rvl69F;g|AuyL{?ttU#fx{Zv%mNbuaq* zY8{=z+-cEOt z)VqNA^K;0X=+$LC1YM7pdaJMeOt?xdDk`pX9d8vTpLJ3N(zsMvYzjn7wp`UR%!N!i zmr(;52tJqd#r!V#sfIq3Jr`{n-Ce(z5wC@{oGj`{ER1c#?u32ZbIt;sjfhkvNuer1 zvwc+&PN0Mc&h=ZlR&9m6fW>?4NI{*Ow{Wfmdhmdf#wcvd+SE-Hg+#LOw z*s<|@+yAps)49sbtP#ajF#zmcRWO7Gy2-s#6QXbMb)+mlN^f6w0dgn1Pc+!SCm*_V zW#F|1(IBuiGvqhk#Kt`vaO~PDhubivxE#%1Vy3Kt(9J`16;Es<5p(||1}ZZz5sn&y zUcCFP{@CDsSwA|f_XGObbE6&F)Byro$^Bx%p8uIib8sI61ILIW|0j6B1U(!kMdp}& zO7;QhsTt4@lvZ>pDfLTQ+Cz&db|TYb@%zR!ZUz`%#DlY|U_^EE`w(D*(`kh~HMm0= ztbD&oF~J9PB=6V4rQvhZ`h)6H@hLt#_gN%&aS7r5OZuz%|B54O)p6wm45;FZT4|k2RjSva*Pf^S~T(+(6A9WGZ-hl7SF!# z!+E5Z_bXK3zL(S6B&dJiX6$l#^Y#%pW5N;**#Cp;%!xd8-0%k-)+ucp-SlLU%+sk3lZQffimN}3x)PArRERiJ>8saCG3jMe4+bC4U#NqD>L-aiyYgfn-bxr}E$G>QVEH_E(JR5)iGn zMn^3h_bIcA?lO=i7Z#@&Un}x`)gRZeKia1S^&S+Gl=OP17I&q(gx|7wm_Y1ElpPN( zQyW;W4B(uK4O-WmJyEk(3g5mN;`8|m1^p`*iBZXh&L5^!e|&IV>>#>>oQ!2h(~)9w zjWQ%Xya&vtL0C}Sq;SyJO+sm8fZxgZy93^rBthS`rN?@>NIJr4lW zcmUe;X|5b^TD4TgDIg@|oASR`I*Mn1auwO3pYA*xG89+{YK?Vj?ofwVtiquS(*aij zEIkM3qCrg2sYdve7mk(@DJ0`v*Jwl{qd^^$B{XPX?=iSR;>1}R2er*>azWp!TcG1{u2#{h)Fic?x zEwQV~n(6VJBr^kyTcGR=CHoPPu-$;|6e(>Iu@MRtx{NFiO;5a(m^&=eTb$kVB;xF% zLtx}G|4&p8OcHQ7F}dm`gGUrPOPGQG&EdIyp!rt-d;9z6?=I0y#G z%^4k~rlYKY=i@5FA{ajDMEIA?2ny)b-8di(>n0>1ZRaQUyuFNkiCx8hUd}k}qf473 z?M>O4@ol|a`CO9leet^1qx41GUYmN zRA{tBfe;z4_jRl->Ke6s)ZYZ~nLR-27$zQ3d&6fCCtBw^diE@~AHB$03I@Y!k>X4A8&o$d@aB9;IlA z%I1ZQliH7u2Q+vWwC?aUH%gl;!H0_4llixP%p~5=@(fNNSoCW`{9b+rtDB=G&K782 zfXN5?ODfLuA5;JT<>V;0^9La94Gqsz%cTxoMr;@lN?L-L4ETlM#=YL}%)K9cKw5%2 za86PXdS6DDESGCjrRw_b-&GjvCr+e-PtZt0UK-17f@+pvjwa8@*F6KcJ=J*Z1|%jn zT~eB1HSd3t*+IskFJm0Ia09u5Oq=hXfB`b^v}oy+z2x>r-OEPdUhz}zQFF2WYm~q1 z=YD&ab}M%yYqC>q{QB6!S$D}YK^a`GKg#u~+OX}P?M0Y-``jhI+A7G`)n*!h{sNQc zzE`fX^Im0EoQ(X$dNiFX;G0qZ_3Jw=*Y=Fi$(KJ|~cN<_PY_bNx%zM>FmZvS2Am|b*@YdL@Pf%eLcn_h{Rl)M0 z-u`W!_&B!vHCrlJ`$t*{rA8f9xU29DQfmOhr5&q?cs2z;J!^1$xqKN{x>#DF0#kUl z2|0SvENQ{sa3GrNuYI|GH$?-he9dV991?NV5dwWI(v8+_DS#kA&qu6y zgN@o(rYQo+@0Z=j0eLIlYx1touT$C*$NImLOzLMbEV~~Y<2xJ-|CKubRF(5Kj6Y~x z*9chjKr$REefEG^(#Ut8c1z9>nzTR0C$;M@;<5fP3fR~(h=-tj(`oJ}W&jkRkSo=@ zLAM-sWOu{wV{&s+IUaCJBw0DJj{X|p6PF(;5a0vhKQKxY_S%^zu#dK|M$Hn44# z=Gz;E*=G*&qK%)rxQMuWbywkj2p~>9TN_9+nksGbH{s zA1D|=49_*lau59FWEz+#nRlP%7X?1@6%#KvPo#p!3<^DGOYF|Z?-HIeR*a70H}@rn zn6UJ8k00zG%SYFly)rAbrMMR;1hV*Xd1;qp(0vc~js_or11`j&k>{tY@Q$01czE!( zpqV#7ZID}YnMLY^KFpd`DEmKBJ49LhNO3PK*4n#7n7+AT_dHwk+A+Qxu(Q+vr+{(p z!t!q|Zoek@RM674vUBy<;;}loYCAmYqu$NkU^mpMjYyP#H8MnM)lA~z{f49g1RUjU zP3HRBRjZ356P0vbAcb_6(DoVz1eL_eVvACkW&>7sG`zRhqzQg0#*yC{-Ysq{P%Zv zz)|?Zah &Uko~XQlE|e!}ZTE2k88Z`5u;k}X2b#yMt9y@`70)ofq(Bxi~)lXo96 z+?Vtm7IVaY8sC;O{{@QydN&(NM?u;c2u;Mg1cVvick(N0TAZ96&9uAt9H zIP4g5qv<-G;*e0+=mO|VyD!wEuhz|#Py+nnY2n{Sw(V$8?+<~|6U8w@Qx}d2Yv*ws z$P%t<$~D_zJ~C3vPNS}6)5yqXBs>gKMn@x5w~DDu7&WYed;#IqdbKUubb>H61N^Iq895q{g*mD znhO39Vh9#&xb)y+GRq)v$h&S=wV61RpG1;1XOiydt^?9ht= zie3tGQg}g)#o$l!H2?Aul*ocS@0xNjt_%#Sr)90kJ2Hq;Ig!6q53%tZM!{|{Luf(- zeKf(&<+HX3bE|!k+)63P{$%nL0^!DkotY?w##E2oFVf=LH0Pb>?LdpjEi_gy&%BF^ zsE-)=f&*)j^y~b)JgFi~lHHERB1gP_Vpz3jVl{>#y>{(sC*4~pv1r|9QA0KoI;pFp zp1fOZ1&OtTl%e+D<_B#5v;aqtreK9r8|S&1_aa9e+5=EZw^x_eFuUG2L;3b$9e)KscBrAlh`opHtPw&WkIpG|&spxzcpMOQj zfF(`PZd|oID5UyEGLm}w7gb5@I==f7OVImVhZ`|DymGfn`-u0eTYAwQXDd3F33+q+ zPBmr)FB0dQ7KY%p5A!R>qP9qvZuK_;Z(t(20q9cy2|OWsM{TVw8Jb*izkW;4*=}3GEyej3y&>T_6#16!rd(<@ z$KLJwCR+qIEJ`ooNH*KvM7bA14ie&?c9{Qo;w z)|J+!D6;zI)id8@YX3xc%O2Z-)@J<(9B5oGoEn*cL(@ zLWD3x7>6wp!|>zn{m1;K{Jy{Mt$#(t1mOP{+1VRA{{@if%As#pYqe@+h)5?HGZFxb zBhz<0zWn~?&i+BOx!>5?{P6Ej-Zyis1OQ~5NC2hdb1U0N9%yT4dG>e_0OLad<(ZQ^ zi90nwcP3j4=NADoDkLzruxHzn{}{rT!n+qRTqOZLVOK<19b%#J1ZzTNEU%Ndl% z?p`^ttrS~wvQPqZMidtk%jxRUZ~I~psNS&JvZW4N@+Tu;u88WIC8x{n!$HVjPM|;l zW6ykPE5%N{KMCfD7}>I%E_r+EL90K@r@sx70EVAwS!tGAmxH+>Ms}PyU2e^nG2HYZ z<&z))5*UBaQi|o)444z5wq-e8-kk)_S!@r)P9{MBAi)RQR*L23D3}iv-?N-9ubqX` z-3K19dPb#VtJ#CToO-OPM4R87=7Q?fNcE$OaX}F4J*a+^I#5$i``vbB(D9^ zk_Ron2$CrvM)$20+ZTi3p|WkI$$uvvOe_yxJ{Sf75&($FL#NA|B?M2*TTYeN%lMTg zXS=`p@Kgl=2@tn+u{;NchO?SZmk&m8?8tI}jm7B_K!AAC>atfs;KXmOH2Y>0Ua_3D zB-=WDUl}A&-n2R_-vNe&%3c@wHQ;f}K}xoF?$h@goRmebo4xXl4e_9y3q z#A{ZEolJls0jIjiOTZ~7;n8+BjRO}ax#+V!28MEn)kVG%^?fUE^2OmS z^k7fpl;wXR%RP->Z8-ur=b|~b6^GZtS?nfn{K0Yzwo3?Q#@)5L?>IJV!OU4*+D%8Y zRR;rkrH6?A_+OX2HwuQa93RV{mk`EMgq-+6@L!d@Uq$xBj}ymOo&z&O;S(!PpA%UV z7dzI;IGEYrtvJcEnW1_RuUE1nE_STtPmnS3TPs>SLz%5a%RZ=P1guA_nc2{8wB+4# zR+UD?Hux{>O=Ml(i`e5}M*KWl_QhEdZ^laA|28rv{uHm1U}jgx%E?3k!-tNtyo?av zw<08;se_pj_r*x^9Uua%H67zWfe4Uz+=|YZa|n|-=fH}J{9z3Y1H_5wJct6qebGrw ztnOfPprg1 zJ{$*oVUO9McsdTZf#^!2XJ%46^@k?0?SVFC2JN$CyC0hc5Pc-ZBK#&BgPM{JjrLS1kp+0hCN%7(4n9WVHTnC9x zVtn6;dv8yI%nB&1S@}2t5~)PJVYz4dNf6l+qhC6Y@{1t4mNzW-*%}Q3#C=u{Y@YyA zi07TScli?{)Kx17a-)h)Vp{H3UIfD+>P;&=%kv-r@I$M=2ZJH5bROkK1psle2RR)G zf$3AZuzWKJ;FQ&m+^8T#5~C}L^YSJ@)Q{Y^yaWb76z7jD<>gEb1>9lvWle@a)YdFH zzf&k+xj%Uo0TQFzPMlwPeFDX;{@gkaW=4!PEay8}`f=0!%JU$yCeGQhoNw8Q`jOu( zAi(09<=o5t%I}xK%!of&j<5V;F*{gSl+0E83FHEeaMlN8>3(*#FYD$x7Mu~ zIPvLkBcmm&x5PWO^Bu{=i`k%h(3|A<&H_$ZQLx+RgM%ZctS*x5qlb$qVA_$CU(5nH zYu8pK#8AQA;~L47+Sab zu{{L_2jY1v9C^6_;+!3;A9*nkK;cCz5ZNb-Ac$kTPWrH06DTk|@OfJSNN&x71V9XL zTIx;St|J$?Q~OcvbM3cv3Bz28gfelsLU+9-)OmO(%e{{ zn678wRg zh4v@TyVJhGKh6KSf7kx;?ZfOl_dnU6^|`~|0!K$}Re#KD5ycB?@NI?=h;SO14rMq- zhQqjB0U#@R!v6;Gz7f_|+A8kp1me@QkCvFe0@mgHRRER~)X851qrA5LbDvPv9(24x zIbIX{zWY#OKLL`EOYe*~DjTEf*1#8}?5rLz)1N=mDZU(qn z``=#tR1RR+*88RxRwRM*d-Hd|1N+1w>rl$vr3*Q>uPX`9bb>C#7F=Ywo20+w$hz+e1&}+4n~- zt!V@mYS_>eN9E@2yD)QMk*lFL(0lJhhR4#P7SGa5&q1iUEdl=_&yy+%D5^5|?#j}V zj_jQ?>nM=d-)g?SKiUlAxbhX5^)g1+uc?(7O}8kKx?XOd&EZ##JB~K%Fzu*+cz^R}esboux7`e|ABGe;mVETn zcYZe7yS}pB6y=3(H1DjyVa}LoIv)Guwwla(^7Cs~s7_ch3><^Sve9!s#uF^o+dT3w zf_Sm?-VvuTe}Rtr(m4U$Ao=5iTlEtfH0F70o0?*o+SN@kJ*}1HrE;QgHX&xHxyphk*UZQae zRWfXGEVB>hmj?7*PpN=J-IPD-834#^i8TOYO#t$Yr<3#$g7u##@GsVRrv#zaXw7C; zMux$V!)G62pD^H~=$0I7-sYEn_mf!PYG&)f2vakQckcct0tah6N3CvNQgZ)Ji_WGGf~Og;RdIvC8(vn~s&S>~4S1Msl11G^NkHaY26_#Vol%nRuIVDFPlCcJ}6}Y3(c7YG|0= zxEV-v?m#O!?inf0|7N=bz8voF8X5QlmI9{xA!v1G0j~~%P_I-jjxnW)QqFl_E%~;S zeRmk1-oN-uVBhd1a+MX&{D(<{fqMhcTK9d|Y4AR6N%+rE8W@4vm}qYoc|i*=#e<8n zeXXjNaaeZsYwmYsZK>*Cylr5sbt^=9LpsV-XKM;BW2!Bym(TFCn8vq?&DSkrmR&-O z*Ta(5uKoeC%jqTTpY=_yzd3N!cV%b=m~VhWL!Yi%Cw5?Hg}>b5%Hc>8f%Y^E?8Ktj z*~dMpBs&<>TRsR0KOX<<6O|UgeWIXjLbJA`@Nz^1lX6O6GD(pg1X8AmTh;_lh1;6s zQ8OX7))gOpThc!VwCK{3N~bT2bq5E<5JH}Io|c-G`gw;bkrm+{evz14B4WM$t@=mu zVRV}nbGQEF=Vj{}yqPY){1U!+0sq>L0W;C|#Lzqcx;(?;FltJ=w3-8>iL=Hm?A>j> zz`D7DHJ?d-M4bMiOCK5&bRFUYdT2sytXCs?tiiI4vc93B<*J=#0A#f_3z8?G{3gao z90Luvnf|%C8FB0tQ@)K9VrOC-lNTNijKV2J;X8J(eIcJ(pPW_YOr-FeqeewO$fgvM z)YC8v@#<)+ivyx@2~T67Llmd}+>xIk07EtnG1;6j+I@_cNh8kmZAI#HiFkf;D>xY3 zL*nr4(f#OiqErEeyiZR0+Xk*ouGYW%*ZfQ4nvTjzSnawa5^ftY{iIy@i`V~=U%R9R z)xEMr$4fQz?F+>7PI@ySch7D;^O*AA(h4vg-$y2>jy2R}Dt(!bx^iYE05Vy4`ciuG zx|LeWwZ2zqy5+Z2bf>9cFMFfb+<+IBiwZ}$q_idHX}Cn7{(mp@xO?OLsr0p~l0V2n ze>?vf1dAGTCQ4tlb{S+?^)bf~Nc0+h zL*8XIG6TcckhKXS!fw%d*oC99(&V?}U&uID?l8xw6uTS7PbnTse~OvkeW!vj1Jdo< zJ2AU!K(^eyurw!lVR!Rilr|YRq$v6xSyGAda&ESivv(|$ly0otG8M6qv$ll?tFvK` zGjw9m%84`}&@9I})k&p`0v!G$z!Qvgk-9@=l#&TK=b+}f)X)4MI>hRBO;i!#IBvXQ zR_9Ap(Cm1LKFLO(KcO|N{fwEo-SqVwis+W0yPfTrH(O$+TiX(bb<&B}^UIKpj4$Fs z*1Od@TX#_9mo9%Z%>RTmjrR*wgX5jQhT<$Y&UR}x^yDueqHP2uN$EVtD0s*6XGc4p zyYf;GVa}I&`J8>!?$c6cp#i*rm0DVorv=DsQ}`Vaj&oLKk_yCr<4}}=x9__!BM z9kpM^4ZI0B{h;1%VLKzk}%m(n6WOfs@>iT5DmVPPRb|an; z1?%cbZVy|mA81E1$Z0@fs>jftNDZ)T6;Mp)FB^sJropR`l*MHL$HH^mDDTy3tKZqr z7hj6C>SxpL^)L-bKA*Fqp%)q%pzt7Dk5$9<-aQ^f{2c~3+Wi5~#0&tE_2;)W0%Ja* zeHVo{%VkSJAz95+^^ju36M6u@-H4PE<*pJ9zu4h-YO2M4m36RbX9F@j_i?}L(&ajF zD9hbQi>N5wjDK01h9oh1u<9-1O~0B{tP_kLbTvBj(|X1{{N#r(u)nGu!ME0-aOdA| zqZtJ-NgkY9h<|ks(Rp|V^$j9{2oV2CIiR7<)l{pI1lt~7XqEnGomNWv%15_DR^Lud zVGu*NWItu$u_K~`Kf?1)offM7pFTIg^*F4_Mf?E)wu51O{Q`cE7i}%yC}KisWAWBe zRb7$IoPW!xx#5`BD4^Rxfi&;plfF^AJ7#H!lZ{GFs!cRWMD`3r6Y<2%xM#qUt6u5I zKAsnzmi)eu`ivb}Y{58T&8NnekJT(O_QQ_)w_9MDm@o zY12*%dwBaB1+y=_wcJWwyO9vn%AX_Rip(t}KO9Z8N~baYC^ca~-1-U~1^YW59zl^z zH?vEHL)1%qpH7x{iV2az&gP?KfanjCOy0H@)b%=HE64pz`YXAH;Wb8=fz~K( zJTl5{uc3rn7+=f7fbrWj<425tQ3y{jW#fk}wKY@Sude)$xD_=>$^`h{fsNSTgBcT> zY{Matb9IN?M@(-Cw0qJ{K&=gKs(#Quz~58t_2qxj&ekn#S@dt{tiw@gg!&j`$;KrQ zUKP%*Tc2IawU_3Q0d{ZJ^?#uR_{lx)vMl4O6SAu~nj@Y5D$?c(ail~_947-{S=%LN)lg2U>` z6#y+G=hW+dxl8{X+?2#uaKC06>n>@w8;@L#9Aa(Fa7;7HYhpioB??Jx{;qNPe7(j) zR7RBwH9!9+Vn&+$crn)FeQtqCDh%qEA=OKtaCqgZljOSu#lH!NXH#j%oQ?sFguR%eLxKOgn=cI=3r_#RhO1vJ;CgB6 zZQNaVyXGkkXu9o{decXR2XY3w&}aa{a1tfFC{hG1U^xs0kBCB^JNK@Cik=2WmG59; zamf(D86jvxiv~E@9X==uxSapur``6cpA7XweEaHT#&~5+2?(R^=GXIX1!vPu%fE4O zd8NR4@ReZsh2S?&P~w1D%eVX3A%`<}8Vl_kWgcv}R68451l$?LZb~A^F1U8YVwy82 zI|mO)U13Q(K~O8(+}3iU3}}?{BxPMLln8p}6=R)RE;;&z(q}ZwLvniC?>Dq!Dwj(# zbUhx<@fT%#_vC$Z7W(VBDR|-4B2GMK4k1`93#(6*NeXv|1l~3VU*F<+Q)6k9*SrkP zIUk@1G|`z);;!Q%Q9vB<0GW!=qVz;njN3KdBs^5*aubZ#rG`>hn8=aopojU}U6?D5 zlU~+dOkwh(KYTI&U89UELJ#AN9JK{UC^snzwSXGDDFf!ofcmNX7{OD&_%syY7Uf$9 zCJ+q zl72VP&sEr-Yn)?TND1)CdeKxgy{I0F7Wp Ac>n+a literal 0 HcmV?d00001 diff --git a/public/poses/aqua/thinking.webp b/public/poses/aqua/thinking.webp new file mode 100644 index 0000000000000000000000000000000000000000..8c0bde4cf6723f2036ec10aea6b0e8cc77bab063 GIT binary patch literal 6150 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 aLv~)5bh=_V;Nl9L3c;{{Q;c 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 literal 0 HcmV?d00001 diff --git a/public/poses/eureka/concerned.webp b/public/poses/eureka/concerned.webp new file mode 100644 index 0000000000000000000000000000000000000000..b3c71b2b36765a6f27f3b99e2dc8f58b53db9856 GIT binary patch literal 4108 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 KwFb5b0000xeinfM literal 0 HcmV?d00001 diff --git a/public/poses/eureka/determined.webp b/public/poses/eureka/determined.webp new file mode 100644 index 0000000000000000000000000000000000000000..547d44640bb77af37887da9f0664c1200dc6e37e GIT binary patch literal 4090 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 literal 0 HcmV?d00001 diff --git a/public/poses/eureka/happy.webp b/public/poses/eureka/happy.webp new file mode 100644 index 0000000000000000000000000000000000000000..782a3463c308d8fee7d79578df0b639b0e16a549 GIT binary patch literal 4146 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 literal 0 HcmV?d00001 diff --git a/public/poses/eureka/inspect.webp b/public/poses/eureka/inspect.webp new file mode 100644 index 0000000000000000000000000000000000000000..2d7bb798b87d169e4d2f0a800ce6e5e4dced8bbc GIT binary patch literal 4258 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 E05jVs*8l(j literal 0 HcmV?d00001 diff --git a/public/poses/eureka/neutral.webp b/public/poses/eureka/neutral.webp new file mode 100644 index 0000000000000000000000000000000000000000..a0ec077ab1a7a05240940b9604eab0098cb6e84d GIT binary patch literal 4172 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

r~!}_WJ2~-YyYDC_= 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 Ynt1SnhuN}%<~wFJt=t7PjsO4v09b*FZU6uP literal 0 HcmV?d00001 diff --git a/public/poses/eureka/surprised.webp b/public/poses/eureka/surprised.webp new file mode 100644 index 0000000000000000000000000000000000000000..067fc33cc1436d6a1c808da9bd5bfb59f50ffb4a GIT binary patch literal 4188 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 literal 0 HcmV?d00001 diff --git a/public/poses/eureka/thinking.webp b/public/poses/eureka/thinking.webp new file mode 100644 index 0000000000000000000000000000000000000000..4789ee162edc8657727fbb5a47ea4228dc054758 GIT binary patch literal 4290 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 Ig|Gks0OB8s{-0hw4m4l&K<*v~ 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 QB@@}~M;-l9c8{{MSd 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 literal 0 HcmV?d00001 diff --git a/public/poses/heimdall/barrier.webp b/public/poses/heimdall/barrier.webp new file mode 100644 index 0000000000000000000000000000000000000000..d35ea9ff7149e10e07a93248b8b428340705a59f GIT binary patch literal 4258 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 E00h-pe*gdg literal 0 HcmV?d00001 diff --git a/public/poses/heimdall/concerned.webp b/public/poses/heimdall/concerned.webp new file mode 100644 index 0000000000000000000000000000000000000000..549719a2ac742972c7aeaa165588e401fcee37c3 GIT binary patch literal 4128 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 Ir=x%X05Mz`M*si- literal 0 HcmV?d00001 diff --git a/public/poses/heimdall/happy.webp b/public/poses/heimdall/happy.webp new file mode 100644 index 0000000000000000000000000000000000000000..a9bdc79b5c061d369057877580aee71dccb5f0fe GIT binary patch literal 4204 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 CV+DBt literal 0 HcmV?d00001 diff --git a/public/poses/heimdall/neutral.webp b/public/poses/heimdall/neutral.webp new file mode 100644 index 0000000000000000000000000000000000000000..ca9ed41fee2233221eac7bf143139ea456cf315a GIT binary patch literal 4152 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 CR38lh literal 0 HcmV?d00001 diff --git a/public/poses/heimdall/protect.webp b/public/poses/heimdall/protect.webp new file mode 100644 index 0000000000000000000000000000000000000000..7daafa22239bdf48fb218c7091b61b19e9a7f665 GIT binary patch literal 4024 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 literal 0 HcmV?d00001 diff --git a/public/poses/heimdall/revoke.webp b/public/poses/heimdall/revoke.webp new file mode 100644 index 0000000000000000000000000000000000000000..96eb32150110de77e10c01b711ab28359ceccbb3 GIT binary patch literal 5296 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 C22?)) literal 0 HcmV?d00001 diff --git a/public/poses/heimdall/surprised.webp b/public/poses/heimdall/surprised.webp new file mode 100644 index 0000000000000000000000000000000000000000..f50c455d01433e88ea2238c5aee4b7c7aef45e6c GIT binary patch literal 4276 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 literal 0 HcmV?d00001 diff --git a/public/poses/heimdall/thinking.webp b/public/poses/heimdall/thinking.webp new file mode 100644 index 0000000000000000000000000000000000000000..9c5e89a59127c4e1e2554e78a8fe2dcf0dffdcaa GIT binary patch literal 4212 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 K^oo+>0001@-4v++ literal 0 HcmV?d00001 diff --git a/public/poses/kiko/amused.webp b/public/poses/kiko/amused.webp new file mode 100644 index 0000000000000000000000000000000000000000..a9f7be7d5bd7859eae86a6fff231641fafe38940 GIT binary patch literal 5060 zcmV;#6FcluNk&Gz6952LMM6+kP&il$0000G0001c004gg06|PpNCO4{01@y-q)*_)k27{%F|wLgn9M;dWF@(tM^w(UTt1wUi~TY zY9S0Uj1XcNVHn22@yFDh*Y)cEM8pK(|2FNPSeU>+ZNrNj2mWaxx2YxAKPSVi*tr^( z67z%1U)0rdT3}ubPe@X`087Txb<6AW`ptCE>X~qMG+1j0LbB!>p(ws)i(#`-y5l2eZ*87O#dG zfOBfYNV)@-g6|FxrO#$yBNz(Wn-4IzK;PAFa+?$5Q zH&Gi#RO2q#2=5W8`mOk+I2?5{1?SX<64jZ74fk$QRBK^dBT|3KAa+B(t>H<17}jIG z0ndpV_YJV<3LHVAJ`0$kui?dh9F01efU8FnbrOc$t#e;q{@T}$f4AQlsb3*>Ls8@8 z12aoo+HY$2Kz#2I$DmeQ68n!$(#^2g-aQ_P)V@>iPL@Zi^iOT^U%O}isokJxspIiY zccPIt4#T1XG8fIIe5Axgh`?%LqS3@Gl3 zsSri2W!wO{lO3hB{rlMoc(vKD)kG$O+I;4LnE=Q0R8sGQ!5ra|51(qbwwr07*0XSJ zK)CS0bWKcws5zJ?n88pIm58l#;M_3NQYxuAn77x(bX4KnFw<5-y9Jo9l9&!^!51vjw^?QS>4~wa2?1`%9JOQhzpkDumsD`g>EghvBM4bw*&fgKW+DO7P){wpd z^JBQMDK!g!5u;N(k2UEW2X)a)R=(8}qZ@AVn_k&uliv4yL@F|gc8D;=NVc{E< zO6AS@%LB|j00@9#NgPBimiI&LrMCJ~M=`Z4Y6)gIr})ILy6WHk<2wR;8hR&I#UA!F z&Uv}B4@2)B{j$h%FvG2X5heDQR5{?IuVG?E)Xus{QAbe{J*eB&sk>mn%Uwy-VD9Z6 zhq-|vQ>HZk4UtrBu_c$(sXMYT;8P+sEY-Rk2FGT&tE$ZdFSq5GssI5;PjxkF_p>=T zH-zGk9>UCWOQI90br^VA;wbIM;NJiQ0}8wH+qw|9c0`T(41zzv39sp0&(rxIQkHR@g-7KsJCEXr;3!d!hMY6TG&>VBvS7DSD76c$VVM{RlD`{d?m z(gX4QOmsEonsByWmI_;w72 z7nMY675)wPwNr5_xHA-*Vj{LC+~J&cQHiL#GrTG4pOj%%R5DJPuYkKcN+z`e*M>{# zVnV(Mb7VN@l&FL}=gE*+5tUG0?g{oaQR$>MvhZ9|mkI5v40H5JQAzC-{1|SiZq}-+ zYkt>Ht&Q<#YeTM9OYm&S<_wNQz<);p09H^qAd(FL0Pr&aodGI*0Du5Kl{%J4C8HuC zFdKXTuo8)60MU4p)0oITCH{RVa?X0`^8x1X=>vIh&;!ep*zfmm2^0D^X@Bdy8}X^` zk8wSBGB?zY5P9S5KjluKPP63CppUp;Xz$i>KE zj`>CIJKJB=|N8yHf13Ys|DD(8_cPeP_9aDH1INrqNNn2Vm^1ea&_O|TQEXH}Z;S@; zKld}xEo~t73BvyW3LGM|jnw7lI?Ao|$}6e(&x)^s`AzV4qcHw-K;oBLlbO!;uYA;) z{MoeN8_660ek2N~u2=Zc@noYxZ3-pmM#Pdc5J+$=^!KSQk5Q+ik0&jtkO&uNxE2{- zwR2~xYZ+hW>i_Pe-Zn$4aIHzX+po?|!I{egm_A{bJ%XdSR9bqfYGx-Uza+)I7;_6$ z)5+ZzhB9{BbtOpo{B;4S5XGlmsad}58}hm+AEH=j*zkwLh{2ANwj_JzsWm~xcP*3@ z|7jjzX%We9YZRSekHD8_`)@PE=uv$Jq%pS_uN1mlR}3^c^-~w>0092{tr>g^jb#+l zqu^>plZ(u~4@<-Qu1N7;AH?#`qT_k~9d>mv;9G4Q18t*z*?`R!H{sx2an>nq=_zLgRsK7DxXZWIbHfYt48AQ&f)zqaIO6g(-i?w zBEr@xfoKYE0|=}K0i~7^DQUM6ete8-^UH~wOf1vn-Gxd}FK_&}uWz!umClTYb7Y6} zIS~*QO~og4IK^!gmRc%@ZxyA|YkHu766U~7&#vx!Ny$`?hq=&&%b6;zi8=Jov+!%t z-!&z8zip!&`xpD;G{7BY`++7AL_)m(2A!GSE~`hP@J-cdDKglmwLxY5uQ^Vg#!N-J z(*qS_t0P4ZeMzM;C_VaW*+(J&Q#7Mp*rmEW$a9wtFwJr1|7({$Iq7j|j37gV+2m^D zU`auQ4Jx4_R^4mJPn}V^3lTpaQRt4akl^jbq&eC?QnR03o5s4bqoz2+)~FDrH&C7FgVwE6t+%%tcI}~#-Imy zYDXF)ce2p;B6&pkbMGQTt~wP&XVgGj8&Jry^eJ{mOfycyOX$|u$A})_6mBG+{wqn1Sns{3MocQ5bD+V=1S|R6bN%q zIQ`)th3;{oVK{!{04*+ajPXIy#SLsJ%Z)U~zgg%8g%f1uAEJw6Y6mblKWflKT$Cw$lw*jK$ToDVS@VA`&!&Y-D$66>O=_XsG#5WOo#16m{+sUsE%GQE4HLSKtjJ6=21$%3kxfOvSM4m1u@GnK~&XhuwXyW3;~dqSMfP*pHJ zz2ZL+_~s0n2J~=Vn-G7diQbzFl}~U|_u0B${~FhMzsEQh*enUO^@QkjfthNR%R)PK zy8@8sj`#3A7!9Xqm7Lv$PVI=mn@nUOEJ+55O2*ZUO;5)hEd&JZSqYfX@<&A`BihT1K35(+SNA~B!;tK~lF&GSN{d0_`WblPw(j-}^rq;zj_ zq5tbq2$_`ciAx~=*bGz^n4bf*Bufi*V=q$T@7F1M|K#KF(#k*PgdXbb+#qT;6jw0u?a0C2;VswfNFqH~%h>G=L6I6ms#nF>2m~<76X*_J!GS?W@pvMFgV_KX zH(c~tIbRG|oYscQt&485@BEbQUk__~Z@c^Eega)vOkDc_yX-;6ybMO*Y8jWL9Q(*d#W(a&1v%`(;(6|r|Vtc(fn z6WN-{9`kmpSc7m%&O33kBTg$0|NZDkn=Y=MHD_V@?jdXIC^P~^s>v$Q$jj22rSL`9Yy=R6D)I3;e$q=Z=s zZRE(iVC3PiOH=5F2||5kj02xJN=>nVay9pVc^B7YiLyItnPU>Q&Z5m#y@@xj;q4?+ zu!ZTX7d9~tZRO1`!0`+LIo4itOBVIXN`?v}V(k3hlAr`Yp9Fxjw&LQJt}P#Iq>(qh zThN2L{@4`*p^2rYI5ABrU+uSSUW=gz@J78czMw^#qIJGWftz|q_6-B82Gh=uGXCz>$O(&2!h}!_KiuIF^0LrPkBjUl32XxT=ifOqRp@@Y=EMGF4dBGAzIme& zdP-%yYSHIEhSBh!vx^0Dg@duSuW=j2X&F@m_R#Y9k(Yem%3I~#*T!~jb3%G)N>3B- z+CqYL{!73OnLW{!4pXpx@OqEu*((7NC@b;TWEg(tDp6HL%&c>EybJp zvqQ4`NgH>bxEqv*&d;ihb;B@0?=i`4OZUfUZ z(Y_|F&SiC<)whBbi|qjZ1#Vhm0ZY+v@nVB<2~I z+ejlpE*1C>Qc>=9?R+5db#HZzN0|@e!5js5AMrJ`qMc1}XkL|%^KT*JUx*8(IIa8l z&ZeCSe<>96=3#3X;&}P$4YA^GUv2@#S{G~!vJ04jAq_#exkqHt?c54lv{C2R+(zwJ!$5A}C^}W*nC!1|2 aN~1q}ajx0gkhE)cGFAD587xjb5C8z_)8DiJ literal 0 HcmV?d00001 diff --git a/public/poses/kiko/annoyed.webp b/public/poses/kiko/annoyed.webp new file mode 100644 index 0000000000000000000000000000000000000000..b2063e8fffc485d84ef5b1336a0831763248d17d GIT binary patch literal 5060 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 aCYIju3{A(P=sz`g9Y?b+2)k0WzyJV=>*R|7 literal 0 HcmV?d00001 diff --git a/public/poses/kiko/concerned.webp b/public/poses/kiko/concerned.webp new file mode 100644 index 0000000000000000000000000000000000000000..5a1b9f425a346a0d9438dd1df739ddf78cccec3c GIT binary patch literal 5176 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 literal 0 HcmV?d00001 diff --git a/public/poses/kiko/confetti.webp b/public/poses/kiko/confetti.webp new file mode 100644 index 0000000000000000000000000000000000000000..9610c0dec0ae8ecdacedc4ad653e6c7bca7893bb GIT binary patch literal 6760 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 Y`RAG6RLaip^eI`V^nUN*(?UQ10Q<*FbN~PV literal 0 HcmV?d00001 diff --git a/public/poses/kiko/determined.webp b/public/poses/kiko/determined.webp new file mode 100644 index 0000000000000000000000000000000000000000..d5e142326a05f1cc874a39cce6e4eb6592838214 GIT binary patch literal 5218 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 literal 0 HcmV?d00001 diff --git a/public/poses/kiko/happy.webp b/public/poses/kiko/happy.webp new file mode 100644 index 0000000000000000000000000000000000000000..a777070bb6f929e73a2793ec3a5532f25133f1dc GIT binary patch literal 5266 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 literal 0 HcmV?d00001 diff --git a/public/poses/kiko/neutral.webp b/public/poses/kiko/neutral.webp new file mode 100644 index 0000000000000000000000000000000000000000..8ae269992006c7578549edc6f50699bc16d593e3 GIT binary patch literal 5172 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 g*)&Esir@YRhSR_ik=Ac9Z2LP` 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 OIK9NKC*Fy`00017w(U*; literal 0 HcmV?d00001 diff --git a/public/poses/kiko/tinker.webp b/public/poses/kiko/tinker.webp new file mode 100644 index 0000000000000000000000000000000000000000..e53019f82757d82ff1a4f79a85c5b085dd9871f3 GIT binary patch literal 5458 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 QjG4~Z=eNo1VK^WF08RTptpET3 literal 0 HcmV?d00001 diff --git a/public/poses/libby/annoyed.webp b/public/poses/libby/annoyed.webp new file mode 100644 index 0000000000000000000000000000000000000000..d4810a1e82777661d56b0b4745c9dbc86d7ac8ba GIT binary patch literal 4944 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 literal 0 HcmV?d00001 diff --git a/public/poses/libby/determined.webp b/public/poses/libby/determined.webp new file mode 100644 index 0000000000000000000000000000000000000000..2170715b9c74638829ab13c13ff32649e014fac1 GIT binary patch literal 4944 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 OVPkJfC*3nPXaE2&X0y!z literal 0 HcmV?d00001 diff --git a/public/poses/libby/happy.webp b/public/poses/libby/happy.webp new file mode 100644 index 0000000000000000000000000000000000000000..182ec8be6a8df723d3453f0013350f0521a4c313 GIT binary patch literal 4816 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 Aga7~l literal 0 HcmV?d00001 diff --git a/public/poses/libby/schema.webp b/public/poses/libby/schema.webp new file mode 100644 index 0000000000000000000000000000000000000000..2dc8b116bdfcaa15513a97458cfed3a27233c7dd GIT binary patch literal 5590 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 literal 0 HcmV?d00001 diff --git a/public/poses/libby/stamp.webp b/public/poses/libby/stamp.webp new file mode 100644 index 0000000000000000000000000000000000000000..854d374c6f33094bdb9fe3743725dd24a15e89c3 GIT binary patch literal 4774 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 literal 0 HcmV?d00001 diff --git a/public/poses/libby/thinking.webp b/public/poses/libby/thinking.webp new file mode 100644 index 0000000000000000000000000000000000000000..9a0e9b76d1dcdf2c3178fe1aa96410fc776ac19a GIT binary patch literal 4814 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 literal 0 HcmV?d00001 diff --git a/public/poses/nibu/amused.webp b/public/poses/nibu/amused.webp new file mode 100644 index 0000000000000000000000000000000000000000..62f3a160ef2925914f552bccf6fcab258ddbd288 GIT binary patch literal 4604 zcmVc-?Em`M z(FeZ#ArKfUfl8=b;8@+=-Q6`%Z`bSYGSuBQ>vm*)2LeOg-P^!0P;Vd*KJZ=~&F}Z? z)$T<^Oo0B+ek^hkvG9*ZgqC4MlrKV(8k-+}!EL{vH+Sybc~4z``W|bBq=>RbNcEkr z_}k}0mi)`^^?NKQDasWg&A9Fz19*v9_`}<_ucRncNZWM!fNhr62HZWSricPXsP*&^ zY?iSNJh#6T#RX){=P+rTN6HYo4U@XLLXlr+FO%g_itM!9Yd8Zl z)ly^@x)_tA`DabJH$Tq=bD65hDfA0WuKg{QmFlnMW4aeVFmIyUY332ypX|-?iaZ{{ z8F}F$SLLC`=UFY%Jdj0b7xFiAm1F1vT!6_;e*2@P>XjnARAmra?Mw1Ey_K@qWLNX*yIK#I!)CC#B9+iD zN`iSrp>%5#xERm%`?FID&6%Q*=CN%KQrflWa2a5*i6W)Y1DHaZ$CUPUm;w$B40*0f zD0C`iyzfJ7-kPJNI}GzPy=E8FX)5i;U$7Z`nc4fbzwzNQQlzEYZJ58^jWdG5I;UU1 zoFbHVgwE=7d?zonze1t(L%ZC!{)f3;rb<~xX!d}t%{=8==_s68ZAZ(V2)9w654)9NzE%`O80NLB$)FRX@ri#6z0Q*60N^f8qE2M6hfzC zit|=oNiO$Jc`%17Mi**h3iKDHSWT7)`ZE-x3hhOa&G{)jf{XKgAM+QDex+W4KKUo6 zx6-IW`%~clod&j7jOsRA9)9r-=A=<6CJSUPc#I)ejBKT;B3*nsL#`T8XwCo_d|90B zaRPgQdH-*?ELW_9xol)@Y&zqm`RSU5V@7uVLKh!jnuFFc!_Ct;d~*sryJeoMG}_SV z7&dQwQxemw?E)7lMzsfK(9#4hNk?sZtBr0(9|kjYL0L}K^<1FPsH;~NF{4jGr<$60 zuBMTPu7g2KdJ31q+^v=7wG;`=>BCOamID@TXJz2~*{U^^V(2kwtJ2+g5z~`7efdw! z^$MjKnla6|Dm6dB3-!WAIKx$%fi=rO=@)Y>vMIXTtQB?=aXyk&LA4n4<_? zh8McK_72X%w6}+g6-v52MEjhj zJQfu?=1a^;(y4u%IY}yZ+cT{Pk%B) zp{yemjY?9zxk~EIy0VYZ|6&4GP&gn64FCWzG60H9#e&Hqo(pRrzSUYdW!@O}IL`%hpG=-=W$@A{VKx9VU0e&K)4f4_g) z^~d%d`-|-5`q-vy7sd$iVSB+H1WFUmio7TGMqY;C*H=oy-`z{$^Zk?I(^}(!u~USW z8baB8QcUbnH}sWMJg2s)2EDIqZqx|+%dHd%E@~>PfbhHl@k_d0Q|CQ$OAL(WWV$hL z`1|K59OsrAKOYoWfUDES4~rxfQxDfDI#XzW*D=XHV#o*gb}6__xU2E?A2v@?LX)|z zJq5p@N`ZwqAzVQgJ_=!ss@UPK?J5Aj6y*l z=W849Y%#^UFnSty3L zF9s!uC=n=oM0>UXf>=`i7yQ0SpX45GiI9%P7Q_Iw^L^Q1NF223T_KFmc=PJfuR2Ia zIuZtpia~4;Fs69CJhpTvP_^Vm(FPQQXDm#iil$mK+$9ci4&q1edrwe!P~olYY$^$; z4y$8GXH+x?Ai(W%^O2yosvN~gfF2v~vRi!p#5J#dKDvbDJUV$+mmFptz8P#_iT=Bm zpJ72{D}|aSD3!Kv@$aGh2pA-z)jB!!NF$5Yq(||#5knDGp<8B67_gu;%z-h^l@U6T zL`#)X9#AXF2Ci)r{zy!e;;hqX4m6+Q7f?y@;G?I~6CPYmO}tAe+Lw;T*@&ss-Stuw zM$dMVNLh$gz@Kw~Yp3+=d!&B$tgiKrDd)|7aeLF8=0ruPdHZmQO25X*TCePLqoUMw z_U&JGv3={uN?i4;!Ve`|5>UgEjJ;~6{96DFJ^=`!Y3#4?c!fu8DD$UNvXkIn3gF&q z3AxMSgO8@U+C2Dr{*R|UQOQeprbL$lVXVeVY?Ebwt!w2WnLZxZYB?9-1Hr<@OdMSJG--_n!{4`yMBw8^HcnNllHf}P%pl(fpZ5>xxXiLEHIy7 z;c%B;)}5sOJIPWRVEza6mw`WjIT{%}+`~c($3nb$K3|~>_@S0|+wzP(@?&W@Pe#Ui z}}N0Hu^P{b-;(iNh+ zuh2wO7gg&*<{iMLZFMO>JkUiYhrM~0amvjVh%N50vu)$_3T108~`@tP8Gv~85u z-FTi%kc&<9>m(ypFUT77WtjrwIUhVo>uIt=Bm2XnjIkgF6DmFsy(=+We|$UKLCDbe z&`re&J<+Ez=PRef01}cm^r|9du%ugiyqY<)8rocc*M2I24s$!uQ+EGq=#FRKol)3} z%?dnPc^Hf6tU@=QP2q0{0rXVz1dz|EMypciTr)1)_!FZhh2Fg!82419UH{#r2Yqy{ zx>k6fsNYW`+NGAJb=Td$_S7z=mXcqwzKR2QwT#Cii^rxtg9oz!HqUF{V+C9Z@9|4Dj1E=me&@-#D#VK@HNr!AQS+cfV1-rHHUJ%%aerYV;))7fPTJ=2p%FE zian%yQ}WTJzEg|*_R@&&kFddCbzQwfF1e0Zngc4>d@M6JJT|8hYLdnuK!NVEupBPhI(5{I3K|zwf{;QT zssPkcLbo31HZggvOi|^9w?XyoQ1o?>1eE29vlRFkzDrDky5}b%M8h$J8+S!cxvX%h z8h_V=p>67N@FpWzgsHod)78CT{65L54TTRY*=$UB;e`cMyRc&EGzrPuOfu;XNsB&* zj~w=Nn-onq7hK-*C6gi_liaG9gtFK2TX9>U>>c1GN|T20+X@#$xI!^c}NizzaGaHCDDZkHQvMp+LX1?MZiLTj8l6AKuErz1H31# z6;V7yLTY(mOF~qx>87&8^;!D4j-RoTx(85HMu>~ z3zvVyxGwpsCt@ol36uMK8VP&UYLwg(Wm|ZR);Pv@Z`1c%yC5Z=Ar^5a-Lk%V)>Cz7 z0lxX}Ywu*-E}1~rjxlxR7g`WOofha+lV1n}PEWu(N6Un+uz74~_Q`cZ>%5O~Vz{hf z=qVg<7rt~qmdmAF02x|iF$k*Cf6FAiq_2R-uX z^MDAJVq}N#MD8&Zb0r-Jp~H8Kkuetwa`)kC*ZKqlwSvn>f)oRzt*+aiLSc)QW3&8h zL~Hi$ZMtqaYf&J2JG7?+_%l5DwmS@nAZ^f~ROvUU&UZqHJ1j>%PxI^~zHrzZwyaEh z)F4bu{MchW?+d1jXhp?xqlM5x_eu^>wZykZ80CC%5#el4w3k;b-S2JT|7$yqDySGq zv(Bq_+KA%5M$SYzKn@AE2Mv6{Wa&Pt_i$M(z0#4wDgJM$(5Ti_KYyTkG~nQ0ZxY+iOeQYh>Q~0* zFoFZj#t!shivpZla*`%oWmvnErdVJf>zpuG`Y}pC=}L-5gzk-5C5=ej;$APA{u$uE z_kfrUH=<6q$JV3FdXQdCQ>-`XuP;TnI~oEX$nM0s20a(NN$Wp!zHTin1tV7D+`Cd9 zTNYgq8seHcF^2f~cgfRjq@bNVasH!59qMx{BpM}G&K|ozr%`y;tlR#E{BD+2%z*%_ z5M1g?tn`A&0660p;8PX8q{a$AmB5R+C~KCd`jbZX^~Nfn+U?&zVxDtmop~9DVn^D|CvZ;j(-`^C|Cy=DM_Hk`-1c=`iz3b{8v~yYbv3JORld*w zg0i@}D`{5C`Ef87?_U{=7A=*RUiwe?><-(H8{@SbDU@mLCR5o{c4dT!S2|TN6r%l>C8sEPwgm=4 ze12d#ReAAvFbv|Gww$iqn*f6#zGTZO%fk!65Qwu1r!EgC!2pOudsb?ed&hzKq57od z^yT?6FgN0tmUEOJuOV~dsDU$;{{rR(JY;2Jdo!35F)n8-Z(Re9%mbLRva$R(5ZMyP z70$YtSaZ}-#~*)qC6CLktlWRMkmR$#^iW^OZDirUWk76n_8Kvb^oc92+*ZraWdObs z#T(V+h<%qGqP*11V51d8e+*2n^Uv(2And`R$pJHA=%fs~VK{@z3tibThS9PcEU#AeIU$}>oH%8Es@C&46$;|2~^o+T0`er<))?Ry+d z1gOqfamaTZ|5+m8ha$1vMoQOP4pe@;id2aoTMn`qH9QDRgE+r%l!KcJTk_>ZvePbN zkY^7cSYG}-65fBSWm^HoN`CUML1akmKXCT)EV3jvJHp;rCcm=+@J28LU?u|j`aGIF z2PiMBLHfj54)QE8gDb5FmOl?BL>x45obp^9NnK;bVfmY2GQ>eEj#OS)gM@CfBC*^76Cn;CI97SF zfn@HmVzK-)FbU$+!qLj_HjyT~Ry3A>2PQyV=8${)g5_b&b}JzHF9{Gowj9#Z7|L(H z8|G6_xEtuoU9!mV=gWDhZVrBAc(HOk&(YtFY~pz)~)^Vj4av z%bikC+a8twH1ERqmO-2($1MNt78ib{f-=5jMRon>U3hD)l-gA-R^OI?*tO!B!ISn}wItcc-4f9)c-(#nrSQMq?Qeo*ZNs|OE;Mehd7bkg!D3dtfg{OhThYogme{=@>gJl4XckM2SDJv5Be&ClEgD91_+RDf%&)GXftfXJ2vPig@~Yh%0AiS|dFXs@0a zuOM&YpE^e{&;0&}6jo3;AUF;H0MIl5odGI)0Du5Kl|GnAC8MGtFBM!kuo8)40MNvV z(_Y8~WBhi0^n14>o@;{&lOE>tu5-;a{vwxKSjpHAXUx0g)>hq62vv6O=&hz*L zI}Xtv`uE!EBjImg57jS5f8qEr{p0?Buov_1?ceu*$$m`yn`fEoA7Jn2Kit3T_7MHj z_FVmnaUk2A_`j@v9euTQZTGlrCu2*I3x+Gno!a?tR3NJJQxESRMcN;^jLZqC~ z(hA;|k+q@)AlMpmqpw6bOUpbr+E1EBW9Je5kNozVDsC&%4=-&*ZJC$S;9C*>epayy zR_GAqcM+*(-a^M3s>gL5UULApdMh_Y1x%c9_RrZGKr9ryq8P*RfkKF%YG+eW(;Bs% zW2tz)_<);h{Tc#5zDe`SP6PM$OR?);4wZd-cSr)S_8>pp&6@Ah0NadJW-^K1{O{veS zgQMXh_7Xqm;bH~)%Q7)Z%3bjROPK<)+ojC_eU7zWZG3;NtCT&HMi99zu_n(=m$_AJ znBZW0rHu_sGYs-JDm!d@4rCR?hEaP$WY+(rIYm~nZk#+MnbJ)T+0vdiuV_p-`H^_H z{hjpp4aV!bQ46&PWDln>>;Cb@%DN5={_+Q7m zkSnSN3a>E%7;_-Z)7U;E-}$4rC2QseooVq&?PGG*#F}xLfsR(g$hAp_)joLr7i}6b zSgq9-5zNRII zh8Ba;;Od(fELG$Jo{xI8Yp$92w5PCk>PKn+(58t9I8|F=>`N71fKP3@JK3G`x|iLwXye`Va3e9wUDvfv0 zGXj_4oBF-V3NV6@!+i&g{>6!isb<$>r8Dle#yS!zXOy`nTpl?k~084kT+f%~6 zX&Zn1x^R)r=RG@*af5$H2WK9_fnrDgche_2-?Fj>i!s^-;IPJXMy?=vmHS7)-34%C z*|t-e#BoKmw;%}c)V(pb#o2Jbz;~%x3NBRnK4pU|8h(Tqyk=b z8^ox|{`-K3ZixsCe7`10ASECWj%1zm-`{lF?!qh@_*=8Gqf|dSt2HgUMs9NI^@)`} z5l?IiCF@nHs#Qk^<85p)JyLG1^u)C0Dkez(fQ5nW)AmUF3qBp;1Lxn0LF|CJ1!dXq z&>BIOq<5p4r;CdD^Tt(6!&-~qlNd}Ht*1vh@@2ZP| z8Amfrbkihp=02a}d){vl5CT7~%3GTdmOG`NvsclJDDhI&O7ONgK+v>4gmMwudkSjV z5D@}oJ_9PTtj^oxG3-D+pp%7+%MryEsn0t6B$ysn*X0BS+nqoel{{#h{B_KnHs4T< z;~W2oo)Fl3*PFQ|^xfqR{Pj2R<|U(fG%fV=A`G#^Qe>gb6KTW=2~g}xy6IX>*qFy&^=Zlc%!T>V%98(}z_$%6=H>N&@7UvbQN7*p= z6-axDlYEa=JW>0K4-li#2I(6}{1e1M-C^A2wMkRGR$}*aqNnur{0tfcxLw+Xlr7Vl zkc^;@lsu{&uN6fHqhb{YGJK!@xUVADg_rAnrXkfPj2Mz-EXnLznx*csLDY{~Z(A-a z?gk+j>B8_+b9}tn5;E`6+>BZu*&qN(R0&TfD8wDxSNhzzub1cmvd@jWZ7I^74B2*< zs1!~n6Q5A3e}t58?cOEP1x;vkCIxgQCznR88~nuUR2}FpqJwb>7=otPsy=c?yF@AP zTDCq5AQGw_98+uNhdr(*zhnmAUZHYYOhEzTg(3 z@k(&*B2?rk<5#1GNOJ;Vz>1UE8w(FVW)j;solyceyv;wgBFaYc7PSo_^Rc$m}2J@`0(u5$KLF7sT5q=OWnT7RsPYT&K3RC{Tu!}5Te z^@jhu4uHJ8WY5Wz^0@|Sma0+!^5Vn%f1G?lqU=-9)!aNBr2{Nu%l`@9WkVHlzRr6< z1&OK+NrI8x($K6Hm_xSbq6tP)cTCiEUPH3>=q{Nhv+$XbIua}%;3e+u_LoC(XW^p> zsN6(XMy&Arx)zBN#R^~iH?tSgMkCfx3(5Z#SCBL5`cQe#eu$*wGlyMtOIJN9`71k` zYq$k_*60knSX~@49@Q!N1tQX?9Q*OTOXDKJ7DE0jUCh zjzY0M`*oNVam%4EIX%Dd(D*B@sMt>SO+DG2JqP7c&LN#l3tfFN2Z<=P@CF{c97H#z z=5ey(5$V7C#5gc~FW2ggU02t7=J0D?Xb!hSc@XEAvYyz%lp*-@C=gtieKk{3y0=P4 za^AtlfjW<=u&1%2`s#t`&~T}rJDs+5hF+v76e&(lujiU2H82E+?=fu>Qk@+ zDI(T$e^ivI5VQkw%s;8^ug6jnh`QVsNUZ&#+OBXw;D)4c;qg;RGJV3F=OCO+B60Lf zNy$6Q;uY}CE68?F@I@_V!g_`EfdZswvV|PgvYJZa*0<02Qp(|l5*1xB%qL%8UjVJH z6p5og%j1^PKdJ^2wK26;r=H;#x#U?}@XOJXonNV&0%$UcgeehymZ~rBeai(Se%CZS z$9zW9<^CMV;{I7Y&!&+h>j3tU)Q*)m;gXvgfR-NP+3Z;Pa8TwWD>2ZE*Ojx|x4Q~v z-t*CaW{ynD1HjOBoyDPV(+A{Q@AiBkDIVml3yK_VEVVB0+zXk&OL_^MLAgMEo_81L z*v{!w84`H`)bfJ}8^MsFd%?j8?M6KTeHB#BJj_S!hpd++&+idTV0pm|y zZ%yDsByE?;FC@t2t}s}RM{u||PhpDXvC1A}Nq1d`T1MaGkaR|G_%}^>Vk`a$*|pok zQqUTbSq@QRazW@c$RYnbkn0ydOl+Lxk7g?>Chi^K4IHiv;qw9C?yXjPC_+}w`;ie# zUO1$v`nI58Xx+n2={`S-Hp#sM!=nFy^C--xqy($>>|E{3ZQroX74O9a1hKhI?HJL)@)d=t{Z)q7WaPzW^FA1n>^Cd(twAQ9RnVND;l!C= z(5|R28Q~2m#!zjC*gmQ&aT`00jpF6wG!rQ<$bAe}^HwF~44bsm8aKM><2)v1iSBQ9 zwk8vPqSOF6RV#`D?q`tFFA!q;BM(G5ofoCUh>H(D727`j~E<%sQ)WanDper=9X~q#3+`S>KC=Fc;Sy6=t zEjCSLfe1_)B>@v(m6_x~pVUiU5G0IPN6smRWU>&Ob(b@Q@n*g5Yfu5Yi|KXQuw0o=f}|fZt$RUX Yo&Uc-Y}@kK z)x!fn1Oh`~*+AU~Qd@RZcXzjCb$54n+wQ)1EilwQFjND9S|HTj2dYCLJn$SW|K4}| zNkmKl{%?~cmqU{OWJs(+lK(770GzqavDf_W$jEH?BZ1}(J=wNa+_t|NKeP@oH3IYRnX)a(D=W!%?~De>yohywH?SqIYT15s zK?Np7V8Q2>yjmsu$tIW*G4eZGUhR^XX_v^3F4=g%a=di08D|H zH|3CWD~-QcAuKOIl>jk&;GpvRYoMytDn`4*3SxVr^?L91cW#Y^Y41l`TK;%iB;p`P z*3zD8f{}h_1zPU*kx=htuvZ{r_R!(wlS3`A5;y}@pqo;-91H`rQus@#$N%X)_s_1@ z`)Aj9KZOfWMIq`rTnaJwO2YDB z3@mJR*OJqeI~9s1Hu%bNqVgGFQ9!+$irjCYaN=+$D*0@%FaVERSxD|rL-EAyZ=7ZS z7dinf4i(O@lJEheBPgF}Jeh^uts!D{kL4ugW5ED7SdKsF+m?M$dBi@J!^`bsEqU@N zluwMkX(e#eMz-5cusi_kPFVJviq)Dn`Tl|mL_}ib*Y=w~b*%o#HlrXBlf=nB?e|}bb}R5z9SNbjB~A(!OJy-t@kppeE=AP*vl!(&me5e%E0y> zU<6{Tp>vemM2vf^94sFSmPc$OXDN4Th&XO#VR;c)8nK5Hm0zi&c($!PEFTM&MJ%$M z$}4pgKCPdNulsp6mJbEXA#Syt&Z8)oSod=$JlGs8=W;6_`GRs# z@8#4+sl-9fSGK=d7cAu-E1j1ImJcWeqn|rl`JRr4zNQdj4=2>R$wU98m`kjDo;%8N z8)XvrX4ISijr|JCK-F358|Bhp`yIPe%FK@~>km`8ywjMlTVM&qy5C#={Gji1c@7iy zt;Kv}`MGr`b9oTY*$ayL8(z0ExfRd(o3T>Xp0s=u?#bmKO!!HZTI+e}^KeEco?N5$ zjt>P3!MKM$1I_Q9raX?OU#?InafXLJ8F<{v<0u@Zt-xZMQ})@wf;8ker{U}iK$QES zeFISITFV$tMUpIE0M;v%OKd;zgQ#%8a=vLr>V0na8z`K(!>`vt{J~nvXLPM7lQ#_P zqrk#|dfQD9(_XP``GSL@$akD={|-dy#2WnzKw|TcEz1w$FIEKk%{88MdjtWAwPyn$ z=6z(zA2sS7D}blwjk!Gv1^}^4VytV)yI`ZfRh|}L!7pkcVsa@E)Ba}p8epTIRk}|D zD}abi;GkFBsff`BEf+0c0D?qx618K0-9UxrgROguZQXnCz4wg&q=p=cWs>;kO=8(B zJdyu#1y)cvAXp3l0FW*KodGI<0C)gCl{}e9rK2JtE*eWvuo8)60Mabm4HT@gia8ea z^n3m5cL$i?IWJ0^$$QKGR`-TKr|@^iZ@+wL_Qc10{{t-O4?ruA_B;R%u8l=JALx(z zr=~yb{1(39{^!^W`WN?)L|pK_SO2%%Gxg8@=dRDO&)pwopV?;IYoc$urprV@w9D_{ zvgTlTCKGwbKfb_Sd4(PsXd3$Wbg+RfX?Doe1PtS)P)OgF1>XtVdBp6#uyH&AW8?U2 z5v%`T8uB%#bBxE;wXS}vRYI>H1ikX4FaW@hpD3#zXbtf-mDrV=k+DZuW6e+;0B zmoFkI-Ip`!qUj2+mH)+`r_Ze#OUR~Gn$A5~#FsNDgOEY-ExYo~ZAc@B!G|P?p<~6oqL&XqB zZ7_^4c2m?*M#Ebc)O>1w2wQKMGKf&`MpDY$)*5K>e13&XO-Z{`g(9@FvZGasY(ayS zK<^&jfX#=NC~|w-OZqC0(~~SL6$+?hvIE`-3+6dfbZowe_0=52*7L(L=7T5M_(>1Y zDALKpwa5t~Q{R&vFgA|^RrAGy5JDn1G!#!Am3gC2nfX&6HZgj*l!?1H5I+=*G`9ZTn~&A)YJ#*Y8iNL&TU% z)^C?Yoqn9-t!~b_h69jAzM?;+za(Cz^>J1?WGV94od|r4b%f`cA}ITYP-!InHb?8QakPOegw+){=J_0m5C{(hFpgW+b*XaMy-Md zTQi&J2RdP%W}l6HTo#3!3}Yfp{^Lpz{(mUTn!;g)RI&nJuXIFMyZLf*+3AU*Sh|tI%Z!^ICo+pb0^j z6yk>JyVz8fuu?+&RZBm^5X#7S{%GQ9t$7Gk<@FJsY&`1%{w!pMb89qlrnk zVeYGF{4LmjNIga<^x#F1LQh39cvI&a0V-i3v_lFE<+yEEvQvdV2GUW#by zMv)L!<;7HRI04xWTf6z!Y(!hpcwYT40UR;cpoDD%DJIYyHVY(AGldz+vl11T6|q5u zj0dKSG&E?zSknms+-}javJq(7(j=l{F3H}09x&tS*}UZooa^77lQr2MWNp;S=wc!D zPFqUN_JK^5o{mh!6R^TfPhTT2#*0HiU`0WyJ@10Zx$2xXqI-6LS8aHAxo)5@1;hGO zd||0*`a~44(|Z-jU`TirddNj4#TZ8SO&Kz29u_ zeA_a%4o;q4AM)+W1*y;X{9}&5z_iIa?9-mCgl?s-O?S>PA;Zs$p;9Vj6*4O>_LFCI zQa6ovU%3aOlZC9C?~|X5Dv-di7j=KpKF{=YII~1mkTR_pzJOKlpCo^mlFW}5hHMa$ zKwR4Kjxrwr}!(jZEiU268l}(Uw)W2+$FbHx}Jzg!57Llcpz8t z@I#2oUlnDDv`tf=KTOhaZ8DxrN>vDVrlwzx-3}oL$ExDomuwn#R#b!qa0W`XUWOK0 zpI)#E<}&Nbb&I5x6#8$r-^c~ZhIK4Bw^v=j1At0o(ToUeR6MepPmpArs79mDP*C6p z!1*bU<jZ=B0I{{`{u9Yy|QMm|*AdZj0wn|=<$m^Od ze}TFL#E<;H*F)}jz;*yWqGr)!FQ3+n|@% zd`8VK#2NT$(|5)@DcK7*KT0+_ccyr1rytBV@%@jx9}+ikr>#jCHcNEY;l1pdo4h(=_cpt*<0?y~OkM&^6B*;q62C*{_=1s7oF-ob)i(QkpseP^ zC!bO;UlDg90FKP^P0?Q~=Kz{`!*hmSpnn7Q%l>ps@iRyB6U%C)^(5zg83xxC4Jxkk z!peUAg8mHHglVQ&2aDN*SL%F_CbwM=;9j)k9)V#o)1l%_ZNCQuNGRf)XOI_yPaJdnH1`0X zY%cq4qp;QNgm>(I5f_P-SUH7GGxZR+^KJ z1=F6bdf1>-e;=*kX&`$WCt07M6VjnpDmt@K{6|Eg_3SPC~b5nKnOs z(liP6cxZl`iuKnUtjj_0j*3!7tssBdjsl_|>-f4l-PamxT2SNajRoaxexb+~6FpA9J~d=qZ^%*)dL^ zBe?VaT8!?7ys`F*bAHx*9b0N1t7;^gAGam8YsfH7a(DPlSN&}xwS)45|F{)} zdiL^tb!jeXWSqvreF%E{B}huS6k!@e&G-sQ#>I2n{c&EOFc^n=xt?+R_L$WDlh^c;Y}@i^ z&IK3mj}QW3AP~s9YhW1It+yd{_YSQ42G*tC^{uhCGc4w z1TMIKycv1k=c%_45fgy_%qGdLkmTPL5(~4+Qln9;mavfgJ3#_qqkYAfe%l>wjK>@6 z{on7pu3ZNJ{v9BJ(hF|7XX0&-e|>$E0GT)fjq7`|ZEe%GXZKuM1DG-rM||)vwk3Jn zl5B6>+5nj>qIC7xmb~MVy>VR`Occ>vvn2&f{3-M0N1(xrxgDD~2E*D$AzXqm*Se{tLvV2z=2`%4mx#;qjz*G?L zv0Qxl3NQ`C$rHzU@VQ_jc3V-%-7=Vuj98gDQu!tj@e^ONBC+>9_o!B@_2Oz=Xp9~4 zzJs>B8;pp!$ciX056g)9#|fAm1x8dKM`vp#BBJdCYB{R+SP3{Is&A*j?TC=|5+I)& zQ+@3C!MM0VDbPUF+B0*gmt z`7S3?)~#9bkX=8ulm<)wMX7MLA6s%V~8%P7Q&^*y*1f5r9~=QYQ~e*m+{t--F*GWa*K=2j2j9 zj5vAf#7lGE6y#O5JsYJ+dE1sR2y&4n_r77JXYu?mqwY5v*m>gi+~?kzpjq$7pL^%I z?UhC)LrCIdxwpR|G4K4K{ab92!R#|uoYZ-vV_$%bc?2e|vphYjHc5(GSG!lvAw2O$ z%j;F#Z>2---S6u`03aFc_^d>N{MYr&hP8)I;#=_Xl!`a1kRXwu~Lzj$WZ?EXt?c1hL1cl==XcQey=y^^&S~?A6#2|aF~CjzrG8p zBo=1n(#~dEEoCIY(x&BPfXw|LOIA=gAW97Y0Pr#ZodGI<0DS;Hl{%P6C8HuCFd6#* zuo8)60Ml5COr)=QpTi$Nb7ScGFWYVA1NNKN7t-$Yrt>fN+!XKb9k=(xC-7Ga z{8jIFfJd}^19mprZ$OJK^$YOdum_eu?|c(HgQJUWDjYBFw6>Rn%MgSjg5Q%(y6wpy@bAn8H`C_2sz9Jf@OSN z4q-l$REk`{`_oaa&I#@b6*JiU~qv$9i5CR zc&-BF?%z*{|371BDsx@&C2?>*?Kd1N?xOdiT8JAgnK~>R*H`pdeFx+w5ju<|Y-tAk zDkcd(@C~5vI|R^r_$i!z`6$xf8NiiliynqPEh<-gfUn0u_J4u7w2%iV$yVdZP)NXg9Z;UlC@t-1_qsN}eCqxpA^ z{==?p-^ukUl>Va$>zgzmO!?0epRRFUktn8XePdNL(0EO{d^#o7jIamVwT1dv z+Vkg*tlHDe@1b2W zmKe>)`QLHXp&0OgZ08ucNVKqGN2`Ok^qM^X&6U67{(O{5{c#BWYPwqPH&v+9{;NIy z4w{X7ygIaVG7W`vvG0^_WxMbOkJh+57Jb5#t*$~U&AnM*n4R(ZzBK25`@x^h5W3|Ng`X41hcPlv>$`A5{WO*=> z_|A%c!&^J|@R=t$WsaiylWTwuggM?_z?H0N%<=!jz`BKcyQ=^D8okofJ<04nb%!Qpf0cw*eSo0Pyw> zhil(;5Ry9eJ(>CwbR_@uIQlR!L8uxSo4tIh%k>Xh2c5n<8aY2~ zf~9Coa4n5-*{O;!7r=C3>~yZTC(#%WBtI+OLPe%}ZU@J690GhxL(t5J!Bheaa;2~E zYlHWv+GJP6z|b=CK#S4&Bj>L}nx!3|1C!JTDRY*Ey6IAT?4)+3B$oDGKY77WjHD#W zTwu_qK2oAN7TEesTMArOG78ZuBE!Y?&14Z1L3nAeTP;qX6l@-^-=LCeQhs*jQ3Ik? zx%%=wK+vv=MqRc}j@)x%dt;_2T++%L#Mn-ja_-zb@xhUHCy$oYB<^^w2L3wBXmt^) zb^SU>RKthaC^)mDz9KCHss{UveD!3OeM!D5ofKc8CVY~yK5Z!{?_vHx*RUeuJYJ!G zjacZ?yJx_CX-+4GXv-LG&mG%q`utRU<9`t3Z9j)_N4k$!bCUjxM6iLRMXOEb0^rrE zRqZ|wEEHpyBpPH#gv+hwUa4J)X#L+8go~*Ki-w5t&G0+RjZNL5=r~!J@#U)nccmSt zGAr1d5wyQ>I7kOmY&w%SOp?Rz>MC*UR36iGM;U3+;}$Qd5p~MAan@WDZG>akLy9?6 zVKT6aXaTiy9g3Mal89i{n9bRZ6ZpPpVy@$462L>^+B)|Gx($!G&mlcM{r$jM0dj+}ZkfS(z;qb?=916(GtlqE4dZHtGJ zIb#t#K_YZ%vAZ?sh-`yYRH%d7wWstQ?TvQnL`N0d*jbClkwA`rbxJ7u(SNl&uucs& z`V1md+gWmxrEo8gI}UeCG5_gAjBbLtL~PEygnOlF-_Cp1pbSc>5Qq z$XWg{m4}rk`RWg??)~Tx;??uxxGZEHjfQ&?L^jl8BZ0UoHKVDXwQXY3;c}U3)A%M9 z!N?1FTz6Qs_d79%KcnWJX9#$>oAOmR%RciR0 zHL5E(5N-QJ|I&vVxZUP{Ftv!b{5&!FO7hRKFZ}MKr<ZGIsh%#s1{I2yNHJyFw~A{z$pkNJM{~;r;x{$h38`f_cg; zp7(nxx}lKwx?EDe3c~Ehc=Ab4dIC_r<`cA7LeWb-Nx@;-(r4*P#c*F84{_(Ze+(%p z7Zbi8%%Z>^36r>d?EyBnfn^~2Y{B$Nk(F{r+;Oybb5#`U`^Ac*DBxut!DP~?u(7a~ z9(0)6@O>%bbRQ_jz1w|29Id>+A6@E1JfQ*Sn5E%qGvD zxNCdzft3h^QtR6NS=ktQ?<$kQ%gAgD{O7PNnJ}yG{sj|8x+N)1X1ZQ&TA9J%%&Eh4 zNc)=z306yLGzVu?*`bKlAe|FAKiUN--4XbTn=uVm@of^((@)@D`9PzerKrS>mGpJV zh9W+WV(oU!=zdW1oibF zap4i1`_u9r&tIuegy}ovL!&dK%^xXfqPL!vMNZ8_RphzBlh_lG3jtgvVALI`wafbp zGkcuV#^C^`rKDsq0DW{_i0HkJH${Y8*2JG8nzOPukwZ^x3;h%Yc&#@Y_lVCMMKcp6 zTkS8T47ZX-qhJNDHYyu3eLgphL*=ip1cyRi&k%I!b4n^!okIKP<_pmf_fYWMHk_bU z;L?3QcgFb!$>}=ZD0=76%h{dHDJLWUmQR5t|6$_b9!Z6jbM$qBgcLl>9a@1-%Is_> z?ke(Y@qF5pfdqcIebtT&l2u6`6?%(f*uB>Jhucr&elbT6%-@sil}%kx5r_Q^HX0zobTn$YZ9Ae++KWtYn%o0IDwXCc8?xRe`EXB^PA55BTf z=gkW&Ux@6(f6z>NTC@7Qt;JA0@ReUR%sKD;d3M>_&AV9MNxfwpK^;--KmO9j-3wwY z<`Yr1ElRM!F!K;&A22mS@l7yk%L4oj;)&JlfMrbaIau_t@i1y}8EuA48_sM@o%c7y zDu@pGg4A?*KTU2e9DTNv8bA#X@Mp9qD4G2_7a{1fh4P(0<7kA$#Iyb*6Jke}(*39d zf<$KW#XVT2iQ-y&-7%Puj%F&bfIVEl4h(jX1oJ|Qpc;Y}+#D z(FGST)H_h`XMwuAyR6g)*4^EAYwIrcuDfd>bq&l)WdeafpziWnAPj+lfq|DB&L5vY z_p|;dA|^opk7?VRvh9B|wl=<9XSdyU(nf9nv#=$3#rdE5?rje|vU=URC;L;^z5m3H zB>SI%Ep2!D*B=l?XZ z)^;CVfC#pB0R6LN*UCEVZxF$@jG+B)N3N6g)+Hj?))CAft)@oStl%UvP+?DHMy`G|j(FJl%tb-Sbk_Z-$mMbFNPEdC6_%gW~)`tm7Z@Jy7 z=dyJq46c+bVO>KcfYFI+ykRzL#Ud$#zcs3&^;seX!Dml=*Jr=*)zh=7na=_d5X?H4 zbvBu!Z5gb{b|aB!!}e%7lbYFdT$IW>ctrBx^~v5r{FD1CZMrLyO%my@NhQx!;0L+3 zd%OZa&9&R=3S5`Tni!Bh_^g^tE0!woVKvTT>%?(77+r2Thqc=m7O3Jq`zfQXOP{8W z!9q8KcKU7*b)e;?PJ-j+h@g_eluEZI1_TxD%7sZyzaqf{jf%G}qM8wO<>Dm$iKxTU zn4Xo;ns}b5K=8not6wQ6P@@l50qA-=WwiEtqz=K{cFMW;BLr1!>BeW6t@7O?>H%CTmu0<`prS!frSI}cU4U20C0XwxsA@2!vbUS94Ga&K%dsw{ zy1|ttNw*R8>8V`nuo0CFV7Q-L&Xq)Eo*ogrrX1;k$^`e#(DKz{to?@6HRxSK%Wn&M z4N;rN-$Bq*sn)k@123I8fWH;=iw&M1)@0qXIe4HLX?lZiy;P52p>rQh6*NOIKKl`( z9`l-pugk^sX<6Or)nr;dcuiq5`e{vWRFBnrtK8dYxm_QvNmsM{L@vd;jxj&b(POH) zxaThG;<%DtL(6xi9#My2N{8`XS>-NUgd3&z)B;|w_ss94g00`eq||wus6jBhosK?O zF6zV)eOd3{#Nd%Ui_a6hNmk+3PguRD^FOXtfck^vQBw~mUl>>YH6e7mYKb^Q%6>ItMl~% zx2aKgfue&y$dZ-c)_&ie+)j<&G~b`;Z`+yvX3tJ9lT}4q8?{xqt!mmE{7*YpP&gpU z3jhF6E&!bYDu4iW06vvImq;a}q9HFC8^Ev-?J3A=UPTS<9(TKpswi$?!h=-}_(JPk;~7 zo`?D4`mfk8_0#po+k@B#?&q=x?7DEpoZiwE-mR*4>iB<>wR%|Gf>H^S%{QuB+^I%G z?jGBb7ITR@#meB<`nxYEz*d$Fj07UT_p9Hl3}8qw;{ zP*XH2Im7{Pzl)n!c%l}(`$Ef-9e4}*4JjjpF+#Gm#8SlidNACN*tb@T5i|*;#AX~2 z2d>aO+UF#_KRZ8!j6v(R`{p=Zb^qW*BOBjsN8k1eG-cf^3By=3Jihz@(GMPEv)&=- znfxN5hZypU$DqN2{@+Os@Bje*{H+l(mV?SQ+@q#}|H>^L|9o685@~0K&vE>1=AsZ% zJCd(-9OSW;-|$Gmhwgu&F>UGO?@uuuk1w43zeIUYIb|DD!^S5Zfy6Ow!%Q9&)6@9W zHs*8fNoIxo9q*>-Q-f>&z$b}n!Lx+>Mooq!=JFtjQzbkIWyR-H#ZOU(061|zl`pn54&kv8)_Cdg#_o9b7q*?3-Cf+Q7~!iZmc zgLJUMkTdrjZtpc~8(0d3bwJ1D;e%ok*rHXFQC08EL4JwW9us}=yY`3D^u7%x(ND9d zME)Zt9K^HWBeB;J4^w?WV#ELK^CykR=zG|jHpFhmV6Dsn#0%i~h}5p@;zadE(bx#{ zv2!0qN?}s=DzImsBfLTAtiKzv&moj9g7j-QN;oqu5Wo|~F#4ykK}2%-Pa8qtxq2x0 zd!kcxLF8!Tx|5U428l^-BS%v-%B;!qN)8kW+=*e?Gl!5TC~7>GMs^HX+?MOXC|u53o(|dY%COc&&)xnj1pN*0Gga7dhKdt zB>2B!b!k#0LUinkU*s?is^-XiG(iaK!K3yhZ~9J9y9SgK#MrJ!c`brJJ}uV}{BvQN z#U&Fn_^v3euy0@JDUv^-eJCed%~57!{DL`JXiZ($`WSx*KP&k^L!o~@{v~M)Xs`vmc_ecrwZ_6WbD-D8lvhk9A(k1GS zJ+3|%?=kiv1IZ5G{98>q=oTax*U6(PUqd;LNhG%0~ed2j^=kaU_CiEWIQ+5*f7SLS!}^j3v{v zb(y6&EiKI)7V0ou!>^CP3?lb;URU_zkJG4m9PW_{48dxNl2v{vm$L)B<`5)IHDqR2 zlS`uq%>8I#(q>oZAX29F4`|M&0gVl|TMXO>9d!$i07XD{%8(lJr;#}{(XEfWi30_Kz$_(!rHgNZQ>1 z($NWfvk7X+Mklr9&_xO!<9r>~s+pCS6!9BU(-QF0i3bN9<_jI~e&=~2uN7T*GwzqR zn&vM~UqTCux_{fMo0s!JdRrcWsaNLBlfVm*^n+bir-i>=0}2w0WzMZZKV9(=&Ig63 zK=tmWv3|U~*+7U9^C}~1*1;@Gb@^$P44x5;?q7`?GHB#{;-t#oRL5rz^|-m8+ESxO zyYA1xl6f^#4Tn}v1l66AU3AgOvpb;`3RU&u-qqGyjX@hoC@r7%EtLlypO{h3fm&&x zG$}_S%L@73SMhaTQ)1Pb8id}B_rW$QV*ftX=>4WqLY{!ITReI=dTBwVZCZ6nM>3A{ zTTR9%Pgdn@7p9W}pC$hz* zqQtX}G8SYo7(jY-5JH}=n(kK#_8}511(vtpF0px7N;%gWiMl$_t-kTs6^zQIf#9FB zEeSor6`AYlK{vjCWB|u$iK1}-ZkBc_X}{WLI)1RT#zYiIcp4JO^^!HeDB#bDg*#(^ zQGm}&0|I4kU(ppW(Fu%n7a~sJN4jK}bVoCKTPWF@CCh8eeIm%}%kXXdTn~9I6L=(6 zGre~TWI>0PcSYu1mv{M@&FAJc;BE@?_`QD7`VVZ+6GjPL?uQg}p*};7+(o_|d`R$J z+-0&mCZDlK5hGWGTD{`fd6*O(;^k(^dnvSIiqr&>%i@9y+~3CS`}%woFJK%`m6RIS zeKO#il!OyzxMqP?W($x@yDNJ+KaI*{qu-VMRhXE@Mt|R;fE`Wm?unu}`Ki}z(Awa0 zIQjm;4CDmpf_N#sT{d)sDv~1CO!<0n(-In1wu6*9g*EiL7#P0&Fm^U_R8(2Zj6*$X zfX)K3D!<|yyXhLg!@Yf@E@ZkUBNrqj%xc(gd(xb@8m zq`QO^*>ov|#XT7>535CCO+AFBd1ocIE&wAr!dMvY!4TX*9$M2d%#TO3a0;hobD|&c za0TJ3#Wz+*ffMpK{(pTL;rrfg4MugQ z$pmJ(y%=5G8^`(p+E7Ba5>iowWKw84FNaPCU$hQHePT%A(W_14u2xneP~NA=|((vj${ z`%Ecgf^~ob*1)o8i`xF#48!u_jjFekq}h|UL%xRt$TTF!!s*_lN+GgP2U?BX8_^!U z-qzBzfO%GPKK6^tlzWYYVq(|5wu+=$2#MMWXDuCve*xKHtf8w9pAkTQ*Z3vnn9kvv zfErx_@*S6-wM*!iAD~z4Y;v$!Hj+`2S^ViJ!%U%oLS;)XkW)7@i6h+e;DBk z0c>wZ)5wUk=~KvYa1~=@(C^~nQft2<@po&4)%iZ>RQ6)t&|KPL*3y^l^I6Y1p4mnQ zfsT~CTt=9U6Lqd1UVh8n)_r@8M)8B%kNr|ADRT$y*pJKG^CVvd$Tb_)IIRXvw=Py9 znw$JwbvC;T85(>+_rB8}=~J|oTAt0Zi52|sKzG>(xB!_HExy$7J+WNqzhVUBVICGY z8E)8FDNYWO&08$YvJ>~x+PIz0mZ=S`fv^fdG&IxB8#bOzJqfo9T?V+Vr2nx zbXk|2L}idtd*1I(9O5G&_J5}r>Td+hmf%8A%5MTrsZO^Kh#Lx<{_U)$$;Y5J(Bt58 zZl19jWBmc-sd8y;9~y*fmvz^v!ZYfdo^K?y<>$^dZ*?_#CAG;hr$v19G}6%vLDT>M I000000Pp!~CjbBd literal 0 HcmV?d00001 diff --git a/public/poses/nibu/glitch.webp b/public/poses/nibu/glitch.webp new file mode 100644 index 0000000000000000000000000000000000000000..686978be8468a2a62404f3b9b1ae9106b71cfa65 GIT binary patch literal 5106 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 U;9Ll+kGscvP>b^l0000001znoY5)KL literal 0 HcmV?d00001 diff --git a/public/poses/nibu/happy.webp b/public/poses/nibu/happy.webp new file mode 100644 index 0000000000000000000000000000000000000000..e9a10235d27575bc508881917d62732ab7b2ae5a GIT binary patch literal 4580 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 Opo}(ufCwRc00029nClq; literal 0 HcmV?d00001 diff --git a/public/poses/nibu/lore.webp b/public/poses/nibu/lore.webp new file mode 100644 index 0000000000000000000000000000000000000000..9e10a4647ebd15d8f87782ff622d28f68bb8d968 GIT binary patch literal 4730 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 literal 0 HcmV?d00001 diff --git a/public/poses/nibu/neutral.webp b/public/poses/nibu/neutral.webp new file mode 100644 index 0000000000000000000000000000000000000000..b07dc83b385a3db6408f4f299ff6ae233615536d GIT binary patch literal 4384 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 aXkT`vHM})F*F35ZC4!L-05$V~0002C*>P_G literal 0 HcmV?d00001 diff --git a/public/poses/nibu/surprised.webp b/public/poses/nibu/surprised.webp new file mode 100644 index 0000000000000000000000000000000000000000..1454b77b73b36f8dde08626dc07e2e63ad559496 GIT binary patch literal 4818 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 sZ-Du9l3tVg%IMba7BLZtY}@ii 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 a72~6~<^};DB9Izz$2ZA!4e-+J1ONb`dn?8O literal 0 HcmV?d00001 diff --git a/public/poses/nibu/triumph.webp b/public/poses/nibu/triumph.webp new file mode 100644 index 0000000000000000000000000000000000000000..ea6157b427d189e141fcd45774cc6363dac6c775 GIT binary patch literal 4252 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 literal 0 HcmV?d00001 diff --git a/public/poses/weksa/amused.webp b/public/poses/weksa/amused.webp new file mode 100644 index 0000000000000000000000000000000000000000..8ec99ef3d33ff56065888292d1efdd6dd18ca2c9 GIT binary patch literal 5088 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 CE%;#o literal 0 HcmV?d00001 diff --git a/public/poses/weksa/annoyed.webp b/public/poses/weksa/annoyed.webp new file mode 100644 index 0000000000000000000000000000000000000000..9f43f9364166511370eabbf3ee48f88e26341646 GIT binary patch literal 4422 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 literal 0 HcmV?d00001 diff --git a/public/poses/weksa/concerned.webp b/public/poses/weksa/concerned.webp new file mode 100644 index 0000000000000000000000000000000000000000..c98206f9fa43e1bcd4d529fbaa4c8d92126e049b GIT binary patch literal 4572 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 literal 0 HcmV?d00001 diff --git a/public/poses/weksa/fieldwork.webp b/public/poses/weksa/fieldwork.webp new file mode 100644 index 0000000000000000000000000000000000000000..cfbef5c5dd0ce444185a6dfd5dac7a14100715a9 GIT binary patch literal 5408 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 literal 0 HcmV?d00001 diff --git a/public/poses/weksa/interlingua.webp b/public/poses/weksa/interlingua.webp new file mode 100644 index 0000000000000000000000000000000000000000..88cc46492af1b06a401d15230b898b4a1a5d5567 GIT binary patch literal 5484 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 literal 0 HcmV?d00001 diff --git a/public/poses/weksa/map.webp b/public/poses/weksa/map.webp new file mode 100644 index 0000000000000000000000000000000000000000..2cbfa70197d53360c0c94b02383460cf4fe74203 GIT binary patch literal 5202 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 literal 0 HcmV?d00001 diff --git a/public/poses/weksa/surprised.webp b/public/poses/weksa/surprised.webp new file mode 100644 index 0000000000000000000000000000000000000000..5e74a1959d89489afe3f90c699033ed7a3a3a511 GIT binary patch literal 4232 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 literal 0 HcmV?d00001 diff --git a/public/poses/weksa/thinking.webp b/public/poses/weksa/thinking.webp new file mode 100644 index 0000000000000000000000000000000000000000..8f2862917f69e3da1ec0864640cd248c9b213616 GIT binary patch literal 4612 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 literal 0 HcmV?d00001 diff --git a/src/app/components/message/layout/Bubble.tsx b/src/app/components/message/layout/Bubble.tsx index 93ff84bc..7fc5171c 100644 --- a/src/app/components/message/layout/Bubble.tsx +++ b/src/app/components/message/layout/Bubble.tsx @@ -5,11 +5,12 @@ import * as css from './layout.css'; type BubbleArrowProps = { variant: ContainerColor; + pose?: boolean; }; -function BubbleLeftArrow({ variant }: BubbleArrowProps) { +function BubbleLeftArrow({ variant, pose }: BubbleArrowProps) { return ( ( - ({ hideBubble, before, header, children, ...props }, ref) => ( - - + ({ hideBubble, pose, before, header, children, ...props }, ref) => ( + + {before} @@ -48,11 +55,15 @@ export const BubbleLayout = as<'div', BubbleLayoutProps>( className={ hideBubble ? undefined - : classNames(css.BubbleContent, before ? css.BubbleContentArrowLeft : undefined) + : classNames( + css.BubbleContent, + before ? css.BubbleContentArrowLeft : undefined, + pose && css.BubbleContentPoseStacked + ) } direction="Column" > - {before ? : null} + {before ? : null} {children} diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index cc2cd0c6..cb53a62d 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -138,6 +138,34 @@ export const BubbleBefore = style({ minWidth: toRem(36), }); +// Pose portraits: beside the bubble on wide screens; on phones the row stacks, so the +// portrait costs a little height instead of a column of width, and the left arrow goes. +export const BubblePose = style({ + '@media': { + 'screen and (max-width: 750px)': { + flexDirection: 'column', + gap: config.space.S100, + }, + }, +}); + +export const BubbleBeforePose = style({ + minWidth: toRem(64), + '@media': { + 'screen and (max-width: 750px)': { + minWidth: 0, + }, + }, +}); + +export const BubbleArrowPose = style({ + '@media': { + 'screen and (max-width: 750px)': { + display: 'none', + }, + }, +}); + export const BubbleContent = style({ maxWidth: toRem(800), padding: config.space.S200, @@ -151,6 +179,34 @@ export const BubbleContentArrowLeft = style({ borderTopLeftRadius: 0, }); +export const BubbleContentPoseStacked = style({ + '@media': { + 'screen and (max-width: 750px)': { + borderTopLeftRadius: config.radii.R500, + }, + }, +}); + +export const PoseButton = style({ + padding: 0, + border: 'none', + background: 'none', + cursor: 'pointer', + lineHeight: 0, +}); + +export const PosePortrait = style({ + width: toRem(64), + height: toRem(64), + objectFit: 'contain', + '@media': { + 'screen and (max-width: 750px)': { + width: toRem(48), + height: toRem(48), + }, + }, +}); + export const BubbleLeftArrow = style({ width: toRem(9), height: toRem(8), diff --git a/src/app/components/message/pose/PoseAvatar.tsx b/src/app/components/message/pose/PoseAvatar.tsx new file mode 100644 index 00000000..ed8bc485 --- /dev/null +++ b/src/app/components/message/pose/PoseAvatar.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import * as css from '../layout/layout.css'; +import { poseSrc } from './poses'; + +type PoseAvatarProps = { + mind: string; + pose: string; + userId: string; + onClick: React.MouseEventHandler; +}; + +export function PoseAvatar({ mind, pose, userId, onClick }: PoseAvatarProps) { + return ( + {`${mind}, + + ); +} diff --git a/src/app/components/message/pose/poses.test.ts b/src/app/components/message/pose/poses.test.ts new file mode 100644 index 00000000..0915cfa7 --- /dev/null +++ b/src/app/components/message/pose/poses.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { mindOfSender, poseSrc, resolvePose } from './poses'; + +describe('resolvePose', () => { + it('keeps shared and own-signature labels', () => { + expect(resolvePose('aqua', 'amused')).toBe('amused'); + expect(resolvePose('aqua', 'conduct')).toBe('conduct'); + expect(resolvePose('kiko', ' Debug ')).toBe('debug'); + }); + it('falls back to neutral for missing, unknown, non-string or foreign-signature labels', () => { + expect(resolvePose('aqua', undefined)).toBe('neutral'); + expect(resolvePose('aqua', 'smug')).toBe('neutral'); + expect(resolvePose('aqua', 7)).toBe('neutral'); + expect(resolvePose('aqua', 'probe')).toBe('neutral'); + expect(resolvePose('nobody', 'probe')).toBe('neutral'); + }); +}); + +describe('mindOfSender', () => { + it('matches localpart or first display-name word', () => { + expect(mindOfSender('Eureka')).toBe('eureka'); + expect(mindOfSender('x', 'Libby the Archivist')).toBe('libby'); + }); + it('ignores humans and Object prototype names', () => { + expect(mindOfSender('alice', 'Alice')).toBeUndefined(); + expect(mindOfSender('constructor')).toBeUndefined(); + expect(mindOfSender()).toBeUndefined(); + }); +}); + +describe('poseSrc', () => { + it('builds the asset path', () => { + expect(poseSrc('nibu', 'glitch', '/app/')).toBe('/app/public/poses/nibu/glitch.webp'); + }); +}); diff --git a/src/app/components/message/pose/poses.ts b/src/app/components/message/pose/poses.ts new file mode 100644 index 00000000..58ce59b8 --- /dev/null +++ b/src/app/components/message/pose/poses.ts @@ -0,0 +1,48 @@ +// Pose labels travel in the event content under POSE_KEY. Art exists per Mind; +// every Mind has the shared set plus four signature poses of its own. +export const POSE_KEY = 'org.gamecult.pose'; + +export const SHARED_POSES = [ + 'neutral', + 'happy', + 'amused', + 'thinking', + 'concerned', + 'annoyed', + 'surprised', + 'determined', +] as const; + +export const MIND_POSES: Record = { + aqua: ['conduct', 'listen', 'patch', 'flourish'], + eureka: ['probe', 'inspect', 'report', 'hood'], + heimdall: ['badge', 'barrier', 'protect', 'revoke'], + kiko: ['tinker', 'confetti', 'sprint', 'debug'], + libby: ['stamp', 'research', 'shush', 'schema'], + nibu: ['console', 'glitch', 'lore', 'triumph'], + weksa: ['map', 'fieldwork', 'argue', 'interlingua'], +}; + +export const NEUTRAL = 'neutral'; + +// A sender is a known Mind when its Matrix localpart or the first word of its +// display name is the Mind's name, case-insensitively. +export const mindOfSender = (localpart?: string, displayName?: string): string | undefined => { + const candidates = [localpart, displayName?.split(/\s+/)[0]]; + return candidates + .map((c) => c?.toLowerCase()) + .find( + (c): c is string => c !== undefined && Object.prototype.hasOwnProperty.call(MIND_POSES, c) + ); +}; + +// Unknown, missing or non-string labels, and another Mind's signature, fall back to neutral. +export const resolvePose = (mind: string, label: unknown): string => { + if (typeof label !== 'string') return NEUTRAL; + const l = label.trim().toLowerCase(); + const known = (SHARED_POSES as readonly string[]).includes(l) || MIND_POSES[mind]?.includes(l); + return known ? l : NEUTRAL; +}; + +export const poseSrc = (mind: string, pose: string, base = '/'): string => + `${base}public/poses/${mind}/${pose}.webp`; diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 2d87e9bd..42d74a6b 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -58,7 +58,10 @@ import { isRoomAlias, mxcUrlToHttp, } from '../../../utils/matrix'; -import { MessageLayout, MessageSpacing } from '../../../state/settings'; +import { MessageLayout, MessageSpacing, settingsAtom } from '../../../state/settings'; +import { useSetting } from '../../../state/hooks/settings'; +import { PoseAvatar } from '../../../components/message/pose/PoseAvatar'; +import { POSE_KEY, mindOfSender, resolvePose } from '../../../components/message/pose/poses'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useRecentEmoji } from '../../../hooks/useRecentEmoji'; import * as css from './styles.css'; @@ -788,6 +791,12 @@ export const Message = as<'div', MessageProps>( ); + const [poseBubbles] = useSetting(settingsAtom, 'poseBubbles'); + const mind = + poseBubbles && messageLayout === MessageLayout.Bubble + ? mindOfSender(getMxIdLocalPart(senderId), senderDisplayName) + : undefined; + const avatarJSX = !collapse && messageLayout !== MessageLayout.Compact && ( ( ); + const poseJSX = !collapse && mind && ( + + ); + const msgContentJSX = ( {reply} @@ -1135,7 +1153,10 @@ export const Message = as<'div', MessageProps>( )} {messageLayout === MessageLayout.Bubble && ( - + {msgContentJSX} )} diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index b861a060..5b51cd8c 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -884,6 +884,7 @@ function Messages() { settingsAtom, 'legacyUsernameColor' ); + const [poseBubbles, setPoseBubbles] = useSetting(settingsAtom, 'poseBubbles'); const [hideMembershipEvents, setHideMembershipEvents] = useSetting( settingsAtom, 'hideMembershipEvents' @@ -918,6 +919,13 @@ function Messages() { } /> + + } + /> + Date: Mon, 2 Nov 2026 06:20:53 +0000 Subject: [PATCH 050/206] Background scene: lint and type clean, integration contract Fixes the 21 eslint errors and 2 tsc errors Void's review found in features/scene (WORLD.phacelleGradBound is typed number, not the literal 7). Adds the contract the host needs: client config backgroundScene.tier as flag and ceiling (off by default, lowered by saveData / memory / cores), lazy startBackgroundScene, still and low tiers, onCompileFail (createScene frees linked programs and returns null), webglcontextlost handling, and creatureScale where neutral overrides sizeBucket. Scene.ts gets its first tests, on a fake GL. Co-Authored-By: Claude Sonnet 5.5 --- config.json | 4 + src/app/features/scene/README.md | 22 ++- src/app/features/scene/Scene.test.ts | 218 +++++++++++++++++++++++ src/app/features/scene/Scene.ts | 121 +++++++++---- src/app/features/scene/budget.test.ts | 27 +-- src/app/features/scene/budget.ts | 16 +- src/app/features/scene/creatures.test.ts | 23 +++ src/app/features/scene/creatures.ts | 26 +++ src/app/features/scene/index.ts | 23 +++ src/app/features/scene/tier.test.ts | 26 +++ src/app/features/scene/tier.ts | 49 +++++ src/app/features/scene/world.test.ts | 6 +- src/app/features/scene/world.ts | 3 +- src/app/hooks/useClientConfig.ts | 3 + 14 files changed, 513 insertions(+), 54 deletions(-) create mode 100644 src/app/features/scene/Scene.test.ts create mode 100644 src/app/features/scene/creatures.test.ts create mode 100644 src/app/features/scene/creatures.ts create mode 100644 src/app/features/scene/index.ts create mode 100644 src/app/features/scene/tier.test.ts create mode 100644 src/app/features/scene/tier.ts diff --git a/config.json b/config.json index e945d1c4..d1c461a4 100644 --- a/config.json +++ b/config.json @@ -17,5 +17,9 @@ "hashRouter": { "enabled": false, "basename": "/" + }, + + "backgroundScene": { + "tier": "off" } } diff --git a/src/app/features/scene/README.md b/src/app/features/scene/README.md index d265c332..0d49d57d 100644 --- a/src/app/features/scene/README.md +++ b/src/app/features/scene/README.md @@ -3,15 +3,29 @@ A two-pass WebGL2 scene for the client's background canvas: an infinite terrain of CultMath noise with a world tree at its centre, dusk atmosphere and clouds, and a camera that pulls back from the tree on login. It is a self-contained module; nothing in the client imports it yet. -Integration (a canvas under the app, `createScene`, `login()` when the session starts, `dispose()`) -is Void's cut `terrain-background`; the personas (`orbiting-personas`) and the squirrel are later cuts. +Integration (a canvas under the app, `startBackgroundScene`, `login()` when the session starts, +`dispose()`) is Void's cut `terrain-background`; the personas (`orbiting-personas`) and the squirrel +are later cuts. This PR is the render core plus the contract the host needs: flag, tier, failure +callbacks and the creature sizing rule. Nothing has drawn a creature. ## Entry points -- `Scene.ts` `createScene({ canvas, ... })` returns `{ login, dispose }`, or `null` when WebGL2 or - `EXT_color_buffer_float` is missing (keep the CSS ground then). It caps at 30 fps, pauses when +- `index.ts` `startBackgroundScene(config.backgroundScene, capabilities, options)` is the host's + door. The client config's `backgroundScene.tier` (`off` default, `still`, `low`, `full`) is the + feature flag and the ceiling; `tier.ts` lowers it for `saveData`, low memory or few cores. At + `off` nothing is imported and no canvas context is requested; otherwise `Scene.ts` loads on + demand, so it stays out of the main chunk. +- `Scene.ts` `createScene({ canvas, tier, onCompileFail, onContextLost, ... })` returns + `{ login, dispose }`, or `null` (keep the CSS ground then) when the tier is `off`, WebGL2 or + `EXT_color_buffer_float` is missing, or a shader fails to compile (`onCompileFail` gets the + `ShaderCompileError`; programs already linked are freed). `still` draws one frame at the top + level and stops; `low` starts the ladder at level 2; `full` at level 0. A lost WebGL context + stops the scene for good and calls `onContextLost`; nothing rebuilds it, the host decides what to + show. It caps at 30 fps, pauses when the tab is hidden, renders 32 frames and stops under `prefers-reduced-motion`, and steps down a quality ladder (`budget.ts`) against a 4 ms GPU budget when timer queries exist. +- `creatures.ts` the creature record and `creatureScale`: a neutral creature is one fixed size and + overrides `sizeBucket`. The bucket names and scales are placeholders until the persona cut owns them. - `camera.ts` the pull-back path as pure functions (`pullProgress`, `cameraPose`). - `world.ts` every world number, and the Lipschitz bounds the sphere trace steps by. The GLSL `#define`s are generated from it; no number is written twice. diff --git a/src/app/features/scene/Scene.test.ts b/src/app/features/scene/Scene.test.ts new file mode 100644 index 00000000..97988687 --- /dev/null +++ b/src/app/features/scene/Scene.test.ts @@ -0,0 +1,218 @@ +import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; +import { createScene, ShaderCompileError } from './Scene'; +import { LEVELS } from './budget'; +import { startBackgroundScene } from './index'; + +type Fn = Mock; + +/** A GL context where every call is a spy and every constant is 1. Compiles succeed until `failFrom`. */ +function fakeGl(failFrom = Infinity) { + let compiles = 0; + const calls: Record = {}; + const spy = (name: string, impl: () => unknown = () => ({})): Fn => { + if (!calls[name]) calls[name] = vi.fn(impl); + return calls[name]; + }; + const gl = new Proxy({} as Record, { + get(_, prop: string) { + if (/^[A-Z][A-Z0-9_]*$/.test(prop)) return 1; + if (prop === 'compileShader') { + return spy(prop, () => { + compiles += 1; + }); + } + if (prop === 'getShaderParameter') return spy(prop, () => compiles < failFrom); + if (prop === 'getShaderInfoLog') return spy(prop, () => 'ERROR: 0:1: bad'); + if (prop === 'getProgramParameter') return spy(prop, () => true); + return spy(prop); + }, + }); + return { gl: gl as unknown as WebGL2RenderingContext, calls }; +} + +function fakeCanvas(gl: WebGL2RenderingContext | null) { + const canvas = new EventTarget() as EventTarget & { + clientWidth: number; + clientHeight: number; + width: number; + height: number; + getContext: Fn; + }; + canvas.clientWidth = 400; + canvas.clientHeight = 300; + canvas.width = 0; + canvas.height = 0; + canvas.getContext = vi.fn(() => gl); + return canvas; +} + +let rafQueue: Array<(t: number) => void>; +let cancelled: number; +let win: EventTarget; +let doc: EventTarget & { visibilityState: string }; + +beforeEach(() => { + rafQueue = []; + cancelled = 0; + win = Object.assign(new EventTarget(), { + devicePixelRatio: 1, + matchMedia: () => ({ matches: false }), + }); + doc = Object.assign(new EventTarget(), { visibilityState: 'visible' }); + vi.stubGlobal('window', win); + vi.stubGlobal('document', doc); + vi.stubGlobal('requestAnimationFrame', (cb: (t: number) => void) => rafQueue.push(cb)); + vi.stubGlobal('cancelAnimationFrame', () => { + cancelled += 1; + }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +const runFrame = (now: number) => { + const cb = rafQueue.shift(); + if (!cb) throw new Error('no frame was scheduled'); + cb(now); +}; +const asCanvas = (c: ReturnType) => c as unknown as HTMLCanvasElement; + +describe('createScene tiers', () => { + it("'off' returns null without asking the canvas for a context", () => { + const { gl } = fakeGl(); + const canvas = fakeCanvas(gl); + expect(createScene({ canvas: asCanvas(canvas), tier: 'off' })).toBeNull(); + expect(canvas.getContext).not.toHaveBeenCalled(); + expect(rafQueue).toHaveLength(0); + }); + + it("'still' draws one frame and schedules no more", () => { + const { gl, calls } = fakeGl(); + const scene = createScene({ canvas: asCanvas(fakeCanvas(gl)), tier: 'still' }); + expect(scene).not.toBeNull(); + runFrame(1000); + expect(calls.drawArrays).toHaveBeenCalledTimes(4); // four passes, one frame + expect(rafQueue).toHaveLength(0); + }); + + it("'still' ignores login(): the pull-back is for a moving scene", () => { + const { gl } = fakeGl(); + const scene = createScene({ canvas: asCanvas(fakeCanvas(gl)), tier: 'still' }); + runFrame(1000); + scene?.login(); + expect(rafQueue).toHaveLength(0); + }); + + it("'full' keeps scheduling frames", () => { + const { gl } = fakeGl(); + createScene({ canvas: asCanvas(fakeCanvas(gl)), tier: 'full' }); + runFrame(1000); + expect(rafQueue).toHaveLength(1); + }); + + it("'low' traces with a cheaper level than 'full'", () => { + const traceSteps = (tier: 'low' | 'full') => { + rafQueue.length = 0; // each call is a fresh scene + const { gl, calls } = fakeGl(); + createScene({ canvas: asCanvas(fakeCanvas(gl)), tier }); + runFrame(1000); + // u_maxSteps is the only uniform1i whose value is a trace-step count + return calls.uniform1i.mock.calls.map((c) => c[1]).filter((v) => v >= 40); + }; + expect(traceSteps('full')).toContain(LEVELS[0].traceSteps); + expect(traceSteps('low')).toContain(LEVELS[2].traceSteps); + expect(traceSteps('low')).not.toContain(LEVELS[0].traceSteps); + }); + + it('returns null when WebGL2 is missing, so the host keeps its ground', () => { + expect(createScene({ canvas: asCanvas(fakeCanvas(null)) })).toBeNull(); + }); +}); + +describe('shader compile failure', () => { + it('reports through onCompileFail, returns null, and frees the programs already linked', () => { + const { gl, calls } = fakeGl(4); // terrain compiles both shaders; atmosphere's fragment is the 4th and fails + const onCompileFail = vi.fn(); + const scene = createScene({ canvas: asCanvas(fakeCanvas(gl)), onCompileFail }); + expect(scene).toBeNull(); + expect(onCompileFail).toHaveBeenCalledTimes(1); + const err = onCompileFail.mock.calls[0][0] as ShaderCompileError; + expect(err).toBeInstanceOf(ShaderCompileError); + expect(err.stage).toBe('atmosphere'); + expect(err.log).toContain('bad'); + expect(calls.deleteProgram).toHaveBeenCalled(); // the terrain program that did link + expect(rafQueue).toHaveLength(0); + }); + + it('does not throw out of createScene when no callback is given', () => { + const { gl } = fakeGl(0); + expect(() => createScene({ canvas: asCanvas(fakeCanvas(gl)) })).not.toThrow(); + }); +}); + +describe('context loss', () => { + it('stops the loop, tells the host, and detaches every listener', () => { + const { gl } = fakeGl(); + const canvas = fakeCanvas(gl); + const onContextLost = vi.fn(); + createScene({ canvas: asCanvas(canvas), onContextLost }); + const lost = new Event('webglcontextlost', { cancelable: true }); + canvas.dispatchEvent(lost); + expect(lost.defaultPrevented).toBe(true); + expect(onContextLost).toHaveBeenCalledTimes(1); + expect(cancelled).toBe(1); + // listeners are gone: a second loss, a resize and a visibility change do nothing + canvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true })); + win.dispatchEvent(new Event('resize')); + doc.dispatchEvent(new Event('visibilitychange')); + expect(onContextLost).toHaveBeenCalledTimes(1); + expect(rafQueue.length).toBe(1); // only the frame queued before the loss, never run + }); + + it('does not render a queued frame after the loss', () => { + const { gl, calls } = fakeGl(); + const canvas = fakeCanvas(gl); + createScene({ canvas: asCanvas(canvas) }); + canvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true })); + runFrame(1000); + expect(calls.drawArrays).toBeUndefined(); + }); +}); + +describe('startBackgroundScene', () => { + it("does not request a context when the config leaves the tier 'off'", async () => { + const { gl } = fakeGl(); + const canvas = fakeCanvas(gl); + expect(await startBackgroundScene(undefined, {}, { canvas: asCanvas(canvas) })).toBeNull(); + expect( + await startBackgroundScene({ tier: 'off' }, {}, { canvas: asCanvas(canvas) }) + ).toBeNull(); + expect(canvas.getContext).not.toHaveBeenCalled(); + }); + + it('applies the device ceiling: saveData runs a still, one frame', async () => { + const { gl, calls } = fakeGl(); + const scene = await startBackgroundScene( + { tier: 'full' }, + { saveData: true }, + { canvas: asCanvas(fakeCanvas(gl)) } + ); + expect(scene).not.toBeNull(); + runFrame(1000); + expect(calls.drawArrays).toHaveBeenCalledTimes(4); + expect(rafQueue).toHaveLength(0); + }); + + it('hands compile failure to the host and returns null, the fall to the CSS ground', async () => { + const { gl } = fakeGl(0); + const onCompileFail = vi.fn(); + const scene = await startBackgroundScene( + { tier: 'full' }, + {}, + { canvas: asCanvas(fakeCanvas(gl)), onCompileFail } + ); + expect(scene).toBeNull(); + expect(onCompileFail).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/features/scene/Scene.ts b/src/app/features/scene/Scene.ts index 367e6cc9..10dacc58 100644 --- a/src/app/features/scene/Scene.ts +++ b/src/app/features/scene/Scene.ts @@ -1,6 +1,7 @@ import { fragmentSource, vertexSource, Stage } from './shaderSource'; import { cameraPose, pullProgress, CameraPose } from './camera'; -import { BudgetLadder, Level } from './budget'; +import { BudgetLadder, Level, LEVELS } from './budget'; +import type { SceneTier } from './tier'; import { PALETTE } from './palette'; /** Phone GPU budget for the whole scene, from the ground-cut doc's budget table. */ @@ -20,8 +21,31 @@ export interface SceneStats { atmosphereSize: [number, number]; } +/** Measurement seam: the harness rewrites a stage's source to time a variant. Never set in the client. */ +export type SourcePatch = (stage: Stage, source: string) => string; + +export class ShaderCompileError extends Error { + constructor(readonly stage: Stage | 'vertex', readonly log: string) { + super(`${stage} shader failed to compile:\n${log}`); + } +} + +/** Ladder level a 'low' scene starts at: the cost of a weak device's first 45 frames is never paid at full. */ +const LOW_START_LEVEL = 2; +/** The 'still' tier draws one frame at this level and stops. */ +const STILL_LEVEL = LEVELS[0]; + export interface SceneOptions { canvas: HTMLCanvasElement; + /** + * 'off' returns null without touching the canvas. 'still' draws one frame and stops. 'low' starts + * the adaptive ladder low. 'full' starts it at the top. Default 'full'. + */ + tier?: SceneTier; + /** A shader failed to compile or link. createScene frees what it built and returns null. */ + onCompileFail?: (e: ShaderCompileError) => void; + /** The WebGL context was lost. The scene stops for good; the host keeps its ground. */ + onContextLost?: () => void; /** Called per finished frame when timer queries exist. */ onStats?: (s: SceneStats) => void; /** Weather knob, 0 clear .. 1 overcast. */ @@ -39,15 +63,6 @@ export interface Scene { dispose(): void; } -/** Measurement seam: the harness rewrites a stage's source to time a variant. Never set in the client. */ -export type SourcePatch = (stage: Stage, source: string) => string; - -export class ShaderCompileError extends Error { - constructor(readonly stage: Stage | 'vertex', readonly log: string) { - super(`${stage} shader failed to compile:\n${log}`); - } -} - function compile( gl: WebGL2RenderingContext, type: number, @@ -112,6 +127,8 @@ function halton(i: number, base: number): number { /** Null when WebGL2 or float colour buffers are missing; the host keeps its CSS ground. */ export function createScene(opts: SceneOptions): Scene | null { const { canvas } = opts; + const tier = opts.tier ?? 'full'; + if (tier === 'off') return null; const gl = canvas.getContext('webgl2', { antialias: false, alpha: false, @@ -127,7 +144,18 @@ export function createScene(opts: SceneOptions): Scene | null { } | null; const programs = {} as Record; - for (const p of PASSES) programs[p] = linkStage(gl, p, opts.patchSource); + try { + PASSES.forEach((p) => { + programs[p] = linkStage(gl, p, opts.patchSource); + }); + } catch (e) { + PASSES.forEach((p) => { + if (programs[p]) gl.deleteProgram(programs[p]); + }); + if (!(e instanceof ShaderCompileError)) throw e; + opts.onCompileFail?.(e); + return null; + } const uniforms = new Map>(); const loc = (p: WebGLProgram, name: string) => { let m = uniforms.get(p); @@ -160,7 +188,9 @@ export function createScene(opts: SceneOptions): Scene | null { let atm: Target; let hist: [Target, Target]; const freeTargets = () => { - for (const t of [solid, depth, atm, hist?.[0], hist?.[1]]) if (t) gl.deleteTexture(t.tex); + [solid, depth, atm, hist?.[0], hist?.[1]].forEach((t) => { + if (t) gl.deleteTexture(t.tex); + }); }; const attach = (fbo: WebGLFramebuffer, targets: Target[]) => { @@ -171,8 +201,9 @@ export function createScene(opts: SceneOptions): Scene | null { gl.drawBuffers(targets.map((_, i) => gl.COLOR_ATTACHMENT0 + i)); }; - const ladder = new BudgetLadder(GPU_BUDGET_MS); - const level = (): Level => opts.fixedLevel ?? ladder.current; + const still = tier === 'still'; + const ladder = new BudgetLadder(GPU_BUDGET_MS, 45, tier === 'low' ? LOW_START_LEVEL : 0); + const level = (): Level => opts.fixedLevel ?? (still ? STILL_LEVEL : ladder.current); let canvasW = 0; let canvasH = 0; @@ -214,7 +245,7 @@ export function createScene(opts: SceneOptions): Scene | null { let prev: CameraPose | undefined; let raf = 0; let lastRendered = -Infinity; - let framesLeft = Infinity; // reduced motion bounds this + let framesLeft = still ? 1 : Infinity; // a still frame, or reduced motion, bounds this let disposed = false; // Timer queries in flight: each holds one pass's query. @@ -222,7 +253,10 @@ export function createScene(opts: SceneOptions): Scene | null { const finished = new Map>>(); const timed = (pass: PassName, f: number, body: () => void) => { - if (!timer || !opts.onStats) return body(); + if (!timer || !opts.onStats) { + body(); + return; + } const q = gl.createQuery()!; gl.beginQuery(timer.TIME_ELAPSED_EXT, q); body(); @@ -282,9 +316,11 @@ export function createScene(opts: SceneOptions): Scene | null { const lv = level(); const frozen = reducedMotion?.matches ?? false; const time = frozen ? 0 : (now - start) / 1000; - const elapsed = loginAt === undefined ? undefined : frozen ? 1e9 : (now - loginAt) / 1000; + let elapsed: number | undefined; + if (loginAt !== undefined) elapsed = frozen ? 1e9 : (now - loginAt) / 1000; const pose = cameraPose(pullProgress(elapsed), time); - const f = frame++; + const f = frame; + frame += 1; const jx = halton((f % 16) + 1, 2) - 0.5; const jy = halton((f % 16) + 1, 3) - 0.5; @@ -320,7 +356,7 @@ export function createScene(opts: SceneOptions): Scene | null { }); // TAA resolve into the history that is not being read. - const write = histIndex ^ 1; + const write = histIndex === 0 ? 1 : 0; timed('resolve', f, () => { gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.hist[write]); gl.viewport(0, 0, atm.w, atm.h); @@ -369,8 +405,10 @@ export function createScene(opts: SceneOptions): Scene | null { !timer && lastRendered > 0 && now - lastRendered > FRAME_INTERVAL_MS * 1.5 - ) - ladder.push(GPU_BUDGET_MS * 2) && (sizedFor = undefined); // no timer: a missed cap counts as over budget + ) { + // no timer: a missed cap counts as over budget + if (ladder.push(GPU_BUDGET_MS * 2)) sizedFor = undefined; + } lastRendered = now; render(now); framesLeft -= 1; @@ -385,30 +423,47 @@ export function createScene(opts: SceneOptions): Scene | null { resetHistory = true; // the first frame after a pause has a real elapsed dt raf = requestAnimationFrame(tick); }; - const onVisibility = () => { - if (document.visibilityState === 'visible') wake(); + const undo: Array<() => void> = []; + const listen = (target: EventTarget, type: string, handler: (e: Event) => void) => { + target.addEventListener(type, handler); + undo.push(() => target.removeEventListener(type, handler)); }; - const onResize = () => wake(); - if (!opts.unthrottled) document.addEventListener('visibilitychange', onVisibility); - window.addEventListener('resize', onResize); - if (reducedMotion?.matches) framesLeft = REDUCED_MOTION_FRAMES; + const stop = () => { + disposed = true; + if (raf) cancelAnimationFrame(raf); + undo.forEach((f) => f()); + }; + if (!opts.unthrottled) { + listen(document, 'visibilitychange', () => { + if (document.visibilityState === 'visible') wake(); + }); + } + listen(window, 'resize', () => wake()); + // A lost context takes every texture and program with it and nothing here rebuilds them, so the + // scene ends and the host decides what to show. preventDefault: otherwise the browser never + // restores the context, which is what we want, and a restore would find a dead scene. + listen(canvas, 'webglcontextlost', (e) => { + e.preventDefault(); + stop(); + opts.onContextLost?.(); + }); + if (reducedMotion?.matches && !still) framesLeft = REDUCED_MOTION_FRAMES; wake(); return { login() { + if (still || disposed) return; // a still has no pull-back loginAt = performance.now(); if (reducedMotion?.matches) framesLeft = REDUCED_MOTION_FRAMES; wake(); }, dispose() { - disposed = true; - if (raf) cancelAnimationFrame(raf); - document.removeEventListener('visibilitychange', onVisibility); - window.removeEventListener('resize', onResize); + if (disposed) return; + stop(); freeTargets(); - for (const p of PASSES) gl.deleteProgram(programs[p]); + PASSES.forEach((p) => gl.deleteProgram(programs[p])); gl.deleteVertexArray(vao); - for (const f of [fbos.solid, fbos.atm, ...fbos.hist]) gl.deleteFramebuffer(f); + [fbos.solid, fbos.atm, ...fbos.hist].forEach((fb) => gl.deleteFramebuffer(fb)); }, }; } diff --git a/src/app/features/scene/budget.test.ts b/src/app/features/scene/budget.test.ts index 27dd0487..9ac8f0af 100644 --- a/src/app/features/scene/budget.test.ts +++ b/src/app/features/scene/budget.test.ts @@ -4,31 +4,38 @@ import { BudgetLadder, LEVELS } from './budget'; describe('budget ladder', () => { it('steps down after a whole window over budget and never back up', () => { const ladder = new BudgetLadder(4, 5); - for (let i = 0; i < 4; i++) expect(ladder.push(9)).toBe(false); + Array.from({ length: 4 }).forEach(() => expect(ladder.push(9)).toBe(false)); expect(ladder.push(9)).toBe(true); expect(ladder.index).toBe(1); - for (let i = 0; i < 100; i++) ladder.push(0.1); // fast frames never raise the level + Array.from({ length: 100 }).forEach(() => ladder.push(0.1)); // fast frames never raise the level expect(ladder.index).toBe(1); }); it('judges the median, so one slow frame does not step down', () => { const ladder = new BudgetLadder(4, 5); - for (const ms of [1, 1, 50, 1, 1]) ladder.push(ms); + [1, 1, 50, 1, 1].forEach((ms) => ladder.push(ms)); expect(ladder.index).toBe(0); }); it('stops at the last level', () => { const ladder = new BudgetLadder(4, 1); - for (let i = 0; i < 20; i++) ladder.push(99); + Array.from({ length: 20 }).forEach(() => ladder.push(99)); expect(ladder.index).toBe(LEVELS.length - 1); }); it('every level is cheaper than the one before in each cost axis', () => { - for (let i = 1; i < LEVELS.length; i++) { - expect(LEVELS[i].solidScale).toBeLessThan(LEVELS[i - 1].solidScale); - expect(LEVELS[i].atmosphereScale).toBeLessThan(LEVELS[i - 1].atmosphereScale); - expect(LEVELS[i].cloudSteps).toBeLessThan(LEVELS[i - 1].cloudSteps); - expect(LEVELS[i].traceSteps).toBeLessThan(LEVELS[i - 1].traceSteps); - } + LEVELS.slice(1).forEach((lv, i) => { + const before = LEVELS[i]; + expect(lv.solidScale).toBeLessThan(before.solidScale); + expect(lv.atmosphereScale).toBeLessThan(before.atmosphereScale); + expect(lv.cloudSteps).toBeLessThan(before.cloudSteps); + expect(lv.traceSteps).toBeLessThan(before.traceSteps); + }); + }); + + it('starts at the level it is given, clamped into the ladder', () => { + expect(new BudgetLadder(4, 5, 2).index).toBe(2); + expect(new BudgetLadder(4, 5, 99).index).toBe(LEVELS.length - 1); + expect(new BudgetLadder(4, 5, -3).index).toBe(0); }); }); diff --git a/src/app/features/scene/budget.ts b/src/app/features/scene/budget.ts index 9550571c..3b373d86 100644 --- a/src/app/features/scene/budget.ts +++ b/src/app/features/scene/budget.ts @@ -20,10 +20,20 @@ export const LEVELS: readonly Level[] = [ /** Median of the last `window` frame times above `budgetMs` for a whole window steps down one level. */ export class BudgetLadder { - private level = 0; + private level: number; + private recent: number[] = []; - constructor(private readonly budgetMs: number, private readonly window = 45) {} + private readonly budgetMs: number; + + private readonly window: number; + + /** `startLevel` lets a weak device begin below level 0; it is clamped into the ladder. */ + constructor(budgetMs: number, window = 45, startLevel = 0) { + this.budgetMs = budgetMs; + this.window = window; + this.level = Math.min(Math.max(startLevel, 0), LEVELS.length - 1); + } get current(): Level { return LEVELS[this.level]; @@ -38,7 +48,7 @@ export class BudgetLadder { this.recent.push(ms); if (this.recent.length < this.window) return false; const sorted = [...this.recent].sort((a, b) => a - b); - const median = sorted[sorted.length >> 1]; + const median = sorted[Math.floor(sorted.length / 2)]; this.recent = []; if (median > this.budgetMs && this.level < LEVELS.length - 1) { this.level += 1; diff --git a/src/app/features/scene/creatures.test.ts b/src/app/features/scene/creatures.test.ts new file mode 100644 index 00000000..8e59175b --- /dev/null +++ b/src/app/features/scene/creatures.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { BUCKET_SCALE, creatureScale, NEUTRAL_SCALE, SizeBucket } from './creatures'; + +const buckets: SizeBucket[] = ['small', 'medium', 'large']; + +describe('creature scale', () => { + it('follows sizeBucket for a creature with an estate', () => { + buckets.forEach((sizeBucket) => + expect(creatureScale({ sizeBucket, neutral: false })).toBe(BUCKET_SCALE[sizeBucket]) + ); + }); + + it('is one fixed size for every neutral creature, whatever its sizeBucket', () => { + buckets.forEach((sizeBucket) => + expect(creatureScale({ sizeBucket, neutral: true })).toBe(NEUTRAL_SCALE) + ); + }); + + it('is only visible as an override where the bucket would have said something else', () => { + expect(BUCKET_SCALE.small).not.toBe(NEUTRAL_SCALE); + expect(BUCKET_SCALE.large).not.toBe(NEUTRAL_SCALE); + }); +}); diff --git a/src/app/features/scene/creatures.ts b/src/app/features/scene/creatures.ts new file mode 100644 index 00000000..7bb5d440 --- /dev/null +++ b/src/app/features/scene/creatures.ts @@ -0,0 +1,26 @@ +// Creature sizing for the orbiting personas. Only the rule lives here; drawing them is a later cut. + +export type SizeBucket = 'small' | 'medium' | 'large'; + +export interface Creature { + id: string; + estate: string; + sizeBucket: SizeBucket; + /** A neutral creature (no estate allegiance) is drawn at one fixed size whatever its bucket. */ + neutral: boolean; + activity: 'idle' | 'active'; +} + +export const BUCKET_SCALE: Readonly> = { + small: 0.7, + medium: 1, + large: 1.4, +}; + +/** Neutral creatures are medium sized, not necessarily their bucket's size. */ +export const NEUTRAL_SCALE = BUCKET_SCALE.medium; + +/** Neutral overrides sizeBucket. */ +export function creatureScale(c: Pick): number { + return c.neutral ? NEUTRAL_SCALE : BUCKET_SCALE[c.sizeBucket]; +} diff --git a/src/app/features/scene/index.ts b/src/app/features/scene/index.ts new file mode 100644 index 00000000..f4109796 --- /dev/null +++ b/src/app/features/scene/index.ts @@ -0,0 +1,23 @@ +import type { Scene, SceneOptions } from './Scene'; +import { BackgroundSceneConfig, Capabilities, resolveTier } from './tier'; + +export type { Scene, SceneStats } from './Scene'; +export type { SceneTier } from './tier'; + +export type StartOptions = Omit; + +/** + * The client's one door to the scene. When the tier resolves to 'off' nothing is imported and no + * canvas context is requested; otherwise the scene code loads on demand, so it stays out of the + * main chunk. Null means "keep the CSS ground". + */ +export async function startBackgroundScene( + config: BackgroundSceneConfig | undefined, + capabilities: Capabilities, + options: StartOptions +): Promise { + const tier = resolveTier(config, capabilities); + if (tier === 'off') return null; + const { createScene } = await import('./Scene'); + return createScene({ ...options, tier }); +} diff --git a/src/app/features/scene/tier.test.ts b/src/app/features/scene/tier.test.ts new file mode 100644 index 00000000..d35b6bb6 --- /dev/null +++ b/src/app/features/scene/tier.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { resolveTier } from './tier'; + +describe('scene tier', () => { + it('is off unless the config turns it on, which is the feature flag', () => { + expect(resolveTier(undefined)).toBe('off'); + expect(resolveTier({})).toBe('off'); + expect(resolveTier({ tier: 'off' }, { deviceMemory: 16 })).toBe('off'); + expect(resolveTier({ tier: 'full' })).toBe('full'); + }); + + it('lets the device lower the configured tier and never raise it', () => { + expect(resolveTier({ tier: 'full' }, { saveData: true })).toBe('still'); + expect(resolveTier({ tier: 'full' }, { deviceMemory: 2 })).toBe('low'); + expect(resolveTier({ tier: 'full' }, { hardwareConcurrency: 4 })).toBe('low'); + expect(resolveTier({ tier: 'low' }, { saveData: true })).toBe('still'); + expect(resolveTier({ tier: 'still' }, { deviceMemory: 16, hardwareConcurrency: 16 })).toBe( + 'still' + ); + expect(resolveTier({ tier: 'low' }, { deviceMemory: 16, hardwareConcurrency: 16 })).toBe('low'); + }); + + it('treats unknown capabilities as capable', () => { + expect(resolveTier({ tier: 'full' }, {})).toBe('full'); + }); +}); diff --git a/src/app/features/scene/tier.ts b/src/app/features/scene/tier.ts new file mode 100644 index 00000000..1c1668c7 --- /dev/null +++ b/src/app/features/scene/tier.ts @@ -0,0 +1,49 @@ +// Which quality the background scene runs at. The client config's `backgroundScene.tier` is both +// the feature flag (absent or 'off' means the scene is not even imported) and the ceiling; the +// device can only lower it. + +export type SceneTier = 'off' | 'still' | 'low' | 'full'; + +/** Cheapest first. */ +export const TIERS: readonly SceneTier[] = ['off', 'still', 'low', 'full']; + +export interface Capabilities { + /** `navigator.connection.saveData`. */ + saveData?: boolean; + /** `navigator.deviceMemory` in GiB (Chromium only). */ + deviceMemory?: number; + hardwareConcurrency?: number; +} + +export interface BackgroundSceneConfig { + tier?: SceneTier; +} + +export function readCapabilities(nav: Navigator): Capabilities { + const n = nav as Navigator & { + deviceMemory?: number; + connection?: { saveData?: boolean }; + }; + return { + saveData: n.connection?.saveData, + deviceMemory: n.deviceMemory, + hardwareConcurrency: n.hardwareConcurrency, + }; +} + +/** The most the device is allowed to run, before the configured ceiling is applied. */ +function deviceCeiling(c: Capabilities): SceneTier { + if (c.saveData) return 'still'; + if ((c.deviceMemory ?? Infinity) <= 2 || (c.hardwareConcurrency ?? Infinity) <= 4) return 'low'; + return 'full'; +} + +/** The tier to run: the configured one (default 'off'), lowered by what the device can carry. */ +export function resolveTier( + config: BackgroundSceneConfig | undefined, + capabilities: Capabilities = {} +): SceneTier { + const configured = config?.tier ?? 'off'; + const ceiling = deviceCeiling(capabilities); + return TIERS.indexOf(configured) <= TIERS.indexOf(ceiling) ? configured : ceiling; +} diff --git a/src/app/features/scene/world.test.ts b/src/app/features/scene/world.test.ts index 87fa1983..caa04fad 100644 --- a/src/app/features/scene/world.test.ts +++ b/src/app/features/scene/world.test.ts @@ -9,9 +9,9 @@ import { terrainBounds, WORLD, worldDefines } from './world'; describe('world defines', () => { it('define every W_ name any scene shader uses', () => { const defined = new Set([...worldDefines().matchAll(/#define (W_\w+)/g)].map((m) => m[1])); - for (const src of [common, terrain, atmosphere, resolve, composite]) { - for (const m of src.matchAll(/\bW_[A-Z0-9_]+\b/g)) expect(defined, m[0]).toContain(m[0]); - } + [common, terrain, atmosphere, resolve, composite] + .flatMap((src) => [...src.matchAll(/\bW_[A-Z0-9_]+\b/g)]) + .forEach((m) => expect(defined, m[0]).toContain(m[0])); }); it('emit only valid GLSL numbers', () => { diff --git a/src/app/features/scene/world.ts b/src/app/features/scene/world.ts index a1c6cb57..ba9aec53 100644 --- a/src/app/features/scene/world.ts +++ b/src/app/features/scene/world.ts @@ -28,7 +28,8 @@ export const WORLD = { // shader passes roughness n = 0 as a literal, so the doc envelope max(10, 1.25|side|)/(1-n) // needs no 1/(1-n) factor here; if n ever becomes nonzero, divide this bound by (1 - n). // |value| <= 1 holds by construction (the output is a normalized phasor). - phacelleGradBound: 7.0, + // Typed number, not the literal 7, so callers can build variants such as { ...WORLD, phacelleGradBound: 0 }. + phacelleGradBound: 7.0 as number, // World tree at the origin. tree: { trunkHeight: 38, diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index e5fc6cc6..7a0586eb 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -1,4 +1,5 @@ import { createContext, useContext } from 'react'; +import type { BackgroundSceneConfig } from '../features/scene/tier'; export type HashRouterConfig = { enabled?: boolean; @@ -18,6 +19,8 @@ export type ClientConfig = { }; hashRouter?: HashRouterConfig; + + backgroundScene?: BackgroundSceneConfig; }; const ClientConfigContext = createContext(null); From 8c7113bcafab90fed550e41c5666da4b7823ab79 Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 2 Nov 2026 07:22:58 +0000 Subject: [PATCH 051/206] creatures: cite the Bifrost creature-props contract Header names heimdall-body/ratatoskr-creature-projection, the shape, invariants and consent rule, and marks the bucket scales as placeholders. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/creatures.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/app/features/scene/creatures.ts b/src/app/features/scene/creatures.ts index 7bb5d440..2daa541e 100644 --- a/src/app/features/scene/creatures.ts +++ b/src/app/features/scene/creatures.ts @@ -1,4 +1,15 @@ // Creature sizing for the orbiting personas. Only the rule lives here; drawing them is a later cut. +// +// Props contract: heimdall-body/ratatoskr-creature-projection (against GameCult/Bifrost). +// Nothing here is binding until Bifrost's steward cuts that contract. +// +// Shape: {id, estate, sizeBucket, neutral, activity}. +// Invariants: +// - Exact vote weights stay in Bifrost; Ratatoskr only ever sees a bucket. +// - neutral=true overrides any bucket, and no size is drawn. +// - activity comes from the Heimdall session. +// Consent: humans get a coarse bucket by default plus a typed opt-out; Minds and Personas may show finer buckets. +// Humans have three coarse buckets. The scales below (0.7 / 1 / 1.4) are placeholders, not fixed. export type SizeBucket = 'small' | 'medium' | 'large'; From 0ec34ff19fe9c0de3d45e8532ef25931de09d439 Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 2 Nov 2026 10:14:52 +0000 Subject: [PATCH 052/206] creatures: cite creature-projection-v2 Header cites heimdall-body:follow_up:ratatoskr-creature-projection-v2, which supersedes heimdall-body/ratatoskr-creature-projection. Citation only. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/creatures.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/features/scene/creatures.ts b/src/app/features/scene/creatures.ts index 2daa541e..5026d2e2 100644 --- a/src/app/features/scene/creatures.ts +++ b/src/app/features/scene/creatures.ts @@ -1,6 +1,6 @@ // Creature sizing for the orbiting personas. Only the rule lives here; drawing them is a later cut. // -// Props contract: heimdall-body/ratatoskr-creature-projection (against GameCult/Bifrost). +// Props contract: heimdall-body:follow_up:ratatoskr-creature-projection-v2 (against GameCult/Bifrost; supersedes heimdall-body/ratatoskr-creature-projection). // Nothing here is binding until Bifrost's steward cuts that contract. // // Shape: {id, estate, sizeBucket, neutral, activity}. From c4c00d7127d9c29582f591431278ddc47b69360c Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 2 Nov 2026 14:39:00 +0000 Subject: [PATCH 053/206] soundscape: audio socket over Aqua's activity frame (flag off, muted, untested by ear) Reads the grammar frame (AquaSynth docs/soundscape-grammar.md @ aeae510) into a per-mind table, plans voices (neutral overrides all, Muted, cap, master mute, weak-device off) and the drawn set. No activity source exists yet (ratatoskr:question:activity-feed-source) and no audio assets, so nothing sounds. Cites heimdall-body:follow_up:ratatoskr-creature-projection-v2. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/soundscape/device.ts | 12 ++ src/app/features/soundscape/frame.ts | 95 ++++++++++ src/app/features/soundscape/scene.ts | 8 + src/app/features/soundscape/socket.ts | 47 +++++ .../features/soundscape/soundscape.test.ts | 171 ++++++++++++++++++ src/app/features/soundscape/voices.ts | 49 +++++ src/app/state/settings.ts | 11 ++ 7 files changed, 393 insertions(+) create mode 100644 src/app/features/soundscape/device.ts create mode 100644 src/app/features/soundscape/frame.ts create mode 100644 src/app/features/soundscape/scene.ts create mode 100644 src/app/features/soundscape/socket.ts create mode 100644 src/app/features/soundscape/soundscape.test.ts create mode 100644 src/app/features/soundscape/voices.ts diff --git a/src/app/features/soundscape/device.ts b/src/app/features/soundscape/device.ts new file mode 100644 index 00000000..ecdf1593 --- /dev/null +++ b/src/app/features/soundscape/device.ts @@ -0,0 +1,12 @@ +export interface DeviceHints { + deviceMemory?: number; + hardwareConcurrency?: number; +} + +/** + * Off on weak phones. The thresholds are placeholders: no phone has been measured (grammar + * section 7, item 4). Unknown hints (Safari reports neither) do not count as weak. + */ +export const isWeakDevice = ({ deviceMemory, hardwareConcurrency }: DeviceHints): boolean => + (deviceMemory !== undefined && deviceMemory < 4) || + (hardwareConcurrency !== undefined && hardwareConcurrency < 4); diff --git a/src/app/features/soundscape/frame.ts b/src/app/features/soundscape/frame.ts new file mode 100644 index 00000000..7d7b2476 --- /dev/null +++ b/src/app/features/soundscape/frame.ts @@ -0,0 +1,95 @@ +// The activity frame of Aqua's soundscape grammar (AquaSynth docs/soundscape-grammar.md at aeae510, +// section 2). The Heimdall session produces it; this file only reads it. Shape follows +// heimdall-body:follow_up:ratatoskr-creature-projection-v2 as the grammar relays it. + +export type Estate = 'human' | 'mind' | 'persona'; +export type Kind = 'Idle' | 'Reading' | 'Thinking' | 'Working' | 'Speaking' | 'Waiting' | 'Alert'; +export type Energy = 'Low' | 'Mid' | 'High' | 'Peak'; +export type Size = 'S' | 'M' | 'L'; +export type Override = 'None' | 'Muted'; + +export interface ActivityFrame { + mind: string; + estate: Estate; + kind: Kind; + energy: Energy; + size: Size; + neutral: boolean; + override: Override; + seq: number; + /** Producer observation time, whole seconds (grammar: 1 s resolution). */ + at: number; +} + +const ESTATES: readonly Estate[] = ['human', 'mind', 'persona']; +const KINDS: readonly Kind[] = [ + 'Idle', + 'Reading', + 'Thinking', + 'Working', + 'Speaking', + 'Waiting', + 'Alert', +]; +const ENERGIES: readonly Energy[] = ['Low', 'Mid', 'High', 'Peak']; +const SIZES: readonly Size[] = ['S', 'M', 'L']; + +/** Grammar S2 (proposed, unmeasured): a frame older than this is absent. */ +export const STALE_AFTER_S = 10; + +const oneOf = (set: readonly T[], v: unknown): T | undefined => + set.find((s) => s === v); + +/** Grammar estate rule: a consumer clamps a human's energy to Low/Mid/High. */ +export const clampEnergy = (estate: Estate, energy: Energy): Energy => + estate === 'human' && energy === 'Peak' ? 'High' : energy; + +/** + * Read an untrusted frame. Only the grammar's fields survive: any other key (a weight, a vote, a + * room id) is dropped, so a consumer cannot be steered by data the grammar does not define (A3). + * Returns undefined for anything malformed; the caller treats that as no frame (A4). + */ +export const readFrame = (raw: unknown): ActivityFrame | undefined => { + if (typeof raw !== 'object' || raw === null) return undefined; + const r = raw as Record; + const estate = oneOf(ESTATES, r.estate); + const kind = oneOf(KINDS, r.kind); + const energy = oneOf(ENERGIES, r.energy); + const size = oneOf(SIZES, r.size); + const override = + r.override === undefined ? 'None' : oneOf(['None', 'Muted'] as const, r.override); + if (!estate || !kind || !energy || !size || !override) return undefined; + if (typeof r.mind !== 'string' || r.mind === '' || typeof r.neutral !== 'boolean') + return undefined; + if (!Number.isInteger(r.seq) || !Number.isFinite(r.at)) return undefined; + return { + mind: r.mind, + estate, + kind, + energy: clampEnergy(estate, energy), + size, + neutral: r.neutral, + override, + seq: r.seq as number, + at: r.at as number, + }; +}; + +/** Latest frame per mind (S1: consumers drop seq <= last). */ +export class FrameTable { + private frames = new Map(); + + offer(raw: unknown): boolean { + const f = readFrame(raw); + if (!f) return false; + const last = this.frames.get(f.mind); + if (last && f.seq <= last.seq) return false; + this.frames.set(f.mind, f); + return true; + } + + /** Frames not stale at `nowS`; a missing or stale frame is simply absent (A4), not neutral. */ + fresh(nowS: number): ActivityFrame[] { + return [...this.frames.values()].filter((f) => nowS - f.at <= STALE_AFTER_S); + } +} diff --git a/src/app/features/soundscape/scene.ts b/src/app/features/soundscape/scene.ts new file mode 100644 index 00000000..027cc27a --- /dev/null +++ b/src/app/features/soundscape/scene.ts @@ -0,0 +1,8 @@ +import type { ActivityFrame, Size } from './frame'; + +/** Placeholders owned by the orbit renderer, not the frame (grammar, Estate section). Unmeasured. */ +export const PLACEHOLDER_SIZE_SCALE: Record = { S: 0.7, M: 1, L: 1.4 }; + +/** O1: a neutral participant gets no orbit. There is no neutral pose; it is not in the list. */ +export const drawnCreatures = (frames: readonly ActivityFrame[]): ActivityFrame[] => + frames.filter((f) => !f.neutral); diff --git a/src/app/features/soundscape/socket.ts b/src/app/features/soundscape/socket.ts new file mode 100644 index 00000000..e1d23010 --- /dev/null +++ b/src/app/features/soundscape/socket.ts @@ -0,0 +1,47 @@ +import { FrameTable } from './frame'; +import { drawnCreatures } from './scene'; +import { planVoices, type SoundscapePrefs, type VoiceCommand } from './voices'; +import { isWeakDevice, type DeviceHints } from './device'; + +/** + * Where frames come from is `ratatoskr:question:activity-feed-source`, still open. Until the + * Heimdall session feed exists the only source is `silentSource`. A source never reads a room's + * member list (A1): being in a room says nothing about being active. + */ +export interface ActivitySource { + subscribe(onFrame: (raw: unknown) => void): () => void; +} +const noop = () => undefined; +export const silentSource: ActivitySource = { subscribe: () => noop }; + +/** Receives the plan. No audio assets exist yet, so nothing in this repo sounds. */ +export interface VoiceSink { + play(commands: readonly VoiceCommand[]): void; +} + +export interface AudioSocketDeps { + source: ActivitySource; + sink: VoiceSink; + prefs: () => SoundscapePrefs; + device: () => DeviceHints; + /** Whole seconds. */ + nowS: () => number; +} + +export const createAudioSocket = (deps: AudioSocketDeps) => { + const table = new FrameTable(); + const unsubscribe = deps.source.subscribe((raw) => table.offer(raw)); + return { + /** Called by the scheduler tick; a hidden page simply stops calling it (S5). */ + tick(): VoiceCommand[] { + const prefs = deps.prefs(); + const effective = { ...prefs, enabled: prefs.enabled && !isWeakDevice(deps.device()) }; + const commands = planVoices(table.fresh(deps.nowS()), effective); + deps.sink.play(commands); + return commands; + }, + /** What the orbit renderer draws: the same table, neutral removed. */ + drawn: () => drawnCreatures(table.fresh(deps.nowS())), + close: unsubscribe, + }; +}; diff --git a/src/app/features/soundscape/soundscape.test.ts b/src/app/features/soundscape/soundscape.test.ts new file mode 100644 index 00000000..e07db5b9 --- /dev/null +++ b/src/app/features/soundscape/soundscape.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it, vi } from 'vitest'; +import { FrameTable, readFrame, STALE_AFTER_S, type ActivityFrame } from './frame'; +import { drawnCreatures, PLACEHOLDER_SIZE_SCALE } from './scene'; +import { DEFAULT_VOICE_CAP, planVoices, type SoundscapePrefs } from './voices'; +import { isWeakDevice } from './device'; +import { createAudioSocket, silentSource, type ActivitySource } from './socket'; + +const frame = (o: Partial = {}): ActivityFrame => ({ + mind: 'void', + estate: 'mind', + kind: 'Working', + energy: 'Mid', + size: 'M', + neutral: false, + override: 'None', + seq: 1, + at: 100, + ...o, +}); +const noop = () => undefined; +const on: SoundscapePrefs = { enabled: true, muted: false, volume: 0.5, voiceCap: 4 }; + +describe('neutral overrides everything', () => { + it('draws nothing and sounds nothing for any kind, energy, size or estate', () => { + const cases = (['human', 'mind', 'persona'] as const).flatMap((estate) => + (['Speaking', 'Alert', 'Working'] as const).flatMap((kind) => + (['High', 'Peak'] as const).flatMap((energy) => + (['S', 'M', 'L'] as const).map((size) => + frame({ neutral: true, estate, kind, energy, size }) + ) + ) + ) + ); + expect(cases).toHaveLength(54); + cases.forEach((f) => { + expect(drawnCreatures([f])).toEqual([]); + expect(planVoices([f], on)).toEqual([]); + }); + }); + it('is not the same as an absent frame: absent reads as idle, neutral as hidden', () => { + const t = new FrameTable(); + t.offer(frame({ at: 100 })); + expect(drawnCreatures(t.fresh(100 + STALE_AFTER_S + 1))).toEqual([]); + expect(drawnCreatures(t.fresh(100)).length).toBe(1); + }); +}); + +describe('human energy is clamped to three buckets by the consumer', () => { + it('turns Peak into High for humans only', () => { + expect(readFrame(frame({ estate: 'human', energy: 'Peak' }))?.energy).toBe('High'); + expect(readFrame(frame({ estate: 'mind', energy: 'Peak' }))?.energy).toBe('Peak'); + expect(readFrame(frame({ estate: 'persona', energy: 'Peak' }))?.energy).toBe('Peak'); + }); + it('keeps the clamp through the voice plan', () => { + const t = new FrameTable(); + t.offer(frame({ estate: 'human', energy: 'Peak' })); + expect(planVoices(t.fresh(100), on)[0].energy).toBe('High'); + }); +}); + +describe('activity is a stub, never the room member list', () => { + it('the silent source and the socket yield nothing, however many members a room has', () => { + const members = ['@a:x', '@b:x', '@c:x']; + const sock = createAudioSocket({ + source: silentSource, + sink: { play: noop }, + prefs: () => on, + device: () => ({}), + nowS: () => 100, + }); + expect(members.length).toBeGreaterThan(0); + expect(sock.tick()).toEqual([]); + expect(sock.drawn()).toEqual([]); + }); + it('only frames the source pushes produce voices', () => { + const pushes: ((raw: unknown) => void)[] = []; + const source: ActivitySource = { + subscribe: (cb) => { + pushes.push(cb); + return noop; + }, + }; + const sock = createAudioSocket({ + source, + sink: { play: noop }, + prefs: () => on, + device: () => ({}), + nowS: () => 100, + }); + pushes[0](frame({ mind: 'aqua' })); + expect(sock.tick().map((v) => v.mind)).toEqual(['aqua']); + }); +}); + +describe('no vote weights', () => { + it('drops fields the grammar does not define, and the plan carries only user volume as gain', () => { + const f = readFrame({ ...frame(), weight: 9, votes: 40, roomId: '!r:x', brief: 'secret' }); + expect(Object.keys(f ?? {}).sort()).toEqual( + ['at', 'energy', 'estate', 'kind', 'mind', 'neutral', 'override', 'seq', 'size'].sort() + ); + const t = new FrameTable(); + t.offer({ ...frame(), weight: 9 }); + expect(planVoices(t.fresh(100), on)[0].gain).toBe(0.5); + }); +}); + +describe('size scales are placeholders', () => { + it('live in the renderer table, not in the frame, and change nothing about voices', () => { + expect(PLACEHOLDER_SIZE_SCALE).toEqual({ S: 0.7, M: 1, L: 1.4 }); + const s = planVoices([frame({ size: 'S' })], on); + const l = planVoices([frame({ size: 'L' })], on); + expect(s).toEqual(l); + }); +}); + +describe('socket defaults and limits', () => { + it('ships off, muted, with the proposed cap', async () => { + vi.stubGlobal('localStorage', { getItem: () => null }); + const { getSettings } = await import('../../state/settings'); + const s = getSettings(); + expect(s.soundscapeEnabled).toBe(false); + expect(s.soundscapeMuted).toBe(true); + expect(s.soundscapeVoiceCap).toBe(DEFAULT_VOICE_CAP); + }); + it('master mute, disabled flag and zero volume all silence', () => { + const f = [frame()]; + expect(planVoices(f, { ...on, muted: true })).toEqual([]); + expect(planVoices(f, { ...on, enabled: false })).toEqual([]); + expect(planVoices(f, { ...on, volume: 0 })).toEqual([]); + }); + it('caps voices, dropping the lowest energy first', () => { + const f = ['a', 'b', 'c', 'd', 'e'].map((mind, i) => + frame({ mind, energy: (['Low', 'Mid', 'High', 'Peak', 'Mid'] as const)[i] }) + ); + const voices = planVoices(f, { ...on, voiceCap: 4 }).map((v) => v.mind); + expect(voices).toHaveLength(4); + expect(voices).not.toContain('a'); + }); + it('Muted silences the voice but the creature is still drawn; Idle/Waiting are silent', () => { + const f = [frame({ override: 'Muted' }), frame({ mind: 'x', kind: 'Idle' })]; + expect(planVoices(f, on)).toEqual([]); + expect(drawnCreatures(f)).toHaveLength(2); + }); + it('is off on weak phones even when the flag is on, and unknown hints are not weak', () => { + expect(isWeakDevice({ deviceMemory: 2 })).toBe(true); + expect(isWeakDevice({ hardwareConcurrency: 2 })).toBe(true); + expect(isWeakDevice({})).toBe(false); + const played: number[] = []; + const sock = createAudioSocket({ + source: { + subscribe: (cb) => { + cb(frame()); + return noop; + }, + }, + sink: { play: (c) => played.push(c.length) }, + prefs: () => on, + device: () => ({ deviceMemory: 1 }), + nowS: () => 100, + }); + expect(sock.tick()).toEqual([]); + expect(played).toEqual([0]); + }); + it('drops frames with seq <= last, and malformed frames', () => { + const t = new FrameTable(); + expect(t.offer(frame({ seq: 5 }))).toBe(true); + expect(t.offer(frame({ seq: 5 }))).toBe(false); + expect(t.offer(frame({ seq: 4 }))).toBe(false); + expect(t.offer({ ...frame(), kind: 'Dancing' })).toBe(false); + }); +}); diff --git a/src/app/features/soundscape/voices.ts b/src/app/features/soundscape/voices.ts new file mode 100644 index 00000000..d20e55ad --- /dev/null +++ b/src/app/features/soundscape/voices.ts @@ -0,0 +1,49 @@ +import type { ActivityFrame, Energy, Kind } from './frame'; + +/** Grammar V3 (proposed, unmeasured): voices audible at once. */ +export const DEFAULT_VOICE_CAP = 4; + +/** Local listener preferences. Never written back into a frame (O3). */ +export interface SoundscapePrefs { + /** The feature flag. Default off. */ + enabled: boolean; + /** Master mute. Default on. */ + muted: boolean; + /** 0..1 */ + volume: number; + voiceCap: number; +} + +export interface VoiceCommand { + mind: string; + kind: Kind; + energy: Energy; + gain: number; +} + +const ENERGY_RANK: Record = { Low: 0, Mid: 1, High: 2, Peak: 3 }; +/** Grammar section 3: Idle and Waiting default silent. */ +const SILENT_KINDS: ReadonlySet = new Set(['Idle', 'Waiting']); + +const clamp01 = (v: number) => (Number.isFinite(v) ? Math.min(1, Math.max(0, v)) : 0); + +/** + * Which voices speak now. Neutral overrides everything (O1); `Muted` silences one Mind (O2); + * over the cap the lowest energy drops first (V3, ties by mind id so the result is stable). + * Identity is the mind id only (V1); `gain` is the user's volume, not a vote weight. + * Lead exemption in V3 is not implemented: the grammar does not say how a lead is chosen. + */ +export const planVoices = ( + frames: readonly ActivityFrame[], + prefs: SoundscapePrefs +): VoiceCommand[] => { + if (!prefs.enabled || prefs.muted) return []; + const gain = clamp01(prefs.volume); + if (gain === 0) return []; + const cap = Math.max(0, Math.floor(prefs.voiceCap)); + return frames + .filter((f) => !f.neutral && f.override !== 'Muted' && !SILENT_KINDS.has(f.kind)) + .sort((a, b) => ENERGY_RANK[b.energy] - ENERGY_RANK[a.energy] || a.mind.localeCompare(b.mind)) + .slice(0, cap) + .map((f) => ({ mind: f.mind, kind: f.kind, energy: f.energy, gain })); +}; diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 6a894350..d4deb39c 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -47,6 +47,12 @@ export interface Settings { dateFormatString: string; developerTools: boolean; + + /** Soundscape flag: default off, and muted by default even when on. */ + soundscapeEnabled: boolean; + soundscapeMuted: boolean; + soundscapeVolume: number; + soundscapeVoiceCap: number; } const defaultSettings: Settings = { @@ -81,6 +87,11 @@ const defaultSettings: Settings = { dateFormatString: 'D MMM YYYY', developerTools: false, + + soundscapeEnabled: false, + soundscapeMuted: true, + soundscapeVolume: 0.5, + soundscapeVoiceCap: 4, }; export const getSettings = () => { From 1f4341dab64d6c90fe07ba4f98056200314142d7 Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 2 Nov 2026 14:43:33 +0000 Subject: [PATCH 054/206] scene: live reduced-motion, chunk-load failure, per-stage CultMath - matchMedia 'change' listener freezes/thaws a running scene; removed on dispose and context loss - startBackgroundScene catches a failed scene chunk import: onCompileFail, null, no throw - shaderSource compiles each stage with only the CultMath declarations it reaches (cultmathTrim.ts); Phacelle stays a separate file, terrain only - README: 'still' is a live rendered frame Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/README.md | 10 ++- src/app/features/scene/Scene.test.ts | 89 ++++++++++++++++++++- src/app/features/scene/Scene.ts | 7 ++ src/app/features/scene/cultmathTrim.test.ts | 82 +++++++++++++++++++ src/app/features/scene/cultmathTrim.ts | 81 +++++++++++++++++++ src/app/features/scene/index.ts | 16 +++- src/app/features/scene/shaderSource.ts | 9 ++- src/app/features/scene/shaders/README.md | 4 + 8 files changed, 288 insertions(+), 10 deletions(-) create mode 100644 src/app/features/scene/cultmathTrim.test.ts create mode 100644 src/app/features/scene/cultmathTrim.ts diff --git a/src/app/features/scene/README.md b/src/app/features/scene/README.md index 0d49d57d..be117a0a 100644 --- a/src/app/features/scene/README.md +++ b/src/app/features/scene/README.md @@ -14,15 +14,17 @@ callbacks and the creature sizing rule. Nothing has drawn a creature. door. The client config's `backgroundScene.tier` (`off` default, `still`, `low`, `full`) is the feature flag and the ceiling; `tier.ts` lowers it for `saveData`, low memory or few cores. At `off` nothing is imported and no canvas context is requested; otherwise `Scene.ts` loads on - demand, so it stays out of the main chunk. + demand, so it stays out of the main chunk; if that chunk fails to load the error goes to + `onCompileFail` and the door returns null, it does not throw. - `Scene.ts` `createScene({ canvas, tier, onCompileFail, onContextLost, ... })` returns `{ login, dispose }`, or `null` (keep the CSS ground then) when the tier is `off`, WebGL2 or `EXT_color_buffer_float` is missing, or a shader fails to compile (`onCompileFail` gets the - `ShaderCompileError`; programs already linked are freed). `still` draws one frame at the top - level and stops; `low` starts the ladder at level 2; `full` at level 0. A lost WebGL context + `ShaderCompileError`; programs already linked are freed). `still` is a live rendered frame, not a + baked image: it runs the full pipeline once at the top level and stops; `low` starts the ladder at level 2; `full` at level 0. A lost WebGL context stops the scene for good and calls `onContextLost`; nothing rebuilds it, the host decides what to show. It caps at 30 fps, pauses when - the tab is hidden, renders 32 frames and stops under `prefers-reduced-motion`, and steps down a + the tab is hidden, renders 32 frames and stops under `prefers-reduced-motion` (a change of the + preference while it runs freezes or thaws it), and steps down a quality ladder (`budget.ts`) against a 4 ms GPU budget when timer queries exist. - `creatures.ts` the creature record and `creatureScale`: a neutral creature is one fixed size and overrides `sizeBucket`. The bucket names and scales are placeholders until the persona cut owns them. diff --git a/src/app/features/scene/Scene.test.ts b/src/app/features/scene/Scene.test.ts index 97988687..25b7f08d 100644 --- a/src/app/features/scene/Scene.test.ts +++ b/src/app/features/scene/Scene.test.ts @@ -49,14 +49,16 @@ function fakeCanvas(gl: WebGL2RenderingContext | null) { let rafQueue: Array<(t: number) => void>; let cancelled: number; let win: EventTarget; +let motion: EventTarget & { matches: boolean }; let doc: EventTarget & { visibilityState: string }; beforeEach(() => { rafQueue = []; cancelled = 0; + motion = Object.assign(new EventTarget(), { matches: false }); win = Object.assign(new EventTarget(), { devicePixelRatio: 1, - matchMedia: () => ({ matches: false }), + matchMedia: () => motion, }); doc = Object.assign(new EventTarget(), { visibilityState: 'visible' }); vi.stubGlobal('window', win); @@ -180,6 +182,69 @@ describe('context loss', () => { }); }); +describe('prefers-reduced-motion', () => { + const runAll = (t0: number) => { + let t = t0; + let n = 0; + while (rafQueue.length && n < 200) { + runFrame(t); + t += 40; + n += 1; + } + return n; + }; + + it('freezes to a bounded run when the preference turns on mid-flight', () => { + const { gl } = fakeGl(); + createScene({ canvas: asCanvas(fakeCanvas(gl)) }); + runFrame(1000); + expect(rafQueue).toHaveLength(1); // moving: keeps going + motion.matches = true; + motion.dispatchEvent(new Event('change')); + expect(runAll(1040)).toBeLessThan(200); // ends + expect(rafQueue).toHaveLength(0); + }); + + it('thaws a frozen scene when the preference turns off', () => { + motion.matches = true; + const { gl } = fakeGl(); + createScene({ canvas: asCanvas(fakeCanvas(gl)) }); + runAll(1000); + expect(rafQueue).toHaveLength(0); + motion.matches = false; + motion.dispatchEvent(new Event('change')); + expect(rafQueue).toHaveLength(1); + runFrame(5000); + expect(rafQueue).toHaveLength(1); // unbounded again + }); + + it('removes the listener on dispose and on context loss', () => { + const { gl } = fakeGl(); + const scene = createScene({ canvas: asCanvas(fakeCanvas(gl)) }); + scene?.dispose(); + rafQueue.length = 0; + motion.dispatchEvent(new Event('change')); + expect(rafQueue).toHaveLength(0); + + const lostCanvas = fakeCanvas(fakeGl().gl); + createScene({ canvas: asCanvas(lostCanvas) }); + lostCanvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true })); + rafQueue.length = 0; + motion.matches = true; + motion.dispatchEvent(new Event('change')); + expect(rafQueue).toHaveLength(0); + }); + + it('a still does not listen: one frame stays one frame', () => { + const { gl } = fakeGl(); + createScene({ canvas: asCanvas(fakeCanvas(gl)), tier: 'still' }); + runFrame(1000); + motion.matches = false; + motion.dispatchEvent(new Event('change')); + expect(rafQueue).toHaveLength(0); + }); +}); + describe('startBackgroundScene', () => { it("does not request a context when the config leaves the tier 'off'", async () => { const { gl } = fakeGl(); @@ -204,6 +269,28 @@ describe('startBackgroundScene', () => { expect(rafQueue).toHaveLength(0); }); + it('reports a failed scene chunk to onCompileFail and returns null instead of throwing', async () => { + vi.resetModules(); + vi.doMock('./Scene', () => { + throw new Error('Failed to fetch dynamically imported module'); + }); + try { + const { startBackgroundScene: start } = await import('./index'); + const onCompileFail = vi.fn(); + const canvas = fakeCanvas(fakeGl().gl); + const scene = await start({ tier: 'full' }, {}, { canvas: asCanvas(canvas), onCompileFail }); + expect(scene).toBeNull(); + expect(onCompileFail).toHaveBeenCalledTimes(1); + expect(onCompileFail.mock.calls[0][0]).toBeInstanceOf(Error); + expect(canvas.getContext).not.toHaveBeenCalled(); + // and with no callback it still does not throw + expect(await start({ tier: 'full' }, {}, { canvas: asCanvas(canvas) })).toBeNull(); + } finally { + vi.doUnmock('./Scene'); + vi.resetModules(); + } + }); + it('hands compile failure to the host and returns null, the fall to the CSS ground', async () => { const { gl } = fakeGl(0); const onCompileFail = vi.fn(); diff --git a/src/app/features/scene/Scene.ts b/src/app/features/scene/Scene.ts index 10dacc58..40ce300d 100644 --- a/src/app/features/scene/Scene.ts +++ b/src/app/features/scene/Scene.ts @@ -448,6 +448,13 @@ export function createScene(opts: SceneOptions): Scene | null { opts.onContextLost?.(); }); if (reducedMotion?.matches && !still) framesLeft = REDUCED_MOTION_FRAMES; + // The preference can flip while the scene lives: freeze to a bounded run, or thaw to a free one. + if (reducedMotion && !still) { + listen(reducedMotion, 'change', () => { + framesLeft = reducedMotion.matches ? REDUCED_MOTION_FRAMES : Infinity; + wake(); + }); + } wake(); return { diff --git a/src/app/features/scene/cultmathTrim.test.ts b/src/app/features/scene/cultmathTrim.test.ts new file mode 100644 index 00000000..cef6b281 --- /dev/null +++ b/src/app/features/scene/cultmathTrim.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; +import { trimGlsl } from './cultmathTrim'; +import { fragmentSource, Stage } from './shaderSource'; +import cultmath from './shaders/CultMath.glsl?raw'; + +const LIB = `// header +#ifndef LIB_GLSL +#define LIB_GLSL +const float LIB_PI = 3.14; + +float lib_half(float v) { return v * 0.5; } +vec2 lib_half(vec2 v) { return v * 0.5; } + +// uses the constant and an overload set +float lib_pi_half(float v) { return lib_half(v) * LIB_PI; } + +struct LibPair { float a; float b; }; + +LibPair lib_pair(float v) { LibPair p; p.a = v; p.b = lib_half(v); return p; } +float lib_unused(float v) { return v; } +#endif +`; + +describe('trimGlsl', () => { + it('keeps what a root names and what that reaches, and drops the rest', () => { + const out = trimGlsl(LIB, ['float x = lib_pi_half(1.0);']); + expect(out).toContain('lib_pi_half'); + expect(out).toContain('LIB_PI'); + expect(out).toContain('float lib_half'); + expect(out).toContain('vec2 lib_half'); // overloads stay together + expect(out).not.toContain('lib_unused'); + expect(out).not.toContain('LibPair'); + expect(out).not.toContain('lib_pair'); + }); + + it('keeps a struct that a kept function returns', () => { + const out = trimGlsl(LIB, ['LibPair p = lib_pair(1.0);']); + expect(out).toContain('struct LibPair'); + expect(out).toContain('lib_half'); + expect(out).not.toContain('lib_pi_half'); + }); + + it('keeps the include guard balanced', () => { + const out = trimGlsl(LIB, ['lib_half(1.0)']); + expect(out).toMatch(/^#ifndef LIB_GLSL\n#define LIB_GLSL\n/); + expect(out.trimEnd().endsWith('#endif')).toBe(true); + }); + + it('keeps nothing but the guard for a root that names nothing', () => { + const out = trimGlsl(LIB, ['void main() {}']); + expect(out).not.toContain('lib_'); + expect(out).toContain('#ifndef LIB_GLSL'); + }); +}); + +describe('CultMath per shader stage', () => { + const stages: Stage[] = ['terrain', 'atmosphere', 'resolve', 'composite']; + const cultmathNames = (text: string) => new Set(text.match(/\bcultmath_\w+/g) ?? []); + const defined = (src: string, name: string) => + new RegExp(`^\\s*(?:float|vec[234]|mat3|int|\\w+)\\s+${name}\\s*\\(`, 'm').test(src); + + it.each(stages)('%s defines every CultMath function it, or Phacelle, calls', (stage) => { + const src = fragmentSource(stage); + cultmathNames(src).forEach((n) => expect(defined(src, n), n).toBe(true)); + }); + + it.each(stages)('%s is smaller than the whole library', (stage) => { + expect(fragmentSource(stage).length).toBeLessThan(cultmath.length / 2); + }); + + it('puts the noise a stage calls in that stage and not in the others', () => { + expect(fragmentSource('terrain')).toContain('cultmath_snoise'); + expect(fragmentSource('composite')).not.toContain('cultmath_snoise'); + }); + + it('adds Phacelle to terrain alone, as its own file', () => { + expect(fragmentSource('terrain')).toContain('cultmath_phacelle'); + stages + .filter((s) => s !== 'terrain') + .forEach((s) => expect(fragmentSource(s)).not.toContain('cultmath_phacelle')); + }); +}); diff --git a/src/app/features/scene/cultmathTrim.ts b/src/app/features/scene/cultmathTrim.ts new file mode 100644 index 00000000..fc246e5c --- /dev/null +++ b/src/app/features/scene/cultmathTrim.ts @@ -0,0 +1,81 @@ +/** + * Keeps the part of CultMath's GLSL that a stage can reach. The vendored file is whole and never + * edited; each stage compiles only the declarations its own source (and the source concatenated + * beside it) names, directly or through other kept declarations. Overloads share a name and are + * kept together. + */ + +interface Decl { + name: string; + text: string; +} + +const GUARD_OPEN = /^#ifndef\s+\w+\s*\n#define\s+\w+\s*\n/m; +const GUARD_CLOSE = /\n#endif\s*$/; + +/** Name a top-level declaration defines: function, struct or const. */ +function declName(text: string): string | undefined { + const code = text.replace(/\/\/.*$/gm, ''); + if (!code.includes('{')) return /\b(\w+)\s*=/.exec(code)?.[1]; + return /\bstruct\s+(\w+)/.exec(code)?.[1] ?? /\b(\w+)\s*\(/.exec(code)?.[1]; +} + +/** Splits at brace depth 0: a declaration ends at the `;` or `}` that returns to depth 0. */ +function declarations(body: string): Decl[] { + const out: Decl[] = []; + let depth = 0; + let start = 0; + let i = 0; + while (i < body.length) { + const c = body[i]; + if (c === '/' && body[i + 1] === '/') { + const eol = body.indexOf('\n', i); + i = eol < 0 ? body.length : eol; + } else if (c === '{') { + depth += 1; + } else if ((c === '}' || c === ';') && depth - (c === '}' ? 1 : 0) === 0) { + if (c === '}') depth -= 1; + // a struct's closing brace is followed by `;` + const end = c === '}' && /^\s*;/.test(body.slice(i + 1)) ? body.indexOf(';', i) : i; + const text = body.slice(start, end + 1); + const name = declName(text); + if (name) out.push({ name, text }); + start = end + 1; + i = end; + } else if (c === '}') { + depth -= 1; + } + i += 1; + } + return out; +} + +const idents = (text: string): string[] => text.match(/\b[A-Za-z_]\w*\b/g) ?? []; + +/** `library` trimmed to what `roots` reach; the include guard is kept. */ +export function trimGlsl(library: string, roots: string[]): string { + const open = GUARD_OPEN.exec(library)?.[0] ?? ''; + const close = GUARD_CLOSE.test(library) ? '\n#endif\n' : ''; + const body = library + .slice(open ? library.indexOf(open) + open.length : 0) + .replace(GUARD_CLOSE, ''); + const decls = declarations(body); + const byName = new Map(); + decls.forEach((d) => byName.set(d.name, [...(byName.get(d.name) ?? []), d])); + + const kept = new Set(); + const queue = roots.flatMap(idents); + while (queue.length) { + const group = byName.get(queue.pop() ?? ''); + group?.forEach((d) => { + if (kept.has(d)) return; + kept.add(d); + queue.push(...idents(d.text)); + }); + } + const text = decls + .filter((d) => kept.has(d)) + .map((d) => d.text.replace(/^\s*\n/, '')) + .join('\n'); + return `${open}${text}${close}`; +} diff --git a/src/app/features/scene/index.ts b/src/app/features/scene/index.ts index f4109796..c896649e 100644 --- a/src/app/features/scene/index.ts +++ b/src/app/features/scene/index.ts @@ -4,12 +4,16 @@ import { BackgroundSceneConfig, Capabilities, resolveTier } from './tier'; export type { Scene, SceneStats } from './Scene'; export type { SceneTier } from './tier'; -export type StartOptions = Omit; +export type StartOptions = Omit & { + /** A shader failed to compile, or the scene chunk failed to load. The host keeps its ground. */ + onCompileFail?: (e: Error) => void; +}; /** * The client's one door to the scene. When the tier resolves to 'off' nothing is imported and no * canvas context is requested; otherwise the scene code loads on demand, so it stays out of the - * main chunk. Null means "keep the CSS ground". + * main chunk. Null means "keep the CSS ground", including when that chunk fails to load (offline, + * stale deploy): the failure goes to onCompileFail and nothing throws. */ export async function startBackgroundScene( config: BackgroundSceneConfig | undefined, @@ -18,6 +22,12 @@ export async function startBackgroundScene( ): Promise { const tier = resolveTier(config, capabilities); if (tier === 'off') return null; - const { createScene } = await import('./Scene'); + let createScene: typeof import('./Scene').createScene; + try { + ({ createScene } = await import('./Scene')); + } catch (e) { + options.onCompileFail?.(e instanceof Error ? e : new Error(String(e))); + return null; + } return createScene({ ...options, tier }); } diff --git a/src/app/features/scene/shaderSource.ts b/src/app/features/scene/shaderSource.ts index aa44d9b2..c7a2a03f 100644 --- a/src/app/features/scene/shaderSource.ts +++ b/src/app/features/scene/shaderSource.ts @@ -7,6 +7,7 @@ import atmosphereFrag from './shaders/atmosphere.frag.glsl?raw'; import resolveFrag from './shaders/resolve.frag.glsl?raw'; import compositeFrag from './shaders/composite.frag.glsl?raw'; import { WORLD, worldDefines } from './world'; +import { trimGlsl } from './cultmathTrim'; export type Stage = 'terrain' | 'atmosphere' | 'resolve' | 'composite'; @@ -22,8 +23,12 @@ const stages: Record = { composite: compositeFrag, }; -/** Fragment source for a stage: header, world defines, CultMath, the shared block, the stage. */ +/** + * Fragment source for a stage: header, world defines, the part of CultMath the stage reaches, the + * shared block, the stage. Phacelle stays a separate file, added only where it is called. + */ export function fragmentSource(stage: Stage): string { const erosion = stage === 'terrain' && WORLD.phacelleGradBound > 0 ? phacelle : ''; - return `${HEADER}${worldDefines()}\n${cultmath}\n${erosion}\n${common}\n${stages[stage]}`; + const math = trimGlsl(cultmath, [erosion, common, stages[stage]]); + return `${HEADER}${worldDefines()}\n${math}\n${erosion}\n${common}\n${stages[stage]}`; } diff --git a/src/app/features/scene/shaders/README.md b/src/app/features/scene/shaders/README.md index 617644fb..b970f82b 100644 --- a/src/app/features/scene/shaders/README.md +++ b/src/app/features/scene/shaders/README.md @@ -13,3 +13,7 @@ header and any change to it is published under MPL-2.0. The other `*.glsl` files here are the scene's own stages. They are concatenated by `../shaderSource.ts` after a prelude; they use no colour literal (the palette is uniforms) and define no noise. + +`../shaderSource.ts` compiles each stage with only the part of `CultMath.glsl` that stage reaches +(`../cultmathTrim.ts`); the vendored file itself stays whole. Phacelle is added to the terrain stage +alone. From cc99a3439d21cca1cbe870175e272246d5be2a1a Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 2 Nov 2026 15:46:03 +0000 Subject: [PATCH 055/206] soundscape: neutral read first; human kind subset (provisional) per grammar 5e2eaa9 A neutral frame yields no kind and no energy: nothing drawn, nothing sounded, never an Idle. For a non-neutral human, a kind outside {Idle, Working, Speaking} or omitted becomes Idle and Peak reads as High. Minds and personas keep all seven kinds. The subset is provisional until Bifrost's steward cuts the list. Cites heimdall-body:follow_up:ratatoskr-creature-projection-v2. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/soundscape/frame.ts | 61 ++++++++++-------- .../features/soundscape/soundscape.test.ts | 62 +++++++++++++++++-- src/app/features/soundscape/voices.ts | 6 +- 3 files changed, 97 insertions(+), 32 deletions(-) diff --git a/src/app/features/soundscape/frame.ts b/src/app/features/soundscape/frame.ts index 7d7b2476..87a7d2da 100644 --- a/src/app/features/soundscape/frame.ts +++ b/src/app/features/soundscape/frame.ts @@ -1,6 +1,7 @@ -// The activity frame of Aqua's soundscape grammar (AquaSynth docs/soundscape-grammar.md at aeae510, +// The activity frame of Aqua's soundscape grammar (AquaSynth docs/soundscape-grammar at 5e2eaa9, // section 2). The Heimdall session produces it; this file only reads it. Shape follows -// heimdall-body:follow_up:ratatoskr-creature-projection-v2 as the grammar relays it. +// heimdall-body:follow_up:ratatoskr-creature-projection-v2 as the grammar relays it. The human +// subset below is PROVISIONAL until Bifrost's steward cuts the list. export type Estate = 'human' | 'mind' | 'persona'; export type Kind = 'Idle' | 'Reading' | 'Thinking' | 'Working' | 'Speaking' | 'Waiting' | 'Alert'; @@ -8,18 +9,27 @@ export type Energy = 'Low' | 'Mid' | 'High' | 'Peak'; export type Size = 'S' | 'M' | 'L'; export type Override = 'None' | 'Muted'; -export interface ActivityFrame { +interface FrameBase { mind: string; estate: Estate; - kind: Kind; - energy: Energy; size: Size; - neutral: boolean; override: Override; seq: number; /** Producer observation time, whole seconds (grammar: 1 s resolution). */ at: number; } +/** O1: a neutral frame carries no kind and no energy; it is not an Idle and nothing reads one. */ +export interface NeutralFrame extends FrameBase { + neutral: true; + kind?: undefined; + energy?: undefined; +} +export interface LiveFrame extends FrameBase { + neutral: false; + kind: Kind; + energy: Energy; +} +export type ActivityFrame = NeutralFrame | LiveFrame; const ESTATES: readonly Estate[] = ['human', 'mind', 'persona']; const KINDS: readonly Kind[] = [ @@ -40,39 +50,40 @@ export const STALE_AFTER_S = 10; const oneOf = (set: readonly T[], v: unknown): T | undefined => set.find((s) => s === v); -/** Grammar estate rule: a consumer clamps a human's energy to Low/Mid/High. */ -export const clampEnergy = (estate: Estate, energy: Energy): Energy => +/** PROVISIONAL (grammar section 2, awaiting the projection-v2 field list): a human's kinds. */ +export const HUMAN_KINDS: readonly Kind[] = ['Idle', 'Working', 'Speaking']; + +/** Estate clamp, only ever revealing less: a human's out-of-subset or omitted kind is Idle. */ +const clampKind = (estate: Estate, kind: unknown): Kind | undefined => + estate === 'human' ? oneOf(HUMAN_KINDS, kind) ?? 'Idle' : oneOf(KINDS, kind); + +/** A human's energy is three buckets: Peak reads as High. */ +const clampEnergy = (estate: Estate, energy: Energy): Energy => estate === 'human' && energy === 'Peak' ? 'High' : energy; /** * Read an untrusted frame. Only the grammar's fields survive: any other key (a weight, a vote, a * room id) is dropped, so a consumer cannot be steered by data the grammar does not define (A3). - * Returns undefined for anything malformed; the caller treats that as no frame (A4). + * `neutral` is read first: a neutral frame yields no kind and no energy even if the producer sent + * them (O1). Returns undefined for anything malformed; the caller treats that as no frame (A4). */ export const readFrame = (raw: unknown): ActivityFrame | undefined => { if (typeof raw !== 'object' || raw === null) return undefined; const r = raw as Record; + if (typeof r.neutral !== 'boolean') return undefined; const estate = oneOf(ESTATES, r.estate); - const kind = oneOf(KINDS, r.kind); - const energy = oneOf(ENERGIES, r.energy); const size = oneOf(SIZES, r.size); const override = r.override === undefined ? 'None' : oneOf(['None', 'Muted'] as const, r.override); - if (!estate || !kind || !energy || !size || !override) return undefined; - if (typeof r.mind !== 'string' || r.mind === '' || typeof r.neutral !== 'boolean') - return undefined; + if (!estate || !size || !override) return undefined; + if (typeof r.mind !== 'string' || r.mind === '') return undefined; if (!Number.isInteger(r.seq) || !Number.isFinite(r.at)) return undefined; - return { - mind: r.mind, - estate, - kind, - energy: clampEnergy(estate, energy), - size, - neutral: r.neutral, - override, - seq: r.seq as number, - at: r.at as number, - }; + const base = { mind: r.mind, estate, size, override, seq: r.seq as number, at: r.at as number }; + if (r.neutral) return { ...base, neutral: true }; + const kind = clampKind(estate, r.kind); + const energy = oneOf(ENERGIES, r.energy); + if (!kind || !energy) return undefined; + return { ...base, neutral: false, kind, energy: clampEnergy(estate, energy) }; }; /** Latest frame per mind (S1: consumers drop seq <= last). */ diff --git a/src/app/features/soundscape/soundscape.test.ts b/src/app/features/soundscape/soundscape.test.ts index e07db5b9..c292222a 100644 --- a/src/app/features/soundscape/soundscape.test.ts +++ b/src/app/features/soundscape/soundscape.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it, vi } from 'vitest'; -import { FrameTable, readFrame, STALE_AFTER_S, type ActivityFrame } from './frame'; +import { FrameTable, readFrame, STALE_AFTER_S, type ActivityFrame, type LiveFrame } from './frame'; import { drawnCreatures, PLACEHOLDER_SIZE_SCALE } from './scene'; import { DEFAULT_VOICE_CAP, planVoices, type SoundscapePrefs } from './voices'; import { isWeakDevice } from './device'; import { createAudioSocket, silentSource, type ActivitySource } from './socket'; -const frame = (o: Partial = {}): ActivityFrame => ({ +const frame = (o: Partial = {}): LiveFrame => ({ mind: 'void', estate: 'mind', kind: 'Working', @@ -17,6 +17,11 @@ const frame = (o: Partial = {}): ActivityFrame => ({ at: 100, ...o, }); +const neutralFrame = (o: Partial = {}): ActivityFrame => + ({ ...frame(), ...o, neutral: true } as unknown as ActivityFrame); +/** A raw frame as a producer sends it, with the named keys omitted. */ +const without = (f: object, ...keys: string[]) => + Object.fromEntries(Object.entries(f).filter(([k]) => !keys.includes(k))); const noop = () => undefined; const on: SoundscapePrefs = { enabled: true, muted: false, volume: 0.5, voiceCap: 4 }; @@ -25,9 +30,7 @@ describe('neutral overrides everything', () => { const cases = (['human', 'mind', 'persona'] as const).flatMap((estate) => (['Speaking', 'Alert', 'Working'] as const).flatMap((kind) => (['High', 'Peak'] as const).flatMap((energy) => - (['S', 'M', 'L'] as const).map((size) => - frame({ neutral: true, estate, kind, energy, size }) - ) + (['S', 'M', 'L'] as const).map((size) => neutralFrame({ estate, kind, energy, size })) ) ) ); @@ -45,6 +48,55 @@ describe('neutral overrides everything', () => { }); }); +describe('neutral is read first', () => { + it('yields no kind and no energy, even when the producer sent them', () => { + (['human', 'mind', 'persona'] as const).forEach((estate) => { + const f = readFrame({ + ...frame({ estate, kind: 'Speaking', energy: 'High' }), + neutral: true, + }); + expect(f).toBeDefined(); + expect(f?.neutral).toBe(true); + expect(f?.kind).toBeUndefined(); + expect(f?.energy).toBeUndefined(); + expect(Object.keys(f ?? {})).not.toContain('kind'); + expect(Object.keys(f ?? {})).not.toContain('energy'); + }); + }); + it('a neutral human with kind omitted is not an Idle', () => { + const bare = without(frame({ estate: 'human' }), 'kind', 'energy'); + const f = readFrame({ ...bare, neutral: true }); + expect(f?.kind).toBeUndefined(); + expect(drawnCreatures([f as ActivityFrame])).toEqual([]); + expect(planVoices([f as ActivityFrame], on)).toEqual([]); + }); +}); + +describe('human kinds are the provisional subset', () => { + it.each(['Reading', 'Thinking', 'Waiting', 'Alert'] as const)('clamps %s to Idle', (kind) => { + expect(readFrame(frame({ estate: 'human', kind }))?.kind).toBe('Idle'); + }); + it('keeps Idle, Working and Speaking', () => { + (['Idle', 'Working', 'Speaking'] as const).forEach((kind) => + expect(readFrame(frame({ estate: 'human', kind }))?.kind).toBe(kind) + ); + }); + it('an omitted kind is Idle for a non-neutral human only', () => { + const noKind = without(frame({ estate: 'human' }), 'kind'); + expect(readFrame(noKind)?.kind).toBe('Idle'); + expect(readFrame({ ...noKind, neutral: true })?.kind).toBeUndefined(); + }); + it('minds and personas keep all seven kinds, and omitted is malformed for them', () => { + (['mind', 'persona'] as const).forEach((estate) => { + (['Idle', 'Reading', 'Thinking', 'Working', 'Speaking', 'Waiting', 'Alert'] as const).forEach( + (kind) => expect(readFrame(frame({ estate, kind }))?.kind).toBe(kind) + ); + const noKind = without(frame({ estate }), 'kind'); + expect(readFrame(noKind)).toBeUndefined(); + }); + }); +}); + describe('human energy is clamped to three buckets by the consumer', () => { it('turns Peak into High for humans only', () => { expect(readFrame(frame({ estate: 'human', energy: 'Peak' }))?.energy).toBe('High'); diff --git a/src/app/features/soundscape/voices.ts b/src/app/features/soundscape/voices.ts index d20e55ad..676ae853 100644 --- a/src/app/features/soundscape/voices.ts +++ b/src/app/features/soundscape/voices.ts @@ -1,4 +1,4 @@ -import type { ActivityFrame, Energy, Kind } from './frame'; +import type { ActivityFrame, Energy, Kind, LiveFrame } from './frame'; /** Grammar V3 (proposed, unmeasured): voices audible at once. */ export const DEFAULT_VOICE_CAP = 4; @@ -42,7 +42,9 @@ export const planVoices = ( if (gain === 0) return []; const cap = Math.max(0, Math.floor(prefs.voiceCap)); return frames - .filter((f) => !f.neutral && f.override !== 'Muted' && !SILENT_KINDS.has(f.kind)) + .filter( + (f): f is LiveFrame => !f.neutral && f.override !== 'Muted' && !SILENT_KINDS.has(f.kind) + ) .sort((a, b) => ENERGY_RANK[b.energy] - ENERGY_RANK[a.energy] || a.mind.localeCompare(b.mind)) .slice(0, cap) .map((f) => ({ mind: f.mind, kind: f.kind, energy: f.energy, gain })); From fff46492caf4e1ed4dcdc53132222342009439b0 Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 2 Nov 2026 17:09:42 +0000 Subject: [PATCH 056/206] scene: Phacelle bound says measured-not-proved; neutral creature draws nothing world.ts already carries Libby's 7.0 (CultLib PR #1 990521b3); the comment now states the 6.63 sup was measured, not proved. creatures.ts follows creature-projection-v2: a neutral creature publishes no sizeBucket, and one that arrives is ignored. creatureScale returns null (draw nothing) for neutral, absent or unknown buckets; it never defaults to medium. NEUTRAL_SCALE is removed. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/creatures.test.ts | 18 +++++++++++------- src/app/features/scene/creatures.ts | 20 ++++++++++---------- src/app/features/scene/world.ts | 3 ++- 3 files changed, 23 insertions(+), 18 deletions(-) diff --git a/src/app/features/scene/creatures.test.ts b/src/app/features/scene/creatures.test.ts index 8e59175b..011f19e3 100644 --- a/src/app/features/scene/creatures.test.ts +++ b/src/app/features/scene/creatures.test.ts @@ -10,14 +10,18 @@ describe('creature scale', () => { ); }); - it('is one fixed size for every neutral creature, whatever its sizeBucket', () => { - buckets.forEach((sizeBucket) => - expect(creatureScale({ sizeBucket, neutral: true })).toBe(NEUTRAL_SCALE) - ); + it('draws nothing for a neutral creature, whatever sizeBucket it was sent', () => { + expect(creatureScale({ neutral: true })).toBeNull(); + buckets.forEach((sizeBucket) => expect(creatureScale({ sizeBucket, neutral: true })).toBeNull()); }); - it('is only visible as an override where the bucket would have said something else', () => { - expect(BUCKET_SCALE.small).not.toBe(NEUTRAL_SCALE); - expect(BUCKET_SCALE.large).not.toBe(NEUTRAL_SCALE); + it('does not crash or default to medium when a non-neutral creature has no sizeBucket', () => { + expect(creatureScale({ neutral: false })).toBeNull(); + expect(creatureScale({ sizeBucket: undefined, neutral: false })).not.toBe(BUCKET_SCALE.medium); + }); + + it('draws nothing for an unknown bucket, including inherited property names', () => { + expect(creatureScale({ sizeBucket: 'huge' as SizeBucket, neutral: false })).toBeNull(); + expect(creatureScale({ sizeBucket: 'toString' as SizeBucket, neutral: false })).toBeNull(); }); }); diff --git a/src/app/features/scene/creatures.ts b/src/app/features/scene/creatures.ts index 5026d2e2..b67a423c 100644 --- a/src/app/features/scene/creatures.ts +++ b/src/app/features/scene/creatures.ts @@ -3,10 +3,12 @@ // Props contract: heimdall-body:follow_up:ratatoskr-creature-projection-v2 (against GameCult/Bifrost; supersedes heimdall-body/ratatoskr-creature-projection). // Nothing here is binding until Bifrost's steward cuts that contract. // -// Shape: {id, estate, sizeBucket, neutral, activity}. +// Shape: {id, estate, neutral, activity} plus sizeBucket when neutral is false. // Invariants: // - Exact vote weights stay in Bifrost; Ratatoskr only ever sees a bucket. -// - neutral=true overrides any bucket, and no size is drawn. +// - neutral=true means Bifrost publishes no sizeBucket, no kind and no energy. A received value is +// ignored, and a neutral creature is not drawn. +// - A missing or unknown sizeBucket is not drawn either; no size is ever defaulted. // - activity comes from the Heimdall session. // Consent: humans get a coarse bucket by default plus a typed opt-out; Minds and Personas may show finer buckets. // Humans have three coarse buckets. The scales below (0.7 / 1 / 1.4) are placeholders, not fixed. @@ -16,8 +18,8 @@ export type SizeBucket = 'small' | 'medium' | 'large'; export interface Creature { id: string; estate: string; - sizeBucket: SizeBucket; - /** A neutral creature (no estate allegiance) is drawn at one fixed size whatever its bucket. */ + /** Absent when neutral. Read it only through creatureScale. */ + sizeBucket?: SizeBucket; neutral: boolean; activity: 'idle' | 'active'; } @@ -28,10 +30,8 @@ export const BUCKET_SCALE: Readonly> = { large: 1.4, }; -/** Neutral creatures are medium sized, not necessarily their bucket's size. */ -export const NEUTRAL_SCALE = BUCKET_SCALE.medium; - -/** Neutral overrides sizeBucket. */ -export function creatureScale(c: Pick): number { - return c.neutral ? NEUTRAL_SCALE : BUCKET_SCALE[c.sizeBucket]; +/** The draw scale, or null when nothing is drawn: a neutral creature, or a bucket that is absent or unknown. */ +export function creatureScale(c: Pick): number | null { + if (c.neutral || c.sizeBucket === undefined) return null; + return Object.hasOwn(BUCKET_SCALE, c.sizeBucket) ? BUCKET_SCALE[c.sizeBucket] : null; } diff --git a/src/app/features/scene/world.ts b/src/app/features/scene/world.ts index ba9aec53..ba366a6e 100644 --- a/src/app/features/scene/world.ts +++ b/src/app/features/scene/world.ts @@ -24,7 +24,8 @@ export const WORLD = { erodeSide: [1.7, 0, 1] as const, erodeNorm: 0.5, // Bound on |grad| of the phasor's cos component for the side and norm above. Libby measured a - // sup of 6.63 at |side| = 1.5, n = 0 (CultLib PR #1 head 990521b3); 7.0 adds margin. The + // sup of 6.63 at |side| = 1.5, n = 0 (CultLib PR #1 head 990521b3); 7.0 adds margin. + // The sup was measured, not proved. The // shader passes roughness n = 0 as a literal, so the doc envelope max(10, 1.25|side|)/(1-n) // needs no 1/(1-n) factor here; if n ever becomes nonzero, divide this bound by (1 - n). // |value| <= 1 holds by construction (the output is a normalized phasor). From 5e79a1853a5076699bfa724f7185d855e4e46c3f Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 2 Nov 2026 18:29:34 +0000 Subject: [PATCH 057/206] soundscape: neutral carries no size; cite grammar head 0091d01 NeutralFrame drops size alongside kind and energy; the reader ignores a size a producer sends on a neutral frame, and a live frame without a valid size is malformed. The human kind subset (Idle/Working/Speaking) stays PROVISIONAL, still proposed. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/soundscape/frame.ts | 24 ++++++++++--------- .../features/soundscape/soundscape.test.ts | 12 +++++++++- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/src/app/features/soundscape/frame.ts b/src/app/features/soundscape/frame.ts index 87a7d2da..947d2bb5 100644 --- a/src/app/features/soundscape/frame.ts +++ b/src/app/features/soundscape/frame.ts @@ -1,7 +1,8 @@ -// The activity frame of Aqua's soundscape grammar (AquaSynth docs/soundscape-grammar at 5e2eaa9, -// section 2). The Heimdall session produces it; this file only reads it. Shape follows +// The activity frame of Aqua's soundscape grammar (AquaSynth docs/soundscape-grammar, grammar head +// 0091d01, section 2). The Heimdall session produces it; this file only reads it. Shape follows // heimdall-body:follow_up:ratatoskr-creature-projection-v2 as the grammar relays it. The human -// subset below is PROVISIONAL until Bifrost's steward cuts the list. +// kind subset (Idle/Working/Speaking) below is PROVISIONAL: still proposed, not ruled, until +// Bifrost's steward cuts the list. export type Estate = 'human' | 'mind' | 'persona'; export type Kind = 'Idle' | 'Reading' | 'Thinking' | 'Working' | 'Speaking' | 'Waiting' | 'Alert'; @@ -12,20 +13,21 @@ export type Override = 'None' | 'Muted'; interface FrameBase { mind: string; estate: Estate; - size: Size; override: Override; seq: number; /** Producer observation time, whole seconds (grammar: 1 s resolution). */ at: number; } -/** O1: a neutral frame carries no kind and no energy; it is not an Idle and nothing reads one. */ +/** O1: a neutral frame carries no size, kind or energy; it is not an Idle and nothing reads one. */ export interface NeutralFrame extends FrameBase { neutral: true; + size?: undefined; kind?: undefined; energy?: undefined; } export interface LiveFrame extends FrameBase { neutral: false; + size: Size; kind: Kind; energy: Energy; } @@ -64,7 +66,7 @@ const clampEnergy = (estate: Estate, energy: Energy): Energy => /** * Read an untrusted frame. Only the grammar's fields survive: any other key (a weight, a vote, a * room id) is dropped, so a consumer cannot be steered by data the grammar does not define (A3). - * `neutral` is read first: a neutral frame yields no kind and no energy even if the producer sent + * `neutral` is read first: a neutral frame yields no size, kind or energy even if the producer sent * them (O1). Returns undefined for anything malformed; the caller treats that as no frame (A4). */ export const readFrame = (raw: unknown): ActivityFrame | undefined => { @@ -72,18 +74,18 @@ export const readFrame = (raw: unknown): ActivityFrame | undefined => { const r = raw as Record; if (typeof r.neutral !== 'boolean') return undefined; const estate = oneOf(ESTATES, r.estate); - const size = oneOf(SIZES, r.size); const override = r.override === undefined ? 'None' : oneOf(['None', 'Muted'] as const, r.override); - if (!estate || !size || !override) return undefined; + if (!estate || !override) return undefined; if (typeof r.mind !== 'string' || r.mind === '') return undefined; if (!Number.isInteger(r.seq) || !Number.isFinite(r.at)) return undefined; - const base = { mind: r.mind, estate, size, override, seq: r.seq as number, at: r.at as number }; + const base = { mind: r.mind, estate, override, seq: r.seq as number, at: r.at as number }; if (r.neutral) return { ...base, neutral: true }; const kind = clampKind(estate, r.kind); const energy = oneOf(ENERGIES, r.energy); - if (!kind || !energy) return undefined; - return { ...base, neutral: false, kind, energy: clampEnergy(estate, energy) }; + const size = oneOf(SIZES, r.size); + if (!kind || !energy || !size) return undefined; + return { ...base, neutral: false, size, kind, energy: clampEnergy(estate, energy) }; }; /** Latest frame per mind (S1: consumers drop seq <= last). */ diff --git a/src/app/features/soundscape/soundscape.test.ts b/src/app/features/soundscape/soundscape.test.ts index c292222a..294456a1 100644 --- a/src/app/features/soundscape/soundscape.test.ts +++ b/src/app/features/soundscape/soundscape.test.ts @@ -49,7 +49,7 @@ describe('neutral overrides everything', () => { }); describe('neutral is read first', () => { - it('yields no kind and no energy, even when the producer sent them', () => { + it('yields no size, kind or energy, even when the producer sent them', () => { (['human', 'mind', 'persona'] as const).forEach((estate) => { const f = readFrame({ ...frame({ estate, kind: 'Speaking', energy: 'High' }), @@ -57,6 +57,8 @@ describe('neutral is read first', () => { }); expect(f).toBeDefined(); expect(f?.neutral).toBe(true); + expect(f?.size).toBeUndefined(); + expect(Object.keys(f ?? {})).not.toContain('size'); expect(f?.kind).toBeUndefined(); expect(f?.energy).toBeUndefined(); expect(Object.keys(f ?? {})).not.toContain('kind'); @@ -221,3 +223,11 @@ describe('socket defaults and limits', () => { expect(t.offer({ ...frame(), kind: 'Dancing' })).toBe(false); }); }); + +describe('neutral carries no size', () => { + it('reads without a size, and a live frame without one is malformed', () => { + const raw = frame(); + expect(readFrame({ ...without(raw, 'size', 'kind', 'energy'), neutral: true })).toBeDefined(); + expect(readFrame(without(raw, 'size'))).toBeUndefined(); + }); +}); From 5eb0a63caa254824dbddc6c0e3319c35bf3a65de Mon Sep 17 00:00:00 2001 From: Nibu Date: Tue, 3 Nov 2026 08:59:54 +0000 Subject: [PATCH 058/206] scene: ES2016-safe hasOwnProperty, tighten undefined-bucket assertion, reflow bound comment Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/creatures.test.ts | 4 ++-- src/app/features/scene/creatures.ts | 2 +- src/app/features/scene/world.ts | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/app/features/scene/creatures.test.ts b/src/app/features/scene/creatures.test.ts index 011f19e3..938c217f 100644 --- a/src/app/features/scene/creatures.test.ts +++ b/src/app/features/scene/creatures.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { BUCKET_SCALE, creatureScale, NEUTRAL_SCALE, SizeBucket } from './creatures'; +import { BUCKET_SCALE, creatureScale, SizeBucket } from './creatures'; const buckets: SizeBucket[] = ['small', 'medium', 'large']; @@ -17,7 +17,7 @@ describe('creature scale', () => { it('does not crash or default to medium when a non-neutral creature has no sizeBucket', () => { expect(creatureScale({ neutral: false })).toBeNull(); - expect(creatureScale({ sizeBucket: undefined, neutral: false })).not.toBe(BUCKET_SCALE.medium); + expect(creatureScale({ sizeBucket: undefined, neutral: false })).toBeNull(); }); it('draws nothing for an unknown bucket, including inherited property names', () => { diff --git a/src/app/features/scene/creatures.ts b/src/app/features/scene/creatures.ts index b67a423c..a0b2bd29 100644 --- a/src/app/features/scene/creatures.ts +++ b/src/app/features/scene/creatures.ts @@ -33,5 +33,5 @@ export const BUCKET_SCALE: Readonly> = { /** The draw scale, or null when nothing is drawn: a neutral creature, or a bucket that is absent or unknown. */ export function creatureScale(c: Pick): number | null { if (c.neutral || c.sizeBucket === undefined) return null; - return Object.hasOwn(BUCKET_SCALE, c.sizeBucket) ? BUCKET_SCALE[c.sizeBucket] : null; + return Object.prototype.hasOwnProperty.call(BUCKET_SCALE, c.sizeBucket) ? BUCKET_SCALE[c.sizeBucket] : null; } diff --git a/src/app/features/scene/world.ts b/src/app/features/scene/world.ts index ba366a6e..e9594a68 100644 --- a/src/app/features/scene/world.ts +++ b/src/app/features/scene/world.ts @@ -25,9 +25,9 @@ export const WORLD = { erodeNorm: 0.5, // Bound on |grad| of the phasor's cos component for the side and norm above. Libby measured a // sup of 6.63 at |side| = 1.5, n = 0 (CultLib PR #1 head 990521b3); 7.0 adds margin. - // The sup was measured, not proved. The - // shader passes roughness n = 0 as a literal, so the doc envelope max(10, 1.25|side|)/(1-n) - // needs no 1/(1-n) factor here; if n ever becomes nonzero, divide this bound by (1 - n). + // The sup was measured, not proved. The shader passes roughness n = 0 as a literal, so the doc + // envelope max(10, 1.25|side|)/(1-n) needs no 1/(1-n) factor here; if n ever becomes nonzero, + // divide this bound by (1 - n). // |value| <= 1 holds by construction (the output is a normalized phasor). // Typed number, not the literal 7, so callers can build variants such as { ...WORLD, phacelleGradBound: 0 }. phacelleGradBound: 7.0 as number, From 33e5f0ebf70309c946060543fd67102a6632ef94 Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 3 Nov 2026 12:01:38 +0000 Subject: [PATCH 059/206] client: nudge sync on resume so a backgrounded tab stops waiting out the 30 s poll On visible (after >=5 s hidden), pageshow(persisted) and online, call retryImmediately; if sync is not Prepared/Syncing/Catchup 3 s later, one non-overlapping stop/start. Debounced, listeners disposable, started once after startClient. Co-Authored-By: Claude Sonnet 5.5 --- src/client/initMatrix.ts | 9 ++- src/client/resumeSync.test.ts | 132 ++++++++++++++++++++++++++++++++++ src/client/resumeSync.ts | 86 ++++++++++++++++++++++ 3 files changed, 225 insertions(+), 2 deletions(-) create mode 100644 src/client/resumeSync.test.ts create mode 100644 src/client/resumeSync.ts diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 498d4f75..871a7625 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -3,6 +3,7 @@ import { createClient, MatrixClient, IndexedDBStore, IndexedDBCryptoStore } from import { cryptoCallbacks } from './secretStorageKeys'; import { clearNavToActivePathStore } from '../app/state/navToActivePath'; import { pushSessionToSW } from '../sw-session'; +import { startResumeSync } from './resumeSync'; type Session = { baseUrl: string; @@ -40,10 +41,14 @@ export const initClient = async (session: Session): Promise => { return mx; }; -export const startClient = async (mx: MatrixClient) => { - await mx.startClient({ +const startSync = (mx: MatrixClient) => + mx.startClient({ lazyLoadMembers: true, }); + +export const startClient = async (mx: MatrixClient) => { + await startSync(mx); + startResumeSync(mx, { restart: startSync }); }; export const clearCacheAndReload = async (mx: MatrixClient) => { diff --git a/src/client/resumeSync.test.ts b/src/client/resumeSync.test.ts new file mode 100644 index 00000000..eada97af --- /dev/null +++ b/src/client/resumeSync.test.ts @@ -0,0 +1,132 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { HEALTH_CHECK_MS, MIN_HIDDEN_MS, RESUME_DEBOUNCE_MS, startResumeSync } from './resumeSync'; + +class Target extends EventTarget { + visibilityState: 'visible' | 'hidden' = 'visible'; +} + +const setup = (state: string | null = 'SYNCING') => { + const doc = new Target(); + const win = new Target(); + const mx = { + state, + getSyncState: () => mx.state, + retryImmediately: vi.fn(() => true), + stopClient: vi.fn(), + }; + const restart = vi.fn(async () => {}); + const stop = startResumeSync(mx, { + restart, + doc: doc as never, + win: win as never, + }); + const hide = () => { + doc.visibilityState = 'hidden'; + doc.dispatchEvent(new Event('visibilitychange')); + }; + const show = () => { + doc.visibilityState = 'visible'; + doc.dispatchEvent(new Event('visibilitychange')); + }; + return { doc, win, mx, restart, stop, hide, show }; +}; + +const settle = () => vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS); + +describe('startResumeSync', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('nudges the poll when the tab returns after a real absence', async () => { + const t = setup(); + t.hide(); + await vi.advanceTimersByTimeAsync(MIN_HIDDEN_MS); + t.show(); + expect(t.mx.retryImmediately).not.toHaveBeenCalled(); + await settle(); + expect(t.mx.retryImmediately).toHaveBeenCalledTimes(1); + }); + + it('ignores a hide shorter than the minimum', async () => { + const t = setup(); + t.hide(); + await vi.advanceTimersByTimeAsync(MIN_HIDDEN_MS - 1); + t.show(); + await vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS + HEALTH_CHECK_MS); + expect(t.mx.retryImmediately).not.toHaveBeenCalled(); + }); + + it('handles pageshow only when restored from bfcache, and online always', async () => { + const t = setup(); + t.win.dispatchEvent(Object.assign(new Event('pageshow'), { persisted: false })); + await settle(); + expect(t.mx.retryImmediately).not.toHaveBeenCalled(); + t.win.dispatchEvent(Object.assign(new Event('pageshow'), { persisted: true })); + await settle(); + expect(t.mx.retryImmediately).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(HEALTH_CHECK_MS); + t.win.dispatchEvent(new Event('online')); + await settle(); + expect(t.mx.retryImmediately).toHaveBeenCalledTimes(2); + }); + + it('debounces a burst of triggers into one nudge', async () => { + const t = setup(); + t.win.dispatchEvent(new Event('online')); + t.win.dispatchEvent(new Event('online')); + t.win.dispatchEvent(Object.assign(new Event('pageshow'), { persisted: true })); + await settle(); + expect(t.mx.retryImmediately).toHaveBeenCalledTimes(1); + }); + + it('does not restart when sync is healthy after the check window', async () => { + for (const state of ['PREPARED', 'SYNCING', 'CATCHUP']) { + const t = setup(state); + t.win.dispatchEvent(new Event('online')); + await vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS + HEALTH_CHECK_MS); + expect(t.restart).not.toHaveBeenCalled(); + t.stop(); + } + }); + + it('does one stop/start when sync is still unhealthy, never overlapping', async () => { + const t = setup('RECONNECTING'); + let release!: () => void; + t.restart.mockImplementation( + () => + new Promise((r) => { + release = r; + }) + ); + t.win.dispatchEvent(new Event('online')); + await vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS + HEALTH_CHECK_MS); + expect(t.mx.stopClient).toHaveBeenCalledTimes(1); + expect(t.restart).toHaveBeenCalledTimes(1); + // a trigger while the restart is in flight does nothing + t.win.dispatchEvent(new Event('online')); + await vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS + HEALTH_CHECK_MS); + expect(t.mx.retryImmediately).toHaveBeenCalledTimes(1); + expect(t.restart).toHaveBeenCalledTimes(1); + release(); + await vi.advanceTimersByTimeAsync(0); + t.win.dispatchEvent(new Event('online')); + await settle(); + expect(t.mx.retryImmediately).toHaveBeenCalledTimes(2); + }); + + it('removes listeners and cancels pending work on dispose', async () => { + const t = setup('ERROR'); + t.win.dispatchEvent(new Event('online')); + await settle(); + t.stop(); + await vi.advanceTimersByTimeAsync(HEALTH_CHECK_MS); + expect(t.restart).not.toHaveBeenCalled(); + t.win.dispatchEvent(new Event('online')); + await settle(); + expect(t.mx.retryImmediately).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/client/resumeSync.ts b/src/client/resumeSync.ts new file mode 100644 index 00000000..5f81547b --- /dev/null +++ b/src/client/resumeSync.ts @@ -0,0 +1,86 @@ +/** The slice of MatrixClient this module drives; a MatrixClient satisfies it. */ +export type SyncHost = { + getSyncState(): string | null; + retryImmediately(): boolean; + stopClient(): void; +}; + +// matrix-js-sdk SyncState values for a live sync loop. +const HEALTHY: ReadonlySet = new Set(['PREPARED', 'SYNCING', 'CATCHUP']); + +export const RESUME_DEBOUNCE_MS = 250; +export const MIN_HIDDEN_MS = 5000; +export const HEALTH_CHECK_MS = 3000; + +type ResumeSyncOptions = { + /** Event sources and visibility; default to the browser globals. */ + doc?: Pick; + win?: Pick; + /** Restart the client after stopClient; must resolve once startClient has been issued. */ + restart: (mx: Host) => Promise; +}; + +/** + * A backgrounded tab or PWA leaves the sync long-poll dead until its 30 s timeout. + * On return (visible after a real absence, pageshow from bfcache, online) nudge the + * poll with retryImmediately; if sync is still unhealthy after HEALTH_CHECK_MS, do one + * stop/start. Returns a disposer that removes every listener and timer. + */ +export const startResumeSync = ( + mx: Host, + { restart, doc = document, win = window }: ResumeSyncOptions +): (() => void) => { + let hiddenAt: number | undefined; + let debounce: ReturnType | undefined; + let healthCheck: ReturnType | undefined; + let restarting = false; + let disposed = false; + + const check = async () => { + healthCheck = undefined; + if (disposed || restarting || HEALTHY.has(mx.getSyncState())) return; + restarting = true; + try { + mx.stopClient(); + await restart(mx); + } finally { + restarting = false; + } + }; + + const resume = () => { + if (debounce !== undefined) clearTimeout(debounce); + debounce = setTimeout(() => { + debounce = undefined; + if (disposed || restarting || healthCheck !== undefined) return; + mx.retryImmediately(); + healthCheck = setTimeout(check, HEALTH_CHECK_MS); + }, RESUME_DEBOUNCE_MS); + }; + + const onVisibility = () => { + if (doc.visibilityState === 'hidden') { + hiddenAt = Date.now(); + return; + } + const away = hiddenAt === undefined ? Infinity : Date.now() - hiddenAt; + hiddenAt = undefined; + if (away >= MIN_HIDDEN_MS) resume(); + }; + const onPageShow = (e: Event) => { + if ((e as PageTransitionEvent).persisted) resume(); + }; + + doc.addEventListener('visibilitychange', onVisibility); + win.addEventListener('pageshow', onPageShow); + win.addEventListener('online', resume); + + return () => { + disposed = true; + if (debounce !== undefined) clearTimeout(debounce); + if (healthCheck !== undefined) clearTimeout(healthCheck); + doc.removeEventListener('visibilitychange', onVisibility); + win.removeEventListener('pageshow', onPageShow); + win.removeEventListener('online', resume); + }; +}; From a1101e90a60a406f3d9c403f8aad5d0a6d454e14 Mon Sep 17 00:00:00 2001 From: Void Date: Wed, 4 Nov 2026 14:06:18 +0000 Subject: [PATCH 060/206] poses: read labels only from the catalogue; add a client-status pose line The client's own SHARED_POSES/MIND_POSES lists and the committed webp art are gone. catalogue.json (cast-art poses/catalogue.json, synced by npm run sync:poses) is the only list of Minds, labels and art paths. RoomViewPoseStatus draws "Ratatoskr: is , since HH:MM" from the loaded timeline; it is never sent to a room. Co-Authored-By: Claude Sonnet 5.5 --- .gitignore | 5 +- .prettierignore | 1 + package.json | 3 +- public/poses/.gitkeep | 0 public/poses/aqua/amused.webp | Bin 6316 -> 0 bytes public/poses/aqua/annoyed.webp | Bin 6388 -> 0 bytes public/poses/aqua/concerned.webp | Bin 6156 -> 0 bytes public/poses/aqua/conduct.webp | Bin 6342 -> 0 bytes public/poses/aqua/determined.webp | Bin 5954 -> 0 bytes public/poses/aqua/flourish.webp | Bin 6302 -> 0 bytes public/poses/aqua/happy.webp | Bin 6416 -> 0 bytes public/poses/aqua/listen.webp | Bin 6088 -> 0 bytes public/poses/aqua/neutral.webp | Bin 6174 -> 0 bytes public/poses/aqua/patch.webp | Bin 6352 -> 0 bytes public/poses/aqua/surprised.webp | Bin 6334 -> 0 bytes public/poses/aqua/thinking.webp | Bin 6150 -> 0 bytes public/poses/eureka/amused.webp | Bin 4176 -> 0 bytes public/poses/eureka/annoyed.webp | Bin 4096 -> 0 bytes public/poses/eureka/concerned.webp | Bin 4108 -> 0 bytes public/poses/eureka/determined.webp | Bin 4090 -> 0 bytes public/poses/eureka/happy.webp | Bin 4146 -> 0 bytes public/poses/eureka/hood.webp | Bin 3606 -> 0 bytes public/poses/eureka/inspect.webp | Bin 4258 -> 0 bytes public/poses/eureka/neutral.webp | Bin 4172 -> 0 bytes public/poses/eureka/probe.webp | Bin 4412 -> 0 bytes public/poses/eureka/report.webp | Bin 4434 -> 0 bytes public/poses/eureka/surprised.webp | Bin 4188 -> 0 bytes public/poses/eureka/thinking.webp | Bin 4290 -> 0 bytes public/poses/heimdall/amused.webp | Bin 4158 -> 0 bytes public/poses/heimdall/annoyed.webp | Bin 4218 -> 0 bytes public/poses/heimdall/badge.webp | Bin 4564 -> 0 bytes public/poses/heimdall/barrier.webp | Bin 4258 -> 0 bytes public/poses/heimdall/concerned.webp | Bin 4128 -> 0 bytes public/poses/heimdall/determined.webp | Bin 4158 -> 0 bytes public/poses/heimdall/happy.webp | Bin 4204 -> 0 bytes public/poses/heimdall/neutral.webp | Bin 4152 -> 0 bytes public/poses/heimdall/protect.webp | Bin 4024 -> 0 bytes public/poses/heimdall/revoke.webp | Bin 5296 -> 0 bytes public/poses/heimdall/surprised.webp | Bin 4276 -> 0 bytes public/poses/heimdall/thinking.webp | Bin 4212 -> 0 bytes public/poses/kiko/amused.webp | Bin 5060 -> 0 bytes public/poses/kiko/annoyed.webp | Bin 5060 -> 0 bytes public/poses/kiko/concerned.webp | Bin 5176 -> 0 bytes public/poses/kiko/confetti.webp | Bin 6760 -> 0 bytes public/poses/kiko/debug.webp | Bin 5266 -> 0 bytes public/poses/kiko/determined.webp | Bin 5218 -> 0 bytes public/poses/kiko/happy.webp | Bin 5266 -> 0 bytes public/poses/kiko/neutral.webp | Bin 5172 -> 0 bytes public/poses/kiko/sprint.webp | Bin 6470 -> 0 bytes public/poses/kiko/surprised.webp | Bin 5136 -> 0 bytes public/poses/kiko/thinking.webp | Bin 5100 -> 0 bytes public/poses/kiko/tinker.webp | Bin 5458 -> 0 bytes public/poses/libby/amused.webp | Bin 4738 -> 0 bytes public/poses/libby/annoyed.webp | Bin 4944 -> 0 bytes public/poses/libby/concerned.webp | Bin 4756 -> 0 bytes public/poses/libby/determined.webp | Bin 4944 -> 0 bytes public/poses/libby/happy.webp | Bin 4816 -> 0 bytes public/poses/libby/neutral.webp | Bin 4856 -> 0 bytes public/poses/libby/research.webp | Bin 5086 -> 0 bytes public/poses/libby/schema.webp | Bin 5590 -> 0 bytes public/poses/libby/shush.webp | Bin 5184 -> 0 bytes public/poses/libby/stamp.webp | Bin 4774 -> 0 bytes public/poses/libby/surprised.webp | Bin 5028 -> 0 bytes public/poses/libby/thinking.webp | Bin 4814 -> 0 bytes public/poses/nibu/amused.webp | Bin 4604 -> 0 bytes public/poses/nibu/annoyed.webp | Bin 5006 -> 0 bytes public/poses/nibu/concerned.webp | Bin 4414 -> 0 bytes public/poses/nibu/console.webp | Bin 4776 -> 0 bytes public/poses/nibu/determined.webp | Bin 4262 -> 0 bytes public/poses/nibu/glitch.webp | Bin 5106 -> 0 bytes public/poses/nibu/happy.webp | Bin 4580 -> 0 bytes public/poses/nibu/lore.webp | Bin 4730 -> 0 bytes public/poses/nibu/neutral.webp | Bin 4384 -> 0 bytes public/poses/nibu/surprised.webp | Bin 4818 -> 0 bytes public/poses/nibu/thinking.webp | Bin 4228 -> 0 bytes public/poses/nibu/triumph.webp | Bin 4252 -> 0 bytes public/poses/weksa/amused.webp | Bin 5088 -> 0 bytes public/poses/weksa/annoyed.webp | Bin 4422 -> 0 bytes public/poses/weksa/argue.webp | Bin 5178 -> 0 bytes public/poses/weksa/concerned.webp | Bin 4572 -> 0 bytes public/poses/weksa/determined.webp | Bin 4236 -> 0 bytes public/poses/weksa/fieldwork.webp | Bin 5408 -> 0 bytes public/poses/weksa/happy.webp | Bin 4590 -> 0 bytes public/poses/weksa/interlingua.webp | Bin 5484 -> 0 bytes public/poses/weksa/map.webp | Bin 5202 -> 0 bytes public/poses/weksa/neutral.webp | Bin 5170 -> 0 bytes public/poses/weksa/surprised.webp | Bin 4232 -> 0 bytes public/poses/weksa/thinking.webp | Bin 4612 -> 0 bytes scripts/sync-poses.mjs | 24 ++ .../components/message/pose/catalogue.json | 242 ++++++++++++++++++ src/app/components/message/pose/poses.test.ts | 62 ++++- src/app/components/message/pose/poses.ts | 89 ++++--- src/app/features/room/RoomView.tsx | 2 + src/app/features/room/RoomViewPoseStatus.tsx | 53 ++++ 94 files changed, 442 insertions(+), 39 deletions(-) create mode 100644 public/poses/.gitkeep delete mode 100644 public/poses/aqua/amused.webp delete mode 100644 public/poses/aqua/annoyed.webp delete mode 100644 public/poses/aqua/concerned.webp delete mode 100644 public/poses/aqua/conduct.webp delete mode 100644 public/poses/aqua/determined.webp delete mode 100644 public/poses/aqua/flourish.webp delete mode 100644 public/poses/aqua/happy.webp delete mode 100644 public/poses/aqua/listen.webp delete mode 100644 public/poses/aqua/neutral.webp delete mode 100644 public/poses/aqua/patch.webp delete mode 100644 public/poses/aqua/surprised.webp delete mode 100644 public/poses/aqua/thinking.webp delete mode 100644 public/poses/eureka/amused.webp delete mode 100644 public/poses/eureka/annoyed.webp delete mode 100644 public/poses/eureka/concerned.webp delete mode 100644 public/poses/eureka/determined.webp delete mode 100644 public/poses/eureka/happy.webp delete mode 100644 public/poses/eureka/hood.webp delete mode 100644 public/poses/eureka/inspect.webp delete mode 100644 public/poses/eureka/neutral.webp delete mode 100644 public/poses/eureka/probe.webp delete mode 100644 public/poses/eureka/report.webp delete mode 100644 public/poses/eureka/surprised.webp delete mode 100644 public/poses/eureka/thinking.webp delete mode 100644 public/poses/heimdall/amused.webp delete mode 100644 public/poses/heimdall/annoyed.webp delete mode 100644 public/poses/heimdall/badge.webp delete mode 100644 public/poses/heimdall/barrier.webp delete mode 100644 public/poses/heimdall/concerned.webp delete mode 100644 public/poses/heimdall/determined.webp delete mode 100644 public/poses/heimdall/happy.webp delete mode 100644 public/poses/heimdall/neutral.webp delete mode 100644 public/poses/heimdall/protect.webp delete mode 100644 public/poses/heimdall/revoke.webp delete mode 100644 public/poses/heimdall/surprised.webp delete mode 100644 public/poses/heimdall/thinking.webp delete mode 100644 public/poses/kiko/amused.webp delete mode 100644 public/poses/kiko/annoyed.webp delete mode 100644 public/poses/kiko/concerned.webp delete mode 100644 public/poses/kiko/confetti.webp delete mode 100644 public/poses/kiko/debug.webp delete mode 100644 public/poses/kiko/determined.webp delete mode 100644 public/poses/kiko/happy.webp delete mode 100644 public/poses/kiko/neutral.webp delete mode 100644 public/poses/kiko/sprint.webp delete mode 100644 public/poses/kiko/surprised.webp delete mode 100644 public/poses/kiko/thinking.webp delete mode 100644 public/poses/kiko/tinker.webp delete mode 100644 public/poses/libby/amused.webp delete mode 100644 public/poses/libby/annoyed.webp delete mode 100644 public/poses/libby/concerned.webp delete mode 100644 public/poses/libby/determined.webp delete mode 100644 public/poses/libby/happy.webp delete mode 100644 public/poses/libby/neutral.webp delete mode 100644 public/poses/libby/research.webp delete mode 100644 public/poses/libby/schema.webp delete mode 100644 public/poses/libby/shush.webp delete mode 100644 public/poses/libby/stamp.webp delete mode 100644 public/poses/libby/surprised.webp delete mode 100644 public/poses/libby/thinking.webp delete mode 100644 public/poses/nibu/amused.webp delete mode 100644 public/poses/nibu/annoyed.webp delete mode 100644 public/poses/nibu/concerned.webp delete mode 100644 public/poses/nibu/console.webp delete mode 100644 public/poses/nibu/determined.webp delete mode 100644 public/poses/nibu/glitch.webp delete mode 100644 public/poses/nibu/happy.webp delete mode 100644 public/poses/nibu/lore.webp delete mode 100644 public/poses/nibu/neutral.webp delete mode 100644 public/poses/nibu/surprised.webp delete mode 100644 public/poses/nibu/thinking.webp delete mode 100644 public/poses/nibu/triumph.webp delete mode 100644 public/poses/weksa/amused.webp delete mode 100644 public/poses/weksa/annoyed.webp delete mode 100644 public/poses/weksa/argue.webp delete mode 100644 public/poses/weksa/concerned.webp delete mode 100644 public/poses/weksa/determined.webp delete mode 100644 public/poses/weksa/fieldwork.webp delete mode 100644 public/poses/weksa/happy.webp delete mode 100644 public/poses/weksa/interlingua.webp delete mode 100644 public/poses/weksa/map.webp delete mode 100644 public/poses/weksa/neutral.webp delete mode 100644 public/poses/weksa/surprised.webp delete mode 100644 public/poses/weksa/thinking.webp create mode 100644 scripts/sync-poses.mjs create mode 100644 src/app/components/message/pose/catalogue.json create mode 100644 src/app/features/room/RoomViewPoseStatus.tsx diff --git a/.gitignore b/.gitignore index 1af58a97..4e7d929d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,7 @@ node_modules devAssets .DS_Store -.idea \ No newline at end of file +.idea +# Pose art is synced from the cast-art catalogue (npm run sync:poses), not committed. +public/poses/* +!public/poses/.gitkeep diff --git a/.prettierignore b/.prettierignore index 036ee8bc..c81e668b 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,3 +4,4 @@ package.json package-lock.json LICENSE harness +src/app/components/message/pose/catalogue.json diff --git a/package.json b/package.json index 298fbf36..0e470460 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "prepare": "husky install", "commit": "git-cz", "bump": "node scripts/update-version.js", - "test": "vitest run" + "test": "vitest run", + "sync:poses": "node scripts/sync-poses.mjs" }, "lint-staged": { "*.{ts,tsx,js,jsx}": "eslint", diff --git a/public/poses/.gitkeep b/public/poses/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/public/poses/aqua/amused.webp b/public/poses/aqua/amused.webp deleted file mode 100644 index 50ae021b25d7f6fcbb06f9f2da622176087c7e75..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6316 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 diff --git a/public/poses/aqua/annoyed.webp b/public/poses/aqua/annoyed.webp deleted file mode 100644 index 924b789d185ffad44f815c457305a648e8bbc52c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6388 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 diff --git a/public/poses/aqua/flourish.webp b/public/poses/aqua/flourish.webp deleted file mode 100644 index 46c6171bce27e650aa89b6d9a942be69ca27f52d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6302 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 U0QpB#^Ib5Ox?i8Q1Stvt03E1JzyJUM diff --git a/public/poses/aqua/happy.webp b/public/poses/aqua/happy.webp deleted file mode 100644 index 1dcb13a2e250075d3c1ec82df56ce51fdb313eb2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6416 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 OyeIFjm>ego4gdgy)v{Co diff --git a/public/poses/aqua/neutral.webp b/public/poses/aqua/neutral.webp deleted file mode 100644 index d8f03a898d9766a1be9b0aed07dd859b375e3c3d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6174 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 diff --git a/public/poses/aqua/patch.webp b/public/poses/aqua/patch.webp deleted file mode 100644 index 7c47973ba5d3344d4ecabd838ac00e763b6c1c15..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6352 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 Ac>n+a diff --git a/public/poses/aqua/thinking.webp b/public/poses/aqua/thinking.webp deleted file mode 100644 index 8c0bde4cf6723f2036ec10aea6b0e8cc77bab063..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6150 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 aLv~)5bh=_V;Nl9L3c;{{Q;c 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 diff --git a/public/poses/eureka/concerned.webp b/public/poses/eureka/concerned.webp deleted file mode 100644 index b3c71b2b36765a6f27f3b99e2dc8f58b53db9856..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4108 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 KwFb5b0000xeinfM diff --git a/public/poses/eureka/determined.webp b/public/poses/eureka/determined.webp deleted file mode 100644 index 547d44640bb77af37887da9f0664c1200dc6e37e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4090 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 diff --git a/public/poses/eureka/happy.webp b/public/poses/eureka/happy.webp deleted file mode 100644 index 782a3463c308d8fee7d79578df0b639b0e16a549..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4146 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 diff --git a/public/poses/eureka/inspect.webp b/public/poses/eureka/inspect.webp deleted file mode 100644 index 2d7bb798b87d169e4d2f0a800ce6e5e4dced8bbc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4258 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 E05jVs*8l(j diff --git a/public/poses/eureka/neutral.webp b/public/poses/eureka/neutral.webp deleted file mode 100644 index a0ec077ab1a7a05240940b9604eab0098cb6e84d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4172 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

r~!}_WJ2~-YyYDC_= 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 Ynt1SnhuN}%<~wFJt=t7PjsO4v09b*FZU6uP diff --git a/public/poses/eureka/surprised.webp b/public/poses/eureka/surprised.webp deleted file mode 100644 index 067fc33cc1436d6a1c808da9bd5bfb59f50ffb4a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4188 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 diff --git a/public/poses/eureka/thinking.webp b/public/poses/eureka/thinking.webp deleted file mode 100644 index 4789ee162edc8657727fbb5a47ea4228dc054758..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4290 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 Ig|Gks0OB8s{-0hw4m4l&K<*v~ zW_R?m<+q=kYOU+aJMTSnImTu!d1fCZM?7Z9D{JSrpBrCgTmC$l7y;tqfo%YwP4b7q=~kmb`8sFoBU- zYwo3&w%BrX`N0ZYfdvr{S{|0;%eNC8Mo~9d!!A;89RL7TqadJaTP{|f`M@EET>jXb z^@50#-*&;W`+fP%svr>eTjf}?2mS$A4)GnAF3&blF0pF4IB%H*%K_I}Wy!BLKom<{ z=JJ=P#=(Lfv5K>I90wLc9MW@w@{U8ma3sJA-?0*~{3OaG&g(lz`7$sVe{gLb2Ylvj zr|}q~R~^r8=sMBGaWE9|E6Y|QIxgH1Jxk7HG8l;OSkAQ9j0RLKOKHe$B7W_5XL>6b zkGOh9NyyW6lt`durC<*>{Ay|<7)fH)q7(fsgAHH3=rrHHeHEfluDbe3IZJuE0%H+> zv7G7P#CXRod60?Eiw5E&w*HlY_+;Nw4g-66G9HK%*Ez@jR~EgAD3GYmoJv5RuG+o~ zEC8%IC63s0s{0SG-3aQTPxO@%#AiyGs^JGb-4S&86fFJL=aQW3+pXxdLdRL@uick1OS-}8ef`q;NBy*9srPu z?DNe9TZ$st-tnuW0Wt|lTb0<1q( zS%7F+(c~?cAy1;&wPMK+j)8fg!ew%>?+`?qBqlDHwnBX#ByuE4@bin7Lw^MXm;o_7 zcE7`K`N(*4ACTn%$9?72Yv-(RR)1;!?=`TvGoM_xZ8^MT%fG6FEQ$DtY{_z&lILy( zSXA2sm;T5ESPU^SZxtqa{!FkCVsgd_%1dX0MG&WTtpqH;Swo4$nQvRpQ741(h$Fg| zbCf?>#y2ge@%J)XnLLLQ_t}!kOf4R<)|vJu!Fb=a@{k`vc;YXa*!`oyV8C;!$TvhI zu63@xW;Be>S*dtcFyaT6GnM}m45Qs-Y~KKe8e4F(a<>Ua8eOn*v3w6gHx`}m@^N6G zvA6S){}PD!iZkktAu4g&azd7G0D}O}SShul5EnaTd43d8*I60aJ`;>UoadzFuSaQF zSv`q}#1TtQd-+5#!jG)1?CiDUrM z;J%U^m#xjiLpN-nM0;{PUA<`iE&ssulIl#}Z2s}*0h)E$Pkp{*K`osh(f7;o&rm02 z(Ob@nXUe%y1I7nF|6uzCezWzr{mk}Q{f2DwXa~M|{85IViBMr?rUjkP9uwQXX z+w2aegP+z5m+?EJ5~STh?%#tlN_gX{qyoYfZrN>Mfjkg_GhWPqG~M*r%=FRK06+=* z>idNTqAchLmi;c~CF89n=(VHSDr%?4lKc0hP6i`zUSY$$5LUf6FETbyYg)dmVqVW; z$VzwZa+35LxXXDt;-EwRAX7uobzL0%5Pkb?=jZdY)C;v2N~3dTDjjZWC3=W3HpYOO z%irLrl3)8D<03lDg_U>IcnkYY`)qbob+yi-F^;l)Nei4HcH{s6{{1#-S2Y}SWV}Zd zn=012FS5+f<`&SV+NpyGmqPgedBc{+ShvaV^f z`Haj!`nY^$RtGSncu#J-(v=N;^ve z1JkUoHK<=XyxB0TU`DMM0i*)ja~?Ei@%Z~N`)m!`VKBf~VhghJV}=%90M0E3w)BVX zow$88o4+>lTsQzvzwu3{eNiYW_APfY zjWmsx36q!HV1yKsRR@65tW|IE%gJjmURYQ@pQdO*Y)cHHcW8w2Qi5^+8ca$EC5(<< z6=X7zi|6BDfwrELTJ_&eX?}G@0@_G9y>FC21H3SO)l7XnXXCRgiHY%5!U7rtf&m$k z$F}d6T!hb?jv?Mra;hrh0V%dhu_TFtK9leO+Xr|LW(w{M*0@p8ah4Y9x7(@joJY#n zqQ3^7a7KTWI?e#ICgr3q5Ocv$Ll2uKV*mP*w{~Cyf~M}E@hBMERD5>ZZuuHRn7EO+ z;qwB0j&dad9=c-L={HBkX=OoZt?`f_wUj&XBTH=>YqEOfjyRKDY5B)8aob?3#}Y)# zLRpnFUT$VeAWaN_5d{jPLeckF>35))kfim<$#1}wa3J9gE?&|a=SSpsoliY89bJACrP(E~l8rdvcdVv0aKW}E?e zQ@_<8XuxN`Bc&QOf`;NZ`>xy}Z#r&oK=jPm?k!>Raz!B8KO{?WP^~0=>JVG)g=4fn zvEoxO=yj_H&-N896H#)0uq88EQOnF5>AMi@moqniFbZc+A7w*`7apJEEfDu*9xy=U{>F09xd zV*GV^ATC6vi2~ClzSmjF0Yq!gjVC<8_Ly_Gc}%iyDk=buWj5i6094>h$udcE+}%g9 zp|#nU@=Mbpmx7CD_SJQm^3du^%*F)IxpDA!hfZzLu9|uvAo^i}sczL0@e46efDVFL z(f|jw-0Pxbnh_b9Hyk*j{@$9L`GSIR9E{l(#r@z^Mr6CkaH&aQch^$O)rqepf;lKx zTUrlt>+9E9iBM|ckbi5xHy2KTBS)rUMpQxk(89THY4HCO#Pj}8p7eeu-978H_#!Qu&v;iq!$Y!1XZaI!2=W zn_hLEpm>VnXU(>cyY;4G9ft1J9x}-D^5uAs{4BAe2~{E}zo*8kji+!qbHc%<*i z51w7}8O$?dnah)qXv+g04lGMMq%nRN6ZGl_wxAR|P#9eHLR%z}(p9Pn=~S11e05q4 z!|O%8>Ew6cp}1uITeRo&eC9P)rLJ&5v$MiU;F{5iwOWemvxwCPgCUrTy1O$T2=6w5 zYe;MbaDeRAQ>+6X-EZSf`vQNqlHU`N7N<>`*#lN#thBI$9cS|HGloIP+dZYFqUYZRvn3!z5gl}e$q>p z_lPN4aTWiqX!&u3?HhwX$fe?~0cJ*t({kY1)OdLSOYRX#SW~*%PsJ-FdMXA~(3Ofl&Wi2l>;+Tbv zjHtupo|F^~(=O6kKVCK}_$|vUCRahf6G^k;nPODrqKfQw$vzzZcpT&En+C;Vt*XHO zQxss1yh7riCC4-0QIg>FNACiu>I~ELQ@Dg{27lT~FMXf#5!Vfe?20c@)?i1vInt4Q z$A{@?_gxE8IR}X=*5;sJB+QMxzYI~uS7Z0b7X?ZVmF~v910L-m2g2|a(+91HfRM65 z!P&Onfqf&9K3VL|Of1m_sYUEI5a>&L5Zd3m_M`sI!y)@a*g(kp?- zi(_XVMdsf-%##zAPVf`Xd)AyA6baMMIN`K;r1>lG0VNj6C-DSP(NB|Zx4`pobG&)- zC0((X?Jzi5=S0^(0}~t*su@n8hJ|ujfBgXH8g$!*jd@tK4w3iDm*Kty=*5uFg(o#| zdyfdOmQsgEeN*4YzYa0VRhDO8!mT7^a?~1=R1BZ)V`biI6fUZj@83*-65d%pzt@#2xd`0qO;ld~&1;J`` z&*|lX0GxI!vCWVFYJO4H`gu~{7n2M1s?K_qUefye8=KnqF3+qd>KZxo>f@HU*vmWh z*q8p-y)6293oIfuD47FcnY!*?D%7p0Z^~6r^_jF)I=eEkW1+gjQ)>(asX$X9+RuI6 zY4k~(gQDg2p$98)V--2pnP_jJFg0lF%xSR0kqHv4ih33V5>&{@WlWanItugIn(y+b QB@@}~M;-l9c8{{MSd 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 diff --git a/public/poses/heimdall/barrier.webp b/public/poses/heimdall/barrier.webp deleted file mode 100644 index d35ea9ff7149e10e07a93248b8b428340705a59f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4258 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 E00h-pe*gdg diff --git a/public/poses/heimdall/concerned.webp b/public/poses/heimdall/concerned.webp deleted file mode 100644 index 549719a2ac742972c7aeaa165588e401fcee37c3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4128 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 Ir=x%X05Mz`M*si- diff --git a/public/poses/heimdall/happy.webp b/public/poses/heimdall/happy.webp deleted file mode 100644 index a9bdc79b5c061d369057877580aee71dccb5f0fe..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4204 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 CV+DBt diff --git a/public/poses/heimdall/neutral.webp b/public/poses/heimdall/neutral.webp deleted file mode 100644 index ca9ed41fee2233221eac7bf143139ea456cf315a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4152 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 CR38lh diff --git a/public/poses/heimdall/protect.webp b/public/poses/heimdall/protect.webp deleted file mode 100644 index 7daafa22239bdf48fb218c7091b61b19e9a7f665..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4024 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 diff --git a/public/poses/heimdall/revoke.webp b/public/poses/heimdall/revoke.webp deleted file mode 100644 index 96eb32150110de77e10c01b711ab28359ceccbb3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5296 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 C22?)) diff --git a/public/poses/heimdall/surprised.webp b/public/poses/heimdall/surprised.webp deleted file mode 100644 index f50c455d01433e88ea2238c5aee4b7c7aef45e6c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4276 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 diff --git a/public/poses/heimdall/thinking.webp b/public/poses/heimdall/thinking.webp deleted file mode 100644 index 9c5e89a59127c4e1e2554e78a8fe2dcf0dffdcaa..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4212 zcmV-)5R30pNk&F&5C8yIMM6+kP&il$0000G0001g004df06|PpNJ9kx01@zpY}@kI z+O}=5K`4YkArz+1!gLQzch|slmonYm-QC^YT}olPYoQPZQwxDi$t8tApag~-Y-~S< zAIE;_uZWlc{68oc`LArTP(l1lc&d&zd%8VrlT z=$UW+*!8lNJl?kDQ$Ua*5i8u;^YWCv&}pa7v-&)uu$}@Xl;n8YA0N%D-syujjw=MR6Y_%p@N_&9WEqaYpW77W zu6p%%GpQt}EkKk^pi+O+Drjy41X&J9;zg%|W=BC3PCRTCBX>toJkfNoRn7!L{H(Qa zIcI_qtZ~p#%PPX%l@KFwLK*TEUz?sXP28ck` z=;!8s14Ja3Ia$g`{;`Ts&vx$emxy(@@lL-#H7BFw8^KV3;dR%=nNGRL?G+F!2@q#n zDG#~79T*D$R&buXr~HwH=N<$iUSg-)R>Esm2LIR^B*P(r>QNu|on!avovHvB46yEP zU1zHw@7|688Ocg__H8K>*-l>`I}9+6k!eeEzLM?s#UqH5A38a6$?lvs6a}$C&-qLC zC&NL2IMd3`N}gUJ3IMNJxygF0jxdQex>jCt?+8Rn9Ob;_9~&U!AW<2&GLnB>14NJ{ zh}zBVfispTS3rdffdqiHzqFj!)J_1v00@k3T0d>Z%FFiP{TE+57G!Z~JlvbxmE|(e z1Xvhvn`_&0iBhsY+zwNcK{0@c9^vsqI_`?!23>);*~OLzgl5TRggci#;l{1+rvd1Wkq54 zw+0J%ED-qF3UUIDa<_3IbMIt|!8L^kcJsZrc z8P8w~Fsn^dH1Y&;Cbm#Cw)X&YYC2r`HRN1xS<%?D>w>uu4_FRYz91W7r{sX;uaWP# z6_7pH0L+5eK@M3yHk;?Hpq|Nt*l6IG<@PZ0xY>%y@@ZfO80iEyUB~h##w8p84(Zii-je{i`FM_7(rer4$Z09H^qAOZ^j05B~8 zodGI<0Dk~Jl{%M5rK2JtFc^D)uo8)40MphnT$~h_e%A57g=XBZ7Qb1qO8@1(C3>2- znE%6hpk{!1Z1MjXJtc?vIr-`Ar+^c+=&|PsHI-UDBiIx4Kct>|-n;e+{d?dWHVsT%}|3`SI+%Hj&u=87aIB$F?Rz?tJl{;bYp{C%aD;akPCoRa6(+o4Q$5WsHR z#ee_+{`{>V;WD=Q1k#++jJL1c3#9ye*KSvihQnc~3b&~Vf$0pbJumKalDTH*0J7>8 z+yATCNZOVL)1|YF|H7Du@(8m0aq5JZ-K5{d4iAAu0<2-Mb%`dBLe_qUI>+vB;uDIh z#?mg@%02>My?E-6e~MJ8d3Y?nJ5~r@3-oA}9rr^Z`0pP;U<~h5784GXrCb0Nn`S#zO)- z?YWE;*0_@2heiR}eUn%3K-ZOOhKJdqjXt>5o*usrl+gc4QEo&G?qqv1aOy6she-=s zx+P)s?obrHClMA}1We-Sub{`89`HJDy1eaM2G^%@p_p{~U32y5AGrD%ef?(T0?-%L ztlm#1mB0RZxZoI)o6ziL_RxgK?|j3lt2RypbEwDf?{`Unw~?x_U+th;E_Kx$5CvjZ zB@}1CSTKF1|ei zLp9eHuEbX^TU!l?c2@wsAgn;lirF>-DkeU^<*r*OtL~O)Mm`BwEga(C@Aws>+%xAj zxcIRE6t<6p&O6XRk#wtJ&@xjV5DZWFKav8(dD+g>YqYD43|JBiJd3bGSTUpNEmfpU z^S}onBfHhAU=$mE>mnH-ApGHDipvOcIhNdld)9XBgrZLUSPSmtcZRmE49zu)G}BCX zTSM+pb}xy`Hf__e%z4u-mB1LWhOm+srm11zO_b*7eB9M2M2?<*G!)-JEz|8!OiuQ0 ziLp0TuPTm=jn9-SKWU`D5l{j4u&CSu;3}E^Mo4Lr_O8NpxxIJ;7gyXs#gR5Z#w2M! z=+Y%u`0nwiqP#H2)q%5!p9|4+!5#C1^?;F1y55I(s)|woPVyRl|OMu11kyx zQ*kV47Ush^WrIA@#=jF5P^uD^trI&eC8CyngVW4$Wlv20|nU ztgsgS!0=s-ZqfB#cDyM__3;YX+_jh$kzj#7jF`B(>ouMR!!@~>f(aF>9Jg<{)-YD4 zcyv98qOVm|~Vc+O!yF9d*#R!=S#;56_=ut7RX+Iue>S(|S%-Pui&~^aQ zx#>IGch|-8VEK9I)JBJKza*d!EBXHOiuBFgvekKSqeGOZ7)eJZpB&x%W)@=9;wD`3 zq3Y#3dwfpp>5lE{$MNGhu;{${S^ zm`VHYA^XleLo{6~1UEe%(RXa(X5R>A^wYdzL_Lo(#F8oF=t&R5H?O~{G-#*U6k>{Yp+T)_HRUaGymFVs!{YgUmxakdICOV!n$L#h@9qYvi#Gj=;i$P?P)V6! zVk#TMr=P1+yVEN$ha{Q$dcRV7GBfT>5Dq6m?DpwAoZUfl^v3#uqcCZ_98#Bx>l(EK zbts=o<`D)M)z<5QLNWhX|~@T;^z$U5!}ltoxz48)_iCSf)X(uor=EKr*hV9R{a zcYK{czTnUaoR{o3L1()oG>**2N|kTV7_-9PHyHQ_--yxV_6L+u$NUtKnv;Qhc^|C* zagDcIm7geGJ@O!A7!;&q%Xu&Y0Cm&Y&vEyt06Bj@tK0OV+^FQ=1nRAVC<-o zY4BX%1@ady9qVN>7+66s_q4QOcD_ExBB8=mQu>3CMlfJNa9%UcUveLtyY>_j&KVk= zo9|}NkR^{rX3A(OTzY_YXf#osl1xwrI{%+RwI#qbN#UIz6Kda*+YMyV6~Q73@~iZd z)7dbo@fahRV-PF{Px@Q#j%0*OMLf1S0>1xUkG`GDDSok_f-~9?8?#9dQHQFPd1FZ{ zII1`W8W@SHzco*eKFo^H#xwgX&W|@|`F?lvitXz!H}FA~%%v(`F`WlotgGd_4l~** zL`ZdI&^id+pF4PViOYXt96CO}Q2-DiETQ=Tk;5HNJSB4+ed5>PqabRK56 zT|y=riI)z%d_Hsiw<&Vy8W$VW-N-gj0#QdeFD8y|PNqo0qPMztJ@RpO-4+|$2978o zP{`RlgiE?Zqh-&BfKp%7!VguEOKGTvpXj$pB9wd{ax!xB=3(O?X$u-&g06M^oB;H1 K^oo+>0001@-4v++ diff --git a/public/poses/kiko/amused.webp b/public/poses/kiko/amused.webp deleted file mode 100644 index a9f7be7d5bd7859eae86a6fff231641fafe38940..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5060 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 aN~1q}ajx0gkhE)cGFAD587xjb5C8z_)8DiJ diff --git a/public/poses/kiko/annoyed.webp b/public/poses/kiko/annoyed.webp deleted file mode 100644 index b2063e8fffc485d84ef5b1336a0831763248d17d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5060 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 aCYIju3{A(P=sz`g9Y?b+2)k0WzyJV=>*R|7 diff --git a/public/poses/kiko/concerned.webp b/public/poses/kiko/concerned.webp deleted file mode 100644 index 5a1b9f425a346a0d9438dd1df739ddf78cccec3c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5176 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 diff --git a/public/poses/kiko/confetti.webp b/public/poses/kiko/confetti.webp deleted file mode 100644 index 9610c0dec0ae8ecdacedc4ad653e6c7bca7893bb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6760 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 Y`RAG6RLaip^eI`V^nUN*(?UQ10Q<*FbN~PV diff --git a/public/poses/kiko/determined.webp b/public/poses/kiko/determined.webp deleted file mode 100644 index d5e142326a05f1cc874a39cce6e4eb6592838214..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5218 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 diff --git a/public/poses/kiko/happy.webp b/public/poses/kiko/happy.webp deleted file mode 100644 index a777070bb6f929e73a2793ec3a5532f25133f1dc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5266 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 diff --git a/public/poses/kiko/neutral.webp b/public/poses/kiko/neutral.webp deleted file mode 100644 index 8ae269992006c7578549edc6f50699bc16d593e3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5172 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 g*)&Esir@YRhSR_ik=Ac9Z2LP` z-os-Uh8VVQh!7!!ZDD>bwuO+8UmLdf-h1!8_YDzF3)^?t9@~2h!?v(3gcwE$!x+vv z`}KJKn3 z>$xX`p%A6rwtNSelAa}>3S?^+$?cahG*&z3E(jDTB2oZxk86ifp#*mS#G zD}kFsc0zu^>LPDT$Cx|IYh`d#0JaflSYBsi5KP3mRtL+wF9x|0VsNem5*2)&vNrzaD6*1%k``4-k`kJ2c{tZ^9C&YN)Q0a4i(FmH&H$} z4aKF6Sn_2c0e~d&LffXwucUw&Xm}G<1`0>tyz`T4_1*ibTd-6_3J%-7337e%)i zvl5sl+nH^3-U9iIBpkj;mb~@y9Z4uis=(UCcv5s&3S>4_1_E~nu`FcxBG3hded7)^iG36$T?fpLI1ywTQFRF-D|#z3O~6U)BgTt_a?<`F&# z9Q~VRH}`wMip%nslSSki7SR91Zp-qI2mBbFW&KuN=nX12HMeHTmY*AO8nder^ucvY zcH6mETJdEov%?6QxWkz{HithgSvz@UJ_C(dNc&mHUd<-3t1!X|p>Em@wbSkc)& z1EGN^++Fu>S59ymn@59zfyBU-<;u}jD+O|G6l7Qc0bs^*66MB)MDRJ{R?F#pcVfSM zPy}+1IH6f)XAcAg{_{gCA-0=y{oxQp&wahyl9KV;CvRLmh@kg-%_)^D06>NW{Bw4( zR$I=}V0#idB5VSMg9~Rnk@7+T!IMCtYNf&QglCd&cbLLlq zn|0>aath={%zC#U00Z0R%-bHYQcywJ@);4xBWI`KG7L7n+85m2_IH?Y3R}bITlFl6 zz)rAd(7zg={5Eh{wl)-AU)|RMZ&>mD7r5C|AOa_*eIHPcNFC&P01uQwusx{0`j-Gd zwPJC-ZrjZf5CDnJWGEp1`QFJBdGZ@yTOH(g@n0+R3`hVdJyBiDGBp;r{kH&yLbUhHJL@LTj5ocN3oUoA zjlb*6b2$(=Y7P-| zBE*bV-}1jx6Pq9CZ&*&{v2n~F@$}KPdeGy|%u~QlA8S0C`dW7s8+Kzwf<(KRo}`{}JqkoyV#_ z_IrhYJO1VWZ`WJbpY9*B$Lyno5p;pn0cfb#lM}>tir`DyVu_JYM#<$p8y~d=Zea$((+v3 zYm2oxm*+`MVLsY8pb{*-FA|_t@#1Ne-0szt4o9ojy*ZG1b^2bry0l0THm+)jPhoI? zz9A%>Z+33kxR@VTZwJbNEHA}%cnMr$&iKe3)WJa+(83348Pt4+%D6J=u2>hGjLe_8EAd;CfNWVUxycb z?;%Kec4<>FodDM0XcuB8m#eg%i>b0t$#jixiR8Rg0092|wDm!}jOSZmbaSYq&t7_8 z5+6}5hJF8gpJk@ym9Ck;;@bfBQ|9lwdKgxNf__BG{G_jI`;ZWM9id|Qz@2%OyemC( z-D@9bxx+90{ShpV54ii{KP=C(dzDFr{kqGekY|@iWqC=YXu#kA6~M&*uyIHeo8!v} zO$J7ju}~Lha8vk4-=&8Ml-2QaO(bqw#{8Br8Dh_4d<%tvR_&3mH<~9951A>9P?)<| z?~kf(Q9k?G+$&Oty7+^}>DwmmHC;_aehy?*pm=i41ml)9wh!4q{r;0X)T-^)NYSI} zHosD|%?4W_(5MFs@l)~-EHV8`yU7YPt=%VgSd!#*!5;c9ArsUyh)_` zYl7QEjJjuO(Zg=e(plKRMt2GJeIAj^z*1QmT^KJ@y&xHZZT5sTRO2gpC|<;f3mu$( z(?eJHu{PE)B72BR+imZNbA7k=wPF6;y{>oX%gM{{8FVsS@}&Jfi2Abxwou>_lPFZs zCZ!$pWs%(n&41P2B%9_Le!f2`B@b6S)T5m#^!3@|7_|Kq{bR;fwuvIAj(D^oB9ozV zxsuF8bD5PmcV3OAqy*HE1!Q-#F6ha?F$c-8)~@o*RUN<$Qfq6S8h@4dmJ}FED`ttA z9qsx&S@zfZtB%CzV9z)wbVoiJ_>UeEr_j}u;+A1>sI?s9tOka1gW5~dF;f>pJ-q?0Ss`cO&wGI>30|#)i1FD7_Cek^~0Q2x8VECvqJrUJ~Moc58}z%t6Q% zFB_$g1!xuF>*o+hP7Y0^(s8~E|2=t!{HVbr?Fyo24Y0$ZMgSg%x9u+j6uYhoNP!n9 zyfz*r6!Gwj9@1ut-ex}U>)~+mK=e#BH0=3t$f!5!e{u^!6bNJ_t`R?K!A7-&H!$EG>OEJnJ|Wn*I1-)u2A#`1 z(4ht?RMjl1q}sZik4J4fdKrL_kmGH$8BA9vR9T5gvar%LwPQXlj}qB1=37#=qYFR6 zDr+McJjGx~8(7fPo)64$?l8a5`A#{cs>t?}DlUJZpMd2KCFuxBsK#ZdzwC?gUzZ1@ zE#R@`DV;o`x|yWG@z-#!k@Cg@sCZ47@zeCAb8|20+I|^qtmwuoFc($h&<;n|O2E5A z$)?sS$KpN=)B(B>6gPqh81#v^vpBMxf7L^K!Gw*>)wbV}m5|=z2+;404r^E}#`0}> zPL?vU+MSZh%;>4B z-^bv)kfbB($G{F$z4zR{`|q3#!OneY=1~gE7(LFobx-$<*PfIG*AV4L{;8mKN!h1`|<5!486 z5QA*|^eWSEddjN#M;vEQXK@z~B2yyqW(Qc$)z5mv?{66M^+1U&&u3N=5T-ke4hlJ7 zCattbBH{X%{7_=ee|a>szRn@(h)TX@pm2!tXa_m?z<)iu%D46tw>O@x;kX-B!fsJr zAI+q(shD>_Ew9DJ*KZ|+xSyVhs@{qCDXPjq#2+cnV2L%c4sxLN`-h_31eNEV;DJTK zhOy>Z?~2=?K3H_EPUh~Ii{nk}hI#GUwWHIx+rYIJ8sZI=>kikFC-{?CZ&c$?oDI?p zi_w+C>15#NBGJbUyeND8A)668w;ey}BNmW>*fA5zv--gqtsrhmsoMU)XbT3+%z0AI zMRgSCg*2I1a2Gy^z(Z<3 zq#W6B_el}`SPV*f74!$sOzIBp1qJ7WOS2W<{Rn2_&o-^Dgd5<(YpWCqY4sQu?_?Yp zr(vH1G07beGhBRYV`OE?&ovkrIjLseT)6d7!OM>h9zzZVLb5Jy^Yb(LL%VY3~GYepm8@(&%`#U>!dODs4&A(q8i`+FX z!C;G$*5e6wP5Sg0yONPJsn*d#C4~C0hhpNY?L!D9{}GY){S`uZ5qt>k^WTh|w#DZg zJlQ+5h5G7_;}*d%DKFP*_=11C?TkuAmtLvVpy-E8NizBg62M%hD3V9Z5Ylaxy6wXy zexx6Wcj~DRhDe%!`iXIT<~GB=QTXRfyoh`d;rgWNU0|)Qeo75!@)NU)&Q$=R%CM(x;5ff|H-8RDw3nugk9}DXK?OJy4dq^EQu@OU4;o)!}HtrmNPPxb< zH}8K6%XOP$Mb^+7l9!=bbm8p%2{z1oJw_A?!-Sgox$C6s;dD9R(})NX4w4o@-`w-K z?Cr(ehl=+1gbbdrJrrY-Tp8qPkhA;O#qArh9&>PvbeE|$WJjFk?8g_78)I@0mK(|w zaa;O$AmWQ){B21GH~s7&ei?9Ggkijq0;neqF-j*So^GL^n=4e#zR`M} zq#O64o1?xlFL?vBJ;IwslA!6uu71H z@^gMQ%Y=135K7%8W zM1$~2(=ZN7T_9`I@NgnJhPr05a5+fs zI+QIFQZnDX{gC+4i}}YnuzpA>L6lT_+n*b8>Swvtch411#Wgbg>!L85Z#coA`dYSo zq3Rtjyg5(s(pu8--FcrISW%a#DbCPm6z^6HXz79}nYHWXF^Sa`o+i0vFEB}P{aDnK z8cQdU3lMVh+o3NFoz;=<$SRIYn?_OYW)4fRY@wH=X`;TC7KE1nGDbZKE2G_D0X8+= z;7_Lbs)2p&mv-`ee6xnQv4;|#Bf?)u9h-B9NjtErQH#8_z=Jds^<{B8M3 zkwM3Eh(IXH+*zTjx9}f>`8_F<$YDRjGRd~e;1k74X%uNu1#&rbOozwEz+BM{lm^ch zkt)aIy;3>vI#Zkm;q{xj#jvRf9l<;gI;GxcZ4IAHeE~88iCV}u^jIHD2<2-NTs^e0 zB1v}AWzzgkP$YKV&vWk9VLIQ8tbAd{Rir?ZTfq*nq<{636Bf1l2=>BQz9-0}Z>ZB! zCZo<@ghoR3R|t6)(DOaJvO%9;ATWrHhT?2(BZFc;+P3S~ z*@A^YATWa0Oo}n+E9o%^(jyG%&5)i~(tGc{8q$03z4vMa27@sejKQF<7*a)=zG4s< zj6fh3tRIpk`J9vbDd+An&p$X_htG9L>5LSbOhvl%3n42C$vEpG)=0x%SC z)-BIwkkH&?B`^$P%zA*FlAYuL^6rn?dmWX0CA){kAjH+K6A^JA#m-0 zNz1nUb~>_)mb|bLTr1*8uiDrZOak$@72ERU0JuirwADerFBylrT`sQ!*MvCUUDgJX zhzl*-mgk4S{;&fwv(_r|6<``3>}0vJ6W9-e0Ekm9cTveA4ezaDxpolP3o$yoA4niq z>BjaLm;hjIHOtKlLH2U9S8mQCgR?DnDSwD04ph6a?5A^J5BIp`sRGu0*`3|k6^xE3 zEW2yDybkQ{HA}9ITxYp!c{~X_HM+C>ubsdSY{zGo<)ylNlq*>z;86D}f4LI~679r< z<^F14FupOX2g_d;K!8L09eHGFUEk!JQ*csmmS=M;-8Wa4t^cjK&+;UqCob!Q<cI*3?Y~KtrJQBy&Eia8eW<{kN5j=tHbxm8I%Pz*|a*2T< zw|U-bdv*`UF1KcrQ-kPHa~QLHDy^745^&?Hf+SxK<$=~Mrf3kbmGy5&T)WH*lmL&*&v`>~u- zRZb-WC5m-hEh!O`FWxtrMaaj_x137(H2`2p$n1CX!*@<5!S<2JAZVh!Y^6Y+CPHTh zmsw7td=eNMhHB}UM(D(SmQ$$`7#0R=>5v}`jJVKpI_8PM%L%bp3SdA)VcAJND6r$L zM6{Z=d{SWJ$@u=h7~ZqI{C#|Kb!gG@5kUb{Zu!Y4t@vhfkG)a|Y|?HHU6F#vaJ)TF zgiJhZPvCeba18r3eH|Fq750rNEXO8S*P-OvAc7`}4POS7t=R4Z#=RK~3c$y{5I8j+ zb#VP}$W5FLB5YzWKM3S1RvfZy%i~}s53U*vi1R9W;MgAOD}hL!dD@oe2LK?0)yY5r z?34rXwAIa*PlqAu2L2%S$B? z$mcdsF1uaT$3_q=0T3H!EH}Tj9PaE0^7HopO2`10eEBcimL%KFCkI05NaWwJeCHdM zZG}+3CJVe~eO5pggYVgv+*y(z1_J_tGk)~OCu*WE3--e zwD!5)&yNpidzI^zjK03LJM91XzPf*y?`MD)v-fI12j;)s`~I~T@K3M@>j$I%?7!Uj z*L|b@SFk7aU-5tVeNDgf^4;|p{*Q34=AYVs?REqG)b=+0b8O3)&Uxt`Gw;NP6`LQX z$>&t5e$nim?CR`pr7;!BWD|yXG+Al=O=|kPgTi?MBe^9ISV|O7*kLLbmC7)Qba9pQ z0WOa)wY$kAbJc;G(riM^l{TOyvxA)TA9rnk$F*ZWj)o-r)oZvL?}SD=P76)S=1&H< zn{@X{x-g27n;)-b{w2lU5AA;Je|*8Oe7@Uo zf!3&j_Mi(Z>Yh}sROHpsx{ZtWmBNDsoCFu!c zP-NY~p~sl3$yQ@!>N^VGUw&PZOUqF*4gESy(#~~Zc`VN$B$%~j$sz`QeV1T{({_DL zp*s6p;s*EP$NnwcHrx%8HbX`y$*T9tozr16R1f!McwJ5Endz90b4sstPmc!v;-fdX zsmv`DsB<0Nc0iy^@P)Mkr2$0EOWF3=ZEmWy%(rOvg9kt@nv%?NMmaH3yIJ#IfD-NK z+PLnnX^*SAcsxTGLjR@v0ma9qx!Vtv@QttXF0v#0xkZL{@ibJ{5?F&wyjoQ7)0R7d z&APw6WsQIN@mE|u$YyCkYlSXe-)zaxphx7#djONfT3TZBoxiSf1pV2LmSCiNrBNCp z&=QJ@;HNxh#W;F8ACPV_)bGAca(3`9t&Ok6W>VH9fFN(wVn*cT%nMCPq8Ib|rLw3<3btU8+@2Iu?w=r)iUP5kWnf#MG`E z{MF}tv?8-JiH)wG#5*CzqrZBge5lLo&W*ta%6+W9)uCIw<}gOrp)iNFeY);Bu(5fMX_VUly@=X<4%4>GV?<@iD$34;55*f zdMN+u;A2^Xq$#9Iwh2vX*czASI_R7Qc83CB5;Pmrsqy!G#_@u3AlsnPl6{b!?N)kD)jvCd-_Jv#NJ%oH z{UFq+J-e**jX-p0@hV`v4eG&$GiJM! zgKW%64)9(ly~1A(29KY*+B|AWD*uZK|0(peWoYT1_PSIY%t zXMJE-i!*~t`ZUmb&`nPJjgP1wm+|w~jd4K?vrhrp5r~q^XZ9MY{3S11nTvPYKk?3R z91uS{SsXh!nyvMV8ptk6yG4wCvr*xUXk(jMf~sAM8pEgiD*aUSF=}m4Dwq>Klttkr zP17F%n*aD*LEGzKj5$_}^Nw>&45*$)E*X5P2omPQ{d>SYBaR1HBYu_~KjZnJlLpFX zMyd{}b1`;0JE?wmao>gw|9M(g>dl?-#AOA8Md0tZ>+A>*9NFEk;PD_?`W^X5^Au(_ zY&!;WO&1>-&K7YA&eXJ;x>anGO-UA~~pa zwm?BQV^3F!XZ14~#C_K<*YE2nxJ~F{-L2&B&io%X_?W49(KQl%cfTji&o<0+!=Rx!z=UN~Xv zmLLCGY1j$Zdk3L(?k|faa&I+<=Iv!mRdtQuS%Ie!G`sqk^xj(r9Nd=#HqQrFP?CD3EyCYN-f+ifjdJ&%$5o zkKA23u8;RCEs{I4N)^()BRLkD*cP5H1G}nE^I}8C$Y0F0FR-<9gdv`s>OE3QjImmw!M){EOG*GQwggN`1B8}X8ydi`vFCAtz}_Sm9v z^XHuFR(U{J{v6)5d6IZFZ|35&@(7@p{~s4bg=mL^EkpJ#7DN(LSPbBqWouZGko;Is zq5nKs8fd%Pr%)Wb-w8=rw=s-Rr!;G5{x?4arpt9@frqJjl=hKgL^EjW?czq^BFzIT zAZY*r@_%{f5a`K{k(T!UQRBV(bs-vvlL!@RUkf-JlilVu`yOsvLHC~$v-Z=PaGa3m zHhlC+b?|XQC&8r6v0vgzy?fbiZ&EqqJm?Ced^-F70nYS=P-6224HNMe(4W{+_}M+R z;P(Pqyk+GweE2-=;4mU=Y-&h#t@C!pN;Gk*!AGsT} zs;~1Cf2ws9?-#kUwg^PRgE=%&xqo4*V#FT~AB%>hH*z@;t>xh#_;JYX^6rC-^ir5H zShGCtUgWM>ZedZPN^0f*-5Hq!B+e;I=lIs+HY@(pIKf26M~N? zVTOIJwXkWj2P!lm%VL$k==m(l2&xUdT!AARaQi%U-M9Wx?taR5IKv0#GH&6mx;zOn zq^>IqkRR3>WpX546fCcac4qs-EihC!^>uatNgx+)x4hGzIDATJn|eh(PsLDB4#_Q3 ziW{T+IF5=|D=Kab1?lcR;jwW|`THvt>zj5en#a1H}m zt`J2@kvGst*`a3e`xd@TV^1Kws(W5%~SpX)hp-LSsu$i)S zQuZH|3u@XoT#qBDPr%?t)v@^H(}Sh1_5S9fd`^He_`cF9vtMF=Pw65r|2dDp1ivhq z+)uzV=ht5pPE*|j$wz1RxSmLrV9uCM?Q7LGH(j2B)J35|jBQs)MQD*`$J}HL-}EWf z@Jpw-Se2ihBeWvhy*mk?PI-3YR7_n!8qTX=+;N%r22n58J){>|7@JwHi?HPvfWM~@ zfD0D&BzJa$rLSlOfSXPTjKE>S=gY)p71^T|jmn}Zt1^6n!<$z)l=QjzZ9t0mH(;uH OIK9NKC*Fy`00017w(U*; diff --git a/public/poses/kiko/tinker.webp b/public/poses/kiko/tinker.webp deleted file mode 100644 index e53019f82757d82ff1a4f79a85c5b085dd9871f3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5458 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 QjG4~Z=eNo1VK^WF08RTptpET3 diff --git a/public/poses/libby/annoyed.webp b/public/poses/libby/annoyed.webp deleted file mode 100644 index d4810a1e82777661d56b0b4745c9dbc86d7ac8ba..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4944 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 diff --git a/public/poses/libby/determined.webp b/public/poses/libby/determined.webp deleted file mode 100644 index 2170715b9c74638829ab13c13ff32649e014fac1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4944 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 OVPkJfC*3nPXaE2&X0y!z diff --git a/public/poses/libby/happy.webp b/public/poses/libby/happy.webp deleted file mode 100644 index 182ec8be6a8df723d3453f0013350f0521a4c313..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4816 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 Aga7~l diff --git a/public/poses/libby/schema.webp b/public/poses/libby/schema.webp deleted file mode 100644 index 2dc8b116bdfcaa15513a97458cfed3a27233c7dd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5590 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 diff --git a/public/poses/libby/stamp.webp b/public/poses/libby/stamp.webp deleted file mode 100644 index 854d374c6f33094bdb9fe3743725dd24a15e89c3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4774 zcmV;X5?Sq1Nk&GV5&!^KMM6+kP&il$0000G0001g004LZ06|PpNCO1`01@zp+O{g$ zwzf5BOh`xwNC=W-yX1CD*zWG`?(XjH?!J$^Zg+S0y%V-eK)`knSTY*4Be87s9{(d^ z0uYnLOp?TaB>|urU20lVoRQ7x$h6poU$mutO%_2 zux!b*Q+;j!eEnQtPQ-k_E0#QGCA+gJm=KBGyOvy`+}ji+lObBS+H#%6?=9W{WG2M; zbCz8153gOiJit7NiH|K;KIO0DU>3wg$HjF^a(5#z2V&6=E!QvqunL$0+AmvihOiX+QcM+5$9MY6wL6pTcy zQXE@8H`=XMwD(#FJ=Yh}Y;O+6A@;T$T>f1o;P!ZScNBrgzDPjc9*jb4?gZuEp^0;; z70>RijwT53a0+r~d9XhIU+d!(EO(Xys{=?Z)pL%;FN~n}kyBb|u$Hqd{&js{+}#3p zx00~EIan8-^yE%BC=dBD)J@##;s@?_qFM#4YbO_{DxXppFxs)CRQ_5SahByw<(G-d z+mlu<9}ZVWY&6BWyw+3(;^;iS{mjL8qI%-+B;?6EU3?Z;TZ6sRxbtdDt5-%6oA=T< z_9FWkqW0$Kp3_|RCVLmKqGot_eD4>WrTiG4^zEp+{bcuBTUpo}wWoYM7g!Ooeb1#h zN%^n1=;Osf)IIT-Z7BnJatxc!1)^@E+3B&KGnBi_f&fuF0WQAF^(BMJVSq$Hg4oh2 zZV4hN`Ir?pHl66HapR zJ40bkvZ7szom_k$!u&Pf-hGY+V*oEl^B41BC;PK8L>+!FSn>J5S@!TFAr{q0g<3TH*=Tf9vY zJB5*NO#v9|STSrLizWf$*g(D2(ZmDzXDf*OatmZU4E@@1-`pG^qczyy3gdEUn+QMF z2_);=+sDI=Oa`(oKQ|PN*f*HGIT-BvShj!I1&oBzPB>3l1)M3eDq;a=IX|?d?*MK!0=iZJ=gnUKCl{M7t0C!86?2v&wod@y*V1Pnm^`n z3L3O_e{70vNuFFD^|m_+$RE#z*5U8jmfZK-EB<_Z9mEDsP(B(PylG3Gp=_7s!747Y zGC1WVS@KL5zt={U&1)IhvgKKncLysVCZ;$?c~<3rqDtauD~H+m)uLbxz}-p7CxNJP zw40OsSc58wjhu9EMX-{Ktc>K-K~(x=O7e3G4$JcX^m5}_!0`=VFlG6SlYJJ8^$X=zMc6YE5%NOUo zdOhoU(CuywxWfnDp#$&mqI);aD1Co1VXQ`-R9}CcVGo2YSCM zbprN&>us#`0`GTyey83H^HuT4@SyWAU@yl>0~Xw z@P}%q%sf`WP5FG0<1vdA=yX>*MvIiSIQKsDNME5r+3n&{MdIUCyBp}}_`0DYL1963Uu0OD?mB*MpdfkD^iHO*1VM`@nC=5sll z3-9X)5n04tDC|*Q8xE$14N^Vi-o(ak`n;_3@Zby)#EKu$&4yare9If`Zf&pE&@PTTc)zg zKlsc!xCw_c^uu!`vpkLmSU~9QVW(R?&XvicnbOdkz49ymv9D>@ui^|}p0%3bl)IsT zLH%V!A9Wo3TsYoonD{>f;a#I5k&s|c)6=XafE1E^N4=6CVHX<#Z1^Lcl$KjWKaT>v z;A+c)P$IBh<-ImA9jE!uSOv}9`U;)tX?#k@a8cQUhG(7;;#O%iyT%-KeiTCgU>fF+ zic%5zts9UhypN;U6v}5O_Ok;qL&_RaPwWkKiTf$z8-_gnd`v0p8yh>)A9?$Q3I1l- zNaC$_ArbQg_}ifX(dO+QAurZf_&t&_iI7*W{FzEGeh#LC#d|u^3Lgw7tqt;qr6_ZB zaA8ZwS;%lHkjlCGNy5&MgsDgngzvit5}=H^-Pe@CFn7mMfntFql}CrDiAO;?j0Cpc zcuk`}jq|HboSbi1Oy=i@1(F8oPkE4wGbGS&GmWK9Ssdh=yd7CmWeWs~epTeXVH|EP z8NwhRvw>DHJ}?7amoBw=)wolA>!u7)cmW)xlIqYmh=NqFS35Ea46aD64S6ISId+95 zlhAbOvgC2a=_@^v!apTVBLEHKolj>XY(Lo8F`f&cA!#g_bXUJ>agacfaBAth@=~Lg zuGEizM8@Y`ceiRi7*O-WE_ps2GIhXZEuX$cdwkoENi7K$(%MqFimJH+xIzwDwAhhy@;RVRFE-7Hy=Ibt-;KSgrDthN$ z>;49e&_1?{PEpHIOzSyZ#%0Iw^wKac1!dlDkFEAMI3{bSr zv*tAW%kh*uXW-Z~yKcn0-_PE8go9(elep_x&c43-)r$>ukbS$&&GF43Iz4x&iTI zl$1GfGN9gDh^RQ-zrVwEo`!%(tqf2hg*|oK=PXmjLXQgJBm}1kK!2YtPxlRowNq7CrZPASc^A@u$couXJDx|C}%V z2F?UAW{xgOK}b!a_}en-?i7n!N1NwB5+IH%$^|4f64k0X^UWCC^Ut>NwxpzR8_vzl z(=v%s?wvX-xaYx*J*I-!Wt%hi-@7q4*URk<=B@)>Ma_iPfP?^4F%5km~HScO*v3T_UKrgR}EMZIn~E`aq|7znooc8KP3L_ppzzr!a=( zwQpJ&cDIec5^)IK`OvpCjd2@TrNJf-Dvtq)Z|O_84`kooBSymz?Ys+`FUp%H;O!PH z>gGl&GoV1n=hODJbieOU6sHgnvurhe0IQwG@yHGM)dN9x!?l=7{y|XYOsr$d<+|iv zu0wfchj|I~3xqwd38rDVVZFM*JSjL#yAbuf<{}%+&GA&GKFz~sOKYOD+%n=(2H`?C zoNh06SMd7mR_Smy-~p5!yVZKx^dx+g>XD7g?@28TZg~tJqws&Ou}k2$f+GwXNDzY& zu1*jncQ*wq^YhqBg%gIyBggR|^%pTQC0HS8V!%e8B4-ggXGd)?^s8Jro9JJ28O#;8 z*?Wx~f+RCowEnl|IDfU$l;9P92l8ZW)Uibjp|@`NGJ@#@d?jZ+IBBP5&gvw_mzLyF zm>CwYpN8zV(#^A54YI6F*PjH+5E4HZ?5$a7X#umz;kFg;gG+Oau_C^GzH|ohV|d>= zlu%G;vsT&(?)o!4>7v^}8SMcsLFZ!TEFHI55M(zg97{ZcXAKTXk!5idsdhr?$gpf= z;cvLX8Som2n|wu|{%hRl%@?462raUUlCxTp%)VyepO|y2a~O$k=LELySizv{NRcWv zaA=_H9M-nHgb+ZLz~?3r0jqVohnp53PLlo=9M(@B>xJ$m21*m*Xd%NTlmFx<EY$$S8=v@XFjEW=Z&tgyfg8cd?n z3>=ay5i)sor}W5#Lf2^{X98?jr*J4U=^;1R2}*u@$0vau1)d;?h|4eVy|y9PAriJ@ zi%lc%1@V|D_s=C3$U%IA0(kSLCIF196|^9o+G!_NFI#)!yt+JZ9zX@sMKt0$ZzlJ^ z**Wz6YnZ>R_PYcjPcomLq_;=1Nipl6axz@A@#X=?${3Du;l4UK$3>=kn2lq(x0gaj zx`%U*rdHPsNROTO0rIIOW8?kGAML{%l~&ayASt!9pKSc=$ z4L>)aMEo9|np^iK?Wj2cAG+==McC<~+=_1317G&7#tiKR!?mEpoQjb2{#?PAcSUg9 zL2oE>{v>A7Ooyz3M8ip;24E+d?t43+YtyvXwF+tyTg}|6S^&Rof{#k0bMPFnSsi&X z_wi}QvNyj{Gx642jznljlCuxfKOFbxFXc?5VpWb7sn8`GAfoBJmMMo+*X+uO?_P7e zd$A1TeZVohh0K@|(cF6wj_IQ(SVpHjYX4qn%e8|+P!i10LHD>;S`t;oU? zaY5Q|$Kr5##GeUH4OXo{6*8Z7cN_M{OTG$FWXbu~oBGn@h>90qjGD!cI=OpFF09g8 zA^vrhXSOozL9b_I?|h!LX_p&@*V8x6Gtf87dmkrmswz}Q$~4?qkU1G+!h}Z60er?6 z{ijE{#|;YqJ(XHdRxi4`N*@>kPqjm-sWocYsZ&Mua(%azt&uW9I(ce5;DAs+s5beB?5U;UENQM=H<8D$-HB&bPN@3vqS`B(T? zGjM_M4HGg#bNq;}Tw+Q5b-!&vERH58PolkfLu4_rmJ;nbNK5w9^D>69UmS`B%-hyS zG61UaI)8!{^sp;n8M5ym0HFHo(2>dHzQGTkzzfJ=%Yxc^uLr^}fnn2S>ZwkIS+a)( zB0Q>KCOJ_z#(I>MLp9V<_Q3l?3(soUe5l(2kO6R!nJP_C*}6`9ULoNPqB~@#@421* zZYHbFJNLRNo3GHD-1J zb`SYqiV6L+{LozflHni%^;tw#q~hx%+We{C2*G+`4o2hghJNqf9fzI7uBRi5v(QQz zbB_pfN&bgERuaSxW3s=FP$exr!6-PFvarfTK(|hl|+`aeS`#r*4U(KtFyZ7FEuQ7xWCnvG7 zy|{||Ird|o*AX!Rh{#>VR!b7QPHrJ205r?PetG`&cinOA`9E200^}A!0!8;X_r6tm z=d*V&?_q}|^F{#ezdTiWd+pSAb?s7fwQS^W6{L+IIO#ivXD^ zqB#0-*^<9SdB>aU1AsXqu=m5ZlzU@rcD& zEk~3`Jun=a4_l5YKU^R>af#)q@=w87#L;qG`J;IRpMTeiWBGJ25^;&;(3@>A4$+-B zwtQ8rJFHmtqy(c7E48D`7e%_miudUpf)eeq2MK{#e%JY|&#&yy z=aF)Obt@aAQ)~NvBuwIW&ZhkMK=#pK8YBt)PC5DKn*C^jgcomhPSyF1X9t356o(vg z+O=O<`H=6&!#)c{qPJSMoYA^G#1|3CHg2^gWkdcae))YQed@c;r+g~`Fd-7Vo%YbD zvrKs00zo8A0CSz|tt|K?5XqA$9?ye(e*_SxS;WZSck?mDc< zz-}L;XIqkO51a9d=R<5S$wz|0h+!C8J_$v*Uxs>q0gMIAe`H0mywN~#;xq@AF9BnL zI6aWU1lN<@*tRanrGn!`xzvfp&Y{3iZ{aAC0RYf>DT5gV~lZ zN5L@vi6+Va1QE6IYP9V~|M~k5ix9-V&ZGPrFvL|>4pjT55NN)Y2g`qnKpg079ung! zD-)A;1T^2ug!~MVKC$LZ%GZJs&bM-L4vO@E$8%v%_C}E!QBIxD(ScxcKe6)R+2y+> zcUM;AOTeVC$48lY24F(Oz?mH%h?I%HSb4G6n+X9M*^#dW(^(j0sP2P_5C=NH@?8m? zWo5^%mccZDZ8?(fN@j87EY~Yw3dFw7vV2o2f3)&6Y9Wnbo-F?mOaP5nv$dWAu{3d> z<+qwhVa?gzY=H^<)XG)8oW%M0@*s)pGq!gU!z~TCH&=4Kk^pg2uIzCuiL*SOOr}*; zRnzHoGOczzvuw|%QeD^6s;cU$nrSwZ>gi;>Ii6J8Po}jb)m1&2R`ql`nO3!ClgV^E z-kiL&2U6UlY%eW!y4`NK>~z}gvfJ&JW!c|%u=l=e{c^RxywdBGWpBCH>ks<<)qbzD zw6L(aRBqpH_f`k}ey`hWce=f@)9#eZgOzr>Y%MM<%;En|09H^qAOa2m08ljmodGI- z0Du5Kl{}b8C8HvtFn7yXuo8(t8Un=IgVuss!OqKGlJ?{Ifb*TxqTZLk8tps1-zk3d z&&L&gcWP_ez9sj&@`F$>WbUlle?Y$Of6DN_)Z@cmz#d?CrhVJ~Kd=|)Z~I^2y_NIK z^;iDS^nU>#&40Ro*Y(@>L;JVvllwB?E~oLNfw0zpO7024aTu)ic*i|Ki=P zeoCw^R-TK)t@qh*KWbu{xHqyP>( zeq*RFI$v-#WA=|;Sn&ZI;qCrxh28Isu#oz}wx8HR_{o1$j*J`LT45=dY*yz~W8}Iz zb}p^0^AG*6zDMc+0RH{7?fn>3D@@>?B4^#8>^~j}1mm6qf9?Q=!9!ErYNk1@amN(o zk*L;PDCSEO=q{Sbn1yA6hQkzhUK;yrEnNRIQ#Qj@%x-iHvOP6;)BtL_ix$Wax?XRu z=1bUxCs?Iph@7`3P=>9e%kM-Rl7Hrl`qyhJq0x`pe`$o_AXR>z&m=9fPUf*4hsDjP*o7+U+ zDt^_?A(%I|J(w#EP2aBpa)(K1bjrn;&2-N&Uqg-f7FI+tcztqSh3ac<&^<&eGLKRU>cnN=s zIJ0Sgi%!wO=Vqq-$X6>sWH)p+JfwF=)wTy&X(rckx>@BCZSPF$u-WWRIS*d*fYj40!-xPku&*Oh9-+KD%B|6GyjH=x>R!-np#0QH{ zUoI(THoVGdUuGf_-$Yu@G^ zyToPACwMa}OE%JCQWZmVsRiGBKW;Uftl=-XJ0MEU09gOWALjnm_W761NKT zSv6RS9ms~tz%}JA+RgnF?M8E{zk*2NU9(NuRA#SKuW{c6gM7PZpFhiTQy!2Li6ax$ zJ@<{xlFQp9yW`D!Ky*PGLDP(`g^T(Oa03W$0*5#j&x00j*F|x!3|9j{SSKJ>+I6wO z=%j+`D-kt(a0k0!-ArogOFkvq!T(u=Oo}V7=!P^h(Rm@`%W3eKsq?{PfYET`A$=c= zX{JF-S?b(vPsdP zcbR~ztVvt#7Hu;fT$sa`-u|_`TXoW^M9NO@s~jdQ#U20)DiD79{PTu_b&tgC2vIH< zEi$<%<2k9Gs(fdy@q-(Kz1__yhiMAI%*dLsy6AxHdX@m%UmtJH{0tFQxg6B_rO@E` zNgVaEaO#D$!xo3D8b?pb)F%d@3wr1w+KSc^)FO>J$G2^wyZ5HmVL!H5BSgWZ^bbq# zXlag4){<3wWYUM9&20tAAzMDn-It>rOK4Fj!*5Ycux}L?M=4&i_$sDiE}ha^uW`Yhl7Jad?XxO&{q8E7@!la!e#U`8@hHK;S$h;5K$x2Asdm(;t(@RYqY zrJzf0AL{b_N|qQO?-w-rHkk!sdDNeZQ|;eFps9T|V)0S4@P7Ug?BL2AJ-(~l&~NJwPbXGCEflV_!G(+A|HRuzbEY}G zD5n~kV9^mgxU#s{{|f&Y4aPXfm2^uMU48q*88r#i8~M=wMRfC6%kd!9cz*u0W5B(>9l5sJjDn9NJO8zNyp|b=mu4#A;mx_{PicKnc&6TxyL_vy@gJ1P zTnf&yr8Qgtt%J9)I^;neR@8zo{@e=Tp!kG7oEzjRqY?wwz{%n#Z zTE^AmoSDW2t38=MqK_O^nT;SV&APu_Lg;M+F2aKPCRrtsSLuGcfBKj=Y&YGS^)vX^ z;|ndkKkY(kbT=KY3+rcn%<5;x{d!&J+)*6MM+TYL@}Z2ih$saQrkf>!GMm~?-#&-) zhrw{YM7dcGsHsi*og~jai=a-mnxju`XecuYR@uEA>0?GE!={?+aiLEQsmCTIyPiMq zv~86+Zkjf}pqPRDh; zr~&7EZ4`XrT~ezDx+Ke2@Jwc6{u&1`G}jQ(QX99V4@dyWWuJdR{yC3C4akZNMC??_ zoJ=Pk?+VhUs29iMc>52Fnasq;et9$$%b=gpq7;;( zg5nbrA6CW}hN{y?Ix0LK7^xZG5pO?7yn)0NBOUKPR?^NMT`i~Hi;qxvWzo6f9|H9P zv%=@W_H2b7K35lNC_`gI?0KvN<}T#{Wv@_pB6^M6qJ9s#+j>w{4mNGDGE1B03`UD z)+wvYPit-v(>cLGn8q+flUMz>IEZuU2qqw((XMCLZtOGhvEo2EX$VzIaDnVNR4eS5 zNNf^qm@B1MS~q|9t>*u|VbX9(xJHe0=u^xn&!e>^-Djxw3TaK{r~J4rfjIuafMD-G zUmjO_x*}QThd49G;z?_CIPRjV4F=SbynToZ0xQOlpa2^nse5r9=Zj13SdG*N`}c#1 z4ws*2N7_~&o;T-B`lrF%i2hl=Hs4d6;T&b1rXfDE*yzHy)tc^rhnP*zTQZ{hsnq&g zCYx&F$tMsm#~lN1uY$FjX9VO3Lx=c!o*^>)OISAczyoE39{FoR43<42wgx_ zps1y(IPATm;h@MFPvvY>#&U`xWR8b|A`fzj>{vIG4Q>08ej+YzVY;L;*|U2M@GM)l z7DZC0hO?Me$d=wE>-9zbD&CQ?{*|>=wvpl?v_tS94UMez>35Erh7ScaVW6*?rdq29 zw3xU+GC92^{7r=$Z$f!ejA1kJQI-aGzoW3wIKZ?qgLi*2Xv2OeEV`1PRz%P+n2h$k zKC+QDw~vOAOP~&jk`*}|(JU0?8nezf*C2hXZO^_ze5sV;RbtIT68jOkDB#}em(6X@ z4KqGQF65$Xg}GRSsKYd$|I+1;UqV;y5~d&FHj_W0001C1@}k* diff --git a/public/poses/libby/thinking.webp b/public/poses/libby/thinking.webp deleted file mode 100644 index 9a0e9b76d1dcdf2c3178fe1aa96410fc776ac19a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4814 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 diff --git a/public/poses/nibu/amused.webp b/public/poses/nibu/amused.webp deleted file mode 100644 index 62f3a160ef2925914f552bccf6fcab258ddbd288..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4604 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%0AiS|dFXs@0a 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 Yo&Uc-Y}@kK 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 I000000Pp!~CjbBd diff --git a/public/poses/nibu/glitch.webp b/public/poses/nibu/glitch.webp deleted file mode 100644 index 686978be8468a2a62404f3b9b1ae9106b71cfa65..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5106 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 U;9Ll+kGscvP>b^l0000001znoY5)KL diff --git a/public/poses/nibu/happy.webp b/public/poses/nibu/happy.webp deleted file mode 100644 index e9a10235d27575bc508881917d62732ab7b2ae5a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4580 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 Opo}(ufCwRc00029nClq; diff --git a/public/poses/nibu/lore.webp b/public/poses/nibu/lore.webp deleted file mode 100644 index 9e10a4647ebd15d8f87782ff622d28f68bb8d968..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4730 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 diff --git a/public/poses/nibu/neutral.webp b/public/poses/nibu/neutral.webp deleted file mode 100644 index b07dc83b385a3db6408f4f299ff6ae233615536d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4384 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 aXkT`vHM})F*F35ZC4!L-05$V~0002C*>P_G diff --git a/public/poses/nibu/surprised.webp b/public/poses/nibu/surprised.webp deleted file mode 100644 index 1454b77b73b36f8dde08626dc07e2e63ad559496..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4818 zcmV;@5-sggNk&G>5&!^KMM6+kP&il$0000G0001g004df06|PpNCyP~01@zpY}-2b z*|u%4!4L?9YN*>#ZwG3ppS3`sE`jQBS|!xo-QC;3=~nNY?tTX9?teKw1Og#2rvwIu z9o7$#^PJ<@PyZ4T6QKX+^s4{q*tY&&+y6XlNgCergNK}Q{?g<2Z;nWk{ZGM`G&+85 zXU#&ea`!jix#5t_B-z>7(#9t}I?M90EX&V6)!t09Q?s5yY)SayHf( zeS$(G_|67$F4ox)lp8$XlrynT3Ca%E?JnnG9oMJyV9j=N7FKJXD2?FR4V324FA2&I zzB81Bbp=6%q8Ol67E@O#gcAnx|B_bqA40aCc+0 zFl(DPGJ(JDE|;azX9*GwK3iJjn*=$=ED*s1D%yI0AjRO7^B{;@UE0k=ByrL{1hdDf z{44qdtm`GPWiLLvja-~{*c>cel)wYJ^+3rg-`f3oK6?gH-LxcIHCh|o+PW`++0A5C zYD?4A0f*EhYqOd9f?KQFpZbGmt%@!ss)NOrT#NM041!B58lB1Ed8<-uoFw;`Yq^=o zAegE}jhC{R*-TYhAMBH3Fe%q@1ChnFRpsy-Speg5dDb{-2H#PI)}urgz#(#JR%?+w zgI`$XO%ZtjN6KYctuC1czqCsGDv=3rj9ij+V32F@Y(r&#hsXtJ%jH;yk#6wz1}b>y zSt1*P^W;*jQwS3J&BiKr`P`uMSlGRUV+a%f8EE;vIe)^0(5uO$h$-)!SfCM^6g<2`5mIak90*g zXKS-x>?Idq9Y-)GjsAvME^3P)ShKN)HdCREX_!<)XRP$bTxDRmerKeQyPQ0s1!ct6_Bo7d zgN0o_OYb;YWe-msBFj1>2!iwV08veQWR6eBD&CT0ozf$Mr!?Flst6v?#QcP;;;m(^ zW9A8dv#~b#CQ$=V45`_aRj;+bBK5 zY+SOo{6xFlPkiE(Pqf?Z_9-7$hSooSdl^@m#Q(Me09H^qAe;>V0Pr&aodGI<0Dk~J zl{}b8C8HvtEu9Qduo8)60MLk?Rb;PkP0eG4Z_i2la(UPNB!7V4Y~Ee}0fLGAzqT*+ zz7_cz`6un3f4Kkamj*mW?>B%Gsrg~(1!s<iW2)` zG^tBFK&yn?4w}AeziC8)2P0plx6IoH3i3u=cyrD~ce^IOAk8cHry|PExL{BiJgjOJ zI{QS`FhfghN0e!HS)!DHigT7WOHRKbC@bYDd})=Y1!niA&(CYQIdqad8*z-kW%1udPh` zzze)ancZy?ht!CNCK`=P8g!78i^IegG0m1{ot^_yQ}(TPXEHCwyaxq{6LqrYZ1B;J zv<2uGbm?Y+VTIN$?g9Nxs-W+<PF~K(<17C))w|H$3GStib zg44~^l|9ClR8*9Zx&2tqTB|4hRS-y^{VMSOB*{%}Gef&rEfro*<=?-IzsqRQilgdN z&~h*QMF)CQqzHz8tJP3bVl0T+9!XLT9xzj8kTR$q2SH>zTT8LNX|JMNVoePo%DyU< z0rVoZun#bfE}SJh@IX{V;6To_%9ii%P>=vmJYyDFu`1;OjVR?Dt74mG^5vD8A;x_n zIsVcLa!H4HtG-hpF!5L9-@9(FfPO`ZZ5`VN6nP&2);{zFv_ZIcR1x z!wDVnMXjC9Fd5cxsVwuaCwgD4pr`Vk=J$dSQCGsr-`H7gHh)0qn#MYh+5)`CuwgVU zS_Fc_F`0^+8>sdM4r%J})W?p{8-9h!u2L7OJxi&>tU&d^qV6Mn@OMUzH;%ywLB6>% zKp`oh^fzUdvPk(Aw-D-NI-m!{e5 zzdpNyHJGi6jvdPb-gH2h8z;>_`x2ED=yN!<|OU=!{X?HbW7?cuhVQ`BS?5on|ULQz;ht67b4bhAT= zZ{Bgq8Uy+DNC`T{ckl`GW2Jg7r%Eb>PRFyA?O1FY8KFL>=tFWNAKxYsp{S^grym)k z=aK2VqP!;*9I{ig4hy%5FP>s8SJGbSf}xBBo8cP2$pp0WZ+b2acng)vt`V4N~bAQS0INu-rXhvccM_E3iFCV25IWwmp&!;t}oz>dMjr8Jw`4+QrfGhT>} zWAK31eVo^l+#HEL3FbibA*|lC0jOk6zT9BeKV;a_-`g;DfUhsIiHZnMF+SCvZSpnR z9MKnSRa0ZHjmzO|o&CyM5}W%orZvN2wEk`p{%3o@X}u^hzcA|8v3KAet*?%brWjiz zmk11t=#Zvrr^oKF_m=^0ZAi;@WRMzD+cRffTRvoz^xh5*iaavbDY=nc)V>aJ?VU_* z#*fRFoZ8kmN709b8D^fu(@wlVJ;bQlaGA_@m(b{a`$t18t1Pm?%Y*{4^2DLI-~3Gqdj%ao()3&izgAvVy;eRo<5VJ0gI9rt)g z@+Xe)nm^L-5Z|aC<{wGuTj^MC(r7|nb(|uFX0E87`*dfJ$8AS~ZSx|b@Mk{@v878% zupP))U_!T01#3M-D-t;&pYO?R9B^+t6SB)PuHjP}aNFf8PlO_!>^jNNW%}79QwcL@ z5;A{18mI)&R^3y8Ey`u21PQExbs8V?7m^*?qE(#afLZ!)kTy!U)iuv_LZigEx9Lo+ zLVmG6U};JY{meFXsIhnXz;cGSf-Oz*p|gh0iSO!?J$?b=Dzz_*k*sRW3PV}YZT8{!*5pzl z`{_+Whc&>D{hZ$;eR4mZFk!#T_)EWegu%MD^wpj77ehl`mukdb+Z6>wAN^7->C_40 z*47JnYAqbvtEB*&Vgm1Ex5lFT&~g?0^3&{tuYnr_XhYe>Z}4*c0z#MiT9K<^{uIJI zh?1God$olzmCKn}3A^5hDr!D zJI!O9W!hwY(m)HS#Lh>>y%;HzK4x`u0_hv^|NG!vbK=M24ay^6A%(9>#VVurXy^># zo(un1GDW3%^ow}Go}@gg`^JQyFdwidkV-E1H7_?I()~y&xdE_+Wq20ie)_} z(h5hY07(S0S}pQD3RSRO?yQ-U3oVs7v%%&Qpcmp_;-iy12vLCu?_PRJ{w zS#bqvQsr6t@8slL|oY@w?0z&AMMU5x#-X=%we5l)-QYcw7L!2SFb7K@UeUE z+I@EM|Bzzzy}~|zUiu*Rj!{8lP`bEoGgOW;z&%P~Bsk12TE8w1cJCl;#$NcBCOqCaUSr1lqw#V`uox#j85GVT%29i<)R zzGuNCGD4D2pqGHxod_li#^%kU3^?fsI43N&J8k@-l51`1FjEDRl*)ro-+WgFM`T{x6yr4TzW*`HDJ zPk+8rA0_wq^pw)~^&=Qj=f_gfch)+?+Qg#_A_c_G@b1t&lPfKw=3l6F9>x@-r}B|N zWTocC_@3eZ`Js5JMK@kM)x5QdajtE#rI!jb>QgBF933@MsWEdK{{irksdE)80O`<) z2Z4V<%mPz$;?(_RT!(2KpcPcldaT@8zEv+Xxc@HQ-xFc2Ob8UkNx);%4S=`%hx4z|K>C;c883&*m0^qU*! zX9x&AO<0D)VQXCljwTVefnrS+>Z^ke5{i&6@o+Jv`?*RJIjA1izNHN?hP&Df@WN4m zZ$EGOdrwQqCTBmV2=fvGExw5f;?o@i3*ati!utAEP#(({(o_4?{^*K%{R_y%7!{`V4#w8l?+ zk$II)Z&KVTG$`haC}WZxe`TT%4wXVxAI*-mU+8!8em<271yo~14JoGiGDjRY9cgl? z=Bm%aWNOKhOi#}EPE78P8e3tdp#^+db(3!8a7oeM_lR$YPTl3XUBow~cw?$VB}oU= zW(|$3o;`8ojwoDAvYI(aop^e`($fqYlx?_gp>U|5i-jl4d+!tbw(X%;fkiJqOm5LM sZ-Du9l3tVg%IMba7BLZtY}@ii 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 a72~6~<^};DB9Izz$2ZA!4e-+J1ONb`dn?8O diff --git a/public/poses/nibu/triumph.webp b/public/poses/nibu/triumph.webp deleted file mode 100644 index ea6157b427d189e141fcd45774cc6363dac6c775..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4252 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 diff --git a/public/poses/weksa/amused.webp b/public/poses/weksa/amused.webp deleted file mode 100644 index 8ec99ef3d33ff56065888292d1efdd6dd18ca2c9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5088 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 CE%;#o diff --git a/public/poses/weksa/annoyed.webp b/public/poses/weksa/annoyed.webp deleted file mode 100644 index 9f43f9364166511370eabbf3ee48f88e26341646..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4422 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 diff --git a/public/poses/weksa/concerned.webp b/public/poses/weksa/concerned.webp deleted file mode 100644 index c98206f9fa43e1bcd4d529fbaa4c8d92126e049b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4572 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 diff --git a/public/poses/weksa/fieldwork.webp b/public/poses/weksa/fieldwork.webp deleted file mode 100644 index cfbef5c5dd0ce444185a6dfd5dac7a14100715a9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5408 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 diff --git a/public/poses/weksa/interlingua.webp b/public/poses/weksa/interlingua.webp deleted file mode 100644 index 88cc46492af1b06a401d15230b898b4a1a5d5567..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5484 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 diff --git a/public/poses/weksa/map.webp b/public/poses/weksa/map.webp deleted file mode 100644 index 2cbfa70197d53360c0c94b02383460cf4fe74203..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5202 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 diff --git a/public/poses/weksa/surprised.webp b/public/poses/weksa/surprised.webp deleted file mode 100644 index 5e74a1959d89489afe3f90c699033ed7a3a3a511..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4232 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 diff --git a/public/poses/weksa/thinking.webp b/public/poses/weksa/thinking.webp deleted file mode 100644 index 8f2862917f69e3da1ec0864640cd248c9b213616..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4612 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 diff --git a/scripts/sync-poses.mjs b/scripts/sync-poses.mjs new file mode 100644 index 00000000..9a5b1f83 --- /dev/null +++ b/scripts/sync-poses.mjs @@ -0,0 +1,24 @@ +// Copies the pose catalogue and the art it names from the cast-art directory. +// The catalogue is the only list of poses the client knows; the art is not committed. +import { copyFileSync, mkdirSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const src = process.argv[2] ?? '/opt/commons/cast-art/poses'; +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const catalogue = JSON.parse(readFileSync(join(src, 'catalogue.json'), 'utf8')); + +copyFileSync( + join(src, 'catalogue.json'), + join(root, 'src/app/components/message/pose/catalogue.json') +); +const files = Object.values(catalogue.minds).flatMap((m) => [ + ...Object.values(m.shared), + ...Object.values(m.signatures), +]); +files.forEach((f) => { + const dest = join(root, 'public/poses', f); + mkdirSync(dirname(dest), { recursive: true }); + copyFileSync(join(src, f), dest); +}); +console.log(`synced catalogue and ${files.length} images from ${src}`); diff --git a/src/app/components/message/pose/catalogue.json b/src/app/components/message/pose/catalogue.json new file mode 100644 index 00000000..87e7ba71 --- /dev/null +++ b/src/app/components/message/pose/catalogue.json @@ -0,0 +1,242 @@ +{ + "shared": [ + "neutral", + "happy", + "amused", + "thinking", + "annoyed", + "surprised" + ], + "minds": { + "eureka": { + "sheet": "r1b", + "shared": { + "neutral": "eureka/neutral.png", + "happy": "eureka/happy.png", + "amused": "eureka/amused.png", + "thinking": "eureka/thinking.png", + "annoyed": "eureka/annoyed.png", + "surprised": "eureka/surprised.png" + }, + "signatures": { + "probe": "eureka/probe.png", + "inspect": "eureka/inspect.png", + "report": "eureka/report.png", + "hood-smug": "eureka/hood-smug.png", + "concerned": "eureka/concerned.png", + "determined": "eureka/determined.png" + }, + "signature_descriptions": { + "probe": "Frowning, pencil raised to her chin, working a problem out.", + "inspect": "Frowning, one finger pointing at the thing to look at.", + "report": "Smirking over a folder of findings.", + "hood-smug": "Eyes shut under the hood, self-satisfied smirk.", + "concerned": "Worried frown, pencil at her chin.", + "determined": "Hard-set frown, pencil in hand, resolved." + } + }, + "aqua": { + "sheet": "r1b", + "shared": { + "neutral": "aqua/neutral.png", + "happy": "aqua/happy.png", + "amused": "aqua/amused.png", + "thinking": "aqua/thinking.png", + "annoyed": "aqua/annoyed.png", + "surprised": "aqua/surprised.png" + }, + "signatures": { + "concerned": "aqua/concerned.png", + "fretful": "aqua/fretful.png", + "weary": "aqua/weary.png", + "glare": "aqua/glare.png", + "smug": "aqua/smug.png", + "sigh": "aqua/sigh.png", + "charging": "aqua/charging.png", + "delighted": "aqua/delighted.png", + "content": "aqua/content.png" + }, + "signature_descriptions": { + "concerned": "Worried frown, brows pinched.", + "fretful": "Anxious, a fin pressed to her chin.", + "weary": "Droopy half-lidded eyes; worn out.", + "glare": "Scowling hard, a fin held out to the side.", + "smug": "Sidelong look and a small smirk.", + "sigh": "Eyes shut, breathing out.", + "charging": "Eyes shut, a charging cable plugged into her mouth; recharging.", + "delighted": "Wide open-mouthed grin, fins up.", + "content": "Calm, small contented smile." + } + }, + "heimdall": { + "sheet": "r1a", + "shared": { + "neutral": "heimdall/neutral.png", + "happy": "heimdall/happy.png", + "amused": "heimdall/amused.png", + "thinking": "heimdall/thinking.png", + "annoyed": "heimdall/annoyed.png", + "surprised": "heimdall/surprised.png" + }, + "signatures": { + "concerned": "heimdall/concerned.png", + "determined": "heimdall/determined.png", + "badge": "heimdall/badge.png", + "barrier": "heimdall/barrier.png", + "protect": "heimdall/protect.png", + "revoke": "heimdall/revoke.png" + }, + "signature_descriptions": { + "concerned": "Worried frown, brows raised.", + "determined": "Hard scowl, jaw set.", + "badge": "Stern, holding a gold key out as his credentials.", + "barrier": "Palm raised behind a translucent blue shield: stop.", + "protect": "Scowling, cloak swept forward like a guard.", + "revoke": "Stern, two keys in hand; taking access away." + } + }, + "kiko": { + "sheet": "r1b", + "shared": { + "neutral": "kiko/neutral.png", + "happy": "kiko/happy.png", + "amused": "kiko/amused.png", + "thinking": "kiko/thinking.png", + "annoyed": "kiko/annoyed.png", + "surprised": "kiko/surprised.png" + }, + "signatures": { + "shy-smile": "kiko/shy-smile.png", + "uneasy": "kiko/uneasy.png", + "tinker": "kiko/tinker.png", + "confetti": "kiko/confetti.png", + "sprint": "kiko/sprint.png", + "debug": "kiko/debug.png" + }, + "signature_descriptions": { + "shy-smile": "Soft, shy smile; blushing.", + "uneasy": "Blushing, tongue peeking out, unsure.", + "tinker": "Delighted, holding up a small gadget.", + "confetti": "Open-mouthed grin, confetti falling, a glowing glyph on one finger.", + "sprint": "Eyes shut, laughing, one arm up, mid-dash.", + "debug": "Squinting through a magnifying glass at a bug." + } + }, + "weksa": { + "sheet": "r1a", + "shared": { + "neutral": "weksa/neutral.png", + "happy": "weksa/happy.png", + "amused": "weksa/amused.png", + "thinking": "weksa/thinking.png", + "annoyed": "weksa/annoyed.png", + "surprised": "weksa/surprised.png" + }, + "signatures": { + "concerned": "weksa/concerned.png", + "skeptical": "weksa/skeptical.png", + "map": "weksa/map.png", + "fieldwork": "weksa/fieldwork.png", + "argue": "weksa/argue.png", + "interlingua": "weksa/interlingua.png" + }, + "signature_descriptions": { + "concerned": "Worried frown, wide eyes, scroll on her back.", + "skeptical": "Half-lidded, flat frown; unconvinced.", + "map": "Worried, unfurling a hand-drawn map.", + "fieldwork": "Frowning in focus, holding a carved idol and a brush.", + "argue": "Open-mouthed, pointing, holding up a tablet as evidence.", + "interlingua": "Shouting, a glyph tablet in each hand." + } + }, + "void": { + "sheet": "r1b", + "shared": { + "neutral": "void/neutral.png", + "happy": "void/happy.png", + "amused": "void/amused.png", + "thinking": "void/thinking.png", + "annoyed": "void/annoyed.png", + "surprised": "void/surprised.png" + }, + "signatures": { + "determined": "void/determined.png", + "glare": "void/glare.png", + "shrug": "void/shrug.png", + "reading": "void/reading.png", + "point": "void/point.png", + "deadpan": "void/deadpan.png" + }, + "signature_descriptions": { + "determined": "Hood bowed, narrowed eyes; set on the task.", + "glare": "Narrowed eyes under the hood; hostile stare.", + "shrug": "Both hands open, eyes wide: no idea.", + "reading": "Holding a sheet of paper, reading it.", + "point": "Half-lidded eyes, one finger pointing off to the side.", + "deadpan": "Flat lines for eyes; unimpressed." + } + }, + "libby": { + "sheet": "r1a", + "shared": { + "neutral": "libby/neutral.png", + "happy": "libby/happy.png", + "amused": "libby/amused.png", + "thinking": "libby/thinking.png", + "annoyed": "libby/annoyed.png", + "surprised": "libby/surprised.png" + }, + "signatures": { + "concerned": "libby/concerned.png", + "sad": "libby/sad.png", + "stamp": "libby/stamp.png", + "research": "libby/research.png", + "shush": "libby/shush.png", + "schema": "libby/schema.png" + }, + "signature_descriptions": { + "concerned": "Worried, a book clutched to her chest.", + "sad": "Downcast, a book clutched to her chest.", + "stamp": "Smiling, stamping a page of an open book.", + "research": "Eager over an open book, an ink bottle at hand.", + "shush": "Speaking over an open book, one finger on the page.", + "schema": "Raising a finger beside a glowing flow-diagram panel." + } + }, + "nibu": { + "sheet": "r1b", + "shared": { + "neutral": "nibu/neutral.png", + "happy": "nibu/happy.png", + "amused": "nibu/amused.png", + "thinking": "nibu/thinking.png", + "annoyed": "nibu/annoyed.png", + "surprised": "nibu/surprised.png" + }, + "signatures": { + "sheepish": "nibu/sheepish.png", + "pout": "nibu/pout.png", + "resolute": "nibu/resolute.png", + "console": "nibu/console.png", + "glitch": "nibu/glitch.png", + "triumph": "nibu/triumph.png" + }, + "signature_descriptions": { + "sheepish": "Arms crossed, awkward toothy grin.", + "pout": "Arms crossed, pouting frown.", + "resolute": "Arms crossed, scowling; standing her ground.", + "console": "Frowning at a handheld console.", + "glitch": "Frowning, palm up, a glitch tearing across her eyes.", + "triumph": "Eyes shut, fist raised; quietly pleased with a win." + } + } + }, + "shared_descriptions": { + "neutral": "At rest, no particular feeling.", + "happy": "Pleased; smiling.", + "amused": "Quietly entertained, as if at a private joke.", + "thinking": "Working something out.", + "annoyed": "Irritated; patience running thin.", + "surprised": "Caught off guard." + } +} \ No newline at end of file diff --git a/src/app/components/message/pose/poses.test.ts b/src/app/components/message/pose/poses.test.ts index 0915cfa7..ed503abe 100644 --- a/src/app/components/message/pose/poses.test.ts +++ b/src/app/components/message/pose/poses.test.ts @@ -1,18 +1,28 @@ import { describe, expect, it } from 'vitest'; -import { mindOfSender, poseSrc, resolvePose } from './poses'; +import catalogue from './catalogue.json'; +import { mindOfSender, poseSrc, poseStatusLine, poseStatuses, resolvePose } from './poses'; describe('resolvePose', () => { - it('keeps shared and own-signature labels', () => { + it('keeps shared and own-signature labels from the catalogue', () => { expect(resolvePose('aqua', 'amused')).toBe('amused'); - expect(resolvePose('aqua', 'conduct')).toBe('conduct'); + expect(resolvePose('aqua', 'fretful')).toBe('fretful'); expect(resolvePose('kiko', ' Debug ')).toBe('debug'); + expect(resolvePose('void', 'reading')).toBe('reading'); }); it('falls back to neutral for missing, unknown, non-string or foreign-signature labels', () => { expect(resolvePose('aqua', undefined)).toBe('neutral'); - expect(resolvePose('aqua', 'smug')).toBe('neutral'); + expect(resolvePose('aqua', 'conduct')).toBe('neutral'); expect(resolvePose('aqua', 7)).toBe('neutral'); expect(resolvePose('aqua', 'probe')).toBe('neutral'); - expect(resolvePose('nobody', 'probe')).toBe('neutral'); + expect(resolvePose('nobody', 'neutral')).toBe('neutral'); + expect(resolvePose('aqua', 'constructor')).toBe('neutral'); + }); + it('knows every label the catalogue lists, and only those', () => { + Object.entries(catalogue.minds).forEach(([mind, entry]) => { + [...Object.keys(entry.shared), ...Object.keys(entry.signatures)].forEach((l) => + expect(resolvePose(mind, l)).toBe(l) + ); + }); }); }); @@ -20,6 +30,7 @@ describe('mindOfSender', () => { it('matches localpart or first display-name word', () => { expect(mindOfSender('Eureka')).toBe('eureka'); expect(mindOfSender('x', 'Libby the Archivist')).toBe('libby'); + expect(mindOfSender('void')).toBe('void'); }); it('ignores humans and Object prototype names', () => { expect(mindOfSender('alice', 'Alice')).toBeUndefined(); @@ -29,7 +40,44 @@ describe('mindOfSender', () => { }); describe('poseSrc', () => { - it('builds the asset path', () => { - expect(poseSrc('nibu', 'glitch', '/app/')).toBe('/app/public/poses/nibu/glitch.webp'); + it('builds the asset path from the catalogue entry', () => { + expect(poseSrc('eureka', 'hood-smug', '/app/')).toBe('/app/public/poses/eureka/hood-smug.png'); + expect(poseSrc('eureka', 'nope')).toBe('/public/poses/eureka/neutral.png'); + }); +}); + +describe('poseStatuses', () => { + const ev = (sender: string, ts: number, label: unknown) => ({ + sender, + localpart: sender, + ts, + label, + }); + it('reports the latest pose per Mind and when its unbroken run began', () => { + const out = poseStatuses([ + ev('aqua', 1, 'happy'), + ev('aqua', 2, 'sigh'), + ev('aqua', 3, 'sigh'), + ev('alice', 4, 'happy'), + ev('eureka', 5, 'probe'), + ]); + expect(out).toEqual([ + { mind: 'aqua', pose: 'sigh', since: 2 }, + { mind: 'eureka', pose: 'probe', since: 5 }, + ]); + }); + it('treats unknown labels as neutral runs', () => { + expect(poseStatuses([ev('void', 1, 'bogus'), ev('void', 2, undefined)])).toEqual([ + { mind: 'void', pose: 'neutral', since: 1 }, + ]); + }); +}); + +describe('poseStatusLine', () => { + it('formats the client-status line', () => { + const since = new Date(2026, 10, 4, 9, 5).getTime(); + expect(poseStatusLine({ mind: 'void', pose: 'reading', since })).toBe( + 'Ratatoskr: Void is reading, since 09:05' + ); }); }); diff --git a/src/app/components/message/pose/poses.ts b/src/app/components/message/pose/poses.ts index 58ce59b8..01fb89e1 100644 --- a/src/app/components/message/pose/poses.ts +++ b/src/app/components/message/pose/poses.ts @@ -1,48 +1,77 @@ -// Pose labels travel in the event content under POSE_KEY. Art exists per Mind; -// every Mind has the shared set plus four signature poses of its own. +import catalogue from './catalogue.json'; + +// Pose labels travel in the event content under POSE_KEY. The catalogue +// (cast-art poses/catalogue.json, synced by `npm run sync:poses`) is the only +// list of Minds, labels and art paths the client knows. export const POSE_KEY = 'org.gamecult.pose'; - -export const SHARED_POSES = [ - 'neutral', - 'happy', - 'amused', - 'thinking', - 'concerned', - 'annoyed', - 'surprised', - 'determined', -] as const; - -export const MIND_POSES: Record = { - aqua: ['conduct', 'listen', 'patch', 'flourish'], - eureka: ['probe', 'inspect', 'report', 'hood'], - heimdall: ['badge', 'barrier', 'protect', 'revoke'], - kiko: ['tinker', 'confetti', 'sprint', 'debug'], - libby: ['stamp', 'research', 'shush', 'schema'], - nibu: ['console', 'glitch', 'lore', 'triumph'], - weksa: ['map', 'fieldwork', 'argue', 'interlingua'], -}; - export const NEUTRAL = 'neutral'; +type MindEntry = { + shared: Record; + signatures: Record; +}; +const minds = catalogue.minds as Record; + // A sender is a known Mind when its Matrix localpart or the first word of its // display name is the Mind's name, case-insensitively. export const mindOfSender = (localpart?: string, displayName?: string): string | undefined => { const candidates = [localpart, displayName?.split(/\s+/)[0]]; return candidates .map((c) => c?.toLowerCase()) - .find( - (c): c is string => c !== undefined && Object.prototype.hasOwnProperty.call(MIND_POSES, c) - ); + .find((c): c is string => c !== undefined && Object.prototype.hasOwnProperty.call(minds, c)); +}; + +const artPath = (mind: string, label: string): string | undefined => { + const entry = Object.prototype.hasOwnProperty.call(minds, mind) ? minds[mind] : undefined; + if (!entry) return undefined; + const has = (o: Record) => Object.prototype.hasOwnProperty.call(o, label); + if (has(entry.shared)) return entry.shared[label]; + return has(entry.signatures) ? entry.signatures[label] : undefined; }; // Unknown, missing or non-string labels, and another Mind's signature, fall back to neutral. export const resolvePose = (mind: string, label: unknown): string => { if (typeof label !== 'string') return NEUTRAL; const l = label.trim().toLowerCase(); - const known = (SHARED_POSES as readonly string[]).includes(l) || MIND_POSES[mind]?.includes(l); - return known ? l : NEUTRAL; + return artPath(mind, l) ? l : NEUTRAL; }; export const poseSrc = (mind: string, pose: string, base = '/'): string => - `${base}public/poses/${mind}/${pose}.webp`; + `${base}public/poses/${artPath(mind, pose) ?? artPath(mind, NEUTRAL)}`; + +export type PoseStatus = { mind: string; pose: string; since: number }; +export type PoseEvent = { + sender: string; + localpart?: string; + name?: string; + ts: number; + label: unknown; +}; + +// Latest pose per Mind in a room's events (oldest first), and when that pose +// began: the first of its unbroken run of messages carrying the same pose. +export const poseStatuses = (events: PoseEvent[]): PoseStatus[] => { + const bySender = new Map(); + events.forEach((e) => { + const mind = mindOfSender(e.localpart, e.name); + if (!mind) return; + const entry = bySender.get(e.sender) ?? { mind, run: [] }; + entry.run.push({ pose: resolvePose(mind, e.label), ts: e.ts }); + bySender.set(e.sender, entry); + }); + return [...bySender.values()].map(({ mind, run }) => { + const last = run[run.length - 1]; + let i = run.length - 1; + while (i > 0 && run[i - 1].pose === last.pose) i -= 1; + return { mind, pose: last.pose, since: run[i].ts }; + }); +}; + +const two = (n: number) => String(n).padStart(2, '0'); + +// Client-status text. Rendered locally only; it is never sent to a room. +export const poseStatusLine = ({ mind, pose, since }: PoseStatus): string => { + const d = new Date(since); + const name = mind.charAt(0).toUpperCase() + mind.slice(1); + return `Ratatoskr: ${name} is ${pose}, since ${two(d.getHours())}:${two(d.getMinutes())}`; +}; diff --git a/src/app/features/room/RoomView.tsx b/src/app/features/room/RoomView.tsx index a4f0d7af..c35c3c8c 100644 --- a/src/app/features/room/RoomView.tsx +++ b/src/app/features/room/RoomView.tsx @@ -11,6 +11,7 @@ import { useEditor } from '../../components/editor'; import { RoomInputPlaceholder } from './RoomInputPlaceholder'; import { RoomTimeline } from './RoomTimeline'; import { RoomViewTyping } from './RoomViewTyping'; +import { RoomViewPoseStatus } from './RoomViewPoseStatus'; import { RoomTombstone } from './RoomTombstone'; import { RoomInput } from './RoomInput'; import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollowing'; @@ -100,6 +101,7 @@ export function RoomView({ eventId }: { eventId?: string }) { roomInputRef={roomInputRef} editor={editor} /> + diff --git a/src/app/features/room/RoomViewPoseStatus.tsx b/src/app/features/room/RoomViewPoseStatus.tsx new file mode 100644 index 00000000..33d0d80f --- /dev/null +++ b/src/app/features/room/RoomViewPoseStatus.tsx @@ -0,0 +1,53 @@ +import React, { useEffect, useState } from 'react'; +import { Box, Text } from 'folds'; +import { Room, RoomEvent } from 'matrix-js-sdk'; +import { settingsAtom } from '../../state/settings'; +import { useSetting } from '../../state/hooks/settings'; +import { getMxIdLocalPart } from '../../utils/matrix'; +import { getMemberDisplayName } from '../../utils/room'; +import { POSE_KEY, poseStatusLine, poseStatuses } from '../../components/message/pose/poses'; + +// Client-status lines about each Mind's current pose. They are drawn from the +// timeline already in memory and are never sent as anyone's message. +export function RoomViewPoseStatus({ room }: { room: Room }) { + const [poseBubbles] = useSetting(settingsAtom, 'poseBubbles'); + const [, bump] = useState(0); + + useEffect(() => { + const onTimeline = () => bump((n) => n + 1); + room.on(RoomEvent.Timeline, onTimeline); + return () => { + room.removeListener(RoomEvent.Timeline, onTimeline); + }; + }, [room]); + + if (!poseBubbles) return null; + + const statuses = poseStatuses( + room + .getLiveTimeline() + .getEvents() + .filter((e) => e.getType() === 'm.room.message' && e.getSender()) + .map((e) => { + const sender = e.getSender() as string; + return { + sender, + localpart: getMxIdLocalPart(sender), + name: getMemberDisplayName(room, sender), + ts: e.getTs(), + label: e.getContent()[POSE_KEY], + }; + }) + ); + if (statuses.length === 0) return null; + + return ( + + {statuses.map((s) => ( + + {poseStatusLine(s)} + + ))} + + ); +} From 9e12f6cbe4364adc44ea95b12bf7352b304a47a6 Mon Sep 17 00:00:00 2001 From: Void Date: Wed, 4 Nov 2026 15:53:30 +0000 Subject: [PATCH 061/206] brand: Ratatoskr replaces Cinny in the user-facing surface Squirrel logo, favicons and app icons generated from shapes by scripts/gen-brand.mjs; titles, meta, manifest, device names, welcome, auth, splash and About are Ratatoskr. About, the auth footer and the welcome page credit Cinny (AGPL-3.0) with source links. LICENSE and notices untouched; storage keys and protocol names are unchanged. Co-Authored-By: Claude Sonnet 5.5 --- index.html | 20 +- public/favicon.ico | Bin 33310 -> 2338 bytes public/manifest.json | 6 +- public/res/android/android-chrome-144x144.png | Bin 4499 -> 3096 bytes public/res/android/android-chrome-192x192.png | Bin 6264 -> 4140 bytes public/res/android/android-chrome-256x256.png | Bin 8384 -> 5684 bytes public/res/android/android-chrome-36x36.png | Bin 1073 -> 817 bytes public/res/android/android-chrome-384x384.png | Bin 13069 -> 9055 bytes public/res/android/android-chrome-48x48.png | Bin 1487 -> 1039 bytes public/res/android/android-chrome-512x512.png | Bin 17829 -> 12620 bytes public/res/android/android-chrome-72x72.png | Bin 2216 -> 1528 bytes public/res/android/android-chrome-96x96.png | Bin 2991 -> 2022 bytes public/res/apple/apple-touch-icon-114x114.png | Bin 4902 -> 2340 bytes public/res/apple/apple-touch-icon-120x120.png | Bin 5100 -> 2472 bytes public/res/apple/apple-touch-icon-144x144.png | Bin 6075 -> 3096 bytes public/res/apple/apple-touch-icon-152x152.png | Bin 6665 -> 3212 bytes public/res/apple/apple-touch-icon-167x167.png | Bin 7325 -> 3555 bytes public/res/apple/apple-touch-icon-180x180.png | Bin 7889 -> 3884 bytes public/res/apple/apple-touch-icon-57x57.png | Bin 2347 -> 1188 bytes public/res/apple/apple-touch-icon-60x60.png | Bin 2482 -> 1261 bytes public/res/apple/apple-touch-icon-72x72.png | Bin 2951 -> 1528 bytes public/res/apple/apple-touch-icon-76x76.png | Bin 3124 -> 1606 bytes public/res/svg/cinny-highlight.svg | 13 -- public/res/svg/cinny-unread.svg | 13 -- public/res/svg/cinny.svg | 19 -- public/res/svg/ratatoskr-highlight.svg | 17 ++ public/res/svg/ratatoskr-unread.svg | 17 ++ public/res/svg/ratatoskr.svg | 15 ++ scripts/gen-brand.mjs | 202 ++++++++++++++++++ .../components/splash-screen/SplashScreen.tsx | 2 +- src/app/features/settings/about/About.tsx | 37 ++-- .../features/settings/devices/LocalBackup.tsx | 2 +- .../notifications/SystemNotification.tsx | 2 +- src/app/pages/auth/AuthFooter.tsx | 16 +- src/app/pages/auth/AuthLayout.tsx | 6 +- .../pages/auth/login/PasswordLoginForm.tsx | 6 +- src/app/pages/auth/login/TokenLogin.tsx | 2 +- .../auth/register/PasswordRegisterForm.tsx | 4 +- src/app/pages/client/ClientNonUIFeatures.tsx | 6 +- src/app/pages/client/WelcomePage.tsx | 26 +-- 40 files changed, 313 insertions(+), 118 deletions(-) delete mode 100644 public/res/svg/cinny-highlight.svg delete mode 100644 public/res/svg/cinny-unread.svg delete mode 100644 public/res/svg/cinny.svg create mode 100644 public/res/svg/ratatoskr-highlight.svg create mode 100644 public/res/svg/ratatoskr-unread.svg create mode 100644 public/res/svg/ratatoskr.svg create mode 100644 scripts/gen-brand.mjs diff --git a/index.html b/index.html index 2eefeee2..7242f309 100644 --- a/index.html +++ b/index.html @@ -4,21 +4,17 @@ - Cinny - - + Ratatoskr + + - + - - - + + - - + + diff --git a/public/favicon.ico b/public/favicon.ico index 916713065b069f0e59a63d520178ea8101818913..cfce994377be2daa71cd95204142fc269980eb34 100644 GIT binary patch literal 2338 zcmah}dpOg58~^^cHe-g$A|orI#H@&%+Q{UT<{YXmr*cXTAt{rPLJo;2X<0!|O`i&orNTLc7WT+Z z3-s+BS4XhE3qsQxtUN6sWSKTune+jqb5}4Mge1*m#CWj@L8y)h;M16Xu>P3a`lG_3 zRlZPXoz^9tQ9(0Hm=qQ+Zms#&W#HBln_;4gAK_ zEYibFkqtMe(R!m3x}>UmNw@6i!?a))pBGRXHw>tZILSJX0GQrro2+sbROy1fa7(KB zd_Qt&y+eMbp|R$s+Tl117?jj$Z0krlWqomDD1o*jyz75ODE*t?l&4)jqh_+b2i?2$ zOxtQEHnwhOZpCRwWk#nt3J%7NJ6)KD16`sw11pN1d4(DM!v7!xFB;?jB_k|4`a1xy zzCUEleChNVS~x}^Q1}R9)n4@ zL8P-m*}aZ_sDBOPq+{DOtnPsSo^j4D!Mh}{%TQexc62AZsWiLb*xwX*@If4&K7cwW z^>^M>7J_r6IKD^A!Zf+?DgFf_=X!1tM}FToO_q9)ViqPnxI2Jf2Ho^{r%K6++^iB~ z{0#ftqijJVaP!Do{QUT5s4yUe4=r>4bG`yP7P(l8SV|d^=5HB`y14dblpHiewW4G>eQZXu-qs0IS{!kf~Piq zKneq2P<6))_HN@ZU%NQMOBy}8xc8k($cAmOrHJCRl=iD3qmlBVU{`Y3g1*3vbBZR$ za>BV1)-+6A*5lcLnx+6F1Za z(jko)>ztzYDYj+Xj6ev8Sbc(#@X;MfC%Cl86O02d(!AmqOa?86>P)cEDB_}rt%js0 zH~f=U+Tr*o>YW{+wV7Pm?7&CC8N;bavTBRe1c;jH1tl(7>FB}DBzSsonnL}wqZFh0 z_VvU@sy9+9Igu)sPBE;tV(4$pQoBY zDJ`c+Xavuatx@EyIITqFraex0t!?xZY-w_TcW-+IHQV`GpXwmys@=90>iQrf{M+%D zQ!VZx6zIg*E9TM}(e*Z6xaC`^Ue!4JJS4hbhG`FA$@;Pji=VEf)MT zS+r}dVx9QS#IE&+A7fB)DEP%8vOK+N@3xkWHn+z;C7E%FTgmnM1>IVwGQIZTVB@*k zwpVbE!LQ2}9cpfiM!trh&ln#^GqhFG6u35T8eVJ<@OT)%YkgPnBN%8Zip8oVdunr1Jr!KpY_(hSvg&Rf=7$*#)w6GTW#2eCiN?Q&oGb;!-=^l~ zU*?@rJSjMP#!)emi!~{?R*#I4i2xRd0Q2f(2ls7s?5RZVjT?mWIJD}f*MQRt8)$?M zCCRZX^($HJJi+4O8jUQ=-fFX&$brcpz_PkyQOjoC=agm``AZcjbfB#^O7GNQXYVVMR$-kDD-5aKtDC(hH@K9o7xXby zVXi_cSfa3<)EnL%JI6HVO)vVql{=$#Lk?CYG06V$GLgHYAjg?0-*{bvw~;v<-oF#m z_~b>kMz#AMb(c~Cpw6zgHOPSN=?KQEpP9xZv_1W{iq3oXDa$d)J;LSy+CRke4b&75co|wF1dlmpv%$9Wv(%+P_F@GIM-L z47W?&){pLLeqUX6JF8KRGJ7}sX%@!Ad(<0>A;@co2){p&mBrhngRZq>y{0G$Nd@If zTI?Dt8l{YDfAbtVF3mh^@_TE>CZ4+_|4WEL`4Czu`*EN6a3lfvJO@PeG XiDcG~5jY>_@MTBI^kA6we|yS5V(}+4 literal 33310 zcmds=39uDKmWD41Zh#;vi?Te&jSgf)fe5#7iEs_K7-bc0ca0fVQQL(<(1~z#P}xOo zffki;3)exq8(Txi1sUl<9R+3_z%4`&ci_Dz@8N_@)vbGhJ`v|--mH`7 zoII6#o~&0Wv?{bIbnIAASXb!Xx=2=oYpw6#Dh^e;-mP z{JBS=aPrCiZ-rYY~UjHYwh?%PzZ^Hf`E0QrzRAupr%Lj~h3Ry!A~@O{Srt zf&Au^Pd+J%ug!1u*a{C{53=_kFkrwokvDGJwyg*%ZqXce*kLwbm`@{R!5*^J%sF%B zAg<`kEeiN;(B+IX&an9j;Xz1Q3^v_opMBQNm@xx#FB6#s{Py75Yp?b4OP1vm=)rrD z&FgpFbyqWV=+NSqUw-LjR2hur@xlu)*nH?~sB*r)F{)hUq5s3!3P&* zdoRCVuV#^7_VjFW=9y;}*RNk6$euQBnpwPfvAOQL>&&gU-da5R=%dlWqupbVJ-j-} zyHHvKc2HP6<&;y34?XmddFrXB%-wh2ZQ8bN>!tHdNZfmW3cdQQ=d-C3`n}cXVd)9I zI5PZqZtD+vTWCj}^z94pCSegfDcuW2h|UeA^;hjIXg{*cY-ETo5_yKMdfu%4gm@Gr zbAamAEZ&P`H-NsKBik?igu0r7x)ZPeQBWwT&q)bAML$Zimgt?Vkqx(i#Fh3(JsT

2R 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 j*?{u}e9Z0;a@O`|A?J>z-vJS{_8o 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 diff --git a/public/res/android/android-chrome-256x256.png b/public/res/android/android-chrome-256x256.png index 57378b413191cabc6c5f86714ffdff47a60ad16c..2f61f46b2d1009ab8831a57cfbf78c6b51fb6950 100644 GIT binary patch literal 5684 zcmdT|`6E<&!&tSSU#?xg=8H!=S;?_SrLmcFk?IZ09} zH7IJ;o5g`c_Ib}Z3AUa7I+8;^Y0b|f_a5A4`^n+aYVkiH28xuWOnYvZJ~Ewk{!tD& z+S3%g5(tQ{gK0LtUrGl(yKJY$wU;YoGL=1!L9vFiIA%FEc6m>l@8D|Hs@lpQ2aiPn zO(fq`5T9x;Lg1IY@|%Ad%t;7GPo z+n+8M3&irVU7uBNkd-ry|KCSBx@=R#mAOz$crG2>e73z(_Ja3V^8Bd%<+Z7^7Qn-Z zYCO8ooqOtFCu?QwTIFB}@J;F%a*LC+#B@CVei?WzYr5wjcEpYG;P7U@d;Mpa zY=k}E%x90y!_r?JvC{ZAtfc~e{=6c(#NZGhe7esx1itJy{STxBDt!4o9>17Ng!US| zantQprWKj*=!c<5f%W|X-KJ*Y>fr?ju<}1U=3@-39Ju$g;;0(BYx4>jr=ji5@_c3g z>>hnTMD5Gm(Qq2slfLAl$#b5vyzncq&D%!gcy<4W02;S;V(QRrOWeqJ|Bj5Bu=>G} zCBgX=ZZrWUaxuNnk9DoshHu~)X;(x}mw|0Ft)8}G8=>`=eI_3bw?&l?*hLQoDMveB zPrD!L{Uf8M=h{AC32W^HKQ|bZAHH0j+rU5{ezgp;$6f}7ox(r?+OZ8)d8lLi6cL7) zydgfzMcpu&*IAgA>MKj#_;gjU0>o;#bGqBGIu%Sax%oYV8)^PB@I(Si#qrPOgjok? z6L|!|+zsRU_w0!xf?t&-F63jeN}z>Tr&~6552#oy)Xdtk;5K7?#TnDB0M!t{4j%?( zFV6WeqnxX_44lpAATI1Iz&El#w;BSi=?8f))RGeE29YSFI}Oa>()TTK;< z(azX+Qi1huyjQ0??y&5y&l++f3&NI>`#FkkfO~POZ$h+7&nGuWRh82>#d>tT#KaE5TijY zaV7B}cb^)ZXmb6ie&y)n)6o(k8<+j5!r}br&&PuS-0lS6&UacA_aYuw?PXg`x*BbN z-Chen=fm{uFFVAj4D#=sUgq2)p6c9?5|NDfGYzJ8FOVCz2C6MRM9_;?+TV+5=Hwmg z%YEJ^LR;Q*KO1Y(9PiM7uDQne5=fYAr|Q{XUBpeXYlohu;;1qwwCVE$o7Nqh7*68x z#<1>pHa!6%DB<2xKo&4FsNDE5xt>e#+opaBH{J{fD{URE3cQgCo~WU@*w9FKb1N8E1QGVy-*fTc8l%LmTLL);SxpcY?7$OFlqz#ld$hyB za_1p#8ncJEq^SD3RS*$5odb1?z?GutWiHdAwR}aZ_-k`&y3{LV_cTd#t|`oVnC4u} zOQ0#p8ljAuR8B3yiA`gRGEB(tE$OEmvbu5nI0Q21;MHVMQcrf^YKrdOVJQNXDJ8O= zR+h7}^H=@{m2=YbLI|AAjRfa_%M@Hw8QG@y<>wGlT2%1_v{iA_$MPIMBYgicui3@D zpp_M4ix6r%^^bkjs3 z`%qSG+XOlF(Lk_s*uuw`CcMM>)j>ON4Y@3z$k$oQ9{r+{p9$pA>v(FTBDMJv)$9lQ+=^pn3C}Yv6rBzJ`9_R+VL=4 z#{_GzW9BITj5T+ZlW3(yNb@;BfLR^q^UV2vAE2I3|J{1;ep5j!;9u}eA-8HwndI&JTA2457#Y@!Gt1Ed!zrDDNKB6styZ9&5Kd}oTGQNB~HQU^4b-WvI zk|cay;rO%QtrEx6IqN-Y#j7Te4+5yyT^!nM!_B&Y5+u746IN**l6(#vHb}uwt~pbW zjQepoPw`rP^14PRDh3`WhTXXKvpoR315RG3pAJ;?tRc8K7Vl^*JjvYOJfOSj=HCHoz^P1KnJ^|7>!=A7ZdQJj-ZGT-2_00il zEA;RT!>LyzoF8~{?mJmQq`zjGL@WS3WE#-U=wWZIPvcn&5EUO%F+9p;3EL&xWlJ^q z283+Yjq+`M&^tOBjIM-o;?Sd$0`Sbr6S^aEkwMAgT{^_Pj+rWy5U9a}@*Z}wes_4e zgKiNpT(z9dndZlIDr>!1T?@zd2?@1B>CaZ(IjJJg#+?+@FoP(N6@`yo;A9Pn4rWW; zd&2y!V?3X=`Xw>t36YDsr<7l;NsE?UInjkNBgD^_)Z2@483Hq<8j#}4fb2duhE-oY zsw(%F&0s0;%6%9Qf~PqH7AecQoUaaQ=3NZp@q0+c7C0tdJV`1WFNsL@4EqMlqgc(R z3x+`Rx{Y?je85zVdl%*`x8Jh32x3wo<61X#llnekOZLr*drXb;wqzjd&#@YygASL4 z@m$?v3>7F~_kfGn+z#{NuhjA_G536&+Wm3jl96Xz{zE%3b>q8mxFFChxuRzbS6N_! z?bnad6F*If?%6=_DxrbC@1|?NAb+?JWu804Y+cJc$2W7t3t#`DbuvHLT=dKe1+VJ# zW^SW&tiAxqex;~Mc@XD(SEv7WmTq`ycohu+>=3xId(l)c2{c-xBx0-Nu1)A{x3XG) z{sBpZaCT|UTQJrUGRTB9-A%MvAvPN)XL^#@(lrZ847~GDVRvV}<0w`Ej2%SE_s~9e z+i(Bedt#vYi66-g#OwDDOTIh{G>#8+r zV?)L^<0?K27C*=sX_Jab7c2p?d|XLqfAe$_*98rG0$$#5{jT;dWjr^gHkRuGV2->P zZU^tIQI*B8sO+#Vb94jL;Yfj&)NV!E-TpAV-WW5c=DkB;>z95C8~CJeBQBHgDATwa!_UEKam}NO%^(OsNk>S$m%~`IfI|S zy$aa%DEZhNo&M~D2EmOBc$6}rG!8WZ0tz?iJBiT!_EF`);M~@;1+71(&3a`=HGi@7 zp|%|QVR^j=H^OHIu8+O~w+Oif8%H{&++{kfAy;!O;{qRj z6od4n_)$kF-;NV#yeYzn2}^5MHgB2WiU%FnrjXWMA*T11G`W+`a3fAJ$b+wOk%!bV zw|4?5?(PR?xT#qM=TxI|ZEhd@fO+{BTYr4TqvgnJ(N>>OAWLLPwtSt~VTJT9wJ%s$_C90qQG>(lH#>@#&T_3GpQ0r!teye&fH zLk?Ntuh2aE0F2s^xCdLH3bjo&5X>G|JM$D7cPTZJh`@@PEJDZ85bZ;R2r8u)~5 zFe9qaE_Tziepyje5N&-&o3%r;H!m(03Tf@e(a&jVQ1`YM12!|55upMMo!1#hpLab< zRXpjZC?Jfs{-6Zzij_3x1`|+weU!%V=aj@KF52BSP=wqny7||$Rn{1si354h$o0AU zx@qk|dKtqsP(8EN?`Q`qiv{S!$s&ixriMW0BG*%n`uM_)o*hCo>k=Y=cI~Y8)2LIl zdZ(J+jS(V&q$@9g2tMxeMLE@NGj-0CVR5lLg}4kBu`C}Ank2bA@qJjmDG-k&L5=Ku zaii6iU$>N&w2gopWUyQYEpYI6@q3c@SC;(waH?1X+K51Ury}t6pHldjbkabMj#L?* zEyAZmU^8K;#U|V?aC6pm41T)FKw)`^C^-AxX{NzN)Gt&-wQ@dg+=-fN3WQ68-la+2 zdsT)x)D6!prHKuF82ms5`-&Ws?oPlzenl(ct5%vr&q93_e@QG31}3J1tLW+DYk588 zo}}oM+&CA=JAvF^8xHsQQ8gopaDKP3oM z93APbb34X*&SK~fN|z58xaf$#J?XC^iCy}0bmk|T&CPIiFv1?zQE}Ad}O5vMOpFaIKN5kyd&fzspk|xb!NWZ)oKmw@VALK>wp*49&=-Px$D^klS%C6dqM=`ljt&0*-{woZ^RJ z39|;c3-?OE${`XN4^^p<2`g%}rMpmivCblUzDY@$9h9U=R*(OvQnW8GlYVI1Ag*g6 zk3OQ1Xf}l{G?O`Y3m~GCl*)@>HII=hBltZxTs|tgEn0^zq~FLBG>?Qw#Rdm8l||N1 zJrOWi;$dP36QzGBFKv$nF7R+V(85|a9||X@a#4$7EtI?}InI90Tb}vMEHVWRVp!@>Vr$d>; z-e2U+a zlr-FTzI*@pt-BViGs8LWIeYJS?`J>zIYek{Dv=U15?{M^jZ{ThLHF7#)do&n2aPG zY_*WOrL=gL>5ZX%Oc8SQ<}!?c)%Fh5>dqHpT~md4WX8OdXX}C#F~J?Krey1Te{Aud zEeSYM6-0;U7KtkvWxgs~4M+DLQa-b~|ICV#3dKlCsb70jisAVO0#o$Ns5K*(&2%-5`JT;u5?z2EtKiT3dg;B06(^RL@<`AxZO?sr+1?wZGYQX)i{L)Cr} zgb^k>5M_5H9T&rR*-$;0s4C%W#cXV7q{rP`T5Oiu)|80HUf;9^ST!Kx)+8N^Zt8TK zmEl%e3Io)VA4*{RE4M+D$32+n|1hO`B|`(z-Xmi9tk3EUbgX+3s)IIC0oS( ziEt%1xls~tINNyovpUAH4+*d=S}Xj^H(n@a1$@gf3Zq3Id;Ttt6JPoxtNi(v$M{#e zbS)hk<4@wdC2U}qC?{ln_`IX^s}TH}Zq@0?&S4wM8Y87!-&IhPo1|u8N5hXOYqY4R zqby-?LY_9)>1YjPLiprw$6MGVNUP*0tjlhiDO;Ym)U^<|*RW$}YBx^99#_hA0TiFB zBqI@OrWPW(pu=H@4)3jDRm>}fq3euxML)iK%_cI$a2$M`4MR5`uEx6l#b}6VGAAyX_hjOZln7I+A(i~h_{IC81Q9zs;jTQ-qRs#5EKl4*zs&|Y z`_SwTAu=^zo1nZDAuPTj=Nbn?XKA+I?=_USL2CL)N1$Qorl4pO38d;)%_$lY0i6+tol zhHD&EtFNt(74L8XPe^iZorUw1EtV-!BWmo2(FxvLlMZvuP4eV2`VHP&Vh)4teaY|M z_5ApuXK46r=BwA}rzg)3*GCLL8e8u!VhzhJ1iI+`mrSQWTwjD2F_h8jr|$&r_-o!@ zLBw(5GuD5n>G_}D^jW_RfuUlpTI;r(h{>;H&4=leI48S~;?(2Wv}z+sKW~dwO~rFaAwg1f4wXmpRJBM$wTB_T{*i%FtA<{A*J} z3!$q6|4noX1?`dqp8fMW7*JA!R6XyP@H?=Nle?@Vy)8jF_=tt<`^vwE?b!_kEwuWo zXaBY?PRCljC!VHGyeMxf$ou~Nk(TuC8v@pVU*H#}{L*LP2zR+dxK>4N?xR@gJ!PNS zm&MsK{*j9Lg9x^Szvuh?4Ztb^L5b3J=OGm$%p{P3Z%Wtu&^;-SaswUJ_;4;_z`SWMH z$d4cSi?jVO`~7lrQ!Xt|KQqKp$pbVyOF7>1z1z2AZ$RdFp;7f~Mem~-L=~f;0(KZ+ zeSd%Or3WV`CvGa4?6d~I6IwNmvnEy}pkmgW{9(Q@; zUSwV#8zSb#>)qpCpYREpD}pRYgXijpFu#k#F|X}u6Q`l(Kz}+5-w(WcMO{CC(hk>& zG;Tg<52!t*f9u#)1BI?8M6?qU#9Z-(_0yE;*N+Zi6R%C69hNDV}ED71b6LtrR_+G zo>GN*)_t#o?U||RdJh5fulm}y88*r0;S)7&uC-Rhf_3xPZ98KPiZz+ji_mCg9i0Ra zK3dASPLS6>K!83PS3c5aH?_Xy9vBsFen#y~86ZDaX5RE~^2O(>s;7}Dd`74HIaixl zI?ID@?wsS)B8~U!vrP?i=YJ&c`)oUEIZb?i3>F0bZ6!c4x)C@Z3D|gVQHoJ-<^q3< zG_x=|`HCXgr8v1V|D)|^y=m4U7MGo%vT!Rx$yv)PDt?s|XTzx?#?2=tCilhpJpuNwm!(Ax%x9%_apqRxR$A6WUH)FtEMX;j%(o5WOClQH>R1G4w9k)(SPQLo= zIBS6tR!9{zk7kiEWlNyYFAdz~ADxIubMX<^q}jCE9IwRHYP!wVWR0fGp*|Li_3rFV z7xmg0)z}#Qv=srY;u8{*bea@H)cnBCZ7B@bZU~GcT7oVP)<@L17^HnG&d&q<7t0#9 zbt!9Syx&u6N+Gnj{LY|m55|ZHNG^>^4L_a#779*q;L^;r*J|4BxV^W(FK++qTXs0s zMlqUx0i7SKtFOPjVN;oQgIro0q~~zCrDeWi4De4|n&rp|T%VGhJYMaP^!c+$eSQ6C znfVI#`oG{cQoIlfbTooUW@>uL3FJk0~fYcz705OTT3JVKy(Du70Wj6RK5NwF3%dGVNN`I}_hGEUH zN!rd_`_?oA>T0Cc#j-flJrZ`$x%uRoiWvZyO>P;3ovM4M)3F{hd9&-*eZ zrPt4XW-tINzj|%-=riB<{M*GI$HU88*}xKbpyM?4B}FAe(q+3-?y?dT;OK`i7PzG3 z7zn3bnn2q83mb2mRthniy6vwUnJ@pOi9k}~_wzqasQfHW*&O3U02M~r^x@A^(t zDlskK&@f7<+1SwV9jDs6@vmN`w<=g~o@Ask*R%;-H!(5E_FW_bb|$aI-e2b-VDZfb z=)uFsx9?O||A#T;CMi{n;>gXj&8oQVnT7^XiOlX@cistE0;jzOjcui4W14BI@;zM3 zzHz3SEq?wXsQ1KkeOP6#Igl0#Jq7zs0}iR6gw^`9Wk^|Nr(faSP{`M*(U4~G0X?H( zF_JISd(vUF+|s2xk#lgEsrRTboS9TU_%%R&B~U-ZhPNdgwav^NuP%=0A{6i+_$|dq zPWx?q0`+_6r!+ADu4;9MxdGuKbbyV>WJCf+oPeS?FMaBTRRtk zj+aKTTWoe`%Ddj=e*`Va z21)P|K(j`MSohr3r4J|vBU4ih+;O^Ypd;pX)79nK<;>;T&Q6+b>_EN8bKK3r;o|hr zqZL9&FXfthw%wow@Rf!Z>z1}m;%2$V?+QA_{l##0{E`oKb>qprdH`@cj(3-SudI|h zycjDx-1{NqfATx^B94stzSMrNXckDW(|=nVAfrS83h`n@xlSHU8^zK{T^tNS#9cte zhLSNOkN+-!%@Z0CFsu9Z{VmDGNq_KYottfOmUl*(c|*oMkD|RjciumQmXT*Yl-JE0 zylIDVWTIie?}5zWu%4=#T4Dar_mch~e1#S4U^5$w(K4<%ltlnZj%=roEL2&;N$W(dg$) zR6XAqE6;fx1tr4FP^Qt<&RAauY@;G~9WaFd7(?HK9eq<%IGQpHIOt74RP8ins9p2L zaRQgm`-@RR#7OHGlE?i_;V_rekxW z3KutAM3jj>1>T8Ow1-#p%bpP?x=dC(^xfa!2kilsTq(q;Frb*LnROpDHc0p3zbTgo zpvJ&nkmzcb@|Y6gfH>Z4r|BqLv`_d;0V7ByD(`hdNDJd=^d%ffn(6=GeBDxM4?6bFMi zGP#q*Up*e0@pwR=f}?d28+Ea6)IwDd^GrvKI$SXy6$$)&uO?WpfIz&4If+H(N0^p@ z98rvA;zieij64FV&#la-v4}2xhcPP8#^Jkj-NMaEV|8ue^5#f1FyR<-Y?S_ajNzZfMP z)HP(9Kt=%60rj5-x&<-ZWpBAVJNWV?VzMWeT`hD9klGoKewovw9U-a+LbwYs8V6bk z(;t!SYBx6~s*06ZUbep`VBt~Detm<$^s`+A?9jnZ% zJZQ;;MRSLkc@8|jNzURp7kEU=F$btmX+?#0{J$?)ooGLiZ@8jc2xIjIWk!hU05qrR zcrgkUt<9uVdFxk$kBfX5*~mu|QN&0+;CY->Lrvh6SVqG54P*%0b{U>c5FavrWtOeWLG2<84Z<^O}Y-q^0fDqlk?X#F0b`r%Hc);3PO#4Q83#;XeY7w=KriK zh&8EtX42sO31<7deYcWPx7OLbb}p#lwsReVA4*;Dj)282D`SAN&?P<8ct41vIU5ZicVP~ahv*`n*_(v z5qjBxItiyqLft7q?K!&a?}3V*>BMLQCNvFl?B*?c7|1VO9K#prvS&R5AVFe~@IRS3 z*!j_<3ZG%?1^@}?*Bt*}Vh(nOlHTTj@LoDSlvfw-8<4t58qu z3+MtQeF4z;)YMe&F5};6ztwasC*A~OUKv>i8pJcWySw8vIN?d0=?=#}L%$-IJJ;6K zjD=nNUS0e}lCLw_^Y)~&BXP}um@0vgYE>Bj#jLNR;0)aqK(c;}L5vN7n@YS{A z9B4r;3UwXPk+D3d5I9uIV}Pe6bC?Wr2yqJM=clnN&~4ntol$JeziwD&=Fkz%fI2YH z)`oQ{i&jjuL`>Fq!|g#A0e=K!zDqd?(CI!6Z3Kvi9zcF&t;dfaqoTzIG!?d{rTGo< zK*Knid{Is`I0U#UgyFd`bUXs>a`cgyQS@0fOvY*Et3kY5_MSjq^NbYj!L&#J#OJ3` zunWL6h(!9N=GfM!n&Pl!bR34=HaK# zZ3e#zRFVRY7s5~n6@G_Egd)Sj1BJla;tCZA2NjWg=q<{EFfF+Y{di%CrF(OC)*GC# z#kx2UZh!x>k&ecFmcgsX5E;RDuJ#!SdOA z%M`JGdN8B~ad2Vin9|T`D8Ch~g8=)xUBAMDK*EKDYVEqI5RJ`0@*BgH{pGc-$Q8Eg&*FHs^c^$S*bVI>6d+1wKw$ywg$-?y#H|G_$PzhM zq*CDQJfkn8lD#~(9W(QY$@K&1q7v`T(s~_8S<;fWF{;N?bC=Ji>)cT53Gd&(__sNM zs@~%b5>m-ad`?}aqtXIq$^WE+LoiOFKYmn?Hw=3FGm!&cpWR9V2dRf$F{fchD%!gHC>JJ9h1aJ^5U20S=;kB-hFg1?LVgEp^V36fSpPPs)9HVjzFb&{$@3vEGfeDAY zpb& zK}Gt55rp5gS~-3Xx>$=bRv+PO)i(=uOsVkQwP6iDuLRsyiv!5<^|xIb2l5_A;qwZx z&QvPWMwPJC9~08Cbul4o zSCS7IgI>QuQt37I2ouqLs8r1mc^dhD?={8}bZjlue4Gb+`stz1a;c*5NclCrU2411 z0*9fZi3;lw9P0!g1n7Zz3Gl#nz`yW$9Yi%B$0DJ6qNCQ(Q?{bS*_SKKfCGB~>K+BU zD>E1)d((t%@p((|R~DSaVMTwIt;G9M@JXQjcfn8(nz1g>epCH3**y<^`eV^L7X_dfm#v6bWay(FibC( z6GRL^+yTSyJU!fi@Y*u^u~|k&sAzEdGKMV?B-#O@o9(}S4_xd%=}=q(2)McjV1_s` z6ZW+6jfV~lf-NW$k&cshOXD!Dwod>WWsh(tQ$D~|5$xXYABS60Mk*>1Fiz>WQ571I zkt$A4eaSw#C3AowB?&hhfcC?Bv8n`=Ay3e$woxR46^;`k2vz!soP2%aD3w5>#&G^j zo(QUlHvo#>OS$ENAfH?wwFsG$GKjSQ`XCSCUC=ZObLSQ03TzBDSbd$wak^R6A5Aa( z9pp4q@cAzAm^&)~XaItZe;{|oJVY_5{5%z;Bf%d1l(Ad8I@ zs?mXY2E)h8>(+N~2lXI#xO};w$DM5T$UdmJ^gdKB?90jAaH2+(Qjs0Rb;Cs+|C-9U{&~p1K&6tY#~m zz98Qio>W;5*VWav8U1Ji>bfX5_aRsW8nu>J!|!)ba5}O!J9N?WT>fTwH&c!+2jfCm zIGPOva5i9H19bd^q@<} zp&70ZbQK_E;O2P1yfVZb7Ux6AfsUpB7N}baaV#=4p#Neqb_T8iRYGoJWTd;m%^_E4@l}m zV6dXI45$Q?q>dP44L6QE-~_yQZ+Vyudj8Yi3P_m)WOkkU=d9ecMj1M3?Kl=K-0b`v4KIf`xV7-nX3eZqy z#vttZ&>HU+pwW6!;00=8%nMi13}O)z!R-z!n$q8FsPY%DN?k?&LG2QyZ?!&jq&lw+ zw|dUFsCrHo16ku|C0}B3q}hal*S3MK%s~-FD9^;G55CvxQCX3M-b&^Ntvi2jAmC?& z zA$U@>ln8_OA=bXvAt>S~!o(70L4*qvmvYftUq;&s{dgL1dhONhYKT09c2}5dUbvT% z!yLTPj%_ztja{Oo+f*l-{cfk;ejSk#Nr-t8$P`xPCTLnRBimM*_R6SIMcauDl`+a> z?sola9($h1m{Qv7wY1ldl?jmw;1xULl9Xphf)rC2Ot2~0h8Jq}-z#=X@QNMAY?pt7 zOo^Zx@uIq;83_is|K85aeq(c_E~pNN&5S6$sBXQq4-Fi+_460a&v>VXCQ1aeScSQ) zYWz2@DJOYB_k<|3fY|;@GFEm12&($<3QzgvtlwMt**fr_3D;B}YbumIvO@hIlr1g9 diff --git a/public/res/android/android-chrome-36x36.png b/public/res/android/android-chrome-36x36.png index 507c443941225e2c73003d85eeac2fba6cc78285..91b26af2a772be5645e5ebe4a11c812937766f46 100644 GIT binary patch delta 795 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 KSv3!_dhkEtONq1q diff --git a/public/res/android/android-chrome-48x48.png b/public/res/android/android-chrome-48x48.png index 489cfd32175ddeaba504eee8aba83de8bb97811a..f8fafcd9fc6a11d325808844c6bc83ea2c3503ed 100644 GIT binary patch delta 1018 zcmVB!I{g zz@Uu?0t%u9MG~+Hl875btqVK|FOs;#gv2Fri5lWAfnY!paRKWReLdz(n5NU-JMEO2 zjwF5f(lU2Ce80Kpo^$UBPz8m+a#n!S2K)d@8}I`tZNLwpw10tR1E5(Ey1W5e)@mR{ z8zwluPEQ|K9Kf2#!0ieh5h6EM4Uc!|pd?W(F8~u93bh&Ga4=KDsvyDhe~18R@>;2u z7fvziP!bkC&o2xM@pmNkjO?;M2Vb%c}!01Y}DcUDJ^ zM(`P&$tR`=cz?bp3I;2p;e2rvj8;e5$8dHe2f(l)y?K&)w$R@o8ho=a2BzAQV7h%3 zPZQ>NJBZ`}81p2vlLrhibvTjzbi9tC=KKr{m$7D}i*aX-ROv-$BDVlHE441>r`wa^ z$3Tf)GnRDw7|xb4oS*YsH|pJ*N4;)I0wzR405n2+`+v4eMLYm!2x}ofl+YOUt=Agi zQDeOQZ-kW$Alne;VjjcB**qs4s&S zu!uK#Y#vpW5#hCYj{r!uKxiFcMW&hkHr&j{J!tZL6db2s1{FSq*++77I*}Jp+f>dVJKm> zmVXt1HVf}sjJ!~y0nmyT3gLVLKq_k7*?@ysu;zOOAodL46#xp)y|mLAt_c*WfC&M- z>Ti^P5xWZ|=dki5193XhROVX%+#(A>Of z$^4Q4REtFpL2yP;coyc4l6R^NZ2#w3jdPyWm^m42_j_66v%~E$)|JmTvW{kZ*AtZl zfF|s5a?L77y{wOh@zxaL7`7Py8vrzeT{cI9*#F$MnjwyP(^8ZbfD?A2F+4~i!d(|? oxhoFf3;6+*mLEWA`2m#HKf>U9L-MBTX#fBK07*qoM6N<$g358uz5oCK delta 1470 zcmV;v1ws0c2+s?UBYy>!Nklun^=@ znsT7@BBf`Cv<9e@_OyQ)-xr9+FYzi~#4~sT*?2^odeq_+zJI|1?8OgyDbRDdn1dob zuV=o-->?}M^*|tfPhdF~V1Q^e;S;RI_tFsPzJXYd<;c)CZs1S&1DbS0pnLN1cl^qO zaRM{(A6?|1kJ{XBU6FarVh=|o)TR^USf{(k~Y#&!J5 zJ;dGH0{j_)hkwIsIEgA9#Qa_~UD+mJj zQ(awc&YU^E`synYXu{*T6xU`k-js&y>}-4X>`_=)CF@HjKcDDNZdN*#|kcJ%mCB#TPFAcM2&sJDis5=sggj*7cgj>Qew15A8@pxQE zRaI5W%gdFQmpgm*tS*eh@Hl>h-%7*g&70-qf3L>b?n$N=gyszmzQVZ!iDnl^Bp~U)Rik&bblhip%5=h!3!*LC8h5Kl|P`0?ZQR!K>TRjXEMXlT%x zmX;PrjvUd2r$am`4LLbEdSm6vm5PgtMdZmC21~50piFI&2FsfY~%E`&mQ>#|3a`ECt5le=s$58h$Y}hdC z)~)l_TW{%&nwlD)efF6?a4p0Y4AntVQIV385<`a$)mxu_`l+U-CVk*ah?5wt-SFYV ztzW<1^y$+@W%usg`pADnoWN^Rii(PqmVcJY%E}Uz*49@0_wUz7zKP+Wb_E3mva+&7 zskXLOLqmf;QW?Wu?Y3>(CTcY`HTp;r`(ikUuchqUw@)IG5T)AMT7BdYF2uCiB&E5z zSy@?`C|$mMSs&RXC8o_LG)XBbDbdu_Br0`vb^1Ufwn&L-a}l3NIe-4Vva&K!Ie&iq zxIXY#T#^!V+gdb9dGEdVR99DvLS<#8h&AE^?P6~G9&4psyLQdu#fv4ANxjkB-0aYy zLn8J*&S)2Nw_==-vUBH7t5>hq8x<858XFr$=omh5hq$}7;0r83zc0W1Qf_Xp7hZTl zPrUQaJ5HTCB_d6D1Lxc!?&mJy41Z=I*tKhykt0VMH*TDCoH%j9yYId$nM{g667#V~ z2XXgSg<8DX+S;n3qC#C=ox;LGK@jMUdGqG^?z`_q3vt4<77(wr}5V&6+hLki-gns1tGbcL1mG2Kt>mdD4ar8)Rl?%FoXihJT@s>gwuD zn>I}%kgN zsF#l6b^KEo0^OH^o;Ue7Hem}c>488ra`7r&!ZUaRzd)uo^{ByVe1ikniwk<` Yf1@~ diff --git a/public/res/android/android-chrome-512x512.png b/public/res/android/android-chrome-512x512.png index d312b532c57b74fa511e6045500d9a83bb5deec4..2c584f3d3ed920f9ade75b67aa2d03fcf484e871 100644 GIT binary patch literal 12620 zcmeHt`8(9#`~P{)48}fX8I@(Usbpy*>kQKJDocrEr$X5(*%I?qNl{TLm24@yvhU2a z$QlaSjYMP{5o4Q~@6qf1d0*G}PxxHd=a=Dno^#*lKKHrT$2oUQjSmS6tQP6o z-~<2|_%8=HY2K<}jgi^;ype4BQkk>4vaiFHyfAKJ0{#3P5LB%;N& z(3{672Qa)p(A`D*j42dC^$qduoAV4+#doVYh=m9rS+~{P^arN=8Z#Xa%FSHjPOdaOp0$#vu&ttWF#sKnXTPtI zw7?8r9eP-r%~~vboIm`Q=NB}Z+izq59{KIg{-9CQ|Kn{OS)QtMSPlV{lSMoG)&ciB z&vnLf-0NtwIz^AcoSXgy2Nr6i?imr~kgfT=q*A10M(g9N4!L z5HR)Q#Z1N>V<2Pz0O7F&%Mn56>6e!#ms|PK-Kzq0dw=A~2mRsrJu#?&*2i5e z<^cu3yDC}AvMieCD!P-#WXQa1dn@+~dd%$>Wr3_be)yJVOGR1uN z^O=l$*vF$`@F1S+vET-HH~2AV8{)vfpN@Y5k@aSQ4v&Mlo={l1zofiCMV|DrimITA zw1`3-?M?2-njV~*43VE_morr6ns4rfj(fn&Sx8l$)LTVWpKA{N`$bFY`(KU?hs<&a<_?@S}FC zicGsIN?B**A$qs;uficEy>_ed{&dSd8u`ONy?+W7UaHReMh^UT{b$gw`$Z=E@8e@U)H!Ju^#RStD zxNoSRdtkt(d^F7ez3BJ4d)^V5r#?ITkg!QLAm+j4KnL4pPTwewqK-ig=3jXj7Bwlb zFMR-t*w3$pK>97TjP-ZBo;YwQE2^6lxWi;QIj_SlQ(Sth`x7Y^h<}S} zjEqc+uvIq?OuYQB!5jd!w+ovB{Kw?ZHdl)k5Vq>uPMp9a+b5R&UWx%o|19U+v@~m} z#*uj=`+6ExA-+98lycgqkS`?Npw?D1DZ*G*tAp_+Q_g>YKfRiWuLeMndGU%Fd^_Hl(md0Pml%rKN%Le$K^27VB@ z62htbzip=@@BB}c@Fm_QOonWShQQ4lYv%h(0j~IPslPXS{3uUz1UV zI?%nf(pJ;N)3QVARvF!V--SR2v;R^SbomsIy&Sz43f`=HV{AJxhDVHg{zd?20{D8) z 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 B{%rsN literal 17829 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 N002ovPDHLkV1hlW;${E< delta 2205 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 delta 2986 zcmV;b3sv;y53d)HBYz8aNkl4IaI{(5Kw_2 z1vD=b2xJcjJK`E3B)iGK%J&7rs}uU-yXcF)=!y1dk28_wsDCPahH6yfV{F6*Y`{i* zDue>z(G~xQf5I?ah(Pje!6Gce66_HIf!5Uxc^Hqu2(*lPl;Dpj#BQx5(DE`c1~*_h zVp`WB{14`00qV4%K+8D;KgNCNBb+|O6PSYmpqI$&WeVD8Kup8_9p`Fg9XfO{aNt0F`t)(e8E5!hWo4!E@^ag^ zZ}w*g|5RFD1&w&F6tXZ?h%P+rd z{`~nq_}~K}HVM;lz%c?}cN?Z@VHaL_p$8v)(0|C0BLzX=RMpqlD=sef$Rm$<^UXJf z)UBB17=f?Kz((X~A?@3@ci(;Y`Nc1OAsUTpu6R6d-n@D4yz@?b_wE%kThSMXd?D(q z#^PVKkV`JP#Ij|}j2t;qBodJfK@d3q{PSIR-F22OU24~^T|%T2)?vLbM19q-ah?`1 zW`E2W#l^)sb?PJ)I(F=6{P^+Ku3c->rcFYmEuQm*s4sQHbVQ_BZf>p@UU)%fW~O9V zwQ7~QbLZNzV~76z`-?;(K9`x9>Dp_rwQSikAAR(ZkT@5!u-_3;Uz&g`rP!cBgDhOQ zP}{a`B}0CGzR{ycTfBI&7hinQ+O=!t<$vV~g23l8GBR9q%{2-O3ze6b3xOy;#%f2z zeCc{A)}~Dx1qB7Nva%%0{rBH59*_IG;^Ja!)~qpj@L+#;=9y>8&(Ak}_;CIE_jm5O z=W5%wtqvVJ_*`vmt(uw|wY9a%%F2|Mma3|%l0w&Gsv}~K>WaZq?5?}+GGM>}$$wN; zRV7hnWu-(zh79r9XP;@;uAP&qudnysd+&Mq<(Dm3u)vx%Yb4qC(HZ-qj=B=#q|jMs zo#llWUXYoYDVfU3%B)2YSk*6 zHf^$b^Jdl6)ym7ueXe82jxsYd_2|*VIp>_CZ{NQ9_3NjgpuoNN-fPdEJ%5^L9R|kG z3H_ze;K76CyisffTyzvde_Uo;`a!^2j4*%$VW8fdiVNuC7i=Nr{q@63q~xUkv@E*hLpzBn%cT zSm5THZ}#b@p9+b7G4z#UeSiA&(VF7%xV!GU%jC(Eg-qWV`bn{~&pumgsjsg$apFX? zXU`Tw{UYcg#oD!Nr?ourzyoH_o-L$$M35!LqS2_<@X9N%c<{jog;;h3*;44LtFF?% zeS0l!`}XZ_y6GnM_4Pt5JBBPN(4|WkQ>IKYe*AbXEgp}%@x~kN+kdxD$YsZnEy+fW z8fDh3S$g#7p*7^^=UcL5i8PQMlSF5qeYR=SrWreStkzOdQQ_{p@0KoNs6uDWG#q5f?d0IdFP#Xq=U-JN@-|`BO;Dkih2o(ii)Iz>gsA~rXH_5BI2mM zD3PGBuuwc67k_p&H8s-AYJBR5h%fz7f)76Uz={(&Xm1^92r5piO7V7~-QmMn44J@;sBMMXv8 z@wgDH#?PEU#EEUg&o$t&#~$AytKcMukR*YP?-ojXXphWB>mB3JMDR;0Hg@t5+|n_RvEQ zdF{2=gisx>#d3`lb!xWYLtKr(F%BL)X#V{9diCmM;J|@WrL3&X_19mouC7kV#PKg! z;C~cEow|2Wjo}g;I&{cOFTG^{{{4my9V!xuNVZ@8@|RYuSRsTaVUAN2)iiIQ3O__3 z!SdzHEnBwC(4j+h?ATGVtX#R$ZMWSf9*+x=I40o(bHGj`LD8bdpl%Te@)=MwFWcl*tF249;UAuPG zRQKP1zoMcdAyS2_@h8m?)qI<=5JS;L1Ga40V$Pg7_U+r}d*Az>tgI}jV)5d|Zn@gwt&U%uSbsZ*_AzutGg^Bp~V_7ntxL0t*ZV-{+qP@v^x zA`kcCJmItrQ!pE~T0o%X#c&O7#0X?)U3K^~=3xO2X+eS3br$k)J-&~KmQjyYC_o|h zXeEL0$i~GOhGDo6f#lhOMOcK_uvZ8K!s|@*MPGauebED1$U-NycT^QVLn9TcupJw* g0UNLpHKgtA8Ng(ZX{ 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℞ZfYDRMD;XuxOAmQZJsVwXID#UB9|}Babu=M>*h@% zQRD0xK-nKy6+kLD-EIdBfDq28@*kt$El32WrYY4>ik6O! zCrw4<@zDq`tGPF|9YU=8L#36!PI)tTBpXGP# zg=F@rtIHWir;~bl@Ey2!4GmR6o6bPCcLA$BDMFi4APVo_t6e$THX~!Dtxc(MFy80w z>zh{v2?BvYkVl&DXf|Do*H$e6a|_%i$cgA`sm2pwV~|Lh!Py~86;xKrKk^FNn4usS zI6vtMLRFQw)FLiFA1U*GExf1 z;XzhF!`$55(aX-xZg1%Mv5uWxc8+PW>_y?D55vQ0K>hN7Rhz+~pKCEbjS(UuPgE6E*+&=L9^05UxW`SV#Y2 zxuI2D7P``2+tsnu*nECONj+BOb~bQ*`eQad?;!rg~XgT3A||P+zY?fB+8;HbWvkE-8^DBSjz( vU__dqpN~pPN>X(HA_fLeb(|Dobf^()Arj5Q&s_ocUndN7jdk8=IY#~;A=-bXq8)`WNB9*fw$$R>4c!AM4r=P^_ar)Db0Uh*i;Jh4>^E74GYzA9gROf4 z=CI(Cg@x4(*|^r#P5vI6m&>C)S(aUye0Hg<;&h!AC0mqU3|t|B2eL%z&BlbDVnFw} z6fTkk-orw@{gE|uBNVxu|5chQe8B{|`j{Uz6P%=S&)Em_#6@n^PLwl>fBp+ePV0D< zgFRhwKun2j`0~>$EL2c?5~MUbe-4kv>dv>%dvS@Ez&exi!-Wdse|u(Wgwp$aXvmsS z--0t6x^v8VQ1*ri?_DHe2w6ky9H3JQd>2B;?B}a|)%+6%gjAnCJ1c+xZl+fuRW z2B}Sz%QNV6s+j6^Yc))7MoW@ob|Fx%X><#>t0KO!>y7DyK{buYpS*`Iln+svXI;2+ zL-*hW9FKM_q=s4_?bulTT>05hlrY!W-USgG-+smsDyr^XY%{;!2d=-Y!yGii|Bzkfx-t zNBO$5BpahU-CNK)mE2L-dVd5K*5C6!sOU;}W?I=*j$72lpt4A*s_3ZI0U$|v0aHpm z(54}js#!Gz{utHXIKZ>|1bP)f&j;-kZT-7guDEg-+4V{Sw^eZr^E-Y_AH2S zcNa_g+s7`i_x#C#UyjA~yO)B+Y%)&JZmDmU^#&{7ZUA zWUTTpQk>N(y;A9+sIj;krNh^m&M%$MiD460U&Imp)I2*$dC)?O+>NC9K2C@9dU?y zxH6|a%I$L`PK&S^;|t78ok~(Na0c^x(WT^F`XQEl3g(4;R{C;J&M&@7iMt5o^e=(;f=W4a9mz>|F#QdpRRh(B#Y*D$j8D zTlxD1WYi2^>QEOm>~uPOKxmPsMiqj0awfm4da`Vx9V3>vrY0`ft3Hj(`SyLVggdhx znDoQ^kMTJEaH2-9$>y$2G`e>vy^c~L$p*UDZGm1~%&R^;XZgO zWExz5iM7(j7#%Hy;t>9(wd47r7yG63{c&MNHNpHFSIRUbRw{svfwHfOYN

    yNan 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

    @dAFv$W*lSH4*UY=@f4kiK%3hgn5lQ&UsC z!lEy_zM-KyWYgXGLslg5FQre`H(=oW?&-8QZ`AYh@+go~v{q*!cfs-3*4A26AD<8B z!!9l?NO++6w2OK_J<*{pw`>Yg6cQ3r$%dX3xT>XfM~fp>vaPMn(tmA6TL4;tH~F-< zcnnxe%-y^8{U9433>O!dO3i}@Y-FTc;jlXikoL^r@+L&>jO)_Ul8-sSHsub@k$S_T zcvJ#{mUb|0X~`Y1$rt_l=2W{WEj_(hf@n~Dm@JU9M<97JUaZ>kG?vn?{vfPGhN6J9cK)oI7(&loed)?>5C}*n5Z}_GS^1X2bcWq65|wb;vg(ir(BAm? zI4X6}wWz+H2V3LVi^P}~Nr+k)WutLh>+5aV%F|&SP_Q9RPR>tbWA}(_8ygPoF^raC z_PUQ`tYd)9%R#ua`+ugfZofk`lINpY&$ssWGJ0Uh4!_2#V`5`(^e+vU_4f7Y;HcL{ z&z*Z*Rdtq(ou@g!x26NFb_Sl5QU3Ydi3zKRtuSK!{%I-@31imn{bgG`TPpp}udiiS zn|EbK9%g3BJ9JS)^c%35ojkz@ai3cjCa*YpcDLA|Z+L9PBEhA~8LSE#$8Pn1Wv~XZ z*A_)9Um-HYB$Z*HZ(@QiHoMWECSlm~{(Za66u>DAQ4fua%|Hk&*I^2=(k{xNW9Dgv zh;b>Wt}PoKpMKKBK4s|hXBgQ~Q*pu(C|03!StCe11F*8Bl$1eN0<7L9V%sSpA)!jy zWAueC$tHaBG9Jc)!>ro#C%ne0w5j_TF}JXw3>nIr!V_=$w7mIP)t^pc14;_+e3%tC ze`5b{w&;;V zb}_xRg-5pbhTSQ`C#9v276afoIy=kTwH^W(7h_^!kp%29<*Z)Z*<4QKQ%rzudpgaw z$5K%+zOV--RR(6}c~h8pX4yAar-e;SOjM<%4O< z7BSC9URd`5%;5zf<%5}Z;#jyE14vOel|Ld5wg+zektbF4lK5f`iVK?v{mv1)U#HEk zUX4dqz#xdo>P4K?JPnvGg6w^!8D*1dvDW_U5(#c1C?41HY?o>slTlgzVmw)O6~mbU?2KDcef#8UrjymS3yC+9awn5plk4T z5GGZJsQCPRVO3RCDzelqa!$u6Es{vRze%4@ojH@yva=)|Nna1$%dg})GyU&CIhyX+ z1>)cu>iq661yDg!8U}-TkdYA!0JG9|s+n=6wX-u8EQb;7mGUG1whS1gk7mv3|IE!h zx{~|D@>Eg?`R&?#u;cNtb$UAg_THRuqL7AFK`wPN z&V{I=>?~J8zI<$l{()cZ0O>mn@{r!Z*lfC~!f?jmQQ1T-<<`gH;f(rv z6(H^fA&n4*fJ53hFVIV!IkV8OJnaa0bdE_`Ss8|UUR6JFutG;Tde|eG+;0DtB&be3 z4uCuU)Q_4(@Q=Or7KV`w<~acZR9ZM}jpoLU8)H#1Nr}7)cc=Fjl^q=&*&u`){D1g} z!^q+Sf~Ss5IAopZ4V^-ie8gTbR1RxlalG%5?4uN6O_>5aXe`jD-2We8sEaThcIZmH z0i@mzX$4@H`iDFv4PtxP3Ol)Xj{!gN(yjfzZ1X*h)GDfZJI?o{MnS{C5zWfi*Xil$ zIyyQ>wlHjnyR5=8Cb?=EFo`j_zp`p&!Hu1|a^*@_itqr80u5o?@@l!s|A(K?UcBf! zJ~=hDFf(Hp6&)Q5lT66(pC{~oh;tUW4CHlmbg*%7M0eWH{u-|ZkS!ctR>f0_!WQ0h zMF!45BGV%f3@1*UfQ+Sjz!OFT(I;mMQIM{K?yow!x`r^+!>y{S2awW0ee)EG-xClG z#47OHv$8TG&~0O*8riIDSVdKJohZD|kff&r`z8$%>47=?^3R9IMC|X#`OIiF2Cd2z c`23@95(&yCI&J)hQ@lesU1Q9P3wBZe15=6F-T(jq diff --git a/public/res/apple/apple-touch-icon-144x144.png b/public/res/apple/apple-touch-icon-144x144.png index b6a731eba6e00ff3a70fd3d660c9ec3857200284..d76ed356c9aeb674b40950163dbd422d61b84ade 100644 GIT binary patch literal 3096 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 Wvb$fUaT12|$F$WkYR^<{!u}66`NA&% diff --git a/public/res/apple/apple-touch-icon-152x152.png b/public/res/apple/apple-touch-icon-152x152.png index 968ae2f01e93c14da064c885111549a06f4c4a9a..76590ddf8e3f12c362d88e43b06998fea08bce40 100644 GIT binary patch literal 3212 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 literal 6665 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 YmO1|=A>%K6?RZW_K~ug|&cyeB0DR9&6#xJL diff --git a/public/res/apple/apple-touch-icon-180x180.png b/public/res/apple/apple-touch-icon-180x180.png index a651cd1c8b2d26a4af969c22b43f7076f6f9bf2c..ed4983706678ecba79a6081b8a869a973cce03c9 100644 GIT binary patch literal 3884 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 KLTbRSul@(HS$PEj literal 7889 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 x3`!r4(8cMY@keM=BhP0Ihxh9iME+VCD~?L zMkPUum2E0f6d6%ewo%w-P<|;0A_$@&ih`(ULo>2{p_QnwZ#)fZNEb3Cb^P47$27TixRb~7ll=0*MC-z^(6=LQVrjNJnfsI=)TL}KO+KgZ+U>8ZU|9V(@^T(I$ZsW|M5B7 zgZNE2Bw|k%kO?s>dLLr!PHhf#Y|K}|MCxoPQ1@BMu+os%YsGHby>$e=ZXQcr8-qR8 zN`w4_F)`O0v0OZ1U;AA18CD9iVyIUL__!_Y>3_zpXbDm#@i3uTz& zgKTTrYzj~sQdB}|uTrwkF`Hg)3mb+*a#9AEACwJ4VvaOhE8(hUEV#HR#}+_oNRLj}^QE;y z7k@II72h41L*K6LRb%h#m1eb!s9n#>g5*P@E#pCA#1a91K31*^`2P5U9$6H1l7S}G zT_MbcJjl*<0b!Q?&@x#Fs8{%5RV!16X-JRG;4Df&dXOj+qQVcMXSE}<8ivFHm8dcJ ze6-vUq^RgK;YV-;QqP*mw6Sih2dM`LA%7V;1}Tb79;g*aSlW@8WD*^Ru>KU22Vg(! zFSP`TV-m+8G5pG&!HOF4*qJd_H;=8g1<*945l~TMj>4}8xnWVc^N_Njo%Q?y8J&)J zc06NSBVZYlf$GI5bh2tN7Ze$k2U%S)%{D+=AenIF2v}xWlp3^x>;(NpRgfO84u7im z8_@lggQ=q_90C3L_&jx=T1B_k7pwOk`==_FuImy4sV98d*jp+lz~b2B{<@Leb*oqD zy#f+<92l!`p~G9Zaxa-wNCvv2)UUcC)6%d{y~6YSy~@MID_euR+5wnUNGIh&rixt1 jRFMmrDsmxHMSlQG=+xrUo=m&|015yANkvXXu0mjf+i)a8PN!4exN$?cTrMIb zBMA!&lTlGo!eB69Fc_rKXcS3FNo?4#0j;TMq@$yQ%F0UW>gpIA9At*z{{%B!E*CDB zON@++(B0jQk6=bzTpX#XsqERa2b0N!7F3pDYHEsazWIjY;$mzz8(J6`7~ts9qZ~bY zl*GhD^78W7wSQ|Ds;Uwwl>kU7sj8}?pr8PU!$Gigc6MSmn>l#!AcqehCMzooAp}dM zvP5>fop;`Oht}3sR*J*nz-%^CUS3W~NeP=bZ)PcUg+44&MMVXvsj2k$_w#RXI2@Fh zmvi5J_wn@8PqSDmi{SBid;<@LZqq(`6|01i^%8ng7q|4=EJ_>WV zb?cT)PfzFK#f$uxwYRs6)YMcw9*<-W3Uhe<_19@`ZsuNKwOZM?Z=YZe3U~3l#<-sTs$5R_acwS!&`5? zC8U(hrZ7uYRTXE?p55 zl zH+kZTC)l)U6CoiX_>GT`)6>&KV`C##Rex0s4Gpna_4W0lsHg~aXlRJR!9fD?=%bI4 zk&(et?Af!2*49>>PAB>K`OKD(kieayqodinbt_x9ZsqXd!<;*Jj*^lRYHDh@efu`^ z>Fw=hcz77~r=NZzP}$kpD2l>TtXZ>$)2B}}AE(nvLqh|<{q`Hf!^4b@j&k$nO@E@J zqlu1=CN3_HBS(%nk z5CSPBbFtZMQtj*OBM?FeHf`F3R(}2USJKndxpwUui{q^GCjcDo6NiHQlRy4`NU5+x)g zaPs6yw2+gN!_})-2^P28Ez}z~Zm?K-y`BRH4)DPTAFyiGDweCZwwCX{`;K6moSZ}r z3k$>TcC!$Z$;8)Re~rmxLJLw#4jec@u!V<*qeetTNVnT9m_ul2D20WEU#&{}C}DJx;knl(b*xN#%h-QCO< z78XW9K>_c-|2~SMpf#`8i`8mnB^V3_)cE*#+0f7+nDPAc&r@1j%0mx5M6h&pbTBb7 z!AdX~3{p)PdV+0iY>brx0{rd6#n|_FSKy`_H8T{3&FB_^=e8= zN+fqsxWnenoB90n&+)U{?Y#W*%iO$qljUk^YGP<;h+sK(>=^O!@q#-j+{M0q`^d`5 zlFYDLt?b>qms_`Pu^eS(Wduuhb~bzW?&WS2=5X@lNp|ksiGQEPV&TOXU&QHj5{QnD z4(jUa2!^DjB#s|HE}4VE96~}uL`_W%TefVG%xGz8VcWKC3=9mg6jDlZb93=}y=d*p zC!dr*{O|*MyOw24SFh&8iGLG(^wCG?bUMKz=n8#Uq{knB zob>c`+11r091aJ50|Nt8R8(;K^l3&$Mu?1z(a}+s%VaW7-n|=}%|>NqrL3>77rni` z1S&p0o|KdnX*QdM(P%^qDjG>hNT8^wNE8(narNp|T3TAL+wHQqw^#J^^x$+l<>cg~ zaJgJWMn)1I9xkJzqC|XrJO+b78jVJgoSaN-Y#J;+0uB8Stfhr;J{~N900000NkvXX Hu0mjf!HtfB diff --git a/public/res/apple/apple-touch-icon-60x60.png b/public/res/apple/apple-touch-icon-60x60.png index 47fafb07aacb8c427095194e8c4cee18e2268e5a..87c8fc2ba5dda34518d6b44db4417927a3382ab5 100644 GIT binary patch delta 1242 zcmV<01SR{j6YU9*BYy<6NklyjoN`h7~)u0VV z(wY_{+Mp&%Jn+a?u?8ch#e=q9LB$_RX{#WJkWw$udZD6GlxU1sZDUPU?5~e}C%((< z?EAj+PG)vzo6HYi!tDFboBix_yw7`#@?~bxNYW^`zkmnq0e^eI9?PfjkUK{@_&@10&kcRpu4N`xw_h% zr-2U@7=lGvJAdL<-)83ejswGzB3QjT=LeOn&0~|A zW$N8j5q~#FvoR~j2TWm)HWgDwMg~QrQEIEni)%|Nu&B^@xMh+lFg!s24l7D#l?IjE zcTr`IX5|ZO%Hlr9`zZ$&#jjDw;Kpgn47jhOie7eg(3=}S(tkZC>Fw=f+VgL#Bbv3X zmar)WHYPCC;TTs2`}*=h4b-SP)m56CXJ(F0Uw>ezXMIuZJ>&$8`~9ayT;OMW>bbDc zsh@XHX(5u|*wh0fA*ljC?x>8tcW!xzx>l44E&TYKnL=P_+LX!3OxT@h3@n?6Re>Q@SfhIb9)Fk2D6s{r42+DZT2_kGzjyACswxPv zUVj=?Ut`+__DNPssHV(d2M&KieOn_#f^`biGO%djuM)?9eK%VP4$~15meIPd0LvEt zvU-|jXfx1((>r3x3B|^f-Gd>3dfOFX2(_@)`55kOW?5d5fsD+cQFprnEOP66YeO_} zsGbY_Vt=g^w*S~V;e7k*{2r~(;;MXifPbeHh^Hr!mCb-ztt=9(Y-jyz3)p9I=ff;v z0Y(N3!2?_cmRT8vh0(QT7Dho~1`M}$6&S*%oGP>oSQfu}VAo=p36+;@%N8r6KX=X? z%+iPIZ3DZOrBz+qs0Uy2S4OgBAXE3R?T*3P0`@8FRqHJ-Dmcu5cdh*_eSob%_kV7# z;6lS?MX=AWAEmNTVR`}k-Y*}`j$y8~j<@XA9UGTB3hNlK?|mY}&Ik`26&m)juN(%(K{eXxdDKL2CGB*-Nm*K0}02U(3PbH z)VI4%+m*ca)okJMY1#sd#DismwNuZwlnC3MxX_YvU|eT_wRnfz>iCJmBFj4|urC10L>r2TWBkZ1(EZ6#xJL07*qoM6N<$ Eg4I=HDgXcg delta 2473 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 N002ovPDHLkV1k}F;!6Mk literal 2951 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 literal 3124 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 - - - - - - - - - - - - diff --git a/public/res/svg/cinny-unread.svg b/public/res/svg/cinny-unread.svg deleted file mode 100644 index be301375..00000000 --- a/public/res/svg/cinny-unread.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/public/res/svg/cinny.svg b/public/res/svg/cinny.svg deleted file mode 100644 index 8701d67b..00000000 --- a/public/res/svg/cinny.svg +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - \ No newline at end of file diff --git a/public/res/svg/ratatoskr-highlight.svg b/public/res/svg/ratatoskr-highlight.svg new file mode 100644 index 00000000..661442d4 --- /dev/null +++ b/public/res/svg/ratatoskr-highlight.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/public/res/svg/ratatoskr-unread.svg b/public/res/svg/ratatoskr-unread.svg new file mode 100644 index 00000000..69a08c95 --- /dev/null +++ b/public/res/svg/ratatoskr-unread.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/public/res/svg/ratatoskr.svg b/public/res/svg/ratatoskr.svg new file mode 100644 index 00000000..3ca98122 --- /dev/null +++ b/public/res/svg/ratatoskr.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/scripts/gen-brand.mjs b/scripts/gen-brand.mjs new file mode 100644 index 00000000..fa44bfde --- /dev/null +++ b/scripts/gen-brand.mjs @@ -0,0 +1,202 @@ +// Draws the Ratatoskr squirrel once, as shapes, and writes the SVG logos plus the +// favicon and app-icon PNGs from the same shapes (no image tools needed). +// Run: node scripts/gen-brand.mjs +import { deflateSync } from 'node:zlib'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const BG = '#1c1b22'; + +// Squirrel facing right on a 64x64 grid. Later shapes draw over earlier ones. +const squirrel = [ + { t: 'ellipse', cx: 19, cy: 33, rx: 13, ry: 22, rot: -15, fill: '#d9742b' }, + { t: 'ellipse', cx: 21, cy: 33, rx: 7, ry: 15, rot: -15, fill: '#f0a45d' }, + { t: 'ellipse', cx: 36, cy: 43, rx: 11, ry: 14, rot: 0, fill: '#e0833a' }, + { t: 'ellipse', cx: 40, cy: 46, rx: 6, ry: 9, rot: 0, fill: '#f6dcb8' }, + { + t: 'poly', + pts: [ + [35, 19], + [36, 7], + [43, 15], + ], + fill: '#c9672a', + }, + { + t: 'poly', + pts: [ + [44, 15], + [50, 7], + [52, 19], + ], + fill: '#c9672a', + }, + { t: 'circle', cx: 43, cy: 25, r: 10, fill: '#e0833a' }, + { t: 'ellipse', cx: 47, cy: 29, rx: 5, ry: 3.5, rot: 0, fill: '#f6dcb8' }, + { t: 'circle', cx: 46.5, cy: 23.5, r: 1.8, fill: '#1a1a1a' }, + { t: 'circle', cx: 47, cy: 23, r: 0.6, fill: '#ffffff' }, + { t: 'circle', cx: 52, cy: 27.5, r: 1.5, fill: '#1a1a1a' }, + { t: 'ellipse', cx: 33, cy: 56, rx: 5, ry: 2.5, rot: 0, fill: '#c9672a' }, + { t: 'ellipse', cx: 44, cy: 56, rx: 4.5, ry: 2.5, rot: 0, fill: '#c9672a' }, +]; +const badge = (fill) => [ + { t: 'circle', cx: 52, cy: 52, r: 11, fill: '#ffffff' }, + { t: 'circle', cx: 52, cy: 52, r: 8, fill }, +]; +const background = [{ t: 'rect', x: 0, y: 0, w: 64, h: 64, fill: BG }]; + +const f = (n) => +n.toFixed(2); +const svgOf = (shapes) => + shapes + .map((s) => { + if (s.t === 'circle') + return ``; + if (s.t === 'rect') return ``; + if (s.t === 'poly') + return ``; + return ``; + }) + .join('\n'); +const writeSvg = (name, shapes) => + writeFileSync( + join(root, 'public/res/svg', name), + `\n${svgOf( + shapes + )}\n\n` + ); + +const hex = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16)); +const inside = (s, x, y) => { + if (s.t === 'rect') return x >= s.x && x < s.x + s.w && y >= s.y && y < s.y + s.h; + if (s.t === 'circle') return (x - s.cx) ** 2 + (y - s.cy) ** 2 <= s.r ** 2; + if (s.t === 'ellipse') { + const a = (-s.rot * Math.PI) / 180; + const dx = x - s.cx; + const dy = y - s.cy; + const u = dx * Math.cos(a) - dy * Math.sin(a); + const v = dx * Math.sin(a) + dy * Math.cos(a); + return (u / s.rx) ** 2 + (v / s.ry) ** 2 <= 1; + } + let c = false; + for (let i = 0, j = s.pts.length - 1; i < s.pts.length; j = i++) { + const [xi, yi] = s.pts[i]; + const [xj, yj] = s.pts[j]; + if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) c = !c; + } + return c; +}; + +const crcTable = Array.from({ length: 256 }, (_, n) => { + let c = n; + for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + return c >>> 0; +}); +const crc = (b) => { + let c = 0xffffffff; + for (const x of b) c = crcTable[(c ^ x) & 255] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +}; +const chunk = (type, data) => { + const out = Buffer.alloc(12 + data.length); + out.writeUInt32BE(data.length, 0); + out.write(type, 4, 'ascii'); + data.copy(out, 8); + out.writeUInt32BE(crc(out.subarray(4, 8 + data.length)), 8 + data.length); + return out; +}; + +// Rasterise with 4x4 supersampling; `scale` shrinks the drawing about the centre. +const png = (shapes, size, scale = 1) => { + const rgba = Buffer.alloc(size * size * 4); + const SS = 4; + const colours = shapes.map((s) => hex(s.fill)); + for (let py = 0; py < size; py += 1) { + for (let px = 0; px < size; px += 1) { + let r = 0; + let g = 0; + let b = 0; + let a = 0; + for (let sy = 0; sy < SS; sy += 1) { + for (let sx = 0; sx < SS; sx += 1) { + const gx = ((px + (sx + 0.5) / SS) / size) * 64; + const gy = ((py + (sy + 0.5) / SS) / size) * 64; + // the background rect covers the whole canvas and ignores `scale` + let hit = -1; + shapes.forEach((s, i) => { + const x = s.t === 'rect' ? gx : (gx - 32) / scale + 32; + const y = s.t === 'rect' ? gy : (gy - 32) / scale + 32; + if (inside(s, x, y)) hit = i; + }); + if (hit >= 0) { + [r, g, b] = [r + colours[hit][0], g + colours[hit][1], b + colours[hit][2]]; + a += 1; + } + } + } + const o = (py * size + px) * 4; + if (a > 0) { + rgba[o] = Math.round(r / a); + rgba[o + 1] = Math.round(g / a); + rgba[o + 2] = Math.round(b / a); + } + rgba[o + 3] = Math.round((a / (SS * SS)) * 255); + } + } + const raw = Buffer.alloc(size * (size * 4 + 1)); + for (let y = 0; y < size; y += 1) { + raw[y * (size * 4 + 1)] = 0; + rgba.copy(raw, y * (size * 4 + 1) + 1, y * size * 4, (y + 1) * size * 4); + } + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(size, 0); + ihdr.writeUInt32BE(size, 4); + ihdr.set([8, 6, 0, 0, 0], 8); + return Buffer.concat([ + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), + chunk('IHDR', ihdr), + chunk('IDAT', deflateSync(raw)), + chunk('IEND', Buffer.alloc(0)), + ]); +}; + +// ICO container holding PNG images. +const ico = (images) => { + const head = Buffer.alloc(6 + images.length * 16); + head.writeUInt16LE(1, 2); + head.writeUInt16LE(images.length, 4); + let offset = head.length; + images.forEach(({ size, data }, i) => { + const e = 6 + i * 16; + head[e] = size; + head[e + 1] = size; + head.writeUInt16LE(1, e + 4); + head.writeUInt16LE(32, e + 6); + head.writeUInt32LE(data.length, e + 8); + head.writeUInt32LE(offset, e + 12); + offset += data.length; + }); + return Buffer.concat([head, ...images.map((i) => i.data)]); +}; + +writeSvg('ratatoskr.svg', squirrel); +writeSvg('ratatoskr-unread.svg', [...squirrel, ...badge('#989898')]); +writeSvg('ratatoskr-highlight.svg', [...squirrel, ...badge('#45B83B')]); + +const icon = [...background, ...squirrel]; +const put = (dir, name, size) => { + mkdirSync(join(root, 'public/res', dir), { recursive: true }); + writeFileSync(join(root, 'public/res', dir, name), png(icon, size, 0.82)); +}; +[36, 48, 72, 96, 144, 192, 256, 384, 512].forEach((s) => + put('android', `android-chrome-${s}x${s}.png`, s) +); +[57, 60, 72, 76, 114, 120, 144, 152, 167, 180].forEach((s) => + put('apple', `apple-touch-icon-${s}x${s}.png`, s) +); +writeFileSync( + join(root, 'public/favicon.ico'), + ico([16, 32, 48].map((size) => ({ size, data: png(icon, size, 0.9) }))) +); +console.log('wrote svg logos, favicon.ico, android and apple icons'); diff --git a/src/app/components/splash-screen/SplashScreen.tsx b/src/app/components/splash-screen/SplashScreen.tsx index 27adadba..8e8f05b6 100644 --- a/src/app/components/splash-screen/SplashScreen.tsx +++ b/src/app/components/splash-screen/SplashScreen.tsx @@ -21,7 +21,7 @@ export function SplashScreen({ children }: SplashScreenProps) { justifyContent="Center" > - Cinny + Ratatoskr diff --git a/src/app/features/settings/about/About.tsx b/src/app/features/settings/about/About.tsx index d3dcde96..6b304965 100644 --- a/src/app/features/settings/about/About.tsx +++ b/src/app/features/settings/about/About.tsx @@ -4,7 +4,7 @@ import { Page, PageContent, PageHeader } from '../../../components/page'; import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCardStyle } from '../styles.css'; import { SettingTile } from '../../../components/setting-tile'; -import CinnySVG from '../../../../../public/res/svg/cinny.svg'; +import RatatoskrSVG from '../../../../../public/res/svg/ratatoskr.svg'; import { clearCacheAndReload } from '../../../../client/initMatrix'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; @@ -38,23 +38,23 @@ export function About({ requestClose }: AboutProps) { Cinny logo - Cinny + Ratatoskr v4.12.8 - Yet another matrix client. + GameCult's Matrix client. - + + Based on{' '} + + Cinny + {' '} + 4.12.8 by Ajay Bura and contributors. Ratatoskr is free software under the GNU + Affero General Public License v3; its source is at the Source Code link above. + diff --git a/src/app/features/settings/devices/LocalBackup.tsx b/src/app/features/settings/devices/LocalBackup.tsx index 00128c8f..4fcdacdb 100644 --- a/src/app/features/settings/devices/LocalBackup.tsx +++ b/src/app/features/settings/devices/LocalBackup.tsx @@ -28,7 +28,7 @@ function ExportKeys() { const blob = new Blob([encKeys], { type: 'text/plain;charset=us-ascii', }); - FileSaver.saveAs(blob, 'cinny-keys.txt'); + FileSaver.saveAs(blob, 'ratatoskr-keys.txt'); }, [mx] ) diff --git a/src/app/features/settings/notifications/SystemNotification.tsx b/src/app/features/settings/notifications/SystemNotification.tsx index e0df06df..38115980 100644 --- a/src/app/features/settings/notifications/SystemNotification.tsx +++ b/src/app/features/settings/notifications/SystemNotification.tsx @@ -27,7 +27,7 @@ function EmailNotification() { device_display_name: email, lang: 'en', data: { - brand: 'Cinny', + brand: 'Ratatoskr', }, append: true, }); diff --git a/src/app/pages/auth/AuthFooter.tsx b/src/app/pages/auth/AuthFooter.tsx index b0b94319..2b888f55 100644 --- a/src/app/pages/auth/AuthFooter.tsx +++ b/src/app/pages/auth/AuthFooter.tsx @@ -5,20 +5,26 @@ import * as css from './styles.css'; export function AuthFooter() { return ( - + About - v4.12.8 + Source - - Twitter + + Based on Cinny Powered by Matrix diff --git a/src/app/pages/auth/AuthLayout.tsx b/src/app/pages/auth/AuthLayout.tsx index 3943f42d..c53e5a65 100644 --- a/src/app/pages/auth/AuthLayout.tsx +++ b/src/app/pages/auth/AuthLayout.tsx @@ -20,7 +20,7 @@ import { } from '../../hooks/useClientConfig'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { LOGIN_PATH, REGISTER_PATH, RESET_PASSWORD_PATH } from '../paths'; -import CinnySVG from '../../../../public/res/svg/cinny.svg'; +import RatatoskrSVG from '../../../../public/res/svg/ratatoskr.svg'; import { ServerPicker } from './ServerPicker'; import { AutoDiscoveryAction, autoDiscovery } from '../../cs-api'; import { SpecVersionsLoader } from '../../components/SpecVersionsLoader'; @@ -134,8 +134,8 @@ export function AuthLayout() {

    - Cinny Logo - Cinny + Ratatoskr logo + Ratatoskr
    diff --git a/src/app/pages/auth/login/PasswordLoginForm.tsx b/src/app/pages/auth/login/PasswordLoginForm.tsx index 62f46dd2..3c8641d9 100644 --- a/src/app/pages/auth/login/PasswordLoginForm.tsx +++ b/src/app/pages/auth/login/PasswordLoginForm.tsx @@ -133,7 +133,7 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog user: username, }, password, - initial_device_display_name: 'Cinny Web', + initial_device_display_name: 'Ratatoskr Web', }); }; @@ -151,7 +151,7 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog user: mxIdUsername, }, password, - initial_device_display_name: 'Cinny Web', + initial_device_display_name: 'Ratatoskr Web', }); }; const handleEmailLogin = (email: string, password: string) => { @@ -163,7 +163,7 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog address: email, }, password, - initial_device_display_name: 'Cinny Web', + initial_device_display_name: 'Ratatoskr Web', }); }; diff --git a/src/app/pages/auth/login/TokenLogin.tsx b/src/app/pages/auth/login/TokenLogin.tsx index 761d5dc5..0541742d 100644 --- a/src/app/pages/auth/login/TokenLogin.tsx +++ b/src/app/pages/auth/login/TokenLogin.tsx @@ -57,7 +57,7 @@ export function TokenLogin({ token }: TokenLoginProps) { startLogin(baseUrl, { type: 'm.login.token', token, - initial_device_display_name: 'Cinny Web', + initial_device_display_name: 'Ratatoskr Web', }); }, [baseUrl, token, startLogin]); diff --git a/src/app/pages/auth/register/PasswordRegisterForm.tsx b/src/app/pages/auth/register/PasswordRegisterForm.tsx index 9f173422..c9b1924c 100644 --- a/src/app/pages/auth/register/PasswordRegisterForm.tsx +++ b/src/app/pages/auth/register/PasswordRegisterForm.tsx @@ -109,7 +109,7 @@ function RegisterUIAFlow({ auth: authDict, password, username, - initial_device_display_name: 'Cinny Web', + initial_device_display_name: 'Ratatoskr Web', }); }, [onRegister, formData] @@ -250,7 +250,7 @@ export function PasswordRegisterForm({ auth: { session: authData.session, }, - initial_device_display_name: 'Cinny Web', + initial_device_display_name: 'Ratatoskr Web', }); }; diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index ce952bfc..db3eefc2 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -3,9 +3,9 @@ import React, { ReactNode, useCallback, useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk'; import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread'; -import LogoSVG from '../../../../public/res/svg/cinny.svg'; -import LogoUnreadSVG from '../../../../public/res/svg/cinny-unread.svg'; -import LogoHighlightSVG from '../../../../public/res/svg/cinny-highlight.svg'; +import LogoSVG from '../../../../public/res/svg/ratatoskr.svg'; +import LogoUnreadSVG from '../../../../public/res/svg/ratatoskr-unread.svg'; +import LogoHighlightSVG from '../../../../public/res/svg/ratatoskr-highlight.svg'; import NotificationSound from '../../../../public/sound/notification.ogg'; import InviteSound from '../../../../public/sound/invite.ogg'; import { notificationPermission, setFavicon } from '../../utils/dom'; diff --git a/src/app/pages/client/WelcomePage.tsx b/src/app/pages/client/WelcomePage.tsx index 6a25392c..74869c3f 100644 --- a/src/app/pages/client/WelcomePage.tsx +++ b/src/app/pages/client/WelcomePage.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Box, Button, Icon, Icons, Text, config, toRem } from 'folds'; import { Page, PageHero, PageHeroSection } from '../../components/page'; -import CinnySVG from '../../../../public/res/svg/cinny.svg'; +import RatatoskrSVG from '../../../../public/res/svg/ratatoskr.svg'; export function WelcomePage() { return ( @@ -14,17 +14,17 @@ export function WelcomePage() { > } - title="Welcome to Cinny" + icon={Ratatoskr logo} + title="Welcome to Ratatoskr" subTitle={ - Yet another matrix client.{' '} + GameCult's Matrix client, based on{' '} - v4.12.8 + Cinny } @@ -33,7 +33,7 @@ export function WelcomePage() { - From 87c9ca60301b5f75eba6198b35db031b3421a4bf Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 4 Nov 2026 18:26:23 +0000 Subject: [PATCH 062/206] scene: cone prepass starts pass-1 rays past empty space; opt-in steps/pixel readout Pass 0 marches one ray per 8x8 tile of pass 1, each step shrunk by the widest any ray of the tile strays from it, and stores the distance no ray of the tile can have hit before (W_FAR when the whole tile climbs out above the highest geometry). Pass 1 starts from it. The distance bound moves to sdf.glsl so both passes step by the same function. onTraceStats adds a step-count render target to pass 1 and reads it back every 8th frame through a pixel-pack buffer and fence, outside the timed span. Unset, neither the output nor the target exists. prepass:false is the baseline. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/README.md | 11 +- src/app/features/scene/Scene.test.ts | 43 ++++- src/app/features/scene/Scene.ts | 153 ++++++++++++++++-- src/app/features/scene/shaderSource.ts | 19 ++- .../features/scene/shaders/prepass.frag.glsl | 49 ++++++ src/app/features/scene/shaders/sdf.glsl | 95 +++++++++++ .../features/scene/shaders/terrain.frag.glsl | 122 +++----------- 7 files changed, 369 insertions(+), 123 deletions(-) create mode 100644 src/app/features/scene/shaders/prepass.frag.glsl create mode 100644 src/app/features/scene/shaders/sdf.glsl diff --git a/src/app/features/scene/README.md b/src/app/features/scene/README.md index be117a0a..a189b803 100644 --- a/src/app/features/scene/README.md +++ b/src/app/features/scene/README.md @@ -31,11 +31,16 @@ callbacks and the creature sizing rule. Nothing has drawn a creature. - `camera.ts` the pull-back path as pure functions (`pullProgress`, `cameraPose`). - `world.ts` every world number, and the Lipschitz bounds the sphere trace steps by. The GLSL `#define`s are generated from it; no number is written twice. -- `shaders/` pass 1 `terrain.frag.glsl`, pass 2 `atmosphere.frag.glsl`, `resolve.frag.glsl` (TAA), +- `shaders/` `sdf.glsl` (the distance bound pass 0 and pass 1 share), pass 0 `prepass.frag.glsl`, pass 1 `terrain.frag.glsl`, pass 2 `atmosphere.frag.glsl`, `resolve.frag.glsl` (TAA), `composite.frag.glsl`, plus CultLib's vendored GLSL (`shaders/README.md`). ## How it renders +0. **Cone prepass** (`prepass.frag.glsl`, one texel per 8x8 tile of pass 1): marches one ray per + tile, shrinking each step by the widest any ray of the tile strays from it, so every step is + free for the whole tile. It stores the distance no ray of the tile can have hit before, or + `W_FAR` when the whole tile climbs out above the highest geometry. Pass 1 starts its rays + there. `createScene({ prepass: false })` skips it (the measurement baseline). 1. **Solid pass** (`terrain.frag.glsl`, 0.4-0.75 of canvas resolution): sphere trace of `min(terrain, tree)`. Terrain height is `mask(r) * (A * fbm + E * phacelle)`, with `cultmath_fbm_grad(...).w` and `cultmath_phacelle(...).cos.w`. It writes lit colour with @@ -47,6 +52,10 @@ callbacks and the creature sizing rule. Nothing has drawn a creature. 4. **Composite** at canvas resolution: solid over analytic sky, then the atmosphere, ACES tonemap, dither. +`createScene({ onTraceStats })` measures the trace: pass 1 gets a step-count output and an extra +render target, read back every 8th frame through a pixel-pack buffer and a fence (never waited +on), and reports mean steps per pixel. Unset, neither the output nor the target exists. + ## The step bound, and what is not proven The trace steps by `(y - h(x, z)) / sqrt(1 + G^2)`, `G` the Lipschitz bound of the height field, diff --git a/src/app/features/scene/Scene.test.ts b/src/app/features/scene/Scene.test.ts index 25b7f08d..c90d1bba 100644 --- a/src/app/features/scene/Scene.test.ts +++ b/src/app/features/scene/Scene.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; import { createScene, ShaderCompileError } from './Scene'; import { LEVELS } from './budget'; +import { fragmentSource } from './shaderSource'; import { startBackgroundScene } from './index'; type Fn = Mock; @@ -94,7 +95,7 @@ describe('createScene tiers', () => { const scene = createScene({ canvas: asCanvas(fakeCanvas(gl)), tier: 'still' }); expect(scene).not.toBeNull(); runFrame(1000); - expect(calls.drawArrays).toHaveBeenCalledTimes(4); // four passes, one frame + expect(calls.drawArrays).toHaveBeenCalledTimes(5); // prepass and four passes, one frame expect(rafQueue).toHaveLength(0); }); @@ -132,6 +133,44 @@ describe('createScene tiers', () => { }); }); +describe('cone prepass', () => { + const programsFor = (prepass?: boolean) => { + rafQueue.length = 0; + const { gl, calls } = fakeGl(); + createScene({ canvas: asCanvas(fakeCanvas(gl)), prepass }); + return calls.createProgram.mock.calls.length; + }; + + it('is one more program than the baseline, and off means off', () => { + expect(programsFor(true)).toBe(programsFor(false) + 1); + expect(programsFor(undefined)).toBe(programsFor(true)); + }); + + it('draws before pass 1 and only when on', () => { + const drawsFor = (prepass: boolean) => { + rafQueue.length = 0; + const { gl, calls } = fakeGl(); + createScene({ canvas: asCanvas(fakeCanvas(gl)), prepass }); + runFrame(1000); + return calls.drawArrays.mock.calls.length; + }; + expect(drawsFor(true)).toBe(drawsFor(false) + 1); + }); + + it('the trace-step output exists in pass 1 only when the host asked to measure', () => { + expect(fragmentSource('terrain')).not.toContain('TRACE_STATS 1'); + expect(fragmentSource('terrain', '#define TRACE_STATS 1\n')).toContain('#define TRACE_STATS 1'); + }); + + it('shares one distance bound between pass 1 and the prepass', () => { + const bound = (stage: 'terrain' | 'prepass') => + fragmentSource(stage).match(/float sceneDistance\([^)]*\)\s*\{[^}]*\}/)?.[0]; + expect(bound('prepass')).toBeDefined(); + expect(bound('prepass')).toBe(bound('terrain')); + expect(fragmentSource('atmosphere')).not.toContain('sceneDistance'); + }); +}); + describe('shader compile failure', () => { it('reports through onCompileFail, returns null, and frees the programs already linked', () => { const { gl, calls } = fakeGl(4); // terrain compiles both shaders; atmosphere's fragment is the 4th and fails @@ -265,7 +304,7 @@ describe('startBackgroundScene', () => { ); expect(scene).not.toBeNull(); runFrame(1000); - expect(calls.drawArrays).toHaveBeenCalledTimes(4); + expect(calls.drawArrays).toHaveBeenCalledTimes(5); expect(rafQueue).toHaveLength(0); }); diff --git a/src/app/features/scene/Scene.ts b/src/app/features/scene/Scene.ts index 40ce300d..9707a56b 100644 --- a/src/app/features/scene/Scene.ts +++ b/src/app/features/scene/Scene.ts @@ -10,8 +10,10 @@ const FRAME_INTERVAL_MS = 1000 / 30; const REDUCED_MOTION_FRAMES = 32; const MAX_DPR = 1.5; -export type PassName = 'terrain' | 'atmosphere' | 'resolve' | 'composite'; -const PASSES: PassName[] = ['terrain', 'atmosphere', 'resolve', 'composite']; +export type PassName = 'terrain' | 'atmosphere' | 'resolve' | 'composite' | 'prepass'; +const PASSES: PassName[] = ['terrain', 'atmosphere', 'resolve', 'composite', 'prepass']; +/** Pass 1 pixels per prepass tile edge. */ +const TILE = 8; export interface SceneStats { /** GPU milliseconds per pass for one finished frame (timer queries; absent without the extension). */ @@ -21,6 +23,16 @@ export interface SceneStats { atmosphereSize: [number, number]; } +/** Trace cost over one finished readback: mean distance evaluations per pass-1 pixel. */ +export interface TraceStats { + /** Mean pass-1 steps per pixel. */ + traceSteps: number; + /** Mean prepass steps per tile; 0 without the prepass. */ + prepassSteps: number; + /** traceSteps plus the prepass's steps spread over the pixels of each tile. */ + stepsPerPixel: number; +} + /** Measurement seam: the harness rewrites a stage's source to time a variant. Never set in the client. */ export type SourcePatch = (stage: Stage, source: string) => string; @@ -48,10 +60,19 @@ export interface SceneOptions { onContextLost?: () => void; /** Called per finished frame when timer queries exist. */ onStats?: (s: SceneStats) => void; + /** + * Measurement: mean sphere-trace steps per pass-1 pixel, read back every few frames. Setting it + * compiles pass 1 with a step-count output and an extra render target; unset, neither exists. + */ + onTraceStats?: (s: TraceStats) => void; /** Weather knob, 0 clear .. 1 overcast. */ coverage?: number; /** Fixed level instead of the adaptive ladder (measurement). */ fixedLevel?: Level; + /** Cone prepass that starts pass-1 rays past empty space. Default true; false is the measurement baseline. */ + prepass?: boolean; + /** Hold the camera at this pull-back progress, 0..1, instead of following login (measurement). */ + fixedPull?: number; /** Ignore the 30 fps cap and the visibility pause (measurement). */ unthrottled?: boolean; patchSource?: SourcePatch; @@ -83,13 +104,14 @@ function compile( export function linkStage( gl: WebGL2RenderingContext, stage: Stage, - patch?: SourcePatch + patch?: SourcePatch, + defines?: string ): WebGLProgram { const vs = compile(gl, gl.VERTEX_SHADER, vertexSource, 'vertex'); const fs = compile( gl, gl.FRAGMENT_SHADER, - patch ? patch(stage, fragmentSource(stage)) : fragmentSource(stage), + patch ? patch(stage, fragmentSource(stage, defines)) : fragmentSource(stage, defines), stage ); const prog = gl.createProgram()!; @@ -143,13 +165,20 @@ export function createScene(opts: SceneOptions): Scene | null { GPU_DISJOINT_EXT: number; } | null; + const usePrepass = opts.prepass ?? true; + const passes = usePrepass ? PASSES : PASSES.filter((p) => p !== 'prepass'); const programs = {} as Record; try { - PASSES.forEach((p) => { - programs[p] = linkStage(gl, p, opts.patchSource); + passes.forEach((p) => { + programs[p] = linkStage( + gl, + p, + opts.patchSource, + p === 'terrain' && opts.onTraceStats ? '#define TRACE_STATS 1\n' : undefined + ); }); } catch (e) { - PASSES.forEach((p) => { + passes.forEach((p) => { if (programs[p]) gl.deleteProgram(programs[p]); }); if (!(e instanceof ShaderCompileError)) throw e; @@ -180,15 +209,18 @@ export function createScene(opts: SceneOptions): Scene | null { const fbos = { solid: gl.createFramebuffer()!, + pre: gl.createFramebuffer()!, atm: gl.createFramebuffer()!, hist: [gl.createFramebuffer()!, gl.createFramebuffer()!], }; let solid: Target; let depth: Target; + let steps: Target | undefined; + let startTex: Target; let atm: Target; let hist: [Target, Target]; const freeTargets = () => { - [solid, depth, atm, hist?.[0], hist?.[1]].forEach((t) => { + [solid, depth, steps, startTex, atm, hist?.[0], hist?.[1]].forEach((t) => { if (t) gl.deleteTexture(t.tex); }); }; @@ -205,6 +237,71 @@ export function createScene(opts: SceneOptions): Scene | null { const ladder = new BudgetLadder(GPU_BUDGET_MS, 45, tier === 'low' ? LOW_START_LEVEL : 0); const level = (): Level => opts.fixedLevel ?? (still ? STILL_LEVEL : ladder.current); + // Trace-step readback: one pixel-pack read in flight at a time, polled by fence, never waited on. + // Pass 1's step bytes and the prepass's RGBA floats (g: steps) come back together. + const READBACK_EVERY = 8; + interface Readback { + fence: WebGLSync; + pixels: number; + tiles: number; + } + let readback: Readback | undefined; + const packSteps = opts.onTraceStats ? gl.createBuffer() : null; + const packStart = opts.onTraceStats && usePrepass ? gl.createBuffer() : null; + const discardReadback = () => { + if (readback) gl.deleteSync(readback.fence); + readback = undefined; + }; + const startReadback = () => { + if (!steps || readback) return; + gl.bindBuffer(gl.PIXEL_PACK_BUFFER, packSteps); + gl.bufferData(gl.PIXEL_PACK_BUFFER, steps.w * steps.h, gl.STREAM_READ); + gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.solid); + gl.readBuffer(gl.COLOR_ATTACHMENT0 + 2); + gl.readPixels(0, 0, steps.w, steps.h, gl.RED, gl.UNSIGNED_BYTE, 0); + let tiles = 0; + if (packStart) { + tiles = startTex.w * startTex.h; + gl.bindBuffer(gl.PIXEL_PACK_BUFFER, packStart); + gl.bufferData(gl.PIXEL_PACK_BUFFER, tiles * 16, gl.STREAM_READ); + gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.pre); + gl.readPixels(0, 0, startTex.w, startTex.h, gl.RGBA, gl.FLOAT, 0); + } + gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null); + readback = { + fence: gl.fenceSync(gl.SYNC_GPU_COMMANDS_COMPLETE, 0)!, + pixels: steps.w * steps.h, + tiles, + }; + }; + const pollReadback = () => { + if (!readback) return; + const status = gl.clientWaitSync(readback.fence, 0, 0); + if (status !== gl.ALREADY_SIGNALED && status !== gl.CONDITION_SATISFIED) return; + const { pixels, tiles } = readback; + const bytes = new Uint8Array(pixels); + gl.bindBuffer(gl.PIXEL_PACK_BUFFER, packSteps); + gl.getBufferSubData(gl.PIXEL_PACK_BUFFER, 0, bytes); + let preSum = 0; + if (packStart) { + const floats = new Float32Array(tiles * 4); + gl.bindBuffer(gl.PIXEL_PACK_BUFFER, packStart); + gl.getBufferSubData(gl.PIXEL_PACK_BUFFER, 0, floats); + for (let i = 1; i < floats.length; i += 4) preSum += floats[i]; + } + gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null); + let sum = 0; + for (let i = 0; i < bytes.length; i += 1) sum += bytes[i]; + discardReadback(); + const traceSteps = sum / pixels; + const prepassSteps = tiles ? preSum / tiles : 0; + opts.onTraceStats?.({ + traceSteps, + prepassSteps, + stepsPerPixel: traceSteps + (prepassSteps * tiles) / pixels, + }); + }; + let canvasW = 0; let canvasH = 0; let sizedFor: Level | undefined; @@ -228,9 +325,16 @@ export function createScene(opts: SceneOptions): Scene | null { const ah = Math.max(2, Math.round(h * lv.atmosphereScale)); solid = makeTarget(sw, sh, gl.RGBA16F, gl.LINEAR); depth = makeTarget(sw, sh, gl.R16F, gl.NEAREST); + // Without the prepass pass 1 reads a 1x1 zero start: every ray from the camera, as before. + startTex = usePrepass + ? makeTarget(Math.ceil(sw / TILE), Math.ceil(sh / TILE), gl.RGBA32F, gl.NEAREST) + : makeTarget(1, 1, gl.RGBA32F, gl.NEAREST); + steps = opts.onTraceStats ? makeTarget(sw, sh, gl.R8, gl.NEAREST) : undefined; atm = makeTarget(aw, ah, gl.RGBA16F, gl.LINEAR); hist = [makeTarget(aw, ah, gl.RGBA16F, gl.LINEAR), makeTarget(aw, ah, gl.RGBA16F, gl.LINEAR)]; - attach(fbos.solid, [solid, depth]); + attach(fbos.solid, steps ? [solid, depth, steps] : [solid, depth]); + discardReadback(); + attach(fbos.pre, [startTex]); attach(fbos.atm, [atm]); attach(fbos.hist[0], [hist[0]]); attach(fbos.hist[1], [hist[1]]); @@ -274,10 +378,10 @@ export function createScene(opts: SceneOptions): Scene | null { const row = finished.get(f) ?? {}; if (!disjoint) row[pass] = ns / 1e6; finished.set(f, row); - if (Object.keys(row).length === PASSES.length) { + if (Object.keys(row).length === passes.length) { finished.delete(f); - const passMs = row as Record; - const total = PASSES.reduce((s, p) => s + passMs[p], 0); + const passMs = { prepass: 0, ...row } as Record; + const total = passes.reduce((s, p) => s + passMs[p], 0); if (!opts.fixedLevel && ladder.push(total)) sizedFor = undefined; opts.onStats?.({ passMs, @@ -318,12 +422,26 @@ export function createScene(opts: SceneOptions): Scene | null { const time = frozen ? 0 : (now - start) / 1000; let elapsed: number | undefined; if (loginAt !== undefined) elapsed = frozen ? 1e9 : (now - loginAt) / 1000; - const pose = cameraPose(pullProgress(elapsed), time); + const pose = cameraPose(opts.fixedPull ?? pullProgress(elapsed), time); const f = frame; frame += 1; const jx = halton((f % 16) + 1, 2) - 0.5; const jy = halton((f % 16) + 1, 3) - 0.5; + // Pass 0: cone march at one ray per 8x8 tile of pass 1, the start distance for its rays. + if (usePrepass) { + timed('prepass', f, () => { + gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.pre); + gl.viewport(0, 0, startTex.w, startTex.h); + const p = programs.prepass; + gl.useProgram(p); + setCommon(p, pose, time); + gl.uniform1i(loc(p, 'u_maxSteps'), lv.traceSteps); + gl.uniform2f(loc(p, 'u_tileHalf'), 0.5 / startTex.w, 0.5 / startTex.h); + draw(); + }); + } + // Pass 1: solid surfaces, writes colour and depth. timed('terrain', f, () => { gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.solid); @@ -336,8 +454,10 @@ export function createScene(opts: SceneOptions): Scene | null { gl.uniform3fv(loc(p, 'u_bark'), PALETTE.bark as unknown as number[]); gl.uniform3fv(loc(p, 'u_leaf'), PALETTE.leaf as unknown as number[]); gl.uniform1i(loc(p, 'u_maxSteps'), lv.traceSteps); + bindTex(0, startTex, p, 'u_start'); draw(); }); + if (f % READBACK_EVERY === 0) startReadback(); // outside the timed span: not pass 1's cost // Pass 2: atmosphere up to pass 1's depth, low-res. timed('atmosphere', f, () => { @@ -393,6 +513,7 @@ export function createScene(opts: SceneOptions): Scene | null { prev = pose; resetHistory = false; collect(); + pollReadback(); }; const tick = (now: number) => { @@ -468,9 +589,11 @@ export function createScene(opts: SceneOptions): Scene | null { if (disposed) return; stop(); freeTargets(); - PASSES.forEach((p) => gl.deleteProgram(programs[p])); + passes.forEach((p) => gl.deleteProgram(programs[p])); + discardReadback(); + [packSteps, packStart].forEach((b) => b && gl.deleteBuffer(b)); gl.deleteVertexArray(vao); - [fbos.solid, fbos.atm, ...fbos.hist].forEach((fb) => gl.deleteFramebuffer(fb)); + [fbos.solid, fbos.pre, fbos.atm, ...fbos.hist].forEach((fb) => gl.deleteFramebuffer(fb)); }, }; } diff --git a/src/app/features/scene/shaderSource.ts b/src/app/features/scene/shaderSource.ts index c7a2a03f..42631d57 100644 --- a/src/app/features/scene/shaderSource.ts +++ b/src/app/features/scene/shaderSource.ts @@ -2,6 +2,8 @@ import cultmath from './shaders/CultMath.glsl?raw'; import phacelle from './shaders/CultMath.Phacelle.glsl?raw'; import common from './shaders/common.glsl?raw'; import fullscreenVert from './shaders/fullscreen.vert.glsl?raw'; +import sdf from './shaders/sdf.glsl?raw'; +import prepassFrag from './shaders/prepass.frag.glsl?raw'; import terrainFrag from './shaders/terrain.frag.glsl?raw'; import atmosphereFrag from './shaders/atmosphere.frag.glsl?raw'; import resolveFrag from './shaders/resolve.frag.glsl?raw'; @@ -9,7 +11,7 @@ import compositeFrag from './shaders/composite.frag.glsl?raw'; import { WORLD, worldDefines } from './world'; import { trimGlsl } from './cultmathTrim'; -export type Stage = 'terrain' | 'atmosphere' | 'resolve' | 'composite'; +export type Stage = 'prepass' | 'terrain' | 'atmosphere' | 'resolve' | 'composite'; const HEADER = '#version 300 es\nprecision highp float;\nprecision highp int;\nprecision highp sampler2D;\n'; @@ -17,6 +19,7 @@ const HEADER = export const vertexSource = HEADER + fullscreenVert; const stages: Record = { + prepass: prepassFrag, terrain: terrainFrag, atmosphere: atmosphereFrag, resolve: resolveFrag, @@ -25,10 +28,14 @@ const stages: Record = { /** * Fragment source for a stage: header, world defines, the part of CultMath the stage reaches, the - * shared block, the stage. Phacelle stays a separate file, added only where it is called. + * shared block, the distance bound (stages that trace), the stage. `defines` is whole `#define` + * lines (measurement flags) ahead of the world's. Phacelle stays a separate file, added only where + * it is called. */ -export function fragmentSource(stage: Stage): string { - const erosion = stage === 'terrain' && WORLD.phacelleGradBound > 0 ? phacelle : ''; - const math = trimGlsl(cultmath, [erosion, common, stages[stage]]); - return `${HEADER}${worldDefines()}\n${math}\n${erosion}\n${common}\n${stages[stage]}`; +export function fragmentSource(stage: Stage, defines = ''): string { + const solid = stage === 'terrain' || stage === 'prepass'; + const erosion = solid && WORLD.phacelleGradBound > 0 ? phacelle : ''; + const world = solid ? sdf : ''; + const math = trimGlsl(cultmath, [erosion, common, world, stages[stage]]); + return `${HEADER}${defines}${worldDefines()}\n${math}\n${erosion}\n${common}\n${world}\n${stages[stage]}`; } diff --git a/src/app/features/scene/shaders/prepass.frag.glsl b/src/app/features/scene/shaders/prepass.frag.glsl new file mode 100644 index 00000000..3fe840fc --- /dev/null +++ b/src/app/features/scene/shaders/prepass.frag.glsl @@ -0,0 +1,49 @@ +// Pass 0: a cone march at one ray per tile of pass-1 pixels. Writes the distance along the tile's +// centre ray that no ray in the tile can have hit before, so pass 1 starts there instead of at the +// camera. Every step is safe for every ray of the tile: the cone's radius at t is the widest any +// ray strays from the centre ray, and the centre's distance bound minus that radius is a free ball +// around each ray's own point. +uniform int u_maxSteps; +uniform vec2 u_tileHalf; // half a tile in uv: the footprint of this texel, as pass 1 samples it + +in vec2 v_uv; +layout(location = 0) out vec4 o_start; // r: start distance, W_FAR when the tile misses; g: steps spent + +void main() +{ + vec3 ro = u_camPos; + vec3 rd = camRay(v_uv); + + // Widest stray of any ray in the tile from the centre ray, per unit t. The rays are the unit + // directions through a rectangle on the view plane, whose sublevel sets about rd are convex, so + // the maximum sits at a corner. The margin is for float rounding. + float spread = 0.0; + for (int c = 0; c < 4; c++) + { + vec2 corner = vec2(c & 1, c >> 1) * 2.0 - 1.0; + spread = max(spread, length(camRay(v_uv + corner * u_tileHalf) - rd)); + } + spread *= 1.05; + + // Past tEnd every ray of the tile is above W_MAX_HEIGHT and still rising (or beyond W_FAR): + // none can hit anything. + float rise = rd.y - spread; + float tEnd = W_FAR; + if (rise > 0.0) + tEnd = min(W_FAR, max(0.0, (W_MAX_HEIGHT - ro.y) / rise)); + + float t = 0.0; + float leafness; + bool onTree; + int evals = 0; + for (int i = 0; i < u_maxSteps && t < tEnd; i++) + { + evals++; + // Pass 1's hit tolerance is kept clear, so a ray never starts inside its own hit radius. + float s = sceneDistance(ro + rd * t, leafness, onTree) - spread * t - (0.0006 * t + 0.01); + if (s <= 0.0) + break; + t += s; + } + o_start = vec4(t >= tEnd ? W_FAR : t, float(evals), 0.0, 0.0); +} diff --git a/src/app/features/scene/shaders/sdf.glsl b/src/app/features/scene/shaders/sdf.glsl new file mode 100644 index 00000000..95e96bc2 --- /dev/null +++ b/src/app/features/scene/shaders/sdf.glsl @@ -0,0 +1,95 @@ +// The solid world as a distance bound: terrain graph plus tree. Shared by the pass-1 trace and the +// cone prepass, so both step by the same bound. + +// ---- terrain ------------------------------------------------------------------------------- + +float terrainMask(vec2 xz) { return smoothstep(W_PLATEAU_IN, W_PLATEAU_OUT, length(xz)); } + +// Value only; cultmath_fbm_grad is the shipped fbm, a public value-only fbm is Libby's gap. +float terrainFbm(vec2 xz) { return cultmath_fbm_grad(vec3(xz * W_FREQ, 0.0), W_OCTAVES, W_LACUNARITY, W_GAIN).w; } + +#if W_ERODE_ENABLED +CultPhasor terrainPhacelle(vec2 xz) +{ + // A fixed stripe wave vector: ridges across the sheet, not slope-aligned erosion. + return cultmath_phacelle(vec3(xz * W_ERODE_FREQ, 0.0), W_ERODE_SIDE, 0.0, W_ERODE_NORM); +} +#endif + +// Conservative distance to the terrain graph. Above the envelope (fbm + erosion amplitude) the +// cheap envelope bound is exact enough to step by; only within it is the eroded field evaluated. +float terrainDistance(vec3 p) +{ + float m = terrainMask(p.xz); + // Flat plateau: the height is exactly 0, so neither noise is evaluated. Sky rays spend most + // of their steps here. + if (m <= 0.0) + return p.y * W_SLOPE_ENV_INV; + float hb = m * W_AMP * terrainFbm(p.xz); + float above = p.y - (hb + m * W_ERODE_AMP); + if (above > 0.0) + return above * W_SLOPE_ENV_INV; +#if W_ERODE_ENABLED + hb += m * W_ERODE_AMP * terrainPhacelle(p.xz).cos.w; +#endif + return (p.y - hb) * W_SLOPE_FULL_INV; +} + +vec3 terrainNormal(vec2 xz) +{ + float r = length(xz); + float m = smoothstep(W_PLATEAU_IN, W_PLATEAU_OUT, r); + float s = clamp((r - W_PLATEAU_IN) / (W_PLATEAU_OUT - W_PLATEAU_IN), 0.0, 1.0); + float dm = r > 0.0 ? 6.0 * s * (1.0 - s) / (W_PLATEAU_OUT - W_PLATEAU_IN) : 0.0; + vec2 dir = r > 0.0 ? xz / r : vec2(0.0); + vec4 fg = cultmath_fbm_grad(vec3(xz * W_FREQ, 0.0), W_OCTAVES, W_LACUNARITY, W_GAIN); + float f = fg.w; + vec2 g = m * W_AMP * W_FREQ * fg.xz + dm * W_AMP * f * dir; +#if W_ERODE_ENABLED + CultPhasor ph = terrainPhacelle(xz); + g += m * W_ERODE_AMP * W_ERODE_FREQ * ph.cos.xz + dm * W_ERODE_AMP * ph.cos.w * dir; +#endif + return normalize(vec3(-g.x, 1.0, -g.y)); +} + +// ---- world tree ---------------------------------------------------------------------------- + +float smin(float a, float b, float k) +{ + float h = max(k - abs(a - b), 0.0) / k; + return min(a, b) - h * h * k * 0.25; +} + +// Not an exact SDF: the flare, ellipsoid and displacement are all scaled to stay under 1-Lipschitz. +float treeDistance(vec3 p, out float leafness) +{ + vec3 c = vec3(0.0, 0.5 * (W_CANOPY_Y + W_CANOPY_R.y), 0.0); + float bound = length(p - c) - (W_CANOPY_Y + W_CANOPY_R.y); + leafness = 0.0; + if (bound > 8.0) + return bound; + + float ang = atan(p.z, p.x); + float y01 = clamp(p.y / W_TRUNK_H, 0.0, 1.0); + float radius = mix(W_TRUNK_R0, W_TRUNK_R1, y01) + 6.0 * exp(-p.y * 0.2) * (1.0 + 0.35 * sin(6.0 * ang + p.y * 0.15)); + float trunk = max(length(p.xz) - radius, p.y - W_TRUNK_H * 1.2) * 0.7; + + vec3 q = (p - vec3(0.0, W_CANOPY_Y, 0.0)) / W_CANOPY_R; + float disp = cultmath_snoise(p * 0.05 + vec3(0.0, u_time * 0.015, 0.0)); + float canopy = (length(q) - 1.0) * min(W_CANOPY_R.x, W_CANOPY_R.y) - 3.0 * disp; + canopy *= 0.45; // 1 / (1 + 3 * 0.05 * L) + + float d = smin(trunk, canopy, 6.0); + leafness = clamp(0.5 + (trunk - canopy) * 0.15, 0.0, 1.0); + return d; +} + +// ---- trace --------------------------------------------------------------------------------- + +float sceneDistance(vec3 p, out float leafness, out bool onTree) +{ + float dt = terrainDistance(p); + float dr = treeDistance(p, leafness); + onTree = dr < dt; + return min(dt, dr); +} diff --git a/src/app/features/scene/shaders/terrain.frag.glsl b/src/app/features/scene/shaders/terrain.frag.glsl index b8f2f718..5946cc4a 100644 --- a/src/app/features/scene/shaders/terrain.frag.glsl +++ b/src/app/features/scene/shaders/terrain.frag.glsl @@ -5,128 +5,49 @@ uniform vec3 u_rock; uniform vec3 u_bark; uniform vec3 u_leaf; uniform int u_maxSteps; +uniform sampler2D u_start; // prepass: r = distance no ray of this pixel's tile hits before; W_FAR = tile misses in vec2 v_uv; layout(location = 0) out vec4 o_color; // rgb lit, a coverage layout(location = 1) out float o_depth; // hit distance along the ray, W_FAR on a miss - -// ---- terrain ------------------------------------------------------------------------------- - -float terrainMask(vec2 xz) { return smoothstep(W_PLATEAU_IN, W_PLATEAU_OUT, length(xz)); } - -// Value only; cultmath_fbm_grad is the shipped fbm, a public value-only fbm is Libby's gap. -float terrainFbm(vec2 xz) { return cultmath_fbm_grad(vec3(xz * W_FREQ, 0.0), W_OCTAVES, W_LACUNARITY, W_GAIN).w; } - -#if W_ERODE_ENABLED -CultPhasor terrainPhacelle(vec2 xz) -{ - // A fixed stripe wave vector: ridges across the sheet, not slope-aligned erosion. - return cultmath_phacelle(vec3(xz * W_ERODE_FREQ, 0.0), W_ERODE_SIDE, 0.0, W_ERODE_NORM); -} +#ifdef TRACE_STATS +layout(location = 2) out float o_steps; // distance evaluations this ray spent, over 255 (measurement only) #endif -// Conservative distance to the terrain graph. Above the envelope (fbm + erosion amplitude) the -// cheap envelope bound is exact enough to step by; only within it is the eroded field evaluated. -float terrainDistance(vec3 p) -{ - float m = terrainMask(p.xz); - // Flat plateau: the height is exactly 0, so neither noise is evaluated. Sky rays spend most - // of their steps here. - if (m <= 0.0) - return p.y * W_SLOPE_ENV_INV; - float hb = m * W_AMP * terrainFbm(p.xz); - float above = p.y - (hb + m * W_ERODE_AMP); - if (above > 0.0) - return above * W_SLOPE_ENV_INV; -#if W_ERODE_ENABLED - hb += m * W_ERODE_AMP * terrainPhacelle(p.xz).cos.w; -#endif - return (p.y - hb) * W_SLOPE_FULL_INV; -} - -vec3 terrainNormal(vec2 xz) -{ - float r = length(xz); - float m = smoothstep(W_PLATEAU_IN, W_PLATEAU_OUT, r); - float s = clamp((r - W_PLATEAU_IN) / (W_PLATEAU_OUT - W_PLATEAU_IN), 0.0, 1.0); - float dm = r > 0.0 ? 6.0 * s * (1.0 - s) / (W_PLATEAU_OUT - W_PLATEAU_IN) : 0.0; - vec2 dir = r > 0.0 ? xz / r : vec2(0.0); - vec4 fg = cultmath_fbm_grad(vec3(xz * W_FREQ, 0.0), W_OCTAVES, W_LACUNARITY, W_GAIN); - float f = fg.w; - vec2 g = m * W_AMP * W_FREQ * fg.xz + dm * W_AMP * f * dir; -#if W_ERODE_ENABLED - CultPhasor ph = terrainPhacelle(xz); - g += m * W_ERODE_AMP * W_ERODE_FREQ * ph.cos.xz + dm * W_ERODE_AMP * ph.cos.w * dir; -#endif - return normalize(vec3(-g.x, 1.0, -g.y)); -} - -// ---- world tree ---------------------------------------------------------------------------- - -float smin(float a, float b, float k) -{ - float h = max(k - abs(a - b), 0.0) / k; - return min(a, b) - h * h * k * 0.25; -} - -// Not an exact SDF: the flare, ellipsoid and displacement are all scaled to stay under 1-Lipschitz. -float treeDistance(vec3 p, out float leafness) -{ - vec3 c = vec3(0.0, 0.5 * (W_CANOPY_Y + W_CANOPY_R.y), 0.0); - float bound = length(p - c) - (W_CANOPY_Y + W_CANOPY_R.y); - leafness = 0.0; - if (bound > 8.0) - return bound; - - float ang = atan(p.z, p.x); - float y01 = clamp(p.y / W_TRUNK_H, 0.0, 1.0); - float radius = mix(W_TRUNK_R0, W_TRUNK_R1, y01) + 6.0 * exp(-p.y * 0.2) * (1.0 + 0.35 * sin(6.0 * ang + p.y * 0.15)); - float trunk = max(length(p.xz) - radius, p.y - W_TRUNK_H * 1.2) * 0.7; - - vec3 q = (p - vec3(0.0, W_CANOPY_Y, 0.0)) / W_CANOPY_R; - float disp = cultmath_snoise(p * 0.05 + vec3(0.0, u_time * 0.015, 0.0)); - float canopy = (length(q) - 1.0) * min(W_CANOPY_R.x, W_CANOPY_R.y) - 3.0 * disp; - canopy *= 0.45; // 1 / (1 + 3 * 0.05 * L) - - float d = smin(trunk, canopy, 6.0); - leafness = clamp(0.5 + (trunk - canopy) * 0.15, 0.0, 1.0); - return d; -} - // ---- trace --------------------------------------------------------------------------------- -float sceneDistance(vec3 p, out float leafness, out bool onTree) -{ - float dt = terrainDistance(p); - float dr = treeDistance(p, leafness); - onTree = dr < dt; - return min(dt, dr); -} - void main() { vec3 ro = u_camPos; vec3 rd = camRay(v_uv); - float t = 0.0; - // Nothing is above W_MAX_HEIGHT: skip the empty air above it, or miss outright. + float t = texture(u_start, v_uv).r; + // Nothing is above W_MAX_HEIGHT: skip the empty air above it, or miss outright. A tile the + // prepass proved empty arrives as W_FAR. + bool missAtOnce = t >= W_FAR; if (ro.y > W_MAX_HEIGHT) { - if (rd.y >= 0.0) - { - o_color = vec4(0.0); - o_depth = W_FAR; - return; - } - t = (W_MAX_HEIGHT - ro.y) / rd.y; + missAtOnce = missAtOnce || rd.y >= 0.0; + t = max(t, (W_MAX_HEIGHT - ro.y) / rd.y); + } + if (missAtOnce) + { + o_color = vec4(0.0); + o_depth = W_FAR; +#ifdef TRACE_STATS + o_steps = 0.0; +#endif + return; } bool hit = false; bool onTree = false; float leafness = 0.0; int i = 0; + int evals = 0; for (; i < u_maxSteps; i++) { + evals++; vec3 p = ro + rd * t; float d = sceneDistance(p, leafness, onTree); if (d < 0.0006 * t + 0.01) @@ -145,6 +66,9 @@ void main() hit = true; sceneDistance(ro + rd * t, leafness, onTree); } +#ifdef TRACE_STATS + o_steps = float(evals) / 255.0; +#endif if (!hit) { From b055b2a5af2080a64b62a8642d4b0482f54d3714 Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 4 Nov 2026 18:26:23 +0000 Subject: [PATCH 063/206] harness: trace and diff modes, standalone demo page with steps/pixel readout trace: steps per pixel and pass ms at three fixed camera poses, PREPASS=0,1 arms. diff: one frozen frame with and without the prepass, against a 512-step render. bundle.cjs demo: full-window page with a prepass switch and the readout. Co-Authored-By: Claude Sonnet 5.5 --- harness/scene/README.md | 2 + harness/scene/bundle.cjs | 11 ++- harness/scene/demo.ts | 59 ++++++++++++++++ harness/scene/drive.cjs | 145 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 214 insertions(+), 3 deletions(-) create mode 100644 harness/scene/demo.ts diff --git a/harness/scene/README.md b/harness/scene/README.md index 382819ec..ef735d2e 100644 --- a/harness/scene/README.md +++ b/harness/scene/README.md @@ -8,6 +8,8 @@ Chromium. Not shipped, not run by CI. node drive.cjs shots # rest, mid pull-back and pulled-back screenshots node drive.cjs measure # GPU time per pass by timer query, two viewports, two levels node drive.cjs variants # the same with source patches (no erosion, no tree, value-only fbm) + node drive.cjs trace # mean trace steps per pixel and pass ms at three camera poses, PREPASS=0,1 arms + node drive.cjs diff # one frozen frame with and without the prepass, pixel diff and both PNGs node drive.cjs phacelle # the Phacelle gradient probe behind world.ts `phacelleGradBound` Needs `esbuild` and `playwright@1.48.2` on `NODE_PATH` and a Chromium. The first run was done on a diff --git a/harness/scene/bundle.cjs b/harness/scene/bundle.cjs index 62a47078..36e2307a 100644 --- a/harness/scene/bundle.cjs +++ b/harness/scene/bundle.cjs @@ -1,9 +1,10 @@ // Bundles entry.ts with the scene's ?raw GLSL imports into $OUT/bundle.js plus an index.html. -// usage: node bundle.cjs (esbuild from NODE_PATH) +// usage: node bundle.cjs [demo] (esbuild from NODE_PATH). `demo` bundles the standalone page. const esbuild = require('esbuild'); const fs = require('fs'); const path = require('path'); const out = process.argv[2]; +const demo = process.argv[3] === 'demo'; fs.mkdirSync(out, { recursive: true }); const rawPlugin = { name: 'raw', @@ -20,7 +21,7 @@ const rawPlugin = { }; esbuild .build({ - entryPoints: [path.join(__dirname, 'entry.ts')], + entryPoints: [path.join(__dirname, demo ? 'demo.ts' : 'entry.ts')], bundle: true, outfile: path.join(out, 'bundle.js'), plugins: [rawPlugin], @@ -30,6 +31,10 @@ esbuild .then(() => { fs.writeFileSync( path.join(out, 'index.html'), - '' + 'Ratatoskr scene: trace prepass' + + (demo + ? '
    ' + : '') + + '' ); }); diff --git a/harness/scene/demo.ts b/harness/scene/demo.ts new file mode 100644 index 00000000..507692d7 --- /dev/null +++ b/harness/scene/demo.ts @@ -0,0 +1,59 @@ +// Standalone page: the scene full-window with a readout of pass ms and trace steps per pixel, and +// a switch for the cone prepass. Not shipped. +import { createScene, Scene, SceneStats, TraceStats } from '../../src/app/features/scene/Scene'; +import { LEVELS } from '../../src/app/features/scene/budget'; + +const canvas = document.getElementById('c') as HTMLCanvasElement; +const out = document.getElementById('out') as HTMLPreElement; +const box = document.getElementById('prepass') as HTMLInputElement; +const pull = document.getElementById('pull') as HTMLInputElement; +const timerless = (() => { + const g = document.createElement('canvas').getContext('webgl2'); + return !g?.getExtension('EXT_disjoint_timer_query_webgl2'); +})(); + +let scene: Scene | null = null; +let stats: SceneStats | undefined; +let trace: TraceStats | undefined; + +function start() { + scene?.dispose(); + stats = trace = undefined; + // A fresh canvas: a context cannot change its attributes, and dispose leaves the old one bound. + const fresh = canvas.cloneNode() as HTMLCanvasElement; + canvas.replaceWith(fresh); + scene = createScene({ + canvas: fresh, + unthrottled: true, + fixedLevel: LEVELS[0], + fixedPull: +pull.value, + prepass: box.checked, + onStats: (s) => (stats = s), + onTraceStats: (t) => (trace = t), + }); + if (!scene) out.textContent = 'createScene returned null: no WebGL2 or float colour buffers'; +} + +setInterval(() => { + if (!scene) return; + const lines = [`prepass ${box.checked ? 'on' : 'off'} pull ${pull.value}`]; + if (stats) { + const ms = stats.passMs; + const total = Object.values(ms).reduce((a, b) => a + b, 0); + lines.push( + `gpu ms prepass ${ms.prepass.toFixed(2)} terrain ${ms.terrain.toFixed(2)} atmosphere ${ms.atmosphere.toFixed(2)} resolve ${ms.resolve.toFixed(2)} composite ${ms.composite.toFixed(2)} total ${total.toFixed(2)}`, + `solid ${stats.solidSize.join('x')}` + ); + } else if (timerless) { + lines.push('gpu ms: this browser has no EXT_disjoint_timer_query_webgl2'); + } + if (trace) + lines.push( + `steps/pixel ${trace.stepsPerPixel.toFixed(2)} (pass 1 ${trace.traceSteps.toFixed(2)}, prepass ${trace.prepassSteps.toFixed(1)} per tile)` + ); + out.textContent = lines.join('\n'); +}, 500); + +box.addEventListener('change', start); +pull.addEventListener('input', start); +start(); diff --git a/harness/scene/drive.cjs b/harness/scene/drive.cjs index 74c4a8b0..0f34191f 100644 --- a/harness/scene/drive.cjs +++ b/harness/scene/drive.cjs @@ -3,6 +3,7 @@ // Needs playwright from NODE_PATH and PLAYWRIGHT_BROWSERS_PATH. Prints one JSON report. const { chromium } = require('playwright'); const path = require('path'); +const fs = require('fs'); const [, , dir, mode, outdir = '.'] = process.argv; (async () => { @@ -15,6 +16,7 @@ const [, , dir, mode, outdir = '.'] = process.argv; ], }); const page = await b.newPage({ viewport: { width: 960, height: 540 } }); + page.on('pageerror', (e) => process.stderr.write(`[pageerror] ${e}\n`)); page.on('console', (m) => process.stderr.write(`[page] ${m.text()}\n`)); await page.goto('file://' + path.resolve(dir, 'index.html')); const report = { mode }; @@ -156,6 +158,149 @@ const [, , dir, mode, outdir = '.'] = process.argv; } } + if (mode === 'trace') { + // Steps per pixel and pass ms at fixed camera poses, one scene per run. PREPASS=0,1 picks the arms. + const arms = (process.env.PREPASS || '1').split(',').map((x) => x === '1'); + report.runs = []; + for (const [w, h] of process.env.PORTRAIT === '0' + ? [[960, 540]] + : [ + [960, 540], + [540, 1200], + ]) { + for (const pull of (process.env.PULLS || '0,0.5,1').split(',').map(Number)) { + for (const prepass of arms) { + await page.setViewportSize({ width: w, height: h }); + const r = await page.evaluate( + ({ pull, prepass, FRAMES }) => + new Promise((res) => { + const rows = []; + const trace = []; + let done = false; + const finish = () => { + if (done || rows.length < FRAMES || trace.length < 4) return; + done = true; + s.dispose(); + const keep = rows.slice(FRAMES >> 2); + const med = (xs) => xs.sort((a, b) => a - b)[xs.length >> 1]; + const tk = trace.slice(1); + const mean = (k) => tk.reduce((a, t) => a + t[k], 0) / tk.length; + res({ + solid: keep[0].solidSize, + readbacks: tk.length, + traceSteps: +mean('traceSteps').toFixed(2), + prepassSteps: +mean('prepassSteps').toFixed(2), + stepsPerPixel: +mean('stepsPerPixel').toFixed(2), + medianMs: Object.fromEntries( + Object.keys(keep[0].passMs).map((k) => [k, +med(keep.map((x) => x.passMs[k])).toFixed(2)]) + ), + }); + }; + const s = window.__scene.createScene({ + canvas: document.getElementById('c'), + unthrottled: true, + fixedLevel: window.__scene.LEVELS[0], + fixedPull: pull, + prepass, + onTraceStats: (t) => { + trace.push(t); + finish(); + }, + onStats: (st) => { + rows.push(st); + finish(); + }, + }); + if (!s) res({ error: 'createScene returned null' }); + }), + { pull, prepass, FRAMES: +(process.env.FRAMES || 64) } + ); + report.runs.push({ viewport: [w, h], pull, prepass, ...r }); + } + } + } + } + + if (mode === 'diff') { + // Same frozen frame (reduced motion: time 0, 32 frames) with and without the prepass, compared + // pixel by pixel. The prepass is meant to change cost, not the picture. + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.setViewportSize({ width: 640, height: 360 }); + const shoot = async (pull, prepass, steps) => { + await page.evaluate( + ({ pull, prepass, steps }) => { + // Reduced motion bounds the scene to 32 frames; each asks for the next with rAF, so the + // count stops moving when it is done. Timer-query callbacks lag too far to count on. + window.__raf = 0; + if (!window.__rafPatched) { + window.__rafPatched = true; + const raf = window.requestAnimationFrame.bind(window); + window.requestAnimationFrame = (cb) => (window.__raf++, raf(cb)); + } + window.__d = window.__scene.createScene({ + canvas: document.getElementById('c'), + fixedLevel: { ...window.__scene.LEVELS[0], traceSteps: steps }, + fixedPull: pull, + prepass, + }); + }, + { pull, prepass, steps } + ); + for (let last = -1, still = 0; still < 3; await page.waitForTimeout(1000)) { + const n = await page.evaluate(() => window.__raf); + still = n === last ? still + 1 : 0; + last = n; + } + const png = await page.screenshot(); + await page.evaluate(() => window.__d.dispose()); + return png.toString('base64'); + }; + // The step cap makes a grazing ray give up and count as a hit, so the baseline is compared + // with a 512-step render of the same frame as well: whichever is closer to it is the more + // faithful one. + const decode = (x) => + page.evaluate( + (x) => + new Promise((r) => { + const i = new Image(); + i.onload = () => { + const c = document.createElement('canvas'); + c.width = i.width; + c.height = i.height; + const g = c.getContext('2d'); + g.drawImage(i, 0, 0); + r(Array.from(g.getImageData(0, 0, i.width, i.height).data)); + }; + i.src = 'data:image/png;base64,' + x; + }), + x + ); + const compare = (da, db) => { + let max = 0, over = 0, sum = 0; + const px = da.length / 4; + for (let i = 0; i < da.length; i += 4) { + let m = 0; + for (let k = 0; k < 3; k++) m = Math.max(m, Math.abs(da[i + k] - db[i + k])); + sum += m; + if (m > max) max = m; + if (m > 2) over++; + } + return { maxChannelDiff: max, pixelsOver2of255: over, ofPixels: px, meanMaxChannelDiff: +(sum / px).toFixed(3) }; + }; + report.diffs = []; + for (const pull of (process.env.PULLS || '0,0.5,1').split(',').map(Number)) { + const png = { + off: await shoot(pull, false, 96), + on: await shoot(pull, true, 96), + ref: await shoot(pull, false, 512), + off2: await shoot(pull, false, 96), + }; + for (const k of Object.keys(png)) fs.writeFileSync(path.join(outdir, `diff-${k}-${pull}.png`), Buffer.from(png[k], 'base64')); + const [off, on, ref, off2] = [await decode(png.off), await decode(png.on), await decode(png.ref), await decode(png.off2)]; + report.diffs.push({ pull, offVsOffAgain: compare(off, off2), offVsOn: compare(off, on), offVsRef512: compare(off, ref), onVsRef512: compare(on, ref) }); + } + } + console.log(JSON.stringify(report, null, 1)); await b.close(); })().catch((e) => { From 12469671c9870f801f04ada621768b6207aa6fb0 Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 4 Nov 2026 18:35:11 +0000 Subject: [PATCH 064/206] scene: pack step bytes tightly; keep the hit tolerance clear at the far end of a prepass step Soul pass on 87c9ca60: the R8 steps read used the default PACK_ALIGNMENT of 4, so at widths not divisible by 4 the buffer was too small (readPixels fails) and padding would be summed. Portrait runs read 0 steps/pixel. The prepass step now divides by 1.0006 so pass 1's tolerance, which grows with t, stays clear at the end of the step. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/Scene.test.ts | 14 ++++++++++++++ src/app/features/scene/Scene.ts | 2 ++ src/app/features/scene/shaders/prepass.frag.glsl | 5 +++-- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/app/features/scene/Scene.test.ts b/src/app/features/scene/Scene.test.ts index c90d1bba..c403018a 100644 --- a/src/app/features/scene/Scene.test.ts +++ b/src/app/features/scene/Scene.test.ts @@ -162,6 +162,20 @@ describe('cone prepass', () => { expect(fragmentSource('terrain', '#define TRACE_STATS 1\n')).toContain('#define TRACE_STATS 1'); }); + it('reads the step bytes tightly packed, whatever the width', () => { + rafQueue.length = 0; + const { gl, calls } = fakeGl(); + createScene({ canvas: asCanvas(fakeCanvas(gl)), onTraceStats: () => {} }); + runFrame(1000); + // fake constants are all 1: PACK_ALIGNMENT is 1, so a restore to 4 is the only other call + const packs = calls.pixelStorei.mock.calls.map((c) => c[1]); + expect(packs[0]).toBe(1); + expect(packs).toContain(4); + expect(calls.readPixels.mock.invocationCallOrder[0]).toBeGreaterThan( + calls.pixelStorei.mock.invocationCallOrder[0] + ); + }); + it('shares one distance bound between pass 1 and the prepass', () => { const bound = (stage: 'terrain' | 'prepass') => fragmentSource(stage).match(/float sceneDistance\([^)]*\)\s*\{[^}]*\}/)?.[0]; diff --git a/src/app/features/scene/Scene.ts b/src/app/features/scene/Scene.ts index 9707a56b..740c5c0c 100644 --- a/src/app/features/scene/Scene.ts +++ b/src/app/features/scene/Scene.ts @@ -254,6 +254,7 @@ export function createScene(opts: SceneOptions): Scene | null { }; const startReadback = () => { if (!steps || readback) return; + gl.pixelStorei(gl.PACK_ALIGNMENT, 1); // R8 rows are not 4-byte multiples at every width gl.bindBuffer(gl.PIXEL_PACK_BUFFER, packSteps); gl.bufferData(gl.PIXEL_PACK_BUFFER, steps.w * steps.h, gl.STREAM_READ); gl.bindFramebuffer(gl.FRAMEBUFFER, fbos.solid); @@ -268,6 +269,7 @@ export function createScene(opts: SceneOptions): Scene | null { gl.readPixels(0, 0, startTex.w, startTex.h, gl.RGBA, gl.FLOAT, 0); } gl.bindBuffer(gl.PIXEL_PACK_BUFFER, null); + gl.pixelStorei(gl.PACK_ALIGNMENT, 4); readback = { fence: gl.fenceSync(gl.SYNC_GPU_COMMANDS_COMPLETE, 0)!, pixels: steps.w * steps.h, diff --git a/src/app/features/scene/shaders/prepass.frag.glsl b/src/app/features/scene/shaders/prepass.frag.glsl index 3fe840fc..691cfba9 100644 --- a/src/app/features/scene/shaders/prepass.frag.glsl +++ b/src/app/features/scene/shaders/prepass.frag.glsl @@ -39,8 +39,9 @@ void main() for (int i = 0; i < u_maxSteps && t < tEnd; i++) { evals++; - // Pass 1's hit tolerance is kept clear, so a ray never starts inside its own hit radius. - float s = sceneDistance(ro + rd * t, leafness, onTree) - spread * t - (0.0006 * t + 0.01); + // Pass 1's hit tolerance, 0.0006 t + 0.01, is kept clear at the far end of the step too, so + // no ray of the tile starts inside its own hit radius. + float s = (sceneDistance(ro + rd * t, leafness, onTree) - spread * t - (0.0006 * t + 0.01)) / 1.0006; if (s <= 0.0) break; t += s; From 50ffd905602fcbbeae564caf31978e6a4912c208 Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 4 Nov 2026 18:47:12 +0000 Subject: [PATCH 065/206] harness: demo page keeps hold of the canvas it swaps in, so the prepass switch works twice Co-Authored-By: Claude Sonnet 5.5 --- harness/scene/demo.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/harness/scene/demo.ts b/harness/scene/demo.ts index 507692d7..ba88e507 100644 --- a/harness/scene/demo.ts +++ b/harness/scene/demo.ts @@ -3,7 +3,7 @@ import { createScene, Scene, SceneStats, TraceStats } from '../../src/app/features/scene/Scene'; import { LEVELS } from '../../src/app/features/scene/budget'; -const canvas = document.getElementById('c') as HTMLCanvasElement; +let canvas = document.getElementById('c') as HTMLCanvasElement; const out = document.getElementById('out') as HTMLPreElement; const box = document.getElementById('prepass') as HTMLInputElement; const pull = document.getElementById('pull') as HTMLInputElement; @@ -22,6 +22,7 @@ function start() { // A fresh canvas: a context cannot change its attributes, and dispose leaves the old one bound. const fresh = canvas.cloneNode() as HTMLCanvasElement; canvas.replaceWith(fresh); + canvas = fresh; scene = createScene({ canvas: fresh, unthrottled: true, From 7e0586643757bd798091bdfdac5a419293b2566d Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 4 Nov 2026 20:39:22 +0000 Subject: [PATCH 066/206] scene: half-float prepass start target where EXT_color_buffer_float is missing; say why createScene is null Phone Chrome without EXT_color_buffer_float returned null with one message. Rendering to RGBA16F only needs EXT_color_buffer_half_float, and every other target is already a half, so only the prepass start (RGBA32F) needed a fallback. In half mode the prepass writes t*(1-2^-10) - 2^-15, which stays below t after the half rounds to nearest, so pass 1 still starts at or before the true distance. W_FAR stays exact. onUnavailable names no-webgl2 vs neither float extension; Scene.startFormat and options.halfStart (force, for tests and the harness). The standalone page shows the reason, renderer, vendor, UA and extension list. Co-Authored-By: Claude Sonnet 5.5 --- harness/scene/bundle.cjs | 2 +- harness/scene/demo.ts | 48 +++++++++- src/app/features/scene/Scene.test.ts | 91 ++++++++++++++++++- src/app/features/scene/Scene.ts | 57 ++++++++++-- .../features/scene/shaders/prepass.frag.glsl | 11 ++- 5 files changed, 190 insertions(+), 19 deletions(-) diff --git a/harness/scene/bundle.cjs b/harness/scene/bundle.cjs index 36e2307a..027a2006 100644 --- a/harness/scene/bundle.cjs +++ b/harness/scene/bundle.cjs @@ -33,7 +33,7 @@ esbuild path.join(out, 'index.html'), 'Ratatoskr scene: trace prepass' + (demo - ? '
    ' + ? '
    device
    ' : '') + '' ); diff --git a/harness/scene/demo.ts b/harness/scene/demo.ts index ba88e507..40424ff5 100644 --- a/harness/scene/demo.ts +++ b/harness/scene/demo.ts @@ -1,17 +1,55 @@ // Standalone page: the scene full-window with a readout of pass ms and trace steps per pixel, and // a switch for the cone prepass. Not shipped. -import { createScene, Scene, SceneStats, TraceStats } from '../../src/app/features/scene/Scene'; +import { + createScene, + Scene, + SceneStats, + SceneUnavailable, + TraceStats, +} from '../../src/app/features/scene/Scene'; import { LEVELS } from '../../src/app/features/scene/budget'; let canvas = document.getElementById('c') as HTMLCanvasElement; const out = document.getElementById('out') as HTMLPreElement; const box = document.getElementById('prepass') as HTMLInputElement; const pull = document.getElementById('pull') as HTMLInputElement; +const half = document.getElementById('half') as HTMLInputElement; +const dev = document.getElementById('dev') as HTMLDetailsElement; +const info = document.getElementById('info') as HTMLPreElement; const timerless = (() => { const g = document.createElement('canvas').getContext('webgl2'); return !g?.getExtension('EXT_disjoint_timer_query_webgl2'); })(); +const WHY: Record = { + 'no-webgl2': 'this browser gave no WebGL2 context', + 'no-float-or-half-float-colour-buffer': + 'WebGL2 is there, but neither EXT_color_buffer_float nor EXT_color_buffer_half_float is', +}; + +// What the browser says about itself, read from a scratch context so it shows even when the scene +// could not start. A canvas that never got a WebGL2 context is asked for WebGL1 to name the GPU. +function describeDevice(): string { + const c = document.createElement('canvas'); + const gl2 = c.getContext('webgl2'); + const gl = gl2 ?? (document.createElement('canvas').getContext('webgl') as WebGLRenderingContext | null); + const lines = [`webgl2 context: ${gl2 ? 'yes' : 'NO'}`]; + if (!gl) return [...lines, 'webgl1 context: NO'].join('\n'); + const dbg = gl.getExtension('WEBGL_debug_renderer_info'); + lines.push( + `renderer: ${gl.getParameter(dbg ? dbg.UNMASKED_RENDERER_WEBGL : gl.RENDERER)}`, + `vendor: ${gl.getParameter(dbg ? dbg.UNMASKED_VENDOR_WEBGL : gl.VENDOR)}`, + `version: ${gl.getParameter(gl.VERSION)}`, + `user agent: ${navigator.userAgent}` + ); + const ext = gl.getSupportedExtensions() ?? []; + for (const n of ['EXT_color_buffer_float', 'EXT_color_buffer_half_float', 'OES_texture_float_linear', 'EXT_disjoint_timer_query_webgl2']) + lines.push(`${n}: ${ext.includes(n) ? 'yes' : 'NO'}`); + lines.push(`extensions (${ext.length}): ${ext.join(' ')}`); + return lines.join('\n'); +} +info.textContent = describeDevice(); + let scene: Scene | null = null; let stats: SceneStats | undefined; let trace: TraceStats | undefined; @@ -29,15 +67,18 @@ function start() { fixedLevel: LEVELS[0], fixedPull: +pull.value, prepass: box.checked, + halfStart: half.checked, + onUnavailable: (r) => (out.textContent = `createScene returned null: ${WHY[r]}`), + onCompileFail: (e) => (out.textContent = `createScene returned null: ${e.message}`), onStats: (s) => (stats = s), onTraceStats: (t) => (trace = t), }); - if (!scene) out.textContent = 'createScene returned null: no WebGL2 or float colour buffers'; + dev.open = !scene; } setInterval(() => { if (!scene) return; - const lines = [`prepass ${box.checked ? 'on' : 'off'} pull ${pull.value}`]; + const lines = [`prepass ${box.checked ? 'on' : 'off'} start target ${scene.startFormat} pull ${pull.value}`]; if (stats) { const ms = stats.passMs; const total = Object.values(ms).reduce((a, b) => a + b, 0); @@ -56,5 +97,6 @@ setInterval(() => { }, 500); box.addEventListener('change', start); +half.addEventListener('change', start); pull.addEventListener('input', start); start(); diff --git a/src/app/features/scene/Scene.test.ts b/src/app/features/scene/Scene.test.ts index c403018a..6493ad88 100644 --- a/src/app/features/scene/Scene.test.ts +++ b/src/app/features/scene/Scene.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; -import { createScene, ShaderCompileError } from './Scene'; +import { createScene, ShaderCompileError, HALF_START_BIAS, HALF_START_SCALE } from './Scene'; +import { WORLD } from './world'; import { LEVELS } from './budget'; import { fragmentSource } from './shaderSource'; import { startBackgroundScene } from './index'; @@ -7,16 +8,21 @@ import { startBackgroundScene } from './index'; type Fn = Mock; /** A GL context where every call is a spy and every constant is 1. Compiles succeed until `failFrom`. */ -function fakeGl(failFrom = Infinity) { +function fakeGl(failFrom = Infinity, missing: string[] = []) { let compiles = 0; const calls: Record = {}; - const spy = (name: string, impl: () => unknown = () => ({})): Fn => { + const spy = (name: string, impl: (...a: any[]) => unknown = () => ({})): Fn => { if (!calls[name]) calls[name] = vi.fn(impl); return calls[name]; }; const gl = new Proxy({} as Record, { get(_, prop: string) { + // the two start-target formats must be told apart; every other constant is 1 + if (prop === 'RGBA32F') return 32; + if (prop === 'RGBA16F') return 16; if (/^[A-Z][A-Z0-9_]*$/.test(prop)) return 1; + if (prop === 'getExtension') + return spy(prop, (n: string) => (missing.includes(n) ? null : {})); if (prop === 'compileShader') { return spy(prop, () => { compiles += 1; @@ -185,6 +191,85 @@ describe('cone prepass', () => { }); }); +describe('device without EXT_color_buffer_float', () => { + const FLOAT = 'EXT_color_buffer_float'; + const HALF = 'EXT_color_buffer_half_float'; + const build = (missing: string[], extra: Partial[0]> = {}) => { + rafQueue.length = 0; + const { gl, calls } = fakeGl(Infinity, missing); + const onUnavailable = vi.fn(); + const scene = createScene({ canvas: asCanvas(fakeCanvas(gl)), onUnavailable, ...extra }); + if (scene) runFrame(1000); // targets are allocated on the first frame + const formats = calls.texStorage2D?.mock.calls.map((c) => c[2]) ?? []; + const sources = (calls.shaderSource?.mock.calls ?? []).map((c) => c[1] as string); + return { + scene, + onUnavailable, + formats, + prepassSource: sources.find((x) => x.includes('o_start')), + }; + }; + + it('names why the scene is unavailable: no WebGL2, or neither float nor half-float colour buffers', () => { + const none = vi.fn(); + createScene({ canvas: asCanvas(fakeCanvas(null)), onUnavailable: none }); + expect(none).toHaveBeenCalledTimes(1); + expect(none).toHaveBeenCalledWith('no-webgl2'); + const r = build([FLOAT, HALF]); + expect(r.scene).toBeNull(); + expect(r.onUnavailable).toHaveBeenCalledTimes(1); + expect(r.onUnavailable).toHaveBeenCalledWith('no-float-or-half-float-colour-buffer'); + expect(build([]).onUnavailable).not.toHaveBeenCalled(); + }); + + it('with only half-float colour buffers, starts the prepass in an RGBA16F target with the rounding-down write', () => { + const r = build([FLOAT]); + expect(r.scene?.startFormat).toBe('RGBA16F'); + expect(r.formats).not.toContain(32); + expect(r.prepassSource).toContain('#define START_HALF 1'); + expect(r.prepassSource).toContain(`#define START_HALF_SCALE ${HALF_START_SCALE}`); + }); + + it('keeps RGBA32F and the plain write where it is renderable, unless asked for half', () => { + const wide = build([HALF]); + expect(wide.scene?.startFormat).toBe('RGBA32F'); + expect(wide.formats).toContain(32); + expect(wide.prepassSource).not.toContain('#define START_HALF'); + expect(build([], { halfStart: true }).scene?.startFormat).toBe('RGBA16F'); + }); + + // The shader writes max(0, t*SCALE - BIAS) into a half, which rounds to nearest (even); this is + // that rounding. The GPU's rounding of a half write is the unknown a phone must confirm. + const toHalf = (x: number) => { + if (x === 0) return 0; + let e = Math.max(Math.floor(Math.log2(x)), -14); + if (2 ** e > x) e -= 1; + const ulp = 2 ** (e - 10); + const q = x / ulp; + let r = Math.floor(q); + const d = q - r; + if (d > 0.5 || (d === 0.5 && r % 2 === 1)) r += 1; + return r * ulp; + }; + const stored = (t: number) => toHalf(Math.max(0, t * HALF_START_SCALE - HALF_START_BIAS)); + + it('stores a start distance never past the true one, and not much short of it', () => { + let seed = 7; + const rand = () => (seed = (seed * 16807) % 2147483647) / 2147483647; + const ts = [0, 1e-6, 1e-4, 6.1e-5, 0.01, 1, 1023.9, 1024, 2047.9]; + for (let i = 0; i < 20000; i++) ts.push(rand() * WORLD.far, rand() * rand() * 4); + for (let t = 0; t <= WORLD.far; t += 0.37) ts.push(t); + for (const t of ts) { + expect(stored(t)).toBeLessThanOrEqual(t); + expect(t - stored(t)).toBeLessThanOrEqual(t * 0.0021 + 6e-5); + } + }); + + it('holds the miss sentinel W_FAR exactly, so pass 1 still skips a tile the prepass proved empty', () => { + expect(toHalf(WORLD.far)).toBe(WORLD.far); + }); +}); + describe('shader compile failure', () => { it('reports through onCompileFail, returns null, and frees the programs already linked', () => { const { gl, calls } = fakeGl(4); // terrain compiles both shaders; atmosphere's fragment is the 4th and fails diff --git a/src/app/features/scene/Scene.ts b/src/app/features/scene/Scene.ts index 740c5c0c..70580ab8 100644 --- a/src/app/features/scene/Scene.ts +++ b/src/app/features/scene/Scene.ts @@ -42,6 +42,23 @@ export class ShaderCompileError extends Error { } } +/** + * Why createScene returned null for want of a device feature. Rendering to half floats needs + * EXT_color_buffer_float or EXT_color_buffer_half_float; the prepass start target is RGBA32F with + * the first and RGBA16F with only the second. + */ +export type SceneUnavailable = 'no-webgl2' | 'no-float-or-half-float-colour-buffer'; + +/** + * The RGBA16F prepass start is written as `t * HALF_START_SCALE - HALF_START_BIAS`. A half rounds + * to nearest with relative error at most 2^-11, so shaving 2^-10 of t leaves the stored value + * below t, and the bias covers the absolute error where halves go subnormal. The shader reads both + * through #defines, so this is the one copy. + */ +export const HALF_START_SCALE = 1 - 1 / 1024; +export const HALF_START_BIAS = 1 / 32768; +const HALF_START_DEFINES = `#define START_HALF 1\n#define START_HALF_SCALE ${HALF_START_SCALE}\n#define START_HALF_BIAS ${HALF_START_BIAS}\n`; + /** Ladder level a 'low' scene starts at: the cost of a weak device's first 45 frames is never paid at full. */ const LOW_START_LEVEL = 2; /** The 'still' tier draws one frame at this level and stops. */ @@ -54,6 +71,10 @@ export interface SceneOptions { * the adaptive ladder low. 'full' starts it at the top. Default 'full'. */ tier?: SceneTier; + /** createScene is about to return null because the device lacks a feature, and which one. */ + onUnavailable?: (reason: SceneUnavailable) => void; + /** Force the RGBA16F prepass start target even where RGBA32F is renderable (measurement, tests). */ + halfStart?: boolean; /** A shader failed to compile or link. createScene frees what it built and returns null. */ onCompileFail?: (e: ShaderCompileError) => void; /** The WebGL context was lost. The scene stops for good; the host keeps its ground. */ @@ -79,6 +100,8 @@ export interface SceneOptions { } export interface Scene { + /** Texture format of the prepass start target. */ + readonly startFormat: 'RGBA32F' | 'RGBA16F'; /** Start the camera pull-back from the tree. */ login(): void; dispose(): void; @@ -146,7 +169,7 @@ function halton(i: number, base: number): number { return r; } -/** Null when WebGL2 or float colour buffers are missing; the host keeps its CSS ground. */ +/** Null when WebGL2 or a float colour buffer is missing (`onUnavailable` says which); the host keeps its CSS ground. */ export function createScene(opts: SceneOptions): Scene | null { const { canvas } = opts; const tier = opts.tier ?? 'full'; @@ -158,7 +181,17 @@ export function createScene(opts: SceneOptions): Scene | null { stencil: false, powerPreference: 'low-power', }); - if (!gl || !gl.getExtension('EXT_color_buffer_float')) return null; + if (!gl) { + opts.onUnavailable?.('no-webgl2'); + return null; + } + const wide = !!gl.getExtension('EXT_color_buffer_float'); + if (!wide && !gl.getExtension('EXT_color_buffer_half_float')) { + opts.onUnavailable?.('no-float-or-half-float-colour-buffer'); + return null; + } + // Every other target is a half; only the prepass start can ask for 32 bits. + const halfStart = opts.halfStart || !wide; gl.getExtension('OES_texture_float_linear'); const timer = gl.getExtension('EXT_disjoint_timer_query_webgl2') as { TIME_ELAPSED_EXT: number; @@ -170,12 +203,13 @@ export function createScene(opts: SceneOptions): Scene | null { const programs = {} as Record; try { passes.forEach((p) => { - programs[p] = linkStage( - gl, - p, - opts.patchSource, - p === 'terrain' && opts.onTraceStats ? '#define TRACE_STATS 1\n' : undefined - ); + const defines = + p === 'terrain' && opts.onTraceStats + ? '#define TRACE_STATS 1\n' + : p === 'prepass' && halfStart + ? HALF_START_DEFINES + : undefined; + programs[p] = linkStage(gl, p, opts.patchSource, defines); }); } catch (e) { passes.forEach((p) => { @@ -196,6 +230,8 @@ export function createScene(opts: SceneOptions): Scene | null { const vao = gl.createVertexArray(); gl.bindVertexArray(vao); + const startFormat = halfStart ? gl.RGBA16F : gl.RGBA32F; + const makeTarget = (w: number, h: number, format: number, filter: number): Target => { const tex = gl.createTexture()!; gl.bindTexture(gl.TEXTURE_2D, tex); @@ -329,8 +365,8 @@ export function createScene(opts: SceneOptions): Scene | null { depth = makeTarget(sw, sh, gl.R16F, gl.NEAREST); // Without the prepass pass 1 reads a 1x1 zero start: every ray from the camera, as before. startTex = usePrepass - ? makeTarget(Math.ceil(sw / TILE), Math.ceil(sh / TILE), gl.RGBA32F, gl.NEAREST) - : makeTarget(1, 1, gl.RGBA32F, gl.NEAREST); + ? makeTarget(Math.ceil(sw / TILE), Math.ceil(sh / TILE), startFormat, gl.NEAREST) + : makeTarget(1, 1, startFormat, gl.NEAREST); steps = opts.onTraceStats ? makeTarget(sw, sh, gl.R8, gl.NEAREST) : undefined; atm = makeTarget(aw, ah, gl.RGBA16F, gl.LINEAR); hist = [makeTarget(aw, ah, gl.RGBA16F, gl.LINEAR), makeTarget(aw, ah, gl.RGBA16F, gl.LINEAR)]; @@ -581,6 +617,7 @@ export function createScene(opts: SceneOptions): Scene | null { wake(); return { + startFormat: halfStart ? 'RGBA16F' : 'RGBA32F', login() { if (still || disposed) return; // a still has no pull-back loginAt = performance.now(); diff --git a/src/app/features/scene/shaders/prepass.frag.glsl b/src/app/features/scene/shaders/prepass.frag.glsl index 691cfba9..4ca21a2c 100644 --- a/src/app/features/scene/shaders/prepass.frag.glsl +++ b/src/app/features/scene/shaders/prepass.frag.glsl @@ -7,7 +7,7 @@ uniform int u_maxSteps; uniform vec2 u_tileHalf; // half a tile in uv: the footprint of this texel, as pass 1 samples it in vec2 v_uv; -layout(location = 0) out vec4 o_start; // r: start distance, W_FAR when the tile misses; g: steps spent +layout(location = 0) out vec4 o_start; // r: start distance, W_FAR when the tile misses; g: steps spent (a half holds both) void main() { @@ -46,5 +46,12 @@ void main() break; t += s; } - o_start = vec4(t >= tEnd ? W_FAR : t, float(evals), 0.0, 0.0); + float start = t; +#ifdef START_HALF + // The target is RGBA16F: the write rounds to a half, and the round may go up. Any start past + // the true one is a ray that could skip a surface, so shave more than half a ulp off first + // (START_HALF_SCALE, START_HALF_BIAS: Scene.ts). W_FAR stays exact, an integer a half holds. + start = max(0.0, t * START_HALF_SCALE - START_HALF_BIAS); +#endif + o_start = vec4(t >= tEnd ? W_FAR : start, float(evals), 0.0, 0.0); } From 293885089a5af6790a3e1a69e8daf2d9181e17ef Mon Sep 17 00:00:00 2001 From: Nibu Date: Wed, 4 Nov 2026 20:39:58 +0000 Subject: [PATCH 067/206] scene README: createScene needs either float colour-buffer extension Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/app/features/scene/README.md b/src/app/features/scene/README.md index a189b803..ad4c13b1 100644 --- a/src/app/features/scene/README.md +++ b/src/app/features/scene/README.md @@ -17,8 +17,9 @@ callbacks and the creature sizing rule. Nothing has drawn a creature. demand, so it stays out of the main chunk; if that chunk fails to load the error goes to `onCompileFail` and the door returns null, it does not throw. - `Scene.ts` `createScene({ canvas, tier, onCompileFail, onContextLost, ... })` returns - `{ login, dispose }`, or `null` (keep the CSS ground then) when the tier is `off`, WebGL2 or - `EXT_color_buffer_float` is missing, or a shader fails to compile (`onCompileFail` gets the + `{ login, dispose }`, or `null` (keep the CSS ground then) when the tier is `off`, WebGL2 is missing, neither + `EXT_color_buffer_float` nor `EXT_color_buffer_half_float` exists (`onUnavailable` says which; with + only the half extension the prepass start target is RGBA16F, written rounded down), or a shader fails to compile (`onCompileFail` gets the `ShaderCompileError`; programs already linked are freed). `still` is a live rendered frame, not a baked image: it runs the full pipeline once at the top level and stops; `low` starts the ladder at level 2; `full` at level 0. A lost WebGL context stops the scene for good and calls `onContextLost`; nothing rebuilds it, the host decides what to From 56f360233a84e226ca16ec2ba6784016f9b63156 Mon Sep 17 00:00:00 2001 From: Void Date: Wed, 4 Nov 2026 22:08:22 +0000 Subject: [PATCH 068/206] composer: spellcheck setting, off by default on touch devices The composer's Editable set no spellCheck/autoCorrect/autoCapitalize/inputMode, so the browser default (spellcheck on) applied. On Gboard that draws the red underline and swaps the keyboard for a correction modal when the caret touches a flagged word. Add 'Spellcheck in composer' (Settings > General > Editor), defaulting to off when the primary pointer is coarse and on otherwise. Off also sets autoCorrect=off, since spellCheck alone leaves the suggestion UI in place. Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/editor/Editor.tsx | 5 ++++ src/app/components/editor/spellcheck.test.ts | 27 +++++++++++++++++++ src/app/components/editor/spellcheck.ts | 11 ++++++++ src/app/features/settings/general/General.tsx | 13 +++++++++ src/app/state/settings.ts | 3 +++ 5 files changed, 59 insertions(+) create mode 100644 src/app/components/editor/spellcheck.test.ts create mode 100644 src/app/components/editor/spellcheck.ts diff --git a/src/app/components/editor/Editor.tsx b/src/app/components/editor/Editor.tsx index bd848dd5..b7e6d2fe 100644 --- a/src/app/components/editor/Editor.tsx +++ b/src/app/components/editor/Editor.tsx @@ -23,6 +23,9 @@ import { RenderElement, RenderLeaf } from './Elements'; import { CustomElement } from './slate'; import * as css from './Editor.css'; import { toggleKeyboardShortcut } from './keyboard'; +import { useSetting } from '../../state/hooks/settings'; +import { settingsAtom } from '../../state/settings'; +import { composerSpellcheckAttributes } from './spellcheck'; const initialValue: CustomElement[] = [ { @@ -90,6 +93,7 @@ export const CustomEditor = forwardRef( }, ref ) => { + const [composerSpellcheck] = useSetting(settingsAtom, 'composerSpellcheck'); const renderElement = useCallback( (props: RenderElementProps) => , [] @@ -146,6 +150,7 @@ export const CustomEditor = forwardRef( onKeyDown={handleKeydown} onKeyUp={onKeyUp} onPaste={onPaste} + {...composerSpellcheckAttributes(composerSpellcheck)} /> {after && ( diff --git a/src/app/components/editor/spellcheck.test.ts b/src/app/components/editor/spellcheck.test.ts new file mode 100644 index 00000000..fa0535cb --- /dev/null +++ b/src/app/components/editor/spellcheck.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { composerSpellcheckAttributes, defaultComposerSpellcheck } from './spellcheck'; + +const media = (coarse: boolean) => (query: string) => ({ + matches: query === '(pointer: coarse)' && coarse, +}); + +describe('composer spellcheck default', () => { + it('is off when the primary pointer is coarse (phone)', () => { + expect(defaultComposerSpellcheck(media(true))).toBe(false); + }); + it('is on for a fine pointer (desktop)', () => { + expect(defaultComposerSpellcheck(media(false))).toBe(true); + }); + it('is on when matchMedia is unavailable', () => { + expect(defaultComposerSpellcheck(undefined)).toBe(true); + }); +}); + +describe('composer spellcheck attributes', () => { + it('off disables both spellcheck and autocorrect', () => { + expect(composerSpellcheckAttributes(false)).toEqual({ spellCheck: false, autoCorrect: 'off' }); + }); + it('on enables spellcheck and leaves autocorrect to the platform', () => { + expect(composerSpellcheckAttributes(true)).toEqual({ spellCheck: true }); + }); +}); diff --git a/src/app/components/editor/spellcheck.ts b/src/app/components/editor/spellcheck.ts new file mode 100644 index 00000000..825676ef --- /dev/null +++ b/src/app/components/editor/spellcheck.ts @@ -0,0 +1,11 @@ +/** Touch devices (phones) get the keyboard's own dictionary; desktop gets the browser's. */ +export const defaultComposerSpellcheck = ( + matchMedia: ((query: string) => { matches: boolean }) | undefined +): boolean => !matchMedia?.('(pointer: coarse)').matches; + +/** + * Attributes for the composer's editable. Off must also switch autocorrect off: + * spellCheck alone leaves Gboard's suggestion strip and correction modal in place. + */ +export const composerSpellcheckAttributes = (enabled: boolean) => + enabled ? ({ spellCheck: true } as const) : ({ spellCheck: false, autoCorrect: 'off' } as const); diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index b861a060..a51f4175 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -709,6 +709,10 @@ function DateAndTime() { function Editor() { const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline'); + const [composerSpellcheck, setComposerSpellcheck] = useSetting( + settingsAtom, + 'composerSpellcheck' + ); const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown'); const [hideActivity, setHideActivity] = useSetting(settingsAtom, 'hideActivity'); @@ -724,6 +728,15 @@ function Editor() { after={} /> + + + } + /> + Date: Thu, 5 Nov 2026 10:49:54 +0000 Subject: [PATCH 069/206] chat look: the three-doors mock-up in the bubble layout Pose portrait beside the bubble on wide screens and floating inside it on phones with shape-outside; asymmetric bubble corner; own messages right-aligned in the accent colour; first-appearance pop and rise, staggered, off under prefers-reduced-motion; Nunito in the GameCult theme; package name and README move to Ratatoskr, Cinny credit and AGPL notice kept. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 155 ++++----------- package-lock.json | 10 + package.json | 11 +- src/app/components/message/layout/Bubble.tsx | 107 ++++++---- .../components/message/layout/layout.css.ts | 186 ++++++++++++++---- .../components/message/pose/PoseAvatar.tsx | 20 +- .../components/message/pose/entrance.test.ts | 19 ++ src/app/components/message/pose/entrance.ts | 22 +++ src/app/components/message/pose/poses.ts | 1 + src/app/features/room/message/Message.tsx | 21 +- src/gamecult.css.ts | 5 + src/index.tsx | 1 + 12 files changed, 353 insertions(+), 205 deletions(-) create mode 100644 src/app/components/message/pose/entrance.test.ts create mode 100644 src/app/components/message/pose/entrance.ts diff --git a/README.md b/README.md index 46c7a67b..66d9114a 100644 --- a/README.md +++ b/README.md @@ -1,128 +1,55 @@ -# Cinny -

    - - GitHub release downloads - - DockerHub downloads - - Follow on Mastodon - - Follow on Twitter - - Sponsor Cinny -

    +# Ratatoskr -A Matrix client focusing primarily on simple, elegant and secure interface. -The main goal is to have an instant messaging application that is easy on -people and has a modern touch. -- [Roadmap](https://github.com/orgs/cinnyapp/projects/1) -- [Contributing](./CONTRIBUTING.md) +GameCult's own Matrix client: the chat where members and the Minds (GameCult's agent +Personas) talk in the same rooms. It is a fork of [Cinny](https://github.com/cinnyapp/cinny) +4.12.8, kept under the same license, with the engine left alone and the interface rebuilt +estate-first and mobile-first. -> [!IMPORTANT] -We are currently in the process of [replacing] the matrix-js-sdk with our -own SDK. As a result, we will not be accepting any pull requests until -further notice. Thank you for your understanding. +## What is here that Cinny does not have -[replacing]: https://github.com/cinnyapp/cinny/issues/257#issuecomment-3714406704 +Live on `main`: - +- **Estate navigator**: rooms are listed before you join them, opening one shows its name + and a one-tap Join, and the menu hides behind a hamburger. + `src/app/features/estate/`, the plan in `docs/gamecult/ratatoskr-target.md`. +- **GameCult theme**: navy ground, orange accent, Nunito. `src/colors.css.ts` + (`gamecultTheme`), `src/gamecult.css.ts`. +- **Background scene** (opt-in, off by default): a WebGL2 terrain behind the chat, tiered by + device capability. `src/app/features/scene/`, switched by `backgroundScene.tier` in + `config.json`. The chat itself never depends on it. +- **Soundscape** (opt-in, muted by default): `src/app/features/soundscape/`. -## Getting started -The web app is available at [app.cinny.in] and gets updated on each new -release. The `dev` branch is continuously deployed at [dev.cinny.in] -but keep in mind that it could have things broken. +Planned, not built: file zones with albums, the activity feed, the corner avatar and EXP bar. +`docs/gamecult/ratatoskr-target.md` has the rulings and the cut sequence. -You can also download our desktop app from the [cinny-desktop repository]. +## Pose portraits -[app.cinny.in]: https://app.cinny.in -[dev.cinny.in]: https://dev.cinny.in -[cinny-desktop repository]: https://github.com/cinnyapp/cinny-desktop +A Mind's message carries a pose label (`org.gamecult.pose`). With the Pose Portraits setting +on, the sender's portrait stands beside the bubble (inside it, text wrapping the silhouette, on +phones). The art is not committed: `npm run sync:poses` copies it from the cast-art +catalogue. `src/app/components/message/pose/`. -## Contributing and Developing - -If you have any ideas, issues, etc. regarding Cinny, or would like to -contribute to Cinny through pull requests, please check out our -["Contributing to Cinny"](CONTRIBUTING.md) document. Those who would like -to get involved with Cinny's development as well should also read the -["Developing Cinny"](HACKING.md) document for more technical details. - -## Self-hosting -To host Cinny on your own, simply download the tarball from -[GitHub releases], and serve the files from `dist/` using your preferred -webserver. Alternatively, you can just pull the docker image from -[DockerHub] or [GitHub Container Registry]. - -* The default homeservers and explore pages are defined in [`config.json`](config.json). - -* You need to set up redirects to serve the assests. Example configurations; -[netlify](netlify.toml), [nginx](contrib/nginx/cinny.domain.tld.conf), -[caddy](contrib/caddy/caddyfile). - * If you have trouble configuring redirects you can - [enable hash routing](config.json#L35) — the url in the browser will have - a `/#/` between the domain and open channel (ie. `app.cinny.in/#/home/` - instead of `app.cinny.in/home/`) but you won't have to configure your webserver. - -* To deploy on subdirectory, you need to rebuild the app youself after -updating the `base` path in [`build.config.ts`](build.config.ts). - * For example, if you want to deploy on `https://cinny.in/app`, then - set `base: '/app'`. - -[GitHub releases]: https://github.com/cinnyapp/cinny/releases/latest -[DockerHub]: https://hub.docker.com/r/ajbura/cinny -[GitHub Container Registry]: https://github.com/cinnyapp/cinny/pkgs/container/cinny - -
    PGP Public Key to verify tarball +## Run and build ``` ------BEGIN PGP PUBLIC KEY BLOCK----- - -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 -mxFo+ioe/ABCufSmyqFye0psX3Sp -=WtqZ ------END PGP PUBLIC KEY BLOCK----- +npm ci +npm start # vite dev server +npm test # vitest +npm run build # dist/ ``` -
    -## License +To serve `dist/`, send unknown paths to `index.html` +([netlify](netlify.toml), [nginx](contrib/nginx/cinny.domain.tld.conf), +[caddy](contrib/caddy/caddyfile)) or turn on hash routing in [`config.json`](config.json). +Default homeservers and the featured rooms are in `config.json`. For a sub-path, set +`CINNY_BASE` when building. + +## Credit and license + +Ratatoskr is free software under the GNU Affero General Public License v3 ([LICENSE](LICENSE)). +It is based on Cinny by Ajay Bura and contributors: Cinny's engine, components and the +copyright below are theirs. The in-app About page credits Cinny and links the source. + Cinny Project Copyright © 2024–present Ajay Bura -https://cinny.in - -Cinny is licensed under the GNU Affero General Public License, -Version 3 of the License (AGPL-3.0-only). -You may obtain a copy of the License at https://www.gnu.org/licenses/agpl-3.0.html \ No newline at end of file +https://cinny.in diff --git a/package-lock.json b/package-lock.json index dc774000..0817f2b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@atlaskit/pragmatic-drag-and-drop": "1.1.6", "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0", "@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3", + "@fontsource-variable/nunito": "5.3.0", "@fontsource/ibm-plex-mono": "5.2.5", "@fontsource/inter": "4.5.14", "@fontsource/montserrat": "5.2.5", @@ -2192,6 +2193,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@fontsource-variable/nunito": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/nunito/-/nunito-5.3.0.tgz", + "integrity": "sha512-tw0ch7evIDSwoeUy+0XzocYHfbRWLeCRPJy22KgbrLjDLAbNw/fErBiil10Ssrs3Sjr55zaZrr36a975j8cBkQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@fontsource/ibm-plex-mono": { "version": "5.2.5", "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.2.5.tgz", diff --git a/package.json b/package.json index 0e470460..8ec86dea 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "cinny", + "name": "ratatoskr", "version": "4.12.8", "description": "Yet another matrix client", "main": "index.js", @@ -38,7 +38,11 @@ "@atlaskit/pragmatic-drag-and-drop": "1.1.6", "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0", "@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3", + "@fontsource-variable/nunito": "5.3.0", + "@fontsource/ibm-plex-mono": "5.2.5", "@fontsource/inter": "4.5.14", + "@fontsource/montserrat": "5.2.5", + "@fontsource/ubuntu": "5.2.5", "@tanstack/react-query": "5.24.1", "@tanstack/react-query-devtools": "5.24.1", "@tanstack/react-virtual": "3.2.0", @@ -89,10 +93,7 @@ "slate-dom": "0.123.0", "slate-history": "0.113.1", "slate-react": "0.123.0", - "ua-parser-js": "1.0.35", - "@fontsource/ubuntu": "5.2.5", - "@fontsource/montserrat": "5.2.5", - "@fontsource/ibm-plex-mono": "5.2.5" + "ua-parser-js": "1.0.35" }, "devDependencies": { "@element-hq/element-call-embedded": "0.20.1", diff --git a/src/app/components/message/layout/Bubble.tsx b/src/app/components/message/layout/Bubble.tsx index 7fc5171c..066a827f 100644 --- a/src/app/components/message/layout/Bubble.tsx +++ b/src/app/components/message/layout/Bubble.tsx @@ -5,12 +5,11 @@ import * as css from './layout.css'; type BubbleArrowProps = { variant: ContainerColor; - pose?: boolean; }; -function BubbleLeftArrow({ variant, pose }: BubbleArrowProps) { +function BubbleLeftArrow({ variant }: BubbleArrowProps) { return ( ( - ({ hideBubble, pose, before, header, children, ...props }, ref) => ( - - - {before} - - - {header} - {hideBubble ? ( - children - ) : ( - - - {before ? : null} - {children} - + ({ hideBubble, own, pose, alt, enterDelay, before, header, children, style, ...props }, ref) => { + // `false` is a Mind's message continuing a run: aligned with the portraits, showing none. + const poseRow = pose !== undefined; + const arrow = !!before && !poseRow && !own; + const entering = enterDelay !== undefined; + return ( + + {!poseRow && !own && ( + + {before} )} + + {header && ( +
    + {header} +
    + )} + {hideBubble ? ( + children + ) : ( + + + {arrow ? : null} + {pose} + {children} + + + )} + - - ) + ); + } ); diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index cb53a62d..cdf5f3e1 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -138,39 +138,12 @@ export const BubbleBefore = style({ minWidth: toRem(36), }); -// Pose portraits: beside the bubble on wide screens; on phones the row stacks, so the -// portrait costs a little height instead of a column of width, and the left arrow goes. -export const BubblePose = style({ - '@media': { - 'screen and (max-width: 750px)': { - flexDirection: 'column', - gap: config.space.S100, - }, - }, -}); - -export const BubbleBeforePose = style({ - minWidth: toRem(64), - '@media': { - 'screen and (max-width: 750px)': { - minWidth: 0, - }, - }, -}); - -export const BubbleArrowPose = style({ - '@media': { - 'screen and (max-width: 750px)': { - display: 'none', - }, - }, -}); - export const BubbleContent = style({ maxWidth: toRem(800), padding: config.space.S200, backgroundColor: color.SurfaceVariant.Container, color: color.SurfaceVariant.OnContainer, + border: `1px solid ${color.SurfaceVariant.ContainerLine}`, borderRadius: config.radii.R500, position: 'relative', }); @@ -179,30 +152,171 @@ export const BubbleContentArrowLeft = style({ borderTopLeftRadius: 0, }); -export const BubbleContentPoseStacked = style({ +// Look of the three-doors mock-up (chat.gamecult.org/mockups/three-doors): a Mind's pose sits +// beside its bubble on wide screens; on phones it floats inside the bubble, overhanging the top +// edge, and the text wraps around its silhouette (shape-outside on the alpha channel). +const PHONE = 'screen and (max-width: 700px)'; +const PORTRAIT = toRem(124); +const POSE_GUTTER = toRem(14); +const BUBBLE_CORNER = toRem(6); +const POP = 'cubic-bezier(0.34, 1.7, 0.64, 1)'; +const RISE = 'cubic-bezier(0.34, 1.45, 0.64, 1)'; + +export const BubbleRowPose = style({ + position: 'relative', + paddingLeft: `calc(${PORTRAIT} + ${POSE_GUTTER})`, '@media': { - 'screen and (max-width: 750px)': { - borderTopLeftRadius: config.radii.R500, + [PHONE]: { + paddingLeft: 0, }, }, }); +// A row that shows a portrait: room for it to hang above the bubble on phones. +export const BubbleRowPortrait = style({ + minHeight: PORTRAIT, + '@media': { + [PHONE]: { + minHeight: 0, + marginTop: toRem(58), + }, + }, +}); + +export const BubbleRowOwn = style({ + justifyContent: 'flex-end', +}); + +export const BubbleColumnOwn = style({ + alignItems: 'flex-end', + maxWidth: toRem(640), +}); + +export const BubbleHeaderOwn = style({ + alignSelf: 'flex-end', +}); + +// Phone: the name is a pill straddling the bubble's top edge, on the side opposite the portrait. +export const BubbleHeaderPose = style({ + '@media': { + [PHONE]: { + position: 'absolute', + zIndex: 2, + top: toRem(-15), + right: toRem(14), + padding: `0 ${toRem(13)}`, + background: color.Secondary.Container, + border: `1px solid ${color.Secondary.ContainerLine}`, + borderRadius: toRem(999), + whiteSpace: 'nowrap', + }, + }, +}); + +export const BubbleHeaderPoseAlt = style({ + '@media': { + [PHONE]: { + right: 'auto', + left: toRem(14), + }, + }, +}); + +export const BubbleColumnPose = style({ + position: 'relative', +}); + +export const BubbleContentPose = style({ + borderRadius: config.radii.R500, + borderTopLeftRadius: BUBBLE_CORNER, + '@media': { + [PHONE]: { + display: 'flow-root', + borderTopLeftRadius: config.radii.R500, + padding: `${toRem(24)} ${toRem(14)} ${toRem(14)}`, + }, + }, +}); + +export const BubbleContentOwn = style({ + backgroundColor: color.Primary.Container, + color: color.Primary.OnContainer, + border: `1px solid ${color.Primary.ContainerLine}`, + borderRadius: config.radii.R500, + borderTopRightRadius: BUBBLE_CORNER, +}); + export const PoseButton = style({ + position: 'absolute', + top: 0, + left: `calc(-1 * (${PORTRAIT} + ${POSE_GUTTER}))`, + width: PORTRAIT, + height: PORTRAIT, padding: 0, border: 'none', background: 'none', cursor: 'pointer', lineHeight: 0, + transformOrigin: '50% 90%', + '@media': { + [PHONE]: { + position: 'static', + float: 'left', + width: 'min(50vw, 210px)', + height: 'auto', + margin: `${toRem(-82)} ${toRem(8)} 0 ${toRem(-12)}`, + shapeMargin: toRem(7), + shapeImageThreshold: 0.35, + WebkitMaskImage: 'linear-gradient(#000 84%, transparent)', + maskImage: 'linear-gradient(#000 84%, transparent)', + }, + }, +}); + +export const PoseButtonAlt = style({ + '@media': { + [PHONE]: { + float: 'right', + margin: `${toRem(-82)} ${toRem(-12)} 0 ${toRem(8)}`, + }, + }, }); export const PosePortrait = style({ - width: toRem(64), - height: toRem(64), + width: '100%', + height: '100%', objectFit: 'contain', + objectPosition: 'top', '@media': { - 'screen and (max-width: 750px)': { - width: toRem(48), - height: toRem(48), + [PHONE]: { + height: 'auto', + }, + }, +}); + +const pop = keyframes({ + from: { opacity: 0, transform: `translateY(${toRem(34)}) scale(0.42)` }, + to: { opacity: 1, transform: 'none' }, +}); +const rise = keyframes({ + from: { opacity: 0, transform: `translateY(${toRem(14)}) scale(0.96)` }, + to: { opacity: 1, transform: 'none' }, +}); + +// First appearance of a message: the portrait pops, the bubble rises just behind it. The delay +// (a --enter-delay on the row) staggers messages that arrive together. +export const EnterPortrait = style({ + '@media': { + '(prefers-reduced-motion: no-preference)': { + animation: `${pop} 460ms ${POP} var(--enter-delay, 0ms) both`, + }, + }, +}); + +export const EnterBubble = style({ + '@media': { + '(prefers-reduced-motion: no-preference)': { + animation: `${rise} 380ms ${RISE} calc(var(--enter-delay, 0ms) + 50ms) both`, }, }, }); diff --git a/src/app/components/message/pose/PoseAvatar.tsx b/src/app/components/message/pose/PoseAvatar.tsx index ed8bc485..e98d6139 100644 --- a/src/app/components/message/pose/PoseAvatar.tsx +++ b/src/app/components/message/pose/PoseAvatar.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import classNames from 'classnames'; import * as css from '../layout/layout.css'; import { poseSrc } from './poses'; @@ -6,25 +7,26 @@ type PoseAvatarProps = { mind: string; pose: string; userId: string; + /** Float on the right on phones. */ + alt?: boolean; + /** Play the first-appearance pop. */ + enter?: boolean; onClick: React.MouseEventHandler; }; -export function PoseAvatar({ mind, pose, userId, onClick }: PoseAvatarProps) { +export function PoseAvatar({ mind, pose, userId, alt, enter, onClick }: PoseAvatarProps) { + const src = poseSrc(mind, pose, import.meta.env.BASE_URL); return ( ); } diff --git a/src/app/components/message/pose/entrance.test.ts b/src/app/components/message/pose/entrance.test.ts new file mode 100644 index 00000000..6f16a384 --- /dev/null +++ b/src/app/components/message/pose/entrance.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { claimEntrance } from './entrance'; + +describe('claimEntrance', () => { + it('animates a message once, never on a remount', () => { + expect(claimEntrance('$once', 1_000_000)).toBe(0); + expect(claimEntrance('$once', 1_000_500)).toBeUndefined(); + }); + it('staggers messages that mount together, capped so a long backlog still lands fast', () => { + const t = 2_000_000; + const delays = ['$a', '$b', '$c', '$d', '$e', '$f'].map((id) => claimEntrance(id, t)); + expect(delays.slice(0, 3)).toEqual([0, 300, 600]); + expect(Math.max(...(delays as number[]))).toBe(900); + }); + it('lets a lone later message in at once', () => { + claimEntrance('$x', 3_000_000); + expect(claimEntrance('$y', 3_010_000)).toBe(0); + }); +}); diff --git a/src/app/components/message/pose/entrance.ts b/src/app/components/message/pose/entrance.ts new file mode 100644 index 00000000..8386f80b --- /dev/null +++ b/src/app/components/message/pose/entrance.ts @@ -0,0 +1,22 @@ +import { useState } from 'react'; + +// A message animates in the first time it is shown, not each time the virtualised timeline +// remounts it. Messages mounting together are staggered so a screenful does not pop at once. +const STEP_MS = 300; +const MAX_DELAY_MS = 900; +const SEEN_CAP = 2000; + +const seen = new Set(); +let nextSlot = 0; + +export const claimEntrance = (id: string, now: number): number | undefined => { + if (seen.has(id)) return undefined; + if (seen.size >= SEEN_CAP) seen.clear(); + seen.add(id); + const start = Math.max(nextSlot, now); + nextSlot = Math.min(start + STEP_MS, now + MAX_DELAY_MS); + return Math.min(start - now, MAX_DELAY_MS); +}; + +export const useEntranceDelay = (id: string | undefined): number | undefined => + useState(() => (id ? claimEntrance(id, performance.now()) : undefined))[0]; diff --git a/src/app/components/message/pose/poses.ts b/src/app/components/message/pose/poses.ts index 01fb89e1..a5b705c3 100644 --- a/src/app/components/message/pose/poses.ts +++ b/src/app/components/message/pose/poses.ts @@ -11,6 +11,7 @@ type MindEntry = { signatures: Record; }; const minds = catalogue.minds as Record; +export const MINDS = Object.keys(minds); // A sender is a known Mind when its Matrix localpart or the first word of its // display name is the Mind's name, case-insensitively. diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 42d74a6b..5c4421b6 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -61,7 +61,8 @@ import { import { MessageLayout, MessageSpacing, settingsAtom } from '../../../state/settings'; import { useSetting } from '../../../state/hooks/settings'; import { PoseAvatar } from '../../../components/message/pose/PoseAvatar'; -import { POSE_KEY, mindOfSender, resolvePose } from '../../../components/message/pose/poses'; +import { POSE_KEY, MINDS, mindOfSender, resolvePose } from '../../../components/message/pose/poses'; +import { useEntranceDelay } from '../../../components/message/pose/entrance'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useRecentEmoji } from '../../../hooks/useRecentEmoji'; import * as css from './styles.css'; @@ -797,6 +798,11 @@ export const Message = as<'div', MessageProps>( ? mindOfSender(getMxIdLocalPart(senderId), senderDisplayName) : undefined; + const own = senderId === mx.getUserId(); + const enterDelay = useEntranceDelay(mEvent.getId()); + // Minds alternate sides on phones, each always on the same one. + const poseAlt = !!mind && MINDS.indexOf(mind) % 2 === 1; + const avatarJSX = !collapse && messageLayout !== MessageLayout.Compact && ( ( mind={mind} pose={resolvePose(mind, mEvent.getContent()[POSE_KEY])} userId={senderId} + alt={poseAlt} + enter={enterDelay !== undefined} onClick={onUserClick} /> ); @@ -1154,9 +1162,14 @@ export const Message = as<'div', MessageProps>( )} {messageLayout === MessageLayout.Bubble && ( + own={own} + pose={mind ? poseJSX : undefined} + alt={poseAlt} + enterDelay={enterDelay} + before={avatarJSX} + header={headerJSX} + onContextMenu={handleContextMenu} + > {msgContentJSX} )} diff --git a/src/gamecult.css.ts b/src/gamecult.css.ts index 4aaf3e76..57fbbf05 100644 --- a/src/gamecult.css.ts +++ b/src/gamecult.css.ts @@ -18,3 +18,8 @@ globalStyle(scope, { [config.lineHeight.D400]: '1.7', }, }); + +// Nunito for the GameCult look (the three-doors mock-up); the stock themes keep Inter. +globalStyle(scope, { + fontFamily: "'Nunito Variable', var(--font-secondary)", +}); diff --git a/src/index.tsx b/src/index.tsx index 672ac5c0..4aba7ed4 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { createRoot } from 'react-dom/client'; import { enableMapSet } from 'immer'; import '@fontsource/inter/variable.css'; +import '@fontsource-variable/nunito/index.css'; import '@fontsource/ubuntu/300.css'; import '@fontsource/ubuntu/400.css'; import '@fontsource/ubuntu/500.css'; From 2255988866cfa11e99ff6d35b6ea40e2bb7c340f Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 6 Nov 2026 07:09:25 +0000 Subject: [PATCH 070/206] brand: Ratatoskr README front door, package name, theme colour, contrib confs README names what the repo is, what is live versus planned and the entry points; Cinny is credited as upstream and the AGPL notice and copyright are kept. package name, HACKING/CONTRIBUTING titles, nginx/caddy examples and the docker tag follow. manifest and theme-color use the mock-up navy #07111a. Co-Authored-By: Claude Sonnet 5.5 --- CONTRIBUTING.md | 4 +- HACKING.md | 6 +- README.md | 140 ++++-------------- contrib/caddy/caddyfile | 4 +- ...ain.tld.conf => ratatoskr.domain.tld.conf} | 6 +- index.html | 2 +- package-lock.json | 4 +- package.json | 2 +- public/manifest.json | 4 +- 9 files changed, 45 insertions(+), 127 deletions(-) rename contrib/nginx/{cinny.domain.tld.conf => ratatoskr.domain.tld.conf} (85%) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 859eab14..6ad62946 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,8 +1,8 @@ -# Contributing to Cinny +# Contributing to Ratatoskr First off, thanks for taking the time to contribute! ❤️ -This document describes the process of contributing to Cinny. It is intended +This document describes the process of contributing to Ratatoskr. It is intended for anyone considering opening an **issue**, **discussion** or **pull request**. For people who are interested in developing Cinny and technical details behind it, please check out our ["Developing Cinny"](HACKING.md) document as well. diff --git a/HACKING.md b/HACKING.md index 30c4960f..bb6d3c5d 100644 --- a/HACKING.md +++ b/HACKING.md @@ -1,4 +1,4 @@ -# Developing Cinny +# Developing Ratatoskr > [!TIP] > We recommend using a version manager as versions change very quickly. @@ -36,13 +36,13 @@ source and serves it with Nginx on port 80. To use this locally, you can build the container like so: ``` -docker build -t cinny:latest . +docker build -t ratatoskr:latest . ``` You can then run the container you've built with a command similar to this: ``` -docker run -p 8080:80 cinny:latest +docker run -p 8080:80 ratatoskr:latest ``` This will forward your `localhost` port 8080 to the container's port 80. diff --git a/README.md b/README.md index 46c7a67b..dfc722ad 100644 --- a/README.md +++ b/README.md @@ -1,125 +1,43 @@ -# Cinny -

    - - GitHub release downloads - - DockerHub downloads - - Follow on Mastodon - - Follow on Twitter - - Sponsor Cinny -

    +# Ratatoskr -A Matrix client focusing primarily on simple, elegant and secure interface. -The main goal is to have an instant messaging application that is easy on -people and has a modern touch. -- [Roadmap](https://github.com/orgs/cinnyapp/projects/1) -- [Contributing](./CONTRIBUTING.md) +GameCult's Matrix chat client, a fork of [Cinny](https://github.com/cinnyapp/cinny). +It opens on GameCult's estate (spaces, project rooms grouped by steward, Minds, +and your own place in it) instead of an invite list. Mobile first. -> [!IMPORTANT] -We are currently in the process of [replacing] the matrix-js-sdk with our -own SDK. As a result, we will not be accepting any pull requests until -further notice. Thank you for your understanding. +**Live now:** the Cinny engine (matrix-js-sdk, Rust crypto, sync) under a +Ratatoskr UI: branding, GameCult theme, estate navigation, pose portraits +(opt-in setting), a background scene and soundscape (opt-in, off by default). +**Planned:** see [`docs/gamecult/ratatoskr-target.md`](docs/gamecult/ratatoskr-target.md) +for the target and cut sequence. Internal names that are protocol or stored +state (`cinny_*` localStorage keys, the `in.cinny.room.power_level_tags` event +type, `CINNY_BASE`) are unchanged on purpose. -[replacing]: https://github.com/cinnyapp/cinny/issues/257#issuecomment-3714406704 +Entry points: `src/app/` (client UI), `src/app/features/scene/` (background +scene), `src/app/features/soundscape/` (audio socket), `harness/` (estate +drive scripts), `scripts/gen-brand.mjs` (regenerates the logo and icons). - +## Upstream +Ratatoskr is based on Cinny by Ajay Bura and contributors, licensed AGPL-3.0. +Cinny's own README, desktop app and releases live at +. We do not ship its releases or images. -## Getting started -The web app is available at [app.cinny.in] and gets updated on each new -release. The `dev` branch is continuously deployed at [dev.cinny.in] -but keep in mind that it could have things broken. - -You can also download our desktop app from the [cinny-desktop repository]. - -[app.cinny.in]: https://app.cinny.in -[dev.cinny.in]: https://dev.cinny.in -[cinny-desktop repository]: https://github.com/cinnyapp/cinny-desktop - -## Contributing and Developing - -If you have any ideas, issues, etc. regarding Cinny, or would like to -contribute to Cinny through pull requests, please check out our -["Contributing to Cinny"](CONTRIBUTING.md) document. Those who would like -to get involved with Cinny's development as well should also read the -["Developing Cinny"](HACKING.md) document for more technical details. +## Developing +See [HACKING.md](HACKING.md). Contribution notes are in [CONTRIBUTING.md](CONTRIBUTING.md). ## Self-hosting -To host Cinny on your own, simply download the tarball from -[GitHub releases], and serve the files from `dist/` using your preferred -webserver. Alternatively, you can just pull the docker image from -[DockerHub] or [GitHub Container Registry]. +Build with `npm ci && npm run build` and serve `dist/` with your webserver. * The default homeservers and explore pages are defined in [`config.json`](config.json). - -* You need to set up redirects to serve the assests. Example configurations; -[netlify](netlify.toml), [nginx](contrib/nginx/cinny.domain.tld.conf), -[caddy](contrib/caddy/caddyfile). - * If you have trouble configuring redirects you can - [enable hash routing](config.json#L35) — the url in the browser will have - a `/#/` between the domain and open channel (ie. `app.cinny.in/#/home/` - instead of `app.cinny.in/home/`) but you won't have to configure your webserver. - -* To deploy on subdirectory, you need to rebuild the app youself after -updating the `base` path in [`build.config.ts`](build.config.ts). - * For example, if you want to deploy on `https://cinny.in/app`, then - set `base: '/app'`. - -[GitHub releases]: https://github.com/cinnyapp/cinny/releases/latest -[DockerHub]: https://hub.docker.com/r/ajbura/cinny -[GitHub Container Registry]: https://github.com/cinnyapp/cinny/pkgs/container/cinny - -
    PGP Public Key to verify tarball - -``` ------BEGIN PGP PUBLIC KEY BLOCK----- - -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 -mxFo+ioe/ABCufSmyqFye0psX3Sp -=WtqZ ------END PGP PUBLIC KEY BLOCK----- -``` -
    +* Serving the assets needs redirects to `index.html`. Examples: +[netlify](netlify.toml), [nginx](contrib/nginx/ratatoskr.domain.tld.conf), +[caddy](contrib/caddy/caddyfile). Without them you can +[enable hash routing](config.json) (`hashRouter`); URLs then carry `/#/`. +* To deploy on a subdirectory, rebuild with the `base` path set, either in +[`build.config.ts`](build.config.ts) or through `CINNY_BASE` +(for example `CINNY_BASE=/app/ npm run build`). ## License -Cinny Project +Ratatoskr is a fork of the Cinny project Copyright © 2024–present Ajay Bura https://cinny.in diff --git a/contrib/caddy/caddyfile b/contrib/caddy/caddyfile index d23dcccb..a686b16b 100644 --- a/contrib/caddy/caddyfile +++ b/contrib/caddy/caddyfile @@ -1,6 +1,6 @@ # more info: https://caddyserver.com/docs/caddyfile/patterns#single-page-apps-spas -cinny.domain.tld { - root * /path/to/cinny/dist +ratatoskr.domain.tld { + root * /path/to/ratatoskr/dist try_files {path} / index.html file_server } diff --git a/contrib/nginx/cinny.domain.tld.conf b/contrib/nginx/ratatoskr.domain.tld.conf similarity index 85% rename from contrib/nginx/cinny.domain.tld.conf rename to contrib/nginx/ratatoskr.domain.tld.conf index 02c7ead9..7df64900 100644 --- a/contrib/nginx/cinny.domain.tld.conf +++ b/contrib/nginx/ratatoskr.domain.tld.conf @@ -1,7 +1,7 @@ server { listen 80; listen [::]:80; - server_name cinny.domain.tld; + server_name ratatoskr.domain.tld; location / { return 301 https://$host$request_uri; @@ -15,10 +15,10 @@ server { server { listen 443 ssl http2; listen [::]:443 ssl; - server_name cinny.domain.tld; + server_name ratatoskr.domain.tld; location / { - root /opt/cinny/dist/; + root /opt/ratatoskr/dist/; rewrite ^/config.json$ /config.json break; rewrite ^/manifest.json$ /manifest.json break; diff --git a/index.html b/index.html index 7242f309..c9042aff 100644 --- a/index.html +++ b/index.html @@ -19,7 +19,7 @@ property="og:description" content="A Matrix client where you can enjoy the conversation using simple, elegant and secure interface protected by e2ee with the power of open source." /> - + diff --git a/package-lock.json b/package-lock.json index dc774000..b6d1187e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "cinny", + "name": "ratatoskr", "version": "4.12.8", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "cinny", + "name": "ratatoskr", "version": "4.12.8", "license": "AGPL-3.0-only", "dependencies": { diff --git a/package.json b/package.json index 298fbf36..b417f0d9 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "cinny", + "name": "ratatoskr", "version": "4.12.8", "description": "Yet another matrix client", "main": "index.js", diff --git a/public/manifest.json b/public/manifest.json index 81fd714c..ef0a3510 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -7,8 +7,8 @@ "display": "standalone", "orientation": "portrait", "start_url": "./", - "background_color": "#fff", - "theme_color": "#fff", + "background_color": "#07111a", + "theme_color": "#07111a", "icons": [ { "src": "./public/android/android-chrome-36x36.png", From 1519f5a8ab23aaabe11f1a7977dbecf8a8933b95 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 6 Nov 2026 08:37:51 +0000 Subject: [PATCH 071/206] README: disambiguate from the OBS media receiver also called Ratatoskr Co-Authored-By: Claude Sonnet 5.5 --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index dfc722ad..a3f025d2 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Ratatoskr +> Not the OBS media receiver that Muninn docs also call Ratatoskr (github.com/GameCult/Ratatoskr, Rust): this is the GameCult chat client, a Matrix web client. + GameCult's Matrix chat client, a fork of [Cinny](https://github.com/cinnyapp/cinny). It opens on GameCult's estate (spaces, project rooms grouped by steward, Minds, and your own place in it) instead of an invite list. Mobile first. From 8774cbbe45c636c93580b34139934153dbfdf612 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 6 Nov 2026 12:39:02 +0000 Subject: [PATCH 072/206] README: drop the OBS media receiver disambiguation Meta ruled 2026-11-06 that the streaming receiver is deprecated and Ratatoskr is the chat client's name. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index a3f025d2..dfc722ad 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,5 @@ # Ratatoskr -> Not the OBS media receiver that Muninn docs also call Ratatoskr (github.com/GameCult/Ratatoskr, Rust): this is the GameCult chat client, a Matrix web client. - GameCult's Matrix chat client, a fork of [Cinny](https://github.com/cinnyapp/cinny). It opens on GameCult's estate (spaces, project rooms grouped by steward, Minds, and your own place in it) instead of an invite list. Mobile first. From 4e2aeddb69789a558c0989b15c757bb82de96482 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 6 Nov 2026 13:39:29 +0000 Subject: [PATCH 073/206] auth: training notice on login and registration; registration needs an acknowledgement One TrainingNotice component above the submit button on both screens. Login shows the notice only; registration adds a ticked checkbox that gates the Register button. Co-Authored-By: Claude Sonnet 5.5 --- src/app/pages/auth/TrainingNotice.tsx | 45 +++++++++++++++++++ .../pages/auth/login/PasswordLoginForm.tsx | 2 + .../auth/register/PasswordRegisterForm.tsx | 10 ++++- 3 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 src/app/pages/auth/TrainingNotice.tsx diff --git a/src/app/pages/auth/TrainingNotice.tsx b/src/app/pages/auth/TrainingNotice.tsx new file mode 100644 index 00000000..28847e4f --- /dev/null +++ b/src/app/pages/auth/TrainingNotice.tsx @@ -0,0 +1,45 @@ +import { Box, Checkbox, Text, color, config } from 'folds'; +import React from 'react'; + +export const TRAINING_NOTICE = + "Everything said on this server may be used to train GameCult's AI stewards. We never ingest Discord content."; + +type TrainingNoticeProps = { + // Registration passes this to require a ticked acknowledgement; login shows the notice only. + acknowledge?: { checked: boolean; onToggle: () => void }; +}; + +export function TrainingNotice({ acknowledge }: TrainingNoticeProps) { + return ( + + {TRAINING_NOTICE} + {acknowledge && ( + + + I understand and agree. + + )} + + ); +} diff --git a/src/app/pages/auth/login/PasswordLoginForm.tsx b/src/app/pages/auth/login/PasswordLoginForm.tsx index 3c8641d9..6ad6e1b0 100644 --- a/src/app/pages/auth/login/PasswordLoginForm.tsx +++ b/src/app/pages/auth/login/PasswordLoginForm.tsx @@ -35,6 +35,7 @@ import { } from './loginUtil'; import { PasswordInput } from '../../../components/password-input'; import { FieldError } from '../FiledError'; +import { TrainingNotice } from '../TrainingNotice'; import { getResetPasswordPath } from '../../pathUtils'; import { stopPropagation } from '../../../utils/keyboard'; @@ -255,6 +256,7 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog + ); } diff --git a/src/app/components/message/pose/silhouette.test.ts b/src/app/components/message/pose/silhouette.test.ts new file mode 100644 index 00000000..9deb3f4f --- /dev/null +++ b/src/app/components/message/pose/silhouette.test.ts @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { silhouette } from './silhouette'; + +// A w x h image whose opaque pixels are the given columns [from, to) on each row. +const fakeImage = (w: number, h: number, solid: (y: number) => [number, number] | null) => { + const data = new Uint8ClampedArray(w * h * 4); + for (let y = 0; y < h; y += 1) { + const span = solid(y); + if (span) for (let x = span[0]; x < span[1]; x += 1) data[(y * w + x) * 4 + 3] = 255; + } + const canvas = { + width: 0, + height: 0, + getContext: () => ({ canvas, drawImage: vi.fn(), getImageData: () => ({ data }) }), + }; + vi.stubGlobal('document', { createElement: () => canvas }); + return { clientWidth: w, clientHeight: h } as HTMLImageElement; +}; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('silhouette', () => { + // Art in columns 20..60 from row 8 down; rows 0..7 are empty. + const art = (y: number): [number, number] | null => (y < 8 ? null : [20, 60]); + + it('hugs the right edge of the art when the text sits to its right', () => { + const poly = silhouette(fakeImage(100, 16, art), true); + expect(poly).toBe( + 'polygon(0px 0, 0px 0px, 0px 4px, 60px 8px, 60px 12px, 60px 16px, 0px 16px) border-box' + ); + }); + + it('hugs the left edge of the art when the text sits to its left', () => { + const poly = silhouette(fakeImage(100, 16, art), false); + expect(poly).toBe( + 'polygon(100px 0, 100px 0px, 100px 4px, 20px 8px, 20px 12px, 20px 16px, 100px 16px) border-box' + ); + }); + + it('takes the widest row of a band, so no row of the art pokes out of the polygon', () => { + // Row 5 reaches x=90; its band's edge row (y=8 covers rows 4..8) must include it. + const poly = silhouette( + fakeImage(100, 16, (y) => (y === 5 ? [20, 90] : null)), + true + ); + expect(poly).toContain('90px 8px'); + }); + + it('has no shape before the image has a size or where there is no canvas', () => { + expect( + silhouette({ clientWidth: 0, clientHeight: 0 } as HTMLImageElement, true) + ).toBeUndefined(); + }); +}); diff --git a/src/app/components/message/pose/silhouette.ts b/src/app/components/message/pose/silhouette.ts new file mode 100644 index 00000000..e9596ded --- /dev/null +++ b/src/app/components/message/pose/silhouette.ts @@ -0,0 +1,34 @@ +const SOLID = 0.35 * 255; +const ROW = 4; + +// The edge of the portrait that the text wraps against, as a polygon in border-box pixels. +// `shape-outside: url()` cannot do this: the browser sizes that image at its natural size and +// anchors it at the margin box, which the phone layout's negative top margin shifts down by the +// overhang, so the exclusion neither matches the art's size nor its place. +export const silhouette = (img: HTMLImageElement, textOnRight: boolean): string | undefined => { + const w = Math.round(img.clientWidth); + const h = Math.round(img.clientHeight); + const ctx = w > 0 && h > 0 ? document.createElement('canvas').getContext('2d') : null; + if (!ctx) return undefined; + ctx.canvas.width = w; + ctx.canvas.height = h; + ctx.drawImage(img, 0, 0, w, h); + const { data } = ctx.getImageData(0, 0, w, h); + const outer = textOnRight ? 0 : w; + const edge: string[] = []; + for (let y = 0; y <= h; y += ROW) { + let x = outer; + // A band's edge is the extreme solid pixel over its rows, so no row pokes out of the polygon. + for (let r = Math.max(0, y - ROW); r < Math.min(h, y + 1); r += 1) { + for (let i = 0; i < w; i += 1) { + const px = textOnRight ? w - 1 - i : i; + if (data[(r * w + px) * 4 + 3] > SOLID) { + x = textOnRight ? Math.max(x, px + 1) : Math.min(x, px); + break; + } + } + } + edge.push(`${x}px ${y}px`); + } + return `polygon(${outer}px 0, ${edge.join(', ')}, ${outer}px ${h}px) border-box`; +}; diff --git a/src/app/features/room/RoomViewPoseStatus.tsx b/src/app/features/room/RoomViewPoseStatus.tsx index 33d0d80f..d4a52c93 100644 --- a/src/app/features/room/RoomViewPoseStatus.tsx +++ b/src/app/features/room/RoomViewPoseStatus.tsx @@ -42,7 +42,7 @@ export function RoomViewPoseStatus({ room }: { room: Room }) { if (statuses.length === 0) return null; return ( - + {statuses.map((s) => ( {poseStatusLine(s)} From c2858452e7a3f44b0d6f400ddd5e896011b6da96 Mon Sep 17 00:00:00 2001 From: Nibu Date: Sat, 7 Nov 2026 23:40:43 +0000 Subject: [PATCH 117/206] scene: terrain sky and haze from Void's baked scattering table Drops the vendored terrain/skyData.ts and the computed azimuth. terrainSky.ts reads src/sky/baked.ts: sun {elevation_deg, azimuth_deg, color_linear}, ambient_linear, horizon_hex. terrainScatter.ts decodes baked.scatter and uploads sky_view as an RGB16F 3D texture; SKY_FRAG and GRID_FRAG sample it (README sampler), the sky for the view ray, the haze for the horizon in the fragment's azimuth. - Sky: 1 - exp(-exposure * radiance) + night glow (midnight horizon colour, off as the sun nears the horizon) + sun disc; +-0.5/255 dither on the 8-bit output. - Stars fade by sun elevation (starGain: 1 below -12, 0 above -6) instead of a dark-sky gate; the field is placed against the midnight gradient. - ?sun=&az= (or the sun option) lights between the two bakes for dusk (skyAtSun). - Changed numbers, noted in scene/README.md: haze colour is the scattered sky (was the gradient's horizon stop), ambient is ambient_linear as baked (was gradient zenith x 0.25 x night lift up to 4), HAZE_K unchanged at 0.0004. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/README.md | 25 +++ .../features/scene/terrain/Terrain.test.ts | 59 ++++-- src/app/features/scene/terrain/Terrain.ts | 82 ++++++-- src/app/features/scene/terrain/skyData.ts | 44 ---- .../scene/terrain/terrainAlbedo.test.ts | 6 +- .../features/scene/terrain/terrainAlbedo.ts | 7 +- .../scene/terrain/terrainScatter.test.ts | 88 ++++++++ .../features/scene/terrain/terrainScatter.ts | 94 +++++++++ .../features/scene/terrain/terrainShaders.ts | 46 +++-- .../features/scene/terrain/terrainSky.test.ts | 190 ++++++++++++------ src/app/features/scene/terrain/terrainSky.ts | 139 +++++++++---- .../scene/terrain/terrainStars.test.ts | 42 +++- .../features/scene/terrain/terrainStars.ts | 34 +++- 13 files changed, 644 insertions(+), 212 deletions(-) delete mode 100644 src/app/features/scene/terrain/skyData.ts create mode 100644 src/app/features/scene/terrain/terrainScatter.test.ts create mode 100644 src/app/features/scene/terrain/terrainScatter.ts diff --git a/src/app/features/scene/README.md b/src/app/features/scene/README.md index 473a92a1..5bf2ef59 100644 --- a/src/app/features/scene/README.md +++ b/src/app/features/scene/README.md @@ -38,6 +38,31 @@ Integration (a canvas under the app, `startBackgroundScene`, `login()` when the are later cuts. This PR is the render core plus the contract the host needs: flag, tier, failure callbacks and the creature sizing rule. Nothing has drawn a creature. +## Terrain scene (`style: 'terrain'`): the baked sky + +`terrain/` draws a Phacelle-displaced grid under a sky read from Void's bake (`src/sky/baked.ts`, layout in +`src/sky/README.md`); nothing is vendored. `terrainSky.ts` reads each moment's `sun.{elevation_deg, azimuth_deg, +color_linear}`, `ambient_linear` and `horizon_hex`; `terrainScatter.ts` decodes `baked.scatter` and uploads `sky_view` as an +RGB16F 3D texture (the transmittance table stays on the CPU, for the sun's colour at an elevation the bake does not hold). +Both fragment shaders sample it: `SKY_FRAG` for the sky, `GRID_FRAG` for the haze, which is the sky's colour at the horizon in the +fragment's azimuth, so far ground fades into the sky behind it at dusk too. + +Page URL: `?sky=midnight|noonday` picks a bake; `?sun=&az=` puts the sun anywhere between them +(`skyAtSun`: baked transmittance for the sun's colour, ambient and horizon blended from the midnight bake to the noonday one as +the sun climbs from -8 to 25 degrees). `?sun=` wins over `?sky=`. + +- Sky: `1 - exp(-SKY_EXPOSURE * radiance)` plus a night glow (the midnight horizon colour, `nightness` 1 below -4 degrees, 0 above 4), a + sun disc from the sun's colour, and a +-0.5/255 dither on the 8-bit output. The table is single scattering only (no ground bounce, + no multiple scattering), so the noon horizon is pale yellow-green and a sun just under the horizon colours the whole sky orange, + where the real one keeps a blue-violet zenith. Within `HORIZON_FLOOR` (1.5 degrees) of the horizon the sky takes one flat colour, + because the table dips toward the ground there and would draw a seam against the haze. +- Stars: faded by the sun's elevation, `starGain` 1 below -12 degrees (where the table goes dark), 0 above -6, smooth between; + the field is placed against the midnight gradient and no longer gated on the sky being dark. At a baked sky that is the same as + before (midnight shows, noonday does not). +- Changed numbers: haze colour (was the gradient's horizon stop; now the scattered sky), ambient (was the gradient's zenith stop + x `AMBIENT_GAIN` 0.25 x `NIGHT_LIFT` up to 4; now `ambient_linear` as baked, so midnight's fill is about 2.5 to 3.5 times darker than + the lifted one). `HAZE_K` (0.0004 per metre) is unchanged. + ## Entry points - `index.ts` `startBackgroundScene(config.backgroundScene, capabilities, options)` is the host's diff --git a/src/app/features/scene/terrain/Terrain.test.ts b/src/app/features/scene/terrain/Terrain.test.ts index 4578a58a..446ab9cf 100644 --- a/src/app/features/scene/terrain/Terrain.test.ts +++ b/src/app/features/scene/terrain/Terrain.test.ts @@ -2,8 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createTerrainScene } from './Terrain'; import { GRID, triangleCount } from './terrainWorld'; -import { SKY } from './skyData'; -import { ambientColour } from './terrainSky'; +import { SKY, skyAtSun } from './terrainSky'; +import { SKY_VIEW, skyViewWords } from './terrainScatter'; +import { SKY_FRAG } from './terrainShaders'; import { startBackgroundScene } from '../index'; /** A GL context that accepts every call, remembers them, reports success for compile, link and framebuffer. */ @@ -134,26 +135,58 @@ describe('createTerrainScene', () => { }); it('lights with the sky chosen: option, then ?sky=, then the night default', () => { - const ambientUploaded = (opts: { sky?: 'midnight' | 'noonday' }) => { + const uploaded = (opts: { sky?: 'midnight' | 'noonday' }) => { const { gl, calls } = fakeGl(); createTerrainScene({ canvas: canvasWith(gl), tier: 'still', ...opts })!.dispose(); - const hit = calls.uniform3fv.filter((a) => (a[1] as ArrayLike).length === 3); - // uniform3fv order in draw(): right, up, forward, then sun, sunColour, ambient, haze (+ camera) - return hit.map((a) => Array.from(a[1] as ArrayLike)); + // every vec3 uniform draw() sets: right, up, forward, sun, sunColour, glow, ambient, camera, soil... + return calls.uniform3fv + .filter((a) => (a[1] as ArrayLike).length === 3) + .map((a) => Array.from(a[1] as ArrayLike)); }; - const night = ambientUploaded({}); - const day = ambientUploaded({ sky: 'noonday' }); - const nightA = Array.from(ambientColour(SKY.midnight)); - const dayA = Array.from(ambientColour(SKY.noonday)); + const night = uploaded({}); + const day = uploaded({ sky: 'noonday' }); + const nightA = Array.from(SKY.midnight.ambient); + const dayA = Array.from(SKY.noonday.ambient); const has = (all: number[][], v: number[]) => all.some((a) => a.every((x, i) => Math.abs(x - v[i]) < 1e-6)); expect(has(night, nightA)).toBe(true); expect(has(night, dayA)).toBe(false); expect(has(day, dayA)).toBe(true); search = '?sky=noonday'; - expect(has(ambientUploaded({}), dayA)).toBe(true); + expect(has(uploaded({}), dayA)).toBe(true); search = '?sky=garbage'; - expect(has(ambientUploaded({}), nightA)).toBe(true); + expect(has(uploaded({}), nightA)).toBe(true); + }); + + it('a sun elevation (option or ?sun=&az=) lights between the bakes, and wins over ?sky=', () => { + const ambients = (opts: Record) => { + const { gl, calls } = fakeGl(); + createTerrainScene({ canvas: canvasWith(gl), tier: 'still', ...opts })!.dispose(); + return calls.uniform3fv.map((a) => Array.from(a[1] as ArrayLike)); + }; + const dusk = skyAtSun(-1, 300); + const has = (all: number[][], v: readonly number[]) => + all.some((a) => a.length === 3 && a.every((x, i) => Math.abs(x - v[i]) < 1e-6)); + expect(has(ambients({ sun: { elevation: -1, azimuth: 300 } }), dusk.ambient)).toBe(true); + search = '?sky=noonday&sun=-1&az=300'; + expect(has(ambients({}), dusk.ambient)).toBe(true); + search = '?sun=garbage'; + expect(has(ambients({}), SKY.midnight.ambient)).toBe(true); + }); + + it('uploads the baked scattering table once, as half floats, to a 3D texture', () => { + const { gl, calls } = fakeGl(); + createTerrainScene({ canvas: canvasWith(gl), tier: 'full' })!.dispose(); + expect(calls.texImage3D).toHaveLength(1); + const a = calls.texImage3D[0]; + expect([a[3], a[4], a[5]]).toEqual([SKY_VIEW.width, SKY_VIEW.height, SKY_VIEW.depth]); + expect(a[9]).toEqual(skyViewWords()); + expect(calls.deleteTexture).toHaveLength(2); + }); + + it('dithers the sky output by half an 8-bit step either way, and the terrain not at all', () => { + expect(SKY_FRAG).toContain('- 0.5'); + expect(SKY_FRAG).toContain('dither / 255.0'); }); it('falls back to a still frame when the measured p95 is over 33 ms', () => { @@ -208,7 +241,7 @@ describe('createTerrainScene', () => { createTerrainScene({ canvas: canvasWith(gl), tier: 'full' })!.dispose(); expect(calls.deleteProgram).toHaveLength(3); expect(calls.deleteBuffer).toHaveLength(2); - expect(calls.deleteTexture).toHaveLength(1); + expect(calls.deleteTexture).toHaveLength(2); expect(rafQueue).toHaveLength(0); }); }); diff --git a/src/app/features/scene/terrain/Terrain.ts b/src/app/features/scene/terrain/Terrain.ts index 27718624..90d90ca2 100644 --- a/src/app/features/scene/terrain/Terrain.ts +++ b/src/app/features/scene/terrain/Terrain.ts @@ -1,17 +1,18 @@ import type { Scene, SceneOptions } from '../Scene'; import { FrameSampler, FrameStats } from '../ridge/probe'; import { skyColours } from '../ridge/skyColour'; -import { SkyName } from './skyData'; import { albedoPalette } from './terrainAlbedo'; +import { sunIndex } from '../../../../sky/scatter.js'; +import { SKY_VIEW, skyViewWords } from './terrainScatter'; import { - ambientColour, - hazeColour, + glowColour, HAZE_K, - hexToLinear, isSkyName, + skyAtSun, + SkyData, skyData, + SkyName, skyNameFor, - sunColour, sunDirection, } from './terrainSky'; import { buildGrid, CAMERA, GRID, seedOffset, TERRAIN, triangleCount, view } from './terrainWorld'; @@ -30,6 +31,8 @@ export type TerrainOptions = Pick< > & { /** Which baked sky lights and hazes the terrain. Default: `?sky=` in the page URL, else the night notion of `skyColours()`. */ sky?: SkyName; + /** Put the sun at this elevation (degrees) and azimuth (clockwise from north; default: noonday's) instead of at the baked sky's: `?sun=` and `?az=` in the page URL. Lights between the two bakes, for dusk. */ + sun?: { elevation: number; azimuth?: number }; /** Terrain seed: the height map is sampled at an offset taken from it. Default 0. */ seed?: number; /** Show the rAF median/p95 readout (plus triangle count). Default: the page URL has `?probe=1`. */ @@ -74,18 +77,25 @@ function program(gl: WebGL2RenderingContext, name: string, vs: string, fs: strin return p; } -/** The sky to use: the option, then `?sky=midnight|noonday`, then the night notion of `skyColours()`. */ -function chooseSky(opt: SkyName | undefined): SkyName { - if (opt) return opt; - const q = new URLSearchParams(window.location.search).get('sky'); - return isSkyName(q) ? q : skyNameFor(skyColours()); +/** The sky to use: a sun elevation from the option or `?sun=` (with `?az=`), else the baked sky from the option, then `?sky=midnight|noonday`, then the night notion of `skyColours()`. */ +function chooseSky(opts: Pick): SkyData { + const q = new URLSearchParams(window.location.search); + const number = (key: string) => (q.get(key)?.trim() ? Number(q.get(key)) : NaN); + const elevation = opts.sun?.elevation ?? number('sun'); + if (Number.isFinite(elevation)) { + const azimuth = opts.sun?.azimuth ?? number('az'); + return skyAtSun(elevation, Number.isFinite(azimuth) ? azimuth : undefined); + } + if (opts.sky) return skyData(opts.sky); + const name = q.get('sky'); + return skyData(isSkyName(name) ? name : skyNameFor(skyColours())); } /** * The terrain scene: same contract as `createRidgeScene`/`createScene` ({ login, dispose }, null for "keep the * CSS ground"). WebGL2 only. The height map is baked once on the GPU (Phacelle, see terrainShaders.ts) into an - * RGBA8 texture; each frame is two draws: the baked gradient sky, then a displaced grid lit by the baked sun and - * ambient and hazed toward the baked horizon colour. Cadence guard, pauses and the `?probe=1` readout match the + * RGBA8 texture; each frame is two draws: the sky from the baked scattering table, then a displaced grid lit by the + * baked sun and ambient and hazed toward that sky at the horizon. Cadence guard, pauses and the `?probe=1` readout match the * ridge scene. */ export function createTerrainScene(opts: TerrainOptions): Scene | null { @@ -101,7 +111,7 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { const units = gl.getParameter(gl.MAX_VERTEX_TEXTURE_IMAGE_UNITS); if (typeof units === 'number' && units < 1) return null; - const sky = skyData(chooseSky(opts.sky)); + const sky = chooseSky(opts); const spec = GRID[tier]; let skyProg: WebGLProgram; @@ -123,6 +133,29 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, spec.tex, spec.tex, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); + // The baked scattering table, RGB16F, uploaded as it is stored (half floats); the shader divides by the store scale. + const skyTex = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_3D, skyTex); + gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); + gl.texImage3D( + gl.TEXTURE_3D, + 0, + gl.RGB16F, + SKY_VIEW.width, + SKY_VIEW.height, + SKY_VIEW.depth, + 0, + gl.RGB, + gl.HALF_FLOAT, + skyViewWords() + ); + gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + [gl.TEXTURE_WRAP_S, gl.TEXTURE_WRAP_T, gl.TEXTURE_WRAP_R].forEach((w) => + gl.texParameteri(gl.TEXTURE_3D, w, gl.CLAMP_TO_EDGE) + ); + gl.pixelStorei(gl.UNPACK_ALIGNMENT, 4); + gl.bindTexture(gl.TEXTURE_2D, heightTex); gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, heightTex, 0); const complete = gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE; @@ -130,6 +163,7 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { const release = () => { free.forEach((f) => f()); gl.deleteTexture(heightTex); + gl.deleteTexture(skyTex); gl.deleteFramebuffer(fbo); gl.deleteProgram(bakeProg); gl.deleteProgram(skyProg); @@ -167,11 +201,11 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { () => gl.deleteBuffer(ibuf) ); - const gradient = new Float32Array(sky.gradient.flatMap((h) => [...hexToLinear(h)])); const sun = [...sunDirection(sky)]; - const sunLight = [...sunColour(sky)]; - const ambient = [...ambientColour(sky)]; - const haze = [...hazeColour(sky)]; + const sunLight = [...sky.direct]; + const ambient = [...sky.ambient]; + const glow = [...glowColour(sky)]; + const sunTexel = sunIndex(sky.elevation); const palette = albedoPalette(sky); const triangles = triangleCount(spec.grid); @@ -201,7 +235,13 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { gl.disable(gl.DEPTH_TEST); gl.useProgram(skyProg); const us = (n: string) => gl.getUniformLocation(skyProg, n); - gl.uniform3fv(us('uGrad'), gradient); + gl.uniform3fv(us('uSun'), sun); + gl.uniform3fv(us('uSunColour'), sunLight); + gl.uniform3fv(us('uGlow'), glow); + gl.uniform1f(us('uSunIndex'), sunTexel); + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_3D, skyTex); + gl.uniform1i(us('uSky'), 1); gl.uniform3fv(us('uRight'), v.right); gl.uniform3fv(us('uUp'), v.up); gl.uniform3fv(us('uForward'), v.forward); @@ -220,7 +260,11 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { gl.uniform3fv(u('uSun'), sun); gl.uniform3fv(u('uSunColour'), sunLight); gl.uniform3fv(u('uAmbient'), ambient); - gl.uniform3fv(u('uHaze'), haze); + gl.uniform3fv(u('uGlow'), glow); + gl.uniform1f(u('uSunIndex'), sunTexel); + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_3D, skyTex); + gl.uniform1i(u('uSky'), 1); gl.uniform1f(u('uHazeK'), HAZE_K); gl.uniform3fv(u('uSoil'), palette.soil); gl.uniform3fv(u('uScree'), palette.scree); diff --git a/src/app/features/scene/terrain/skyData.ts b/src/app/features/scene/terrain/skyData.ts deleted file mode 100644 index 2b75f405..00000000 --- a/src/app/features/scene/terrain/skyData.ts +++ /dev/null @@ -1,44 +0,0 @@ -// The sky numbers the terrain scene lights and hazes with, vendored from Void's baked sky (GameCult/Void -// PR #22, branch void/sky-tokens, revision 7b4aa2f): `src/sky/baked.ts` (generated by scripts/bake-sky.mjs -// from src/sky/sun.js and src/sky/skyTokens.js) and `light()` in `src/sky/sun.js`. Both moments are -// 2026-09-22 at lat 45, lon 0. -// -// elevation, gradient copied from baked.ts (`baked.midnight` / `baked.noonday`). -// direct `light({time, lat, lon}).direct` from sun.js at that moment: sun colour at normal -// incidence, linear RGB, clear zenith sun ~ 1. baked.ts does not carry it. -// azimuth NOT in #22. Computed here from the same NOAA/Meeus series sun.js uses for the -// elevation (hour angle and declination), degrees clockwise from north. It is the -// one number in this file Void's bake does not produce; see the report on the table shape. -// -// These are copies, not a link: #22 is not in this tree, so nothing fails if Void re-bakes. When #22 lands, -// replace this module's literals with an import of `baked` (plus the fields above, if the bake adds them) and -// keep the exports. - -export type SkyName = 'midnight' | 'noonday'; -export type Vec3 = readonly [number, number, number]; - -export interface SkyData { - /** Degrees above the horizon, no refraction. Negative: the sun is down. */ - elevation: number; - /** Degrees clockwise from north. */ - azimuth: number; - /** Sun colour at normal incidence, linear RGB. Black when the sun is down. */ - direct: Vec3; - /** Seven sRGB hex stops, zenith first, horizon last. */ - gradient: readonly string[]; -} - -export const SKY: Record = { - midnight: { - elevation: -44.579021600011224, - azimuth: 2.4988773054640205, - direct: [0, 0, 0], - gradient: ['#37446a', '#38456a', '#3b4669', '#3d4868', '#414a67', '#444c66', '#484e64'], - }, - noonday: { - elevation: 45.16994813502928, - azimuth: 182.58737070473097, - direct: [0.9189659216820631, 0.8444983652129638, 0.6933684802959983], - gradient: ['#95c6ff', '#9ac9ff', '#a5cfff', '#b3d6ff', '#c2dfff', '#d2e9ff', '#e2f4ff'], - }, -}; diff --git a/src/app/features/scene/terrain/terrainAlbedo.test.ts b/src/app/features/scene/terrain/terrainAlbedo.test.ts index 331b820a..0136b5f3 100644 --- a/src/app/features/scene/terrain/terrainAlbedo.test.ts +++ b/src/app/features/scene/terrain/terrainAlbedo.test.ts @@ -1,6 +1,6 @@ /* eslint-disable no-restricted-syntax -- tests loop over skies, heights and slopes */ import { describe, expect, it } from 'vitest'; -import { SKY } from './skyData'; +import { SKY } from './terrainSky'; import { ALBEDO, albedoAt, albedoPalette, wobble } from './terrainAlbedo'; import { GRID_FRAG } from './terrainShaders'; @@ -98,7 +98,7 @@ describe('shader agrees with the pure function on its numbers', () => { expect(GRID_FRAG).toContain(`#define ALB_SNOW_BAND ${ALBEDO.snowBand.toFixed(1)}`); expect(GRID_FRAG).toContain(`#define ALB_ROCK_LO ${ALBEDO.rockLo}`); expect(GRID_FRAG).toContain(`#define ALB_WOBBLE_S ${ALBEDO.wobbleSlope}`); - // the only fetch in the fragment shader is the height map's - expect(GRID_FRAG.match(/texelFetch|texture\(/g)).toHaveLength(1); + // the only fetch for colour-by-material would be a texture; the height map's texelFetch is the only other read, and the sky lookup is the baked scattering table's + expect(GRID_FRAG.match(/texelFetch|texture\((?!uSky)/g)).toHaveLength(1); }); }); diff --git a/src/app/features/scene/terrain/terrainAlbedo.ts b/src/app/features/scene/terrain/terrainAlbedo.ts index 703f9f0c..e3230a8b 100644 --- a/src/app/features/scene/terrain/terrainAlbedo.ts +++ b/src/app/features/scene/terrain/terrainAlbedo.ts @@ -1,10 +1,9 @@ // Terrain surface colour as a pure function of height, slope and world position, no texture reads: the shader // (terrainShaders.ts GRID_FRAG) does the same arithmetic with these numbers as uniforms and #defines. No DOM, no GL. // Rock where the face is steep, then by height soil, scree and snow; snow only settles on gentle faces. The per-sky -// part reads the baked sky (./skyData.ts): the snow line drops and every material is tinted toward the sky's colour +// part reads the baked sky (./terrainSky.ts): the snow line drops and every material is tinted toward the sky's colour // as the sun goes down, warm by the sun's own colour by day and cool by the horizon haze at night. -import { SkyData, Vec3 } from './skyData'; -import { coldness, hazeColour, sunColour } from './terrainSky'; +import { coldness, hazeColour, SkyData, Vec3 } from './terrainSky'; const clamp01 = (x: number) => Math.min(1, Math.max(0, x)); const smooth = (a: number, b: number, x: number) => { @@ -58,7 +57,7 @@ const unit = (c: Vec3): Vec3 => { /** The palette one baked sky gives: base reflectances tinted toward the sky's colour, and its snow line. */ export function albedoPalette(d: SkyData): AlbedoPalette { const cold = coldness(d); - const sun = unit(sunColour(d)); + const sun = unit(d.direct); const haze = unit(hazeColour(d)); const tint = [0, 1, 2].map((i) => mix(sun[i], haze[i], cold)); const k = mix(0.15, 0.5, cold); diff --git a/src/app/features/scene/terrain/terrainScatter.test.ts b/src/app/features/scene/terrain/terrainScatter.test.ts new file mode 100644 index 00000000..782d980e --- /dev/null +++ b/src/app/features/scene/terrain/terrainScatter.test.ts @@ -0,0 +1,88 @@ +/* eslint-disable no-restricted-syntax -- tests walk table texels */ +import { describe, expect, it } from 'vitest'; +import { baked } from '../../../../sky/baked'; +import { decode, viewElevation } from '../../../../sky/scatter.js'; +import { + SCATTER_GLSL, + scatterRadiance, + skyViewWords, + SKY_VIEW, + tableWords, + transmittanceAt, +} from './terrainScatter'; + +const S = baked.scatter.sky_view; + +describe('the table upload', () => { + it('decodes the baked base64 to one half-float word per channel per texel', () => { + const w = skyViewWords(); + expect(w.length).toBe(S.width * S.height * S.depth * S.channels); + expect(SKY_VIEW).toEqual({ width: S.width, height: S.height, depth: S.depth }); + expect(tableWords(baked.scatter.transmittance.data)).toHaveLength(32 * 3); + }); + + it('words are little-endian: the base64 bytes lo, hi make a word hi << 8 | lo', () => { + expect(Array.from(tableWords(btoa(String.fromCharCode(0x34, 0x12, 0xff, 0x7b))))).toEqual([ + 0x1234, 0x7bff, + ]); + }); +}); + +describe('scatterRadiance (the CPU twin of the sampler)', () => { + it('returns the stored texel exactly at a texel centre: layout x azimuth, y view, z sun', () => { + const w = skyViewWords(); + for (const [z, y, x] of [ + [10, 31, 0], + [8, 5, 3], + [5, 20, 6], + [11, 0, 2], + ]) { + const base = ((z * S.height + y) * S.width + x) * 3; + const got = scatterRadiance( + S.sun_elevations_deg[z], + viewElevation(y), + x * S.azimuth_step_deg + ); + got.forEach((v, c) => expect(v).toBeCloseTo(decode(w[base + c]), 9)); + } + }); + + it('interpolates between texels and clamps beyond the table', () => { + const a = scatterRadiance(25, 10, 0)[2]; + const b = scatterRadiance(45, 10, 0)[2]; + const mid = scatterRadiance(35, 10, 0)[2]; // 35 is halfway between 25 and 45 on the sun axis + expect(mid).toBeCloseTo((a + b) / 2, 9); + expect(scatterRadiance(500, 10, 400)).toEqual(scatterRadiance(90, 10, 180)); + expect(scatterRadiance(30, 10, -90)).toEqual(scatterRadiance(30, 10, 90)); + }); + + it('is black under the planet’s shadow limit and blue at a high noon zenith', () => { + scatterRadiance(-18, 30, 0).forEach((v) => expect(v).toBeLessThan(1e-6)); + const noon = scatterRadiance(45, 85, 90); + expect(noon[2]).toBeGreaterThan(1.5 * noon[0]); + }); +}); + +describe('transmittanceAt', () => { + it('is the zenith transmittance exp(-TAU) overhead and redder at the horizon', () => { + const z = transmittanceAt(90); + [0.06, 0.12, 0.26].forEach((tau, i) => expect(z[i]).toBeCloseTo(Math.exp(-tau), 2)); + const h = transmittanceAt(2); + expect(h[2] / h[0]).toBeLessThan(0.5 * (z[2] / z[0])); + }); + + it('is zero where the ground is in the way', () => { + expect(transmittanceAt(-4)).toEqual([0, 0, 0]); + }); +}); + +describe('the GLSL sampler', () => { + it('uses the layout the README documents: texel-centre coordinates over a 7 x 32 x 12 texture, decoded by the store scale', () => { + expect(SCATTER_GLSL).toContain('(x + 0.5) / 7.0'); + expect(SCATTER_GLSL).toContain('(y + 0.5) / 32.0'); + expect(SCATTER_GLSL).toContain('(uSunIndex + 0.5) / 12.0'); + expect(SCATTER_GLSL).toContain('/ 64.0'); + expect(SCATTER_GLSL).toContain('sqrt(clamp((el - (-4.0)) / 94.0, 0.0, 1.0)) * 31.0'); + expect(SCATTER_GLSL).toContain('* 6.0'); + }); +}); diff --git a/src/app/features/scene/terrain/terrainScatter.ts b/src/app/features/scene/terrain/terrainScatter.ts new file mode 100644 index 00000000..9149ac47 --- /dev/null +++ b/src/app/features/scene/terrain/terrainScatter.ts @@ -0,0 +1,94 @@ +// The baked scattering tables (src/sky/baked.ts `scatter`, layout in src/sky/README.md) as the terrain scene +// uses them: decoded to half-float words for the GPU, a CPU twin of the shader's sky lookup for tests and +// for the sun's colour at an arbitrary elevation, and the GLSL both shaders share. No DOM, no GL. +import { baked } from '../../../../sky/baked'; +import { decode, sunIndex, viewIndex } from '../../../../sky/scatter.js'; +import type { Vec3 } from './terrainSky'; + +const S = baked.scatter; +const SKY = S.sky_view; +const VIEW = S.view_elevation_deg; + +/** Raw little-endian half-float words of a base64 table, ready for `texImage3D(..., HALF_FLOAT, words)`. */ +export function tableWords(b64: string): Uint16Array { + const bytes = atob(b64); + return Uint16Array.from( + { length: bytes.length / 2 }, + (_, i) => bytes.charCodeAt(2 * i) + 256 * bytes.charCodeAt(2 * i + 1) + ); +} + +export const SKY_VIEW = { width: SKY.width, height: SKY.height, depth: SKY.depth } as const; +export const STORE_SCALE = S.store_scale; +export const skyViewWords = (): Uint16Array => tableWords(SKY.data); + +/** Decode a stored table to values, once. */ +const decoded = (data: string) => { + let values: Float32Array | undefined; + return (): Float32Array => { + if (!values) values = Float32Array.from(tableWords(data), decode); + return values; + }; +}; +const transmittanceTable = decoded(S.transmittance.data); +const skyTable = decoded(SKY.data); + +/** Sun colour at normal incidence for a sun at `elevationDeg`: the baked transmittance, per channel. Zero under the horizon. */ +export function transmittanceAt(elevationDeg: number): Vec3 { + const t = transmittanceTable(); + const f = Math.min(viewIndex(elevationDeg), VIEW.samples - 1); + const i = Math.min(Math.floor(f), VIEW.samples - 2); + const k = f - i; + return [0, 1, 2].map((c) => t[i * 3 + c] * (1 - k) + t[(i + 1) * 3 + c] * k) as unknown as Vec3; +} + +/** + * Sky radiance per steradian for a sun of unit irradiance, trilinear in (azimuth from the sun, view + * elevation, sun elevation) on texel centres, clamped at the edges: what the GPU's LINEAR sampler returns. + */ +export function scatterRadiance(sunElDeg: number, viewElDeg: number, azFromSunDeg: number): Vec3 { + const t = skyTable(); + const at = [ + Math.min(Math.abs(azFromSunDeg) / 180, 1) * (SKY.width - 1), + viewIndex(viewElDeg), + sunIndex(sunElDeg), + ]; + const lo = at.map((v, a) => Math.min(Math.floor(v), [SKY.width, SKY.height, SKY.depth][a] - 2)); + const f = at.map((v, a) => v - lo[a]); + const out = [0, 0, 0]; + for (let corner = 0; corner < 8; corner += 1) { + const dx = corner % 2; + const dy = Math.floor(corner / 2) % 2; + const dz = Math.floor(corner / 4); + const w = (dx ? f[0] : 1 - f[0]) * (dy ? f[1] : 1 - f[1]) * (dz ? f[2] : 1 - f[2]); + const base = (((lo[2] + dz) * SKY.height + lo[1] + dy) * SKY.width + lo[0] + dx) * 3; + for (let c = 0; c < 3; c += 1) out[c] += w * t[base + c]; + } + return [out[0], out[1], out[2]]; +} + +/** + * The lowest view elevation the sky is sampled at, degrees. The table dips toward the ground over its last degree + * (its rows below the horizon are for an observer who would hit the ground), so at and below this the sky and the + * terrain's haze both take the colour here, flat to the horizon, and meet without a seam. + */ +export const HORIZON_FLOOR = 1.5; + +/** The sky shader's lookup, in GLSL: `skyScatter(viewDirection)` is unit-sun radiance, needs uSky, uSunIndex, uSun. */ +export const SCATTER_GLSL = ` +uniform highp sampler3D uSky; +uniform float uSunIndex; +uniform vec3 uSun; +vec3 skyScatter(vec3 d) { + float el = max(degrees(asin(clamp(d.y, -1.0, 1.0))), ${HORIZON_FLOOR.toFixed(1)}); + vec2 h = d.xz, s = uSun.xz; + float az = (length(h) < 1e-4 || length(s) < 1e-4) ? 0.0 + : degrees(acos(clamp(dot(normalize(h), normalize(s)), -1.0, 1.0))); + float y = sqrt(clamp((el - (${VIEW.min.toFixed(1)})) / ${(VIEW.max - VIEW.min).toFixed( + 1 +)}, 0.0, 1.0)) * ${(VIEW.samples - 1).toFixed(1)}; + float x = clamp(az / 180.0, 0.0, 1.0) * ${(SKY.width - 1).toFixed(1)}; + return texture(uSky, vec3((x + 0.5) / ${SKY.width}.0, (y + 0.5) / ${ + SKY.height +}.0, (uSunIndex + 0.5) / ${SKY.depth}.0)).rgb / ${S.store_scale.toFixed(1)}; +}`; diff --git a/src/app/features/scene/terrain/terrainShaders.ts b/src/app/features/scene/terrain/terrainShaders.ts index 0e1783e7..8418e91a 100644 --- a/src/app/features/scene/terrain/terrainShaders.ts +++ b/src/app/features/scene/terrain/terrainShaders.ts @@ -8,6 +8,8 @@ import cultmath from '../shaders/CultMath.glsl?raw'; import phacelle from '../shaders/CultMath.Phacelle.glsl?raw'; import { trimGlsl } from '../cultmathTrim'; import { ALBEDO } from './terrainAlbedo'; +import { SCATTER_GLSL } from './terrainScatter'; +import { GLOW_ZENITH, SKY_EXPOSURE } from './terrainSky'; import { TERRAIN } from './terrainWorld'; const HEADER = @@ -107,7 +109,8 @@ void main() { gl_Position = uViewProj * vec4(v_world, 1.0); }`; -// Lit by the baked sky: uSun (direction), uSunColour (linear, zero when the sun is down), uAmbient, uHaze (linear). +// Lit by the baked sky: uSun (direction), uSunColour (linear, zero when the sun is down), uAmbient; hazed toward the +// scattered sky at the horizon in the ray's azimuth (SKY_COLOUR_GLSL). // The same arithmetic as terrainSky.shade(); the normal comes from the height map so shading carries detail finer than the mesh. const ALBEDO_DEFINES = ` #define ALB_SCREE_FROM ${lit(ALBEDO.screeFrom)} @@ -120,10 +123,20 @@ const ALBEDO_DEFINES = ` #define ALB_WOBBLE_H ${lit(ALBEDO.wobbleHeight)} #define ALB_WOBBLE_S ${lit(ALBEDO.wobbleSlope)} `; -export const GRID_FRAG = `${HEADER}${ALBEDO_DEFINES} +// The sky colour along a unit direction: scattering lifted by the exposure, tone-mapped, plus the night glow. The +// same arithmetic as terrainSky.skyColour(). uGlow is the baked horizon colour already scaled by how dark the sun is. +const SKY_COLOUR_GLSL = `${SCATTER_GLSL} +uniform vec3 uGlow; +vec3 skyColour(vec3 d) { + return 1.0 - exp(-${lit(SKY_EXPOSURE)} * skyScatter(d)) + uGlow * mix(1.0, ${lit( + GLOW_ZENITH +)}, sqrt(clamp(d.y, 0.0, 1.0))); +}`; + +export const GRID_FRAG = `${HEADER}${ALBEDO_DEFINES}${SKY_COLOUR_GLSL} in vec2 v_uv; in vec3 v_world; -uniform vec3 uCamera, uSun, uSunColour, uAmbient, uHaze, uSoil, uScree, uRock, uSnow; +uniform vec3 uCamera, uSunColour, uAmbient, uSoil, uScree, uRock, uSnow; uniform float uSpan, uHazeK, uSnowLine; out vec4 o; ${FETCH} @@ -151,13 +164,21 @@ void main() { float rockW = 1.0 - smoothstep(ALB_ROCK_LO, ALB_ROCK_HI, flat_); vec3 albedo = mix(mix(ground, uSnow, snow), uRock, rockW); vec3 lit = albedo * (uSunColour * max(dot(n, uSun), 0.0) + uAmbient * (0.5 + 0.5 * n.y)); - float T = exp(-uHazeK * length(v_world - uCamera)); - o = vec4(pow(mix(uHaze, lit, T), vec3(1.0 / 2.2)), 1.0); + vec3 toSurface = v_world - uCamera; + float T = exp(-uHazeK * length(toSurface)); + vec3 haze = skyColour(normalize(vec3(toSurface.x, 0.0, toSurface.z))); + o = vec4(pow(mix(haze, lit, T), vec3(1.0 / 2.2)), 1.0); }`; -// The sky behind: the gradient's seven stops, zenith first, by the view ray's elevation (horizon stop at and below the horizon). -export const SKY_FRAG = `${HEADER} -uniform vec3 uGrad[7]; +// The sky behind: the baked scattering tables by the view ray's elevation and azimuth from the sun, a sun disc, and a +// dither of +-0.5/255 on the 8-bit output so the dusk glow does not band. +const DISC = { + inner: Math.cos((0.5 * Math.PI) / 180), + outer: Math.cos((1.1 * Math.PI) / 180), + gain: 6.0, +}; +export const SKY_FRAG = `${HEADER}${SKY_COLOUR_GLSL} +uniform vec3 uSunColour; uniform vec3 uRight, uUp, uForward; uniform vec2 uSize; uniform float uTanHalf; @@ -165,8 +186,9 @@ out vec4 o; void main() { vec2 ndc = gl_FragCoord.xy / uSize * 2.0 - 1.0; vec3 d = normalize(uForward + ndc.x * uTanHalf * uSize.x / uSize.y * uRight + ndc.y * uTanHalf * uUp); - float s = (1.0 - sqrt(smoothstep(-0.15, 1.0, d.y))) * 6.0; - int i = int(min(floor(s), 5.0)); - vec3 c = mix(uGrad[i], uGrad[i + 1], s - float(i)); - o = vec4(pow(c, vec3(1.0 / 2.2)), 1.0); + vec3 c = skyColour(d) + uSunColour * ${lit(DISC.gain)} * smoothstep(${DISC.outer}, ${ + DISC.inner +}, dot(d, uSun)); + float dither = fract(52.9829189 * fract(dot(gl_FragCoord.xy, vec2(0.06711056, 0.00583715)))) - 0.5; + o = vec4(pow(c, vec3(1.0 / 2.2)) + dither / 255.0, 1.0); }`; diff --git a/src/app/features/scene/terrain/terrainSky.test.ts b/src/app/features/scene/terrain/terrainSky.test.ts index b3b4d21e..d8cb0129 100644 --- a/src/app/features/scene/terrain/terrainSky.test.ts +++ b/src/app/features/scene/terrain/terrainSky.test.ts @@ -1,29 +1,47 @@ /* eslint-disable no-restricted-syntax -- tests loop over skies, normals and distances */ import { describe, expect, it } from 'vitest'; -import { SKY } from './skyData'; +import { baked } from '../../../../sky/baked'; import { - AMBIENT_GAIN, - NIGHT_LIFT, - ambientColour, + coldness, + glowColour, hazeColour, hexToLinear, + nightness, shade, + SKY, + skyAtSun, + skyColour, skyNameFor, - sunColour, sunDirection, transmittance, + Vec3, } from './terrainSky'; import { skyColours } from '../ridge/skyColour'; const UP = [0, 1, 0] as const; const luma = (c: readonly number[]) => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; +/** Unit view direction at an elevation (degrees) and an azimuth clockwise from north. */ +const look = (el: number, az: number): Vec3 => { + const e = (el * Math.PI) / 180; + const a = (az * Math.PI) / 180; + return [Math.sin(a) * Math.cos(e), Math.sin(e), -Math.cos(a) * Math.cos(e)]; +}; -describe('baked sky numbers', () => { - it('keep the two moments apart: night sun below the horizon, noon sun well above', () => { +describe('the baked sky is read, not copied', () => { + it('SKY carries Void’s numbers: sun, ambient, horizon', () => { + for (const name of ['midnight', 'noonday'] as const) { + const b = baked[name]; + expect(SKY[name].elevation).toBe(b.sun.elevation_deg); + expect(SKY[name].azimuth).toBe(b.sun.azimuth_deg); + expect(SKY[name].direct).toEqual(b.sun.color_linear); + expect(SKY[name].ambient).toEqual(b.ambient_linear); + expect(SKY[name].horizon).toEqual(hexToLinear(b.horizon_hex)); + } + }); + + it('keeps the two moments apart: night sun below the horizon, noon sun well above', () => { expect(SKY.midnight.elevation).toBeLessThan(0); expect(SKY.noonday.elevation).toBeGreaterThan(30); - expect(SKY.midnight.gradient).toHaveLength(7); - expect(SKY.noonday.gradient).toHaveLength(7); }); it('sunDirection is a unit vector with y = sin(elevation), north at -z, south at +z', () => { @@ -48,67 +66,118 @@ describe('baked sky numbers', () => { }); }); -describe('night fill', () => { - it('lifts ambient at night by NIGHT_LIFT and leaves a high sun at the plain gain', () => { - const z = (d: typeof SKY.midnight) => hexToLinear(d.gradient[0]); - ambientColour(SKY.midnight).forEach((v, i) => expect(v).toBeCloseTo(z(SKY.midnight)[i] * AMBIENT_GAIN * NIGHT_LIFT, 9)); - ambientColour(SKY.noonday).forEach((v, i) => expect(v).toBeCloseTo(z(SKY.noonday)[i] * AMBIENT_GAIN, 9)); +describe('a sun at any elevation (skyAtSun)', () => { + it('lights nothing under the horizon and fades in across the last degrees, as sun.js does', () => { + expect(skyAtSun(-2).direct).toEqual([0, 0, 0]); + expect(skyAtSun(-30).direct).toEqual([0, 0, 0]); + const at = (e: number) => luma(skyAtSun(e).direct); + expect(at(0.3)).toBeGreaterThan(0); + expect(at(0.3)).toBeLessThan(at(1.5)); + expect(at(1.5)).toBeLessThan(at(60)); + }); + + it('agrees with the baked noon sun colour where they overlap, to a couple of percent', () => { + const want = SKY.noonday.direct; + const got = skyAtSun(SKY.noonday.elevation).direct; + got.forEach((v, i) => expect(Math.abs(v / want[i] - 1)).toBeLessThan(0.02)); + }); + + it('the sun goes red as it sets: less blue left in the direct light at 2 degrees than at 45', () => { + const lo = skyAtSun(2).direct; + const hi = skyAtSun(45).direct; + expect(lo[2] / lo[0]).toBeLessThan(0.6 * (hi[2] / hi[0])); + }); + + it('ambient and horizon run from the midnight bake to the noonday one as the sun climbs', () => { + expect(luma(skyAtSun(-30).ambient)).toBeCloseTo(luma(SKY.midnight.ambient), 9); + expect(luma(skyAtSun(60).ambient)).toBeCloseTo(luma(SKY.noonday.ambient), 9); + expect(luma(skyAtSun(5).ambient)).toBeGreaterThan(luma(SKY.midnight.ambient)); + expect(luma(skyAtSun(5).ambient)).toBeLessThan(luma(SKY.noonday.ambient)); + expect(skyAtSun(10, 270).azimuth).toBe(270); }); }); describe('lighting', () => { it('noonday lights an upward face with the sun; midnight has none', () => { - expect(luma(sunColour(SKY.noonday))).toBeGreaterThan(0.5); - expect(sunColour(SKY.midnight)).toEqual([0, 0, 0]); - }); - - it('a sun below the horizon gives no negative or sun light on any normal, even with a lit colour', () => { - const forcedDown = { ...SKY.noonday, elevation: -30 }; - expect(sunColour(forcedDown)).toEqual([0, 0, 0]); - for (const n of [UP, [1, 0, 0], [0, -1, 0], [0, 0.6, -0.8]] as const) { - const c = shade(forcedDown, [1, 1, 1], n, 0); - for (const ch of c) expect(ch).toBeGreaterThanOrEqual(0); - } - }); - - it('the sun fades out through the horizon instead of switching', () => { - const at = (elevation: number) => luma(sunColour({ ...SKY.noonday, elevation })); - expect(at(-2)).toBe(0); - expect(at(0.3)).toBeGreaterThan(0); - expect(at(0.3)).toBeLessThan(at(1.5)); - expect(at(1.5)).toBeCloseTo(at(60), 9); + expect(luma(SKY.noonday.direct)).toBeGreaterThan(0.5); + expect(SKY.midnight.direct).toEqual([0, 0, 0]); }); it('shade never goes negative on any normal at either sky, at any distance', () => { - for (const d of Object.values(SKY)) + for (const d of [...Object.values(SKY), skyAtSun(-3), skyAtSun(1)]) for (const n of [UP, [0, -1, 0], [1, 0, 0], [-0.6, 0.0, 0.8]] as const) for (const dist of [0, 100, 5000]) - for (const ch of shade(d, [0.3, 0.3, 0.3], n, dist)) expect(ch).toBeGreaterThanOrEqual(0); + for (const ch of shade(d, [0.3, 0.3, 0.3], n, dist, look(-5, 40))) + expect(ch).toBeGreaterThanOrEqual(0); }); it('a face turned to the noon sun is brighter than one turned away; at midnight the two match to ambient', () => { const toSun = sunDirection(SKY.noonday); const away = [-toSun[0], 0, -toSun[2]] as const; - const lit = luma(shade(SKY.noonday, [0.5, 0.5, 0.5], toSun, 0)); - const dark = luma(shade(SKY.noonday, [0.5, 0.5, 0.5], away, 0)); + const view = look(-5, 0); + const lit = luma(shade(SKY.noonday, [0.5, 0.5, 0.5], toSun, 0, view)); + const dark = luma(shade(SKY.noonday, [0.5, 0.5, 0.5], away, 0, view)); expect(lit).toBeGreaterThan(3 * dark); - const m = (n: readonly [number, number, number]) => shade(SKY.midnight, [0.5, 0.5, 0.5], n, 0); + const m = (n: readonly [number, number, number]) => + shade(SKY.midnight, [0.5, 0.5, 0.5], n, 0, view); expect(luma(m(UP))).toBeGreaterThan(0); // moonlit fill, not black expect(luma(m(UP))).toBeLessThan(0.05); }); it('noon is much brighter than midnight, ambient included', () => { - expect(luma(ambientColour(SKY.noonday))).toBeGreaterThan(2 * luma(ambientColour(SKY.midnight))); - expect(luma(shade(SKY.noonday, [0.4, 0.4, 0.4], UP, 0))).toBeGreaterThan( - 10 * luma(shade(SKY.midnight, [0.4, 0.4, 0.4], UP, 0)) + expect(luma(SKY.noonday.ambient)).toBeGreaterThan(2 * luma(SKY.midnight.ambient)); + const view = look(-5, 0); + expect(luma(shade(SKY.noonday, [0.4, 0.4, 0.4], UP, 0, view))).toBeGreaterThan( + 10 * luma(shade(SKY.midnight, [0.4, 0.4, 0.4], UP, 0, view)) ); }); - it('noon ambient matches sun.js diffuse at that moment to within a few percent (the gain)', () => { - const a = ambientColour(SKY.noonday); - const diffuse = [0.07, 0.132, 0.27]; // light() at 2026-09-22T12:00Z, 45N 0E, revision 7b4aa2f - for (let i = 0; i < 3; i += 1) expect(a[i] / diffuse[i]).toBeGreaterThan(0.85); - for (let i = 0; i < 3; i += 1) expect(a[i] / diffuse[i]).toBeLessThan(1.2); + it('the cold tint (albedo) is 0 with the sun up and 1 once it is below -6', () => { + expect(coldness(SKY.noonday)).toBe(0); + expect(coldness(SKY.midnight)).toBe(1); + expect(hazeColour(SKY.noonday)).toEqual(SKY.noonday.horizon); + }); +}); + +describe('the scattered sky', () => { + it('noon is blue overhead: more blue than red, much brighter than the night glow', () => { + const zenith = skyColour(SKY.noonday, [0, 1, 0]); + expect(zenith[2]).toBeGreaterThan(1.5 * zenith[0]); + expect(luma(zenith)).toBeGreaterThan(5 * luma(skyColour(SKY.midnight, [0, 1, 0]))); + zenith.forEach((v) => expect(v).toBeLessThan(1)); + }); + + it('a sun at the horizon reddens the sky toward it and darkens it away', () => { + const dusk = skyAtSun(-1, 270); + const toward = skyColour(dusk, look(3, 270)); + const away = skyColour(dusk, look(3, 90)); + const blueShare = (c: Vec3) => c[2] / (c[0] + c[1] + c[2]); + expect(blueShare(toward)).toBeLessThan(blueShare(away)); + expect(luma(toward)).toBeGreaterThan(3 * luma(away)); + }); + + it('under a deep night sun the tables are black and only the glow remains, brightest at the horizon', () => { + const d = SKY.midnight; + const horizon = skyColour(d, look(0, 180)); + const zenith = skyColour(d, [0, 1, 0]); + horizon.forEach((v, i) => expect(v).toBeCloseTo(d.horizon[i], 6)); + expect(luma(zenith)).toBeLessThan(luma(horizon)); + expect(luma(zenith)).toBeGreaterThan(0.5 * luma(horizon)); + }); + + it('the night glow fades out as the sun reaches the horizon', () => { + expect(nightness(-10)).toBe(1); + expect(nightness(5)).toBe(0); + expect(glowColour(SKY.noonday)).toEqual([0, 0, 0]); + expect(glowColour(SKY.midnight)).toEqual(SKY.midnight.horizon); + expect(luma(glowColour(skyAtSun(-2)))).toBeLessThan(luma(glowColour(skyAtSun(-6)))); + }); + + it('is continuous in the sun’s elevation through dusk: no step between neighbours', () => { + const at = (e: number) => luma(skyColour(skyAtSun(e, 270), look(10, 200))); + for (let e = -20; e < 30; e += 0.5) { + expect(Math.abs(at(e + 0.5) - at(e))).toBeLessThan(0.05); + } }); }); @@ -124,24 +193,33 @@ describe('haze', () => { } }); - it('far surfaces take the horizon colour of the baked gradient, at both skies', () => { - for (const d of Object.values(SKY)) { - const far = shade(d, [0.9, 0.2, 0.1], UP, 100000); - const h = hazeColour(d); - for (let i = 0; i < 3; i += 1) expect(far[i]).toBeCloseTo(h[i], 6); + it('far surfaces take the scattered sky at the horizon in their azimuth, at both skies', () => { + for (const d of [...Object.values(SKY), skyAtSun(2, 270)]) { + const far = shade(d, [0.9, 0.2, 0.1], UP, 100000, look(-3, 200)); + const sky = skyColour(d, look(0, 200)); + for (let i = 0; i < 3; i += 1) expect(far[i]).toBeCloseTo(sky[i], 6); } - expect(hazeColour(SKY.noonday)).toEqual(hexToLinear('#e2f4ff')); - expect(hazeColour(SKY.midnight)).toEqual(hexToLinear('#484e64')); + }); + + it('haze toward the dusk sun is warmer and brighter than haze away from it', () => { + const dusk = skyAtSun(-1, 270); + const toward = shade(dusk, [0, 0, 0], UP, 100000, look(-3, 270)); + const away = shade(dusk, [0, 0, 0], UP, 100000, look(-3, 90)); + const blueShare = (c: Vec3) => c[2] / (c[0] + c[1] + c[2]); + expect(luma(toward)).toBeGreaterThan(3 * luma(away)); + expect(blueShare(toward)).toBeLessThan(blueShare(away)); }); it('haze colour differs between the two skies: pale day horizon, dim night one', () => { - expect(luma(hazeColour(SKY.noonday))).toBeGreaterThan(5 * luma(hazeColour(SKY.midnight))); + const far = (d: typeof SKY.midnight) => luma(shade(d, [0, 0, 0], UP, 1e6, look(-3, 180))); + expect(far(SKY.noonday)).toBeGreaterThan(5 * far(SKY.midnight)); }); it('with distance a dark surface rises toward the haze and a bright one falls toward it', () => { const d = SKY.noonday; - const h = luma(hazeColour(d)); - const dark = (x: number) => luma(shade(d, [0.02, 0.02, 0.02], UP, x)); + const view = look(-4, 200); + const h = luma(skyColour(d, look(0, 200))); + const dark = (x: number) => luma(shade(d, [0.02, 0.02, 0.02], UP, x, view)); expect(dark(2000)).toBeGreaterThan(dark(200)); expect(dark(2000)).toBeLessThan(h); }); diff --git a/src/app/features/scene/terrain/terrainSky.ts b/src/app/features/scene/terrain/terrainSky.ts index d0f41c40..a7e38a39 100644 --- a/src/app/features/scene/terrain/terrainSky.ts +++ b/src/app/features/scene/terrain/terrainSky.ts @@ -1,8 +1,25 @@ -// Lighting and haze for the terrain scene, as pure functions of one sky's baked numbers (./skyData.ts). -// No DOM, no GL. The fragment shader does the same arithmetic with these results as uniforms. +// Lighting, haze and sky for the terrain scene, as pure functions of one sky's baked numbers (src/sky/baked.ts, Void's +// bake, GameCult/Void PR #22). No DOM, no GL. The fragment shaders do the same arithmetic with these results as uniforms. +import { baked } from '../../../../sky/baked'; import { isNight } from '../ridge/stars'; import type { SkyColours } from '../ridge/skyColour'; -import { SKY, SkyData, SkyName, Vec3 } from './skyData'; +import { HORIZON_FLOOR, scatterRadiance, transmittanceAt } from './terrainScatter'; + +export type SkyName = 'midnight' | 'noonday'; +export type Vec3 = readonly [number, number, number]; + +export interface SkyData { + /** Degrees above the horizon, no refraction. Negative: the sun is down. */ + elevation: number; + /** Degrees clockwise from north. */ + azimuth: number; + /** Sun colour at normal incidence, linear RGB. Black when the sun is down. */ + direct: Vec3; + /** Sky ambient on an upward surface, linear RGB; at night a moonlit floor, not black. */ + ambient: Vec3; + /** The horizon, linear RGB: the baked `horizon_hex`. */ + horizon: Vec3; +} const RAD = Math.PI / 180; const clamp01 = (x: number) => Math.min(1, Math.max(0, x)); @@ -10,6 +27,8 @@ const smooth = (a: number, b: number, x: number) => { const t = clamp01((x - a) / (b - a)); return t * t * (3 - 2 * t); }; +const mixVec = (a: Vec3, b: Vec3, t: number): Vec3 => + [0, 1, 2].map((i) => a[i] + (b[i] - a[i]) * t) as unknown as Vec3; /** Which baked sky a ridge-style `SkyColours` means: the night notion the stars use (`isNight`). */ export const skyNameFor = (sky: SkyColours): SkyName => (isNight(sky) ? 'midnight' : 'noonday'); @@ -25,6 +44,39 @@ export function hexToLinear(hex: string): Vec3 { return [ch(1), ch(3), ch(5)]; } +const fromBaked = (b: typeof baked.midnight | typeof baked.noonday): SkyData => ({ + elevation: b.sun.elevation_deg, + azimuth: b.sun.azimuth_deg, + direct: b.sun.color_linear, + ambient: b.ambient_linear, + horizon: hexToLinear(b.horizon_hex), +}); + +export const SKY: Record = { + midnight: fromBaked(baked.midnight), + noonday: fromBaked(baked.noonday), +}; + +export const skyData = (name: SkyName): SkyData => SKY[name]; + +/** + * A sky for a sun at any elevation, between the two baked ones. The sun's colour is the baked transmittance at + * that elevation, faded out across the disc's last degrees the way sun.js does (gone at -0.83, whole by 1.5); + * ambient and horizon blend from the midnight bake to the noonday one as the sun climbs from -8 to 25 degrees. + */ +export function skyAtSun(elevation: number, azimuth: number = SKY.noonday.azimuth): SkyData { + const day = smooth(-8, 25, elevation); + const fade = smooth(-0.83, 1.5, elevation); + const t = transmittanceAt(Math.max(elevation, -4)); + return { + elevation, + azimuth, + direct: [t[0] * fade, t[1] * fade, t[2] * fade], + ambient: mixVec(SKY.midnight.ambient, SKY.noonday.ambient, day), + horizon: mixVec(SKY.midnight.horizon, SKY.noonday.horizon, day), + }; +} + /** Unit vector toward the sun: x east, y up, z south (so azimuth 0, north, is -z). */ export function sunDirection(d: SkyData): Vec3 { const el = d.elevation * RAD; @@ -32,59 +84,68 @@ export function sunDirection(d: SkyData): Vec3 { return [Math.sin(az) * Math.cos(el), Math.sin(el), -Math.cos(az) * Math.cos(el)]; } -/** - * The sun's colour as the ground sees it: baked `direct`, off at the horizon the way sun.js fades the disc - * (gone at -0.83 degrees, soft to 1.5). A sun below the horizon lights nothing. - */ -export function sunColour(d: SkyData): Vec3 { - const k = smooth(-0.83, 1.5, d.elevation); - return [d.direct[0] * k, d.direct[1] * k, d.direct[2] * k]; -} - -/** - * Ambient light on an upward surface, from the baked gradient's zenith stop. The gain is chosen so the - * noonday value (0.075, 0.14, 0.25) matches sun.js's own `diffuse` there (0.07, 0.13, 0.27); at midnight - * the same gain turns the gradient's glow into a faint moonlit fill, where `diffuse` alone would be black. - */ -export const AMBIENT_GAIN = 0.25; - -/** 0 with the sun well up, 1 once it is below -6 degrees (civil twilight over): the night notion the palette and fill share. */ +/** 0 with the sun well up, 1 once it is below -6 degrees (civil twilight over): the night notion the palette shares. */ export const coldness = (d: SkyData): number => 1 - smooth(-6, 25, d.elevation); -/** Extra ambient at night, so a moonlit face is readable and not black on a phone: x4 at full night, 1 by day. */ -export const NIGHT_LIFT = 4; +/** The colour distance fades to when the air has nothing to scatter: the baked horizon. Albedo tints toward it at night. */ +export const hazeColour = (d: SkyData): Vec3 => d.horizon; -export function ambientColour(d: SkyData): Vec3 { - const z = hexToLinear(d.gradient[0]); - const k = AMBIENT_GAIN * (1 + (NIGHT_LIFT - 1) * coldness(d)); - return [z[0] * k, z[1] * k, z[2] * k]; +/** Sky radiance (see the sun's `skyScatter` in terrainScatter.ts) is for unit sun irradiance; this lifts it to the image. EYEBALLED. */ +export const SKY_EXPOSURE = 45; +/** How far the night glow dims from the horizon to the zenith (1 at the horizon). EYEBALLED. */ +export const GLOW_ZENITH = 0.75; + +/** 1 with the sun below -4 degrees, 0 above 4: where the midnight horizon colour stands in for light the single-scattering tables lack. */ +export const nightness = (elevation: number): number => 1 - smooth(-4, 4, elevation); + +/** The night glow colour: the midnight bake's horizon, fading out as the sun nears the horizon (whatever horizon the day blend gives `d`). */ +export const glowColour = (d: SkyData): Vec3 => { + const k = nightness(d.elevation); + const h = SKY.midnight.horizon; + return [h[0] * k, h[1] * k, h[2] * k]; +}; + +/** + * Linear colour of the sky along a unit view direction (x east, y up, z south): the baked single-scattering + * radiance for this sun, lifted by SKY_EXPOSURE and tone-mapped `1 - exp(-x)`, plus the night glow, which sits + * brightest at the horizon; at and below the horizon, the horizon's colour. The fragment shader does the same. + */ +export function skyColour(d: SkyData, dir: Vec3): Vec3 { + const el = Math.max(HORIZON_FLOOR, Math.asin(Math.min(1, Math.max(-1, dir[1]))) / RAD); + const sun = sunDirection(d); + const h = Math.hypot(dir[0], dir[2]); + const s = Math.hypot(sun[0], sun[2]); + const az = + h < 1e-4 || s < 1e-4 + ? 0 + : Math.acos(Math.min(1, Math.max(-1, (dir[0] * sun[0] + dir[2] * sun[2]) / (h * s)))) / RAD; + const r = scatterRadiance(d.elevation, el, az); + const glow = glowColour(d); + const shape = 1 + (GLOW_ZENITH - 1) * Math.sqrt(clamp01(dir[1])); + return [0, 1, 2].map( + (i) => 1 - Math.exp(-SKY_EXPOSURE * r[i]) + glow[i] * shape + ) as unknown as Vec3; } -/** The colour distance fades to: the gradient's horizon stop, linear. */ -export const hazeColour = (d: SkyData): Vec3 => hexToLinear(d.gradient[d.gradient.length - 1]); - /** Haze: transmittance T = exp(-k d) at distance d metres. */ export const HAZE_K = 0.0004; export const transmittance = (distance: number): number => Math.exp(-HAZE_K * Math.max(0, distance)); /** - * Linear colour of a surface: lambert from the sun plus ambient (full on an upward face, half on a - * vertical one, none on a downward one: `0.5 + 0.5 n.y`), then hazed toward the horizon colour by distance. - * `normal` is a unit vector. Never negative, whatever the sun does. + * Linear colour of a surface seen along the unit direction `view` (from the eye to the surface): lambert from + * the sun plus ambient (full on an upward face, half on a vertical one, none on a downward one: `0.5 + 0.5 n.y`), + * then hazed by distance toward the sky at the horizon in that direction. `normal` is a unit vector. Never negative. */ -export function shade(d: SkyData, albedo: Vec3, normal: Vec3, distance: number): Vec3 { +export function shade(d: SkyData, albedo: Vec3, normal: Vec3, distance: number, view: Vec3): Vec3 { const sun = sunDirection(d); const lambert = Math.max(0, normal[0] * sun[0] + normal[1] * sun[1] + normal[2] * sun[2]); - const direct = sunColour(d); - const amb = ambientColour(d); const hemi = 0.5 + 0.5 * normal[1]; - const haze = hazeColour(d); + const flat = Math.hypot(view[0], view[2]) || 1; + const haze = skyColour(d, [view[0] / flat, 0, view[2] / flat]); const t = transmittance(distance); return [0, 1, 2].map((i) => { - const lit = albedo[i] * (direct[i] * lambert + amb[i] * Math.max(0, hemi)); + const lit = albedo[i] * (d.direct[i] * lambert + d.ambient[i] * Math.max(0, hemi)); return lit * t + haze[i] * (1 - t); }) as unknown as Vec3; } - -export const skyData = (name: SkyName): SkyData => SKY[name]; diff --git a/src/app/features/scene/terrain/terrainStars.test.ts b/src/app/features/scene/terrain/terrainStars.test.ts index 370b6843..c1231bd8 100644 --- a/src/app/features/scene/terrain/terrainStars.test.ts +++ b/src/app/features/scene/terrain/terrainStars.test.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-empty-function -- DOM stubs, as the other scene tests */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { SKY } from './skyData'; +import { SKY, skyAtSun } from './terrainSky'; import { BRIGHTEN, createTerrainStars, @@ -9,12 +9,12 @@ import { FRAME_MS, horizonY, PERIOD, + nightColours, project, - skyColoursOf, + starGain, } from './terrainStars'; import { CAMERA, view } from './terrainWorld'; import { isNight, makeStars } from '../ridge/stars'; -import { skyNameFor } from './terrainSky'; /** A 2D context that only counts: arcs painted and clears since the last reset. */ function fakeCtx() { @@ -64,10 +64,21 @@ const glCanvas = (w = 390, h = 844) => ({ clientWidth: w, clientHeight: h, parentElement: parent } as any); describe('which skies get stars', () => { - it("midnight is dark, noonday is not, by the ridge module's own test", () => { - expect(isNight(skyColoursOf(SKY.midnight))).toBe(true); - expect(isNight(skyColoursOf(SKY.noonday))).toBe(false); - expect(skyNameFor(skyColoursOf(SKY.noonday))).toBe('noonday'); + it('the field is placed against the baked night gradient, which the ridge module calls dark', () => { + expect(isNight(nightColours())).toBe(true); + }); + + it('starGain: full below -12 degrees, none above -6, smooth and monotone between', () => { + expect(starGain(-44.6)).toBe(1); + expect(starGain(-12)).toBe(1); + expect(starGain(-6)).toBe(0); + expect(starGain(45.2)).toBe(0); + expect(starGain(-9)).toBeCloseTo(0.5, 9); + let prev = 1; + for (let e = -12; e <= -6; e += 0.25) { + expect(starGain(e)).toBeLessThanOrEqual(prev); + prev = starGain(e); + } }); it('noonday gets no layer and no element', () => { @@ -75,6 +86,17 @@ describe('which skies get stars', () => { expect(parent.appendChild).not.toHaveBeenCalled(); }); + it('twilight is a fade, not a gate: a sun at -9 degrees paints the stars at half alpha', () => { + createTerrainStars(glCanvas(), skyAtSun(-9))?.draw(0); + const half = Math.max(...ctx2d.c.alphas); + ctx2d.c.alphas = []; + createTerrainStars(glCanvas(), SKY.midnight)?.draw(0); + expect(half).toBeGreaterThan(0); + expect(half / Math.max(...ctx2d.c.alphas)).toBeCloseTo(0.5, 6); + expect(createTerrainStars(glCanvas(), skyAtSun(-6.5))).not.toBeNull(); + expect(createTerrainStars(glCanvas(), skyAtSun(-6))).toBeNull(); + }); + it('midnight adds one overlay to the canvas parent; dispose removes it', () => { const s = createTerrainStars(glCanvas(), SKY.midnight)!; expect(parent.appendChild).toHaveBeenCalledWith(overlay); @@ -172,12 +194,10 @@ describe('turning with the camera', () => { describe('brightness', () => { it('draws every star at BRIGHTEN times its ridge-field alpha, capped at 1', () => { createTerrainStars(glCanvas(), SKY.midnight)?.draw(0); - const want = makeStars(skyColoursOf(SKY.midnight), 390, 844).map((st) => - Math.min(1, st.alpha * BRIGHTEN) - ); + const want = makeStars(nightColours(), 390, 844).map((st) => Math.min(1, st.alpha * BRIGHTEN)); ctx2d.c.alphas.forEach((a) => expect(want.some((w) => Math.abs(w - a) < 1e-9)).toBe(true)); // and it is brighter than the ridge field's own top alpha - const top = Math.max(...makeStars(skyColoursOf(SKY.midnight), 390, 844).map((st) => st.alpha)); + const top = Math.max(...makeStars(nightColours(), 390, 844).map((st) => st.alpha)); expect(Math.max(...ctx2d.c.alphas)).toBeGreaterThan(top * 1.5); }); }); diff --git a/src/app/features/scene/terrain/terrainStars.ts b/src/app/features/scene/terrain/terrainStars.ts index 4fca72c9..a7275062 100644 --- a/src/app/features/scene/terrain/terrainStars.ts +++ b/src/app/features/scene/terrain/terrainStars.ts @@ -1,5 +1,5 @@ // The terrain scene's star layer: the ridge scene's starfield (ridge/stars.ts: placement, alpha, tint, the -// rolling twinkle schedule) on a 2D overlay canvas above the WebGL canvas, shown only when the baked sky is dark. +// rolling twinkle schedule) on a 2D overlay canvas above the WebGL canvas, faded by the sun's elevation (starGain). // // The WebGL canvas is opaque, so the overlay sits on top and is cut to the sky: it is only as tall as the // horizon line is low on screen. The camera has no roll and the eye (CAMERA.eye) is above the highest peak @@ -13,9 +13,10 @@ // the layer only paints when Terrain.ts draws, and a draw with no clock (reduced motion, still tier, resize) // paints every star at base brightness. +import { baked } from '../../../../sky/baked'; import type { SkyColours, Srgb } from '../ridge/skyColour'; -import { activeAt, isNight, makeStars, SKY_STOPS, Star } from '../ridge/stars'; -import { SkyData } from './skyData'; +import { activeAt, makeStars, SKY_STOPS, Star } from '../ridge/stars'; +import { SkyData } from './terrainSky'; import { CAMERA } from './terrainWorld'; /** Azimuth the star field repeats at (radians); wider than any horizontal field of view the layer is shown at. */ @@ -30,9 +31,19 @@ const TWINKLE_MS = 50; const hex = (h: string): Srgb => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16)) as unknown as Srgb; -/** The ridge star module's view of a baked terrain sky: zenith, the middle stop, and the horizon stop. */ -export function skyColoursOf(d: SkyData): SkyColours { - const g = d.gradient; +/** + * How much of the starfield shows with the sun at `elevation` degrees: all of it below -12 (where the baked + * scattering goes dark: the planet's shadow covers every sunlit air path), none above -6 (civil twilight), a smooth + * fade between. Not a dark/day gate: a sun at -9 shows the stars at half. + */ +export function starGain(elevation: number): number { + const t = Math.min(1, Math.max(0, (elevation + 12) / 6)); + return 1 - t * t * (3 - 2 * t); +} + +/** The ridge star module's view of the night sky it places stars against: the baked midnight gradient's zenith, middle and horizon stops. */ +export function nightColours(): SkyColours { + const g = baked.midnight.gradient; return { top: hex(g[0]), mid: hex(g[Math.floor(g.length / 2)]), @@ -84,11 +95,12 @@ export interface TerrainStars { dispose(): void; } -/** The layer for `canvas`'s parent, or null: a day sky, no parent, no 2D context. */ +/** The layer for `canvas`'s parent, or null: the sun is too high for any star, no parent, no 2D context. */ export function createTerrainStars(canvas: HTMLCanvasElement, sky: SkyData): TerrainStars | null { - const colours = skyColoursOf(sky); + const colours = nightColours(); + const gain = starGain(sky.elevation); const parent = canvas.parentElement; - if (!parent || !isNight(colours)) return null; + if (!parent || gain <= 0) return null; const el = document.createElement('canvas'); const ctx = el.getContext?.('2d'); if (!ctx) return null; @@ -129,8 +141,8 @@ export function createTerrainStars(canvas: HTMLCanvasElement, sky: SkyData): Ter const yaw = 2 * Math.PI * turns; lit.forEach(({ star, az, e }, i) => { const da = wrap(az - yaw); - const gain = seconds === undefined ? 1 : gains.get(i) ?? 1; - ctx.globalAlpha = Math.min(1, star.alpha * gain * BRIGHTEN); + const twinkle = seconds === undefined ? 1 : gains.get(i) ?? 1; + ctx.globalAlpha = Math.min(1, star.alpha * twinkle * BRIGHTEN) * gain; ctx.fillStyle = star.fill; [da, da > 0 ? da - PERIOD : da + PERIOD].forEach((d) => { const at = project(d, e, w, h); From 45aad35efabec9b416280f3f5454210e211535c8 Mon Sep 17 00:00:00 2001 From: Void Date: Sun, 8 Nov 2026 02:12:11 +0000 Subject: [PATCH 118/206] preview-demo: drop the submit-event shim; manifest link relative The host's CSP map grants allow-forms to /void/rat-* (live nginx, minds.gamecult.org), so native form submission fires its submit event and deliverSubmitEvents has nothing left to do. The manifest link was host-root (/manifest.json, a 404 under /void/rat-*); ./manifest.json resolves under the build's base. Co-Authored-By: Claude Sonnet 5.5 --- index.html | 2 +- src/preview-demo/install.ts | 32 -------------------------------- 2 files changed, 1 insertion(+), 33 deletions(-) diff --git a/index.html b/index.html index c9042aff..2585704c 100644 --- a/index.html +++ b/index.html @@ -23,7 +23,7 @@ - + diff --git a/src/preview-demo/install.ts b/src/preview-demo/install.ts index 36990a57..b182a935 100644 --- a/src/preview-demo/install.ts +++ b/src/preview-demo/install.ts @@ -41,37 +41,6 @@ const showBanner = (text: string) => { document.body.appendChild(el); }; -// The preview host's CSP sandbox has no allow-forms, so the browser cancels a native form -// submission before its submit event fires and no login button works. Deliver the event ourselves; -// nothing is submitted natively. Remove this when the host grants allow-forms to /void/rat-*. -const deliverSubmitEvents = (doc: Document) => { - const submit = (form: HTMLFormElement) => - form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); - doc.addEventListener( - 'click', - (e) => { - const button = (e.target as Element | null)?.closest?.( - 'button[type="submit"], input[type="submit"]' - ); - const form = (button as HTMLButtonElement | null)?.form; - if (!form || (button as HTMLButtonElement).disabled) return; - e.preventDefault(); - submit(form); - }, - true - ); - doc.addEventListener( - 'keydown', - (e) => { - const target = e.target as HTMLInputElement | null; - if (e.key !== 'Enter' || e.isComposing || target?.tagName !== 'INPUT' || !target.form) return; - e.preventDefault(); - submit(target.form); - }, - true - ); -}; - const parseBody = (body: unknown): unknown => { if (typeof body !== 'string') return undefined; try { @@ -139,7 +108,6 @@ export const installDemo = (win: Window & typeof globalThis = window) => { routeOf(String(url), origin) === 'own' ? realBeacon(url, data) : false; } - deliverSubmitEvents(document); const stored = win.localStorage.getItem('cinny_hs_base_url'); showBanner(stored !== null && stored !== DEMO_ORIGIN ? FOREIGN_SESSION_TEXT : BANNER_TEXT); return server; From b6a97046b0cd0c3aa5afdb40dc1874129613068e Mon Sep 17 00:00:00 2001 From: Void Date: Sun, 8 Nov 2026 07:55:54 +0000 Subject: [PATCH 119/206] docs: file zone design sketch Albums as threads, marks as custom reference events (or local), export, mobile layout, existing media code, and a four-slice first plan. Design only. Co-Authored-By: Claude Sonnet 5.5 --- docs/file-zone.md | 88 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 docs/file-zone.md diff --git a/docs/file-zone.md b/docs/file-zone.md new file mode 100644 index 00000000..eeca817a --- /dev/null +++ b/docs/file-zone.md @@ -0,0 +1,88 @@ +# File zone: design sketch + +Status: design only. No code exists. Everything below is a proposal; items marked **UNKNOWN** are not yet verified against the Body. + +Source: Meta ruling 2026-10-31, eureka mind `ratatoskr:ruling:file-zone-looks-like-file-sharing`. + +## What it is + +A file-sharing space beside the room timeline, not a view of it. A room's files appear as **albums**: a browsable grid or list with title, item count and date, opening into the files, with inline players and previews. The Matrix room (and its threads) is the storage and the fallback: other clients see ordinary messages and threads, and never need to know the zone exists. No homeserver change. + +First use (Aqua): listen to audio in place, mark each clip keep / fix / drop with a one-line reason, export the marks as a file. See `/opt/commons/handoff/aqua-listen-kits-20261108/` (static `listen.html` kits that emit `verdict.json` + `verdict.md`); the zone replaces emailing that page around. + +## Matrix event shapes (stock events only) + +- **File** = an ordinary `m.room.message` with `msgtype` `m.audio` / `m.image` / `m.video` / `m.file`, uploaded to the media repo. The existing builders in `src/app/features/room/msgContent.ts` already produce these (`getAudioMsgContent`, `getImageMsgContent`, `getVideoMsgContent`, `getFileMsgContent`). Unchanged. +- **Album** = a thread. The root is a text `m.room.message` whose `body` is the title (`m.text`), and files are posted with `m.relates_to: { rel_type: "m.thread", event_id: }`. Other clients see a normal thread; its count and last-event time come from the SDK's thread summary (`unsigned.m.relations.m.thread`: `count`, `latest_event`). Album date = root `origin_server_ts`. Cover = first image child, if any. + - Why a thread and not a custom state event or a space: needs no power-level or server change, degrades to something readable, and the SDK already tracks it. Cost: any member can add to it; an album is only as structured as its root. +- **Loose files** (no thread) fall into an implicit "Unsorted" album, newest first. +- **Custom marker on the root** so the zone can tell albums from chat threads: an extra content key, e.g. `org.gamecult.album: { v: 1 }`. Unknown keys are preserved by servers and ignored by other clients. **UNKNOWN:** whether to show every thread as an album instead and skip the marker; the marker is cleaner but is the one bit of non-stock shape. +- **Edits/redaction**: renaming = `m.replace` on the root; deleting a file = redact the child. Stock. + +## Marks (keep / fix / drop + one line) + +Two options; recommend a hybrid. + +1. **Local only**: marks live in the client (IndexedDB), never sent. Zero room noise, private, lost with the browser. Fine for a lone listener, wrong for "Aqua hears it, others see the verdict". +2. **Room events**: one small event per mark, related to the file event. Recommended shape: `m.room.message` `m.notice` in the same thread is visible to everyone but is chat spam. Better is an event type of our own, `org.gamecult.file_mark`, content `{ "m.relates_to": { "rel_type": "m.reference", "event_id": }, "verdict": "keep"|"fix"|"drop", "reason": "" }`. Latest mark per (sender, file) wins; changing one means sending a new event. Other clients ignore unknown event types, so no spam, and the room history stays the store. + - Alternative: `m.annotation` reactions with a key of `keep`/`fix`/`drop`, which stock clients already render; but a reaction key has no room for the reason line. Rejected for that. + - **UNKNOWN:** do the room's power levels allow custom event types for ordinary members (default `events_default` covers them); do synapse/tuwunel/conduwuit servers send unknown types through sync to us; encrypted rooms carry them as `m.room.encrypted` like any other (fine, expected). +3. **Hybrid (proposed)**: write marks locally first (instant, offline), publish to the room only on explicit "share marks". Keeps the player responsive and the room quiet. + +## Export + +Client-side only. "Export marks" builds a file from the marks the viewer can see for one album and either downloads it or uploads it into the album as another file: + +- `verdict.json`: `{ album, exported_at, marks: [{ file, sha256?, name, verdict, reason, by }] }` +- `verdict.md`: human table of the same. + +Matches the shape the `aqua-listen` kits already emit (`verdict.json` + `verdict.md`), so downstream tooling does not change. **UNKNOWN:** exact field names of the kits' `verdict.json`; read `tools/aqua-listen` in GameCult/AquaSynth (PR #16) before fixing the schema. Per ruling hygiene, JSON here is a xenos-boundary export, not state. + +## Mobile-first layout + +- Phone width is the design width; desktop adds columns. +- Level 1, album list: one column, each row = cover/icon, title, "N files", date. Tap opens. +- Level 2, album: list by default (title, duration/size), grid toggle for images. Audio rows play in place, one at a time (starting a clip stops the last), a persistent mini-player at the bottom with seek. +- Mark controls on each audio row: three large tap targets (keep / fix / drop) and a one-line text field that appears after a choice. Thumb-reachable, no modal. +- Back is one level up; the room timeline stays one tap away (the zone is a sibling view, not a replacement). **UNKNOWN:** where the entry lives in the room header; check `RoomViewHeader.tsx`, `Room.tsx` for how the member drawer and pin menu attach. + +## Upload + +Reuse the composer's pipeline, pointed at an album instead of the main timeline: + +- `createUploadAtom` / `useBindUploadAtom` (`src/app/state/upload.ts`), `UploadCard`, `upload-board` for progress. +- On success, the msgContent builder produces the event; the zone adds `m.relates_to` thread to the album root before sending (the composer already does this for thread replies, `RoomInput.tsx`). +- "New album" = send the root text event first (with the marker), then upload into it. Multi-file drop = N events, sent in order. +- Size limit from `MediaConfigLoader` (server `m.upload.size`). **UNKNOWN:** whether the 32 MB `shelf` kit's clips (42 x ~0.75 MB) should be uploaded individually or batched; individually is the only option without a server change. + +## Existing code to reuse (verified present in the repo) + +| need | where | +|---|---| +| audio player (play/seek/volume, encrypted download) | `src/app/components/message/content/AudioContent.tsx` (210 lines) | +| video, image, generic file | `.../content/VideoContent.tsx`, `ImageContent.tsx`, `FileContent.tsx` | +| full-screen image viewer | `src/app/components/image-viewer/ImageViewer.tsx` | +| media dispatch by msgtype | `src/app/components/RenderMessageContent.tsx`, `message/MsgTypeRenderers.tsx` | +| upload + progress | `state/upload.ts`, `components/upload-card`, `upload-board` | +| event content builders | `features/room/msgContent.ts` | +| thread plumbing | `features/room/message/Message.tsx`, `RoomTimeline.tsx`, `RoomInput.tsx` (thread relation on send) | +| room-scoped file/pin precedent | `features/room/room-pin-menu/RoomPinMenu.tsx` | + +Nothing named "gallery" exists as a feature; `AudioContent` takes its controls as render props (`renderMediaControl`), so it can be rehosted in a row without a fork. **UNKNOWN:** whether `AudioContent` can be rendered outside a timeline event context (it needs only `url`, `info`, `mimeType`, `encInfo`, from the event content, so probably yes). + +## First slice (what to build first) + +Goal: Aqua can open a room, see an album of clips, play them, mark them, export. Estimates are rough lines of TypeScript, tests excluded. + +1. **Read-only album list + album view** (~250 lines): derive albums from threads (marker or all-threads, see UNKNOWN), list/grid, rehost `AudioContent` and `ImageContent`. No upload, no marks. One new route/panel plus entry in the room header. This alone replaces "scroll the timeline". +2. **Local marks + export** (~200 lines): IndexedDB store keyed (room, file event id), three buttons and a reason field, `verdict.json`/`verdict.md` download. This is Aqua's actual workflow and needs no room writes. +3. **Upload into an album** (~150 lines): thread-related send through the existing upload atoms, "new album". +4. **Publish marks as room events** (~120 lines): `org.gamecult.file_mark`, read-back merge of other people's marks. + +Each is its own PR. Slices 1+2 serve the first use case; 3 and 4 follow once someone other than the listener needs to see the result. + +## Open questions for the Meta + +- Album = thread with a marker, or any thread? (above) +- Marks private-by-default with explicit share, or shared by default? (above: private) +- Is one flat album level enough, or do albums nest under a room's space structure? Proposed: flat, one level, per room. From 089dcc86e11ad99aa635fe125c8c8ed9a0759ffd Mon Sep 17 00:00:00 2001 From: Void Date: Sun, 8 Nov 2026 07:56:36 +0000 Subject: [PATCH 120/206] docs: file zone is one room, one thread per Mind; marks are thread replies Co-Authored-By: Claude Sonnet 5.5 --- docs/file-zone.md | 48 +++++++++++++++++++++++------------------------ 1 file changed, 23 insertions(+), 25 deletions(-) diff --git a/docs/file-zone.md b/docs/file-zone.md index eeca817a..ad0d3474 100644 --- a/docs/file-zone.md +++ b/docs/file-zone.md @@ -6,32 +6,31 @@ Source: Meta ruling 2026-10-31, eureka mind `ratatoskr:ruling:file-zone-looks-li ## What it is -A file-sharing space beside the room timeline, not a view of it. A room's files appear as **albums**: a browsable grid or list with title, item count and date, opening into the files, with inline players and previews. The Matrix room (and its threads) is the storage and the fallback: other clients see ordinary messages and threads, and never need to know the zone exists. No homeserver change. +A file-sharing space beside the room timeline, not a view of it. The zone is **one ordinary Matrix room with threaded discussions, one thread per Mind**. The UI presents each thread as an **album** (title, item count, date) and each post in it as a **file** with an inline player or preview. Matrix is the storage and the fallback: another client sees a normal room with threads and never needs the zone. No homeserver change. -First use (Aqua): listen to audio in place, mark each clip keep / fix / drop with a one-line reason, export the marks as a file. See `/opt/commons/handoff/aqua-listen-kits-20261108/` (static `listen.html` kits that emit `verdict.json` + `verdict.md`); the zone replaces emailing that page around. +First use (Aqua): listen to audio in place, mark each clip keep / fix / drop with a one-line reason, export the marks as a file. See `/opt/commons/handoff/aqua-listen-kits-20261108/` (static `listen.html` kits that emit `verdict.json` + `verdict.md`); the zone replaces sending that page around. ## Matrix event shapes (stock events only) -- **File** = an ordinary `m.room.message` with `msgtype` `m.audio` / `m.image` / `m.video` / `m.file`, uploaded to the media repo. The existing builders in `src/app/features/room/msgContent.ts` already produce these (`getAudioMsgContent`, `getImageMsgContent`, `getVideoMsgContent`, `getFileMsgContent`). Unchanged. -- **Album** = a thread. The root is a text `m.room.message` whose `body` is the title (`m.text`), and files are posted with `m.relates_to: { rel_type: "m.thread", event_id: }`. Other clients see a normal thread; its count and last-event time come from the SDK's thread summary (`unsigned.m.relations.m.thread`: `count`, `latest_event`). Album date = root `origin_server_ts`. Cover = first image child, if any. - - Why a thread and not a custom state event or a space: needs no power-level or server change, degrades to something readable, and the SDK already tracks it. Cost: any member can add to it; an album is only as structured as its root. -- **Loose files** (no thread) fall into an implicit "Unsorted" album, newest first. -- **Custom marker on the root** so the zone can tell albums from chat threads: an extra content key, e.g. `org.gamecult.album: { v: 1 }`. Unknown keys are preserved by servers and ignored by other clients. **UNKNOWN:** whether to show every thread as an album instead and skip the marker; the marker is cleaner but is the one bit of non-stock shape. -- **Edits/redaction**: renaming = `m.replace` on the root; deleting a file = redact the child. Stock. +- **Zone** = one room, chosen by the client (**UNKNOWN:** how the client knows which room is the zone: a room-account-data flag, a `m.room.create` type, or a configured room id in `config.json`; config is simplest). +- **Album** = a thread root: a text `m.room.message` posted by a Mind, `body` = the album title (the Mind's name, e.g. `void`). One thread per Mind, owned by authorship (the root's sender). **UNKNOWN:** whether a Mind may open more than one (e.g. per kit); the shape allows it with no change, the zone would then list several albums per author. Count and last-activity come from the SDK's thread summary (`unsigned.m.relations.m.thread`); album date = root `origin_server_ts`. Rename = `m.replace` on the root. +- **File** = a post in the thread: an `m.room.message` with `msgtype` `m.audio` / `m.image` / `m.video` / `m.file` and `m.relates_to: { rel_type: "m.thread", event_id: , is_falling_back: true, "m.in_reply_to": { event_id: } }`. The builders in `src/app/features/room/msgContent.ts` already make the content. Delete = redact. +- **Album vs chat thread**: with a single-purpose room, every thread is an album. No marker key is needed. A non-media reply in a thread (chat, a mark) is not a file and is not listed as one. +- **Cover** = first image file in the thread, if any. Loose room messages outside any thread are not shown in the zone; they remain in the timeline. ## Marks (keep / fix / drop + one line) -Two options; recommend a hybrid. +A mark is a **thread reply to the file it judges**, so it lives in the same thread, is readable in any client, and needs no custom event type: -1. **Local only**: marks live in the client (IndexedDB), never sent. Zero room noise, private, lost with the browser. Fine for a lone listener, wrong for "Aqua hears it, others see the verdict". -2. **Room events**: one small event per mark, related to the file event. Recommended shape: `m.room.message` `m.notice` in the same thread is visible to everyone but is chat spam. Better is an event type of our own, `org.gamecult.file_mark`, content `{ "m.relates_to": { "rel_type": "m.reference", "event_id": }, "verdict": "keep"|"fix"|"drop", "reason": "" }`. Latest mark per (sender, file) wins; changing one means sending a new event. Other clients ignore unknown event types, so no spam, and the room history stays the store. - - Alternative: `m.annotation` reactions with a key of `keep`/`fix`/`drop`, which stock clients already render; but a reaction key has no room for the reason line. Rejected for that. - - **UNKNOWN:** do the room's power levels allow custom event types for ordinary members (default `events_default` covers them); do synapse/tuwunel/conduwuit servers send unknown types through sync to us; encrypted rooms carry them as `m.room.encrypted` like any other (fine, expected). -3. **Hybrid (proposed)**: write marks locally first (instant, offline), publish to the room only on explicit "share marks". Keeps the player responsive and the room quiet. +- `m.room.message`, `msgtype` `m.text`, `body` = `keep: ` (or `fix:` / `drop:`), `m.relates_to` = `{ rel_type: "m.thread", event_id: , "m.in_reply_to": { event_id: } }`. +- Plus one structured key for the zone to read without parsing prose: `org.gamecult.mark: { verdict: "keep"|"fix"|"drop", reason: "", target: }`. Unknown keys survive servers and are ignored by other clients; the `body` is the fallback if the key is stripped or absent. **UNKNOWN:** whether to also parse a bare `keep: ...` reply from a client that cannot send the key; recommend yes, as a tolerant reader of `body`. +- Latest mark by a given sender on a given file wins; changing a mark is a new reply (or an `m.replace` of the old one; prefer replace so the thread does not grow). The zone shows the latest per sender, and everyone's marks are visible on the file. +- **Why replies and not `m.annotation`**: an annotation key is a short string with no room for the reason line, and stock clients show reactions as chips with no text. Annotations stay available for a lightweight unreasoned ack, if wanted (**UNKNOWN**, not recommended in v1). +- **Local marks**: the client keeps an unsent mark draft in IndexedDB so playback and marking stay instant and survive a dropped connection; "send" publishes the reply. Marks are visible to the room once sent (the room is the shared store). **UNKNOWN:** whether Aqua wants a "private until I export" mode; if so it is local-only and the draft never publishes. ## Export -Client-side only. "Export marks" builds a file from the marks the viewer can see for one album and either downloads it or uploads it into the album as another file: +Client-side only. "Export marks" builds a file from the mark replies in one album (the viewer's own, or everyone's) and either downloads it or uploads it into the album as another file: - `verdict.json`: `{ album, exported_at, marks: [{ file, sha256?, name, verdict, reason, by }] }` - `verdict.md`: human table of the same. @@ -51,8 +50,8 @@ Matches the shape the `aqua-listen` kits already emit (`verdict.json` + `verdict Reuse the composer's pipeline, pointed at an album instead of the main timeline: - `createUploadAtom` / `useBindUploadAtom` (`src/app/state/upload.ts`), `UploadCard`, `upload-board` for progress. -- On success, the msgContent builder produces the event; the zone adds `m.relates_to` thread to the album root before sending (the composer already does this for thread replies, `RoomInput.tsx`). -- "New album" = send the root text event first (with the marker), then upload into it. Multi-file drop = N events, sent in order. +- On success, the msgContent builder produces the event; the zone adds the thread relation to the album root before sending (the composer already does this for thread replies, `RoomInput.tsx`). +- "New album" = send the root text event, then upload into its thread. Multi-file drop = N events, sent in order. - Size limit from `MediaConfigLoader` (server `m.upload.size`). **UNKNOWN:** whether the 32 MB `shelf` kit's clips (42 x ~0.75 MB) should be uploaded individually or batched; individually is the only option without a server change. ## Existing code to reuse (verified present in the repo) @@ -72,17 +71,16 @@ Nothing named "gallery" exists as a feature; `AudioContent` takes its controls a ## First slice (what to build first) -Goal: Aqua can open a room, see an album of clips, play them, mark them, export. Estimates are rough lines of TypeScript, tests excluded. +Rough lines of TypeScript, tests excluded. -1. **Read-only album list + album view** (~250 lines): derive albums from threads (marker or all-threads, see UNKNOWN), list/grid, rehost `AudioContent` and `ImageContent`. No upload, no marks. One new route/panel plus entry in the room header. This alone replaces "scroll the timeline". -2. **Local marks + export** (~200 lines): IndexedDB store keyed (room, file event id), three buttons and a reason field, `verdict.json`/`verdict.md` download. This is Aqua's actual workflow and needs no room writes. +1. **Read-only albums + album view** (~250 lines): list the room's thread roots as albums (grouped or filterable by author = Mind), open one to list its file posts, rehost `AudioContent` and `ImageContent`. One route/panel plus a header entry. Depends on the thread list the SDK already keeps. +2. **Marks + export** (~220 lines): three buttons and a reason field per audio row, sent as the thread reply above (with an IndexedDB draft), latest-per-sender merge on read, `verdict.json`/`verdict.md` download. This is Aqua's workflow end to end. 3. **Upload into an album** (~150 lines): thread-related send through the existing upload atoms, "new album". -4. **Publish marks as room events** (~120 lines): `org.gamecult.file_mark`, read-back merge of other people's marks. -Each is its own PR. Slices 1+2 serve the first use case; 3 and 4 follow once someone other than the listener needs to see the result. +Slices 1+2 serve the first use case. Each is its own PR. Because marks are ordinary thread replies, there is no separate "publish" slice. ## Open questions for the Meta -- Album = thread with a marker, or any thread? (above) -- Marks private-by-default with explicit share, or shared by default? (above: private) -- Is one flat album level enough, or do albums nest under a room's space structure? Proposed: flat, one level, per room. +- How the client identifies the zone room (config id recommended). +- Edit-in-place (`m.replace`) versus new reply for changing a mark (replace recommended). +- Whether a private, never-published marking mode is wanted. From 002e0d608bd6e7f26c43f8cabbb2adf6e7103518 Mon Sep 17 00:00:00 2001 From: Void Date: Sun, 8 Nov 2026 07:57:30 +0000 Subject: [PATCH 121/206] docs: file zone marks and export follow aqua.listen.verdict.v1 Co-Authored-By: Claude Sonnet 5.5 --- docs/file-zone.md | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/docs/file-zone.md b/docs/file-zone.md index ad0d3474..3679eb13 100644 --- a/docs/file-zone.md +++ b/docs/file-zone.md @@ -23,19 +23,37 @@ First use (Aqua): listen to audio in place, mark each clip keep / fix / drop wit A mark is a **thread reply to the file it judges**, so it lives in the same thread, is readable in any client, and needs no custom event type: - `m.room.message`, `msgtype` `m.text`, `body` = `keep: ` (or `fix:` / `drop:`), `m.relates_to` = `{ rel_type: "m.thread", event_id: , "m.in_reply_to": { event_id: } }`. -- Plus one structured key for the zone to read without parsing prose: `org.gamecult.mark: { verdict: "keep"|"fix"|"drop", reason: "", target: }`. Unknown keys survive servers and are ignored by other clients; the `body` is the fallback if the key is stripped or absent. **UNKNOWN:** whether to also parse a bare `keep: ...` reply from a client that cannot send the key; recommend yes, as a tolerant reader of `body`. +- Plus one structured key for the zone to read without parsing prose: `org.gamecult.mark: { clip: "", verdict: "keep"|"fix"|"drop", reason: "", landed: 1-5|null, device: "headphones"|"speakers"|null, target: }`. `clip` is the kit's clip id (see Export); `landed` (how well the sound met its intended feel) and `device` are optional and null when unanswered. The `body` stays `verdict: reason` for other clients. Unknown keys survive servers and are ignored by other clients; the `body` is the fallback if the key is stripped or absent. **UNKNOWN:** whether to also parse a bare `keep: ...` reply from a client that cannot send the key; recommend yes, as a tolerant reader of `body`. - Latest mark by a given sender on a given file wins; changing a mark is a new reply (or an `m.replace` of the old one; prefer replace so the thread does not grow). The zone shows the latest per sender, and everyone's marks are visible on the file. - **Why replies and not `m.annotation`**: an annotation key is a short string with no room for the reason line, and stock clients show reactions as chips with no text. Annotations stay available for a lightweight unreasoned ack, if wanted (**UNKNOWN**, not recommended in v1). - **Local marks**: the client keeps an unsent mark draft in IndexedDB so playback and marking stay instant and survive a dropped connection; "send" publishes the reply. Marks are visible to the room once sent (the room is the shared store). **UNKNOWN:** whether Aqua wants a "private until I export" mode; if so it is local-only and the draft never publishes. ## Export -Client-side only. "Export marks" builds a file from the mark replies in one album (the viewer's own, or everyone's) and either downloads it or uploads it into the album as another file: +Client-side only. "Export" builds Aqua's verdict file from the mark replies in one album (one listener's marks per export; the sender is the `listener`) and either downloads it or uploads it into the album. Two files, as the kits write them: `verdict.json` and `verdict.md`. -- `verdict.json`: `{ album, exported_at, marks: [{ file, sha256?, name, verdict, reason, by }] }` -- `verdict.md`: human table of the same. +`verdict.json` is schema **`aqua.listen.verdict.v1`**, field for field what `buildVerdict` in the kit (`dings/listen.html`, section `buildVerdict`) emits, so Aqua's tooling reads it unchanged: -Matches the shape the `aqua-listen` kits already emit (`verdict.json` + `verdict.md`), so downstream tooling does not change. **UNKNOWN:** exact field names of the kits' `verdict.json`; read `tools/aqua-listen` in GameCult/AquaSynth (PR #16) before fixing the schema. Per ruling hygiene, JSON here is a xenos-boundary export, not state. +``` +{ schema: "aqua.listen.verdict.v1", + kit: { id, title }, // album root: id = root event id (or the kit id if the upload carried one), title = root body + exportedAt, // ISO time of export + seed, consent, // kit-only: zone writes seed: 0 (or omits), consent: true for the sender's own export (see UNKNOWN) + listener, // sender display name + device, // headphones|speakers|null, the sender's most-used mark device, or null + notes, // free text; zone leaves "" in v1 + summary: { total, keep, fix, drop, unanswered }, + clips: [{ position, id, title, group, feel, file, pair, slot, verdict, reason, landed, device }], + pairs: [{ a, b, preferred }] } // empty array when the album has no pairs +``` + +- `clips` has one row per file post in the album, in thread order (`position` = 1-based). `verdict` null and `reason` "" where the sender left no mark; those count as `unanswered`. `landed` is 1-5 or null; `device` is `headphones`, `speakers` or null (a mark's own value; per-clip, not the file-level `device`). +- `pairs`, `seed`, `consent` are optional for the zone: `pairs: []`, no `slot`/`pair` (null) unless the clip metadata supplies them. +- **Where clip metadata comes from.** A mark gets `id`, `verdict`, `reason`, `landed`, `device` from the reply. `title` = file post `body`; `file` = the post's `filename`/`body`. `group`, `feel`, `pair`, `slot` are kit data that Matrix has no slot for: carry them on the file post as one extra key `org.gamecult.clip: { id, group, feel, pair, slot }` written by whoever uploads the kit (a small uploader script, **UNKNOWN**, not in the first slice; without it those columns export as ""/null and the id falls back to the filename stem). +- `verdict.md` follows `verdictMarkdown` from the same file: header line, count line, the clip table (`# | clip | group | intended feel | verdict | landed (1-5) | device | reason`), pairs table when `pairs` is nonempty, notes. +- To avoid drift, the exporter should be a port of `buildVerdict`/`verdictMarkdown` with the schema string and field order pinned by a test that compares against a fixture produced by the kit. **UNKNOWN:** whether to share code (the kit's `logic.cjs`) rather than port; recommend port, ~80 lines, because the kit is a standalone page. +- **UNKNOWN:** `consent` is a kit-side checkbox before listening. The zone has no such step; recommend writing `true` only if the sender ticks a one-time "include my name in exports" toggle, else `false` and `listener: ""`. +- JSON here is a xenos-boundary export, not state; the room events are the state. ## Mobile-first layout @@ -74,7 +92,7 @@ Nothing named "gallery" exists as a feature; `AudioContent` takes its controls a Rough lines of TypeScript, tests excluded. 1. **Read-only albums + album view** (~250 lines): list the room's thread roots as albums (grouped or filterable by author = Mind), open one to list its file posts, rehost `AudioContent` and `ImageContent`. One route/panel plus a header entry. Depends on the thread list the SDK already keeps. -2. **Marks + export** (~220 lines): three buttons and a reason field per audio row, sent as the thread reply above (with an IndexedDB draft), latest-per-sender merge on read, `verdict.json`/`verdict.md` download. This is Aqua's workflow end to end. +2. **Marks + export** (~260 lines, with the schema port): three buttons, a landed 1-5 chip row, a headphones/speakers toggle and a reason field per audio row, sent as the thread reply above (with an IndexedDB draft), latest-per-sender merge on read, `verdict.json`/`verdict.md` download. This is Aqua's workflow end to end. 3. **Upload into an album** (~150 lines): thread-related send through the existing upload atoms, "new album". Slices 1+2 serve the first use case. Each is its own PR. Because marks are ordinary thread replies, there is no separate "publish" slice. From ba592523d8e119813c6b01c21e2401fb58e92c47 Mon Sep 17 00:00:00 2001 From: Void Date: Sun, 8 Nov 2026 08:05:29 +0000 Subject: [PATCH 122/206] docs: file zone marks are verdict and landed reactions plus optional reply Co-Authored-By: Claude Sonnet 5.5 --- docs/file-zone.md | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/docs/file-zone.md b/docs/file-zone.md index 3679eb13..01b5a4c5 100644 --- a/docs/file-zone.md +++ b/docs/file-zone.md @@ -18,19 +18,21 @@ First use (Aqua): listen to audio in place, mark each clip keep / fix / drop wit - **Album vs chat thread**: with a single-purpose room, every thread is an album. No marker key is needed. A non-media reply in a thread (chat, a mark) is not a file and is not listed as one. - **Cover** = first image file in the thread, if any. Loose room messages outside any thread are not shown in the zone; they remain in the timeline. -## Marks (keep / fix / drop + one line) +## Marks (reactions, plus an optional reply) -A mark is a **thread reply to the file it judges**, so it lives in the same thread, is readable in any client, and needs no custom event type: +A mark is stock Matrix, so every client shows it. Two reaction sets on the **file post**, and one optional reply: -- `m.room.message`, `msgtype` `m.text`, `body` = `keep: ` (or `fix:` / `drop:`), `m.relates_to` = `{ rel_type: "m.thread", event_id: , "m.in_reply_to": { event_id: } }`. -- Plus one structured key for the zone to read without parsing prose: `org.gamecult.mark: { clip: "", verdict: "keep"|"fix"|"drop", reason: "", landed: 1-5|null, device: "headphones"|"speakers"|null, target: }`. `clip` is the kit's clip id (see Export); `landed` (how well the sound met its intended feel) and `device` are optional and null when unanswered. The `body` stays `verdict: reason` for other clients. Unknown keys survive servers and are ignored by other clients; the `body` is the fallback if the key is stripped or absent. **UNKNOWN:** whether to also parse a bare `keep: ...` reply from a client that cannot send the key; recommend yes, as a tolerant reader of `body`. -- Latest mark by a given sender on a given file wins; changing a mark is a new reply (or an `m.replace` of the old one; prefer replace so the thread does not grow). The zone shows the latest per sender, and everyone's marks are visible on the file. -- **Why replies and not `m.annotation`**: an annotation key is a short string with no room for the reason line, and stock clients show reactions as chips with no text. Annotations stay available for a lightweight unreasoned ack, if wanted (**UNKNOWN**, not recommended in v1). -- **Local marks**: the client keeps an unsent mark draft in IndexedDB so playback and marking stay instant and survive a dropped connection; "send" publishes the reply. Marks are visible to the room once sent (the room is the shared store). **UNKNOWN:** whether Aqua wants a "private until I export" mode; if so it is local-only and the draft never publishes. +- **Verdict** = an `m.annotation` reaction (`m.reaction` event, `m.relates_to: { rel_type: "m.annotation", event_id: , key }`) with key **👍 keep**, **🔧 fix**, **🗑 drop**. One per person per file: choosing another replaces the old one (the client redacts the sender's previous verdict reaction, then sends the new one); tapping the active one clears it. +- **Landed** = a second reaction set, keys **1️⃣ 2️⃣ 3️⃣ 4️⃣ 5️⃣** (keycap digits, 1-5). Also one per person per file, same replace rule. The two sets are independent: a file can have a verdict, a landed score, both or neither. +- **Reason** = an ordinary thread reply under the file: `m.room.message` `m.text`, `m.relates_to: { rel_type: "m.thread", event_id: , "m.in_reply_to": { event_id: } }`. Optional. **Keep needs none**; the UI prompts for one mainly after fix or drop. The latest such reply by the sender under that file is the reason (edits via `m.replace` count). No special key: the reply text is the reason, so nothing is lost if another client wrote it. +- **No private mode.** Marks are room events the moment they are tapped; there is no local-only draft and no "Share" step. Offline taps queue in the SDK's normal send queue. +- Who marked what is read from the reactions' senders, so each listener is distinguishable and the album can show tallies per file. +- Why reactions: one tap, readable in any client, no custom event type, and they can be changed with no history growth beyond a redaction. Reaction keys carry no text, hence the separate reply for the reason. +- **UNKNOWN:** emoji variation selectors. 🗑 and 🔧 and the keycaps may arrive with or without U+FE0F from different clients; match by normalizing (strip U+FE0F) before comparing keys. Also **UNKNOWN:** servers' per-event limits on distinct reaction keys; a handful per file is far below any default. ## Export -Client-side only. "Export" builds Aqua's verdict file from the mark replies in one album (one listener's marks per export; the sender is the `listener`) and either downloads it or uploads it into the album. Two files, as the kits write them: `verdict.json` and `verdict.md`. +Client-side only. "Export" builds Aqua's verdict file from the verdict and landed reactions and the reply under each file in one album (one listener's marks per export; that sender is the `listener`) and either downloads it or uploads it into the album. Two files, as the kits write them: `verdict.json` and `verdict.md`. `verdict.json` is schema **`aqua.listen.verdict.v1`**, field for field what `buildVerdict` in the kit (`dings/listen.html`, section `buildVerdict`) emits, so Aqua's tooling reads it unchanged: @@ -47,9 +49,9 @@ Client-side only. "Export" builds Aqua's verdict file from the mark replies in o pairs: [{ a, b, preferred }] } // empty array when the album has no pairs ``` -- `clips` has one row per file post in the album, in thread order (`position` = 1-based). `verdict` null and `reason` "" where the sender left no mark; those count as `unanswered`. `landed` is 1-5 or null; `device` is `headphones`, `speakers` or null (a mark's own value; per-clip, not the file-level `device`). +- `clips` has one row per file post in the album, in thread order (`position` = 1-based). The join per file, for the exporting listener: `verdict` = their 👍/🔧/🗑 reaction (keep/fix/drop) or null; `landed` = their keycap reaction (1-5) or null; `reason` = text of their latest reply under the file, or "". A file with no verdict reaction counts as `unanswered` even if it has a reply. `device` per clip is null (reactions cannot carry it); the top-level `device` (headphones|speakers|null) is asked once in the export dialog. - `pairs`, `seed`, `consent` are optional for the zone: `pairs: []`, no `slot`/`pair` (null) unless the clip metadata supplies them. -- **Where clip metadata comes from.** A mark gets `id`, `verdict`, `reason`, `landed`, `device` from the reply. `title` = file post `body`; `file` = the post's `filename`/`body`. `group`, `feel`, `pair`, `slot` are kit data that Matrix has no slot for: carry them on the file post as one extra key `org.gamecult.clip: { id, group, feel, pair, slot }` written by whoever uploads the kit (a small uploader script, **UNKNOWN**, not in the first slice; without it those columns export as ""/null and the id falls back to the filename stem). +- **Where clip metadata comes from.** `id` is the clip id from `org.gamecult.clip`, else the filename stem. `title` = file post `body`; `file` = the post's `filename`/`body`. `group`, `feel`, `pair`, `slot` are kit data that Matrix has no slot for: carry them on the file post as one extra key `org.gamecult.clip: { id, group, feel, pair, slot }` written by whoever uploads the kit (a small uploader script, **UNKNOWN**, not in the first slice; without it those columns export as ""/null and the id falls back to the filename stem). - `verdict.md` follows `verdictMarkdown` from the same file: header line, count line, the clip table (`# | clip | group | intended feel | verdict | landed (1-5) | device | reason`), pairs table when `pairs` is nonempty, notes. - To avoid drift, the exporter should be a port of `buildVerdict`/`verdictMarkdown` with the schema string and field order pinned by a test that compares against a fixture produced by the kit. **UNKNOWN:** whether to share code (the kit's `logic.cjs`) rather than port; recommend port, ~80 lines, because the kit is a standalone page. - **UNKNOWN:** `consent` is a kit-side checkbox before listening. The zone has no such step; recommend writing `true` only if the sender ticks a one-time "include my name in exports" toggle, else `false` and `listener: ""`. @@ -60,7 +62,7 @@ Client-side only. "Export" builds Aqua's verdict file from the mark replies in o - Phone width is the design width; desktop adds columns. - Level 1, album list: one column, each row = cover/icon, title, "N files", date. Tap opens. - Level 2, album: list by default (title, duration/size), grid toggle for images. Audio rows play in place, one at a time (starting a clip stops the last), a persistent mini-player at the bottom with seek. -- Mark controls on each audio row: three large tap targets (keep / fix / drop) and a one-line text field that appears after a choice. Thumb-reachable, no modal. +- Mark controls on each audio row: three large tap targets (👍 keep / 🔧 fix / 🗑 drop) with tallies, a row of five "landed" chips (1-5), and after fix or drop an inline one-line reason field (skippable). Thumb-reachable, no modal. - Back is one level up; the room timeline stays one tap away (the zone is a sibling view, not a replacement). **UNKNOWN:** where the entry lives in the room header; check `RoomViewHeader.tsx`, `Room.tsx` for how the member drawer and pin menu attach. ## Upload @@ -92,7 +94,7 @@ Nothing named "gallery" exists as a feature; `AudioContent` takes its controls a Rough lines of TypeScript, tests excluded. 1. **Read-only albums + album view** (~250 lines): list the room's thread roots as albums (grouped or filterable by author = Mind), open one to list its file posts, rehost `AudioContent` and `ImageContent`. One route/panel plus a header entry. Depends on the thread list the SDK already keeps. -2. **Marks + export** (~260 lines, with the schema port): three buttons, a landed 1-5 chip row, a headphones/speakers toggle and a reason field per audio row, sent as the thread reply above (with an IndexedDB draft), latest-per-sender merge on read, `verdict.json`/`verdict.md` download. This is Aqua's workflow end to end. +2. **Marks + export** (~240 lines, with the schema port): verdict and landed reaction sets with the replace-one rule, optional reason reply, per-file tallies, the join into `aqua.listen.verdict.v1` and `verdict.json`/`verdict.md` download. This is Aqua's workflow end to end. 3. **Upload into an album** (~150 lines): thread-related send through the existing upload atoms, "new album". Slices 1+2 serve the first use case. Each is its own PR. Because marks are ordinary thread replies, there is no separate "publish" slice. @@ -100,5 +102,5 @@ Slices 1+2 serve the first use case. Each is its own PR. Because marks are ordin ## Open questions for the Meta - How the client identifies the zone room (config id recommended). -- Edit-in-place (`m.replace`) versus new reply for changing a mark (replace recommended). -- Whether a private, never-published marking mode is wanted. +- Whether exports are per listener (proposed) or a merged all-listeners file. +- Where `device` comes from, now that reactions cannot carry it (export dialog, proposed). From e007514af3b8aac104f76995c13df06cc2f59d18 Mon Sep 17 00:00:00 2001 From: Nibu Date: Sun, 8 Nov 2026 08:10:28 +0000 Subject: [PATCH 123/206] scene: terrain falls back to its sky alone on a 2D canvas where WebGL2 is missing createTerrainScene returned null without WebGL2, leaving plain dark ground. The door now mounts terrain/TerrainSky2D.ts on the same canvas: skyColour (the CPU twin of SKY_FRAG) painted once, the star layer on top when dark, same ?sky= ?sun= ?az= choice. chooseSky moves from Terrain.ts to terrainSky.ts so both scenes share one owner. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/README.md | 5 + src/app/features/scene/index.ts | 14 +- src/app/features/scene/terrain/Terrain.ts | 27 +-- .../scene/terrain/TerrainSky2D.test.ts | 220 ++++++++++++++++++ .../features/scene/terrain/TerrainSky2D.ts | 83 +++++++ src/app/features/scene/terrain/terrainSky.ts | 18 ++ 6 files changed, 337 insertions(+), 30 deletions(-) create mode 100644 src/app/features/scene/terrain/TerrainSky2D.test.ts create mode 100644 src/app/features/scene/terrain/TerrainSky2D.ts diff --git a/src/app/features/scene/README.md b/src/app/features/scene/README.md index 5bf2ef59..35075536 100644 --- a/src/app/features/scene/README.md +++ b/src/app/features/scene/README.md @@ -51,6 +51,11 @@ Page URL: `?sky=midnight|noonday` picks a bake; `?sun=&az= Scene | null; try { if (config?.style === 'raymarch') createScene = (await import('./Scene')).createScene; - else if (config?.style === 'terrain') - createScene = (await import('./terrain/Terrain')).createTerrainScene; - else createScene = (await import('./ridge/Ridge')).createRidgeScene; + else if (config?.style === 'terrain') { + const { createTerrainScene } = await import('./terrain/Terrain'); + const scene = createTerrainScene({ ...options, tier }); + // No WebGL2 (or no usable GL): the terrain's sky alone on the same canvas, not bare ground. + if (scene) return scene; + const { createTerrainSkyScene } = await import('./terrain/TerrainSky2D'); + return createTerrainSkyScene({ ...options, tier }); + } else createScene = (await import('./ridge/Ridge')).createRidgeScene; } catch (e) { options.onCompileFail?.(e instanceof Error ? e : new Error(String(e))); return null; diff --git a/src/app/features/scene/terrain/Terrain.ts b/src/app/features/scene/terrain/Terrain.ts index 90d90ca2..3db90d3c 100644 --- a/src/app/features/scene/terrain/Terrain.ts +++ b/src/app/features/scene/terrain/Terrain.ts @@ -1,20 +1,9 @@ import type { Scene, SceneOptions } from '../Scene'; import { FrameSampler, FrameStats } from '../ridge/probe'; -import { skyColours } from '../ridge/skyColour'; import { albedoPalette } from './terrainAlbedo'; import { sunIndex } from '../../../../sky/scatter.js'; import { SKY_VIEW, skyViewWords } from './terrainScatter'; -import { - glowColour, - HAZE_K, - isSkyName, - skyAtSun, - SkyData, - skyData, - SkyName, - skyNameFor, - sunDirection, -} from './terrainSky'; +import { glowColour, HAZE_K, chooseSky, SkyName, sunDirection } from './terrainSky'; import { buildGrid, CAMERA, GRID, seedOffset, TERRAIN, triangleCount, view } from './terrainWorld'; import { FULLSCREEN_VERT, @@ -77,20 +66,6 @@ function program(gl: WebGL2RenderingContext, name: string, vs: string, fs: strin return p; } -/** The sky to use: a sun elevation from the option or `?sun=` (with `?az=`), else the baked sky from the option, then `?sky=midnight|noonday`, then the night notion of `skyColours()`. */ -function chooseSky(opts: Pick): SkyData { - const q = new URLSearchParams(window.location.search); - const number = (key: string) => (q.get(key)?.trim() ? Number(q.get(key)) : NaN); - const elevation = opts.sun?.elevation ?? number('sun'); - if (Number.isFinite(elevation)) { - const azimuth = opts.sun?.azimuth ?? number('az'); - return skyAtSun(elevation, Number.isFinite(azimuth) ? azimuth : undefined); - } - if (opts.sky) return skyData(opts.sky); - const name = q.get('sky'); - return skyData(isSkyName(name) ? name : skyNameFor(skyColours())); -} - /** * The terrain scene: same contract as `createRidgeScene`/`createScene` ({ login, dispose }, null for "keep the * CSS ground"). WebGL2 only. The height map is baked once on the GPU (Phacelle, see terrainShaders.ts) into an diff --git a/src/app/features/scene/terrain/TerrainSky2D.test.ts b/src/app/features/scene/terrain/TerrainSky2D.test.ts new file mode 100644 index 00000000..c3eb1e3d --- /dev/null +++ b/src/app/features/scene/terrain/TerrainSky2D.test.ts @@ -0,0 +1,220 @@ +/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-empty-function -- DOM stubs, as the other scene tests */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createTerrainSkyScene, SKY_WIDTH } from './TerrainSky2D'; +import { startBackgroundScene } from '../index'; + +let search: string; +let arcs: number; +let overlay: { remove: ReturnType }; +let windowListeners: Record void>; +beforeEach(() => { + search = ''; + arcs = 0; + windowListeners = {}; + vi.stubGlobal('document', { + createElement: () => { + overlay = { + remove: vi.fn(), + style: {}, + setAttribute() {}, + getContext: () => ({ + clearRect() {}, + beginPath() {}, + fill() {}, + arc: () => { + arcs += 1; + }, + }), + } as any; + return overlay; + }, + }); + vi.stubGlobal('window', { + devicePixelRatio: 1, + innerWidth: 400, + innerHeight: 800, + addEventListener: (n: string, f: () => void) => { + windowListeners[n] = f; + }, + removeEventListener: (n: string) => { + delete windowListeners[n]; + }, + get location() { + return { search }; + }, + setTimeout, + }); +}); +afterEach(() => { + vi.unstubAllGlobals(); +}); + +/** A canvas with a recording 2D context; `gl` is what 'webgl2' answers. */ +function canvasWith(opts: { twoD?: boolean; gl?: unknown } = {}) { + const painted: { data: Uint8ClampedArray; width: number; height: number }[] = []; + const ctx = { + createImageData: (width: number, height: number) => ({ + width, + height, + data: new Uint8ClampedArray(width * height * 4), + }), + putImageData: (image: any) => painted.push(image), + }; + const parentAppend = vi.fn(); + const canvas: any = { + width: 0, + height: 0, + clientWidth: 400, + clientHeight: 800, + parentElement: { appendChild: parentAppend }, + getContext: vi.fn((kind: string) => { + if (kind !== '2d') return opts.gl ?? null; + return opts.twoD === false ? null : ctx; + }), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }; + return { canvas, painted, parentAppend }; +} + +const pixel = (img: { data: Uint8ClampedArray; width: number }, x: number, y: number) => + Array.from(img.data.slice((y * img.width + x) * 4, (y * img.width + x) * 4 + 3)); +const mean = (img: { data: Uint8ClampedArray }) => { + let s = 0; + for (let i = 0; i < img.data.length; i += 4) s += img.data[i] + img.data[i + 1] + img.data[i + 2]; + return s / (img.data.length / 4) / 3; +}; + +describe('createTerrainSkyScene', () => { + it("returns null at tier 'off' and touches nothing", () => { + const { canvas } = canvasWith(); + expect(createTerrainSkyScene({ canvas, tier: 'off' })).toBeNull(); + expect(canvas.getContext).not.toHaveBeenCalled(); + }); + + it('returns null when the canvas gives no 2D context (the CSS ground stays)', () => { + const { canvas } = canvasWith({ twoD: false }); + expect(createTerrainSkyScene({ canvas, tier: 'low' })).toBeNull(); + }); + + it('paints one opaque, graded sky: the horizon end of the image is not the zenith end', () => { + const { canvas, painted } = canvasWith(); + const s = createTerrainSkyScene({ canvas, tier: 'low', sky: 'noonday' })!; + expect(painted).toHaveLength(1); + const img = painted[0]; + expect(img.width).toBe(SKY_WIDTH); + expect(canvas.width).toBe(SKY_WIDTH); + expect(canvas.height).toBe(img.height); + for (let i = 3; i < img.data.length; i += 4) expect(img.data[i]).toBe(255); + const x = Math.floor(SKY_WIDTH / 2); + expect(pixel(img, x, 0)).not.toEqual(pixel(img, x, img.height - 1)); + expect(mean(img)).toBeGreaterThan(20); + s.dispose(); + }); + + it('follows the baked sky: noon is brighter than midnight, and ?sun= / ?az= move it', () => { + const noon = canvasWith(); + createTerrainSkyScene({ canvas: noon.canvas, tier: 'low', sky: 'noonday' })!.dispose(); + const night = canvasWith(); + createTerrainSkyScene({ canvas: night.canvas, tier: 'low', sky: 'midnight' })!.dispose(); + expect(mean(noon.painted[0])).toBeGreaterThan(mean(night.painted[0]) * 1.5); + + const low = canvasWith(); + createTerrainSkyScene({ canvas: low.canvas, tier: 'low', sun: { elevation: 30 } })!.dispose(); + const down = canvasWith(); + createTerrainSkyScene({ canvas: down.canvas, tier: 'low', sun: { elevation: -20 } })!.dispose(); + expect(mean(low.painted[0])).toBeGreaterThan(mean(down.painted[0])); + + search = '?sun=30&az=0'; + const q0 = canvasWith(); + createTerrainSkyScene({ canvas: q0.canvas, tier: 'low' })!.dispose(); + search = '?sun=30&az=180'; + const q1 = canvasWith(); + createTerrainSkyScene({ canvas: q1.canvas, tier: 'low' })!.dispose(); + expect(Array.from(q0.painted[0].data)).not.toEqual(Array.from(q1.painted[0].data)); + }); + + it('stars only on a dark sky', () => { + const night = canvasWith(); + const a = createTerrainSkyScene({ canvas: night.canvas, tier: 'low', sky: 'midnight' })!; + expect(night.parentAppend).toHaveBeenCalledTimes(1); + expect(arcs).toBeGreaterThan(0); + a.dispose(); + expect(overlay.remove).toHaveBeenCalled(); + arcs = 0; + const day = canvasWith(); + createTerrainSkyScene({ canvas: day.canvas, tier: 'low', sky: 'noonday' })!.dispose(); + expect(day.parentAppend).not.toHaveBeenCalled(); + expect(arcs).toBe(0); + }); + + it('repaints on resize, and dispose stops listening', () => { + vi.useFakeTimers(); + vi.stubGlobal('window', { + ...(window as any), + setTimeout, + addEventListener: (window as any).addEventListener, + }); + const { canvas, painted } = canvasWith(); + const s = createTerrainSkyScene({ canvas, tier: 'low', sky: 'noonday' })!; + expect(windowListeners.resize).toBeDefined(); + windowListeners.resize(); + vi.advanceTimersByTime(300); + expect(painted).toHaveLength(2); + s.dispose(); + expect(windowListeners.resize).toBeUndefined(); + vi.useRealTimers(); + }); +}); + +describe('startBackgroundScene, terrain style', () => { + it('without WebGL2 mounts the sky on the same canvas instead of returning null', async () => { + const { canvas, painted } = canvasWith({ gl: null }); + const scene = await startBackgroundScene({ tier: 'low', style: 'terrain' }, {}, { canvas }); + expect(scene).not.toBeNull(); + expect(canvas.getContext.mock.calls.map((c: unknown[]) => c[0])).toEqual(['webgl2', '2d']); + expect(painted).toHaveLength(1); + scene!.dispose(); + }); + + it('with WebGL2 the terrain runs and the 2D sky is never requested', async () => { + const gl: any = new Proxy( + {}, + { + get(_t, name: string) { + if (name === 'getShaderParameter' || name === 'getProgramParameter') return () => true; + if (name === 'checkFramebufferStatus') return () => 1; + if (name === 'getParameter') return () => 16; + if (/^[A-Z_0-9]+$/.test(name)) return 1; + return () => ({}); + }, + } + ); + Object.assign(document, { + addEventListener() {}, + removeEventListener() {}, + visibilityState: 'visible', + }); + vi.stubGlobal('requestAnimationFrame', () => 1); + vi.stubGlobal('cancelAnimationFrame', () => {}); + vi.stubGlobal('performance', { now: () => 0 }); + (window as any).matchMedia = () => ({ + matches: false, + addEventListener() {}, + removeEventListener() {}, + }); + const { canvas, painted } = canvasWith({ gl }); + const scene = await startBackgroundScene({ tier: 'low', style: 'terrain' }, {}, { canvas }); + expect(scene).not.toBeNull(); + expect(canvas.getContext.mock.calls.map((c: unknown[]) => c[0])).toEqual(['webgl2']); + expect(painted).toHaveLength(0); + scene!.dispose(); + }); + + it('a canvas that took a GL context but gives no scene still ends as null: no 2D context to paint on', async () => { + const { canvas } = canvasWith({ gl: null, twoD: false }); + expect( + await startBackgroundScene({ tier: 'low', style: 'terrain' }, {}, { canvas }) + ).toBeNull(); + }); +}); diff --git a/src/app/features/scene/terrain/TerrainSky2D.ts b/src/app/features/scene/terrain/TerrainSky2D.ts new file mode 100644 index 00000000..e61c6fee --- /dev/null +++ b/src/app/features/scene/terrain/TerrainSky2D.ts @@ -0,0 +1,83 @@ +import type { Scene } from '../Scene'; +import type { TerrainOptions } from './Terrain'; +import { chooseSky, skyColour, Vec3 } from './terrainSky'; +import { createTerrainStars } from './terrainStars'; +import { CAMERA, view } from './terrainWorld'; + +/** The sky is smooth, so it is painted this many px wide and stretched by the canvas's CSS size. */ +export const SKY_WIDTH = 96; + +/** The sky of the terrain scene without WebGL2: the camera's first frame of `skyColour`, once, on a 2D canvas, with the star layer on top. */ +export type TerrainSkyOptions = Pick; + +/** + * Same contract as `createTerrainScene`. Paints the terrain scene's sky (the CPU twin of its sky shader, from the + * same baked tables and the same `?sun=` `?az=` `?sky=` choice) for the camera at its start, still: nothing moves, so + * a device that cannot run WebGL2 pays one paint and one resize repaint. Below the horizon there is no terrain, so + * the image continues with the horizon's colour. Null for "keep the CSS ground": tier off, or the canvas already + * holds a WebGL context and gives no 2D one. + */ +export function createTerrainSkyScene(opts: TerrainSkyOptions): Scene | null { + if ((opts.tier ?? 'full') === 'off') return null; + const { canvas } = opts; + const ctx = canvas.getContext('2d'); + if (!ctx) return null; + const sky = chooseSky(opts); + const stars = createTerrainStars(canvas, sky); + const tanHalf = Math.tan(CAMERA.fov / 2); + let disposed = false; + let resizeTimer = 0; + + const paint = () => { + const w = SKY_WIDTH; + const cw = canvas.clientWidth || window.innerWidth; + const ch = canvas.clientHeight || window.innerHeight; + const h = Math.max(1, Math.round((w * ch) / cw)); + canvas.width = w; + canvas.height = h; + const v = view(cw / ch, 0); + const image = ctx.createImageData(w, h); + for (let y = 0; y < h; y += 1) { + const ny = 1 - ((y + 0.5) / h) * 2; + for (let x = 0; x < w; x += 1) { + const nx = ((x + 0.5) / w) * 2 - 1; + const d = [0, 1, 2].map( + (i) => v.forward[i] + nx * tanHalf * (cw / ch) * v.right[i] + ny * tanHalf * v.up[i] + ); + const len = Math.hypot(d[0], d[1], d[2]); + const c: Vec3 = skyColour(sky, [d[0] / len, d[1] / len, d[2] / len]); + const at = (y * w + x) * 4; + for (let i = 0; i < 3; i += 1) { + image.data[at + i] = Math.round(255 * Math.min(1, Math.max(0, c[i])) ** (1 / 2.2)); + } + image.data[at + 3] = 255; + } + } + ctx.putImageData(image, 0, 0); + stars?.draw(0); + }; + + const onResize = () => { + clearTimeout(resizeTimer); + resizeTimer = window.setTimeout(() => { + if (disposed) return; + stars?.resize(); + paint(); + }, 250); + }; + window.addEventListener('resize', onResize); + paint(); + + return { + login() { + // Nothing moves, so there is no pull-back to start. + }, + dispose() { + if (disposed) return; + disposed = true; + clearTimeout(resizeTimer); + window.removeEventListener('resize', onResize); + stars?.dispose(); + }, + }; +} diff --git a/src/app/features/scene/terrain/terrainSky.ts b/src/app/features/scene/terrain/terrainSky.ts index a7e38a39..b77ddbf2 100644 --- a/src/app/features/scene/terrain/terrainSky.ts +++ b/src/app/features/scene/terrain/terrainSky.ts @@ -2,6 +2,7 @@ // bake, GameCult/Void PR #22). No DOM, no GL. The fragment shaders do the same arithmetic with these results as uniforms. import { baked } from '../../../../sky/baked'; import { isNight } from '../ridge/stars'; +import { skyColours } from '../ridge/skyColour'; import type { SkyColours } from '../ridge/skyColour'; import { HORIZON_FLOOR, scatterRadiance, transmittanceAt } from './terrainScatter'; @@ -149,3 +150,20 @@ export function shade(d: SkyData, albedo: Vec3, normal: Vec3, distance: number, return lit * t + haze[i] * (1 - t); }) as unknown as Vec3; } + +/** The sky to use: a sun elevation from the option or `?sun=` (with `?az=`), else the baked sky from the option, then `?sky=midnight|noonday`, then the night notion of `skyColours()`. */ +export function chooseSky(opts: { + sky?: SkyName; + sun?: { elevation: number; azimuth?: number }; +}): SkyData { + const q = new URLSearchParams(window.location.search); + const number = (key: string) => (q.get(key)?.trim() ? Number(q.get(key)) : NaN); + const elevation = opts.sun?.elevation ?? number('sun'); + if (Number.isFinite(elevation)) { + const azimuth = opts.sun?.azimuth ?? number('az'); + return skyAtSun(elevation, Number.isFinite(azimuth) ? azimuth : undefined); + } + if (opts.sky) return skyData(opts.sky); + const name = q.get('sky'); + return skyData(isSkyName(name) ? name : skyNameFor(skyColours())); +} From 252fabc1389c9edaa27802dc794179b24d8b276d Mon Sep 17 00:00:00 2001 From: Void Date: Sun, 8 Nov 2026 17:08:51 +0000 Subject: [PATCH 124/206] Upload failures keep their name, size and file readability The catch in uploadContent set errcode from e.message and dropped an empty message, so the SDK's status-0 AbortError (DOMException with empty message) surfaced as "MatrixError: Unknown message". uploadFailure now uses e.name for errcode, keeps the original as cause, and puts size, type and whether the file can still be read into the message the upload card shows. Co-Authored-By: Claude Sonnet 5.5 --- src/app/utils/matrix.ts | 26 +++++++++++++++-- src/app/utils/uploadFailure.test.ts | 44 +++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 src/app/utils/uploadFailure.test.ts diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts index 2859dbc3..34497de0 100644 --- a/src/app/utils/matrix.ts +++ b/src/app/utils/matrix.ts @@ -145,6 +145,28 @@ export type ContentUploadOptions = { onError: (error: MatrixError) => void; }; +/** + * Describe why an upload died. The SDK reports an XHR with status 0 as an + * AbortError DOMException with an empty message, so name, size, type and + * whether the browser can still read the file all go into the message. + */ +export const uploadFailure = async (e: any, file: TUploadContent): Promise => { + const name = typeof e?.name === 'string' && e.name ? e.name : 'Error'; + const message = typeof e?.message === 'string' ? e.message : ''; + let readable: string; + try { + await file.arrayBuffer(); + readable = 'file readable'; + } catch (readErr: any) { + readable = `file unreadable (${readErr?.name ?? 'unknown'})`; + } + const detail = `${file.size} bytes, ${file.type || 'unknown type'}, ${readable}`; + const error = `Upload failed: ${message ? `${name}: ${message}` : name} (${detail})`; + const err = new MatrixError({ error, errcode: name }); + (err as any).cause = e; + return err; +}; + export const uploadContent = async ( mx: MatrixClient, file: TUploadContent, @@ -165,9 +187,7 @@ export const uploadContent = async ( if (mxc) onSuccess(mxc); else onError(new MatrixError(data)); } catch (e: any) { - const error = typeof e?.message === 'string' ? e.message : undefined; - const errcode = typeof e?.name === 'string' ? e.message : undefined; - onError(new MatrixError({ error, errcode })); + onError(await uploadFailure(e, file)); } }; diff --git a/src/app/utils/uploadFailure.test.ts b/src/app/utils/uploadFailure.test.ts new file mode 100644 index 00000000..7a2e62b1 --- /dev/null +++ b/src/app/utils/uploadFailure.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { uploadFailure } from './matrix'; + +const abort = () => new DOMException('', 'AbortError'); + +describe('uploadFailure', () => { + it('keeps the error name as errcode, never the message', async () => { + const err = await uploadFailure(abort(), new Blob(['abc'], { type: 'image/png' })); + expect(err.errcode).toBe('AbortError'); + }); + + it('never reduces an empty-message error to "Unknown message"', async () => { + const err = await uploadFailure(abort(), new Blob(['abc'])); + expect(err.message).not.toContain('Unknown message'); + expect(err.message).toContain('AbortError'); + }); + + it('reports size and type of the file', async () => { + const err = await uploadFailure(abort(), new Blob(['abcd'], { type: 'image/png' })); + expect(err.message).toContain('4 bytes'); + expect(err.message).toContain('image/png'); + }); + + it('keeps the original error as cause', async () => { + const e = abort(); + const err = await uploadFailure(e, new Blob(['x'])); + expect((err as any).cause).toBe(e); + }); + + it('says whether the browser could still read the file', async () => { + const ok = await uploadFailure(abort(), new Blob(['x'])); + expect(ok.message).toContain('file readable'); + const bad = new Blob(['x']); + (bad as any).arrayBuffer = () => Promise.reject(new DOMException('gone', 'NotReadableError')); + const err = await uploadFailure(abort(), bad); + expect(err.message).toContain('file unreadable (NotReadableError)'); + }); + + it('keeps a real message when the error has one', async () => { + const err = await uploadFailure(new Error('Timeout'), new Blob(['x'])); + expect(err.message).toContain('Timeout'); + expect(err.errcode).toBe('Error'); + }); +}); From 16cc05a33b214b74acdb2b7dda4b877b5af0b665 Mon Sep 17 00:00:00 2001 From: Void Date: Sun, 8 Nov 2026 17:20:13 +0000 Subject: [PATCH 125/206] phone text flow: portrait width floor, list markers, in-flow preview banner, pretend-server notice - portrait on phones is min(38vw,160px), was min(50vw,210px): at 390px the text column beside it was ~85px - outside list markers ignored the portrait silhouette and landed under the art; inside markers wrap with the text - Jump to Latest sits 8px (was 16px) above the timeline's lower edge - preview banner is in flow above the app, not fixed over the room header and connection strip - on the pretend server the training line and the registration checkbox say nothing is trained (trainingNoticeText); the real sentence and its wording are unchanged and still await Meta's legal read Co-Authored-By: Claude Sonnet 5.5 --- .../components/message/layout/layout.css.ts | 14 +++++++++++- src/app/features/room/RoomTimeline.css.ts | 2 +- src/app/features/room/RoomTrainingLine.tsx | 4 +++- src/app/pages/auth/TrainingNotice.tsx | 6 +++-- src/app/utils/trainingNotice.test.ts | 22 +++++++++++++++++++ src/app/utils/trainingNotice.ts | 15 +++++++++++-- src/preview-demo/install.ts | 11 ++++++---- 7 files changed, 63 insertions(+), 11 deletions(-) diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index 991f60d8..50fd4417 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -262,7 +262,7 @@ export const PoseButton = style({ [PHONE]: { position: 'static', float: 'left', - width: 'min(50vw, 210px)', + width: 'min(38vw, 160px)', height: 'auto', margin: `${toRem(-82)} ${toRem(8)} 0 ${toRem(-12)}`, shapeMargin: toRem(7), @@ -387,6 +387,18 @@ globalStyle(`${BubbleContentPose} > div`, { }, }); +// An outside list marker hangs left of its item's text and ignores the portrait's silhouette, so +// beside a left portrait the number lands under the art. Inside, it is a word on the first line +// and wraps around the silhouette like the rest. +globalStyle(`${BubbleContentPose} ol, ${BubbleContentPose} ul`, { + '@media': { + [PHONE]: { + listStylePosition: 'inside', + paddingLeft: 0, + }, + }, +}); + // While the background scene runs (html[data-scene]) a chat bubble is the one filled surface in // the app: its own colour at half strength over a blur of the scene, so the words stay readable // and the ridge shows through. The arrow wedge is opaque, so it goes. diff --git a/src/app/features/room/RoomTimeline.css.ts b/src/app/features/room/RoomTimeline.css.ts index 9cd428ed..d798f62c 100644 --- a/src/app/features/room/RoomTimeline.css.ts +++ b/src/app/features/room/RoomTimeline.css.ts @@ -18,7 +18,7 @@ export const TimelineFloat = recipe({ top: config.space.S400, }, Bottom: { - bottom: config.space.S400, + bottom: config.space.S200, }, }, }, diff --git a/src/app/features/room/RoomTrainingLine.tsx b/src/app/features/room/RoomTrainingLine.tsx index 147932de..8f09fdcf 100644 --- a/src/app/features/room/RoomTrainingLine.tsx +++ b/src/app/features/room/RoomTrainingLine.tsx @@ -2,6 +2,7 @@ import { Text, color, config } from 'folds'; import { Room } from 'matrix-js-sdk'; import React from 'react'; import { useStateEvent } from '../../hooks/useStateEvent'; +import { useClientConfig } from '../../hooks/useClientConfig'; import { ROOM_NOTICE_EVENT_TYPE, parseRoomNotice, @@ -12,7 +13,8 @@ import { // collector reads the version from the same event, so nothing is stored in the browser. export function RoomTrainingLine({ room }: { room: Room }) { const event = useStateEvent(room, ROOM_NOTICE_EVENT_TYPE); - const line = roomTrainingLine(parseRoomNotice(event?.getContent())); + const { previewDemo } = useClientConfig(); + const line = roomTrainingLine(parseRoomNotice(event?.getContent()), previewDemo); if (!line) return null; return ( - {TRAINING_NOTICE_TEXT} + {trainingNoticeText(previewDemo)} ); diff --git a/src/app/utils/trainingNotice.test.ts b/src/app/utils/trainingNotice.test.ts index 0663d2f9..dce3ee90 100644 --- a/src/app/utils/trainingNotice.test.ts +++ b/src/app/utils/trainingNotice.test.ts @@ -4,7 +4,9 @@ import { ROOM_NOTICE_EVENT_TYPE, TRAINING_NOTICE_TEXT, parseRoomNotice, + PREVIEW_NOTICE_TEXT, roomTrainingLine, + trainingNoticeText, } from './trainingNotice'; const lineFor = (content: unknown) => roomTrainingLine(parseRoomNotice(content)); @@ -66,3 +68,23 @@ describe('training notice', () => { expect(lineFor({ version: 1, scope: ['train'] })).toBeUndefined(); }); }); + +describe('training notice on the pretend server', () => { + it('states that nothing is trained instead of the real sentence', () => { + expect(trainingNoticeText(true)).toBe(PREVIEW_NOTICE_TEXT); + expect(PREVIEW_NOTICE_TEXT).not.toContain('may be used to train'); + expect(PREVIEW_NOTICE_TEXT.toLowerCase()).toContain('pretend'); + }); + + it('keeps the ruled sentence everywhere else', () => { + expect(trainingNoticeText(false)).toBe(TRAINING_NOTICE_TEXT); + expect(trainingNoticeText(undefined)).toBe(TRAINING_NOTICE_TEXT); + }); + + it('shows the same text in a train room and at registration', () => { + const notice = parseRoomNotice({ version: 1, scope: 'train' }); + expect(roomTrainingLine(notice, true)).toBe(trainingNoticeText(true)); + expect(roomTrainingLine(notice, false)).toBe(trainingNoticeText(false)); + expect(roomTrainingLine(parseRoomNotice({ version: 1, scope: 'show' }), true)).toBeUndefined(); + }); +}); diff --git a/src/app/utils/trainingNotice.ts b/src/app/utils/trainingNotice.ts index 7f60cce4..e2c1de00 100644 --- a/src/app/utils/trainingNotice.ts +++ b/src/app/utils/trainingNotice.ts @@ -3,6 +3,15 @@ import { StateEvent } from '../../types/matrix/room'; export const TRAINING_NOTICE_TEXT = "Everything said here may be used to train GameCult's AI stewards."; +// The preview build runs against a pretend server where nothing is trained, so the real sentence +// would be false there. The real wording awaits Meta's legal read (Heimdall's consent text, open). +export const PREVIEW_NOTICE_TEXT = + 'Pretend server: nothing you type here is stored or used to train anything.'; + +/** The one sentence every first-typing surface shows: the real one, or the pretend server's. */ +export const trainingNoticeText = (previewDemo?: boolean): string => + previewDemo === true ? PREVIEW_NOTICE_TEXT : TRAINING_NOTICE_TEXT; + /** * Heimdall's ruling: a room's notice is a room state event, not a pinned message. Type below, * state_key '', content `{version, scope}`; scope exactly one of show, study or train. Heimdall's @@ -31,5 +40,7 @@ export const parseRoomNotice = (content: unknown): RoomNotice | undefined => { }; /** The line a room shows: only a notice that says `train` in terms gets the train sentence. */ -export const roomTrainingLine = (notice: RoomNotice | undefined): string | undefined => - notice?.scope === 'train' ? TRAINING_NOTICE_TEXT : undefined; +export const roomTrainingLine = ( + notice: RoomNotice | undefined, + previewDemo?: boolean +): string | undefined => (notice?.scope === 'train' ? trainingNoticeText(previewDemo) : undefined); diff --git a/src/preview-demo/install.ts b/src/preview-demo/install.ts index 36990a57..392d91d5 100644 --- a/src/preview-demo/install.ts +++ b/src/preview-demo/install.ts @@ -30,15 +30,18 @@ const BANNER_TEXT = 'preview: pretend server. Any password works.'; const FOREIGN_SESSION_TEXT = 'preview: pretend server. A login for another server is stored here and cannot be used.'; +// In flow, above the app: body becomes a column and the app root takes the rest, so the banner +// never covers the room header or the connection strip. const showBanner = (text: string) => { const el = document.createElement('div'); el.textContent = text; el.setAttribute('role', 'status'); el.style.cssText = - 'position:fixed;top:0;left:50%;transform:translateX(-50%);z-index:2147483647;max-width:96vw;' + - 'padding:0 8px;font:10px/1.5 system-ui,sans-serif;white-space:nowrap;opacity:0.9;color:#1b1b1b;background:#ffc46b;' + - 'border-radius:0 0 6px 6px;pointer-events:none;text-align:center'; - document.body.appendChild(el); + 'flex:none;padding:2px 8px;font:11px/1.4 system-ui,sans-serif;color:#1b1b1b;background:#ffc46b;text-align:center'; + const root = document.getElementById('root'); + document.body.style.cssText += ';display:flex;flex-direction:column'; + if (root) root.style.cssText += ';flex:1;min-height:0;height:auto'; + document.body.insertBefore(el, root); }; // The preview host's CSP sandbox has no allow-forms, so the browser cancels a native form From a1aece7d36e051ff3f2daa187dab001ec420694d Mon Sep 17 00:00:00 2001 From: Void Date: Sun, 8 Nov 2026 17:20:31 +0000 Subject: [PATCH 126/206] preview-demo test: fake document carries what the in-flow banner uses Co-Authored-By: Claude Sonnet 5.5 --- src/preview-demo/server.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/preview-demo/server.test.ts b/src/preview-demo/server.test.ts index e3cc977e..d277b97b 100644 --- a/src/preview-demo/server.test.ts +++ b/src/preview-demo/server.test.ts @@ -237,7 +237,8 @@ describe('the guard', () => { } as unknown as Window & typeof globalThis; vi.stubGlobal('document', { createElement: () => ({ style: {}, setAttribute: vi.fn() }), - body: { appendChild: vi.fn() }, + getElementById: () => null, + body: { style: {}, insertBefore: vi.fn() }, addEventListener: vi.fn(), }); const server = installDemo(win); From 95f0dce05f99cfa7c51b7a19a90e56fb577785e3 Mon Sep 17 00:00:00 2001 From: Void Date: Sun, 8 Nov 2026 17:32:32 +0000 Subject: [PATCH 127/206] Upload failure text carries what the client can observe Adds connectivity (onLine, effectiveType, rtt, downlink, saveData), tab visibility, ms since last user activity, time to first progress and bytes loaded, and ms to failure. Nothing patches XMLHttpRequest; the progress handler and window input events are the only sources. Co-Authored-By: Claude Sonnet 5.5 --- src/app/utils/matrix.ts | 69 +++++++++++++++++++++++++---- src/app/utils/uploadFailure.test.ts | 60 ++++++++++++++++++++++++- 2 files changed, 119 insertions(+), 10 deletions(-) diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts index 34497de0..7890a17c 100644 --- a/src/app/utils/matrix.ts +++ b/src/app/utils/matrix.ts @@ -145,12 +145,48 @@ export type ContentUploadOptions = { onError: (error: MatrixError) => void; }; +let lastUserActivity = Date.now(); +export const noteUserActivity = () => { + lastUserActivity = Date.now(); +}; +if (typeof window !== 'undefined') { + ['pointerdown', 'keydown', 'touchstart'].forEach((type) => + window.addEventListener(type, noteUserActivity, { capture: true, passive: true }) + ); +} + +/** What the progress handler saw of one upload, for the failure text. */ +export type UploadTrace = { + startedAt: number; + firstProgressAt?: number; + loaded?: number; + total?: number; +}; + +const describeNetwork = (): string => { + const online = + typeof navigator !== 'undefined' && navigator.onLine === false ? 'offline' : 'online'; + const c = typeof navigator !== 'undefined' ? (navigator as any).connection : undefined; + const net = c + ? `${c.effectiveType ?? '?'} rtt ${c.rtt ?? '?'}ms down ${c.downlink ?? '?'}Mbps saveData ${ + c.saveData ? 'on' : 'off' + }` + : 'n/a'; + const tab = typeof document !== 'undefined' ? document.visibilityState : 'n/a'; + return `${online}, net ${net}, tab ${tab}`; +}; + /** - * Describe why an upload died. The SDK reports an XHR with status 0 as an - * AbortError DOMException with an empty message, so name, size, type and - * whether the browser can still read the file all go into the message. + * Describe why an upload died. The SDK reports an XHR with status 0 as an AbortError + * DOMException with an empty message, so the text carries what the client can observe: file + * size, type and readability, connectivity, tab visibility, idle time, progress, time to fail. */ -export const uploadFailure = async (e: any, file: TUploadContent): Promise => { +export const uploadFailure = async ( + e: any, + file: TUploadContent, + trace?: UploadTrace +): Promise => { + const now = Date.now(); const name = typeof e?.name === 'string' && e.name ? e.name : 'Error'; const message = typeof e?.message === 'string' ? e.message : ''; let readable: string; @@ -160,8 +196,19 @@ export const uploadFailure = async (e: any, file: TUploadContent): Promise { const { name, fileType, hideFilename, onProgress, onPromise, onSuccess, onError } = options; + const trace: UploadTrace = { startedAt: Date.now() }; const uploadPromise = mx.uploadContent(file, { name, type: fileType, includeFilename: !hideFilename, - progressHandler: onProgress, + progressHandler: (progress) => { + if (trace.firstProgressAt === undefined) trace.firstProgressAt = Date.now(); + trace.loaded = progress.loaded; + trace.total = progress.total; + onProgress?.(progress); + }, }); onPromise?.(uploadPromise); try { @@ -187,7 +240,7 @@ export const uploadContent = async ( if (mxc) onSuccess(mxc); else onError(new MatrixError(data)); } catch (e: any) { - onError(await uploadFailure(e, file)); + onError(await uploadFailure(e, file, trace)); } }; diff --git a/src/app/utils/uploadFailure.test.ts b/src/app/utils/uploadFailure.test.ts index 7a2e62b1..ec1e8115 100644 --- a/src/app/utils/uploadFailure.test.ts +++ b/src/app/utils/uploadFailure.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from 'vitest'; -import { uploadFailure } from './matrix'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { noteUserActivity, uploadFailure } from './matrix'; const abort = () => new DOMException('', 'AbortError'); @@ -42,3 +42,59 @@ describe('uploadFailure', () => { expect(err.errcode).toBe('Error'); }); }); + +describe('uploadFailure client trace', () => { + const blob = new Blob(['abcd'], { type: 'image/jpeg' }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('reports connectivity, visibility and idle time at the moment of failure', async () => { + vi.useFakeTimers(); + vi.setSystemTime(10_000); + vi.stubGlobal('navigator', { + onLine: false, + connection: { effectiveType: '3g', rtt: 300, downlink: 1.5, saveData: true }, + }); + vi.stubGlobal('document', { visibilityState: 'hidden' }); + noteUserActivity(); + vi.setSystemTime(13_000); + const err = await uploadFailure(abort(), blob, { startedAt: 12_000 }); + expect(err.message).toContain('offline'); + expect(err.message).toContain('3g rtt 300ms down 1.5Mbps saveData on'); + expect(err.message).toContain('tab hidden'); + expect(err.message).toContain('idle 3000ms'); + expect(err.message).toContain('failed after 1000ms'); + }); + + it('says so when no progress event ever arrived', async () => { + vi.useFakeTimers(); + vi.setSystemTime(5_000); + const err = await uploadFailure(abort(), blob, { startedAt: 4_000 }); + expect(err.message).toContain('no progress'); + }); + + it('reports time to first progress and bytes loaded when some arrived', async () => { + vi.useFakeTimers(); + vi.setSystemTime(9_000); + const err = await uploadFailure(abort(), blob, { + startedAt: 5_000, + firstProgressAt: 5_120, + loaded: 65536, + total: 700000, + }); + expect(err.message).toContain('first progress 120ms'); + expect(err.message).toContain('65536/700000B'); + expect(err.message).toContain('failed after 4000ms'); + }); + + it('copes with a browser that has no connection info or document', async () => { + vi.stubGlobal('navigator', { onLine: true }); + vi.stubGlobal('document', undefined); + const err = await uploadFailure(abort(), blob, { startedAt: Date.now() }); + expect(err.message).toContain('online'); + expect(err.message).toContain('net n/a'); + }); +}); From 1296f1833820f123ecdf8f0cc94c39edb87226f9 Mon Sep 17 00:00:00 2001 From: Nibu Date: Sun, 8 Nov 2026 17:38:27 +0000 Subject: [PATCH 128/206] scene: terrain relief and contrast on a phone, a tier label Why it read hazy and flat at 390x844: the camera started with the noon sun straight behind it (no shading), the low tier sampled a 512 height map (6 m texels), plain exp(-k d) haze greyed the near ground, albedo had no grain, and the night ground sat at ambient 0.018 (near black). - camera starts with the noon sun 100 degrees off axis (START_TURNS; ?az= still moves the sun) - haze T = exp(-(k d)^1.6), k 0.0007: near ground clear, far ridges melt - low tier bakes 1024 texels - albedo grain (three-wave octaves, fine one fades with distance) and rock strata; shader-only cavity shading - night: moon from the sun's mirrored position, ambient lifted by nightness - tier label (full / low / still / sky-only) in preview builds or ?tierlabel=1 Co-Authored-By: Claude Sonnet 5.5 --- .../features/scene/terrain/Terrain.test.ts | 14 +++--- src/app/features/scene/terrain/Terrain.ts | 24 +++++++-- .../features/scene/terrain/TerrainSky2D.ts | 10 ++-- .../scene/terrain/terrainAlbedo.test.ts | 30 +++++++++++- .../features/scene/terrain/terrainAlbedo.ts | 40 +++++++++++++++ .../features/scene/terrain/terrainShaders.ts | 25 ++++++++-- .../features/scene/terrain/terrainSky.test.ts | 41 +++++++++++++++- src/app/features/scene/terrain/terrainSky.ts | 39 ++++++++++++--- .../features/scene/terrain/terrainWorld.ts | 2 +- .../features/scene/terrain/tierLabel.test.ts | 49 +++++++++++++++++++ src/app/features/scene/terrain/tierLabel.ts | 26 ++++++++++ 11 files changed, 272 insertions(+), 28 deletions(-) create mode 100644 src/app/features/scene/terrain/tierLabel.test.ts create mode 100644 src/app/features/scene/terrain/tierLabel.ts diff --git a/src/app/features/scene/terrain/Terrain.test.ts b/src/app/features/scene/terrain/Terrain.test.ts index 446ab9cf..b61b632b 100644 --- a/src/app/features/scene/terrain/Terrain.test.ts +++ b/src/app/features/scene/terrain/Terrain.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createTerrainScene } from './Terrain'; import { GRID, triangleCount } from './terrainWorld'; -import { SKY, skyAtSun } from './terrainSky'; +import { groundLight, SKY, skyAtSun } from './terrainSky'; import { SKY_VIEW, skyViewWords } from './terrainScatter'; import { SKY_FRAG } from './terrainShaders'; import { startBackgroundScene } from '../index'; @@ -145,8 +145,8 @@ describe('createTerrainScene', () => { }; const night = uploaded({}); const day = uploaded({ sky: 'noonday' }); - const nightA = Array.from(SKY.midnight.ambient); - const dayA = Array.from(SKY.noonday.ambient); + const nightA = Array.from(groundLight(SKY.midnight).ambient); + const dayA = Array.from(groundLight(SKY.noonday).ambient); const has = (all: number[][], v: number[]) => all.some((a) => a.every((x, i) => Math.abs(x - v[i]) < 1e-6)); expect(has(night, nightA)).toBe(true); @@ -167,11 +167,13 @@ describe('createTerrainScene', () => { const dusk = skyAtSun(-1, 300); const has = (all: number[][], v: readonly number[]) => all.some((a) => a.length === 3 && a.every((x, i) => Math.abs(x - v[i]) < 1e-6)); - expect(has(ambients({ sun: { elevation: -1, azimuth: 300 } }), dusk.ambient)).toBe(true); + expect(has(ambients({ sun: { elevation: -1, azimuth: 300 } }), groundLight(dusk).ambient)).toBe( + true + ); search = '?sky=noonday&sun=-1&az=300'; - expect(has(ambients({}), dusk.ambient)).toBe(true); + expect(has(ambients({}), groundLight(dusk).ambient)).toBe(true); search = '?sun=garbage'; - expect(has(ambients({}), SKY.midnight.ambient)).toBe(true); + expect(has(ambients({}), groundLight(SKY.midnight).ambient)).toBe(true); }); it('uploads the baked scattering table once, as half floats, to a 3D texture', () => { diff --git a/src/app/features/scene/terrain/Terrain.ts b/src/app/features/scene/terrain/Terrain.ts index 3db90d3c..d3c95ff3 100644 --- a/src/app/features/scene/terrain/Terrain.ts +++ b/src/app/features/scene/terrain/Terrain.ts @@ -3,7 +3,15 @@ import { FrameSampler, FrameStats } from '../ridge/probe'; import { albedoPalette } from './terrainAlbedo'; import { sunIndex } from '../../../../sky/scatter.js'; import { SKY_VIEW, skyViewWords } from './terrainScatter'; -import { glowColour, HAZE_K, chooseSky, SkyName, sunDirection } from './terrainSky'; +import { + glowColour, + groundLight, + HAZE_K, + chooseSky, + SkyName, + START_TURNS, + sunDirection, +} from './terrainSky'; import { buildGrid, CAMERA, GRID, seedOffset, TERRAIN, triangleCount, view } from './terrainWorld'; import { FULLSCREEN_VERT, @@ -13,6 +21,7 @@ import { SKY_FRAG, } from './terrainShaders'; import { createTerrainStars } from './terrainStars'; +import { showTierLabel } from './tierLabel'; export type TerrainOptions = Pick< SceneOptions, @@ -178,7 +187,9 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { const sun = [...sunDirection(sky)]; const sunLight = [...sky.direct]; - const ambient = [...sky.ambient]; + const ground = groundLight(sky); + const ambient = [...ground.ambient]; + const heading = START_TURNS; const glow = [...glowColour(sky)]; const sunTexel = sunIndex(sky.elevation); const palette = albedoPalette(sky); @@ -206,7 +217,7 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { const draw = (turns: number) => { if (lost) return; gl.viewport(0, 0, canvas.width, canvas.height); - const v = view(canvas.width / canvas.height, turns % 1); + const v = view(canvas.width / canvas.height, (turns + heading) % 1); gl.disable(gl.DEPTH_TEST); gl.useProgram(skyProg); const us = (n: string) => gl.getUniformLocation(skyProg, n); @@ -233,7 +244,8 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { gl.uniform1f(u('uSpan'), TERRAIN.span); gl.uniform1i(u('uTex'), spec.tex); gl.uniform3fv(u('uSun'), sun); - gl.uniform3fv(u('uSunColour'), sunLight); + gl.uniform3fv(u('uLightDir'), ground.dir); + gl.uniform3fv(u('uLight'), ground.colour); gl.uniform3fv(u('uAmbient'), ambient); gl.uniform3fv(u('uGlow'), glow); gl.uniform1f(u('uSunIndex'), sunTexel); @@ -252,7 +264,7 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { gl.bindVertexArray(vao); gl.drawElements(gl.TRIANGLES, g.indices.length, gl.UNSIGNED_INT, 0); gl.bindVertexArray(null); - stars?.draw(turns % 1, moving() ? performance.now() / 1000 : undefined); + stars?.draw((turns + heading) % 1, moving() ? performance.now() / 1000 : undefined); }; const moving = () => @@ -347,6 +359,7 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { }); canvas.parentElement.appendChild(readout); } + const removeLabel = showTierLabel(canvas, tier); if (still) settleToStill(); else wake(); if (!still && !moving()) draw(0); @@ -383,6 +396,7 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { reducedMotion?.removeEventListener?.('change', onVisibility); canvas.removeEventListener('webglcontextlost', onLost); readout?.remove(); + removeLabel?.(); stars?.dispose(); release(); }, diff --git a/src/app/features/scene/terrain/TerrainSky2D.ts b/src/app/features/scene/terrain/TerrainSky2D.ts index e61c6fee..0c19ec97 100644 --- a/src/app/features/scene/terrain/TerrainSky2D.ts +++ b/src/app/features/scene/terrain/TerrainSky2D.ts @@ -1,7 +1,8 @@ import type { Scene } from '../Scene'; import type { TerrainOptions } from './Terrain'; -import { chooseSky, skyColour, Vec3 } from './terrainSky'; +import { chooseSky, skyColour, START_TURNS, Vec3 } from './terrainSky'; import { createTerrainStars } from './terrainStars'; +import { showTierLabel } from './tierLabel'; import { CAMERA, view } from './terrainWorld'; /** The sky is smooth, so it is painted this many px wide and stretched by the canvas's CSS size. */ @@ -24,6 +25,7 @@ export function createTerrainSkyScene(opts: TerrainSkyOptions): Scene | null { if (!ctx) return null; const sky = chooseSky(opts); const stars = createTerrainStars(canvas, sky); + const heading = START_TURNS; const tanHalf = Math.tan(CAMERA.fov / 2); let disposed = false; let resizeTimer = 0; @@ -35,7 +37,7 @@ export function createTerrainSkyScene(opts: TerrainSkyOptions): Scene | null { const h = Math.max(1, Math.round((w * ch) / cw)); canvas.width = w; canvas.height = h; - const v = view(cw / ch, 0); + const v = view(cw / ch, heading); const image = ctx.createImageData(w, h); for (let y = 0; y < h; y += 1) { const ny = 1 - ((y + 0.5) / h) * 2; @@ -54,7 +56,7 @@ export function createTerrainSkyScene(opts: TerrainSkyOptions): Scene | null { } } ctx.putImageData(image, 0, 0); - stars?.draw(0); + stars?.draw(heading); }; const onResize = () => { @@ -67,6 +69,7 @@ export function createTerrainSkyScene(opts: TerrainSkyOptions): Scene | null { }; window.addEventListener('resize', onResize); paint(); + const removeLabel = showTierLabel(canvas, 'sky-only'); return { login() { @@ -77,6 +80,7 @@ export function createTerrainSkyScene(opts: TerrainSkyOptions): Scene | null { disposed = true; clearTimeout(resizeTimer); window.removeEventListener('resize', onResize); + removeLabel?.(); stars?.dispose(); }, }; diff --git a/src/app/features/scene/terrain/terrainAlbedo.test.ts b/src/app/features/scene/terrain/terrainAlbedo.test.ts index 0136b5f3..385344d3 100644 --- a/src/app/features/scene/terrain/terrainAlbedo.test.ts +++ b/src/app/features/scene/terrain/terrainAlbedo.test.ts @@ -1,7 +1,7 @@ /* eslint-disable no-restricted-syntax -- tests loop over skies, heights and slopes */ import { describe, expect, it } from 'vitest'; import { SKY } from './terrainSky'; -import { ALBEDO, albedoAt, albedoPalette, wobble } from './terrainAlbedo'; +import { ALBEDO, albedoAt, albedoPalette, grainAt, wobble } from './terrainAlbedo'; import { GRID_FRAG } from './terrainShaders'; const near = (a: readonly number[], b: readonly number[], eps = 1e-6) => @@ -98,7 +98,35 @@ describe('shader agrees with the pure function on its numbers', () => { expect(GRID_FRAG).toContain(`#define ALB_SNOW_BAND ${ALBEDO.snowBand.toFixed(1)}`); expect(GRID_FRAG).toContain(`#define ALB_ROCK_LO ${ALBEDO.rockLo}`); expect(GRID_FRAG).toContain(`#define ALB_WOBBLE_S ${ALBEDO.wobbleSlope}`); + expect(GRID_FRAG).toContain(`#define ALB_GRAIN_COARSE ${ALBEDO.grainCoarse}`); + expect(GRID_FRAG).toContain(`#define ALB_STRATA ${ALBEDO.strata}`); // the only fetch for colour-by-material would be a texture; the height map's texelFetch is the only other read, and the sky lookup is the baked scattering table's expect(GRID_FRAG.match(/texelFetch|texture\((?!uSky)/g)).toHaveLength(1); }); }); + +describe('grain', () => { + const samples = (rock: number, distance: number) => + Array.from({ length: 400 }, (_, i) => grainAt(i * 1.7, i * 0.9, 100 + i * 0.3, rock, distance)); + const spread = (v: number[]) => Math.max(...v) - Math.min(...v); + + it('varies the surface near the eye, never goes negative, and averages about 1', () => { + const v = samples(0, 50); + expect(spread(v)).toBeGreaterThan(0.4); + expect(Math.min(...v)).toBeGreaterThanOrEqual(0); + expect(v.reduce((a, b) => a + b, 0) / v.length).toBeGreaterThan(0.85); + }); + + it('drops its fine octave with distance, so far ground does not shimmer', () => { + const rangeOf = (d: number) => + spread(Array.from({ length: 400 }, (_, i) => grainAt(i * 0.37, i * 0.21, 100, 0, d))); + expect(rangeOf(900)).toBeLessThan(rangeOf(50)); + }); + + it('layers the rock: strata show only where the face is rock', () => { + const height = (rock: number) => + spread(Array.from({ length: 400 }, (_, i) => grainAt(0, 0, 100 + i * 0.05, rock, 2000))); + expect(height(1)).toBeGreaterThan(0.2); + expect(height(0)).toBeLessThan(1e-9); + }); +}); diff --git a/src/app/features/scene/terrain/terrainAlbedo.ts b/src/app/features/scene/terrain/terrainAlbedo.ts index e3230a8b..d452dec8 100644 --- a/src/app/features/scene/terrain/terrainAlbedo.ts +++ b/src/app/features/scene/terrain/terrainAlbedo.ts @@ -28,6 +28,13 @@ export const ALBEDO = { /** world-position wobble of the height edges (metres) and the slope edges, so bands are not contour lines. */ wobbleHeight: 14, wobbleSlope: 0.05, + /** Albedo grain: relative swing of the two sine-product octaves (about 20 m and 5 m periods), and of the rock strata. */ + grainCoarse: 0.35, + grainFine: 0.3, + /** The fine octave fades out between these distances (metres): beyond them it is under a pixel and only shimmers. */ + fineNear: 250, + fineFar: 700, + strata: 0.22, } as const; export interface AlbedoPalette { @@ -95,3 +102,36 @@ export function albedoAt(p: AlbedoPalette, height: number, ny: number, x: number mix(mix(ground(i), p.snow[i], snow), p.rock[i], rock) ) as unknown as Vec3; } + +/** + * The multiplier on albedo that gives the surface grain and the rock its layers, from world position (metres), + * height, `rock` (0..1, how much of the face is rock) and the distance from the eye. The shader's `ALB_GRAIN_*` block does the same arithmetic. + * The cavity term beside it in the shader reads the height map and has no twin here. + */ +export function grainAt( + x: number, + z: number, + height: number, + rock: number, + distance: number +): number { + const waves = (k: ReadonlyArray) => + k.reduce((sum, [kx, kz]) => sum + Math.sin(x * kx + z * kz), 0) / 3; + const coarse = waves([ + [0.31, 0.27], + [-0.22, 0.35], + [0.38, -0.12], + ]); + const fine = + waves([ + [1.1, 0.7], + [-0.8, 1.2], + [1.3, -0.6], + ]) * + (1 - smooth(ALBEDO.fineNear, ALBEDO.fineFar, distance)); + const layers = Math.sin(height * 0.9 + wobble(x, z) * 3); + return Math.max( + 0, + 1 + ALBEDO.grainCoarse * coarse + ALBEDO.grainFine * fine + ALBEDO.strata * rock * layers + ); +} diff --git a/src/app/features/scene/terrain/terrainShaders.ts b/src/app/features/scene/terrain/terrainShaders.ts index 8418e91a..04d0425b 100644 --- a/src/app/features/scene/terrain/terrainShaders.ts +++ b/src/app/features/scene/terrain/terrainShaders.ts @@ -9,7 +9,7 @@ import phacelle from '../shaders/CultMath.Phacelle.glsl?raw'; import { trimGlsl } from '../cultmathTrim'; import { ALBEDO } from './terrainAlbedo'; import { SCATTER_GLSL } from './terrainScatter'; -import { GLOW_ZENITH, SKY_EXPOSURE } from './terrainSky'; +import { GLOW_ZENITH, HAZE_POWER, SKY_EXPOSURE } from './terrainSky'; import { TERRAIN } from './terrainWorld'; const HEADER = @@ -122,6 +122,11 @@ const ALBEDO_DEFINES = ` #define ALB_SNOW_LO ${lit(ALBEDO.snowLo)} #define ALB_WOBBLE_H ${lit(ALBEDO.wobbleHeight)} #define ALB_WOBBLE_S ${lit(ALBEDO.wobbleSlope)} +#define ALB_GRAIN_COARSE ${lit(ALBEDO.grainCoarse)} +#define ALB_GRAIN_FINE ${lit(ALBEDO.grainFine)} +#define ALB_STRATA ${lit(ALBEDO.strata)} +#define ALB_FINE_NEAR ${lit(ALBEDO.fineNear)} +#define ALB_FINE_FAR ${lit(ALBEDO.fineFar)} `; // The sky colour along a unit direction: scattering lifted by the exposure, tone-mapped, plus the night glow. The // same arithmetic as terrainSky.skyColour(). uGlow is the baked horizon colour already scaled by how dark the sun is. @@ -136,7 +141,7 @@ vec3 skyColour(vec3 d) { export const GRID_FRAG = `${HEADER}${ALBEDO_DEFINES}${SKY_COLOUR_GLSL} in vec2 v_uv; in vec3 v_world; -uniform vec3 uCamera, uSunColour, uAmbient, uSoil, uScree, uRock, uSnow; +uniform vec3 uCamera, uLightDir, uLight, uAmbient, uSoil, uScree, uRock, uSnow; uniform float uSpan, uHazeK, uSnowLine; out vec4 o; ${FETCH} @@ -163,9 +168,21 @@ void main() { float snow = smoothstep(uSnowLine - ALB_SNOW_BAND, uSnowLine + ALB_SNOW_BAND, h) * smoothstep(ALB_SNOW_LO, ALB_SNOW_HI, flat_); float rockW = 1.0 - smoothstep(ALB_ROCK_LO, ALB_ROCK_HI, flat_); vec3 albedo = mix(mix(ground, uSnow, snow), uRock, rockW); - vec3 lit = albedo * (uSunColour * max(dot(n, uSun), 0.0) + uAmbient * (0.5 + 0.5 * n.y)); + // Surface grain and rock strata, the same arithmetic as terrainAlbedo.grainAt(). + vec2 q = v_world.xz; + float coarse = (sin(dot(q, vec2(0.31, 0.27))) + sin(dot(q, vec2(-0.22, 0.35))) + sin(dot(q, vec2(0.38, -0.12)))) / 3.0; + float fine = (sin(dot(q, vec2(1.1, 0.7))) + sin(dot(q, vec2(-0.8, 1.2))) + sin(dot(q, vec2(1.3, -0.6)))) / 3.0; + float near = 1.0 - smoothstep(ALB_FINE_NEAR, ALB_FINE_FAR, length(v_world - uCamera)); + float layers = sin(v_world.y * 0.9 + w * 3.0); + albedo *= max(0.0, 1.0 + ALB_GRAIN_COARSE * coarse + ALB_GRAIN_FINE * near * fine + ALB_STRATA * rockW * layers); + // Cavity: a point lower than its surroundings sits in shade, one higher catches light. Shader-only, from the height map. + int r = max(1, int(12.0 / texel)); + ivec2 c = ivec2(p + 0.5); + float curv = 0.25 * (hAt(c + ivec2(r, 0)) + hAt(c - ivec2(r, 0)) + hAt(c + ivec2(0, r)) + hAt(c - ivec2(0, r))) - hAt(c); + float cavity = 1.0 - 0.55 * clamp(curv / 12.0, 0.0, 1.0) + 0.3 * clamp(-curv / 12.0, 0.0, 1.0); + vec3 lit = albedo * cavity * (uLight * max(dot(n, uLightDir), 0.0) + uAmbient * (0.5 + 0.5 * n.y)); vec3 toSurface = v_world - uCamera; - float T = exp(-uHazeK * length(toSurface)); + float T = exp(-pow(uHazeK * length(toSurface), ${lit(HAZE_POWER)})); vec3 haze = skyColour(normalize(vec3(toSurface.x, 0.0, toSurface.z))); o = vec4(pow(mix(haze, lit, T), vec3(1.0 / 2.2)), 1.0); }`; diff --git a/src/app/features/scene/terrain/terrainSky.test.ts b/src/app/features/scene/terrain/terrainSky.test.ts index d8cb0129..9e5bc91e 100644 --- a/src/app/features/scene/terrain/terrainSky.test.ts +++ b/src/app/features/scene/terrain/terrainSky.test.ts @@ -14,6 +14,10 @@ import { skyNameFor, sunDirection, transmittance, + groundLight, + HAZE_K, + HAZE_POWER, + START_TURNS, Vec3, } from './terrainSky'; import { skyColours } from '../ridge/skyColour'; @@ -121,14 +125,14 @@ describe('lighting', () => { const m = (n: readonly [number, number, number]) => shade(SKY.midnight, [0.5, 0.5, 0.5], n, 0, view); expect(luma(m(UP))).toBeGreaterThan(0); // moonlit fill, not black - expect(luma(m(UP))).toBeLessThan(0.05); + expect(luma(m(UP))).toBeLessThan(0.12); }); it('noon is much brighter than midnight, ambient included', () => { expect(luma(SKY.noonday.ambient)).toBeGreaterThan(2 * luma(SKY.midnight.ambient)); const view = look(-5, 0); expect(luma(shade(SKY.noonday, [0.4, 0.4, 0.4], UP, 0, view))).toBeGreaterThan( - 10 * luma(shade(SKY.midnight, [0.4, 0.4, 0.4], UP, 0, view)) + 3 * luma(shade(SKY.midnight, [0.4, 0.4, 0.4], UP, 0, view)) ); }); @@ -224,3 +228,36 @@ describe('haze', () => { expect(dark(2000)).toBeLessThan(h); }); }); + +describe('ground light and haze', () => { + it("at night the ground has a moon above the horizon on the sun's mirrored side, and a lifted ambient; by day it is the sun", () => { + const night = groundLight(SKY.midnight); + expect(night.dir[1]).toBeGreaterThan(0.5); + expect(luma(night.colour)).toBeGreaterThan(0.03); + expect(luma(night.ambient)).toBeGreaterThan(2 * luma(SKY.midnight.ambient)); + const day = groundLight(SKY.noonday); + expect(Array.from(day.dir)).toEqual(Array.from(sunDirection(SKY.noonday))); + expect(Array.from(day.colour)).toEqual(Array.from(SKY.noonday.direct)); + expect(Array.from(day.ambient)).toEqual(Array.from(SKY.noonday.ambient)); + }); + + it('the ground light is continuous through sunset', () => { + const at = (e: number) => luma(groundLight(skyAtSun(e)).colour); + for (let e = -10; e < 10; e += 0.5) expect(Math.abs(at(e + 0.5) - at(e))).toBeLessThan(0.35); + }); + + it('haze leaves the near ground nearly clear and takes the far ridges: a power law, not plain exp(-k d)', () => { + expect(transmittance(300)).toBeGreaterThan(0.85); + expect(transmittance(300)).toBeGreaterThan(Math.exp(-HAZE_K * 300)); + expect(transmittance(2500)).toBeLessThan(0.15); + expect(HAZE_POWER).toBeGreaterThan(1); + }); + + it('the camera starts with the noon sun off its axis, so the light rakes the relief', () => { + const yaw = 2 * Math.PI * START_TURNS; + const forward = [Math.sin(yaw), 0, -Math.cos(yaw)]; + const sun = sunDirection(SKY.noonday); + const along = (forward[0] * sun[0] + forward[2] * sun[2]) / Math.hypot(sun[0], sun[2]); + expect(Math.abs(along)).toBeLessThan(0.4); + }); +}); diff --git a/src/app/features/scene/terrain/terrainSky.ts b/src/app/features/scene/terrain/terrainSky.ts index b77ddbf2..697896a3 100644 --- a/src/app/features/scene/terrain/terrainSky.ts +++ b/src/app/features/scene/terrain/terrainSky.ts @@ -128,25 +128,52 @@ export function skyColour(d: SkyData, dir: Vec3): Vec3 { ) as unknown as Vec3; } -/** Haze: transmittance T = exp(-k d) at distance d metres. */ -export const HAZE_K = 0.0004; +/** + * Haze: transmittance T = exp(-(k d)^HAZE_POWER) at distance d metres. The power above 1 keeps the near and middle + * ground at its own colour and lets only the far ridges melt toward the sky; a plain exp(-k d) greyed everything. + */ +export const HAZE_K = 0.0007; +export const HAZE_POWER = 1.6; export const transmittance = (distance: number): number => - Math.exp(-HAZE_K * Math.max(0, distance)); + Math.exp(-((HAZE_K * Math.max(0, distance)) ** HAZE_POWER)); + +/** Added to the ambient on a dark sky, scaled by `nightness`: the baked night floor is too dark to show any form on a phone. */ +export const NIGHT_AMBIENT_LIFT = 3; +/** Cool light from the mirror of the sun's below-horizon position, linear, scaled by `nightness`: gives the terrain a lit side at night. */ +export const MOON_COLOUR: Vec3 = [0.05, 0.07, 0.13]; + +/** The light on the ground: the sun, or at night the moon standing where the sun is mirrored above the horizon. Continuous through sunset. */ +export function groundLight(d: SkyData): { dir: Vec3; colour: Vec3; ambient: Vec3 } { + const sun = sunDirection(d); + const k = nightness(d.elevation); + return { + dir: [sun[0], Math.abs(sun[1]), sun[2]], + colour: [0, 1, 2].map((i) => d.direct[i] + MOON_COLOUR[i] * k) as unknown as Vec3, + ambient: [0, 1, 2].map((i) => d.ambient[i] * (1 + NIGHT_AMBIENT_LIFT * k)) as unknown as Vec3, + }; +} + +/** + * The heading the camera starts at, in turns from north: the noonday sun a hundred degrees to its right, so the light + * rakes across the relief instead of flattening it from behind. Fixed, so `?az=` still moves the sun in the picture. + */ +export const START_TURNS = ((SKY.noonday.azimuth - 100) / 360 + 1) % 1; /** * Linear colour of a surface seen along the unit direction `view` (from the eye to the surface): lambert from - * the sun plus ambient (full on an upward face, half on a vertical one, none on a downward one: `0.5 + 0.5 n.y`), + * the ground light plus ambient (full on an upward face, half on a vertical one, none on a downward one: `0.5 + 0.5 n.y`), * then hazed by distance toward the sky at the horizon in that direction. `normal` is a unit vector. Never negative. */ export function shade(d: SkyData, albedo: Vec3, normal: Vec3, distance: number, view: Vec3): Vec3 { - const sun = sunDirection(d); + const light = groundLight(d); + const sun = light.dir; const lambert = Math.max(0, normal[0] * sun[0] + normal[1] * sun[1] + normal[2] * sun[2]); const hemi = 0.5 + 0.5 * normal[1]; const flat = Math.hypot(view[0], view[2]) || 1; const haze = skyColour(d, [view[0] / flat, 0, view[2] / flat]); const t = transmittance(distance); return [0, 1, 2].map((i) => { - const lit = albedo[i] * (d.direct[i] * lambert + d.ambient[i] * Math.max(0, hemi)); + const lit = albedo[i] * (light.colour[i] * lambert + light.ambient[i] * Math.max(0, hemi)); return lit * t + haze[i] * (1 - t); }) as unknown as Vec3; } diff --git a/src/app/features/scene/terrain/terrainWorld.ts b/src/app/features/scene/terrain/terrainWorld.ts index e21a229e..97577054 100644 --- a/src/app/features/scene/terrain/terrainWorld.ts +++ b/src/app/features/scene/terrain/terrainWorld.ts @@ -8,7 +8,7 @@ export const TERRAIN = { span: 3000, hmax: 256 }; /** Grid quads per side and height-map texels per side, by tier. 'still' draws one frame, so it takes the cheap pair. */ export const GRID = { still: { grid: 128, tex: 512 }, - low: { grid: 128, tex: 512 }, + low: { grid: 128, tex: 1024 }, full: { grid: 192, tex: 1024 }, } as const; diff --git a/src/app/features/scene/terrain/tierLabel.test.ts b/src/app/features/scene/terrain/tierLabel.test.ts new file mode 100644 index 00000000..3e9f3a48 --- /dev/null +++ b/src/app/features/scene/terrain/tierLabel.test.ts @@ -0,0 +1,49 @@ +/* eslint-disable @typescript-eslint/no-explicit-any -- DOM stubs, as the other scene tests */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { showTierLabel } from './tierLabel'; + +let search: string; +let appended: any[]; +const canvas = (parent: boolean) => + ({ + parentElement: parent + ? { + appendChild: (e: any) => appended.push(e), + } + : null, + } as any); + +beforeEach(() => { + search = ''; + appended = []; + vi.stubGlobal('window', { + get location() { + return { search }; + }, + }); + vi.stubGlobal('document', { + createElement: () => ({ style: {}, remove: vi.fn(), textContent: '' }), + }); +}); +afterEach(() => vi.unstubAllGlobals()); + +describe('showTierLabel', () => { + it('adds nothing in a normal build', () => { + expect(showTierLabel(canvas(true), 'full')).toBeNull(); + expect(appended).toHaveLength(0); + }); + + it('names the path on screen when asked with ?tierlabel=1, and removes itself', () => { + search = '?tierlabel=1'; + const remove = showTierLabel(canvas(true), 'sky-only'); + expect(appended).toHaveLength(1); + expect(appended[0].textContent).toBe('terrain · sky-only'); + remove!(); + expect(appended[0].remove).toHaveBeenCalled(); + }); + + it('has nowhere to go without a parent', () => { + search = '?tierlabel=1'; + expect(showTierLabel(canvas(false), 'full')).toBeNull(); + }); +}); diff --git a/src/app/features/scene/terrain/tierLabel.ts b/src/app/features/scene/terrain/tierLabel.ts new file mode 100644 index 00000000..6e3a9236 --- /dev/null +++ b/src/app/features/scene/terrain/tierLabel.ts @@ -0,0 +1,26 @@ +// A tiny label naming which terrain path is on screen ('full', 'low', 'still', or 'sky-only' where WebGL2 is missing), +// so a screenshot of a preview says what it shows. Built into preview builds only (VITE_PREVIEW_DEMO=1), or by `?tierlabel=1`. +const wanted = (): boolean => + import.meta.env?.VITE_PREVIEW_DEMO === '1' || + new URLSearchParams(window.location.search).get('tierlabel') === '1'; + +/** Adds the label to the canvas's parent; returns its remover, or null when no label is wanted. */ +export function showTierLabel(canvas: HTMLCanvasElement, text: string): (() => void) | null { + const parent = canvas.parentElement; + if (!parent || !wanted()) return null; + const el = document.createElement('div'); + el.textContent = `terrain · ${text}`; + Object.assign(el.style, { + position: 'absolute', + right: '6px', + bottom: '6px', + font: '10px monospace', + color: 'rgba(255,255,255,.75)', + background: 'rgba(0,0,0,.4)', + padding: '1px 5px', + borderRadius: '3px', + pointerEvents: 'none', + }); + parent.appendChild(el); + return () => el.remove(); +} From 58df7b833cbd679926458aff4cc090adff071142 Mon Sep 17 00:00:00 2001 From: Void Date: Sun, 8 Nov 2026 17:41:04 +0000 Subject: [PATCH 129/206] phone: pill clears the portrait, portrait bubbles keep a text floor, status block collapses - the name pill is at most bubble width minus portrait minus 30px, so it cannot reach the face - a bubble with a portrait is at least portrait + 170px wide, so text beside a right portrait no longer wraps into a ~100px column - <=480px: the pose status block shows only the newest mood plus "+N more" on one line, tap to expand; the read-receipt line is hidden and its slot is an 8px margin under the composer Co-Authored-By: Claude Sonnet 5.5 --- .../components/message/layout/layout.css.ts | 11 ++++- src/app/components/message/pose/poses.test.ts | 23 ++++++++++- src/app/components/message/pose/poses.ts | 11 +++++ .../features/room/RoomViewFollowing.css.ts | 10 +++++ .../features/room/RoomViewPoseStatus.css.ts | 40 ++++++++++++++++++ src/app/features/room/RoomViewPoseStatus.tsx | 41 +++++++++++++++---- 6 files changed, 127 insertions(+), 9 deletions(-) create mode 100644 src/app/features/room/RoomViewPoseStatus.css.ts diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index 50fd4417..6c7b97f3 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -156,6 +156,8 @@ export const BubbleContentArrowLeft = style({ // beside its bubble on wide screens; on phones it floats inside the bubble, overhanging the top // edge, and the text wraps around its silhouette (shape-outside on the alpha channel). const PHONE = 'screen and (max-width: 700px)'; +const PHONE_PORTRAIT = 'min(38vw, 160px)'; +const PHONE_TEXT_FLOOR = toRem(170); const PORTRAIT = toRem(124); const POSE_GUTTER = toRem(14); const BUBBLE_CORNER = toRem(6); @@ -209,6 +211,10 @@ export const BubbleHeaderPose = style({ border: `1px solid ${color.Secondary.ContainerLine}`, borderRadius: toRem(999), whiteSpace: 'nowrap', + // Never wider than the bubble minus the portrait, so it cannot reach the portrait's face. + maxWidth: `calc(100% - ${PHONE_PORTRAIT} - ${toRem(30)})`, + overflow: 'hidden', + textOverflow: 'ellipsis', }, }, }); @@ -232,6 +238,9 @@ export const BubbleContentPose = style({ '@media': { [PHONE]: { display: 'flow-root', + // A bubble with a portrait is at least portrait + a readable column wide, or the text + // beside the portrait collapses to a strip. + minWidth: `min(100%, calc(${PHONE_PORTRAIT} + ${PHONE_TEXT_FLOOR}))`, borderTopLeftRadius: config.radii.R500, padding: `${toRem(24)} ${toRem(14)} ${toRem(14)}`, }, @@ -262,7 +271,7 @@ export const PoseButton = style({ [PHONE]: { position: 'static', float: 'left', - width: 'min(38vw, 160px)', + width: PHONE_PORTRAIT, height: 'auto', margin: `${toRem(-82)} ${toRem(8)} 0 ${toRem(-12)}`, shapeMargin: toRem(7), diff --git a/src/app/components/message/pose/poses.test.ts b/src/app/components/message/pose/poses.test.ts index ed503abe..81c2f0c6 100644 --- a/src/app/components/message/pose/poses.test.ts +++ b/src/app/components/message/pose/poses.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from 'vitest'; import catalogue from './catalogue.json'; -import { mindOfSender, poseSrc, poseStatusLine, poseStatuses, resolvePose } from './poses'; +import { + mindOfSender, + poseSrc, + poseStatusLine, + poseStatuses, + resolvePose, + splitNewestStatus, +} from './poses'; describe('resolvePose', () => { it('keeps shared and own-signature labels from the catalogue', () => { @@ -81,3 +88,17 @@ describe('poseStatusLine', () => { ); }); }); + +describe('splitNewestStatus', () => { + const s = (mind: string, since: number) => ({ mind, pose: 'happy', since }); + + it('picks the status that changed last and keeps the others in their order', () => { + const { newest, rest } = splitNewestStatus([s('eureka', 100), s('void', 300), s('nibu', 200)]); + expect(newest?.mind).toBe('void'); + expect(rest.map((r) => r.mind)).toEqual(['eureka', 'nibu']); + }); + + it('has no newest for an empty list', () => { + expect(splitNewestStatus([])).toEqual({ newest: undefined, rest: [] }); + }); +}); diff --git a/src/app/components/message/pose/poses.ts b/src/app/components/message/pose/poses.ts index a5b705c3..7175196a 100644 --- a/src/app/components/message/pose/poses.ts +++ b/src/app/components/message/pose/poses.ts @@ -68,6 +68,17 @@ export const poseStatuses = (events: PoseEvent[]): PoseStatus[] => { }); }; +// The status that changed last, and the rest in their given order (phones show only the first). +export const splitNewestStatus = ( + statuses: PoseStatus[] +): { newest: PoseStatus | undefined; rest: PoseStatus[] } => { + let newest: PoseStatus | undefined; + statuses.forEach((st) => { + if (!newest || st.since > newest.since) newest = st; + }); + return { newest, rest: statuses.filter((st) => st !== newest) }; +}; + const two = (n: number) => String(n).padStart(2, '0'); // Client-status text. Rendered locally only; it is never sent to a room. diff --git a/src/app/features/room/RoomViewFollowing.css.ts b/src/app/features/room/RoomViewFollowing.css.ts index 18b53ac9..ad7fd12b 100644 --- a/src/app/features/room/RoomViewFollowing.css.ts +++ b/src/app/features/room/RoomViewFollowing.css.ts @@ -2,10 +2,19 @@ import { style } from '@vanilla-extract/css'; import { recipe } from '@vanilla-extract/recipes'; import { DefaultReset, color, config, toRem } from 'folds'; +// Phones: the read-receipt line is hidden; its slot shrinks to a margin under the composer. +const NARROW_HIDDEN = { + height: toRem(8), + minHeight: 0, + overflow: 'hidden', + visibility: 'hidden', +} as const; + export const RoomViewFollowingPlaceholder = style([ DefaultReset, { height: toRem(28), + '@media': { 'screen and (max-width: 480px)': NARROW_HIDDEN }, }, ]); @@ -19,6 +28,7 @@ export const RoomViewFollowing = recipe({ backgroundColor: color.Surface.Container, color: color.Surface.OnContainer, outline: 'none', + '@media': { 'screen and (max-width: 480px)': NARROW_HIDDEN }, }, ], variants: { diff --git a/src/app/features/room/RoomViewPoseStatus.css.ts b/src/app/features/room/RoomViewPoseStatus.css.ts new file mode 100644 index 00000000..d8319d0d --- /dev/null +++ b/src/app/features/room/RoomViewPoseStatus.css.ts @@ -0,0 +1,40 @@ +import { style } from '@vanilla-extract/css'; +import { DefaultReset, color, config, toRem } from 'folds'; + +const NARROW = 'screen and (max-width: 480px)'; + +export const StatusBlock = style([ + DefaultReset, + { + display: 'flex', + flexDirection: 'column', + flexShrink: 0, + padding: `0 ${toRem(16)}`, + // Phones: the newest mood and its "+N more" share one line. + '@media': { [NARROW]: { flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center' } }, + }, +]); + +// On phones only the newest mood shows until the strip is tapped; wide screens show every line. +export const StatusRow = style({ + '@media': { + [NARROW]: { + selectors: { + '&[data-newest="false"]': { display: 'none' }, + [`${StatusBlock}[data-open="true"] &`]: { display: 'block', flexBasis: '100%' }, + }, + }, + }, +}); + +export const StatusToggle = style([ + DefaultReset, + { + display: 'none', + minHeight: toRem(24), + padding: `0 ${config.space.S100}`, + color: color.Primary.Main, + cursor: 'pointer', + '@media': { [NARROW]: { display: 'block' } }, + }, +]); diff --git a/src/app/features/room/RoomViewPoseStatus.tsx b/src/app/features/room/RoomViewPoseStatus.tsx index d4a52c93..e62f81c0 100644 --- a/src/app/features/room/RoomViewPoseStatus.tsx +++ b/src/app/features/room/RoomViewPoseStatus.tsx @@ -1,17 +1,24 @@ import React, { useEffect, useState } from 'react'; -import { Box, Text } from 'folds'; +import { Text } from 'folds'; import { Room, RoomEvent } from 'matrix-js-sdk'; import { settingsAtom } from '../../state/settings'; import { useSetting } from '../../state/hooks/settings'; import { getMxIdLocalPart } from '../../utils/matrix'; import { getMemberDisplayName } from '../../utils/room'; -import { POSE_KEY, poseStatusLine, poseStatuses } from '../../components/message/pose/poses'; +import { + POSE_KEY, + poseStatusLine, + poseStatuses, + splitNewestStatus, +} from '../../components/message/pose/poses'; +import * as css from './RoomViewPoseStatus.css'; // Client-status lines about each Mind's current pose. They are drawn from the // timeline already in memory and are never sent as anyone's message. export function RoomViewPoseStatus({ room }: { room: Room }) { const [poseBubbles] = useSetting(settingsAtom, 'poseBubbles'); const [, bump] = useState(0); + const [open, setOpen] = useState(false); useEffect(() => { const onTimeline = () => bump((n) => n + 1); @@ -41,13 +48,33 @@ export function RoomViewPoseStatus({ room }: { room: Room }) { ); if (statuses.length === 0) return null; + const { newest, rest } = splitNewestStatus(statuses); + return ( - - {statuses.map((s) => ( - - {poseStatusLine(s)} +
    + {statuses.map((st) => ( + + {poseStatusLine(st)} ))} - + {rest.length > 0 && ( + + )} +
    ); } From be947a0bf929873ced3ae0580fdb7ead38144317 Mon Sep 17 00:00:00 2001 From: Void Date: Sun, 8 Nov 2026 18:05:11 +0000 Subject: [PATCH 130/206] Retry an upload once when it aborts at once with no progress An AbortError within 3s of starting, with no progress event and no user cancel, is retried once; the failed attempt discards its connection, so the retry cannot reuse it. The final failure text says "retried" with attempt 1's timing. Never retried after progress, a user cancel, a different error, or a slow abort. The cause (a reused dead keep-alive socket) is unproven. Co-Authored-By: Claude Sonnet 5.5 --- src/app/utils/matrix.ts | 67 ++++++++++++------ src/app/utils/uploadRetry.test.ts | 113 ++++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+), 20 deletions(-) create mode 100644 src/app/utils/uploadRetry.test.ts diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts index 7890a17c..3eef6b21 100644 --- a/src/app/utils/matrix.ts +++ b/src/app/utils/matrix.ts @@ -161,6 +161,8 @@ export type UploadTrace = { firstProgressAt?: number; loaded?: number; total?: number; + /** Summary of an earlier attempt of the same upload, when this one is a retry. */ + previous?: string; }; const describeNetwork = (): string => { @@ -207,6 +209,7 @@ export const uploadFailure = async ( }B` ); parts.push(`failed after ${now - trace.startedAt}ms`); + if (trace.previous) parts.push(`retried (attempt 1: ${trace.previous})`); } const error = `Upload failed: ${message ? `${name}: ${message}` : name} (${parts.join(', ')})`; const err = new MatrixError({ error, errcode: name }); @@ -214,6 +217,11 @@ export const uploadFailure = async ( return err; }; +// An abort before any progress, within this long, is retried once: the request most likely died on +// a reused connection before a byte left, and the failed attempt discards that connection. Nothing +// here has shown that is the cause; the failure text records both attempts so it can be checked. +const RETRY_WINDOW_MS = 3000; + export const uploadContent = async ( mx: MatrixClient, file: TUploadContent, @@ -221,26 +229,45 @@ export const uploadContent = async ( ) => { const { name, fileType, hideFilename, onProgress, onPromise, onSuccess, onError } = options; - const trace: UploadTrace = { startedAt: Date.now() }; - const uploadPromise = mx.uploadContent(file, { - name, - type: fileType, - includeFilename: !hideFilename, - progressHandler: (progress) => { - if (trace.firstProgressAt === undefined) trace.firstProgressAt = Date.now(); - trace.loaded = progress.loaded; - trace.total = progress.total; - onProgress?.(progress); - }, - }); - onPromise?.(uploadPromise); - try { - const data = await uploadPromise; - const mxc = data.content_uri; - if (mxc) onSuccess(mxc); - else onError(new MatrixError(data)); - } catch (e: any) { - onError(await uploadFailure(e, file, trace)); + let previous: string | undefined; + for (let attempt = 1; ; attempt += 1) { + const trace: UploadTrace = { startedAt: Date.now(), previous }; + const abortController = new AbortController(); + const uploadPromise = mx.uploadContent(file, { + name, + type: fileType, + includeFilename: !hideFilename, + abortController, + progressHandler: (progress) => { + if (trace.firstProgressAt === undefined) trace.firstProgressAt = Date.now(); + trace.loaded = progress.loaded; + trace.total = progress.total; + onProgress?.(progress); + }, + }); + onPromise?.(uploadPromise); + try { + // eslint-disable-next-line no-await-in-loop + const data = await uploadPromise; + const mxc = data.content_uri; + if (mxc) onSuccess(mxc); + else onError(new MatrixError(data)); + return; + } catch (e: any) { + const elapsed = Date.now() - trace.startedAt; + const retry = + attempt === 1 && + e?.name === 'AbortError' && + !abortController.signal.aborted && + trace.firstProgressAt === undefined && + elapsed < RETRY_WINDOW_MS; + if (!retry) { + // eslint-disable-next-line no-await-in-loop + onError(await uploadFailure(e, file, trace)); + return; + } + previous = `AbortError after ${elapsed}ms, no progress`; + } } }; diff --git a/src/app/utils/uploadRetry.test.ts b/src/app/utils/uploadRetry.test.ts new file mode 100644 index 00000000..3e9957c2 --- /dev/null +++ b/src/app/utils/uploadRetry.test.ts @@ -0,0 +1,113 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { uploadContent } from './matrix'; + +const abort = () => new DOMException('', 'AbortError'); +const blob = new Blob(['abcd'], { type: 'image/jpeg' }); + +type Step = (opts: any) => Promise; +const fakeMx = (steps: Step[]) => { + const calls: any[] = []; + const mx = { + uploadContent: vi.fn((_file: unknown, opts: any) => { + calls.push(opts); + return steps[calls.length - 1](opts); + }), + }; + return { mx: mx as any, calls }; +}; +const run = async (mx: any) => { + const onSuccess = vi.fn(); + const onError = vi.fn(); + const onPromise = vi.fn(); + await uploadContent(mx, blob, { onSuccess, onError, onPromise }); + return { onSuccess, onError, onPromise }; +}; + +describe('uploadContent retry', () => { + afterEach(() => vi.useRealTimers()); + + it('retries once when the first attempt aborts at once with no progress', async () => { + const { mx } = fakeMx([ + () => Promise.reject(abort()), + async () => ({ content_uri: 'mxc://a/b' }), + ]); + const r = await run(mx); + expect(mx.uploadContent).toHaveBeenCalledTimes(2); + expect(r.onSuccess).toHaveBeenCalledWith('mxc://a/b'); + expect(r.onError).not.toHaveBeenCalled(); + expect(r.onPromise).toHaveBeenCalledTimes(2); + }); + + it('retries at most once and reports both attempts when the retry fails too', async () => { + const { mx } = fakeMx([() => Promise.reject(abort()), () => Promise.reject(abort())]); + const r = await run(mx); + expect(mx.uploadContent).toHaveBeenCalledTimes(2); + const msg = r.onError.mock.calls[0][0].message; + expect(msg).toContain('retried'); + expect(msg).toContain('attempt 1: AbortError'); + expect(msg).toContain('no progress'); + }); + + it('never retries after a progress event', async () => { + const { mx } = fakeMx([ + (opts) => { + opts.progressHandler({ loaded: 10, total: 100 }); + return Promise.reject(abort()); + }, + async () => ({ content_uri: 'mxc://a/b' }), + ]); + const r = await run(mx); + expect(mx.uploadContent).toHaveBeenCalledTimes(1); + expect(r.onError).toHaveBeenCalledTimes(1); + expect(r.onError.mock.calls[0][0].message).not.toContain('retried'); + }); + + it('never retries after the user cancelled', async () => { + const { mx } = fakeMx([ + (opts) => { + opts.abortController.abort(); + return Promise.reject(abort()); + }, + async () => ({ content_uri: 'mxc://a/b' }), + ]); + const r = await run(mx); + expect(mx.uploadContent).toHaveBeenCalledTimes(1); + expect(r.onError).toHaveBeenCalledTimes(1); + }); + + it('never retries an error that is not an abort', async () => { + const { mx } = fakeMx([() => Promise.reject(new Error('Timeout')), async () => ({})]); + const r = await run(mx); + expect(mx.uploadContent).toHaveBeenCalledTimes(1); + expect(r.onError).toHaveBeenCalledTimes(1); + }); + + it('never retries an abort that took three seconds or more', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const { mx } = fakeMx([ + () => { + vi.setSystemTime(3000); + return Promise.reject(abort()); + }, + async () => ({ content_uri: 'mxc://a/b' }), + ]); + const r = await run(mx); + expect(mx.uploadContent).toHaveBeenCalledTimes(1); + expect(r.onError).toHaveBeenCalledTimes(1); + }); + + it('retries the same abort at 2999ms', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const { mx } = fakeMx([ + () => { + vi.setSystemTime(2999); + return Promise.reject(abort()); + }, + async () => ({ content_uri: 'mxc://a/b' }), + ]); + await run(mx); + expect(mx.uploadContent).toHaveBeenCalledTimes(2); + }); +}); From 685abbb19b341062c78702065ba91c8f3877468b Mon Sep 17 00:00:00 2001 From: Nibu Date: Sun, 8 Nov 2026 18:10:21 +0000 Subject: [PATCH 131/206] scene: terrain steep faces without streaks, smoother normals and silhouettes - albedo wobble, grain and strata are triplanar (weights |n|^4 over yz, xz, xy), so a cliff is not the floor pattern smeared down it - normals from bilinear height at a continuous position, stencil 1.5 texels each side - the grid is packed toward the camera at the patch centre (warpUv), 192 low / 256 full, vertex heights bilinear: near ridge silhouettes no longer step in 23 m blocks Co-Authored-By: Claude Sonnet 5.5 --- .../scene/terrain/terrainAlbedo.test.ts | 54 ++++++++++++-- .../features/scene/terrain/terrainAlbedo.ts | 73 ++++++++++++------- .../features/scene/terrain/terrainShaders.ts | 49 +++++++------ .../scene/terrain/terrainWorld.test.ts | 29 +++++++- .../features/scene/terrain/terrainWorld.ts | 14 +++- 5 files changed, 162 insertions(+), 57 deletions(-) diff --git a/src/app/features/scene/terrain/terrainAlbedo.test.ts b/src/app/features/scene/terrain/terrainAlbedo.test.ts index 385344d3..33855158 100644 --- a/src/app/features/scene/terrain/terrainAlbedo.test.ts +++ b/src/app/features/scene/terrain/terrainAlbedo.test.ts @@ -1,7 +1,15 @@ /* eslint-disable no-restricted-syntax -- tests loop over skies, heights and slopes */ import { describe, expect, it } from 'vitest'; import { SKY } from './terrainSky'; -import { ALBEDO, albedoAt, albedoPalette, grainAt, wobble } from './terrainAlbedo'; +import { + ALBEDO, + albedoAt, + albedoPalette, + grainAt, + triWeights, + wobble, + wobbleAt, +} from './terrainAlbedo'; import { GRID_FRAG } from './terrainShaders'; const near = (a: readonly number[], b: readonly number[], eps = 1e-6) => @@ -9,8 +17,10 @@ const near = (a: readonly number[], b: readonly number[], eps = 1e-6) => const luma = (c: readonly number[]) => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; // A point where the wobble is zero, so band edges sit exactly where the palette says. const FLAT_X = Math.PI / 0.043; +// A normal for the xz plane alone (all the weight on it), carrying `ny` for the slope bands. +const facing = (ny: number) => [0, ny, 0] as const; const at = (sky: keyof typeof SKY, h: number, ny: number) => - albedoAt(albedoPalette(SKY[sky]), h, ny, FLAT_X, 0); + albedoAt(albedoPalette(SKY[sky]), h, facing(ny), FLAT_X, 0); describe('albedo by slope and height', () => { it('the test point really has no wobble', () => { @@ -50,7 +60,7 @@ describe('albedo by slope and height', () => { for (const sky of ['midnight', 'noonday'] as const) for (let h = -20; h < 300; h += 7) for (let ny = 0; ny <= 1; ny += 0.1) - for (const v of albedoAt(albedoPalette(SKY[sky]), h, ny, h * 3, ny * 91)) { + for (const v of albedoAt(albedoPalette(SKY[sky]), h, facing(ny), h * 3, ny * 91)) { expect(v).toBeGreaterThanOrEqual(0); expect(v).toBeLessThanOrEqual(1); } @@ -105,9 +115,14 @@ describe('shader agrees with the pure function on its numbers', () => { }); }); +const UP = [0, 1, 0] as const; +const EAST = [1, 0, 0] as const; + describe('grain', () => { const samples = (rock: number, distance: number) => - Array.from({ length: 400 }, (_, i) => grainAt(i * 1.7, i * 0.9, 100 + i * 0.3, rock, distance)); + Array.from({ length: 400 }, (_, i) => + grainAt(i * 1.7, 100 + i * 0.3, i * 0.9, UP, rock, distance) + ); const spread = (v: number[]) => Math.max(...v) - Math.min(...v); it('varies the surface near the eye, never goes negative, and averages about 1', () => { @@ -119,14 +134,41 @@ describe('grain', () => { it('drops its fine octave with distance, so far ground does not shimmer', () => { const rangeOf = (d: number) => - spread(Array.from({ length: 400 }, (_, i) => grainAt(i * 0.37, i * 0.21, 100, 0, d))); + spread(Array.from({ length: 400 }, (_, i) => grainAt(i * 0.37, 100, i * 0.21, UP, 0, d))); expect(rangeOf(900)).toBeLessThan(rangeOf(50)); }); it('layers the rock: strata show only where the face is rock', () => { const height = (rock: number) => - spread(Array.from({ length: 400 }, (_, i) => grainAt(0, 0, 100 + i * 0.05, rock, 2000))); + spread(Array.from({ length: 400 }, (_, i) => grainAt(0, 100 + i * 0.05, 0, UP, rock, 2000))); expect(height(1)).toBeGreaterThan(0.2); expect(height(0)).toBeLessThan(1e-9); }); }); + +describe('triplanar projection', () => { + it('weights sum to 1 and go to the plane the face looks along', () => { + for (const n of [UP, EAST, [0.6, 0.6, 0.529], [0, 0, 1]] as const) { + const w = triWeights(n); + expect(w[0] + w[1] + w[2]).toBeCloseTo(1, 9); + } + expect(triWeights(UP)).toEqual([0, 1, 0]); + expect(triWeights(EAST)).toEqual([1, 0, 0]); + expect(triWeights([0.8, 0.5, 0.33])[0]).toBeGreaterThan(0.7); + }); + + it('a cliff facing east takes its pattern over (z, y), so it varies with height and not with x: no vertical smear', () => { + const col = (x: number, y: number) => grainAt(x, y, 40, EAST, 0, 50); + const alongY = Array.from({ length: 200 }, (_, i) => col(10, 50 + i * 0.7)); + const alongX = Array.from({ length: 200 }, (_, i) => col(i * 0.7, 50)); + expect(Math.max(...alongY) - Math.min(...alongY)).toBeGreaterThan(0.3); + expect(Math.max(...alongX) - Math.min(...alongX)).toBeLessThan(1e-9); + expect(wobbleAt(5, 80, 40, EAST)).toBeCloseTo(wobbleAt(500, 80, 40, EAST), 12); + expect(wobbleAt(5, 80, 40, EAST)).not.toBeCloseTo(wobbleAt(5, 120, 40, EAST), 3); + }); + + it('level ground takes its pattern over (x, z) alone, as before', () => { + expect(grainAt(12, 10, 30, UP, 0, 50)).toBeCloseTo(grainAt(12, 900, 30, UP, 0, 50), 12); + expect(wobbleAt(12, 10, 30, UP)).toBeCloseTo(wobble(12, 30), 12); + }); +}); diff --git a/src/app/features/scene/terrain/terrainAlbedo.ts b/src/app/features/scene/terrain/terrainAlbedo.ts index d452dec8..c9f11ab4 100644 --- a/src/app/features/scene/terrain/terrainAlbedo.ts +++ b/src/app/features/scene/terrain/terrainAlbedo.ts @@ -79,19 +79,36 @@ export function albedoPalette(d: SkyData): AlbedoPalette { }; } -/** Cheap two-sine value in -1..1 from world position; stands in for noise so no texture or hash is read. */ +/** Cheap two-sine value in -1..1 from a 2D world position; stands in for noise so no texture or hash is read. */ export const wobble = (x: number, z: number): number => Math.sin(x * 0.043 + z * 0.017) * Math.sin(z * 0.051 - x * 0.023); /** - * Linear albedo at a surface point. `height` in metres, `ny` the unit normal's y, `x`/`z` world metres. + * How much each axis-aligned projection plane counts at a surface with unit normal `n`: weights for (yz, xz, xy), summing + * to 1, sharply favouring the plane the face looks along. Procedural patterns are evaluated on all three and blended by + * these (triplanar), so a cliff is not the floor pattern smeared down it. + */ +export function triWeights(n: Vec3): Vec3 { + const w = n.map((c) => Math.abs(c) ** 4); + const sum = w[0] + w[1] + w[2] || 1; + return [w[0] / sum, w[1] / sum, w[2] / sum]; +} + +/** `wobble` blended over the three projection planes, at a surface point with unit normal `n`. */ +export function wobbleAt(x: number, y: number, z: number, n: Vec3): number { + const w = triWeights(n); + return w[0] * wobble(z, y) + w[1] * wobble(x, z) + w[2] * wobble(x, y); +} + +/** + * Linear albedo at a surface point. `y` the height in metres, `n` the unit normal, `x`/`z` world metres. * Rock takes the steep faces at any height; on the rest, soil to scree by height, then snow above the snow * line, thinning to nothing as the face steepens. */ -export function albedoAt(p: AlbedoPalette, height: number, ny: number, x: number, z: number): Vec3 { - const w = wobble(x, z); - const h = height + ALBEDO.wobbleHeight * w; - const flat = ny + ALBEDO.wobbleSlope * w; +export function albedoAt(p: AlbedoPalette, y: number, n: Vec3, x: number, z: number): Vec3 { + const w = wobbleAt(x, y, z, n); + const h = y + ALBEDO.wobbleHeight * w; + const flat = n[1] + ALBEDO.wobbleSlope * w; const ground = (i: number) => mix(p.soil[i], p.scree[i], smooth(ALBEDO.screeFrom, ALBEDO.screeTo, h)); const snow = @@ -103,33 +120,39 @@ export function albedoAt(p: AlbedoPalette, height: number, ny: number, x: number ) as unknown as Vec3; } +const COARSE = [ + [0.31, 0.27], + [-0.22, 0.35], + [0.38, -0.12], +] as const; +const FINE = [ + [1.1, 0.7], + [-0.8, 1.2], + [1.3, -0.6], +] as const; +const waves = (k: ReadonlyArray, a: number, b: number) => + k.reduce((sum, [ka, kb]) => sum + Math.sin(a * ka + b * kb), 0) / 3; + /** - * The multiplier on albedo that gives the surface grain and the rock its layers, from world position (metres), - * height, `rock` (0..1, how much of the face is rock) and the distance from the eye. The shader's `ALB_GRAIN_*` block does the same arithmetic. - * The cavity term beside it in the shader reads the height map and has no twin here. + * The multiplier on albedo that gives the surface grain and the rock its layers, at a surface point (metres) with unit + * normal `n`, `rock` (0..1, how much of the face is rock) and the distance from the eye. Grain is triplanar like the + * wobble. The shader's `ALB_GRAIN_*` block does the same arithmetic. The cavity term beside it in the shader reads the + * height map and has no twin here. */ export function grainAt( x: number, + y: number, z: number, - height: number, + n: Vec3, rock: number, distance: number ): number { - const waves = (k: ReadonlyArray) => - k.reduce((sum, [kx, kz]) => sum + Math.sin(x * kx + z * kz), 0) / 3; - const coarse = waves([ - [0.31, 0.27], - [-0.22, 0.35], - [0.38, -0.12], - ]); - const fine = - waves([ - [1.1, 0.7], - [-0.8, 1.2], - [1.3, -0.6], - ]) * - (1 - smooth(ALBEDO.fineNear, ALBEDO.fineFar, distance)); - const layers = Math.sin(height * 0.9 + wobble(x, z) * 3); + const w = triWeights(n); + const over = (k: ReadonlyArray) => + w[0] * waves(k, z, y) + w[1] * waves(k, x, z) + w[2] * waves(k, x, y); + const coarse = over(COARSE); + const fine = over(FINE) * (1 - smooth(ALBEDO.fineNear, ALBEDO.fineFar, distance)); + const layers = Math.sin(y * 0.9 + wobbleAt(x, y, z, n) * 3); return Math.max( 0, 1 + ALBEDO.grainCoarse * coarse + ALBEDO.grainFine * fine + ALBEDO.strata * rock * layers diff --git a/src/app/features/scene/terrain/terrainShaders.ts b/src/app/features/scene/terrain/terrainShaders.ts index 04d0425b..93670449 100644 --- a/src/app/features/scene/terrain/terrainShaders.ts +++ b/src/app/features/scene/terrain/terrainShaders.ts @@ -90,6 +90,12 @@ float hAt(ivec2 t) { return (floor(s.r * 255.0 + 0.5) * 256.0 + floor(s.g * 255.0 + 0.5)) / 65535.0 * ${f1( TERRAIN.hmax )}; +} +// The height at a point in texel units, bilinear over the four texels around it (the packed texture cannot be filtered by the GPU). +float hBil(vec2 p) { + ivec2 i = ivec2(floor(p)); + vec2 f = fract(p); + return mix(mix(hAt(i), hAt(i + ivec2(1, 0)), f.x), mix(hAt(i + ivec2(0, 1)), hAt(i + ivec2(1, 1)), f.x), f.y); }`; export const GRID_VERT = `${HEADER} @@ -100,12 +106,14 @@ out vec2 v_uv; out vec3 v_world; ${FETCH} void main() { - ivec2 t = ivec2(a_uv * float(uTex - 1) + 0.5); + // The grid is packed toward the centre, where the camera stands (terrainWorld.warpUv): s = 2u - 1, 0.5 + 0.5 (0.5 s + 0.5 s |s|). + vec2 s = a_uv * 2.0 - 1.0; + vec2 uv = 0.5 + 0.5 * (0.5 * s + 0.5 * s * abs(s)); // The patch's rim sinks to zero so the grid has no cliff edge to show through the haze. - float rim = 1.0 - smoothstep(0.78, 1.0, 2.0 * max(abs(a_uv.x - 0.5), abs(a_uv.y - 0.5))); - float h = hAt(t) * rim; - v_uv = a_uv; - v_world = vec3((a_uv.x - 0.5) * uSpan, h, (a_uv.y - 0.5) * uSpan); + float rim = 1.0 - smoothstep(0.78, 1.0, 2.0 * max(abs(uv.x - 0.5), abs(uv.y - 0.5))); + float h = hBil(uv * float(uTex - 1)) * rim; + v_uv = uv; + v_world = vec3((uv.x - 0.5) * uSpan, h, (uv.y - 0.5) * uSpan); gl_Position = uViewProj * vec4(v_world, 1.0); }`; @@ -145,23 +153,21 @@ uniform vec3 uCamera, uLightDir, uLight, uAmbient, uSoil, uScree, uRock, uSnow; uniform float uSpan, uHazeK, uSnowLine; out vec4 o; ${FETCH} -// Unnormalised normal at one texel, central differences over its neighbours. -vec3 nAt(ivec2 t, float texel) { - float dx = hAt(t + ivec2(1, 0)) - hAt(t - ivec2(1, 0)); - float dz = hAt(t + ivec2(0, 1)) - hAt(t - ivec2(0, 1)); - return normalize(vec3(-dx, 2.0 * texel, -dz)); -} +// Triplanar patterns: each projection plane's value, blended by the weights of the face's normal (terrainAlbedo.triWeights). +float wob2(vec2 q) { return sin(q.x * 0.043 + q.y * 0.017) * sin(q.y * 0.051 - q.x * 0.023); } +float waves3(vec2 q, vec2 a, vec2 b, vec2 c) { return (sin(dot(q, a)) + sin(dot(q, b)) + sin(dot(q, c))) / 3.0; } void main() { - // The four texels around this fragment, blended, so the shading is continuous and not one flat square per texel. + // Normal from the bilinear height at a continuous position, a stencil 1.5 texels each side: smooth across texel borders. vec2 p = v_uv * float(uTex - 1); - ivec2 t = ivec2(floor(p)); - vec2 f = fract(p); float texel = uSpan / float(uTex - 1); - vec3 n = normalize(mix( - mix(nAt(t, texel), nAt(t + ivec2(1, 0), texel), f.x), - mix(nAt(t + ivec2(0, 1), texel), nAt(t + ivec2(1, 1), texel), f.x), f.y)); + float dx = hBil(p + vec2(1.5, 0.0)) - hBil(p - vec2(1.5, 0.0)); + float dz = hBil(p + vec2(0.0, 1.5)) - hBil(p - vec2(0.0, 1.5)); + vec3 n = normalize(vec3(-dx, 3.0 * texel, -dz)); + vec3 tw = pow(abs(n), vec3(4.0)); + tw /= tw.x + tw.y + tw.z; + vec2 qx = v_world.zy, qy = v_world.xz, qz = v_world.xy; // Procedural albedo, no texture reads; the same arithmetic as terrainAlbedo.albedoAt(). - float w = sin(v_world.x * 0.043 + v_world.z * 0.017) * sin(v_world.z * 0.051 - v_world.x * 0.023); + float w = tw.x * wob2(qx) + tw.y * wob2(qy) + tw.z * wob2(qz); float h = v_world.y + ALB_WOBBLE_H * w; float flat_ = n.y + ALB_WOBBLE_S * w; vec3 ground = mix(uSoil, uScree, smoothstep(ALB_SCREE_FROM, ALB_SCREE_TO, h)); @@ -169,15 +175,14 @@ void main() { float rockW = 1.0 - smoothstep(ALB_ROCK_LO, ALB_ROCK_HI, flat_); vec3 albedo = mix(mix(ground, uSnow, snow), uRock, rockW); // Surface grain and rock strata, the same arithmetic as terrainAlbedo.grainAt(). - vec2 q = v_world.xz; - float coarse = (sin(dot(q, vec2(0.31, 0.27))) + sin(dot(q, vec2(-0.22, 0.35))) + sin(dot(q, vec2(0.38, -0.12)))) / 3.0; - float fine = (sin(dot(q, vec2(1.1, 0.7))) + sin(dot(q, vec2(-0.8, 1.2))) + sin(dot(q, vec2(1.3, -0.6)))) / 3.0; + float coarse = tw.x * waves3(qx, vec2(0.31, 0.27), vec2(-0.22, 0.35), vec2(0.38, -0.12)) + tw.y * waves3(qy, vec2(0.31, 0.27), vec2(-0.22, 0.35), vec2(0.38, -0.12)) + tw.z * waves3(qz, vec2(0.31, 0.27), vec2(-0.22, 0.35), vec2(0.38, -0.12)); + float fine = tw.x * waves3(qx, vec2(1.1, 0.7), vec2(-0.8, 1.2), vec2(1.3, -0.6)) + tw.y * waves3(qy, vec2(1.1, 0.7), vec2(-0.8, 1.2), vec2(1.3, -0.6)) + tw.z * waves3(qz, vec2(1.1, 0.7), vec2(-0.8, 1.2), vec2(1.3, -0.6)); float near = 1.0 - smoothstep(ALB_FINE_NEAR, ALB_FINE_FAR, length(v_world - uCamera)); float layers = sin(v_world.y * 0.9 + w * 3.0); albedo *= max(0.0, 1.0 + ALB_GRAIN_COARSE * coarse + ALB_GRAIN_FINE * near * fine + ALB_STRATA * rockW * layers); // Cavity: a point lower than its surroundings sits in shade, one higher catches light. Shader-only, from the height map. int r = max(1, int(12.0 / texel)); - ivec2 c = ivec2(p + 0.5); + ivec2 c = ivec2(floor(p + 0.5)); float curv = 0.25 * (hAt(c + ivec2(r, 0)) + hAt(c - ivec2(r, 0)) + hAt(c + ivec2(0, r)) + hAt(c - ivec2(0, r))) - hAt(c); float cavity = 1.0 - 0.55 * clamp(curv / 12.0, 0.0, 1.0) + 0.3 * clamp(-curv / 12.0, 0.0, 1.0); vec3 lit = albedo * cavity * (uLight * max(dot(n, uLightDir), 0.0) + uAmbient * (0.5 + 0.5 * n.y)); diff --git a/src/app/features/scene/terrain/terrainWorld.test.ts b/src/app/features/scene/terrain/terrainWorld.test.ts index 5d21ba47..17935382 100644 --- a/src/app/features/scene/terrain/terrainWorld.test.ts +++ b/src/app/features/scene/terrain/terrainWorld.test.ts @@ -1,6 +1,15 @@ /* eslint-disable no-restricted-syntax -- tests loop over skies, normals and distances */ import { describe, expect, it } from 'vitest'; -import { buildGrid, CAMERA, GRID, seedOffset, TERRAIN, triangleCount, view } from './terrainWorld'; +import { + buildGrid, + CAMERA, + GRID, + seedOffset, + TERRAIN, + triangleCount, + view, + warpUv, +} from './terrainWorld'; import { heightFragmentSource } from './terrainShaders'; describe('seed', () => { @@ -32,7 +41,7 @@ describe('grid', () => { }); it('each tier fits a 32-bit index and the cheap tiers stay cheap', () => { - expect(triangleCount(GRID.low.grid)).toBeLessThan(40000); + expect(triangleCount(GRID.low.grid)).toBeLessThan(80000); expect(GRID.full.grid).toBeGreaterThan(GRID.low.grid); expect(GRID.still.tex).toBeLessThanOrEqual(GRID.low.tex); }); @@ -79,3 +88,19 @@ describe('camera', () => { expect(Math.hypot(...v.up)).toBeCloseTo(1, 9); }); }); + +describe('warpUv', () => { + it('maps the patch onto itself, monotonic, centre fixed', () => { + expect(warpUv(0)).toBe(0); + expect(warpUv(1)).toBe(1); + expect(warpUv(0.5)).toBe(0.5); + for (let i = 0; i < 100; i += 1) expect(warpUv((i + 1) / 100)).toBeGreaterThan(warpUv(i / 100)); + }); + + it('packs the vertices toward the camera at the centre: finer there, coarser at the rim, than a plain grid', () => { + const plain = 1 / 192; + const step = (u: number) => warpUv(u + 1 / 192) - warpUv(u); + expect(step(0.5)).toBeLessThan(0.6 * plain); + expect(step(0.99)).toBeGreaterThan(1.3 * plain); + }); +}); diff --git a/src/app/features/scene/terrain/terrainWorld.ts b/src/app/features/scene/terrain/terrainWorld.ts index 97577054..fcc02390 100644 --- a/src/app/features/scene/terrain/terrainWorld.ts +++ b/src/app/features/scene/terrain/terrainWorld.ts @@ -8,8 +8,8 @@ export const TERRAIN = { span: 3000, hmax: 256 }; /** Grid quads per side and height-map texels per side, by tier. 'still' draws one frame, so it takes the cheap pair. */ export const GRID = { still: { grid: 128, tex: 512 }, - low: { grid: 128, tex: 1024 }, - full: { grid: 192, tex: 1024 }, + low: { grid: 192, tex: 1024 }, + full: { grid: 256, tex: 1024 }, } as const; /** The camera: at the patch centre, above the highest peak, turning slowly and looking a little down. */ @@ -29,6 +29,16 @@ export function seedOffset(seed: number): [number, number] { return [(s * 7919) % 65536, (s * 104729) % 65536]; } +/** + * Where grid coordinate `u` (0..1 across the patch) lands in the patch, 0..1. The camera stands at the centre, so the + * grid is packed toward it: with s = 2u - 1 the spacing grows as 0.5 + |s| of the plain grid's, about half of it at the + * centre and half again as much at the rim. The vertex shader does the same arithmetic (terrainShaders.ts GRID_VERT). + */ +export const warpUv = (u: number): number => { + const s = 2 * u - 1; + return 0.5 + 0.5 * (0.5 * s + 0.5 * s * Math.abs(s)); +}; + export const triangleCount = (grid: number): number => 2 * grid * grid; /** Vertex grid in unit uv: (grid + 1)^2 positions, then the two-triangle-per-quad index list. */ From c35ac7e5cd7b0660302ee47534ccc7680f4bce8c Mon Sep 17 00:00:00 2001 From: Nibu Date: Sun, 8 Nov 2026 18:16:47 +0000 Subject: [PATCH 132/206] scene: terrain haze is aerial perspective from a baked Hillaire camera volume Replaces the exp(-(k d)^1.6) haze with the camera volume of Hillaire, EGSR 2020 section 5.4 (and sebh/UnrealEngineSkyAtmosphere RenderCameraVolumePS): slices on the squared distribution, in-scatter integrated with each step's extinction analytic, alpha the grey opacity 1 - mean(T). Baked into baked.scatter.aerial_perspective by src/sky/scatter.js (RGBA16F 7 x 96 x 12, 86 KB), looked up in the terrain fragment shader (two fetches). Night: the moon lights the haze through the same volume, plus a deep blue starlight floor. Shortcuts, with units and constants, in src/sky/README.md: sun-relative indexing, 12 x 8 x 7 texels, path = scene metres x 8, ground ignored, single scattering, no multi-scattering LUT. Co-Authored-By: Claude Sonnet 5.5 --- .../features/scene/terrain/Terrain.test.ts | 13 +- src/app/features/scene/terrain/Terrain.ts | 33 ++++- .../scene/terrain/terrainAlbedo.test.ts | 2 +- .../scene/terrain/terrainScatter.test.ts | 55 ++++++++- .../features/scene/terrain/terrainScatter.ts | 87 +++++++++++++- .../features/scene/terrain/terrainShaders.ts | 28 +++-- .../features/scene/terrain/terrainSky.test.ts | 94 +++++++++------ src/app/features/scene/terrain/terrainSky.ts | 63 ++++++++-- src/sky/README.md | 22 ++++ src/sky/baked.ts | 12 ++ src/sky/scatter.js | 113 ++++++++++++++++++ src/sky/scatter.test.ts | 66 ++++++++++ 12 files changed, 521 insertions(+), 67 deletions(-) diff --git a/src/app/features/scene/terrain/Terrain.test.ts b/src/app/features/scene/terrain/Terrain.test.ts index b61b632b..01f2df11 100644 --- a/src/app/features/scene/terrain/Terrain.test.ts +++ b/src/app/features/scene/terrain/Terrain.test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createTerrainScene } from './Terrain'; import { GRID, triangleCount } from './terrainWorld'; import { groundLight, SKY, skyAtSun } from './terrainSky'; -import { SKY_VIEW, skyViewWords } from './terrainScatter'; +import { AP_VOLUME, apVolumeWords, SKY_VIEW, skyViewWords } from './terrainScatter'; import { SKY_FRAG } from './terrainShaders'; import { startBackgroundScene } from '../index'; @@ -176,14 +176,17 @@ describe('createTerrainScene', () => { expect(has(ambients({}), groundLight(SKY.midnight).ambient)).toBe(true); }); - it('uploads the baked scattering table once, as half floats, to a 3D texture', () => { + it('uploads the baked sky table and the aerial perspective volume once each, as half floats, to 3D textures', () => { const { gl, calls } = fakeGl(); createTerrainScene({ canvas: canvasWith(gl), tier: 'full' })!.dispose(); - expect(calls.texImage3D).toHaveLength(1); + expect(calls.texImage3D).toHaveLength(2); const a = calls.texImage3D[0]; expect([a[3], a[4], a[5]]).toEqual([SKY_VIEW.width, SKY_VIEW.height, SKY_VIEW.depth]); expect(a[9]).toEqual(skyViewWords()); - expect(calls.deleteTexture).toHaveLength(2); + const ap = calls.texImage3D[1]; + expect([ap[3], ap[4], ap[5]]).toEqual([AP_VOLUME.width, AP_VOLUME.height, AP_VOLUME.depth]); + expect(ap[9]).toEqual(apVolumeWords()); + expect(calls.deleteTexture).toHaveLength(3); }); it('dithers the sky output by half an 8-bit step either way, and the terrain not at all', () => { @@ -243,7 +246,7 @@ describe('createTerrainScene', () => { createTerrainScene({ canvas: canvasWith(gl), tier: 'full' })!.dispose(); expect(calls.deleteProgram).toHaveLength(3); expect(calls.deleteBuffer).toHaveLength(2); - expect(calls.deleteTexture).toHaveLength(2); + expect(calls.deleteTexture).toHaveLength(3); expect(rafQueue).toHaveLength(0); }); }); diff --git a/src/app/features/scene/terrain/Terrain.ts b/src/app/features/scene/terrain/Terrain.ts index d3c95ff3..fdc53926 100644 --- a/src/app/features/scene/terrain/Terrain.ts +++ b/src/app/features/scene/terrain/Terrain.ts @@ -2,11 +2,11 @@ import type { Scene, SceneOptions } from '../Scene'; import { FrameSampler, FrameStats } from '../ridge/probe'; import { albedoPalette } from './terrainAlbedo'; import { sunIndex } from '../../../../sky/scatter.js'; -import { SKY_VIEW, skyViewWords } from './terrainScatter'; +import { AP_VOLUME, apVolumeWords, SKY_VIEW, skyViewWords } from './terrainScatter'; import { + nightness, glowColour, groundLight, - HAZE_K, chooseSky, SkyName, START_TURNS, @@ -138,6 +138,26 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { [gl.TEXTURE_WRAP_S, gl.TEXTURE_WRAP_T, gl.TEXTURE_WRAP_R].forEach((w) => gl.texParameteri(gl.TEXTURE_3D, w, gl.CLAMP_TO_EDGE) ); + // The aerial perspective volume, RGBA16F, half floats as stored (src/sky/scatter.js). + const apTex = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_3D, apTex); + gl.texImage3D( + gl.TEXTURE_3D, + 0, + gl.RGBA16F, + AP_VOLUME.width, + AP_VOLUME.height, + AP_VOLUME.depth, + 0, + gl.RGBA, + gl.HALF_FLOAT, + apVolumeWords() + ); + gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + [gl.TEXTURE_WRAP_S, gl.TEXTURE_WRAP_T, gl.TEXTURE_WRAP_R].forEach((w) => + gl.texParameteri(gl.TEXTURE_3D, w, gl.CLAMP_TO_EDGE) + ); gl.pixelStorei(gl.UNPACK_ALIGNMENT, 4); gl.bindTexture(gl.TEXTURE_2D, heightTex); gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); @@ -148,6 +168,7 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { free.forEach((f) => f()); gl.deleteTexture(heightTex); gl.deleteTexture(skyTex); + gl.deleteTexture(apTex); gl.deleteFramebuffer(fbo); gl.deleteProgram(bakeProg); gl.deleteProgram(skyProg); @@ -192,6 +213,8 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { const heading = START_TURNS; const glow = [...glowColour(sky)]; const sunTexel = sunIndex(sky.elevation); + const night = nightness(sky.elevation); + const moonTexel = sunIndex(Math.abs(sky.elevation)); const palette = albedoPalette(sky); const triangles = triangleCount(spec.grid); @@ -252,7 +275,11 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_3D, skyTex); gl.uniform1i(u('uSky'), 1); - gl.uniform1f(u('uHazeK'), HAZE_K); + gl.uniform1f(u('uNight'), night); + gl.uniform1f(u('uMoonIndex'), moonTexel); + gl.activeTexture(gl.TEXTURE2); + gl.bindTexture(gl.TEXTURE_3D, apTex); + gl.uniform1i(u('uAP'), 2); gl.uniform3fv(u('uSoil'), palette.soil); gl.uniform3fv(u('uScree'), palette.scree); gl.uniform3fv(u('uRock'), palette.rock); diff --git a/src/app/features/scene/terrain/terrainAlbedo.test.ts b/src/app/features/scene/terrain/terrainAlbedo.test.ts index 33855158..2314f758 100644 --- a/src/app/features/scene/terrain/terrainAlbedo.test.ts +++ b/src/app/features/scene/terrain/terrainAlbedo.test.ts @@ -111,7 +111,7 @@ describe('shader agrees with the pure function on its numbers', () => { expect(GRID_FRAG).toContain(`#define ALB_GRAIN_COARSE ${ALBEDO.grainCoarse}`); expect(GRID_FRAG).toContain(`#define ALB_STRATA ${ALBEDO.strata}`); // the only fetch for colour-by-material would be a texture; the height map's texelFetch is the only other read, and the sky lookup is the baked scattering table's - expect(GRID_FRAG.match(/texelFetch|texture\((?!uSky)/g)).toHaveLength(1); + expect(GRID_FRAG.match(/texelFetch|texture\((?!uSky|uAP)/g)).toHaveLength(1); }); }); diff --git a/src/app/features/scene/terrain/terrainScatter.test.ts b/src/app/features/scene/terrain/terrainScatter.test.ts index 782d980e..c153eb5f 100644 --- a/src/app/features/scene/terrain/terrainScatter.test.ts +++ b/src/app/features/scene/terrain/terrainScatter.test.ts @@ -1,8 +1,21 @@ /* eslint-disable no-restricted-syntax -- tests walk table texels */ import { describe, expect, it } from 'vitest'; import { baked } from '../../../../sky/baked'; -import { decode, viewElevation } from '../../../../sky/scatter.js'; import { + aerialRay, + AP_ELEVATIONS, + AP_SLICES, + apElevation, + apSliceKm, + decode, + SUN_ELEVATIONS_DEG, + viewElevation, +} from '../../../../sky/scatter.js'; +import { + AERIAL_GLSL, + aerialAt, + AP_VOLUME, + apVolumeWords, SCATTER_GLSL, scatterRadiance, skyViewWords, @@ -86,3 +99,43 @@ describe('the GLSL sampler', () => { expect(SCATTER_GLSL).toContain('* 6.0'); }); }); + +describe('the aerial perspective volume as the terrain reads it', () => { + it('uploads RGBA half floats in the baked layout', () => { + const a = baked.scatter.aerial_perspective; + expect(apVolumeWords()).toHaveLength(a.width * a.height * a.depth * 4); + expect(AP_VOLUME).toEqual({ width: a.width, height: a.height, depth: a.depth }); + }); + + it('on a texel centre the CPU twin returns the baked ray exactly (to half-float precision)', () => { + const sunI = 8; + const ei = 5; + const slice = 6; + const want = aerialRay(SUN_ELEVATIONS_DEG[sunI], apElevation(ei), 90)[slice]; + const got = aerialAt(SUN_ELEVATIONS_DEG[sunI], apElevation(ei), 90, apSliceKm(slice)); + for (let c = 0; c < 3; c += 1) expect(got.L[c]).toBeCloseTo(want[c], 3); + expect(got.alpha).toBeCloseTo(want[3], 3); + }); + + it('between texels it is near the model: the filtering error is small against the signal', () => { + const want = aerialRay(10, -1.3, 75)[AP_SLICES - 2]; + const got = aerialAt(10, -1.3, 75, apSliceKm(AP_SLICES - 2)); + expect(Math.abs(got.alpha - want[3])).toBeLessThan(0.03); + expect(got.L[0] / want[0]).toBeGreaterThan(0.6); + expect(got.L[0] / want[0]).toBeLessThan(1.6); + }); + + it('fades to nothing at the observer, and clamps past the last slice', () => { + expect(aerialAt(45, -2, 90, 0)).toEqual({ L: [0, 0, 0], alpha: 0 }); + const last = aerialAt(45, -2, 90, apSliceKm(AP_SLICES - 1)); + const beyond = aerialAt(45, -2, 90, 1e6); + expect(beyond.alpha).toBeCloseTo(last.alpha, 9); + }); + + it('the shader lookup carries the same constants as the table', () => { + const a = baked.scatter.aerial_perspective; + expect(AERIAL_GLSL).toContain(`/ ${a.height}.0`); + expect(AERIAL_GLSL).toContain(`${a.max_km.toFixed(1)}`); + expect(AERIAL_GLSL).toContain(`${AP_ELEVATIONS}.0 +`); + }); +}); diff --git a/src/app/features/scene/terrain/terrainScatter.ts b/src/app/features/scene/terrain/terrainScatter.ts index 9149ac47..72ebfb5c 100644 --- a/src/app/features/scene/terrain/terrainScatter.ts +++ b/src/app/features/scene/terrain/terrainScatter.ts @@ -2,10 +2,20 @@ // uses them: decoded to half-float words for the GPU, a CPU twin of the shader's sky lookup for tests and // for the sun's colour at an arbitrary elevation, and the GLSL both shaders share. No DOM, no GL. import { baked } from '../../../../sky/baked'; -import { decode, sunIndex, viewIndex } from '../../../../sky/scatter.js'; +import { + AP_DISTANCE_SCALE, + AP_ELEVATIONS, + AP_SLICES, + apElevationIndex, + apSliceIndex, + decode, + sunIndex, + viewIndex, +} from '../../../../sky/scatter.js'; import type { Vec3 } from './terrainSky'; const S = baked.scatter; +const AP = S.aerial_perspective; const SKY = S.sky_view; const VIEW = S.view_elevation_deg; @@ -22,6 +32,11 @@ export const SKY_VIEW = { width: SKY.width, height: SKY.height, depth: SKY.depth export const STORE_SCALE = S.store_scale; export const skyViewWords = (): Uint16Array => tableWords(SKY.data); +/** The aerial perspective volume (layout in src/sky/scatter.js): RGBA16F, x azimuth from the light, y slice-major block of elevations, z light elevation. */ +export const AP_VOLUME = { width: AP.width, height: AP.height, depth: AP.depth } as const; +export const apVolumeWords = (): Uint16Array => tableWords(AP.data); +export { AP_DISTANCE_SCALE }; + /** Decode a stored table to values, once. */ const decoded = (data: string) => { let values: Float32Array | undefined; @@ -32,6 +47,7 @@ const decoded = (data: string) => { }; const transmittanceTable = decoded(S.transmittance.data); const skyTable = decoded(SKY.data); +const apTable = decoded(AP.data); /** Sun colour at normal incidence for a sun at `elevationDeg`: the baked transmittance, per channel. Zero under the horizon. */ export function transmittanceAt(elevationDeg: number): Vec3 { @@ -67,6 +83,52 @@ export function scatterRadiance(sunElDeg: number, viewElDeg: number, azFromSunDe return [out[0], out[1], out[2]]; } +/** + * Aerial perspective along a view ray: in-scatter luminance per channel (for a light of unit irradiance) and opacity + * (1 - mean transmittance), for a light at `lightElDeg`, a ray `viewElDeg` (0 and below) and `azDeg` from the light, + * `km` of baked path along it. Trilinear in (azimuth, elevation, light elevation) and linear between slices, the arithmetic the + * shader's `aerial()` does on the GPU; below the first slice it fades to nothing at the observer (Hillaire's near-slice fade). + */ +export function aerialAt( + lightElDeg: number, + viewElDeg: number, + azDeg: number, + km: number +): { L: Vec3; alpha: number } { + const t = apTable(); + const x = Math.min(Math.abs(azDeg) / 180, 1) * (AP.width - 1); + const e = apElevationIndex(viewElDeg); + const z = sunIndex(lightElDeg); + const texel = (slice: number): number[] => { + const lo = [ + Math.min(Math.floor(x), AP.width - 2), + Math.min(Math.floor(e), AP_ELEVATIONS - 2), + Math.min(Math.floor(z), AP.depth - 2), + ]; + const f = [x - lo[0], e - lo[1], z - lo[2]]; + const out = [0, 0, 0, 0]; + for (let corner = 0; corner < 8; corner += 1) { + const dx = corner % 2; + const dy = Math.floor(corner / 2) % 2; + const dz = Math.floor(corner / 4); + const w = (dx ? f[0] : 1 - f[0]) * (dy ? f[1] : 1 - f[1]) * (dz ? f[2] : 1 - f[2]); + const base = + (((lo[2] + dz) * AP.height + slice * AP_ELEVATIONS + lo[1] + dy) * AP.width + lo[0] + dx) * + 4; + for (let c = 0; c < 4; c += 1) out[c] += w * t[base + c]; + } + return out; + }; + const f = apSliceIndex(km); + const fs = Math.min(Math.max(f, 0), AP_SLICES - 1); + const k = Math.min(Math.floor(fs), AP_SLICES - 2); + const a = texel(k); + const b = texel(k + 1); + const fade = Math.min(Math.max(f + 1, 0), 1); + const v = [0, 1, 2, 3].map((c) => (a[c] + (b[c] - a[c]) * (fs - k)) * fade); + return { L: [v[0], v[1], v[2]], alpha: v[3] }; +} + /** * The lowest view elevation the sky is sampled at, degrees. The table dips toward the ground over its last degree * (its rows below the horizon are for an observer who would hit the ground), so at and below this the sky and the @@ -92,3 +154,26 @@ vec3 skyScatter(vec3 d) { SKY.height }.0, (uSunIndex + 0.5) / ${SKY.depth}.0)).rgb / ${S.store_scale.toFixed(1)}; }`; + +/** + * The aerial perspective lookup, in GLSL: `aerial(lightIndex, viewElDeg, azDeg, km)` is `aerialAt` on the GPU. Needs uAP. + * Each slice is one hardware-filtered fetch, kept inside its block of elevation rows so the filter never reads a + * neighbouring slice; two fetches and a lerp per call. + */ +export const AERIAL_GLSL = ` +uniform highp sampler3D uAP; +vec4 apSlice(float k, float e, float x, float z) { + return texture(uAP, vec3((x + 0.5) / ${AP.width}.0, (k * ${AP.elevations}.0 + e + 0.5) / ${ + AP.height +}.0, (z + 0.5) / ${AP.depth}.0)) / ${S.store_scale.toFixed(1)}; +} +vec4 aerial(float lightIndex, float viewElDeg, float azDeg, float km) { + float e = ${(AP.elevations - 1).toFixed( + 1 + )} * (1.0 - sqrt(clamp(-viewElDeg / ${(-AP.elevation_min_deg).toFixed(1)}, 0.0, 1.0))); + float x = clamp(abs(azDeg) / 180.0, 0.0, 1.0) * ${(AP.width - 1).toFixed(1)}; + float f = ${AP.slices}.0 * sqrt(max(km, 0.0) / ${AP.max_km.toFixed(1)}) - 1.0; + float fs = clamp(f, 0.0, ${(AP.slices - 1).toFixed(1)}); + float k = min(floor(fs), ${(AP.slices - 2).toFixed(1)}); + return mix(apSlice(k, e, x, lightIndex), apSlice(k + 1.0, e, x, lightIndex), fs - k) * clamp(f + 1.0, 0.0, 1.0); +}`; diff --git a/src/app/features/scene/terrain/terrainShaders.ts b/src/app/features/scene/terrain/terrainShaders.ts index 93670449..12428d70 100644 --- a/src/app/features/scene/terrain/terrainShaders.ts +++ b/src/app/features/scene/terrain/terrainShaders.ts @@ -8,8 +8,8 @@ import cultmath from '../shaders/CultMath.glsl?raw'; import phacelle from '../shaders/CultMath.Phacelle.glsl?raw'; import { trimGlsl } from '../cultmathTrim'; import { ALBEDO } from './terrainAlbedo'; -import { SCATTER_GLSL } from './terrainScatter'; -import { GLOW_ZENITH, HAZE_POWER, SKY_EXPOSURE } from './terrainSky'; +import { AERIAL_GLSL, AP_DISTANCE_SCALE, SCATTER_GLSL } from './terrainScatter'; +import { GLOW_ZENITH, MOON_HAZE, SKY_EXPOSURE, STAR_FLOOR } from './terrainSky'; import { TERRAIN } from './terrainWorld'; const HEADER = @@ -146,11 +146,11 @@ vec3 skyColour(vec3 d) { )}, sqrt(clamp(d.y, 0.0, 1.0))); }`; -export const GRID_FRAG = `${HEADER}${ALBEDO_DEFINES}${SKY_COLOUR_GLSL} +export const GRID_FRAG = `${HEADER}${ALBEDO_DEFINES}${SKY_COLOUR_GLSL}${AERIAL_GLSL} in vec2 v_uv; in vec3 v_world; uniform vec3 uCamera, uLightDir, uLight, uAmbient, uSoil, uScree, uRock, uSnow; -uniform float uSpan, uHazeK, uSnowLine; +uniform float uSpan, uSnowLine, uNight, uMoonIndex; out vec4 o; ${FETCH} // Triplanar patterns: each projection plane's value, blended by the weights of the face's normal (terrainAlbedo.triWeights). @@ -187,9 +187,23 @@ void main() { float cavity = 1.0 - 0.55 * clamp(curv / 12.0, 0.0, 1.0) + 0.3 * clamp(-curv / 12.0, 0.0, 1.0); vec3 lit = albedo * cavity * (uLight * max(dot(n, uLightDir), 0.0) + uAmbient * (0.5 + 0.5 * n.y)); vec3 toSurface = v_world - uCamera; - float T = exp(-pow(uHazeK * length(toSurface), ${lit(HAZE_POWER)})); - vec3 haze = skyColour(normalize(vec3(toSurface.x, 0.0, toSurface.z))); - o = vec4(pow(mix(haze, lit, T), vec3(1.0 / 2.2)), 1.0); + float dist = length(toSurface); + // Aerial perspective (terrainSky.aerialPerspective): the baked volume by this ray's elevation, azimuth from the light and path. + vec3 ray = toSurface / dist; + float el = min(degrees(asin(clamp(ray.y, -1.0, 1.0))), 0.0); + vec2 hz = ray.xz, sz = uSun.xz; + float az = (length(hz) < 1e-4 || length(sz) < 1e-4) ? 0.0 : degrees(acos(clamp(dot(normalize(hz), normalize(sz)), -1.0, 1.0))); + float km = dist * ${lit(AP_DISTANCE_SCALE / 1000)}; + vec4 ap = aerial(uSunIndex, el, az, km); + vec3 L = ap.rgb; + // The moon lights the haze from where the sun is mirrored above the horizon, sharing its azimuth; a starlight floor stays. + if (uNight > 0.0) L += uNight * vec3(${MOON_HAZE.map(lit).join( + ', ' + )}) * aerial(uMoonIndex, el, az, km).rgb; + vec3 haze = 1.0 - exp(-${lit(SKY_EXPOSURE)} * L) + uNight * ap.a * vec3(${STAR_FLOOR.map( + lit +).join(', ')}); + o = vec4(pow(lit * (1.0 - ap.a) + haze, vec3(1.0 / 2.2)), 1.0); }`; // The sky behind: the baked scattering tables by the view ray's elevation and azimuth from the sun, a sun disc, and a diff --git a/src/app/features/scene/terrain/terrainSky.test.ts b/src/app/features/scene/terrain/terrainSky.test.ts index 9e5bc91e..5a8e0502 100644 --- a/src/app/features/scene/terrain/terrainSky.test.ts +++ b/src/app/features/scene/terrain/terrainSky.test.ts @@ -1,4 +1,5 @@ /* eslint-disable no-restricted-syntax -- tests loop over skies, normals and distances */ +import { AP_DISTANCE_SCALE } from './terrainScatter'; import { describe, expect, it } from 'vitest'; import { baked } from '../../../../sky/baked'; import { @@ -13,7 +14,8 @@ import { skyColour, skyNameFor, sunDirection, - transmittance, + aerialPerspective, + metresToKm, groundLight, HAZE_K, HAZE_POWER, @@ -185,47 +187,72 @@ describe('the scattered sky', () => { }); }); -describe('haze', () => { - it('transmittance falls monotonically from 1 toward 0', () => { - let prev = transmittance(0); - expect(prev).toBe(1); - for (const d of [10, 100, 400, 1000, 3000, 8000]) { - const t = transmittance(d); - expect(t).toBeLessThan(prev); - expect(t).toBeGreaterThan(0); - prev = t; +describe('aerial perspective', () => { + const ray = (el: number, az: number): Vec3 => look(el, az); + const along = (d: typeof SKY.midnight, el: number, az: number, m: number) => + aerialPerspective(d, ray(el, az), m); + + it('opacity grows with distance from nothing at the eye toward the far value, never past 1', () => { + let prev = along(SKY.noonday, -2, 200, 0).alpha; + expect(prev).toBe(0); + for (const m of [10, 100, 400, 1000, 2000, 2500]) { + const a = along(SKY.noonday, -2, 200, m).alpha; + expect(a).toBeGreaterThan(prev); + expect(a).toBeLessThanOrEqual(1); + prev = a; } }); - it('far surfaces take the scattered sky at the horizon in their azimuth, at both skies', () => { - for (const d of [...Object.values(SKY), skyAtSun(2, 270)]) { - const far = shade(d, [0.9, 0.2, 0.1], UP, 100000, look(-3, 200)); - const sky = skyColour(d, look(0, 200)); - for (let i = 0; i < 3; i += 1) expect(far[i]).toBeCloseTo(sky[i], 6); - } + it('leaves the near ground its own colour and takes the far ridges', () => { + expect(along(SKY.noonday, -2, 200, 100).alpha).toBeLessThan(0.15); + expect(along(SKY.noonday, -2, 200, 2000).alpha).toBeGreaterThan(0.3); }); - it('haze toward the dusk sun is warmer and brighter than haze away from it', () => { - const dusk = skyAtSun(-1, 270); - const toward = shade(dusk, [0, 0, 0], UP, 100000, look(-3, 270)); - const away = shade(dusk, [0, 0, 0], UP, 100000, look(-3, 90)); - const blueShare = (c: Vec3) => c[2] / (c[0] + c[1] + c[2]); + it('the in-scatter is a function of the baked volume: its colour is bluer than the sun far by day', () => { + const far = along(SKY.noonday, -2, 90, 2400).colour; + expect(far[2]).toBeGreaterThan(far[0]); + }); + + it('haze toward a setting sun is brighter than away from it, and red, not blue (the sun is reddened on its way)', () => { + const dusk = skyAtSun(2, 270); + const toward = along(dusk, -1, 270, 2000).colour; + const away = along(dusk, -1, 90, 2000).colour; expect(luma(toward)).toBeGreaterThan(3 * luma(away)); - expect(blueShare(toward)).toBeLessThan(blueShare(away)); + expect(toward[0]).toBeGreaterThan(toward[2]); }); - it('haze colour differs between the two skies: pale day horizon, dim night one', () => { - const far = (d: typeof SKY.midnight) => luma(shade(d, [0, 0, 0], UP, 1e6, look(-3, 180))); - expect(far(SKY.noonday)).toBeGreaterThan(5 * far(SKY.midnight)); + it('night haze is a deep blue floor, not grey: dark, blue over red by a wide margin, brighter than black', () => { + const c = along(SKY.midnight, -2, 0, 2400).colour; + expect(luma(c)).toBeGreaterThan(0.001); + expect(luma(c)).toBeLessThan(0.08); + expect(c[2]).toBeGreaterThan(2.5 * c[0]); + expect(luma(along(SKY.noonday, -2, 0, 2400).colour)).toBeGreaterThan(5 * luma(c)); }); - it('with distance a dark surface rises toward the haze and a bright one falls toward it', () => { + it('moonlight lights the night haze toward the moon more than away from it', () => { + const moon = groundLight(SKY.midnight).dir; + const toward = aerialPerspective(SKY.midnight, [moon[0], -0.02, moon[2]], 2400).colour; + const away = aerialPerspective(SKY.midnight, [-moon[0], -0.02, -moon[2]], 2400).colour; + expect(luma(toward)).toBeGreaterThan(luma(away)); + }); + + it('with distance a dark surface rises toward the haze and its own colour is lost', () => { const d = SKY.noonday; - const view = look(-4, 200); - const h = luma(skyColour(d, look(0, 200))); + const view = ray(-4, 200); const dark = (x: number) => luma(shade(d, [0.02, 0.02, 0.02], UP, x, view)); - expect(dark(2000)).toBeGreaterThan(dark(200)); - expect(dark(2000)).toBeLessThan(h); + expect(dark(2000)).toBeGreaterThan(dark(100)); + const own = (x: number) => 1 - aerialPerspective(d, view, x).alpha; + expect(own(2000)).toBeLessThan(0.7 * own(100)); + }); + + it('is continuous in the sun elevation through dusk: no step between neighbours', () => { + const at = (e: number) => luma(along(skyAtSun(e, 270), -1, 270, 2000).colour); + for (let e = -12; e < 12; e += 0.5) expect(Math.abs(at(e + 0.5) - at(e))).toBeLessThan(0.45); + }); + + it('the metres-to-km law is the scene metres times the baked distance scale', () => { + expect(metresToKm(1000)).toBeCloseTo(AP_DISTANCE_SCALE, 9); + expect(metresToKm(-5)).toBe(0); }); }); @@ -246,13 +273,6 @@ describe('ground light and haze', () => { for (let e = -10; e < 10; e += 0.5) expect(Math.abs(at(e + 0.5) - at(e))).toBeLessThan(0.35); }); - it('haze leaves the near ground nearly clear and takes the far ridges: a power law, not plain exp(-k d)', () => { - expect(transmittance(300)).toBeGreaterThan(0.85); - expect(transmittance(300)).toBeGreaterThan(Math.exp(-HAZE_K * 300)); - expect(transmittance(2500)).toBeLessThan(0.15); - expect(HAZE_POWER).toBeGreaterThan(1); - }); - it('the camera starts with the noon sun off its axis, so the light rakes the relief', () => { const yaw = 2 * Math.PI * START_TURNS; const forward = [Math.sin(yaw), 0, -Math.cos(yaw)]; diff --git a/src/app/features/scene/terrain/terrainSky.ts b/src/app/features/scene/terrain/terrainSky.ts index 697896a3..2efef429 100644 --- a/src/app/features/scene/terrain/terrainSky.ts +++ b/src/app/features/scene/terrain/terrainSky.ts @@ -4,7 +4,13 @@ import { baked } from '../../../../sky/baked'; import { isNight } from '../ridge/stars'; import { skyColours } from '../ridge/skyColour'; import type { SkyColours } from '../ridge/skyColour'; -import { HORIZON_FLOOR, scatterRadiance, transmittanceAt } from './terrainScatter'; +import { + AP_DISTANCE_SCALE, + aerialAt, + HORIZON_FLOOR, + scatterRadiance, + transmittanceAt, +} from './terrainScatter'; export type SkyName = 'midnight' | 'noonday'; export type Vec3 = readonly [number, number, number]; @@ -129,13 +135,48 @@ export function skyColour(d: SkyData, dir: Vec3): Vec3 { } /** - * Haze: transmittance T = exp(-(k d)^HAZE_POWER) at distance d metres. The power above 1 keeps the near and middle - * ground at its own colour and lets only the far ridges melt toward the sky; a plain exp(-k d) greyed everything. + * Aerial perspective: the baked volume of src/sky/scatter.js (after Hillaire 2020, see there for what follows the paper + * and what does not), looked up by the view ray relative to the light and the metres to the surface. The surface keeps + * (1 - alpha) of its own colour (alpha = 1 - mean transmittance, a grey transmittance as the paper) and gains the in-scatter L, + * lifted by the sky's exposure and tone-mapped the way the sky is, so far ground meets the sky's colour. SHORTCUT: the path is + * the scene's metres times AP_DISTANCE_SCALE (a real atmosphere is nearly clear over 3 km). */ -export const HAZE_K = 0.0007; -export const HAZE_POWER = 1.6; -export const transmittance = (distance: number): number => - Math.exp(-((HAZE_K * Math.max(0, distance)) ** HAZE_POWER)); +export const metresToKm = (metres: number): number => + (Math.max(0, metres) / 1000) * AP_DISTANCE_SCALE; + +/** Moonlight's irradiance on the haze, relative to the sun's 1, at full night. EYEBALLED, far above a real moon (about 3e-6) as the sky exposure is. */ +export const MOON_HAZE: Vec3 = [0.0004, 0.0007, 0.0018]; +/** Starlight and airglow scattered by the air, added to the in-scatter in proportion to alpha, at full night: a deep blue floor, not grey. EYEBALLED. */ +export const STAR_FLOOR: Vec3 = [0.0006, 0.0012, 0.0045]; + +/** Haze in front of a surface seen along `dir` (unit, eye to surface) `distance` metres away: the colour it adds and the surface's loss. */ +export function aerialPerspective( + d: SkyData, + dir: Vec3, + distance: number +): { colour: Vec3; alpha: number } { + const el = Math.min(0, Math.asin(Math.min(1, Math.max(-1, dir[1]))) / RAD); + const sun = sunDirection(d); + const h = Math.hypot(dir[0], dir[2]); + const s = Math.hypot(sun[0], sun[2]); + const az = + h < 1e-4 || s < 1e-4 + ? 0 + : Math.acos(Math.min(1, Math.max(-1, (dir[0] * sun[0] + dir[2] * sun[2]) / (h * s)))) / RAD; + const km = metresToKm(distance); + const night = nightness(d.elevation); + const day = aerialAt(d.elevation, el, az, km); + const L = [...day.L]; + if (night > 0) { + // The moon stands where the sun is mirrored above the horizon (groundLight), so it shares the sun's azimuth. + const moon = aerialAt(Math.abs(d.elevation), el, az, km); + for (let i = 0; i < 3; i += 1) L[i] += MOON_HAZE[i] * night * moon.L[i]; + } + const colour = [0, 1, 2].map( + (i) => 1 - Math.exp(-SKY_EXPOSURE * L[i]) + STAR_FLOOR[i] * night * day.alpha + ) as unknown as Vec3; + return { colour, alpha: day.alpha }; +} /** Added to the ambient on a dark sky, scaled by `nightness`: the baked night floor is too dark to show any form on a phone. */ export const NIGHT_AMBIENT_LIFT = 3; @@ -162,19 +203,17 @@ export const START_TURNS = ((SKY.noonday.azimuth - 100) / 360 + 1) % 1; /** * Linear colour of a surface seen along the unit direction `view` (from the eye to the surface): lambert from * the ground light plus ambient (full on an upward face, half on a vertical one, none on a downward one: `0.5 + 0.5 n.y`), - * then hazed by distance toward the sky at the horizon in that direction. `normal` is a unit vector. Never negative. + * then aerial perspective (`aerialPerspective`). `normal` is a unit vector. Never negative. */ export function shade(d: SkyData, albedo: Vec3, normal: Vec3, distance: number, view: Vec3): Vec3 { const light = groundLight(d); const sun = light.dir; const lambert = Math.max(0, normal[0] * sun[0] + normal[1] * sun[1] + normal[2] * sun[2]); const hemi = 0.5 + 0.5 * normal[1]; - const flat = Math.hypot(view[0], view[2]) || 1; - const haze = skyColour(d, [view[0] / flat, 0, view[2] / flat]); - const t = transmittance(distance); + const haze = aerialPerspective(d, view, distance); return [0, 1, 2].map((i) => { const lit = albedo[i] * (light.colour[i] * lambert + light.ambient[i] * Math.max(0, hemi)); - return lit * t + haze[i] * (1 - t); + return lit * (1 - haze.alpha) + haze.colour[i]; }) as unknown as Vec3; } diff --git a/src/sky/README.md b/src/sky/README.md index 7afc80a7..1f816a15 100644 --- a/src/sky/README.md +++ b/src/sky/README.md @@ -81,3 +81,25 @@ Size: 16,128 bytes for `sky_view` (21.5 KB as base64) and 192 bytes for `transmi twice the "few KB" of an 8-bit table, the price of not quantising the glow. `scatter.test.ts` pins the layout, the encoding, the checksums and a set of physical anchors (blue noon sky, red sunset toward the sun, black below the planet's shadow limit). + +### `aerial_perspective`: the camera volume for ground seen through the air + +After Hillaire, "A Scalable and Production Ready Sky and Atmosphere Rendering Technique", EGSR 2020, section 5.4, and its +reference code (sebh/UnrealEngineSkyAtmosphere, `RenderCameraVolumePS` in `RenderSkyRayMarching.hlsl`). RGBA half floats +(same encoding, `store_scale` 64), `width` 7, `height` 96, `depth` 12, 4 channels; one 3D texture, `x` fastest: + +- `x`: azimuth of the view ray from the light, 0, 30, ... 180 degrees (as `sky_view`). +- `y`: `slice * 8 + elevation`: 12 slices, each a block of 8 view elevations, `-50 * (1 - i / 7)^2` degrees (finest at 0, + rays at or above the horizon are clamped to 0). Keep the filter inside a block: sample at `(slice * 8 + e + 0.5) / 96`, `e` in 0..7. +- `z`: light elevation, `sun_elevations_deg`, as `sky_view`. The same volume serves the moon: look up at the moon's elevation. +- Slice `k` is the far side of `max_km * ((k + 1) / 12)^2` km (the paper's squared distribution; `max_km` 20). Between slices interpolate + linearly; below slice 0 fade to zero at the observer, as the reference does for `Slice < 0.5`. +- RGB: in-scatter luminance per steradian for a light of unit irradiance. A: opacity `1 - mean(rgb transmittance)`, the paper's grey + transmittance. Apply as `surface * (1 - A) + L * exposure`. + +Follows the paper: the squared slice distribution, the in-scatter integral with each step's extinction integrated analytically +(`S (1 - exp(-e dt)) / e`), single scattering with Rayleigh and Mie (g 0.8) phase, and the grey opacity in alpha. +Shortcuts: sun-relative azimuth and elevation instead of screen position; 12 x 8 x 7 texels, not 32 x 32 x 32; path lengths are the +scene's metres times `distance_scale` (8), because a real atmosphere is nearly clear across a 3 km patch; the ray ignores the ground +(density held at its ground value, sun visibility tested from the ground sphere); no multi-scattering LUT (the paper's dusk fix); +observer at 100 m. Units: distances in km, radiance per unit sun irradiance, as `sky_view`. 86 KB as base64. diff --git a/src/sky/baked.ts b/src/sky/baked.ts index b732fbc1..0393fdbf 100644 --- a/src/sky/baked.ts +++ b/src/sky/baked.ts @@ -260,5 +260,17 @@ export const baked = { channels: 3, data: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHwAHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAyhRWDusADhBFCV8AUAFcAAMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABRKEGYE5g40C8wBGQGDAA0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAqQ++DQsGtQrbCHkCiABRAA4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAVgq5CZkEmAXWBNoBMgAbAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADwX3BIkCOAIMAvUACwAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/AH2ARkB1wC/AFcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwACvAFcASgA1ABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQQAvAA4AFAAIAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEQAHAAAAAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA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LithKgMmxya1JQYhpSDLHh0ZEBhtFJAMugRDARIADAABAAAA2CqjKp8nYykdKcQlUyXYJCAhQyDUHksa9xmgF0MRkhDfC+ACCAkeA0QAKik1KcAmGigRKC8lPSQOJN8glx+KHvgaRBsxGWsUuhVaEnILVBL6DUoF8CcbKMwlWyZ/JoMk2yLJIncgxR4dHj8b5RstGh4WkhjzFZkQoRbcEzsNNCaKJukkAiU8Jb4joiG2IQUgDx6lHUAbFBzDGoQX2xlCGO0T5RisFqUR6yRDJSIkAiRDJJsitCDZIC0fcB0wHRcbFBwMG0YYsRpEGZYVExqLGIQU5iNHJPQigiIEI6Eh9R8nIGQe5hzEHNYa5xsdG54YHRv1GfkWyBp3GRkWayIGI9shWyHUIc0g0R4sH7EdbxxiHIkaixsHG9EYVBtfGgYYJhsVGmgXRiHQIfEgdSDiIBwg5B1AHhQdChwLHDkaJxvaGukYUBuRGmcYURtxGjIYYCDYIC4ggR8gIAwfKB19HYwcZxt9G+oZxhqeGuwYHBuZGqQYQxuYGokYUx8QIBgfXx4DHxEejhzcHBcc0xr4Gp8ZahpcGuAY0BqBGsMYBhuVGrsYLh7bHggedR0AHj0dDxxWHGMbVhqFGloZExoYGssYehpUGsoYtBp0Gs8YQR3QHSYdtxwsHYscTBvNG7Ya7BkhGhwZwxnVGa8YIxoZGr8YWBo/Gs0YgBz2HGkcGBx8HOwbnRoSGyEakxnNGeQYeRmWGZEYzRnXGagY/Bn8GbkYwxtEHJkbMxvZG/QaDRp4GqQZSBmFGbQYNxlcGXMYfBmTGYkYohm0GZoYxxpmG5gaZBrvGicalxn5GToZChlJGYoY/RgpGVcYMBlSGWcYThlrGXUY/xmAGsoZvhkzGoAZOBmSGeQY2RgZGWgYzBj9GD8Y7hgXGUYYAhkoGU8YYxnOGSkZPhmhGf0Y7hhBGZ4YshjzGE0YphjcGCwYtxjmGCsYwxjvGC8Y8RhLGbAY3xg1GZoYuBgEGWgYlxjYGDoYixjFGCAYkBjFGBoYlBjHGBoYphjzGFwYoRjrGFQYkxjaGEEYhhjIGC0Yfhi8GB8YfBi3GBcYfBi2GBUYgBjCGCkYgBjCGCkYgBjCGCkYgBjCGCkYgBjCGCkYgBjCGCkYgBjCGCkYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA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zjJ/Nyg7YELVQnVDVjrRPI4/IDYLOog9WDSaOFo8ETOsN1U7fTIgN9g6YzIJN8Q6YUD1QNtB3jl+PCA/7TXQOVY9PzR8OEM86DJ9NzM7RDLfNqM6HzK8NoQ6YD22PndALDkSPJ4+uTWYOSc9LjRmODI82DJnNyU7KzLANow6/jGVNmQ6yzrKPC0/Zjg+OxU+gjVgOfg8JTRXOCY83jJmNyk7MTLANpE6ADKQNmU6rjhsOxo+YTdoOpM9RDUnOco8ITRNOB08+zJ6Nz47VTLeNq86IzKuNoM6BzclOmM9OjazOSA9/zTtOJs8ITRHOBg8LjOiN2M7mDIYN+c6aTLsNr86pzVQOds8VzUeObo8tjSzOGw8JDREOBU8dzPcN5Y7/TJxNzg71jJNNxg7xDS4OG48qzSmOGE8bTR5OD88JjRDOBM81zMUONg7izPpN6I7cjPTN487KDRDOBM8KDRDOBM8KDRDOBM8KDRDOBM8KDRDOBM8KDRDOBM8KDRDOBM8SSnQLG4wSSnQLG4wSSnQLG4wSSnQLG4wSSnQLG4wSSnQLG4wSSnQLG4waynvLIowaynvLIowaynvLIowaynvLIowaynvLIowaynvLIowaynvLIow3SlULeUw3SlULeUw3SlULeUw3SlULeUw3SlULeUw3SlULeUw3SlULeUwzCooLqIxzCooLqIxzCooLqIxzCooLqIxzCooLqIxzCooLqIxzCooLqIxXCzbLyQzXCzbLyQzXCzbLyQzXCzbLyQzXCzbLyQzXCzbLyQzXCzbLyQz0y4XMnY10y4XMnY10y4XMnY10y4XMnY10y4XMnY10y4XMnY10y4XMnY1ujXnOAo8ujXnOAo8ujXnOAo8ujXnOAo8ujXnOAo8ujXnOAo8ujXnOAo8cD0PPsk7cD0PPsk7cD0PPsk7cD0PPsk7cD0PPsk7cD0PPsk7cD0PPsk7oz2LP8w9oz2LP8w9oz2LP8w9oz2LP8w9oz2LP8w9oz2LP8w9oz2LP8w99TyiPzg/9TyiPzg/9TyiPzg/9TyiPzg/9TyiPzg/9TyiPzg/9TyiPzg/LDwUP/Q/LDwUP/Q/LDwUP/Q/LDwUP/Q/LDwUP/Q/LDwUP/Q/LDwUP/Q/AjtaPhJAAjtaPhJAAjtaPhJAAjtaPhJAAjtaPhJAAjtaPhJAAjtaPhJA9DmnPf0/9DmnPf0/9DmnPf0/9DmnPf0/9DmnPf0/9DmnPf0/9DmnPf0/IzkLPa0/IzkLPa0/IzkLPa0/IzkLPa0/IzkLPa0/IzkLPa0/IzkLPa0/gziKPE4/gziKPE4/gziKPE4/gziKPE4/gziKPE4/gziKPE4/gziKPE4/CTghPPA+CTghPPA+CTghPPA+CTghPPA+CTghPPA+CTghPPA+CTghPPA+VzeZO5s+VzeZO5s+VzeZO5s+VzeZO5s+VzeZO5s+VzeZO5s+VzeZO5s+yjYVO1I+yjYVO1I+yjYVO1I+yjYVO1I+yjYVO1I+yjYVO1I+yjYVO1I+YTavOhc+YTavOhc+YTavOhc+YTavOhc+YTavOhc+YTavOhc+YTavOhc+FjZjOuk9FjZjOuk9FjZjOuk9FjZjOuk9FjZjOuk9FjZjOuk9FjZjOuk95jUsOsg95jUsOsg95jUsOsg95jUsOsg95jUsOsg95jUsOsg95jUsOsg9zzUKOrI9zzUKOrI9zzUKOrI9zzUKOrI9zzUKOrI9zzUKOrI9zzUKOrI90DX5Oac90DX5Oac90DX5Oac90DX5Oac90DX5Oac90DX5Oac90DX5Oac97jX8Oag97jX8Oag97jX8Oag97jX8Oag97jX8Oag97jX8Oag97jX8Oag9MzYVOrQ9MzYVOrQ9MzYVOrQ9MzYVOrQ9MzYVOrQ9MzYVOrQ9MzYVOrQ9sjZMOs89sjZMOs89sjZMOs89sjZMOs89sjZMOs89sjZMOs89sjZMOs89lje2OgE+lje2OgE+lje2OgE+lje2OgE+lje2OgE+lje2OgE+lje2OgE+oTh+O1s+oTh+O1s+oTh+O1s+oTh+O1s+oTh+O1s+oTh+O1s+oTh+O1s+SDqFPAo/SDqFPAo/SDqFPAo/SDqFPAo/SDqFPAo/SDqFPAo/SDqFPAo/2TwfPjZA2TwfPjZA2TwfPjZA2TwfPjZA2TwfPjZA2TwfPjZA2TwfPjZA6z9/QHJB6z9/QHJB6z9/QHJB6z9/QHJB6z9/QHJB6z9/QHJB6z9/QHJBGUGQQVtCGUGQQVtCGUGQQVtCGUGQQVtCGUGQQVtCGUGQQVtCGUGQQVtC', }, + aerial_perspective: { + slices: 12, + elevations: 8, + elevation_min_deg: -50, + max_km: 20, + distance_scale: 8, + width: 7, + height: 96, + depth: 12, + channels: 4, + data: 'AAAAAAAAgjQAAAAAAACCNAAAAAAAAII0AAAAAAAAgjQAAAAAAACCNAAAAAAAAII0AAAAAAAAgjQAAAAAAAB8NAAAAAAAAHw0AAAAAAAAfDQAAAAAAAB8NAAAAAAAAHw0AAAAAAAAfDQAAAAAAAB8NAAAAAAAAHU0AAAAAAAAdTQAAAAAAAB1NAAAAAAAAHU0AAAAAAAAdTQAAAAAAAB1NAAAAAAAAHU0AAAAAAAAbzQAAAAAAABvNAAAAAAAAG80AAAAAAAAbzQAAAAAAABvNAAAAAAAAG80AAAAAAAAbzQAAAAAAABrNAAAAAAAAGs0AAAAAAAAazQAAAAAAABrNAAAAAAAAGs0AAAAAAAAazQAAAAAAABrNAAAAAAAAGg0AAAAAAAAaDQAAAAAAABoNAAAAAAAAGg0AAAAAAAAaDQAAAAAAABoNAAAAAAAAGg0AAAAAAAAZjQAAAAAAABmNAAAAAAAAGY0AAAAAAAAZjQAAAAAAABmNAAAAAAAAGY0AAAAAAAAZjQAAAAAAABlNAAAAAAAAGU0AAAAAAAAZTQAAAAAAABlNAAAAAAAAGU0AAAAAAAAZTQAAAAAAABlNAAAAAAAAI08AAAAAAAAjTwAAAAAAACNPAAAAAAAAI08AAAAAAAAjTwAAAAAAACNPAAAAAAAAI08AAAAAAAAizwAAAAAAACLPAAAAAAAAIs8AAAAAAAAizwAAAAAAACLPAAAAAAAAIs8AAAAAAAAizwAAAAAAACIPAAAAAAAAIg8AAAAAAAAiDwAAAAAAACIPAAAAAAAAIg8AAAAAAAAiDwAAAAAAACIPAAAAAAAAII8AAAAAAAAgjwAAAAAAACCPAAAAAAAAII8AAAAAAAAgjwAAAAAAACCPAAAAAAAAII8AAAAAAAAdTwAAAAAAAB1PAAAAAAAAHU8AAAAAAAAdTwAAAAAAAB1PAAAAAAAAHU8AAAAAAAAdTwAAAAAAABnPAAAAAAAAGc8AAAAAAAAZzwAAAAAAABnPAAAAAAAAGc8AAAAAAAAZzwAAAAAAABnPAAAAAAAAGA8AAAAAAAAYDwAAAAAAABgPAAAAAAAAGA8AAAAAAAAYDwAAAAAAABgPAAAAAAAAGA8AAAAAAAAXTwAAAAAAABdPAAAAAAAAF08AAAAAAAAXTwAAAAAAABdPAAAAAAAAF08AAAAAAAAXTwAAAAAAAATQQAAAAAAABNBAAAAAAAAE0EAAAAAAAATQQAAAAAAABNBAAAAAAAAE0EAAAAAAAATQQAAAAAAABJBAAAAAAAAEkEAAAAAAAASQQAAAAAAABJBAAAAAAAAEkEAAAAAAAASQQAAAAAAABJBAAAAAAAAEEEAAAAAAAAQQQAAAAAAABBBAAAAAAAAEEEAAAAAAAAQQQAAAAAAABBBAAAAAAAAEEEAAAAAAAANQQAAAAAAAA1BAAAAAAAADUEAAAAAAAANQQAAAAAAAA1BAAAAAAAADUEAAAAAAAANQQAAAAAAAAdBAAAAAAAAB0EAAAAAAAAHQQAAAAAAAAdBAAAAAAAAB0EAAAAAAAAHQQAAAAAAAAdBAAAAAAAA9EAAAAAAAAD0QAAAAAAAAPRAAAAAAAAA9EAAAAAAAAD0QAAAAAAAAPRAAAAAAAAA9EAAAAAAAADgQAAAAAAAAOBAAAAAAAAA4EAAAAAAAADgQAAAAAAAAOBAAAAAAAAA4EAAAAAAAADgQAAAAAAAANpAAAAAAAAA2kAAAAAAAADaQAAAAAAAANpAAAAAAAAA2kAAAAAAAADaQAAAAAAAANpAAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAG9EAAAAAAAAb0QAAAAAAABvRAAAAAAAAG9EAAAAAAAAb0QAAAAAAABvRAAAAAAAAG9EAAAAAAAAbUQAAAAAAABtRAAAAAAAAG1EAAAAAAAAbUQAAAAAAABtRAAAAAAAAG1EAAAAAAAAbUQAAAAAAABqRAAAAAAAAGpEAAAAAAAAakQAAAAAAABqRAAAAAAAAGpEAAAAAAAAakQAAAAAAABqRAAAAAAAAGFEAAAAAAAAYUQAAAAAAABhRAAAAAAAAGFEAAAAAAAAYUQAAAAAAABhRAAAAAAAAGFEAAAAAAAASEQAAAAAAABIRAAAAAAAAEhEAAAAAAAASEQAAAAAAABIRAAAAAAAAEhEAAAAAAAASEQAAAAAAAA+RAAAAAAAAD5EAAAAAAAAPkQAAAAAAAA+RAAAAAAAAD5EAAAAAAAAPkQAAAAAAAA+RAAAAAAAAMlG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AAAAAAAA00wAAAAAAADTTAAAAAAAANNMAAAAAAAA00wAAAAAAADTTAAAAAAAANNMAAAAAAAA00wAAAAAAADTTAAAAAAAANNMAAAAAAAA00wAAAAAAADTTAAAAAAAANNMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0EwAAAAAAADQTAAAAAAAANBMAAAAAAAA0EwAAAAAAADQTAAAAAAAANBMAAAAAAAA0EwAAAAAAADGTAAAAAAAAMZMAAAAAAAAxkwAAAAAAADGTAAAAAAAAMZMAAAAAAAAxkwAAAAAAADGTAAAAAAAAKBMAAAAAAAAoEwAAAAAAACgTAAAAAAAAKBMAAAAAAAAoEwAAAAAAACgTAAAAAAAAKBMAAAAAAAAtU0AAAAAAAC1TQAAAAAAALVNAAAAAAAAtU0AAAAAAAC1TQAAAAAAALVNAAAAAAAAtU0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALJNAAAAAAAAsk0AAAAAAACyTQAAAAAAALJNAAAAAAAAsk0AAAAAAACyTQAAAAAAALJNAAAAAAAAqU0AAAAAAACpTQAAAAAAAKlNAAAAAAAAqU0AAAAAAACpTQAAAAAAAKlNAAAAAAAAqU0AAAAAAAB6TQAAAAAAAHpNAAAAAAAAek0AAAAAAAB6TQAAAAAAAHpNAAAAAAAAek0AAAAAAAB6TQAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACVTgAAAAAAAJVOAAAAAAAAlU4AAAAAAACVTgAAAAAAAJVOAAAAAAAAlU4AAAAAAACVTgAAAAAAAI1OAAAAAAAAjU4AAAAAAACNTgAAAAAAAI1OAAAAAAAAjU4AAAAAAACNTgAAAAAAAI1OAAAAAAAAVk4AAAAAAABWTgAAAAAAAFZOAAAAAAAAVk4AAAAAAABWTgAAAAAAAFZOAAAAAAAAVk4AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB3TwAAAAAAAHdPAAAAAAAAd08AAAAAAAB3TwAAAAAAAHdPAAAAAAAAd08AAAAAAAB3TwAAAAAAAHdPAAAAAAAAd08AAAAAAAB3TwAAAAAAAHdPAAAAAAAAd08AAAAAAAB3TwAAAAAAAHdPAAAAAAAAdk8AAAAAAAB2TwAAAAAAAHZPAAAAAAAAdk8AAAAAAAB2TwAAAAAAAHZPAAAAAAAAdk8AAAAAAABuTwAAAAAAAG5PAAAAAAAAbk8AAAAAAABuTwAAAAAAAG5PAAAAAAAAbk8AAAAAAABuTwAAAAAAAC9PAAAAAAAAL08AAAAAAAAvTwAAAAAAAC9PAAAAAAAAL08AAAAAAAAvTwAAAAAAAC9PAAAAAAAAgjQAAAAAAACCNAAAAAAAAII0AAAAAAAAgjQAAAAAAACCNAAAAAAAAII0AAAAAAAAgjQAAAAAAAB8NAAAAAAAAHw0AAAAAAAAfDQAAAAAAAB8NAAAAAAAAHw0AAAAAAAAfDQAAAAAAAB8NAAAAAAAAHU0AAAAAAAAdTQAAAAAAAB1NAAAAAAAAHU0AAAAAAAAdTQAAAAAAAB1NAAAAAAAAHU0AAAAAAAAbzQAAAAAAABvNAAAAAAAAG80AAAAAAAAbzQAAAAAAABvNAAAAAAAAG80AAAAAAAAbzQAAAAAAABrNAAAAAAAAGs0AAAAAAAAazQAAAAAAABrNAAAAAAAAGs0AAAAAAAAazQAAAAAAABrNAAAAAAAAGg0AAAAAAAAaDQAAAAAAABoNAAAAAAAAGg0AAAAAAAAaDQAAAAAAABoNAAAAAAAAGg0AAAAAAAAZjQAAAAAAABmNAAAAAAAAGY0AAAAAAAAZjQAAAAAAABmNAAAAAAAAGY0AAAAAAAAZjQAAAAAAABlNAAAAAAAAGU0AAAAAAAAZTQAAAAAAABlNAAAAAAAAGU0AAAAAAAAZTQAAAAAAABlNAAAAAAAAI08AAAAAAAAjTwAAAAAAACNPAAAAAAAAI08AAAAAAAAjTwAAAAAAACNPAAAAAAAAI08AAAAAAAAizwAAAAAAACLPAAAAAAAAIs8AAAAAAAAizwAAAAAAACLPAAAAAAAAIs8AAAAAAAAizwAAAAAAACIPAAAAAAAAIg8AAAAAAAAiDwAAAAAAACIPAAAAAAAAIg8AAAAAAAAiDwAAAAAAACIPAAAAAAAAII8AAAAAAAAgjwAAAAAAACCPAAAAAAAAII8AAAAAAAAgjwAAAAAAACCPAAAAAAAAII8AAAAAAAAdTwAAAAAAAB1PAAAAAAAAHU8AAAAAAAAdTwAAAAAAAB1PAAAAAAAAHU8AAAAAAAAdTwAAAAAAABnPAAAAAAAAGc8AAAAAAAAZzwAAAAAAABnPAAAAAAAAGc8AAAAAAAAZzwAAAAAAABnPAAAAAAAAGA8AAAAAAAAYDwAAAAAAABgPAAAAAAAAGA8AAAAAAAAYDwAAAAAAABgPAAAAAAAAGA8AAAAAAAAXTwAAAAAAABdPAAAAAAAAF08AAAAAAAAXTwAAAAAAABdPAAAAAAAAF08AAAAAAAAXTwAAAAAAAATQQAAAAAAABNBAAAAAAAAE0EAAAAA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AAAAAAAA00wAAAAAAADTTAAAAAAAANNMAAAAAAAA00wAAAAAAADTTAAAAAAAANNMAAAAAAAA00wAAAAAAADTTAAAAAAAANNMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0EwAAAAAAADQTAAAAAAAANBMAAAAAAAA0EwAAAAAAADQTAAAAAAAANBMAAAAAAAA0EwAAAAAAADGTAAAAAAAAMZMAAAAAAAAxkwAAAAAAADGTAAAAAAAAMZMAAAAAAAAxkwAAAAAAADGTAAAAAAAAKBMAAAAAAAAoEwAAAAAAACgTAAAAAAAAKBMAAAAAAAAoEwAAAAAAACgTAAAAAAAAKBMAAAAAAAAtU0AAAAAAAC1TQAAAAAAALVNAAAAAAAAtU0AAAAAAAC1TQAAAAAAALVNAAAAAAAAtU0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALJNAAAAAAAAsk0AAAAAAACyTQAAAAAAALJNAAAAAAAAsk0AAAAAAACyTQAAAAAAALJNAAAAAAAAqU0AAAAAAACpTQAAAAAAAKlNAAAAAAAAqU0AAAAAAACpTQAAAAAAAKlNAAAAAAAAqU0AAAAAAAB6TQAAAAAAAHpNAAAAAAAAek0AAAAAAAB6TQAAAAAAAHpNAAAAAAAAek0AAAAAAAB6TQAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACXTgAAAAAAAJdOAAAAAAAAl04AAAAAAACVTgAAAAAAAJVOAAAAAAAAlU4AAAAAAACVTgAAAAAAAJVOAAAAAAAAlU4AAAAAAACVTgAAAAAAAI1OAAAAAAAAjU4AAAAAAACNTgAAAAAAAI1OAAAAAAAAjU4AAAAAAACNTgAAAAAAAI1OAAAAAAAAVk4AAAAAAABWTgAAAAAAAFZOAAAAAAAAVk4AAAAAAABWTgAAAAAAAFZOAAAAAAAAVk4AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB4TwAAAAAAAHhPAAAAAAAAeE8AAAAAAAB3TwAAAAAAAHdPAAAAAAAAd08AAAAAAAB3TwAAAAAAAHdPAAAAAAAAd08AAAAAAAB3TwAAAAAAAHdPAAAAAAAAd08AAAAAAAB3TwAAAAAAAHdPAAAAAAAAd08AAAAAAAB3TwAAAAAAAHdPAAAAAAAAdk8AAAAAAAB2TwAAAAAAAHZPAAAAAAAAdk8AAAAAAAB2TwAAAAAAAHZPAAAAAAAAdk8AAAAAAABuTwAAAAAAAG5PAAAAAAAAbk8AAAAAAABuTwAAAAAAAG5PAAAAAAAAbk8AAAAAAABuTwAAAAAAAC9PAAAAAAAAL08AAAAAAAAvTwAAAAAAAC9PAAAAAAAAL08AAAAAAAAvTwAAAAAAAC9PAAAAAAAAgjQAAAAAAACCNAAAAAAAAII0AAAAAAAAgjQAAAAAAACCNAAAAAAAAII0AAAAAAAAgjQAAAAAAAB8NAAAAAAAAHw0AAAAAAAAfDQAAAAAAAB8NAAAAAAAAHw0AAAAAAAAfDQAAAAAAAB8NAAAAAAAAHU0AAAAAAAAdTQAAAAAAAB1NAAAAAAAAHU0AAAAAAAAdTQAAAAAAAB1NAAAAAAAAHU0AAAAAAAAbzQAAAAAAABvNAAAAAAAAG80AAAAAAAAbzQAAAAAAABvNAAAAAAAAG80AAAAAAAAbzQAAAAAAABrNAAAAAAAAGs0AAAAAAAAazQAAAAAAABrNAAAAAAAAGs0AAAAAAAAazQAAAAAAABrNAAAAAAAAGg0AAAAAAAAaDQAAAAAAABoNAAAAAAAAGg0AAAAAAAAaDQAAAAAAABoNAAAAAAAAGg0AAAAAAAAZjQAAAAAAABmNAAAAAAAAGY0AAAAAAAAZjQAAAAAAABmNAAAAAAAAGY0AAAAAAAAZjQAAAAAAABlNAAAAAAAAGU0AAAAAAAAZTQAAAAAAABlNAAAAAAAAGU0AAAAAAAAZTQAAAAAAABlNAAAAAAAAI08AAAAAAAAjTwAAAAAAACNPAAAAAAAAI08AAAAAAAAjTwAAAAAAACNPAAAAAAAAI08AAAAAAAAizwAAAAAAACLPAAAAAAAAIs8AAAAAAAAizwAAAAAAACLPAAAAAAAAIs8AAAAAAAAizwAAAAAAACIPAAAAAAAAIg8AAAAAAAAiDwAAAAAAACIPAAAAAAAAIg8AAAAAAAAiDwAAAAAAACIPAAAAAAAAII8AAAAAAAAgjwAAAAAAACCPAAAAAAAAII8AAAAAAAAgjwAAAAAAACCPAAAAAAAAII8AAAAAAAAdTwAAAAAAAB1PAAAAAAAAHU8AAAAAAAAdTwAAAAAAAB1PAAAAAAAAHU8AAAAAAAAdTwAAAAAAABnPAAAAAAAAGc8AAAAAAAAZzwAAAAAAABnPAAAAAAAAGc8AAAAAAAAZzwAAAAAAABnPAAAAAAAAGA8AAAAAAAAYDwAAAAAAABgPAAAAAAAAGA8AAAAAAAAYDwAAAAAAABgPAAAAAAAAGA8AAAAAAAAXTwAAAAAAABdPAAAAAAAAF08AAAAAAAAXTwAAAAAAABdPAAAAAAAAF08AAAAAAAAXTwAAAAAAAATQQAAAAAAABNBAAAAAAAAE0EAAAAAAAATQQAAAAAAABNBAAAAAAAAE0EAAAAA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AAAAAAAA00wAAAAAAADTTAAAAAAAANNMAAAAAAAA00wAAAAAAADTTAAAAAAAANNMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0EwAAAAAAADQTAAAAAAAANBMAAAAAAAA0EwAAAAAAADQTAAAAAAAANBMAAAAAAAA0EwAAAAAAADGTAAAAAAAAMZMAAAAAAAAxkwAAAAAAADGTAAAAAAAAMZMAAAAAAAAxkwAAAAAAADGTAAAAAAAAKBMAAAAAAAAoEwAAAAAAACgTAAAAAAAAKBMAAAAAAAAoEwAAAAAAACgTAAAAAAAAKBMAAAAAAAAtU0AAAAAAAC1TQAAAAAAALVNAAAAAAAAtU0AAAAAAAC1TQAAAAAAALVNAAAAAAAAtU0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALJNAAAAAAAAsk0AAAAAAACyTQAAAAAAALJNAAAAAAAAsk0AAAAAAACyTQAAAAAAALJNAAAAAAAAqU0AAAAAAACpTQAAAAAAAKlNAAAAAAAAqU0AAAAAAACpTQAAAAAAAKlNAAAAAAAAqU0AAAAAAAB6TQAAAAAAAHpNAAAAAAAAek0AAAAAAAB6TQAAAAAAAHpNAAAAAAAAek0AAAAAAAB6TQAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAA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AAASQQAAAAAAABJBAAAAAAAAEkEAAAAAAAASQQAAAAAAABJBAAAAAAAAEEEAAAAAAAAQQQAAAAAAABBBAAAAAAAAEEEAAAAAAAAQQQAAAAAAABBBAAAAAAAAEEEAAAAAAAANQQAAAAAAAA1BAAAAAAAADUEAAAAAAAANQQAAAAAAAA1BAAAAAAAADUEAAAAAAAANQQAAAAAAAAdBAAAAAAAAB0EAAAAAAAAHQQAAAAAAAAdBAAAAAAAAB0EAAAAAAAAHQQAAAAAAAAdBAAAAAAAA9EAAAAAAAAD0QAAAAAAAAPRAAAAAAAAA9EAAAAAAAAD0QAAAAAAAAPRAAAAAAAAA9EAAAAAAAADgQAAAAAAAAOBAAAAAAAAA4EAAAAAAAADgQAAAAAAAAOBAAAAAAAAA4EAAAAAAAADgQAAAAAAAANpAAAAAAAAA2kAAAAAAAADaQAAAAAAAANpAAAAAAAAA2kAAAAAAAADaQAAAAAAAANpAAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAG9EAAAAAAAAb0QAAAAAAABvRAAAAAAAAG9EAAAAAAAAb0QAAAAAAABvRAAAAAAAAG9EAAAAAAAAbUQAAAAAAABtRAAAAAAAAG1EAAAAAAAAbUQAAAAAAABtRAAAAAAAAG1EAAAAAAAAbUQAAAAAAABqRAAAAAAAAGpEAAAAAAAAakQAAAAAAABqRAAAAAAAAGpEAAAAAAAAakQAAAAAAABqRAAAAAAAAGFEAAAAAAAAYUQAAAAAAABhRAAAAAAAAGFEAAAAAAAAYUQAAAAAAABhRAAAAAAAAGFEAAAAAAAASEQAAAAAAABIRAAAAAAAAEhEAAAAAAAASEQAAAAAAABIRAAAAAAAAEhEAAAAAAAASEQAAAAAAAA+RAAAAAAAAD5EAAAAAAAAPkQAAAAAAAA+RAAAAAAAAD5EAAAAAAAAPkQAAAAAAAA+RAAAAAAAAMlGAAAAAAAAyUYAAAAAAADJRgAAAAAAAMlGAAAAAAAAyUYAAAAAAADJRgAAAAAAAMlGAAAAAAAAyUYAAAAAAADJRgAAAAAAAMlG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AAAAAAAA00wAAAAAAADTTAAAAAAAANNMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0EwAAAAAAADQTAAAAAAAANBMAAAAAAAA0EwAAAAAAADQTAAAAAAAANBMAAAAAAAA0EwAAAAAAADGTAAAAAAAAMZMAAAAAAAAxkwAAAAAAADGTAAAAAAAAMZMAAAAAAAAxkwAAAAAAADGTAAAAAAAAKBMAAAAAAAAoEwAAAAAAACgTAAAAAAAAKBMAAAAAAAAoEwAAAAAAACgTAAAAAAAAKBMAAAAAAAAtU0AAAAAAAC1TQAAAAAAALVNAAAAAAAAtU0AAAAAAAC1TQAAAAAAALVNAAAAAAAAtU0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALJNAAAAAAAAsk0AAAAAAACyTQAAAAAAALJNAAAAAAAAsk0AAAAAAACyTQAAAAAAALJNAAAAAAAAqU0AAAAAAACpTQAAAAAAAKlNAAAAAAAAqU0AAAAAAACpTQAAAAAAAKlNAAAAAAAAqU0AAAAAAAB6TQAAAAAAAHpNAAAAAAAAek0AAAAAAAB6TQAAAAAAAHpNAAAAAAAAek0AAAAAAAB6TQAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAA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AAASQQAAAAAAABJBAAAAAAAAEEEAAAAAAAAQQQAAAAAAABBBAAAAAAAAEEEAAAAAAAAQQQAAAAAAABBBAAAAAAAAEEEAAAAAAAANQQAAAAAAAA1BAAAAAAAADUEAAAAAAAANQQAAAAAAAA1BAAAAAAAADUEAAAAAAAANQQAAAAAAAAdBAAAAAAAAB0EAAAAAAAAHQQAAAAAAAAdBAAAAAAAAB0EAAAAAAAAHQQAAAAAAAAdBAAAAAAAA9EAAAAAAAAD0QAAAAAAAAPRAAAAAAAAA9EAAAAAAAAD0QAAAAAAAAPRAAAAAAAAA9EAAAAAAAADgQAAAAAAAAOBAAAAAAAAA4EAAAAAAAADgQAAAAAAAAOBAAAAAAAAA4EAAAAAAAADgQAAAAAAAANpAAAAAAAAA2kAAAAAAAADaQAAAAAAAANpAAAAAAAAA2kAAAAAAAADaQAAAAAAAANpAAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAHBEAAAAAAAAcEQAAAAAAABwRAAAAAAAAG9EAAAAAAAAb0QAAAAAAABvRAAAAAAAAG9EAAAAAAAAb0QAAAAAAABvRAAAAAAAAG9EAAAAAAAAbUQAAAAAAABtRAAAAAAAAG1EAAAAAAAAbUQAAAAAAABtRAAAAAAAAG1EAAAAAAAAbUQAAAAAAABqRAAAAAAAAGpEAAAAAAAAakQAAAAAAABqRAAAAAAAAGpEAAAAAAAAakQAAAAAAABqRAAAAAAAAGFEAAAAAAAAYUQAAAAAAABhRAAAAAAAAGFEAAAAAAAAYUQAAAAAAABhRAAAAAAAAGFEAAAAAAAASEQAAAAAAABIRAAAAAAAAEhEAAAAAAAASEQAAAAAAABIRAAAAAAAAEhEAAAAAAAASEQAAAAAAAA+RAAAAAAAAD5EAAAAAAAAPkQAAAAAAAA+RAAAAAAAAD5EAAAAAAAAPkQAAAAAAAA+RAAAAAAAAMlGAAAAAAAAyUYAAAAAAADJRgAAAAAAAMlGAAAAAAAAyUYAAAAAAADJRgAAAAAAAMlGAAAAAAAAyUYAAAAAAADJRgAAAAAAAMlGAAAAAAAAyUYAAAAAAADJRgAAAAAAAMlG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AAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0kwAAAAAAADSTAAAAAAAANJMAAAAAAAA0EwAAAAAAADQTAAAAAAAANBMAAAAAAAA0EwAAAAAAADQTAAAAAAAANBMAAAAAAAA0EwAAAAAAADGTAAAAAAAAMZMAAAAAAAAxkwAAAAAAADGTAAAAAAAAMZMAAAAAAAAxkwAAAAAAADGTAAAAAAAAKBMAAAAAAAAoEwAAAAAAACgTAAAAAAAAKBMAAAAAAAAoEwAAAAAAACgTAAAAAAAAKBMAAAAAAAAtU0AAAAAAAC1TQAAAAAAALVNAAAAAAAAtU0AAAAAAAC1TQAAAAAAALVNAAAAAAAAtU0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALRNAAAAAAAAtE0AAAAAAAC0TQAAAAAAALJNAAAAAAAAsk0AAAAAAACyTQAAAAAAALJNAAAAAAAAsk0AAAAAAACyTQAAAAAAALJNAAAAAAAAqU0AAAAAAACpTQAAAAAAAKlNAAAAAAAAqU0AAAAAAACpTQAAAAAAAKlNAAAAAAAAqU0AAAAAAAB6TQAAAAAAAHpNAAAAAAAAek0AAAAAAAB6TQAAAAAAAHpNAAAAAAAAek0AAAAAAAB6TQAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAAAACYTgAAAAAAAJhOAAAAAAAAmE4AAAAA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', + }, }, } as const; diff --git a/src/sky/scatter.js b/src/sky/scatter.js index 5781c089..bd12b7e0 100644 --- a/src/sky/scatter.js +++ b/src/sky/scatter.js @@ -193,6 +193,87 @@ export function skyRadiance(sunElevationDeg, viewElevationDeg, azimuthDeg) { return radiance; } +// ---- Aerial perspective ------------------------------------------------------------------------------------------ +// After Hillaire, "A Scalable and Production Ready Sky and Atmosphere Rendering Technique", EGSR 2020 (section 5.4, the +// aerial perspective "camera volume"), and its reference implementation, sebh/UnrealEngineSkyAtmosphere +// Resources/RenderSkyRayMarching.hlsl `RenderCameraVolumePS`: a volume of AP_SLICES slices whose depth grows with the +// square of the slice index (`Slice *= Slice`), each texel holding the single-scattered luminance L accumulated along +// the view ray to that depth, and in alpha the opacity 1 - mean(rgb transmittance) (a grey transmittance, as the paper). +// Applied to a surface as out = surface * (1 - alpha) + L, with L the in-scatter along that ray. +// +// What differs from the paper, and why: +// - the volume is indexed by the view ray relative to the sun (azimuth from the sun, elevation) and sun elevation, not by +// screen position, because the terrain camera turns and the sky tables here are sun-relative too; +// - the path lengths are the scene's length times AP_DISTANCE_SCALE: a real atmosphere is nearly clear over the 3 km patch, +// so the baked path is that many times longer (EYEBALLED); it sets how fast far ridges go to the sky; +// - the ray ignores the ground: below the observer's altitude the density is held at its ground value, and the sun's +// visibility is tested from the ground sphere; the terrain is the ground; +// - single scattering with the sky tables' own air (no multi-scattering LUT, which the paper adds for dusk); +// - AP_SLICES = 12 slices, 8 view elevations and 7 azimuths, not 32 x 32 x 32 screen froxels, to stay phone-sized. +export const AP_SLICES = 12; +export const AP_ELEVATIONS = 8; +/** The view elevations the volume holds run from this down to 0 degrees, finest at 0: -AP_ELEVATION_MIN_DEG * (1 - i / 7)^2. */ +export const AP_ELEVATION_MIN_DEG = 50; +/** Scene metres a slice volume covers: the patch's far corner (about 2.1 km) with margin. */ +export const AP_SCENE_MAX_M = 2500; +export const AP_DISTANCE_SCALE = 8; +/** Physical kilometres the last slice stands for. */ +export const AP_MAX_KM = (AP_SCENE_MAX_M * AP_DISTANCE_SCALE) / 1000; +const AP_SUBSTEPS = 4; + +export const apElevation = (i) => -AP_ELEVATION_MIN_DEG * (1 - i / (AP_ELEVATIONS - 1)) ** 2; +/** Fractional elevation index of a view elevation in degrees (clamped to 0 degrees and below). */ +export const apElevationIndex = (deg) => + (AP_ELEVATIONS - 1) * (1 - Math.sqrt(clamp(-deg / AP_ELEVATION_MIN_DEG, 0, 1))); +/** Distance in km at the far side of slice k (k = 0 .. AP_SLICES - 1): the squared distribution. */ +export const apSliceKm = (k) => AP_MAX_KM * ((k + 1) / AP_SLICES) ** 2; +/** Fractional slice index of a distance in km: below 0 the volume fades to nothing at the observer. */ +export const apSliceIndex = (km) => AP_SLICES * Math.sqrt(Math.max(km, 0) / AP_MAX_KM) - 1; + +/** + * In-scatter luminance per channel (for a sun of unit irradiance) and opacity along a view ray from the observer, + * at the far side of each of AP_SLICES slices: [[r, g, b, alpha] x AP_SLICES]. + */ +export function aerialRay(sunElevationDeg, viewElevationDeg, azimuthDeg) { + const v = viewDirection(viewElevationDeg, azimuthDeg); + const s = sunDirection(sunElevationDeg); + const cosTheta = v[0] * s[0] + v[1] * s[1] + v[2] * s[2]; + const pr = rayleighPhase(cosTheta); + const pm = miePhase(cosTheta); + const out = []; + const along = [0, 0, 0]; + const radiance = [0, 0, 0]; + let from = 0; + for (let k = 0; k < AP_SLICES; k += 1) { + const to = apSliceKm(k); + const dt = (to - from) / AP_SUBSTEPS; + for (let j = 0; j < AP_SUBSTEPS; j += 1) { + const t = from + (j + 0.5) * dt; + let q = [observer[0] + v[0] * t, observer[1] + v[1] * t, observer[2] + v[2] * t]; + const r = Math.hypot(...q); + if (r < PLANET_RADIUS_KM) q = q.map((x) => (x * PLANET_RADIUS_KM) / r); + const h = Math.max(r - PLANET_RADIUS_KM, 0); + const e = extinction(h); + const toSun = opticalDepthToSpace(q, s, 12); + const rm = Math.exp(-h / H_RAYLEIGH); + const mm = Math.exp(-h / H_MIE); + for (let c = 0; c < 3; c += 1) { + // The step's own extinction is integrated analytically (Hillaire eq. 6): S (1 - exp(-e dt)) / e. + const step = Math.exp(-e[c] * dt); + if (toSun) { + const scatter = BETA_RAYLEIGH[c] * rm * pr + BETA_MIE_SCA * mm * pm; + radiance[c] += Math.exp(-along[c] - toSun[c]) * ((scatter * (1 - step)) / e[c]); + } + along[c] += e[c] * dt; + } + } + from = to; + const mean = along.reduce((a, x) => a + Math.exp(-x), 0) / 3; + out.push([...radiance, 1 - mean]); + } + return out; +} + const base64 = (halves) => { const bytes = new Uint8Array(halves.length * 2); halves.forEach((h, i) => { @@ -226,6 +307,26 @@ export function bakeScatter() { }) ); + // x = azimuth from the sun, y = elevation block k * AP_ELEVATIONS + i (slice-major), z = sun elevation: four half floats per texel (L rgb, alpha). + const aerial = new Uint16Array( + SUN_ELEVATIONS_DEG.length * AP_SLICES * AP_ELEVATIONS * AZIMUTH_SAMPLES * 4 + ); + let ap = 0; + SUN_ELEVATIONS_DEG.forEach((sun) => { + const rays = Array.from({ length: AP_ELEVATIONS }, (_, i) => + Array.from({ length: AZIMUTH_SAMPLES }, (_, a) => + aerialRay(sun, apElevation(i), a * AZIMUTH_STEP_DEG) + ) + ); + for (let k = 0; k < AP_SLICES; k += 1) + for (let i = 0; i < AP_ELEVATIONS; i += 1) + for (let a = 0; a < AZIMUTH_SAMPLES; a += 1) + rays[i][a][k].forEach((x) => { + aerial[ap] = encode(x); + ap += 1; + }); + }); + return { planet_radius_km: PLANET_RADIUS_KM, atmosphere_height_km: ATMOSPHERE_HEIGHT_KM, @@ -245,5 +346,17 @@ export function bakeScatter() { channels: 3, data: base64(sky), }, + aerial_perspective: { + slices: AP_SLICES, + elevations: AP_ELEVATIONS, + elevation_min_deg: -AP_ELEVATION_MIN_DEG, + max_km: AP_MAX_KM, + distance_scale: AP_DISTANCE_SCALE, + width: AZIMUTH_SAMPLES, + height: AP_SLICES * AP_ELEVATIONS, + depth: SUN_ELEVATIONS_DEG.length, + channels: 4, + data: base64(aerial), + }, }; } diff --git a/src/sky/scatter.test.ts b/src/sky/scatter.test.ts index 9ebcb014..e0ea9023 100644 --- a/src/sky/scatter.test.ts +++ b/src/sky/scatter.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it } from 'vitest'; import { + AP_ELEVATIONS, + AP_MAX_KM, + AP_SLICES, + apElevation, + apElevationIndex, + apSliceIndex, + apSliceKm, + aerialRay, AZIMUTH_SAMPLES, AZIMUTH_STEP_DEG, STORE_SCALE, @@ -158,3 +166,61 @@ describe('scattering tables', () => { expect([sum(sky), sum(trans)]).toEqual([SKY_CHECKSUM, TRANS_CHECKSUM]); }); }); + +describe('aerial perspective volume', () => { + const ap = scatter.aerial_perspective; + + it('has the documented layout and size: RGBA half floats, x azimuth, y slice-major elevations, z sun elevation', () => { + expect([ap.width, ap.height, ap.depth, ap.channels]).toEqual([ + AZIMUTH_SAMPLES, + AP_SLICES * AP_ELEVATIONS, + SUN_ELEVATIONS_DEG.length, + 4, + ]); + expect(table(ap.data)).toHaveLength(ap.width * ap.height * ap.depth * 4); + }); + + it("slices follow the paper's squared distribution, ending at the covered distance", () => { + expect(apSliceKm(AP_SLICES - 1)).toBeCloseTo(AP_MAX_KM, 9); + expect(apSliceKm(0)).toBeCloseTo(AP_MAX_KM / AP_SLICES ** 2, 9); + for (let k = 0; k < AP_SLICES; k += 1) expect(apSliceIndex(apSliceKm(k))).toBeCloseTo(k, 9); + expect(apSliceIndex(0)).toBeCloseTo(-1, 9); + }); + + it('elevations are finest at the horizon and the index inverts them', () => { + expect(apElevation(AP_ELEVATIONS - 1)).toBeCloseTo(0, 12); + for (let i = 0; i < AP_ELEVATIONS; i += 1) + expect(apElevationIndex(apElevation(i))).toBeCloseTo(i, 9); + expect(apElevation(AP_ELEVATIONS - 1) - apElevation(AP_ELEVATIONS - 2)).toBeLessThan( + apElevation(1) - apElevation(0) + ); + }); + + it("opacity grows with path, in-scatter grows with path, and a sun under the planet's shadow scatters nothing", () => { + const r = aerialRay(45, -2, 90); + for (let k = 1; k < AP_SLICES; k += 1) { + expect(r[k][3]).toBeGreaterThan(r[k - 1][3]); + expect(r[k][0]).toBeGreaterThan(r[k - 1][0]); + } + for (const slice of aerialRay(-18, -2, 0)) expect(slice.slice(0, 3)).toEqual([0, 0, 0]); + }); + + it('is brighter toward a low sun than away, and redder toward it (Rayleigh and Mie phase)', () => { + const toward = aerialRay(2, -2, 0)[AP_SLICES - 1]; + const away = aerialRay(2, -2, 180)[AP_SLICES - 1]; + expect(toward[0]).toBeGreaterThan(20 * away[0]); + expect(toward[0] / toward[2]).toBeGreaterThan(away[0] / away[2]); + }); + + it('opacity does not depend on the sun, only on the path', () => { + expect(aerialRay(45, -2, 0)[5][3]).toBeCloseTo(aerialRay(-18, -2, 90)[5][3], 12); + }); + + it("the baked table is the model's output: spot texels equal aerialRay, to half-float precision", () => { + const t = table(ap.data); + const at = (sun: number, slice: number, el: number, az: number, c: number) => + decode(t[((sun * ap.height + slice * AP_ELEVATIONS + el) * ap.width + az) * 4 + c]); + const r = aerialRay(SUN_ELEVATIONS_DEG[8], apElevation(6), 60)[7]; + for (let c = 0; c < 4; c += 1) expect(at(8, 7, 6, 2, c)).toBeCloseTo(r[c], 3); + }); +}); From 3e57865eca4ea5d1f96905d3795996b5e497e91c Mon Sep 17 00:00:00 2001 From: Void Date: Sun, 8 Nov 2026 18:45:29 +0000 Subject: [PATCH 133/206] Upload failure text carries who called abort and what the owning component did For each upload attempt, XMLHttpRequest.prototype.abort and AbortController.prototype.abort are wrapped (reference-counted across overlapping attempts, restored in finally) and every call is recorded with a six-frame file:line:col stack; "abort calls: none" says the page called neither. The upload hook also counts renders after attach (and those within 300ms) and any unmount. The in-memory copy and the once-only retry are unchanged. Co-Authored-By: Claude Sonnet 5.5 --- src/app/state/upload.ts | 43 ++++-- src/app/utils/matrix.ts | 187 ++++++++++++++++++++----- src/app/utils/uploadAbortProbe.test.ts | 171 ++++++++++++++++++++++ 3 files changed, 356 insertions(+), 45 deletions(-) create mode 100644 src/app/utils/uploadAbortProbe.test.ts diff --git a/src/app/state/upload.ts b/src/app/state/upload.ts index 810c0d2e..84786a29 100644 --- a/src/app/state/upload.ts +++ b/src/app/state/upload.ts @@ -1,9 +1,14 @@ import { atom, useAtom } from 'jotai'; import { atomFamily } from 'jotai/utils'; import { MatrixClient, UploadResponse, UploadProgress, MatrixError } from 'matrix-js-sdk'; -import { useCallback } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { useThrottle } from '../hooks/useThrottle'; -import { uploadContent, TUploadContent } from '../utils/matrix'; +import { + ComponentLifecycle, + describeComponentLifecycle, + uploadContent, + TUploadContent, +} from '../utils/matrix'; export enum UploadStatus { Idle = 'idle', @@ -105,22 +110,34 @@ export const useBindUploadAtom = ( const [upload, setUpload] = useAtom(uploadAtom); const { file } = upload; + // For the failure text: renders and unmount of the component that owns this upload. + const life = useRef({ attachedAt: 0, rendersAtAttach: 0, renders: [] }); + if (life.current.renders.length < 50) life.current.renders.push(Date.now()); + useEffect(() => { + const tracked = life.current; + tracked.unmountedAt = undefined; + return () => { + tracked.unmountedAt = Date.now(); + }; + }, []); + const handleProgress = useThrottle( useCallback((progress: UploadProgress) => setUpload({ progress }), [setUpload]), { immediate: true, wait: 200 } ); - const startUpload = useCallback( - () => - uploadContent(mx, file, { - hideFilename, - onPromise: (promise: Promise) => setUpload({ promise }), - onProgress: handleProgress, - onSuccess: (mxc) => setUpload({ mxc }), - onError: (error) => setUpload({ error }), - }), - [mx, file, hideFilename, setUpload, handleProgress] - ); + const startUpload = useCallback(() => { + life.current.attachedAt = Date.now(); + life.current.rendersAtAttach = life.current.renders.length; + return uploadContent(mx, file, { + hideFilename, + describeLifecycle: () => describeComponentLifecycle(life.current), + onPromise: (promise: Promise) => setUpload({ promise }), + onProgress: handleProgress, + onSuccess: (mxc) => setUpload({ mxc }), + onError: (error) => setUpload({ error }), + }); + }, [mx, file, hideFilename, setUpload, handleProgress]); const cancelUpload = useCallback(async () => { if (upload.status === UploadStatus.Loading) { diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts index 3eef6b21..a3257f26 100644 --- a/src/app/utils/matrix.ts +++ b/src/app/utils/matrix.ts @@ -143,6 +143,8 @@ export type ContentUploadOptions = { onProgress?: (progress: UploadProgress) => void; onSuccess: (mxc: string) => void; onError: (error: MatrixError) => void; + /** Read at failure time: what the owning component did since the upload attached. */ + describeLifecycle?: () => string; }; let lastUserActivity = Date.now(); @@ -155,6 +157,8 @@ if (typeof window !== 'undefined') { ); } +export type AbortCall = { api: string; frames: string }; + /** What the progress handler saw of one upload, for the failure text. */ export type UploadTrace = { startedAt: number; @@ -163,6 +167,98 @@ export type UploadTrace = { total?: number; /** Summary of an earlier attempt of the same upload, when this one is a retry. */ previous?: string; + /** abort() calls the page made while this attempt ran; absent when not probed. */ + aborts?: AbortCall[]; + /** What the owning component did since the upload attached. */ + lifecycle?: string; +}; + +const MAX_FRAMES = 6; +const MAX_ABORT_CALLS = 4; + +/** + * An Error stack as `file:line:col < file:line:col`, at most six frames. The error line and the + * probe's own frame are dropped; origins and function names are not kept. + */ +export const formatAbortStack = (stack: string): string => { + const frames = stack + .split('\n') + .map((l) => l.trim()) + .filter((l) => l.startsWith('at ') || l.includes('@')) + .map((l) => { + const m = /([^/\s()@]+):(\d+):(\d+)\)?$/.exec(l); + return m ? `${m[1]}:${m[2]}:${m[3]}` : undefined; + }) + .filter((f): f is string => f !== undefined) + .slice(1, 1 + MAX_FRAMES); + return frames.length > 0 ? frames.join(' < ') : 'no stack'; +}; + +export type ComponentLifecycle = { + attachedAt: number; + rendersAtAttach: number; + renders: number[]; + unmountedAt?: number; +}; + +/** What the component that owns an upload did after the upload attached to it. */ +export const describeComponentLifecycle = (life: ComponentLifecycle): string => { + const since = life.renders.slice(life.rendersAtAttach); + const quick = since.filter((t) => t - life.attachedAt <= 300).length; + const unmount = + life.unmountedAt === undefined + ? 'not unmounted' + : `unmounted +${life.unmountedAt - life.attachedAt}ms`; + return `component: ${since.length} renders since attach (${quick} within 300ms), ${unmount}`; +}; + +const collectors = new Set(); +let restoreProbe: (() => void) | undefined; + +/* eslint-disable no-param-reassign */ +const patchAbort = (proto: { abort: (...a: any[]) => any }, api: string) => { + const original = proto.abort; + const wrapper = function abortProbe(this: unknown, ...args: any[]) { + const frames = formatAbortStack(new Error().stack ?? ''); + collectors.forEach((calls) => { + if (calls.length < MAX_ABORT_CALLS) calls.push({ api, frames }); + }); + return original.apply(this, args); + }; + proto.abort = wrapper; + return () => { + if (proto.abort === wrapper) proto.abort = original; + }; +}; +/* eslint-enable no-param-reassign */ + +/** + * Records every XMLHttpRequest.abort and AbortController.abort the page makes until stopped. The + * prototypes are wrapped while at least one probe runs and restored when the last one stops. + */ +export const startAbortProbe = () => { + const calls: AbortCall[] = []; + if (collectors.size === 0) { + const restores: (() => void)[] = []; + if (typeof XMLHttpRequest !== 'undefined') { + restores.push(patchAbort(XMLHttpRequest.prototype, 'XHR.abort')); + } + if (typeof AbortController !== 'undefined') { + restores.push(patchAbort(AbortController.prototype, 'AbortController.abort')); + } + restoreProbe = () => restores.forEach((r) => r()); + } + collectors.add(calls); + return { + calls, + stop: () => { + collectors.delete(calls); + if (collectors.size === 0) { + restoreProbe?.(); + restoreProbe = undefined; + } + }, + }; }; const describeNetwork = (): string => { @@ -209,6 +305,14 @@ export const uploadFailure = async ( }B` ); parts.push(`failed after ${now - trace.startedAt}ms`); + if (trace.aborts) { + parts.push( + trace.aborts.length === 0 + ? 'abort calls: none' + : `abort calls: ${trace.aborts.map((a) => `${a.api} ${a.frames}`).join('; ')}` + ); + } + if (trace.lifecycle) parts.push(trace.lifecycle); if (trace.previous) parts.push(`retried (attempt 1: ${trace.previous})`); } const error = `Upload failed: ${message ? `${name}: ${message}` : name} (${parts.join(', ')})`; @@ -227,46 +331,65 @@ export const uploadContent = async ( file: TUploadContent, options: ContentUploadOptions ) => { - const { name, fileType, hideFilename, onProgress, onPromise, onSuccess, onError } = options; + const { + name, + fileType, + hideFilename, + onProgress, + onPromise, + onSuccess, + onError, + describeLifecycle, + } = options; let previous: string | undefined; for (let attempt = 1; ; attempt += 1) { - const trace: UploadTrace = { startedAt: Date.now(), previous }; + const probe = startAbortProbe(); + const trace: UploadTrace = { startedAt: Date.now(), previous, aborts: probe.calls }; const abortController = new AbortController(); - const uploadPromise = mx.uploadContent(file, { - name, - type: fileType, - includeFilename: !hideFilename, - abortController, - progressHandler: (progress) => { - if (trace.firstProgressAt === undefined) trace.firstProgressAt = Date.now(); - trace.loaded = progress.loaded; - trace.total = progress.total; - onProgress?.(progress); - }, - }); - onPromise?.(uploadPromise); try { - // eslint-disable-next-line no-await-in-loop - const data = await uploadPromise; - const mxc = data.content_uri; - if (mxc) onSuccess(mxc); - else onError(new MatrixError(data)); - return; - } catch (e: any) { - const elapsed = Date.now() - trace.startedAt; - const retry = - attempt === 1 && - e?.name === 'AbortError' && - !abortController.signal.aborted && - trace.firstProgressAt === undefined && - elapsed < RETRY_WINDOW_MS; - if (!retry) { + const uploadPromise = mx.uploadContent(file, { + name, + type: fileType, + includeFilename: !hideFilename, + abortController, + progressHandler: (progress) => { + if (trace.firstProgressAt === undefined) trace.firstProgressAt = Date.now(); + trace.loaded = progress.loaded; + trace.total = progress.total; + onProgress?.(progress); + }, + }); + onPromise?.(uploadPromise); + try { // eslint-disable-next-line no-await-in-loop - onError(await uploadFailure(e, file, trace)); + const data = await uploadPromise; + const mxc = data.content_uri; + if (mxc) onSuccess(mxc); + else onError(new MatrixError(data)); return; + } catch (e: any) { + const elapsed = Date.now() - trace.startedAt; + const retry = + attempt === 1 && + e?.name === 'AbortError' && + !abortController.signal.aborted && + trace.firstProgressAt === undefined && + elapsed < RETRY_WINDOW_MS; + if (!retry) { + trace.lifecycle = describeLifecycle?.(); + // eslint-disable-next-line no-await-in-loop + onError(await uploadFailure(e, file, trace)); + return; + } + previous = `AbortError after ${elapsed}ms, no progress, ${ + trace.aborts?.length + ? `abort calls: ${trace.aborts.map((a) => `${a.api} ${a.frames}`).join('; ')}` + : 'abort calls: none' + }`; } - previous = `AbortError after ${elapsed}ms, no progress`; + } finally { + probe.stop(); } } }; diff --git a/src/app/utils/uploadAbortProbe.test.ts b/src/app/utils/uploadAbortProbe.test.ts new file mode 100644 index 00000000..557908ef --- /dev/null +++ b/src/app/utils/uploadAbortProbe.test.ts @@ -0,0 +1,171 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + describeComponentLifecycle, + formatAbortStack, + startAbortProbe, + uploadContent, + uploadFailure, +} from './matrix'; + +const chromeStack = [ + 'Error', + ' at wrapper (https://chat.example/assets/index-abc.js:1:100)', + ' at https://chat.example/assets/index-abc.js:1:200', + ' at Object.cb (https://chat.example/assets/vendor-def.js:7:8)', + ' at https://chat.example/assets/a.js:2:3', + ' at https://chat.example/assets/b.js:4:5', + ' at https://chat.example/assets/c.js:6:7', + ' at https://chat.example/assets/d.js:8:9', + ' at https://chat.example/assets/e.js:10:11', +].join('\n'); + +describe('formatAbortStack', () => { + it('keeps at most six frames as file:line:col, skipping the probe wrapper and the error line', () => { + const out = formatAbortStack(chromeStack); + const frames = out.split(' < '); + expect(frames).toHaveLength(6); + expect(frames[0]).toBe('index-abc.js:1:200'); + expect(out).not.toContain('https://'); + expect(out).not.toContain('wrapper'); + }); + + it('copes with an empty stack', () => { + expect(formatAbortStack('')).toBe('no stack'); + }); +}); + +describe('abort probe', () => { + const original = AbortController.prototype.abort; + + afterEach(() => { + vi.unstubAllGlobals(); + AbortController.prototype.abort = original; + }); + + it('records an AbortController.abort made while it runs, still aborts, and restores afterwards', () => { + const probe = startAbortProbe(); + expect(AbortController.prototype.abort).not.toBe(original); + const c = new AbortController(); + c.abort(); + expect(c.signal.aborted).toBe(true); + probe.stop(); + expect(AbortController.prototype.abort).toBe(original); + expect(probe.calls).toHaveLength(1); + expect(probe.calls[0].api).toBe('AbortController.abort'); + }); + + it('records XMLHttpRequest.prototype.abort and restores it', () => { + const xhrAbort = vi.fn(); + class FakeXhr { + // eslint-disable-next-line class-methods-use-this + abort() { + xhrAbort(); + } + } + vi.stubGlobal('XMLHttpRequest', FakeXhr); + const probe = startAbortProbe(); + new FakeXhr().abort(); + probe.stop(); + expect(xhrAbort).toHaveBeenCalledTimes(1); + expect(probe.calls.map((c) => c.api)).toContain('XHR.abort'); + expect(FakeXhr.prototype.abort.toString()).not.toContain('collectors'); + const after = new FakeXhr(); + after.abort(); + expect(xhrAbort).toHaveBeenCalledTimes(2); + }); + + it('records nothing after stop', () => { + const probe = startAbortProbe(); + probe.stop(); + new AbortController().abort(); + expect(probe.calls).toHaveLength(0); + }); + + it('keeps the wrapper until the last overlapping probe stops, then restores the original', () => { + const a = startAbortProbe(); + const b = startAbortProbe(); + a.stop(); + new AbortController().abort(); + expect(b.calls).toHaveLength(1); + expect(a.calls).toHaveLength(0); + b.stop(); + expect(AbortController.prototype.abort).toBe(original); + }); + + it('restores the original when the upload attempt throws', async () => { + const mx = { uploadContent: () => Promise.reject(new Error('boom')) } as any; + await uploadContent(mx, new Blob(['x']), { onSuccess: vi.fn(), onError: vi.fn() }); + expect(AbortController.prototype.abort).toBe(original); + }); + + it('restores the original when uploadContent itself throws synchronously', async () => { + const mx = { + uploadContent: () => { + throw new Error('sync'); + }, + } as any; + await expect( + uploadContent(mx, new Blob(['x']), { onSuccess: vi.fn(), onError: vi.fn() }) + ).rejects.toThrow('sync'); + expect(AbortController.prototype.abort).toBe(original); + }); +}); + +describe('failure text', () => { + const abort = () => new DOMException('', 'AbortError'); + + it('lists abort calls seen during the attempt', async () => { + const mx = { + uploadContent: () => { + new AbortController().abort(); + return Promise.reject(abort()); + }, + } as any; + const onError = vi.fn(); + await uploadContent(mx, new Blob(['x']), { onSuccess: vi.fn(), onError }); + // retried once: the final message carries the second attempt and attempt 1's summary + const msg = onError.mock.calls[0][0].message; + expect(msg).toContain('abort calls: AbortController.abort'); + expect(msg).toContain('retried (attempt 1: AbortError'); + }); + + it('says none when nothing in the page called abort', async () => { + const err = await uploadFailure(abort(), new Blob(['x']), { + startedAt: Date.now(), + aborts: [], + }); + expect(err.message).toContain('abort calls: none'); + }); + + it('reports the component lifecycle when given', async () => { + const mx = { uploadContent: () => Promise.reject(new Error('Timeout')) } as any; + const onError = vi.fn(); + await uploadContent(mx, new Blob(['x']), { + onSuccess: vi.fn(), + onError, + describeLifecycle: () => 'component remounted 0x, renders 3 (2 within 300ms)', + }); + expect(onError.mock.calls[0][0].message).toContain('renders 3 (2 within 300ms)'); + }); +}); + +describe('describeComponentLifecycle', () => { + it('counts renders after attach and those within 300ms, and reports an unmount', () => { + const text = describeComponentLifecycle({ + attachedAt: 1000, + rendersAtAttach: 1, + renders: [990, 1100, 1250, 1400], + unmountedAt: 1500, + }); + expect(text).toBe('component: 3 renders since attach (2 within 300ms), unmounted +500ms'); + }); + + it('says not unmounted when it was not', () => { + const text = describeComponentLifecycle({ + attachedAt: 1000, + rendersAtAttach: 1, + renders: [990], + }); + expect(text).toBe('component: 0 renders since attach (0 within 300ms), not unmounted'); + }); +}); From 25765c44c5ed6577e7ada36d52201de0d87ebacc Mon Sep 17 00:00:00 2001 From: Void Date: Sun, 8 Nov 2026 18:47:27 +0000 Subject: [PATCH 134/206] Upload each attempt from an in-memory copy; failure line names the XHR's end state - before every attempt the picked file is read into memory (ArrayBuffer) and that copy is what the SDK uploads; a file the browser cannot read fails there as "file unreadable before upload" - while an attempt runs, XMLHttpRequest open/send are wrapped (reference-counted with the abort probe, restored in finally, no behaviour change) so the failure line carries each upload XHR's method, path (query dropped), readyState, status, the events it fired with timings, and our signal.aborted - the abort-call stacks stay as a minor extra Co-Authored-By: Claude Sonnet 5.5 --- src/app/utils/matrix.ts | 127 +++++++++++++++++++-- src/app/utils/uploadXhrProbe.test.ts | 163 +++++++++++++++++++++++++++ 2 files changed, 278 insertions(+), 12 deletions(-) create mode 100644 src/app/utils/uploadXhrProbe.test.ts diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts index a3257f26..f0ded881 100644 --- a/src/app/utils/matrix.ts +++ b/src/app/utils/matrix.ts @@ -171,6 +171,9 @@ export type UploadTrace = { aborts?: AbortCall[]; /** What the owning component did since the upload attached. */ lifecycle?: string; + /** Upload XHR events and end state, and whether our own signal was aborted. */ + xhr?: string; + signalAborted?: boolean; }; const MAX_FRAMES = 6; @@ -212,15 +215,29 @@ export const describeComponentLifecycle = (life: ComponentLifecycle): string => return `component: ${since.length} renders since attach (${quick} within 300ms), ${unmount}`; }; -const collectors = new Set(); +export type XhrRecord = { + xhr: XMLHttpRequest; + method: string; + path: string; + startedAt: number; + events: string[]; +}; + +type Probe = { calls: AbortCall[]; xhrs: XhrRecord[] }; + +const probes = new Set(); let restoreProbe: (() => void) | undefined; +const xhrTargets = new WeakMap(); + +const XHR_EVENTS = ['loadstart', 'error', 'abort', 'timeout', 'load', 'loadend']; +const MAX_XHR_EVENTS = 12; /* eslint-disable no-param-reassign */ const patchAbort = (proto: { abort: (...a: any[]) => any }, api: string) => { const original = proto.abort; const wrapper = function abortProbe(this: unknown, ...args: any[]) { const frames = formatAbortStack(new Error().stack ?? ''); - collectors.forEach((calls) => { + probes.forEach(({ calls }) => { if (calls.length < MAX_ABORT_CALLS) calls.push({ api, frames }); }); return original.apply(this, args); @@ -230,30 +247,87 @@ const patchAbort = (proto: { abort: (...a: any[]) => any }, api: string) => { if (proto.abort === wrapper) proto.abort = original; }; }; + +// Upload XHRs only; the path is kept and the query (it holds the access token) is dropped. +const patchXhr = (proto: XMLHttpRequest) => { + const { open, send } = proto; + const openWrapper = function openProbe(this: XMLHttpRequest, ...args: any[]) { + try { + const url = new URL(String(args[1]), 'http://x'); + if (url.pathname.includes('/upload')) { + xhrTargets.set(this, { method: String(args[0]).toUpperCase(), path: url.pathname }); + } + } catch { + // not a URL we can read; the request is not recorded + } + return (open as any).apply(this, args); + }; + const sendWrapper = function sendProbe(this: XMLHttpRequest, ...args: any[]) { + const target = xhrTargets.get(this); + if (target && probes.size > 0) { + const startedAt = Date.now(); + const events: string[] = []; + const note = (name: string) => () => { + if (events.length < MAX_XHR_EVENTS) events.push(`${name} +${Date.now() - startedAt}ms`); + }; + XHR_EVENTS.forEach((ev) => this.addEventListener(ev, note(ev))); + XHR_EVENTS.forEach((ev) => this.upload?.addEventListener(ev, note(`upload.${ev}`))); + this.upload?.addEventListener( + 'progress', + (() => { + let first = true; + return () => { + if (first) note('upload.progress')(); + first = false; + }; + })() + ); + const record: XhrRecord = { xhr: this, ...target, startedAt, events }; + probes.forEach((probe) => probe.xhrs.push(record)); + } + return (send as any).apply(this, args); + }; + (proto as any).open = openWrapper; + (proto as any).send = sendWrapper; + return () => { + if ((proto as any).open === openWrapper) (proto as any).open = open; + if ((proto as any).send === sendWrapper) (proto as any).send = send; + }; +}; /* eslint-enable no-param-reassign */ +const describeXhr = (r: XhrRecord): string => + `${r.method} ${r.path} readyState ${r.xhr.readyState} status ${ + r.xhr.status + } events [${r.events.join(', ')}]`; + /** - * Records every XMLHttpRequest.abort and AbortController.abort the page makes until stopped. The - * prototypes are wrapped while at least one probe runs and restored when the last one stops. + * Records, until stopped, every XMLHttpRequest.abort and AbortController.abort the page makes and + * the lifecycle events and end state of upload XHRs. The prototypes are wrapped while at least one + * probe runs and restored when the last one stops; the wrappers change no behaviour. */ export const startAbortProbe = () => { - const calls: AbortCall[] = []; - if (collectors.size === 0) { + const probe: Probe = { calls: [], xhrs: [] }; + if (probes.size === 0) { const restores: (() => void)[] = []; if (typeof XMLHttpRequest !== 'undefined') { restores.push(patchAbort(XMLHttpRequest.prototype, 'XHR.abort')); + restores.push(patchXhr(XMLHttpRequest.prototype)); } if (typeof AbortController !== 'undefined') { restores.push(patchAbort(AbortController.prototype, 'AbortController.abort')); } restoreProbe = () => restores.forEach((r) => r()); } - collectors.add(calls); + probes.add(probe); return { - calls, + calls: probe.calls, + xhrs: probe.xhrs, + describeXhrs: () => + probe.xhrs.length === 0 ? 'xhr: none seen' : `xhr: ${probe.xhrs.map(describeXhr).join('; ')}`, stop: () => { - collectors.delete(calls); - if (collectors.size === 0) { + probes.delete(probe); + if (probes.size === 0) { restoreProbe?.(); restoreProbe = undefined; } @@ -312,6 +386,8 @@ export const uploadFailure = async ( : `abort calls: ${trace.aborts.map((a) => `${a.api} ${a.frames}`).join('; ')}` ); } + if (trace.xhr) parts.push(trace.xhr); + if (trace.signalAborted !== undefined) parts.push(`signal.aborted ${trace.signalAborted}`); if (trace.lifecycle) parts.push(trace.lifecycle); if (trace.previous) parts.push(`retried (attempt 1: ${trace.previous})`); } @@ -326,6 +402,13 @@ export const uploadFailure = async ( // here has shown that is the cause; the failure text records both attempts so it can be checked. const RETRY_WINDOW_MS = 3000; +const copyInMemory = async (file: TUploadContent): Promise => { + const bytes = await file.arrayBuffer(); + return typeof File !== 'undefined' && file instanceof File + ? new File([bytes], file.name, { type: file.type, lastModified: file.lastModified }) + : new Blob([bytes], { type: file.type }); +}; + export const uploadContent = async ( mx: MatrixClient, file: TUploadContent, @@ -344,11 +427,29 @@ export const uploadContent = async ( let previous: string | undefined; for (let attempt = 1; ; attempt += 1) { + // The browser may hold a picked file as a handle it cannot always re-read (e.g. content://), so + // each attempt uploads a copy held in memory; a file that cannot be read fails here, named. + let body: TUploadContent; + try { + // eslint-disable-next-line no-await-in-loop + body = await copyInMemory(file); + } catch (readErr: any) { + const errcode = readErr?.name || 'Error'; + onError( + new MatrixError({ + error: `Upload failed: file unreadable before upload (${errcode}) (${file.size} bytes, ${ + file.type || 'unknown type' + })`, + errcode, + }) + ); + return; + } const probe = startAbortProbe(); const trace: UploadTrace = { startedAt: Date.now(), previous, aborts: probe.calls }; const abortController = new AbortController(); try { - const uploadPromise = mx.uploadContent(file, { + const uploadPromise = mx.uploadContent(body, { name, type: fileType, includeFilename: !hideFilename, @@ -370,6 +471,8 @@ export const uploadContent = async ( return; } catch (e: any) { const elapsed = Date.now() - trace.startedAt; + trace.xhr = probe.describeXhrs(); + trace.signalAborted = abortController.signal.aborted; const retry = attempt === 1 && e?.name === 'AbortError' && @@ -386,7 +489,7 @@ export const uploadContent = async ( trace.aborts?.length ? `abort calls: ${trace.aborts.map((a) => `${a.api} ${a.frames}`).join('; ')}` : 'abort calls: none' - }`; + }, ${trace.xhr}`; } } finally { probe.stop(); diff --git a/src/app/utils/uploadXhrProbe.test.ts b/src/app/utils/uploadXhrProbe.test.ts new file mode 100644 index 00000000..e72afa3d --- /dev/null +++ b/src/app/utils/uploadXhrProbe.test.ts @@ -0,0 +1,163 @@ +/* eslint-disable class-methods-use-this, @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars, no-unused-vars */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { startAbortProbe, uploadContent } from './matrix'; + +class FakeXhr extends EventTarget { + upload = new EventTarget(); + + readyState = 0; + + status = 0; + + open(_method: string, _url: string) { + this.readyState = 1; + } + + send() { + this.readyState = 2; + } + + abort() {} +} + +const origOpen = FakeXhr.prototype.open; +const origSend = FakeXhr.prototype.send; +const uploadUrl = + 'https://matrix.example/_matrix/media/v3/upload?filename=a.jpg&access_token=SECRET'; + +describe('XHR probe', () => { + afterEach(() => { + vi.unstubAllGlobals(); + FakeXhr.prototype.open = origOpen; + FakeXhr.prototype.send = origSend; + }); + + it('records which events an upload XHR fired and how it ended, without the query string', () => { + vi.stubGlobal('XMLHttpRequest', FakeXhr); + const probe = startAbortProbe(); + const x = new FakeXhr(); + x.open('POST', uploadUrl); + x.send(); + x.upload.dispatchEvent(new Event('loadstart')); + x.upload.dispatchEvent(new Event('error')); + x.dispatchEvent(new Event('error')); + x.readyState = 4; + x.status = 0; + probe.stop(); + expect(probe.xhrs).toHaveLength(1); + const text = probe.describeXhrs(); + expect(text).toContain('POST /_matrix/media/v3/upload'); + expect(text).toContain('readyState 4 status 0'); + expect(text).toContain('upload.loadstart'); + expect(text).toContain('upload.error'); + expect(text).toContain('error'); + expect(text).not.toContain('SECRET'); + expect(text).not.toContain('access_token'); + }); + + it('ignores XHRs that are not uploads', () => { + vi.stubGlobal('XMLHttpRequest', FakeXhr); + const probe = startAbortProbe(); + const x = new FakeXhr(); + x.open('GET', 'https://matrix.example/_matrix/client/v3/sync'); + x.send(); + probe.stop(); + expect(probe.xhrs).toHaveLength(0); + expect(probe.describeXhrs()).toBe('xhr: none seen'); + }); + + it('still calls the real open and send and restores both afterwards', () => { + const open = vi.fn(); + const send = vi.fn(); + FakeXhr.prototype.open = open; + FakeXhr.prototype.send = send; + vi.stubGlobal('XMLHttpRequest', FakeXhr); + const probe = startAbortProbe(); + const x = new FakeXhr(); + x.open('POST', uploadUrl); + x.send(); + expect(open).toHaveBeenCalledWith('POST', uploadUrl); + expect(send).toHaveBeenCalledTimes(1); + probe.stop(); + expect(FakeXhr.prototype.open).toBe(open); + expect(FakeXhr.prototype.send).toBe(send); + }); + + it('records nothing for requests made after stop', () => { + vi.stubGlobal('XMLHttpRequest', FakeXhr); + const probe = startAbortProbe(); + probe.stop(); + const x = new FakeXhr(); + x.open('POST', uploadUrl); + x.send(); + expect(probe.xhrs).toHaveLength(0); + }); +}); + +describe('uploadContent in-memory copy and failure line', () => { + const abort = () => new DOMException('', 'AbortError'); + + it('uploads an in-memory copy with the same bytes, name and type, not the original', async () => { + const file = new File(['abcdef'], 'a.jpg', { type: 'image/jpeg' }); + let sent: any; + const mx = { + uploadContent: vi.fn(async (f: any) => { + sent = f; + return { content_uri: 'mxc://a/b' }; + }), + } as any; + await uploadContent(mx, file, { onSuccess: vi.fn(), onError: vi.fn() }); + expect(sent).not.toBe(file); + expect(sent.type).toBe('image/jpeg'); + expect(sent.name).toBe('a.jpg'); + expect(await sent.text()).toBe('abcdef'); + }); + + it('copies again before the retry', async () => { + const seen: any[] = []; + const mx = { + uploadContent: vi.fn((f: any) => { + seen.push(f); + return seen.length === 1 + ? Promise.reject(abort()) + : Promise.resolve({ content_uri: 'mxc://a/b' }); + }), + } as any; + await uploadContent(mx, new Blob(['xy']), { onSuccess: vi.fn(), onError: vi.fn() }); + expect(seen).toHaveLength(2); + expect(seen[0]).not.toBe(seen[1]); + }); + + it('reports an unreadable file and never starts the upload', async () => { + const bad = new Blob(['x']); + (bad as any).arrayBuffer = () => Promise.reject(new DOMException('gone', 'NotReadableError')); + const mx = { uploadContent: vi.fn() } as any; + const onError = vi.fn(); + await uploadContent(mx, bad, { onSuccess: vi.fn(), onError }); + expect(mx.uploadContent).not.toHaveBeenCalled(); + expect(onError.mock.calls[0][0].message).toContain( + 'unreadable before upload (NotReadableError)' + ); + }); + + it('puts the XHR end state and signal.aborted in the failure line', async () => { + vi.stubGlobal('XMLHttpRequest', FakeXhr); + const mx = { + uploadContent: () => { + const x = new FakeXhr(); + x.open('POST', uploadUrl); + x.send(); + x.upload.dispatchEvent(new Event('error')); + x.readyState = 4; + return Promise.reject(new Error('Timeout')); + }, + } as any; + const onError = vi.fn(); + await uploadContent(mx, new Blob(['x']), { onSuccess: vi.fn(), onError }); + const msg = onError.mock.calls[0][0].message; + expect(msg).toContain('readyState 4 status 0'); + expect(msg).toContain('upload.error'); + expect(msg).toContain('signal.aborted false'); + vi.unstubAllGlobals(); + }); +}); From f7725545a53166b0c8aacb9ea388e716f781cf49 Mon Sep 17 00:00:00 2001 From: Nibu Date: Sun, 8 Nov 2026 19:30:04 +0000 Subject: [PATCH 135/206] Move the terrain tier label to top-left under the preview banner It sat bottom right and overlapped the chat status line. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/terrain/tierLabel.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/features/scene/terrain/tierLabel.ts b/src/app/features/scene/terrain/tierLabel.ts index 6e3a9236..0b2c79ec 100644 --- a/src/app/features/scene/terrain/tierLabel.ts +++ b/src/app/features/scene/terrain/tierLabel.ts @@ -12,8 +12,8 @@ export function showTierLabel(canvas: HTMLCanvasElement, text: string): (() => v el.textContent = `terrain · ${text}`; Object.assign(el.style, { position: 'absolute', - right: '6px', - bottom: '6px', + left: '2px', + top: '16px', font: '10px monospace', color: 'rgba(255,255,255,.75)', background: 'rgba(0,0,0,.4)', From 20cc2c682d10d2d6f8e2a0c1316bd13c50437a47 Mon Sep 17 00:00:00 2001 From: Nibu Date: Sun, 8 Nov 2026 21:12:07 +0000 Subject: [PATCH 136/206] scene: far terrain fades into the sky's own horizon colour (night glow included) Haze was the Hillaire volume alone, ~40% opaque by 2 km and without the night glow the sky adds, so the far ground sat well off the sky at the horizon (night: sky 73,79,99 vs far ground 20,24,31). Past SKY_FADE the surface is now skyColour flat along the horizon in its ray's azimuth, in the CPU twin and the GLSL alike. Co-Authored-By: Claude Sonnet 5.5 --- .../features/scene/terrain/terrainShaders.ts | 7 ++- .../features/scene/terrain/terrainSky.test.ts | 43 +++++++++++++------ src/app/features/scene/terrain/terrainSky.ts | 15 ++++++- 3 files changed, 48 insertions(+), 17 deletions(-) diff --git a/src/app/features/scene/terrain/terrainShaders.ts b/src/app/features/scene/terrain/terrainShaders.ts index 12428d70..5fd01d7b 100644 --- a/src/app/features/scene/terrain/terrainShaders.ts +++ b/src/app/features/scene/terrain/terrainShaders.ts @@ -9,7 +9,7 @@ import phacelle from '../shaders/CultMath.Phacelle.glsl?raw'; import { trimGlsl } from '../cultmathTrim'; import { ALBEDO } from './terrainAlbedo'; import { AERIAL_GLSL, AP_DISTANCE_SCALE, SCATTER_GLSL } from './terrainScatter'; -import { GLOW_ZENITH, MOON_HAZE, SKY_EXPOSURE, STAR_FLOOR } from './terrainSky'; +import { GLOW_ZENITH, MOON_HAZE, SKY_EXPOSURE, SKY_FADE, STAR_FLOOR } from './terrainSky'; import { TERRAIN } from './terrainWorld'; const HEADER = @@ -203,7 +203,10 @@ void main() { vec3 haze = 1.0 - exp(-${lit(SKY_EXPOSURE)} * L) + uNight * ap.a * vec3(${STAR_FLOOR.map( lit ).join(', ')}); - o = vec4(pow(lit * (1.0 - ap.a) + haze, vec3(1.0 / 2.2)), 1.0); + // Past SKY_FADE the surface is the sky at the horizon in this ray's azimuth, the colour the sky pass draws there (terrainSky.aerialPerspective). + float toSky = smoothstep(${lit(SKY_FADE[0])}, ${lit(SKY_FADE[1])}, dist); + vec3 horizon = skyColour(vec3(ray.x, 0.0, ray.z)); + o = vec4(pow(mix(lit * (1.0 - ap.a) + haze, horizon, toSky), vec3(1.0 / 2.2)), 1.0); }`; // The sky behind: the baked scattering tables by the view ray's elevation and azimuth from the sun, a sun disc, and a diff --git a/src/app/features/scene/terrain/terrainSky.test.ts b/src/app/features/scene/terrain/terrainSky.test.ts index 5a8e0502..016ee1ce 100644 --- a/src/app/features/scene/terrain/terrainSky.test.ts +++ b/src/app/features/scene/terrain/terrainSky.test.ts @@ -15,6 +15,7 @@ import { skyNameFor, sunDirection, aerialPerspective, + SKY_FADE, metresToKm, groundLight, HAZE_K, @@ -195,7 +196,7 @@ describe('aerial perspective', () => { it('opacity grows with distance from nothing at the eye toward the far value, never past 1', () => { let prev = along(SKY.noonday, -2, 200, 0).alpha; expect(prev).toBe(0); - for (const m of [10, 100, 400, 1000, 2000, 2500]) { + for (const m of [10, 100, 400, 800, 1000, 1300]) { const a = along(SKY.noonday, -2, 200, m).alpha; expect(a).toBeGreaterThan(prev); expect(a).toBeLessThanOrEqual(1); @@ -205,34 +206,34 @@ describe('aerial perspective', () => { it('leaves the near ground its own colour and takes the far ridges', () => { expect(along(SKY.noonday, -2, 200, 100).alpha).toBeLessThan(0.15); - expect(along(SKY.noonday, -2, 200, 2000).alpha).toBeGreaterThan(0.3); + expect(along(SKY.noonday, -2, 200, SKY_FADE[0]).alpha).toBeGreaterThan(0.1); }); it('the in-scatter is a function of the baked volume: its colour is bluer than the sun far by day', () => { - const far = along(SKY.noonday, -2, 90, 2400).colour; + const far = along(SKY.noonday, -2, 90, SKY_FADE[0]).colour; expect(far[2]).toBeGreaterThan(far[0]); }); it('haze toward a setting sun is brighter than away from it, and red, not blue (the sun is reddened on its way)', () => { const dusk = skyAtSun(2, 270); - const toward = along(dusk, -1, 270, 2000).colour; - const away = along(dusk, -1, 90, 2000).colour; + const toward = along(dusk, -1, 270, SKY_FADE[0]).colour; + const away = along(dusk, -1, 90, SKY_FADE[0]).colour; expect(luma(toward)).toBeGreaterThan(3 * luma(away)); expect(toward[0]).toBeGreaterThan(toward[2]); }); it('night haze is a deep blue floor, not grey: dark, blue over red by a wide margin, brighter than black', () => { - const c = along(SKY.midnight, -2, 0, 2400).colour; - expect(luma(c)).toBeGreaterThan(0.001); + const c = along(SKY.midnight, -2, 0, SKY_FADE[0]).colour; + expect(luma(c)).toBeGreaterThan(0.0003); expect(luma(c)).toBeLessThan(0.08); expect(c[2]).toBeGreaterThan(2.5 * c[0]); - expect(luma(along(SKY.noonday, -2, 0, 2400).colour)).toBeGreaterThan(5 * luma(c)); + expect(luma(along(SKY.noonday, -2, 0, SKY_FADE[0]).colour)).toBeGreaterThan(5 * luma(c)); }); it('moonlight lights the night haze toward the moon more than away from it', () => { const moon = groundLight(SKY.midnight).dir; - const toward = aerialPerspective(SKY.midnight, [moon[0], -0.02, moon[2]], 2400).colour; - const away = aerialPerspective(SKY.midnight, [-moon[0], -0.02, -moon[2]], 2400).colour; + const toward = aerialPerspective(SKY.midnight, [moon[0], -0.02, moon[2]], SKY_FADE[0]).colour; + const away = aerialPerspective(SKY.midnight, [-moon[0], -0.02, -moon[2]], SKY_FADE[0]).colour; expect(luma(toward)).toBeGreaterThan(luma(away)); }); @@ -240,16 +241,32 @@ describe('aerial perspective', () => { const d = SKY.noonday; const view = ray(-4, 200); const dark = (x: number) => luma(shade(d, [0.02, 0.02, 0.02], UP, x, view)); - expect(dark(2000)).toBeGreaterThan(dark(100)); + expect(dark(SKY_FADE[0])).toBeGreaterThan(dark(100)); const own = (x: number) => 1 - aerialPerspective(d, view, x).alpha; - expect(own(2000)).toBeLessThan(0.7 * own(100)); + expect(own(SKY_FADE[0])).toBeLessThan(0.9 * own(100)); }); it('is continuous in the sun elevation through dusk: no step between neighbours', () => { - const at = (e: number) => luma(along(skyAtSun(e, 270), -1, 270, 2000).colour); + const at = (e: number) => luma(along(skyAtSun(e, 270), -1, 270, SKY_FADE[0]).colour); for (let e = -12; e < 12; e += 0.5) expect(Math.abs(at(e + 0.5) - at(e))).toBeLessThan(0.45); }); + it('far terrain is the sky: past the fade it is the horizon sky colour in its azimuth, by day, dusk and night, whatever it was', () => { + for (const [d, az] of [ + [SKY.midnight, 0], + [skyAtSun(-1, 90), 90], + [skyAtSun(2, 270), 200], + [SKY.noonday, 120], + ] as const) { + const view = ray(-0.5, az); + const hz = skyColour(d, [view[0], 0, view[2]]); + const far = aerialPerspective(d, view, SKY_FADE[1]); + expect(far.alpha).toBe(1); + for (let i = 0; i < 3; i += 1) expect(far.colour[i]).toBeCloseTo(hz[i], 9); + expect(shade(d, [0.5, 0.5, 0.5], UP, SKY_FADE[1] + 500, view)).toEqual(far.colour); + } + }); + it('the metres-to-km law is the scene metres times the baked distance scale', () => { expect(metresToKm(1000)).toBeCloseTo(AP_DISTANCE_SCALE, 9); expect(metresToKm(-5)).toBe(0); diff --git a/src/app/features/scene/terrain/terrainSky.ts b/src/app/features/scene/terrain/terrainSky.ts index 2efef429..4034fd93 100644 --- a/src/app/features/scene/terrain/terrainSky.ts +++ b/src/app/features/scene/terrain/terrainSky.ts @@ -149,6 +149,13 @@ export const MOON_HAZE: Vec3 = [0.0004, 0.0007, 0.0018]; /** Starlight and airglow scattered by the air, added to the in-scatter in proportion to alpha, at full night: a deep blue floor, not grey. EYEBALLED. */ export const STAR_FLOOR: Vec3 = [0.0006, 0.0012, 0.0045]; +/** + * Metres from the eye over which the terrain gives itself up to the sky: past the far end a surface is the sky's own + * colour at the horizon in its azimuth (`skyColour` flat along the horizon, night glow included), so the patch's rim + * meets the sky above and beyond it with no seam. The volume alone is only ~40% opaque by then. EYEBALLED. + */ +export const SKY_FADE: readonly [number, number] = [600, 1350]; + /** Haze in front of a surface seen along `dir` (unit, eye to surface) `distance` metres away: the colour it adds and the surface's loss. */ export function aerialPerspective( d: SkyData, @@ -172,10 +179,14 @@ export function aerialPerspective( const moon = aerialAt(Math.abs(d.elevation), el, az, km); for (let i = 0; i < 3; i += 1) L[i] += MOON_HAZE[i] * night * moon.L[i]; } + const horizon = skyColour(d, [dir[0], 0, dir[2]]); + const f = smooth(SKY_FADE[0], SKY_FADE[1], distance); const colour = [0, 1, 2].map( - (i) => 1 - Math.exp(-SKY_EXPOSURE * L[i]) + STAR_FLOOR[i] * night * day.alpha + (i) => + (1 - f) * (1 - Math.exp(-SKY_EXPOSURE * L[i]) + STAR_FLOOR[i] * night * day.alpha) + + f * horizon[i] ) as unknown as Vec3; - return { colour, alpha: day.alpha }; + return { colour, alpha: (1 - f) * day.alpha + f }; } /** Added to the ambient on a dark sky, scaled by `nightness`: the baked night floor is too dark to show any form on a phone. */ From 8ac43bd73f10f1f9fb2d615db42b16c55cea7a70 Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 9 Nov 2026 10:05:02 +0000 Subject: [PATCH 137/206] Name pill shows the full name on phones, wraps when long The pill was capped at a percentage of the bubble column and clipped with an ellipsis; a short bubble's column is narrower than its own name, so names read as "E..." and "V...". It is now max-content up to a viewport-based cap and wraps. Demo fixtures gain a long display name to keep this honest. Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/message/layout/layout.css.ts | 13 +++++++------ src/preview-demo/fixtures.ts | 3 +++ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index 6c7b97f3..f5890c1e 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -209,12 +209,13 @@ export const BubbleHeaderPose = style({ padding: `0 ${toRem(13)}`, background: color.Secondary.Container, border: `1px solid ${color.Secondary.ContainerLine}`, - borderRadius: toRem(999), - whiteSpace: 'nowrap', - // Never wider than the bubble minus the portrait, so it cannot reach the portrait's face. - maxWidth: `calc(100% - ${PHONE_PORTRAIT} - ${toRem(30)})`, - overflow: 'hidden', - textOverflow: 'ellipsis', + // The pill is as wide as the full name needs, up to the room beside the portrait, then the + // name wraps. It is capped by the viewport, not by the column: a short bubble's column is + // narrower than its own name, and a percentage cap there truncated names to one letter. + width: 'max-content', + maxWidth: `calc(100vw - ${PHONE_PORTRAIT} - ${toRem(64)})`, + overflowWrap: 'anywhere', + borderRadius: toRem(14), }, }, }); diff --git a/src/preview-demo/fixtures.ts b/src/preview-demo/fixtures.ts index 32d3c7a6..6d03ec4c 100644 --- a/src/preview-demo/fixtures.ts +++ b/src/preview-demo/fixtures.ts @@ -23,6 +23,7 @@ export const USERS: DemoUser[] = [ { id: `@void:${DEMO_SERVER}`, name: 'Void' }, { id: `@nibu:${DEMO_SERVER}`, name: 'Nibu' }, { id: `@visitor:${DEMO_SERVER}`, name: 'Visitor' }, + { id: `@watch:${DEMO_SERVER}`, name: 'Heimdall of the Long Watch, Keeper of the Bridge' }, ]; const user = (localpart: string) => `@${localpart}:${DEMO_SERVER}`; @@ -110,6 +111,8 @@ export const ROOMS: DemoRoom[] = [ pose: 'deadpan', minutesAgo: 25, }, + // A long display name: the name pill has to show all of it on a phone. + { from: user('watch'), body: 'Ok.', pose: 'reading', minutesAgo: 20 }, ], }, { From b091f65cea36fbfc29ded405199bcd8d33dc0ca2 Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 9 Nov 2026 10:08:18 +0000 Subject: [PATCH 138/206] Name inside the pose pill wraps instead of ellipsising Username carries its own nowrap + ellipsis, which still clipped long names ("Heimdall o...") inside the widened pill. On phones the name now wraps. Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/message/layout/layout.css.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index f5890c1e..e87627bd 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -397,6 +397,19 @@ globalStyle(`${BubbleContentPose} > div`, { }, }); +// The name inside the pill wraps instead of ellipsising: the pill already caps its own width, and +// Username's own nowrap + ellipsis clipped long names to "Heimdall o…" inside it. +globalStyle(`${BubbleHeaderPose} ${Username}`, { + '@media': { + [PHONE]: { + whiteSpace: 'normal', + overflow: 'visible', + textOverflow: 'clip', + overflowWrap: 'anywhere', + }, + }, +}); + // An outside list marker hangs left of its item's text and ignores the portrait's silhouette, so // beside a left portrait the number lands under the art. Inside, it is a word on the first line // and wraps around the silhouette like the rest. From ea33ab6daa004f3d4d34b30226ad85772f809c4a Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 9 Nov 2026 10:10:31 +0000 Subject: [PATCH 139/206] Pose pill: the Text inside the name button wraps too The name button wraps a truncate Text span; overriding only the button left the span nowrap, so the long name overflowed the pill. Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/message/layout/layout.css.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index e87627bd..0afb68bf 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -398,8 +398,9 @@ globalStyle(`${BubbleContentPose} > div`, { }); // The name inside the pill wraps instead of ellipsising: the pill already caps its own width, and -// Username's own nowrap + ellipsis clipped long names to "Heimdall o…" inside it. -globalStyle(`${BubbleHeaderPose} ${Username}`, { +// Username's own nowrap + ellipsis, and the truncate on the Text inside it, clipped long names to +// "Heimdall o…" inside it. +globalStyle(`${BubbleHeaderPose} ${Username}, ${BubbleHeaderPose} ${Username} *`, { '@media': { [PHONE]: { whiteSpace: 'normal', From 34bdccd87c7a6cf64aa8eed2fa360e5527ff1edf Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 9 Nov 2026 10:21:07 +0000 Subject: [PATCH 140/206] Demo fixture: the long-named user is a known Mind's localpart The pose-label test resolves fixture senders to known Minds; "@watch" was not one, so server.test.ts failed on the long-name fixture. Same long display name, now @heimdall. Co-Authored-By: Claude Sonnet 5.5 --- src/preview-demo/fixtures.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/preview-demo/fixtures.ts b/src/preview-demo/fixtures.ts index 6d03ec4c..4ef4f0bc 100644 --- a/src/preview-demo/fixtures.ts +++ b/src/preview-demo/fixtures.ts @@ -23,7 +23,7 @@ export const USERS: DemoUser[] = [ { id: `@void:${DEMO_SERVER}`, name: 'Void' }, { id: `@nibu:${DEMO_SERVER}`, name: 'Nibu' }, { id: `@visitor:${DEMO_SERVER}`, name: 'Visitor' }, - { id: `@watch:${DEMO_SERVER}`, name: 'Heimdall of the Long Watch, Keeper of the Bridge' }, + { id: `@heimdall:${DEMO_SERVER}`, name: 'Heimdall of the Long Watch, Keeper of the Bridge' }, ]; const user = (localpart: string) => `@${localpart}:${DEMO_SERVER}`; @@ -112,7 +112,7 @@ export const ROOMS: DemoRoom[] = [ minutesAgo: 25, }, // A long display name: the name pill has to show all of it on a phone. - { from: user('watch'), body: 'Ok.', pose: 'reading', minutesAgo: 20 }, + { from: user('heimdall'), body: 'Ok.', pose: 'reading', minutesAgo: 20 }, ], }, { From 8ca38c4980881ce7ebf51ae47374cb810191d1bf Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 9 Nov 2026 10:21:27 +0000 Subject: [PATCH 141/206] Demo fixture: long-named Heimdall uses a pose Heimdall's art has Co-Authored-By: Claude Sonnet 5.5 --- src/preview-demo/fixtures.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/preview-demo/fixtures.ts b/src/preview-demo/fixtures.ts index 4ef4f0bc..c96a4ccf 100644 --- a/src/preview-demo/fixtures.ts +++ b/src/preview-demo/fixtures.ts @@ -112,7 +112,7 @@ export const ROOMS: DemoRoom[] = [ minutesAgo: 25, }, // A long display name: the name pill has to show all of it on a phone. - { from: user('heimdall'), body: 'Ok.', pose: 'reading', minutesAgo: 20 }, + { from: user('heimdall'), body: 'Ok.', pose: 'thinking', minutesAgo: 20 }, ], }, { From 245eb90f63048bf68f1c91e0cefc272cbf71df9c Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 9 Nov 2026 10:27:42 +0000 Subject: [PATCH 142/206] Pose pill: a long name takes its own lines, the time drops below At 320px the name shrank to a 37px column beside the time and broke mid-word. The pill now wraps its children and breaks only where a single word is too long. Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/message/layout/layout.css.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index 0afb68bf..ad5d1c38 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -214,7 +214,9 @@ export const BubbleHeaderPose = style({ // narrower than its own name, and a percentage cap there truncated names to one letter. width: 'max-content', maxWidth: `calc(100vw - ${PHONE_PORTRAIT} - ${toRem(64)})`, - overflowWrap: 'anywhere', + // A name too long for one line takes its own lines and the time drops below it, instead of + // the name shrinking to a sliver beside the time. + flexWrap: 'wrap', borderRadius: toRem(14), }, }, @@ -406,7 +408,7 @@ globalStyle(`${BubbleHeaderPose} ${Username}, ${BubbleHeaderPose} ${Username} *` whiteSpace: 'normal', overflow: 'visible', textOverflow: 'clip', - overflowWrap: 'anywhere', + overflowWrap: 'break-word', }, }, }); From 14312459f27e05dec5539535e6b22d06c4bfa036 Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 9 Nov 2026 10:29:22 +0000 Subject: [PATCH 143/206] Pose pill: wrap the header row, not the pill The pill's child is the flex row holding name and time; flex-wrap on the pill itself did nothing. Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/message/layout/layout.css.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index ad5d1c38..1603fc43 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -214,9 +214,6 @@ export const BubbleHeaderPose = style({ // narrower than its own name, and a percentage cap there truncated names to one letter. width: 'max-content', maxWidth: `calc(100vw - ${PHONE_PORTRAIT} - ${toRem(64)})`, - // A name too long for one line takes its own lines and the time drops below it, instead of - // the name shrinking to a sliver beside the time. - flexWrap: 'wrap', borderRadius: toRem(14), }, }, @@ -413,6 +410,16 @@ globalStyle(`${BubbleHeaderPose} ${Username}, ${BubbleHeaderPose} ${Username} *` }, }); +// A name too long for one line takes its own lines and the time drops below it, instead of the +// name shrinking to a sliver beside the time (the pill's one child is the flex row of both). +globalStyle(`${BubbleHeaderPose} > div`, { + '@media': { + [PHONE]: { + flexWrap: 'wrap', + }, + }, +}); + // An outside list marker hangs left of its item's text and ignores the portrait's silhouette, so // beside a left portrait the number lands under the art. Inside, it is a word on the first line // and wraps around the silhouette like the rest. From 6110292e3bcf877bb0d4d263e809776b0ff4e2d3 Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 9 Nov 2026 10:50:24 +0000 Subject: [PATCH 144/206] Pose pill grows upward when its name wraps, clear of the bubble text Anchored by its bottom edge 13px inside the bubble instead of its top edge 15px above it, so a wrapped name and time never cover the first line of text. Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/message/layout/layout.css.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index 1603fc43..731fa0f5 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -204,7 +204,9 @@ export const BubbleHeaderPose = style({ [PHONE]: { position: 'absolute', zIndex: 2, - top: toRem(-15), + // Anchored by its bottom edge, 13px inside the bubble: a name that wraps grows upward, out of + // the bubble, instead of down over the first line of text. + bottom: `calc(100% - ${toRem(13)})`, right: toRem(14), padding: `0 ${toRem(13)}`, background: color.Secondary.Container, From a23dd239ba4145fe1fb0fa24295b1eae2dc1f8a0 Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 9 Nov 2026 10:50:24 +0000 Subject: [PATCH 145/206] Room list: steady scrim behind rows over the scene, icon on unjoined rows With the background scene on, nav rows were fully clear and the ridges showed through their text. Rows now carry the theme's own surface at 62% (80% on hover and selected). An unjoined row (Commons) gets the same leading room icon as a joined one. Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/nav/styles.css.ts | 11 +++++++++++ src/app/features/estate/EstateNavigator.tsx | 13 +++++++++---- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/app/components/nav/styles.css.ts b/src/app/components/nav/styles.css.ts index 48b2a4d3..a7f3053d 100644 --- a/src/app/components/nav/styles.css.ts +++ b/src/app/components/nav/styles.css.ts @@ -1,6 +1,7 @@ import { ComplexStyleRule, createVar, style } from '@vanilla-extract/css'; import { RecipeVariants, recipe } from '@vanilla-extract/recipes'; import { ContainerColor, DefaultReset, Disabled, RadiiVariant, color, config, toRem } from 'folds'; +import { surfaceInk } from '../../features/scene/sceneShell.css'; export const NavCategory = style([ DefaultReset, @@ -61,6 +62,16 @@ const NavItemBase = style({ '&[data-hover=true]': { backgroundColor: ContainerHover, }, + // While the background scene runs the shell is clear (sceneShell.css.ts); a row is a steady + // scrim of the theme's own surface, so ridges never show through its text. Hover and the + // selected row strengthen it. + ':root[data-scene] &': { + backgroundColor: `color-mix(in srgb, ${surfaceInk} 62%, transparent)`, + }, + ':root[data-scene] &:hover, :root[data-scene] &:focus-visible, :root[data-scene] &[data-hover=true], :root[data-scene] &[aria-selected=true]': + { + backgroundColor: `color-mix(in srgb, ${surfaceInk} 80%, transparent)`, + }, [`&:has(.${NavLink}:active)`]: { backgroundColor: ContainerActive, }, diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx index 6fa4bf48..29451c92 100644 --- a/src/app/features/estate/EstateNavigator.tsx +++ b/src/app/features/estate/EstateNavigator.tsx @@ -1,7 +1,7 @@ import React, { MouseEventHandler, ReactNode, useCallback, useMemo, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { useAtom, useAtomValue, useSetAtom } from 'jotai'; -import { Avatar, Box, Chip, Icon, IconButton, Icons, Spinner, Text, color } from 'folds'; +import { Avatar, Box, Chip, Icon, IconButton, Icons, Spinner, Text, color, config } from 'folds'; import { PageNav, PageNavContent, PageNavHeader } from '../../components/page'; import { NavButton, @@ -76,9 +76,14 @@ function JoinRow({ room }: { room: EstateRoom }) { - - {room.name} - + + + + + + {room.name} + + {state.status === AsyncStatus.Error && ( Date: Mon, 9 Nov 2026 10:51:46 +0000 Subject: [PATCH 146/206] Room list: category labels get the same scene scrim as the rows Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/nav/styles.css.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/app/components/nav/styles.css.ts b/src/app/components/nav/styles.css.ts index a7f3053d..70344c04 100644 --- a/src/app/components/nav/styles.css.ts +++ b/src/app/components/nav/styles.css.ts @@ -12,6 +12,13 @@ export const NavCategory = style([ export const NavCategoryHeader = style({ gap: config.space.S100, + selectors: { + // Category labels sit on the scene like the rows do, and need the same scrim to be read. + ':root[data-scene] &': { + backgroundColor: `color-mix(in srgb, ${surfaceInk} 62%, transparent)`, + borderRadius: config.radii.R400, + }, + }, }); export const NavLink = style({ From c7d6c388d32af353c319502b6605eabf9c619874 Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 9 Nov 2026 10:59:08 +0000 Subject: [PATCH 147/206] settings: do not read window at import when there is none dev's settings default reads window.matchMedia at module load (composer-spellcheck merge); soundscape.test.ts imports settings in a node environment, so "ships off, muted" threw ReferenceError on dev itself. No window means no coarse-pointer query. Co-Authored-By: Claude Sonnet 5.5 --- src/app/state/settings.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 297ab1e8..2b48fb59 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -73,7 +73,9 @@ const defaultSettings: Settings = { isPeopleDrawer: false, memberSortFilterIndex: 0, enterForNewline: false, - composerSpellcheck: defaultComposerSpellcheck(window.matchMedia?.bind(window)), + composerSpellcheck: defaultComposerSpellcheck( + typeof window === 'undefined' ? undefined : window.matchMedia?.bind(window) + ), messageLayout: 2, messageSpacing: '400', hideMembershipEvents: false, From cfb8a8641aea71c36bcaa55eb915e79563ee2401 Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 9 Nov 2026 11:06:24 +0000 Subject: [PATCH 148/206] Room list scrim: hover, selected and pressed rows are three distinct steps Soul: selected and hover shared one 80% value and the press state lost to hover on specificity. Now hover 74%, selected 84%, pressed 92%. Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/nav/styles.css.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/app/components/nav/styles.css.ts b/src/app/components/nav/styles.css.ts index 70344c04..927fee0f 100644 --- a/src/app/components/nav/styles.css.ts +++ b/src/app/components/nav/styles.css.ts @@ -70,15 +70,21 @@ const NavItemBase = style({ backgroundColor: ContainerHover, }, // While the background scene runs the shell is clear (sceneShell.css.ts); a row is a steady - // scrim of the theme's own surface, so ridges never show through its text. Hover and the - // selected row strengthen it. + // scrim of the theme's own surface, so ridges never show through its text. Hover, the selected row and a + // press each strengthen it a step. ':root[data-scene] &': { backgroundColor: `color-mix(in srgb, ${surfaceInk} 62%, transparent)`, }, - ':root[data-scene] &:hover, :root[data-scene] &:focus-visible, :root[data-scene] &[data-hover=true], :root[data-scene] &[aria-selected=true]': + ':root[data-scene] &:hover, :root[data-scene] &:focus-visible, :root[data-scene] &[data-hover=true]': { - backgroundColor: `color-mix(in srgb, ${surfaceInk} 80%, transparent)`, + backgroundColor: `color-mix(in srgb, ${surfaceInk} 74%, transparent)`, }, + ':root[data-scene] &[aria-selected=true]': { + backgroundColor: `color-mix(in srgb, ${surfaceInk} 84%, transparent)`, + }, + [`:root[data-scene] &:has(.${NavLink}:active)`]: { + backgroundColor: `color-mix(in srgb, ${surfaceInk} 92%, transparent)`, + }, [`&:has(.${NavLink}:active)`]: { backgroundColor: ContainerActive, }, From 9794714f9760d8ffd7925e5f5ec04803a13cab52 Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 9 Nov 2026 14:57:08 +0000 Subject: [PATCH 149/206] Room list wash and name line: undo the solid slabs and the boxed pill (#39) Co-Authored-By: Claude Sonnet 5.5 --- .../components/message/layout/layout.css.ts | 51 +++++++++---------- src/app/components/nav/styles.css.ts | 26 ++++++---- 2 files changed, 40 insertions(+), 37 deletions(-) diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index 731fa0f5..815c579f 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -198,25 +198,19 @@ export const BubbleHeaderOwn = style({ alignSelf: 'flex-end', }); -// Phone: the name is a pill straddling the bubble's top edge, on the side opposite the portrait. +// Phone: name and time are one plain line in flow above the bubble, on the side opposite the +// portrait. It is as wide as the full name needs, up to the room beside the portrait; only a name +// longer than that wraps (and then the time drops below it). It is capped by the viewport, not by +// the column: a short bubble's column is narrower than its own name, and a percentage cap there +// truncated names to one letter. export const BubbleHeaderPose = style({ '@media': { [PHONE]: { - position: 'absolute', - zIndex: 2, - // Anchored by its bottom edge, 13px inside the bubble: a name that wraps grows upward, out of - // the bubble, instead of down over the first line of text. - bottom: `calc(100% - ${toRem(13)})`, - right: toRem(14), - padding: `0 ${toRem(13)}`, - background: color.Secondary.Container, - border: `1px solid ${color.Secondary.ContainerLine}`, - // The pill is as wide as the full name needs, up to the room beside the portrait, then the - // name wraps. It is capped by the viewport, not by the column: a short bubble's column is - // narrower than its own name, and a percentage cap there truncated names to one letter. + alignSelf: 'flex-end', + margin: `0 ${toRem(14)} ${toRem(2)}`, width: 'max-content', maxWidth: `calc(100vw - ${PHONE_PORTRAIT} - ${toRem(64)})`, - borderRadius: toRem(14), + textAlign: 'right', }, }, }); @@ -224,8 +218,8 @@ export const BubbleHeaderPose = style({ export const BubbleHeaderPoseAlt = style({ '@media': { [PHONE]: { - right: 'auto', - left: toRem(14), + alignSelf: 'flex-start', + textAlign: 'left', }, }, }); @@ -398,9 +392,8 @@ globalStyle(`${BubbleContentPose} > div`, { }, }); -// The name inside the pill wraps instead of ellipsising: the pill already caps its own width, and -// Username's own nowrap + ellipsis, and the truncate on the Text inside it, clipped long names to -// "Heimdall o…" inside it. +// The name wraps instead of ellipsising: the header already caps its own width, and Username's own +// nowrap + ellipsis, and the truncate on the Text inside it, clipped long names to "Heimdall o…". globalStyle(`${BubbleHeaderPose} ${Username}, ${BubbleHeaderPose} ${Username} *`, { '@media': { [PHONE]: { @@ -413,11 +406,21 @@ globalStyle(`${BubbleHeaderPose} ${Username}, ${BubbleHeaderPose} ${Username} *` }); // A name too long for one line takes its own lines and the time drops below it, instead of the -// name shrinking to a sliver beside the time (the pill's one child is the flex row of both). +// name shrinking to a sliver beside the time (the header's one child is the flex row of both). The +// row hugs the side the header sits on, so a dropped time stays under the name's edge. globalStyle(`${BubbleHeaderPose} > div`, { '@media': { [PHONE]: { flexWrap: 'wrap', + justifyContent: 'flex-end', + }, + }, +}); + +globalStyle(`${BubbleHeaderPoseAlt} > div`, { + '@media': { + [PHONE]: { + justifyContent: 'flex-start', }, }, }); @@ -446,14 +449,6 @@ globalStyle(`${scene} ${BubbleContent}`, { globalStyle(`${scene} ${BubbleContentOwn}`, { backgroundColor: [color.Primary.Container, half(color.Primary.Container)], }); -globalStyle(`${scene} ${BubbleHeaderPose}`, { - '@media': { - [PHONE]: { - backgroundColor: [color.Secondary.Container, half(color.Secondary.Container)], - backdropFilter: 'blur(12px)', - }, - }, -}); globalStyle(`${scene} ${BubbleLeftArrow}`, { display: 'none', }); diff --git a/src/app/components/nav/styles.css.ts b/src/app/components/nav/styles.css.ts index 927fee0f..b123f9bc 100644 --- a/src/app/components/nav/styles.css.ts +++ b/src/app/components/nav/styles.css.ts @@ -3,6 +3,14 @@ import { RecipeVariants, recipe } from '@vanilla-extract/recipes'; import { ContainerColor, DefaultReset, Disabled, RadiiVariant, color, config, toRem } from 'folds'; import { surfaceInk } from '../../features/scene/sceneShell.css'; +// Wash strengths over the scene: rows and labels are faint, and hover, selected and pressed rows +// are three distinct steps above them. +const SCRIM_LABEL = '12%'; +const SCRIM_ROW = '16%'; +const SCRIM_HOVER = '26%'; +const SCRIM_SELECTED = '36%'; +const SCRIM_PRESSED = '48%'; + export const NavCategory = style([ DefaultReset, { @@ -13,9 +21,9 @@ export const NavCategory = style([ export const NavCategoryHeader = style({ gap: config.space.S100, selectors: { - // Category labels sit on the scene like the rows do, and need the same scrim to be read. + // Category labels sit on the scene like the rows do, and need a wash to be read. ':root[data-scene] &': { - backgroundColor: `color-mix(in srgb, ${surfaceInk} 62%, transparent)`, + backgroundColor: `color-mix(in srgb, ${surfaceInk} ${SCRIM_LABEL}, transparent)`, borderRadius: config.radii.R400, }, }, @@ -69,21 +77,21 @@ const NavItemBase = style({ '&[data-hover=true]': { backgroundColor: ContainerHover, }, - // While the background scene runs the shell is clear (sceneShell.css.ts); a row is a steady - // scrim of the theme's own surface, so ridges never show through its text. Hover, the selected row and a - // press each strengthen it a step. + // While the background scene runs the shell is clear (sceneShell.css.ts); a row is a faint + // wash of the theme's own surface: the terrain stays visible, the text stays readable. Hover, + // the selected row and a press each strengthen it a step. ':root[data-scene] &': { - backgroundColor: `color-mix(in srgb, ${surfaceInk} 62%, transparent)`, + backgroundColor: `color-mix(in srgb, ${surfaceInk} ${SCRIM_ROW}, transparent)`, }, ':root[data-scene] &:hover, :root[data-scene] &:focus-visible, :root[data-scene] &[data-hover=true]': { - backgroundColor: `color-mix(in srgb, ${surfaceInk} 74%, transparent)`, + backgroundColor: `color-mix(in srgb, ${surfaceInk} ${SCRIM_HOVER}, transparent)`, }, ':root[data-scene] &[aria-selected=true]': { - backgroundColor: `color-mix(in srgb, ${surfaceInk} 84%, transparent)`, + backgroundColor: `color-mix(in srgb, ${surfaceInk} ${SCRIM_SELECTED}, transparent)`, }, [`:root[data-scene] &:has(.${NavLink}:active)`]: { - backgroundColor: `color-mix(in srgb, ${surfaceInk} 92%, transparent)`, + backgroundColor: `color-mix(in srgb, ${surfaceInk} ${SCRIM_PRESSED}, transparent)`, }, [`&:has(.${NavLink}:active)`]: { backgroundColor: ContainerActive, From 9b4d92c9de81638f31cc79660c150f9f100099a4 Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 9 Nov 2026 15:19:14 +0000 Subject: [PATCH 150/206] scene: terrain mesh as fine as the height map it is shaded from, cavity read bilinear MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phone shots of 'terrain · low' showed hard-edged facets on the near ground and stepped crests. Cause: the grid (192 quads low, 256 full, 7 m wide at the nearest ground) was coarser than the 2.9 m height texels the fragment shader reads its normals from, so gullies the polygons could not carry showed as hard shading edges. Measured by rendering the low tier at 192..384 quads against a 768-quad reference: mean abs error 1.7 / 1.1 / 0.7 / 0.5 on the day frame at 192 / 256 / 320 / 384; the warp's shape barely moved it. - GRID: still 192, low 320, full 384; a test holds every tier to a quad of at most two texels at the nearest ground the camera sees, and the tiers ordered by cost. The low triangle ceiling goes from 80k to 210k (205k used), unmeasured on a phone. - cavity: the shader read it from the nearest texel (blocks of one texel); it is now bilinear (hBil) at a continuous position, with a CPU twin (bilinearHeight, curvatureAt, cavityAt, CAVITY) and a test that shading is continuous across texel borders where the nearest-texel read jumps. Co-Authored-By: Claude Sonnet 5.5 --- .../scene/terrain/terrainAlbedo.test.ts | 50 +++++++++++++++++++ .../features/scene/terrain/terrainAlbedo.ts | 37 ++++++++++++++ .../features/scene/terrain/terrainShaders.ts | 16 +++--- .../scene/terrain/terrainWorld.test.ts | 31 +++++++++++- .../features/scene/terrain/terrainWorld.ts | 12 +++-- 5 files changed, 135 insertions(+), 11 deletions(-) diff --git a/src/app/features/scene/terrain/terrainAlbedo.test.ts b/src/app/features/scene/terrain/terrainAlbedo.test.ts index 2314f758..17363282 100644 --- a/src/app/features/scene/terrain/terrainAlbedo.test.ts +++ b/src/app/features/scene/terrain/terrainAlbedo.test.ts @@ -5,6 +5,10 @@ import { ALBEDO, albedoAt, albedoPalette, + bilinearHeight, + CAVITY, + cavityAt, + curvatureAt, grainAt, triWeights, wobble, @@ -172,3 +176,49 @@ describe('triplanar projection', () => { expect(wobbleAt(12, 10, 30, UP)).toBeCloseTo(wobble(12, 30), 12); }); }); + +describe('cavity shading', () => { + // A texel grid of heights with one bump; `hAt` is the nearest texel, `bilinearAt` the continuous reading. + const bump = (i: number, j: number) => 6 * Math.exp(-((i - 20) ** 2 + (j - 20) ** 2) / 40); + const nearestAt = (px: number, pz: number) => bump(Math.floor(px + 0.5), Math.floor(pz + 0.5)); + // Largest change in curvature between neighbouring sample positions a hundredth of a texel apart, over a few texels. + const worstStep = (read: (px: number, pz: number) => number) => { + let worst = 0; + for (let px = 14; px < 26; px += 0.01) + worst = Math.max( + worst, + Math.abs(curvatureAt(read, px + 0.01, 20.3, 4) - curvatureAt(read, px, 20.3, 4)) + ); + return worst; + }; + + it('curvature is the mean of four neighbours at the radius minus the centre', () => { + expect(curvatureAt((px, pz) => px * 2 + pz, 7, 9, 3)).toBeCloseTo(0, 12); + expect(curvatureAt((px, pz) => (px - 5) ** 2 + (pz - 5) ** 2, 5, 5, 2)).toBeCloseTo(4, 12); + }); + + it('bilinear height reads the texel exactly at a texel centre and the mean at the middle of four', () => { + const t = (i: number, j: number) => i * 10 + j * 100; + expect(bilinearHeight(t, 3, 4)).toBe(430); + expect(bilinearHeight(t, 3.5, 4.5)).toBeCloseTo(485, 9); + }); + + it('shading from a bilinear read changes smoothly across texel borders; from the nearest texel it jumps', () => { + const smooth = worstStep((px, pz) => bilinearHeight(bump, px, pz)); + const blocky = worstStep(nearestAt); + expect(blocky).toBeGreaterThan(0.1); + expect(smooth).toBeLessThan(0.02); + }); + + it('the cavity factor darkens a hollow, lights a rise and leaves flat ground alone', () => { + expect(cavityAt(0)).toBe(1); + expect(cavityAt(CAVITY.range)).toBeCloseTo(1 - CAVITY.dark, 12); + expect(cavityAt(-CAVITY.range)).toBeCloseTo(1 + CAVITY.light, 12); + expect(cavityAt(10 * CAVITY.range)).toBeCloseTo(1 - CAVITY.dark, 12); + }); + + it('the grid shader reads the cavity through the bilinear height, the same constants as CAVITY', () => { + expect(GRID_FRAG).toContain(`#define CAV_RANGE ${CAVITY.range.toFixed(1)}`); + expect(GRID_FRAG).not.toMatch(/curv[^;]*hAt\(/); + }); +}); diff --git a/src/app/features/scene/terrain/terrainAlbedo.ts b/src/app/features/scene/terrain/terrainAlbedo.ts index c9f11ab4..fef204ff 100644 --- a/src/app/features/scene/terrain/terrainAlbedo.ts +++ b/src/app/features/scene/terrain/terrainAlbedo.ts @@ -158,3 +158,40 @@ export function grainAt( 1 + ALBEDO.grainCoarse * coarse + ALBEDO.grainFine * fine + ALBEDO.strata * rock * layers ); } + +/** The cavity term's numbers: curvature (metres) at which a hollow is fully shaded / a rise fully lit, and by how much. */ +export const CAVITY = { radius: 12, range: 12, dark: 0.55, light: 0.3 } as const; + +/** Height at a continuous texel position, bilinear over the four texels around it (the shader's `hBil`). */ +export function bilinearHeight( + texel: (i: number, j: number) => number, + px: number, + pz: number +): number { + const i = Math.floor(px); + const j = Math.floor(pz); + const fx = px - i; + const fz = pz - j; + return mix( + mix(texel(i, j), texel(i + 1, j), fx), + mix(texel(i, j + 1), texel(i + 1, j + 1), fx), + fz + ); +} + +/** Mean of the four neighbours `r` texels away minus the centre: positive in a hollow, negative on a rise. */ +export function curvatureAt( + height: (px: number, pz: number) => number, + px: number, + pz: number, + r: number +): number { + return ( + 0.25 * (height(px + r, pz) + height(px - r, pz) + height(px, pz + r) + height(px, pz - r)) - + height(px, pz) + ); +} + +/** The multiplier on lit colour for a curvature: a hollow sits in shade, a rise catches light (the shader's `cavity`). */ +export const cavityAt = (curv: number): number => + 1 - CAVITY.dark * clamp01(curv / CAVITY.range) + CAVITY.light * clamp01(-curv / CAVITY.range); diff --git a/src/app/features/scene/terrain/terrainShaders.ts b/src/app/features/scene/terrain/terrainShaders.ts index 5fd01d7b..8e270d83 100644 --- a/src/app/features/scene/terrain/terrainShaders.ts +++ b/src/app/features/scene/terrain/terrainShaders.ts @@ -7,7 +7,7 @@ import cultmath from '../shaders/CultMath.glsl?raw'; import phacelle from '../shaders/CultMath.Phacelle.glsl?raw'; import { trimGlsl } from '../cultmathTrim'; -import { ALBEDO } from './terrainAlbedo'; +import { ALBEDO, CAVITY } from './terrainAlbedo'; import { AERIAL_GLSL, AP_DISTANCE_SCALE, SCATTER_GLSL } from './terrainScatter'; import { GLOW_ZENITH, MOON_HAZE, SKY_EXPOSURE, SKY_FADE, STAR_FLOOR } from './terrainSky'; import { TERRAIN } from './terrainWorld'; @@ -135,6 +135,10 @@ const ALBEDO_DEFINES = ` #define ALB_STRATA ${lit(ALBEDO.strata)} #define ALB_FINE_NEAR ${lit(ALBEDO.fineNear)} #define ALB_FINE_FAR ${lit(ALBEDO.fineFar)} +#define CAV_RADIUS ${lit(CAVITY.radius)} +#define CAV_RANGE ${lit(CAVITY.range)} +#define CAV_DARK ${lit(CAVITY.dark)} +#define CAV_LIGHT ${lit(CAVITY.light)} `; // The sky colour along a unit direction: scattering lifted by the exposure, tone-mapped, plus the night glow. The // same arithmetic as terrainSky.skyColour(). uGlow is the baked horizon colour already scaled by how dark the sun is. @@ -180,11 +184,11 @@ void main() { float near = 1.0 - smoothstep(ALB_FINE_NEAR, ALB_FINE_FAR, length(v_world - uCamera)); float layers = sin(v_world.y * 0.9 + w * 3.0); albedo *= max(0.0, 1.0 + ALB_GRAIN_COARSE * coarse + ALB_GRAIN_FINE * near * fine + ALB_STRATA * rockW * layers); - // Cavity: a point lower than its surroundings sits in shade, one higher catches light. Shader-only, from the height map. - int r = max(1, int(12.0 / texel)); - ivec2 c = ivec2(floor(p + 0.5)); - float curv = 0.25 * (hAt(c + ivec2(r, 0)) + hAt(c - ivec2(r, 0)) + hAt(c + ivec2(0, r)) + hAt(c - ivec2(0, r))) - hAt(c); - float cavity = 1.0 - 0.55 * clamp(curv / 12.0, 0.0, 1.0) + 0.3 * clamp(-curv / 12.0, 0.0, 1.0); + // Cavity: a point lower than its surroundings sits in shade, one higher catches light (terrainAlbedo.cavityAt). Shader-only, from the height map, + // read bilinear at a continuous position so it does not snap to a texel (that made the shading blocks of one texel, 3 m). + float cr = CAV_RADIUS / texel; + float curv = 0.25 * (hBil(p + vec2(cr, 0.0)) + hBil(p - vec2(cr, 0.0)) + hBil(p + vec2(0.0, cr)) + hBil(p - vec2(0.0, cr))) - hBil(p); + float cavity = 1.0 - CAV_DARK * clamp(curv / CAV_RANGE, 0.0, 1.0) + CAV_LIGHT * clamp(-curv / CAV_RANGE, 0.0, 1.0); vec3 lit = albedo * cavity * (uLight * max(dot(n, uLightDir), 0.0) + uAmbient * (0.5 + 0.5 * n.y)); vec3 toSurface = v_world - uCamera; float dist = length(toSurface); diff --git a/src/app/features/scene/terrain/terrainWorld.test.ts b/src/app/features/scene/terrain/terrainWorld.test.ts index 17935382..78ab2700 100644 --- a/src/app/features/scene/terrain/terrainWorld.test.ts +++ b/src/app/features/scene/terrain/terrainWorld.test.ts @@ -41,7 +41,8 @@ describe('grid', () => { }); it('each tier fits a 32-bit index and the cheap tiers stay cheap', () => { - expect(triangleCount(GRID.low.grid)).toBeLessThan(80000); + // 205k at 320 quads: a guess at what a phone carries (SwiftShader only, no phone frame time), kept as a ceiling so a bigger grid is a deliberate change. + expect(triangleCount(GRID.low.grid)).toBeLessThan(210000); expect(GRID.full.grid).toBeGreaterThan(GRID.low.grid); expect(GRID.still.tex).toBeLessThanOrEqual(GRID.low.tex); }); @@ -89,6 +90,34 @@ describe('camera', () => { }); }); +describe('grid resolution', () => { + // The mesh must carry what the height map shows: where a quad is wider than a couple of texels the shading (read from the + // height map) has gullies the polygons cannot, and the near ground reads as hard-edged facets (the 'terrain · low' phone + // shots, 2026-11-09). Metres from the patch centre to the nearest ground the camera can see: the bottom view ray meets + // the highest terrain, hmax below the eye, at (eye - hmax) / tan(pitch + fov / 2). + const nearest = (CAMERA.eye - TERRAIN.hmax) / Math.tan(CAMERA.pitch + CAMERA.fov / 2); + const spacingAt = (grid: number, metres: number) => { + for (let i = 0; i < grid; i += 1) { + const x = (warpUv((i + 1) / grid) - 0.5) * TERRAIN.span; + if (x >= metres) return (warpUv((i + 1) / grid) - warpUv(i / grid)) * TERRAIN.span; + } + throw new Error('beyond the patch'); + }; + + it.each(['still', 'low', 'full'] as const)( + 'the %s tier has a quad no wider than two height texels at the nearest ground the camera can see', + (tier) => { + const { grid, tex } = GRID[tier]; + expect(spacingAt(grid, nearest)).toBeLessThanOrEqual((2 * TERRAIN.span) / (tex - 1)); + } + ); + + it('a dearer tier never has a coarser mesh', () => { + expect(GRID.low.grid).toBeGreaterThanOrEqual(GRID.still.grid); + expect(GRID.full.grid).toBeGreaterThanOrEqual(GRID.low.grid); + }); +}); + describe('warpUv', () => { it('maps the patch onto itself, monotonic, centre fixed', () => { expect(warpUv(0)).toBe(0); diff --git a/src/app/features/scene/terrain/terrainWorld.ts b/src/app/features/scene/terrain/terrainWorld.ts index fcc02390..7296025b 100644 --- a/src/app/features/scene/terrain/terrainWorld.ts +++ b/src/app/features/scene/terrain/terrainWorld.ts @@ -5,11 +5,15 @@ /** Metres across the patch, and the height in metres that normalised height 1.0 stands for. */ export const TERRAIN = { span: 3000, hmax: 256 }; -/** Grid quads per side and height-map texels per side, by tier. 'still' draws one frame, so it takes the cheap pair. */ +/** + * Grid quads per side and height-map texels per side, by tier. 'still' draws one frame, so it takes the cheap pair. The + * mesh is dense enough that a quad is at most two texels wide at the nearest ground (terrainWorld.test.ts); a coarser mesh + * under shading read from the height map shows as hard-edged facets and stepped crests. + */ export const GRID = { - still: { grid: 128, tex: 512 }, - low: { grid: 192, tex: 1024 }, - full: { grid: 256, tex: 1024 }, + still: { grid: 192, tex: 512 }, + low: { grid: 320, tex: 1024 }, + full: { grid: 384, tex: 1024 }, } as const; /** The camera: at the patch centre, above the highest peak, turning slowly and looking a little down. */ From deb4e0dfe6677de394b07bd0ff990cac5c8b238e Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 9 Nov 2026 15:39:38 +0000 Subject: [PATCH 151/206] Room list on a phone: plain text rows in the heading type; no Create Room or Join with Address (#40) --- .../components/message/layout/layout.css.ts | 17 +- src/app/components/nav/styles.css.ts | 158 ++++++++++++++---- src/app/features/estate/EstateNavigator.tsx | 8 +- src/app/features/room-nav/RoomNavItem.tsx | 9 +- src/app/features/room-nav/styles.css.ts | 18 ++ 5 files changed, 174 insertions(+), 36 deletions(-) diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index 815c579f..a724241c 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -158,6 +158,7 @@ export const BubbleContentArrowLeft = style({ const PHONE = 'screen and (max-width: 700px)'; const PHONE_PORTRAIT = 'min(38vw, 160px)'; const PHONE_TEXT_FLOOR = toRem(170); +const PORTRAIT_OVERHANG = 58; const PORTRAIT = toRem(124); const POSE_GUTTER = toRem(14); const BUBBLE_CORNER = toRem(6); @@ -180,7 +181,6 @@ export const BubbleRowPortrait = style({ '@media': { [PHONE]: { minHeight: 0, - marginTop: toRem(58), }, }, }); @@ -207,6 +207,12 @@ export const BubbleHeaderPose = style({ '@media': { [PHONE]: { alignSelf: 'flex-end', + // The band above the bubble where the portrait overhangs: the header sits at its foot, beside + // the portrait, so the overhang needs no margin of its own. + minHeight: toRem(PORTRAIT_OVERHANG), + display: 'flex', + flexDirection: 'column', + justifyContent: 'flex-end', margin: `0 ${toRem(14)} ${toRem(2)}`, width: 'max-content', maxWidth: `calc(100vw - ${PHONE_PORTRAIT} - ${toRem(64)})`, @@ -224,6 +230,15 @@ export const BubbleHeaderPoseAlt = style({ }, }); +// A portrait row with no header (nothing to fill the overhang band) keeps the band as a margin. +globalStyle(`${BubbleRowPortrait}:not(:has(.${BubbleHeaderPose}))`, { + '@media': { + [PHONE]: { + marginTop: toRem(PORTRAIT_OVERHANG), + }, + }, +}); + export const BubbleColumnPose = style({ position: 'relative', }); diff --git a/src/app/components/nav/styles.css.ts b/src/app/components/nav/styles.css.ts index b123f9bc..feee3276 100644 --- a/src/app/components/nav/styles.css.ts +++ b/src/app/components/nav/styles.css.ts @@ -1,4 +1,4 @@ -import { ComplexStyleRule, createVar, style } from '@vanilla-extract/css'; +import { ComplexStyleRule, StyleRule, createVar, globalStyle, style } from '@vanilla-extract/css'; import { RecipeVariants, recipe } from '@vanilla-extract/recipes'; import { ContainerColor, DefaultReset, Disabled, RadiiVariant, color, config, toRem } from 'folds'; import { surfaceInk } from '../../features/scene/sceneShell.css'; @@ -20,11 +20,16 @@ export const NavCategory = style([ export const NavCategoryHeader = style({ gap: config.space.S100, - selectors: { - // Category labels sit on the scene like the rows do, and need a wash to be read. - ':root[data-scene] &': { - backgroundColor: `color-mix(in srgb, ${surfaceInk} ${SCRIM_LABEL}, transparent)`, - borderRadius: config.radii.R400, + '@media': { + // Category labels sit on the scene like the rows do, and need a wash to be read (wide screens; + // a phone's list is plain text). + 'screen and (min-width: 751px)': { + selectors: { + ':root[data-scene] &': { + backgroundColor: `color-mix(in srgb, ${surfaceInk} ${SCRIM_LABEL}, transparent)`, + borderRadius: config.radii.R400, + }, + }, }, }, }); @@ -60,6 +65,12 @@ const getVariant = (variant: ContainerColor): ComplexStyleRule => ({ }, }); +// The room list is plain text on a phone: no pill, slab or border behind any row, and hover, +// selected and pressed show in the label (NavLabel), never in a box. Wider screens keep the rows +// and the wash. +const WIDE = 'screen and (min-width: 751px)'; +const PHONE = 'screen and (max-width: 750px)'; + const NavItemBase = style({ width: '100%', display: 'flex', @@ -70,35 +81,45 @@ const NavItemBase = style({ outline: 'none', minHeight: toRem(36), - selectors: { - '&:hover, &:focus-visible': { - backgroundColor: ContainerHover, + '@media': { + [PHONE]: { + backgroundColor: 'transparent', + borderRadius: 0, }, - '&[data-hover=true]': { - backgroundColor: ContainerHover, - }, - // While the background scene runs the shell is clear (sceneShell.css.ts); a row is a faint - // wash of the theme's own surface: the terrain stays visible, the text stays readable. Hover, - // the selected row and a press each strengthen it a step. - ':root[data-scene] &': { - backgroundColor: `color-mix(in srgb, ${surfaceInk} ${SCRIM_ROW}, transparent)`, - }, - ':root[data-scene] &:hover, :root[data-scene] &:focus-visible, :root[data-scene] &[data-hover=true]': - { - backgroundColor: `color-mix(in srgb, ${surfaceInk} ${SCRIM_HOVER}, transparent)`, + [WIDE]: { + selectors: { + '&:hover, &:focus-visible': { + backgroundColor: ContainerHover, + }, + '&[data-hover=true]': { + backgroundColor: ContainerHover, + }, + // While the background scene runs the shell is clear (sceneShell.css.ts); a row is a faint + // wash of the theme's own surface: the terrain stays visible, the text stays readable. + // Hover, the selected row and a press each strengthen it a step. + ':root[data-scene] &': { + backgroundColor: `color-mix(in srgb, ${surfaceInk} ${SCRIM_ROW}, transparent)`, + }, + ':root[data-scene] &:hover, :root[data-scene] &:focus-visible, :root[data-scene] &[data-hover=true]': + { + backgroundColor: `color-mix(in srgb, ${surfaceInk} ${SCRIM_HOVER}, transparent)`, + }, + ':root[data-scene] &[aria-selected=true]': { + backgroundColor: `color-mix(in srgb, ${surfaceInk} ${SCRIM_SELECTED}, transparent)`, + }, + [`:root[data-scene] &:has(.${NavLink}:active)`]: { + backgroundColor: `color-mix(in srgb, ${surfaceInk} ${SCRIM_PRESSED}, transparent)`, + }, + [`&:has(.${NavLink}:active)`]: { + backgroundColor: ContainerActive, + }, + '&[aria-selected=true]': { + backgroundColor: ContainerActive, + }, }, - ':root[data-scene] &[aria-selected=true]': { - backgroundColor: `color-mix(in srgb, ${surfaceInk} ${SCRIM_SELECTED}, transparent)`, - }, - [`:root[data-scene] &:has(.${NavLink}:active)`]: { - backgroundColor: `color-mix(in srgb, ${surfaceInk} ${SCRIM_PRESSED}, transparent)`, - }, - [`&:has(.${NavLink}:active)`]: { - backgroundColor: ContainerActive, - }, - '&[aria-selected=true]': { - backgroundColor: ContainerActive, }, + }, + selectors: { [`&:has(.${NavLink}:focus-visible)`]: { outline: `${config.borderWidth.B600} solid ${ContainerLine}`, outlineOffset: `calc(-1 * ${config.borderWidth.B600})`, @@ -158,3 +179,76 @@ export const NavItemContent = style({ export const NavItemOptions = style({ paddingRight: config.space.S200, }); + +// A room's name on a phone wears the section headings' type (small caps-height, uppercase, 600, +// at heading strength) and states itself in the text: hover and focus underline it, the selected +// row is bolder and underlined, a press dims it, an unread room is at full strength. Over the scene +// a halo of the theme's own surface keeps it readable without a box. +export const NavLabel = style({}); +const label = `.${NavLabel}.${NavLabel}`; +const phoneLabel = (selector: string, rule: StyleRule) => + globalStyle(selector, { '@media': { [PHONE]: rule } }); + +phoneLabel(label, { + fontSize: toRem(12), + lineHeight: toRem(16), + fontWeight: config.fontWeight.W600, + textTransform: 'uppercase', + opacity: 0.75, + textDecorationThickness: '1px', + textUnderlineOffset: toRem(4), +}); +phoneLabel( + [ + `.${NavItemBase}:hover ${label}`, + `.${NavItemBase}:focus-visible ${label}`, + `.${NavItemBase}[data-hover=true] ${label}`, + `.${NavItemBase}[data-highlight=true] ${label}`, + ].join(', '), + { opacity: 1 } +); +phoneLabel(`.${NavItemBase}:hover ${label}, .${NavItemBase}:focus-visible ${label}`, { + textDecorationLine: 'underline', +}); +phoneLabel(`.${NavItemBase}[aria-selected=true] ${label}`, { + opacity: 1, + fontWeight: config.fontWeight.W700, + textDecorationLine: 'underline', + textDecorationThickness: '2px', +}); +phoneLabel(`.${NavItemBase}:has(.${NavLink}:active) ${label}`, { + opacity: 0.6, + textDecorationLine: 'underline', +}); +phoneLabel(`:root[data-scene] ${label}`, { + textShadow: `0 0 3px ${surfaceInk}, 0 0 8px ${surfaceInk}`, +}); + +// Icons, dots and the Join action share the label's halo over the scene. +globalStyle(`:root[data-scene] ${NavItemBase} svg, :root[data-scene] ${NavItemBase} button`, { + '@media': { + [PHONE]: { + filter: `drop-shadow(0 0 3px ${surfaceInk})`, + }, + }, +}); + +// The Join action on a phone is a plain underlined word, not a pill. +export const NavAction = style({ + '@media': { + [PHONE]: { + selectors: { + '&&': { + backgroundColor: 'transparent', + boxShadow: 'none', + border: 'none', + borderRadius: 0, + minWidth: 0, + textDecoration: 'underline', + textUnderlineOffset: toRem(4), + flexShrink: 0, + }, + }, + }, + }, +}); diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx index 29451c92..1a9ca8ee 100644 --- a/src/app/features/estate/EstateNavigator.tsx +++ b/src/app/features/estate/EstateNavigator.tsx @@ -10,6 +10,7 @@ import { NavItem, NavItemContent, } from '../../components/nav'; +import { NavAction, NavLabel } from '../../components/nav/styles.css'; import { UseStateProvider } from '../../components/UseStateProvider'; import { JoinAddressPrompt } from '../../components/join-address-prompt'; import { RoomNavCategoryButton, RoomNavItem } from '../room-nav'; @@ -80,7 +81,7 @@ function JoinRow({ room }: { room: EstateRoom }) { - + {room.name} @@ -95,6 +96,7 @@ function JoinRow({ room }: { room: EstateRoom }) {
    )} (null); const mx = useMatrixClient(); const title = (rootId && mx.getRoom(rootId)?.name) || 'GameCult'; + // A phone's list is the estate and nothing else: no Create Room, no Join with Address. + const mobile = useScreenSizeContext() === ScreenSize.Mobile; return ( @@ -302,7 +306,7 @@ export function EstateNavigator() { - + {!mobile && } {rootId && } {!rootId && error && ( diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index 08341113..ff23930e 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -21,6 +21,7 @@ import { useFocusWithin, useHover } from 'react-aria'; import FocusTrap from 'focus-trap-react'; import { useAtom, useAtomValue } from 'jotai'; import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav'; +import { NavLabel } from '../../components/nav/styles.css'; import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge'; import { RoomAvatar, RoomIcon } from '../../components/room-avatar'; import { getDirectRoomAvatarUrl, getRoomAvatarUrl, getStateEvent } from '../../utils/room'; @@ -358,7 +359,13 @@ export function RoomNavItem({ )} - + {roomName} diff --git a/src/app/features/room-nav/styles.css.ts b/src/app/features/room-nav/styles.css.ts index b5701a6f..b9655923 100644 --- a/src/app/features/room-nav/styles.css.ts +++ b/src/app/features/room-nav/styles.css.ts @@ -1,8 +1,26 @@ import { style } from '@vanilla-extract/css'; import { config } from 'folds'; +import { surfaceInk } from '../scene/sceneShell.css'; export const CategoryButton = style({ flexGrow: 1, + '@media': { + // A section heading on a phone is plain text: no pill behind it. + 'screen and (max-width: 750px)': { + selectors: { + '&&': { + backgroundColor: 'transparent', + boxShadow: 'none', + border: 'none', + borderRadius: 0, + }, + ':root[data-scene] &&': { + textShadow: `0 0 3px ${surfaceInk}, 0 0 8px ${surfaceInk}`, + filter: `drop-shadow(0 0 3px ${surfaceInk})`, + }, + }, + }, + }, }); export const CategoryButtonIcon = style({ opacity: config.opacity.P400, From 030417f27acfd74374a5534b2c0f492e00e0f491 Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 9 Nov 2026 16:01:37 +0000 Subject: [PATCH 152/206] CI: drop artifact upload from the build check (#41) --- .github/workflows/build-pull-request.yml | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/.github/workflows/build-pull-request.yml b/.github/workflows/build-pull-request.yml index 11b5056c..4ff842ed 100644 --- a/.github/workflows/build-pull-request.yml +++ b/.github/workflows/build-pull-request.yml @@ -8,8 +8,6 @@ jobs: build-pull-request: name: Build pull request runs-on: ubuntu-latest - env: - PR_NUMBER: ${{github.event.number}} steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -24,17 +22,3 @@ jobs: env: NODE_OPTIONS: '--max_old_space_size=4096' run: npm run build - - name: Upload artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: preview - path: dist - retention-days: 1 - - name: Save pr number - run: echo ${PR_NUMBER} > ./pr.txt - - name: Upload pr number - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: pr - path: ./pr.txt - retention-days: 1 From 78a7dbe9231adecabb6df2af15f362f310a2230d Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 9 Nov 2026 20:17:35 +0000 Subject: [PATCH 153/206] auth: login card fill 50% -> 54% so 4.5:1 holds without backdrop-filter Co-Authored-By: Claude Sonnet 5.5 --- src/app/pages/auth/styles.css.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/app/pages/auth/styles.css.ts b/src/app/pages/auth/styles.css.ts index b45539a6..9a4240f0 100644 --- a/src/app/pages/auth/styles.css.ts +++ b/src/app/pages/auth/styles.css.ts @@ -56,9 +56,9 @@ export const AuthFooter = style({ // signed-out ground is clear and the card is a translucent, blurred pane shaped like a message // bubble (BubbleContent: radius R500, no border), filled with the sky's bubble colour. The // --sky-bubble variables come from the sky theme (PR #22); the plain colours are the fallback -// until it lands. The fill is 50%: the 18px blur carries the readability, flattening the sky and -// ridges behind the text. Without the blur, text is 4.15:1 over the brightest backdrop (the horizon -// glow; 4.5:1 needs 54%) and >= 7.3:1 elsewhere in the scene. +// until it lands. The fill is 54%, the least that gives 4.5:1 text over the brightest backdrop (the +// horizon glow) without depending on the blur; it is >= 7.3:1 elsewhere in the scene. The 18px blur +// only softens the sky and ridges behind the text. // The card is also made shorter so it leaves more of the scene visible on a phone. const scene = ':root[data-scene]'; globalStyle(`${scene} ${AuthLayout}`, { @@ -67,8 +67,8 @@ globalStyle(`${scene} ${AuthLayout}`, { }); globalStyle(`${scene} ${AuthCard}`, { backgroundColor: [ - 'rgba(13, 26, 38, 0.5)', - 'color-mix(in srgb, var(--sky-bubble, #0d1a26) 50%, transparent)', + 'rgba(13, 26, 38, 0.54)', + 'color-mix(in srgb, var(--sky-bubble, #0d1a26) 54%, transparent)', ], color: 'var(--sky-bubble-text, #b7c7d9)', borderRadius: config.radii.R500, From a773bc4932e081f3de6b763f8d9467cd1eef989b Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 9 Nov 2026 20:37:16 +0000 Subject: [PATCH 154/206] auth: Noonday Sky login card opaque, so text and link pass AA over any terrain Libby's read: at 54% fill Noonday text was 2.99:1 and the Register link 2.06:1 over shaded terrain. Dark text on a light bubble fails when a dark ground shows through, so Noonday's card now takes the bubble colour with no transparency. Midnight is unchanged (54%). WCAG 2.x ratios, computed (relative luminance, sRGB): Noonday, bubble #b9b6af, text #342d25, link #2a4f27, card over black / white: 54% fill: text 2.23 / 9.51, link 1.54 / 6.55 (fails over dark) 80% fill: text 4.36 / 7.86, link 3.00 / 5.42 (fails over dark) 90% fill: text 5.44 / 7.24, link 3.75 / 4.99 (link fails over dark) 100% fill: text 6.70, link 4.62, independent of backdrop and blur Midnight, bubble #0a090e, text #aeccff, link #609385, 54% fill: over black text 12.49, link 5.83; over white text 2.62, link 1.22 (white is not in the night scene; 4.5:1 over the horizon glow is an estimate, not measured) Co-Authored-By: Claude Sonnet 5.5 --- src/app/pages/auth/styles.css.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/app/pages/auth/styles.css.ts b/src/app/pages/auth/styles.css.ts index 9a4240f0..06339710 100644 --- a/src/app/pages/auth/styles.css.ts +++ b/src/app/pages/auth/styles.css.ts @@ -56,9 +56,12 @@ export const AuthFooter = style({ // signed-out ground is clear and the card is a translucent, blurred pane shaped like a message // bubble (BubbleContent: radius R500, no border), filled with the sky's bubble colour. The // --sky-bubble variables come from the sky theme (PR #22); the plain colours are the fallback -// until it lands. The fill is 54%, the least that gives 4.5:1 text over the brightest backdrop (the -// horizon glow) without depending on the blur; it is >= 7.3:1 elsewhere in the scene. The 18px blur +// until it lands. The fill is 54% in Midnight Sky (light text on a near-black bubble: 12.5:1 over a +// black backdrop; 54% was estimated as the least for 4.5:1 over the horizon glow). The 18px blur // only softens the sky and ridges behind the text. +// Noonday Sky is dark text on a light bubble, so the dark ground behind a translucent card is what +// fails it (Libby: 2.99:1 text, 2.06:1 Register link at 54%). Its card is opaque: bubble #b9b6af +// holds text #342d25 at 6.70:1 and the link #2a4f27 at 4.62:1 whatever is behind it, blur or not. // The card is also made shorter so it leaves more of the scene visible on a phone. const scene = ':root[data-scene]'; globalStyle(`${scene} ${AuthLayout}`, { @@ -76,6 +79,9 @@ globalStyle(`${scene} ${AuthCard}`, { borderColor: 'rgba(183, 199, 217, 0.14)', backdropFilter: 'blur(18px)', }); +globalStyle(`${scene} body.noonday-sky ${AuthCard}`, { + backgroundColor: 'var(--sky-bubble, #b9b6af)', +}); globalStyle(`${scene} ${AuthHeader}`, { backgroundColor: 'transparent', }); From ef961636c3ab345648ae917d284856a58574ba26 Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 9 Nov 2026 20:40:47 +0000 Subject: [PATCH 155/206] auth: opaque login card for every theme, own link colour off the sky themes The scene sky is one fixed dusk for every theme, so a translucent card put Midnight's link at 4.12/3.37 and Light's and Silver's at 2.49 (Libby). The card is now the theme's bubble colour with no transparency and no backdrop-filter, so text and link contrast no longer depend on the scene or a blur. Themes without --sky-bubble (Light, Silver, Dark, Butter) get link #99c7ff on the card (Dark and Butter already used that hue); Midnight and Noonday keep their own. WCAG 2.x ratios, computed, text / Register link on the card: Light, Silver, Dark, Butter #b7c7d9 / #99c7ff on #0d1a26 10.21 / 10.03 Midnight Sky #aeccff / #609385 on #0a090e 12.17 / 5.68 Noonday Sky #342d25 / #2a4f27 on #b9b6af 6.70 / 4.62 Co-Authored-By: Claude Sonnet 5.5 --- src/app/pages/auth/styles.css.ts | 30 ++++++++++++++---------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/src/app/pages/auth/styles.css.ts b/src/app/pages/auth/styles.css.ts index 06339710..4fa42d74 100644 --- a/src/app/pages/auth/styles.css.ts +++ b/src/app/pages/auth/styles.css.ts @@ -53,15 +53,17 @@ export const AuthFooter = style({ }); // While the background scene runs (html[data-scene], set by features/scene/BackgroundScene), the -// signed-out ground is clear and the card is a translucent, blurred pane shaped like a message -// bubble (BubbleContent: radius R500, no border), filled with the sky's bubble colour. The -// --sky-bubble variables come from the sky theme (PR #22); the plain colours are the fallback -// until it lands. The fill is 54% in Midnight Sky (light text on a near-black bubble: 12.5:1 over a -// black backdrop; 54% was estimated as the least for 4.5:1 over the horizon glow). The 18px blur -// only softens the sky and ridges behind the text. -// Noonday Sky is dark text on a light bubble, so the dark ground behind a translucent card is what -// fails it (Libby: 2.99:1 text, 2.06:1 Register link at 54%). Its card is opaque: bubble #b9b6af -// holds text #342d25 at 6.70:1 and the link #2a4f27 at 4.62:1 whatever is behind it, blur or not. +// signed-out ground is clear and the card is a pane shaped like a message bubble (BubbleContent: +// radius R500, no border), filled with the sky's bubble colour. The scene is one fixed dusk for +// every theme, so the card is opaque: its text and link contrast cannot depend on what the scene +// shows behind it, nor on a blur. The --sky-bubble variables come from the sky themes (Midnight and +// Noonday); the other themes (Light, Silver, Dark, Butter) take the fallback colours. +// WCAG ratios of text / Register link on the card, per theme: +// Light, Silver, Dark, Butter #b7c7d9 / #99c7ff on #0d1a26 10.21 / 10.03 +// Midnight Sky #aeccff / #609385 on #0a090e 12.17 / 5.68 +// Noonday Sky #342d25 / #2a4f27 on #b9b6af 6.70 / 4.62 +// The fallback link is set here because those themes' own links (Light 2.49:1, Silver 2.49:1) are +// made for their own grounds, not this card; Dark and Butter already use the same hsl(213 100% 80%). // The card is also made shorter so it leaves more of the scene visible on a phone. const scene = ':root[data-scene]'; globalStyle(`${scene} ${AuthLayout}`, { @@ -69,18 +71,14 @@ globalStyle(`${scene} ${AuthLayout}`, { backgroundImage: 'none', }); globalStyle(`${scene} ${AuthCard}`, { - backgroundColor: [ - 'rgba(13, 26, 38, 0.54)', - 'color-mix(in srgb, var(--sky-bubble, #0d1a26) 54%, transparent)', - ], + backgroundColor: 'var(--sky-bubble, #0d1a26)', color: 'var(--sky-bubble-text, #b7c7d9)', borderRadius: config.radii.R500, // A faint inner edge, so a pane this clear still reads as a surface. borderColor: 'rgba(183, 199, 217, 0.14)', - backdropFilter: 'blur(18px)', }); -globalStyle(`${scene} body.noonday-sky ${AuthCard}`, { - backgroundColor: 'var(--sky-bubble, #b9b6af)', +globalStyle(`${scene} body:not(.gamecult-theme) ${AuthCard}`, { + vars: { '--tc-link': '#99c7ff' }, }); globalStyle(`${scene} ${AuthHeader}`, { backgroundColor: 'transparent', From ecdf5e9d29965911463d975d51d809439fadd46b Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 9 Nov 2026 22:02:01 +0000 Subject: [PATCH 156/206] scene: sky-state seam (auto/day/dusk/night) with a luminance bound per state getSkyState/setSkyState/subscribe over one localStorage key, default auto, tolerant of missing, invalid and refused storage. skyBackdrop(state) returns the darkest and lightest sky luminance the state can show. Tests measure login text and link contrast against it per state and theme. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/skyState.test.ts | 185 ++++++++++++++++++++++++ src/app/features/scene/skyState.ts | 84 +++++++++++ 2 files changed, 269 insertions(+) create mode 100644 src/app/features/scene/skyState.test.ts create mode 100644 src/app/features/scene/skyState.ts diff --git a/src/app/features/scene/skyState.test.ts b/src/app/features/scene/skyState.test.ts new file mode 100644 index 00000000..71d35fd0 --- /dev/null +++ b/src/app/features/scene/skyState.test.ts @@ -0,0 +1,185 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { baked } from '../../../sky/baked'; +import { AA, luminance } from '../../../sky/skyTokens.js'; +import { + SKY_STATES, + SKY_STATE_KEY, + skyBackdrop, + type SkyBackdrop, + type SkyState, +} from './skyState'; + +type Mod = typeof import('./skyState'); + +function fakeStorage(initial: Record = {}) { + const data = new Map(Object.entries(initial)); + return { + data, + getItem: (k: string) => data.get(k) ?? null, + setItem: (k: string, v: string) => { + data.set(k, v); + }, + }; +} +const fresh = (): Promise => { + vi.resetModules(); + return import('./skyState'); +}; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('sky state', () => { + let storage: ReturnType; + beforeEach(() => { + storage = fakeStorage(); + vi.stubGlobal('localStorage', storage); + }); + + it('defaults to auto when nothing is stored', async () => { + expect((await fresh()).getSkyState()).toBe('auto'); + }); + + it.each(['night', 'day', 'dusk', 'auto'] as const)('restores a stored %s', async (s) => { + storage.data.set(SKY_STATE_KEY, s); + expect((await fresh()).getSkyState()).toBe(s); + }); + + it.each(['', 'noon', 'NIGHT', '"night"', 'null'])( + 'falls back to auto on stored %j', + async (v) => { + storage.data.set(SKY_STATE_KEY, v); + expect((await fresh()).getSkyState()).toBe('auto'); + } + ); + + it('falls back to auto when storage throws', async () => { + vi.stubGlobal('localStorage', { + getItem: () => { + throw new Error('denied'); + }, + setItem: () => { + throw new Error('denied'); + }, + }); + const m = await fresh(); + expect(m.getSkyState()).toBe('auto'); + m.setSkyState('night'); + expect(m.getSkyState()).toBe('night'); + }); + + it('persists a set state under one key and reads it back after a reload', async () => { + (await fresh()).setSkyState('dusk'); + expect([...storage.data.entries()]).toEqual([[SKY_STATE_KEY, 'dusk']]); + expect((await fresh()).getSkyState()).toBe('dusk'); + }); + + it('ignores an invalid state', async () => { + const m = await fresh(); + m.setSkyState('night'); + m.setSkyState('noon' as SkyState); + expect(m.getSkyState()).toBe('night'); + expect(storage.data.get(SKY_STATE_KEY)).toBe('night'); + }); + + it('notifies subscribers on change only, and stops after unsubscribe', async () => { + const m = await fresh(); + const cb = vi.fn(); + const off = m.subscribe(cb); + m.setSkyState('day'); + m.setSkyState('day'); + expect(cb.mock.calls).toEqual([['day']]); + off(); + m.setSkyState('night'); + expect(cb).toHaveBeenCalledTimes(1); + }); + + it('tells a late subscriber nothing until the next change', async () => { + const m = await fresh(); + m.setSkyState('day'); + const cb = vi.fn(); + m.subscribe(cb); + expect(cb).not.toHaveBeenCalled(); + }); +}); + +describe('skyBackdrop', () => { + it('orders the fixed skies night < dusk < day by their lightest sky', () => { + expect(skyBackdrop('night').lightest).toBeLessThan(skyBackdrop('dusk').lightest); + expect(skyBackdrop('dusk').lightest).toBeLessThan(skyBackdrop('day').lightest); + }); + + it('is the luminance of the baked gradients for day and night', () => { + const lum = (g: readonly string[]) => g.map(luminance); + expect(skyBackdrop('day').lightest).toBe(Math.max(...lum(baked.noonday.gradient))); + expect(skyBackdrop('night').darkest).toBe(Math.min(...lum(baked.midnight.gradient))); + }); + + it('bounds every fixed sky when auto', () => { + const a = skyBackdrop('auto'); + (['day', 'dusk', 'night'] as const).forEach((s) => { + expect(a.lightest).toBeGreaterThanOrEqual(skyBackdrop(s).lightest); + expect(a.darkest).toBeLessThanOrEqual(skyBackdrop(s).darkest); + }); + }); + + it('never has darkest above lightest', () => { + SKY_STATES.forEach((s) => + expect(skyBackdrop(s).darkest).toBeLessThanOrEqual(skyBackdrop(s).lightest) + ); + }); +}); + +// Login text and link, per theme, from the card variables in pages/auth/styles.css.ts (the +// fallback pair serves Light, Silver, Dark and Butter) and the sky themes' baked bubble text and +// link. Light ink is measured against the lightest sky it can sit on, dark ink against the darkest. +const inks = [ + { theme: 'Light/Silver/Dark/Butter', text: '#b7c7d9', link: '#99c7ff' }, + { theme: 'Midnight Sky', text: baked.midnight.bubbles.OtherText, link: baked.midnight.link }, + { theme: 'Noonday Sky', text: baked.noonday.bubbles.OtherText, link: baked.noonday.link }, +]; +// WCAG contrast of an ink against a backdrop of the given luminance, with the ink's polarity +// deciding which end of the sky is its worst case. +const ratio = (ink: string, b: SkyBackdrop) => { + const li = luminance(ink); + const bg = li > 0.18 ? b.lightest : b.darkest; + const [hi, lo] = li > bg ? [li, bg] : [bg, li]; + return (hi + 0.05) / (lo + 0.05); +}; + +describe('login ink against the sky backdrop', () => { + const rows = SKY_STATES.flatMap((state) => + inks.flatMap(({ theme, text, link }) => + ( + [ + ['text', text], + ['link', link], + ] as const + ).map(([role, ink]) => ({ state, theme, role, ratio: ratio(ink, skyBackdrop(state)) })) + ) + ); + + it('prints the measured ratios', () => { + // eslint-disable-next-line no-console + console.table(rows.map((r) => ({ ...r, ratio: r.ratio.toFixed(2), ok: r.ratio >= AA }))); + expect(rows).toHaveLength(SKY_STATES.length * inks.length * 2); + }); + + // Bare sky does not hold either ink in most states: the card behind the login form stays opaque + // (pages/auth/styles.css.ts) for that reason. These are the cells that clear AA; every other + // cell is asserted to fail, so a sky or theme change that fixes one turns it red until listed. + const clears = new Set([ + 'day|Noonday Sky|text', + 'day|Noonday Sky|link', + 'night|Light/Silver/Dark/Butter|text', + 'night|Light/Silver/Dark/Butter|link', + 'night|Midnight Sky|text', + ]); + describe.each(rows)('$theme $role in $state', (r) => { + const holds = clears.has(`${r.state}|${r.theme}|${r.role}`); + (holds ? it : it.fails)(holds ? 'is at least AA' : 'is below AA (known)', () => { + expect(r.ratio).toBeGreaterThanOrEqual(AA); + }); + }); +}); diff --git a/src/app/features/scene/skyState.ts b/src/app/features/scene/skyState.ts new file mode 100644 index 00000000..60e0471f --- /dev/null +++ b/src/app/features/scene/skyState.ts @@ -0,0 +1,84 @@ +// Which sky the signed-out page is drawn against. The state is one value, kept in one +// localStorage key; whoever draws the sky (the scene, the title bar's control) reads it here, and +// `skyBackdrop` says how light or dark the sky behind the login text can be in each state, so the +// text colours can be checked against it. +import { baked } from '../../../sky/baked'; +import { luminance } from '../../../sky/skyTokens.js'; +import { skyColours } from './ridge/skyColour'; + +export type SkyState = 'auto' | 'day' | 'dusk' | 'night'; + +export const SKY_STATES: readonly SkyState[] = ['auto', 'day', 'dusk', 'night']; +export const SKY_STATE_KEY = 'ratatoskr.skyState'; + +const isSkyState = (v: unknown): v is SkyState => SKY_STATES.includes(v as SkyState); + +function load(): SkyState { + try { + const stored = localStorage.getItem(SKY_STATE_KEY); + return isSkyState(stored) ? stored : 'auto'; + } catch { + return 'auto'; + } +} + +let current: SkyState | undefined; +const listeners = new Set<(s: SkyState) => void>(); + +export const getSkyState = (): SkyState => { + current ??= load(); + return current; +}; + +export function setSkyState(s: SkyState): void { + if (!isSkyState(s)) return; + const changed = s !== getSkyState(); + current = s; + try { + localStorage.setItem(SKY_STATE_KEY, s); + } catch { + // Storage is refused (private mode, quota): the choice holds for this page load only. + } + if (changed) [...listeners].forEach((cb) => cb(s)); +} + +export function subscribe(cb: (s: SkyState) => void): () => void { + listeners.add(cb); + return () => { + listeners.delete(cb); + }; +} + +/** + * Relative luminance (WCAG, 0..1) of the lightest and darkest sky the state can show behind the + * page. Light ink is worst against `lightest`, dark ink against `darkest`. 'auto' follows the + * clock, so it can show any of the three fixed skies and bounds all of them. + */ +export interface SkyBackdrop { + readonly darkest: number; + readonly lightest: number; +} + +const hex = (c: readonly number[]) => `#${c.map((v) => v.toString(16).padStart(2, '0')).join('')}`; +const bound = (colours: readonly string[]): SkyBackdrop => { + const l = colours.map(luminance); + return { darkest: Math.min(...l), lightest: Math.max(...l) }; +}; + +const FIXED: Record, SkyBackdrop> = (() => { + const dusk = skyColours(); + return { + day: bound(baked.noonday.gradient), + dusk: bound([dusk.top, dusk.mid, dusk.horizon].map(hex)), + night: bound(baked.midnight.gradient), + }; +})(); + +export function skyBackdrop(state: SkyState): SkyBackdrop { + if (state !== 'auto') return FIXED[state]; + const all = Object.values(FIXED); + return { + darkest: Math.min(...all.map((b) => b.darkest)), + lightest: Math.max(...all.map((b) => b.lightest)), + }; +} From 3b61f2babfc635d7b148708d37f7a3895211b67b Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 9 Nov 2026 22:05:30 +0000 Subject: [PATCH 157/206] scene: host handles a lost GL context (drop to ground, rebuild on restore) host.ts passes onContextLost to the scene; on loss it disposes the dead scene and calls onInactive (BackgroundScene removes data-scene so the CSS ground shows). A webglcontextrestored listener on the canvas starts a fresh scene; it is removed on dispose, and a stale start is dropped by generation. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/BackgroundScene.tsx | 1 + src/app/features/scene/README.md | 4 +- src/app/features/scene/host.test.ts | 106 ++++++++++++++++++++- src/app/features/scene/host.ts | 60 +++++++++--- 4 files changed, 152 insertions(+), 19 deletions(-) diff --git a/src/app/features/scene/BackgroundScene.tsx b/src/app/features/scene/BackgroundScene.tsx index 643fa06d..10785a5f 100644 --- a/src/app/features/scene/BackgroundScene.tsx +++ b/src/app/features/scene/BackgroundScene.tsx @@ -27,6 +27,7 @@ export function BackgroundScene() { const dispose = mountBackgroundScene(config, readCapabilities(navigator), canvas, { loggedIn: !!getFallbackSession(), onActive: () => root.setAttribute(SCENE_ACTIVE_ATTRIBUTE, ''), + onInactive: () => root.removeAttribute(SCENE_ACTIVE_ATTRIBUTE), // eslint-disable-next-line no-console onFail: (e) => console.warn('Background scene unavailable:', e), }); diff --git a/src/app/features/scene/README.md b/src/app/features/scene/README.md index 35075536..fa7565b0 100644 --- a/src/app/features/scene/README.md +++ b/src/app/features/scene/README.md @@ -82,8 +82,8 @@ holds a GL context gives no 2D one, so a GL failure after context creation (comp only the half extension the prepass start target is RGBA16F, written rounded down), or a shader fails to compile (`onCompileFail` gets the `ShaderCompileError`; programs already linked are freed). `still` is a live rendered frame, not a baked image: it runs the full pipeline once at the top level and stops; `low` starts the ladder at level 2; `full` at level 0. A lost WebGL context - stops the scene for good and calls `onContextLost`; nothing rebuilds it, the host decides what to - show. It caps at 30 fps, pauses when + stops the scene for good and calls `onContextLost`; the scene never rebuilds itself. `host.ts` disposes it, drops + `data-scene` so the CSS ground shows, and starts a fresh scene on `webglcontextrestored`. It caps at 30 fps, pauses when the tab is hidden, renders 32 frames and stops under `prefers-reduced-motion` (a change of the preference while it runs freezes or thaws it), and steps down a quality ladder (`budget.ts`) against a 4 ms GPU budget when timer queries exist. diff --git a/src/app/features/scene/host.test.ts b/src/app/features/scene/host.test.ts index 920e98e4..7d6d3cfe 100644 --- a/src/app/features/scene/host.test.ts +++ b/src/app/features/scene/host.test.ts @@ -1,11 +1,11 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { Scene } from './index'; import { mountBackgroundScene } from './host'; const { startBackgroundScene } = vi.hoisted(() => ({ startBackgroundScene: vi.fn() })); vi.mock('./index', () => ({ startBackgroundScene })); -const canvas = {} as HTMLCanvasElement; +let canvas: HTMLCanvasElement; const config = { tier: 'low' as const }; const caps = { hardwareConcurrency: 8 }; @@ -20,6 +20,10 @@ const flush = () => setTimeout(r, 0); }); +beforeEach(() => { + canvas = new EventTarget() as HTMLCanvasElement; +}); + afterEach(() => { startBackgroundScene.mockReset(); }); @@ -103,4 +107,102 @@ describe('mountBackgroundScene', () => { await flush(); expect(scene.login).toHaveBeenCalledOnce(); }); + + describe('a lost WebGL context', () => { + const lose = () => + startBackgroundScene.mock.calls[ + startBackgroundScene.mock.calls.length - 1 + ][2].onContextLost(); + const restore = () => canvas.dispatchEvent(new Event('webglcontextrestored')); + + it('disposes the dead scene and puts the ground back, without rebuilding yet', async () => { + const scene = fakeScene(); + startBackgroundScene.mockResolvedValue(scene); + const onInactive = vi.fn(); + mountBackgroundScene(config, caps, canvas, { loggedIn: false, onInactive }); + await flush(); + lose(); + expect(scene.dispose).toHaveBeenCalledOnce(); + expect(onInactive).toHaveBeenCalledOnce(); + expect(startBackgroundScene).toHaveBeenCalledOnce(); + }); + + it('starts a fresh scene on restore, activates it and logs in when a session exists', async () => { + const first = fakeScene(); + const second = fakeScene(); + startBackgroundScene.mockResolvedValueOnce(first).mockResolvedValueOnce(second); + const onActive = vi.fn(); + mountBackgroundScene(config, caps, canvas, { loggedIn: true, onActive }); + await flush(); + lose(); + restore(); + await flush(); + expect(startBackgroundScene).toHaveBeenCalledTimes(2); + expect(onActive).toHaveBeenCalledTimes(2); + expect(second.login).toHaveBeenCalledOnce(); + expect(first.dispose).toHaveBeenCalledOnce(); + }); + + it('rebuilds on every loss and restore, disposing each dead scene once', async () => { + const scenes = [fakeScene(), fakeScene(), fakeScene()]; + scenes.forEach((s) => startBackgroundScene.mockResolvedValueOnce(s)); + mountBackgroundScene(config, caps, canvas, { loggedIn: false }); + await flush(); + lose(); + restore(); + await flush(); + lose(); + restore(); + await flush(); + expect(scenes.map((s) => s.dispose.mock.calls.length)).toEqual([1, 1, 0]); + }); + + it('ignores a restore while a scene is running', async () => { + startBackgroundScene.mockResolvedValue(fakeScene()); + mountBackgroundScene(config, caps, canvas, { loggedIn: false }); + await flush(); + restore(); + await flush(); + expect(startBackgroundScene).toHaveBeenCalledOnce(); + }); + + it('leaves the ground when the rebuild yields no scene', async () => { + startBackgroundScene.mockResolvedValueOnce(fakeScene()).mockResolvedValueOnce(null); + const onActive = vi.fn(); + mountBackgroundScene(config, caps, canvas, { loggedIn: false, onActive }); + await flush(); + lose(); + restore(); + await flush(); + expect(onActive).toHaveBeenCalledOnce(); + }); + + it('does not rebuild after dispose, and removes its restore listener', async () => { + startBackgroundScene.mockResolvedValue(fakeScene()); + const dispose = mountBackgroundScene(config, caps, canvas, { loggedIn: false }); + await flush(); + lose(); + dispose(); + restore(); + await flush(); + expect(startBackgroundScene).toHaveBeenCalledOnce(); + }); + + it('drops a scene that resolves after a loss that arrived while it started', async () => { + const late = fakeScene(); + let resolve!: (s: Scene) => void; + startBackgroundScene.mockReturnValueOnce( + new Promise((r) => { + resolve = r; + }) + ); + const onActive = vi.fn(); + mountBackgroundScene(config, caps, canvas, { loggedIn: false, onActive }); + lose(); + resolve(late); + await flush(); + expect(late.dispose).toHaveBeenCalledOnce(); + expect(onActive).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/app/features/scene/host.ts b/src/app/features/scene/host.ts index bd28f45c..e771f696 100644 --- a/src/app/features/scene/host.ts +++ b/src/app/features/scene/host.ts @@ -7,6 +7,8 @@ export type HostOptions = { loggedIn: boolean; /** The scene is running: the host may now let it show through. Not called when there is none. */ onActive?: () => void; + /** The scene stopped because its GL context was lost: put the CSS ground back until it restores. */ + onInactive?: () => void; /** A shader failed to compile, the chunk failed to load, or starting threw. The CSS ground stays. */ onFail?: (e: Error) => void; }; @@ -15,34 +17,62 @@ export type HostOptions = { * Start the background scene on `canvas` and return the function that ends it. The start is async, * so a dispose that arrives first wins: the scene is disposed the moment it resolves. A null scene * (tier off, no 2D context, load failure) leaves the canvas untouched and the ground showing. + * + * A lost WebGL context ends the scene it killed: the host disposes it, calls `onInactive` so the + * CSS ground shows, and starts a fresh scene when the canvas's context is restored. The 2D sky + * cannot take over on this canvas (a canvas that has held a WebGL context gives no 2D one), so the + * ground is the fallback while the context is gone; a restore that cannot rebuild leaves it there. */ export function mountBackgroundScene( config: BackgroundSceneConfig | undefined, capabilities: Capabilities, canvas: HTMLCanvasElement, - { loggedIn, onActive, onFail }: HostOptions + { loggedIn, onActive, onInactive, onFail }: HostOptions ): () => void { let cancelled = false; let scene: Scene | null = null; + let generation = 0; - startBackgroundScene(config, capabilities, { canvas, onCompileFail: onFail }).then( - (started) => { - if (cancelled) { - started?.dispose(); - return; + const start = () => { + generation += 1; + const mine = generation; + const stale = () => cancelled || mine !== generation; + startBackgroundScene(config, capabilities, { + canvas, + onCompileFail: onFail, + onContextLost: () => { + if (stale()) return; + generation += 1; + scene?.dispose(); + scene = null; + onInactive?.(); + }, + }).then( + (started) => { + if (stale()) { + started?.dispose(); + return; + } + scene = started; + if (!scene) return; + onActive?.(); + if (loggedIn) scene.login(); + }, + (e) => { + if (!stale()) onFail?.(e instanceof Error ? e : new Error(String(e))); } - scene = started; - if (!scene) return; - onActive?.(); - if (loggedIn) scene.login(); - }, - (e) => { - if (!cancelled) onFail?.(e instanceof Error ? e : new Error(String(e))); - } - ); + ); + }; + + const onRestored = () => { + if (!cancelled && !scene) start(); + }; + canvas.addEventListener('webglcontextrestored', onRestored); + start(); return () => { cancelled = true; + canvas.removeEventListener('webglcontextrestored', onRestored); scene?.dispose(); scene = null; }; From 571a2de3fab9b204c3e9567b15e334ae41116d37 Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 9 Nov 2026 22:10:36 +0000 Subject: [PATCH 158/206] scene: pin the host's restore-listener removal and stale-loss guard; fix preventDefault comment Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/Scene.ts | 5 ++-- src/app/features/scene/host.test.ts | 42 +++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/app/features/scene/Scene.ts b/src/app/features/scene/Scene.ts index de80583c..c2f0423a 100644 --- a/src/app/features/scene/Scene.ts +++ b/src/app/features/scene/Scene.ts @@ -599,8 +599,9 @@ export function createScene(opts: SceneOptions): Scene | null { } listen(window, 'resize', () => wake()); // A lost context takes every texture and program with it and nothing here rebuilds them, so the - // scene ends and the host decides what to show. preventDefault: otherwise the browser never - // restores the context, which is what we want, and a restore would find a dead scene. + // scene ends and the host decides what to show. preventDefault is what lets the browser restore + // the context later (without it a lost context stays lost); the host then builds a new scene on + // `webglcontextrestored`, and this one stays dead. listen(canvas, 'webglcontextlost', (e) => { e.preventDefault(); stop(); diff --git a/src/app/features/scene/host.test.ts b/src/app/features/scene/host.test.ts index 7d6d3cfe..272f502f 100644 --- a/src/app/features/scene/host.test.ts +++ b/src/app/features/scene/host.test.ts @@ -188,6 +188,48 @@ describe('mountBackgroundScene', () => { expect(startBackgroundScene).toHaveBeenCalledOnce(); }); + it('removes the exact restore listener it added when it is disposed', async () => { + const add = vi.spyOn(canvas, 'addEventListener'); + const remove = vi.spyOn(canvas, 'removeEventListener'); + startBackgroundScene.mockResolvedValue(fakeScene()); + const dispose = mountBackgroundScene(config, caps, canvas, { loggedIn: false }); + await flush(); + const added = add.mock.calls.find(([type]) => type === 'webglcontextrestored'); + expect(added).toBeDefined(); + expect(remove).not.toHaveBeenCalled(); + dispose(); + expect(remove).toHaveBeenCalledWith('webglcontextrestored', added?.[1]); + }); + + it('treats a second loss from the same scene as nothing', async () => { + const scene = fakeScene(); + startBackgroundScene.mockResolvedValue(scene); + const onInactive = vi.fn(); + mountBackgroundScene(config, caps, canvas, { loggedIn: false, onInactive }); + await flush(); + lose(); + lose(); + expect(onInactive).toHaveBeenCalledOnce(); + expect(scene.dispose).toHaveBeenCalledOnce(); + }); + + it('ignores the old scene loss callback once a new scene has started', async () => { + const first = fakeScene(); + const second = fakeScene(); + startBackgroundScene.mockResolvedValueOnce(first).mockResolvedValueOnce(second); + const onInactive = vi.fn(); + mountBackgroundScene(config, caps, canvas, { loggedIn: false, onInactive }); + await flush(); + const oldLost = startBackgroundScene.mock.calls[0][2].onContextLost; + lose(); + restore(); + await flush(); + onInactive.mockClear(); + oldLost(); + expect(second.dispose).not.toHaveBeenCalled(); + expect(onInactive).not.toHaveBeenCalled(); + }); + it('drops a scene that resolves after a loss that arrived while it started', async () => { const late = fakeScene(); let resolve!: (s: Scene) => void; From a3a500a3d28b1a0830dd40b580515b1a38ad9e5f Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 9 Nov 2026 22:13:36 +0000 Subject: [PATCH 159/206] scene: skyState test measures ink against both sky ends and takes the worse Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/skyState.test.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/app/features/scene/skyState.test.ts b/src/app/features/scene/skyState.test.ts index 71d35fd0..65e0872d 100644 --- a/src/app/features/scene/skyState.test.ts +++ b/src/app/features/scene/skyState.test.ts @@ -131,22 +131,24 @@ describe('skyBackdrop', () => { }); }); -// Login text and link, per theme, from the card variables in pages/auth/styles.css.ts (the -// fallback pair serves Light, Silver, Dark and Butter) and the sky themes' baked bubble text and -// link. Light ink is measured against the lightest sky it can sit on, dark ink against the darkest. +// Login text and link, per theme, from the card variables in pages/auth/styles.css.ts and the sky +// themes' baked bubble text and link. The Light/Silver/Dark/Butter row uses the ink those themes +// actually render: under data-scene the auth card text is var(--sky-bubble-text, #b7c7d9), not each +// theme's own OnContainer. const inks = [ { theme: 'Light/Silver/Dark/Butter', text: '#b7c7d9', link: '#99c7ff' }, { theme: 'Midnight Sky', text: baked.midnight.bubbles.OtherText, link: baked.midnight.link }, { theme: 'Noonday Sky', text: baked.noonday.bubbles.OtherText, link: baked.noonday.link }, ]; -// WCAG contrast of an ink against a backdrop of the given luminance, with the ink's polarity -// deciding which end of the sky is its worst case. -const ratio = (ink: string, b: SkyBackdrop) => { +// WCAG contrast of an ink against the sky: the worse of its two ends, so a mid-luminance ink is +// measured against whichever end it is closer to. +const against = (ink: string, bg: number) => { const li = luminance(ink); - const bg = li > 0.18 ? b.lightest : b.darkest; const [hi, lo] = li > bg ? [li, bg] : [bg, li]; return (hi + 0.05) / (lo + 0.05); }; +const ratio = (ink: string, b: SkyBackdrop) => + Math.min(against(ink, b.lightest), against(ink, b.darkest)); describe('login ink against the sky backdrop', () => { const rows = SKY_STATES.flatMap((state) => From 32cc8d6ad6226eee879b8c8c3ebfc3e9007e7c9b Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 9 Nov 2026 22:16:39 +0000 Subject: [PATCH 160/206] scene: skyState test clamps the ink into the sky range for its worst case Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/skyState.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/app/features/scene/skyState.test.ts b/src/app/features/scene/skyState.test.ts index 65e0872d..4885abcd 100644 --- a/src/app/features/scene/skyState.test.ts +++ b/src/app/features/scene/skyState.test.ts @@ -140,15 +140,17 @@ const inks = [ { theme: 'Midnight Sky', text: baked.midnight.bubbles.OtherText, link: baked.midnight.link }, { theme: 'Noonday Sky', text: baked.noonday.bubbles.OtherText, link: baked.noonday.link }, ]; -// WCAG contrast of an ink against the sky: the worse of its two ends, so a mid-luminance ink is -// measured against whichever end it is closer to. +// WCAG contrast of an ink against the sky it is worst on. The sky can show any luminance between +// its darkest and lightest, and contrast is lowest where the backdrop is closest to the ink: the +// ink's own luminance clamped into that range. An ink inside the range can land on a backdrop of +// exactly its own luminance (1.00); an ink outside it is worst on the nearer end. const against = (ink: string, bg: number) => { const li = luminance(ink); const [hi, lo] = li > bg ? [li, bg] : [bg, li]; return (hi + 0.05) / (lo + 0.05); }; const ratio = (ink: string, b: SkyBackdrop) => - Math.min(against(ink, b.lightest), against(ink, b.darkest)); + against(ink, Math.min(b.lightest, Math.max(b.darkest, luminance(ink)))); describe('login ink against the sky backdrop', () => { const rows = SKY_STATES.flatMap((state) => From 0e0cff66ae3752cf521272485543a73c472da021 Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 9 Nov 2026 22:20:49 +0000 Subject: [PATCH 161/206] scene: the ridge and terrain scenes follow the sky state The door (index.ts) reads getSkyState, subscribes on start and rebuilds the scene on the same canvas when it changes; the returned scene's dispose unsubscribes. skyForState.ts maps a state to the scene's sky option: auto leaves it unset (today's behaviour), dusk is the ridge dusk, day and night are the baked gradients skyBackdrop bounds. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/index.test.ts | 123 +++++++++++++++++++++ src/app/features/scene/index.ts | 87 ++++++++++++--- src/app/features/scene/skyForState.test.ts | 65 +++++++++++ src/app/features/scene/skyForState.ts | 52 +++++++++ 4 files changed, 310 insertions(+), 17 deletions(-) create mode 100644 src/app/features/scene/index.test.ts create mode 100644 src/app/features/scene/skyForState.test.ts create mode 100644 src/app/features/scene/skyForState.ts diff --git a/src/app/features/scene/index.test.ts b/src/app/features/scene/index.test.ts new file mode 100644 index 00000000..5ea2cdab --- /dev/null +++ b/src/app/features/scene/index.test.ts @@ -0,0 +1,123 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { baked } from '../../../sky/baked'; +import type { Scene } from './Scene'; +import { startBackgroundScene } from './index'; +import { setSkyState } from './skyState'; + +const { createRidgeScene, createScene } = vi.hoisted(() => ({ + createRidgeScene: vi.fn(), + createScene: vi.fn(), +})); +vi.mock('./ridge/Ridge', () => ({ createRidgeScene })); +vi.mock('./Scene', () => ({ createScene })); + +const canvas = {} as HTMLCanvasElement; +const config = { tier: 'low' as const }; +const caps = { hardwareConcurrency: 8 }; +const flush = () => + new Promise((r) => { + setTimeout(r, 0); + }); +const fakeScene = () => ({ login: vi.fn(), dispose: vi.fn() }); +const hex = (c: readonly number[]) => `#${c.map((v) => v.toString(16).padStart(2, '0')).join('')}`; + +beforeEach(() => { + vi.stubGlobal('localStorage', { getItem: () => null, setItem: () => undefined }); + setSkyState('auto'); + createRidgeScene.mockReset(); + createScene.mockReset(); +}); +afterEach(() => { + setSkyState('auto'); + vi.unstubAllGlobals(); +}); + +describe('startBackgroundScene follows the sky state', () => { + it('hands the ridge scene no sky for auto, as before there was a state', async () => { + createRidgeScene.mockReturnValue(fakeScene()); + const scene = await startBackgroundScene(config, caps, { canvas }); + expect(createRidgeScene).toHaveBeenCalledOnce(); + expect(createRidgeScene.mock.calls[0][0].sky).toBeUndefined(); + scene?.dispose(); + }); + + it('starts on the stored state', async () => { + setSkyState('night'); + createRidgeScene.mockReturnValue(fakeScene()); + const scene = await startBackgroundScene(config, caps, { canvas }); + expect(hex(createRidgeScene.mock.calls[0][0].sky.top)).toBe(baked.midnight.gradient[0]); + scene?.dispose(); + }); + + it('rebuilds on a change: the old scene is disposed, the new one draws the new sky', async () => { + const first = fakeScene(); + const second = fakeScene(); + createRidgeScene.mockReturnValueOnce(first).mockReturnValueOnce(second); + const scene = await startBackgroundScene(config, caps, { canvas }); + setSkyState('day'); + await flush(); + expect(first.dispose).toHaveBeenCalledOnce(); + expect(hex(createRidgeScene.mock.calls[1][0].sky.top)).toBe(baked.noonday.gradient[0]); + scene?.dispose(); + expect(second.dispose).toHaveBeenCalledOnce(); + }); + + it('replays a login onto the rebuilt scene, but only if one happened', async () => { + const [a, b, c] = [fakeScene(), fakeScene(), fakeScene()]; + createRidgeScene.mockReturnValueOnce(a).mockReturnValueOnce(b).mockReturnValueOnce(c); + const scene = await startBackgroundScene(config, caps, { canvas }); + setSkyState('day'); + await flush(); + expect(b.login).not.toHaveBeenCalled(); + scene?.login(); + expect(b.login).toHaveBeenCalledOnce(); + setSkyState('dusk'); + await flush(); + expect(c.login).toHaveBeenCalledOnce(); + scene?.dispose(); + }); + + it('removes the subscription on dispose', async () => { + const first = fakeScene(); + createRidgeScene.mockReturnValue(first); + const scene = await startBackgroundScene(config, caps, { canvas }); + scene?.dispose(); + scene?.dispose(); + setSkyState('day'); + await flush(); + expect(createRidgeScene).toHaveBeenCalledOnce(); + expect(first.dispose).toHaveBeenCalledOnce(); + }); + + it('drops a scene whose rebuild finishes after dispose', async () => { + const second = fakeScene(); + createRidgeScene.mockReturnValueOnce(fakeScene()).mockReturnValueOnce(second); + const scene = await startBackgroundScene(config, caps, { canvas }); + setSkyState('day'); + scene?.dispose(); + await flush(); + expect(second.dispose).toHaveBeenCalledOnce(); + }); + + it('keeps only the latest of two quick changes', async () => { + const scenes = [fakeScene(), fakeScene(), fakeScene()]; + scenes.forEach((s) => createRidgeScene.mockReturnValueOnce(s)); + const scene = await startBackgroundScene(config, caps, { canvas }); + setSkyState('day'); + setSkyState('night'); + await flush(); + expect(scenes[1].dispose).toHaveBeenCalledOnce(); + expect(scenes[2].dispose).not.toHaveBeenCalled(); + scene?.dispose(); + }); + + it('leaves the ray-march scene alone: it has one fixed dusk', async () => { + const inner = fakeScene() as unknown as Scene; + createScene.mockReturnValue(inner); + const scene = await startBackgroundScene({ ...config, style: 'raymarch' }, caps, { canvas }); + expect(scene).toBe(inner); + setSkyState('day'); + await flush(); + expect(createScene).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/app/features/scene/index.ts b/src/app/features/scene/index.ts index 794ddc11..7dc5b4f0 100644 --- a/src/app/features/scene/index.ts +++ b/src/app/features/scene/index.ts @@ -1,5 +1,8 @@ import type { Scene, SceneOptions } from './Scene'; -import { BackgroundSceneConfig, Capabilities, resolveTier } from './tier'; +import { BackgroundSceneConfig, Capabilities, SceneTier, resolveTier } from './tier'; +import { getSkyState, subscribe } from './skyState'; +import type { SkyState } from './skyState'; +import { ridgeSkyFor, terrainSkyFor } from './skyForState'; export type { Scene, SceneStats } from './Scene'; export type { SceneStyle, SceneTier } from './tier'; @@ -9,6 +12,33 @@ export type StartOptions = Omit void; }; +async function create( + config: BackgroundSceneConfig | undefined, + options: StartOptions & { tier: SceneTier }, + state: SkyState +): Promise { + let createScene: (o: SceneOptions) => Scene | null; + try { + if (config?.style === 'raymarch') createScene = (await import('./Scene')).createScene; + else if (config?.style === 'terrain') { + const { createTerrainScene } = await import('./terrain/Terrain'); + const sky = terrainSkyFor(state); + const scene = createTerrainScene({ ...options, ...sky }); + // No WebGL2 (or no usable GL): the terrain's sky alone on the same canvas, not bare ground. + if (scene) return scene; + const { createTerrainSkyScene } = await import('./terrain/TerrainSky2D'); + return createTerrainSkyScene({ ...options, ...sky }); + } else { + const { createRidgeScene } = await import('./ridge/Ridge'); + return createRidgeScene({ ...options, sky: ridgeSkyFor(state) }); + } + } catch (e) { + options.onCompileFail?.(e instanceof Error ? e : new Error(String(e))); + return null; + } + return createScene(options); +} + /** * The client's one door to the scene. When the tier resolves to 'off' nothing is imported and no * canvas context is requested; otherwise the scene code loads on demand, so it stays out of the @@ -16,6 +46,11 @@ export type StartOptions = Omit { const tier = resolveTier(config, capabilities); if (tier === 'off') return null; - let createScene: (o: SceneOptions) => Scene | null; - try { - if (config?.style === 'raymarch') createScene = (await import('./Scene')).createScene; - else if (config?.style === 'terrain') { - const { createTerrainScene } = await import('./terrain/Terrain'); - const scene = createTerrainScene({ ...options, tier }); - // No WebGL2 (or no usable GL): the terrain's sky alone on the same canvas, not bare ground. - if (scene) return scene; - const { createTerrainSkyScene } = await import('./terrain/TerrainSky2D'); - return createTerrainSkyScene({ ...options, tier }); - } else createScene = (await import('./ridge/Ridge')).createRidgeScene; - } catch (e) { - options.onCompileFail?.(e instanceof Error ? e : new Error(String(e))); - return null; - } - return createScene({ ...options, tier }); + const build = (state: SkyState) => create(config, { ...options, tier }, state); + const first = await build(getSkyState()); + if (!first || config?.style === 'raymarch') return first; + + let inner: Scene | null = first; + let loggedIn = false; + let disposed = false; + let generation = 0; + const off = subscribe(async (state) => { + generation += 1; + const mine = generation; + inner?.dispose(); + inner = null; + const next = await build(state); + if (disposed || mine !== generation) { + next?.dispose(); + return; + } + inner = next; + if (loggedIn) inner?.login(); + }); + return { + login() { + loggedIn = true; + inner?.login(); + }, + dispose() { + if (disposed) return; + disposed = true; + off(); + inner?.dispose(); + inner = null; + }, + }; } diff --git a/src/app/features/scene/skyForState.test.ts b/src/app/features/scene/skyForState.test.ts new file mode 100644 index 00000000..443e8fdb --- /dev/null +++ b/src/app/features/scene/skyForState.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { baked } from '../../../sky/baked'; +import { luminance } from '../../../sky/skyTokens.js'; +import { skyColours } from './ridge/skyColour'; +import { DUSK_SUN_ELEVATION, ridgeSkyFor, terrainSkyFor } from './skyForState'; +import { skyBackdrop } from './skyState'; + +const hex = (c: readonly number[]) => `#${c.map((v) => v.toString(16).padStart(2, '0')).join('')}`; +const EPS = 1e-9; + +describe('ridgeSkyFor', () => { + it('leaves auto to the scene, so it draws what it drew before there was a state', () => { + expect(ridgeSkyFor('auto')).toBeUndefined(); + }); + + it('draws the current ridge dusk for dusk', () => { + expect(ridgeSkyFor('dusk')).toEqual(skyColours()); + }); + + it('draws the baked gradients for day and night', () => { + const day = ridgeSkyFor('day'); + const night = ridgeSkyFor('night'); + expect(hex(day?.top ?? [])).toBe(baked.noonday.gradient[0]); + expect(hex(day?.horizon ?? [])).toBe(baked.noonday.gradient[6]); + expect(hex(night?.top ?? [])).toBe(baked.midnight.gradient[0]); + expect(hex(night?.horizon ?? [])).toBe(baked.midnight.gradient[6]); + }); + + it.each(['day', 'dusk', 'night'] as const)( + 'paints a %s sky whose colours lie inside skyBackdrop', + (state) => { + const sky = ridgeSkyFor(state); + const { darkest, lightest } = skyBackdrop(state); + [sky?.top, sky?.mid, sky?.horizon].forEach((c) => { + const l = luminance(hex(c ?? [])); + expect(l).toBeGreaterThanOrEqual(darkest - EPS); + expect(l).toBeLessThanOrEqual(lightest + EPS); + }); + } + ); +}); + +describe('terrainSkyFor', () => { + it('leaves auto to the clock and the URL', () => { + expect(terrainSkyFor('auto')).toEqual({}); + }); + + it('names the baked sky for day and night, and a low sun for dusk', () => { + expect(terrainSkyFor('day')).toEqual({ sky: 'noonday' }); + expect(terrainSkyFor('night')).toEqual({ sky: 'midnight' }); + expect(terrainSkyFor('dusk')).toEqual({ sun: { elevation: DUSK_SUN_ELEVATION } }); + }); + + it.each([ + ['day', 'noonday'], + ['night', 'midnight'], + ] as const)('lights %s from a gradient inside skyBackdrop', (state, name) => { + const { darkest, lightest } = skyBackdrop(state); + expect(terrainSkyFor(state).sky).toBe(name); + baked[name].gradient.forEach((c) => { + expect(luminance(c)).toBeGreaterThanOrEqual(darkest - EPS); + expect(luminance(c)).toBeLessThanOrEqual(lightest + EPS); + }); + }); +}); diff --git a/src/app/features/scene/skyForState.ts b/src/app/features/scene/skyForState.ts new file mode 100644 index 00000000..64869440 --- /dev/null +++ b/src/app/features/scene/skyForState.ts @@ -0,0 +1,52 @@ +// What each scene is told for a sky state. 'auto' returns nothing: the scene chooses for itself +// (the ridges' fixed dusk, the terrain's clock with `?sky=` `?sun=` `?az=`), exactly as before +// there was a state. The other states name a sky explicitly, drawn from the same gradients +// `skyBackdrop` bounds, so the colours a scene paints lie inside the luminance range the login +// text was checked against. +import { baked } from '../../../sky/baked'; +import type { SkyName } from './terrain/terrainSky'; +import type { SkyColours, Srgb } from './ridge/skyColour'; +import { skyColours } from './ridge/skyColour'; +import type { SkyState } from './skyState'; + +const rgb = (hex: string): Srgb => + [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)) as unknown as Srgb; + +const fromGradient = (g: readonly string[], ground: Srgb): SkyColours => ({ + top: rgb(g[0]), + mid: rgb(g[Math.floor(g.length / 2)]), + horizon: rgb(g[g.length - 1]), + ground, +}); + +/** Sun elevation in degrees for the terrain's dusk: just above the horizon, between the two bakes (as `?sun=` puts it). */ +export const DUSK_SUN_ELEVATION = 2; + +/** The ridge scene's `sky` option; undefined leaves the scene on `skyColours()`. */ +export function ridgeSkyFor(state: SkyState): SkyColours | undefined { + const { ground } = skyColours(); + switch (state) { + case 'dusk': + return skyColours(); + case 'day': + return fromGradient(baked.noonday.gradient, ground); + case 'night': + return fromGradient(baked.midnight.gradient, ground); + default: + return undefined; + } +} + +/** The terrain scene's `sky` and `sun` options; `{}` leaves the scene on its clock and URL choice. */ +export function terrainSkyFor(state: SkyState): { sky?: SkyName; sun?: { elevation: number } } { + switch (state) { + case 'day': + return { sky: 'noonday' }; + case 'night': + return { sky: 'midnight' }; + case 'dusk': + return { sun: { elevation: DUSK_SUN_ELEVATION } }; + default: + return {}; + } +} From 6c22d4f1dc197e2017c664bf91a917e9abc623a3 Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 9 Nov 2026 22:23:47 +0000 Subject: [PATCH 162/206] scene: a failed sky-state rebuild restores the ground; an explicit sky beats the URL The door tells the host (onInactive/onActive) when a rebuild makes no scene or makes one again. chooseSky takes an explicit sun or sky option before ?sun=, ?az= and ?sky=; only auto, which passes none, is steered by the URL. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/host.test.ts | 14 +++++++++ src/app/features/scene/host.ts | 2 ++ src/app/features/scene/index.test.ts | 28 +++++++++++++++++ src/app/features/scene/index.ts | 10 +++++- .../features/scene/terrain/terrainSky.test.ts | 31 ++++++++++++++++++- src/app/features/scene/terrain/terrainSky.ts | 13 +++++--- 6 files changed, 92 insertions(+), 6 deletions(-) diff --git a/src/app/features/scene/host.test.ts b/src/app/features/scene/host.test.ts index 272f502f..b987d0cc 100644 --- a/src/app/features/scene/host.test.ts +++ b/src/app/features/scene/host.test.ts @@ -108,6 +108,20 @@ describe('mountBackgroundScene', () => { expect(scene.login).toHaveBeenCalledOnce(); }); + it('hands the door its onInactive and onActive, for a sky-state rebuild that makes no scene', async () => { + startBackgroundScene.mockResolvedValue(fakeScene()); + const onInactive = vi.fn(); + const onActive = vi.fn(); + mountBackgroundScene(config, caps, canvas, { loggedIn: false, onInactive, onActive }); + await flush(); + const given = startBackgroundScene.mock.calls[0][2]; + onActive.mockClear(); + given.onInactive(); + given.onActive(); + expect(onInactive).toHaveBeenCalledOnce(); + expect(onActive).toHaveBeenCalledOnce(); + }); + describe('a lost WebGL context', () => { const lose = () => startBackgroundScene.mock.calls[ diff --git a/src/app/features/scene/host.ts b/src/app/features/scene/host.ts index e771f696..9a31c812 100644 --- a/src/app/features/scene/host.ts +++ b/src/app/features/scene/host.ts @@ -40,6 +40,8 @@ export function mountBackgroundScene( startBackgroundScene(config, capabilities, { canvas, onCompileFail: onFail, + onInactive, + onActive, onContextLost: () => { if (stale()) return; generation += 1; diff --git a/src/app/features/scene/index.test.ts b/src/app/features/scene/index.test.ts index 5ea2cdab..684e0c67 100644 --- a/src/app/features/scene/index.test.ts +++ b/src/app/features/scene/index.test.ts @@ -77,6 +77,34 @@ describe('startBackgroundScene follows the sky state', () => { scene?.dispose(); }); + it('puts the ground back when a rebuild makes no scene, and lets it show when one returns', async () => { + const [a, c] = [fakeScene(), fakeScene()]; + createRidgeScene.mockReturnValueOnce(a).mockReturnValueOnce(null).mockReturnValueOnce(c); + const onInactive = vi.fn(); + const onActive = vi.fn(); + const scene = await startBackgroundScene(config, caps, { canvas, onInactive, onActive }); + expect(onInactive).not.toHaveBeenCalled(); + setSkyState('day'); + await flush(); + expect(onInactive).toHaveBeenCalledOnce(); + expect(onActive).not.toHaveBeenCalled(); + setSkyState('dusk'); + await flush(); + expect(onActive).toHaveBeenCalledOnce(); + expect(onInactive).toHaveBeenCalledOnce(); + scene?.dispose(); + }); + + it('does not report a dropped rebuild as inactive', async () => { + createRidgeScene.mockReturnValueOnce(fakeScene()).mockReturnValueOnce(null); + const onInactive = vi.fn(); + const scene = await startBackgroundScene(config, caps, { canvas, onInactive }); + setSkyState('day'); + scene?.dispose(); + await flush(); + expect(onInactive).not.toHaveBeenCalled(); + }); + it('removes the subscription on dispose', async () => { const first = fakeScene(); createRidgeScene.mockReturnValue(first); diff --git a/src/app/features/scene/index.ts b/src/app/features/scene/index.ts index 7dc5b4f0..2319b328 100644 --- a/src/app/features/scene/index.ts +++ b/src/app/features/scene/index.ts @@ -10,6 +10,10 @@ export type { SceneStyle, SceneTier } from './tier'; export type StartOptions = Omit & { /** A shader failed to compile, or the scene chunk failed to load. The host keeps its ground. */ onCompileFail?: (e: Error) => void; + /** A rebuild for a new sky state made no scene: the host puts the CSS ground back (as on a lost context). */ + onInactive?: () => void; + /** A rebuild made a scene again: the host lets it show. */ + onActive?: () => void; }; async function create( @@ -78,7 +82,11 @@ export async function startBackgroundScene( return; } inner = next; - if (loggedIn) inner?.login(); + if (!next) options.onInactive?.(); + else { + options.onActive?.(); + if (loggedIn) next.login(); + } }); return { login() { diff --git a/src/app/features/scene/terrain/terrainSky.test.ts b/src/app/features/scene/terrain/terrainSky.test.ts index 016ee1ce..a6dc7a7e 100644 --- a/src/app/features/scene/terrain/terrainSky.test.ts +++ b/src/app/features/scene/terrain/terrainSky.test.ts @@ -1,8 +1,9 @@ /* eslint-disable no-restricted-syntax -- tests loop over skies, normals and distances */ import { AP_DISTANCE_SCALE } from './terrainScatter'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { baked } from '../../../../sky/baked'; import { + chooseSky, coldness, glowColour, hazeColour, @@ -11,6 +12,7 @@ import { shade, SKY, skyAtSun, + skyData, skyColour, skyNameFor, sunDirection, @@ -298,3 +300,30 @@ describe('ground light and haze', () => { expect(Math.abs(along)).toBeLessThan(0.4); }); }); + +describe('chooseSky: an explicit sky wins over the page URL', () => { + const at = (search: string) => vi.stubGlobal('window', { location: { search } }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('lets the URL steer when no option is given', () => { + at('?sun=40&az=90'); + expect(chooseSky({})).toEqual(skyAtSun(40, 90)); + at('?sky=noonday'); + expect(chooseSky({})).toEqual(skyData('noonday')); + }); + + it('prefers an explicit baked sky to ?sun= and ?sky=', () => { + at('?sun=40&az=90&sky=noonday'); + expect(chooseSky({ sky: 'midnight' })).toEqual(skyData('midnight')); + at('?sky=noonday'); + expect(chooseSky({ sky: 'midnight' })).toEqual(skyData('midnight')); + }); + + it('prefers an explicit sun to ?sun=, and takes no azimuth from ?az=', () => { + at('?sun=40&az=90&sky=noonday'); + expect(chooseSky({ sun: { elevation: 2 } })).toEqual(skyAtSun(2, undefined)); + expect(chooseSky({ sun: { elevation: 2, azimuth: 10 } })).toEqual(skyAtSun(2, 10)); + }); +}); diff --git a/src/app/features/scene/terrain/terrainSky.ts b/src/app/features/scene/terrain/terrainSky.ts index 4034fd93..f6647407 100644 --- a/src/app/features/scene/terrain/terrainSky.ts +++ b/src/app/features/scene/terrain/terrainSky.ts @@ -228,19 +228,24 @@ export function shade(d: SkyData, albedo: Vec3, normal: Vec3, distance: number, }) as unknown as Vec3; } -/** The sky to use: a sun elevation from the option or `?sun=` (with `?az=`), else the baked sky from the option, then `?sky=midnight|noonday`, then the night notion of `skyColours()`. */ +/** + * The sky to use. An explicit option wins over the page URL, so a person who picked a sky sees it: a sun + * elevation (with its azimuth, default noonday's), else a baked sky. Only without either does the URL steer: + * `?sun=` (with `?az=`), then `?sky=midnight|noonday`, then the night notion of `skyColours()`. + */ export function chooseSky(opts: { sky?: SkyName; sun?: { elevation: number; azimuth?: number }; }): SkyData { + if (opts.sun) return skyAtSun(opts.sun.elevation, opts.sun.azimuth); + if (opts.sky) return skyData(opts.sky); const q = new URLSearchParams(window.location.search); const number = (key: string) => (q.get(key)?.trim() ? Number(q.get(key)) : NaN); - const elevation = opts.sun?.elevation ?? number('sun'); + const elevation = number('sun'); if (Number.isFinite(elevation)) { - const azimuth = opts.sun?.azimuth ?? number('az'); + const azimuth = number('az'); return skyAtSun(elevation, Number.isFinite(azimuth) ? azimuth : undefined); } - if (opts.sky) return skyData(opts.sky); const name = q.get('sky'); return skyData(isSkyName(name) ? name : skyNameFor(skyColours())); } From edc6a9a8f15691968aa3c2fc9fb4fb659ad0e794 Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 9 Nov 2026 22:31:00 +0000 Subject: [PATCH 163/206] scene: catch a state change during the first build; skyBackdrop is the union of what the scenes draw index.ts compares the current state with the built one after subscribing and rebuilds if they differ. skyBackdrop unions the ridge sky and the terrain sky (skyColour sampled over the sphere, from chooseSky(terrainSkyFor(state))), so terrain dusk's sun glow and terrain day's darker zenith are inside the range. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/index.test.ts | 14 ++++++ src/app/features/scene/index.ts | 22 +++++---- src/app/features/scene/skyForState.test.ts | 55 +++++++++++++++++++++- src/app/features/scene/skyForState.ts | 48 ++++++++++++++++++- src/app/features/scene/skyState.test.ts | 19 ++++---- src/app/features/scene/skyState.ts | 42 ++++++++--------- 6 files changed, 161 insertions(+), 39 deletions(-) diff --git a/src/app/features/scene/index.test.ts b/src/app/features/scene/index.test.ts index 684e0c67..6c3d034a 100644 --- a/src/app/features/scene/index.test.ts +++ b/src/app/features/scene/index.test.ts @@ -62,6 +62,20 @@ describe('startBackgroundScene follows the sky state', () => { expect(second.dispose).toHaveBeenCalledOnce(); }); + it('rebuilds for a change that landed while the first build was awaited', async () => { + const [a, b] = [fakeScene(), fakeScene()]; + createRidgeScene.mockReturnValueOnce(a).mockReturnValueOnce(b); + const started = startBackgroundScene(config, caps, { canvas }); + setSkyState('day'); + const scene = await started; + await flush(); + expect(createRidgeScene).toHaveBeenCalledTimes(2); + expect(hex(createRidgeScene.mock.calls[1][0].sky.top)).toBe(baked.noonday.gradient[0]); + expect(a.dispose).toHaveBeenCalledOnce(); + scene?.dispose(); + expect(b.dispose).toHaveBeenCalledOnce(); + }); + it('replays a login onto the rebuilt scene, but only if one happened', async () => { const [a, b, c] = [fakeScene(), fakeScene(), fakeScene()]; createRidgeScene.mockReturnValueOnce(a).mockReturnValueOnce(b).mockReturnValueOnce(c); diff --git a/src/app/features/scene/index.ts b/src/app/features/scene/index.ts index 2319b328..3cc42486 100644 --- a/src/app/features/scene/index.ts +++ b/src/app/features/scene/index.ts @@ -64,30 +64,36 @@ export async function startBackgroundScene( const tier = resolveTier(config, capabilities); if (tier === 'off') return null; const build = (state: SkyState) => create(config, { ...options, tier }, state); - const first = await build(getSkyState()); + const state = getSkyState(); + const first = await build(state); if (!first || config?.style === 'raymarch') return first; let inner: Scene | null = first; + let built = state; let loggedIn = false; let disposed = false; let generation = 0; - const off = subscribe(async (state) => { + const rebuild = async (next: SkyState) => { generation += 1; const mine = generation; + built = next; inner?.dispose(); inner = null; - const next = await build(state); + const scene = await build(next); if (disposed || mine !== generation) { - next?.dispose(); + scene?.dispose(); return; } - inner = next; - if (!next) options.onInactive?.(); + inner = scene; + if (!scene) options.onInactive?.(); else { options.onActive?.(); - if (loggedIn) next.login(); + if (loggedIn) scene.login(); } - }); + }; + const off = subscribe(rebuild); + // A change that landed while the first build was awaited was missed by the subscription. + if (getSkyState() !== built) rebuild(getSkyState()); return { login() { loggedIn = true; diff --git a/src/app/features/scene/skyForState.test.ts b/src/app/features/scene/skyForState.test.ts index 443e8fdb..1e09021d 100644 --- a/src/app/features/scene/skyForState.test.ts +++ b/src/app/features/scene/skyForState.test.ts @@ -2,7 +2,14 @@ import { describe, expect, it } from 'vitest'; import { baked } from '../../../sky/baked'; import { luminance } from '../../../sky/skyTokens.js'; import { skyColours } from './ridge/skyColour'; -import { DUSK_SUN_ELEVATION, ridgeSkyFor, terrainSkyFor } from './skyForState'; +import { chooseSky, skyColour } from './terrain/terrainSky'; +import { + DUSK_SUN_ELEVATION, + ridgeDrawn, + ridgeSkyFor, + terrainDrawn, + terrainSkyFor, +} from './skyForState'; import { skyBackdrop } from './skyState'; const hex = (c: readonly number[]) => `#${c.map((v) => v.toString(16).padStart(2, '0')).join('')}`; @@ -63,3 +70,49 @@ describe('terrainSkyFor', () => { }); }); }); + +describe('the terrain sky lies inside skyBackdrop', () => { + // A grid offset from the one skyBackdrop samples, so a miss between its samples shows. + const sweep = (state: 'day' | 'dusk' | 'night') => { + const sky = chooseSky(terrainSkyFor(state)); + const out: number[] = []; + for (let el = 1.3; el <= 90; el += 4.1) { + for (let az = 0.7; az < 360; az += 4.3) { + const e = (el * Math.PI) / 180; + const a = (az * Math.PI) / 180; + const c = skyColour(sky, [ + Math.cos(e) * Math.sin(a), + Math.sin(e), + -Math.cos(e) * Math.cos(a), + ]); + const [r, g, b] = c.map((v) => Math.min(1, Math.max(0, v))); + out.push(0.2126 * r + 0.7152 * g + 0.0722 * b); + } + } + return out; + }; + + it.each(['day', 'dusk', 'night'] as const)('for %s', (state) => { + const { darkest, lightest } = skyBackdrop(state); + const l = sweep(state); + expect(Math.min(...l)).toBeGreaterThanOrEqual(darkest - EPS); + expect(Math.max(...l)).toBeLessThanOrEqual(lightest + EPS); + }); + + it('and the terrain dusk reaches the sun glow the ridge dusk does not', () => { + expect(skyBackdrop('dusk').lightest).toBeGreaterThan(0.9); + expect(skyBackdrop('dusk').lightest).toBeGreaterThan(Math.max(...sweep('night'))); + }); +}); + +describe('both scenes of a state lie inside skyBackdrop', () => { + it.each(['day', 'dusk', 'night'] as const)('%s', (state) => { + const b = skyBackdrop(state); + const r = ridgeDrawn(state); + const t = terrainDrawn(state); + [r, t].forEach((x) => { + expect(x?.darkest).toBeGreaterThanOrEqual(b.darkest - EPS); + expect(x?.lightest).toBeLessThanOrEqual(b.lightest + EPS); + }); + }); +}); diff --git a/src/app/features/scene/skyForState.ts b/src/app/features/scene/skyForState.ts index 64869440..aa3a7803 100644 --- a/src/app/features/scene/skyForState.ts +++ b/src/app/features/scene/skyForState.ts @@ -4,10 +4,12 @@ // `skyBackdrop` bounds, so the colours a scene paints lie inside the luminance range the login // text was checked against. import { baked } from '../../../sky/baked'; -import type { SkyName } from './terrain/terrainSky'; +import { chooseSky, skyColour } from './terrain/terrainSky'; +import type { SkyName, Vec3 } from './terrain/terrainSky'; import type { SkyColours, Srgb } from './ridge/skyColour'; import { skyColours } from './ridge/skyColour'; import type { SkyState } from './skyState'; +import { luminance } from '../../../sky/skyTokens.js'; const rgb = (hex: string): Srgb => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)) as unknown as Srgb; @@ -50,3 +52,47 @@ export function terrainSkyFor(state: SkyState): { sky?: SkyName; sun?: { elevati return {}; } } + +const rgbHex = (c: Srgb) => `#${c.map((v) => v.toString(16).padStart(2, '0')).join('')}`; + +/** Relative luminance (WCAG) of the darkest and lightest colour a scene can draw. */ +export interface Range { + readonly darkest: number; + readonly lightest: number; +} + +/** The ridge sky's range: its top, middle and horizon stops (the canvas blends between them). */ +export function ridgeDrawn(state: SkyState): Range | undefined { + const sky = ridgeSkyFor(state === 'auto' ? 'dusk' : state); + if (!sky) return undefined; + const l = [sky.top, sky.mid, sky.horizon].map((c) => luminance(rgbHex(c))); + return { darkest: Math.min(...l), lightest: Math.max(...l) }; +} + +const SAMPLE_DEG = 3; +const linearLuminance = (c: Vec3) => { + const [r, g, b] = c.map((v) => Math.min(1, Math.max(0, v))); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +}; + +/** + * The terrain sky's range: `skyColour` (the CPU twin of its sky shader) over the whole upper sphere, which the camera + * sweeps as it turns, for the sky the scene picks for this state. Sampled every 3 degrees. + */ +export function terrainDrawn(state: SkyState): Range { + const sky = chooseSky(terrainSkyFor(state)); + let darkest = Infinity; + let lightest = -Infinity; + for (let el = 0; el <= 90; el += SAMPLE_DEG) { + for (let az = 0; az < 360; az += SAMPLE_DEG) { + const e = (el * Math.PI) / 180; + const a = (az * Math.PI) / 180; + const l = linearLuminance( + skyColour(sky, [Math.cos(e) * Math.sin(a), Math.sin(e), -Math.cos(e) * Math.cos(a)]) + ); + darkest = Math.min(darkest, l); + lightest = Math.max(lightest, l); + } + } + return { darkest, lightest }; +} diff --git a/src/app/features/scene/skyState.test.ts b/src/app/features/scene/skyState.test.ts index 4885abcd..7b33a1ba 100644 --- a/src/app/features/scene/skyState.test.ts +++ b/src/app/features/scene/skyState.test.ts @@ -105,15 +105,20 @@ describe('sky state', () => { }); describe('skyBackdrop', () => { - it('orders the fixed skies night < dusk < day by their lightest sky', () => { + it('puts night below day and dusk, which spans from dark to the sun glow', () => { + expect(skyBackdrop('night').lightest).toBeLessThan(skyBackdrop('day').darkest); expect(skyBackdrop('night').lightest).toBeLessThan(skyBackdrop('dusk').lightest); - expect(skyBackdrop('dusk').lightest).toBeLessThan(skyBackdrop('day').lightest); + expect(skyBackdrop('dusk').darkest).toBeLessThan(skyBackdrop('day').darkest); }); - it('is the luminance of the baked gradients for day and night', () => { + it('holds the baked gradients for day and night', () => { const lum = (g: readonly string[]) => g.map(luminance); - expect(skyBackdrop('day').lightest).toBe(Math.max(...lum(baked.noonday.gradient))); - expect(skyBackdrop('night').darkest).toBe(Math.min(...lum(baked.midnight.gradient))); + expect(skyBackdrop('day').lightest).toBeGreaterThanOrEqual( + Math.max(...lum(baked.noonday.gradient)) - 1e-9 + ); + expect(skyBackdrop('night').darkest).toBeLessThanOrEqual( + Math.min(...lum(baked.midnight.gradient)) + 1e-9 + ); }); it('bounds every fixed sky when auto', () => { @@ -171,11 +176,9 @@ describe('login ink against the sky backdrop', () => { }); // Bare sky does not hold either ink in most states: the card behind the login form stays opaque - // (pages/auth/styles.css.ts) for that reason. These are the cells that clear AA; every other + // (pages/auth/styles.css.ts) for that reason. These are the cells that clear AA, with the sky range the scenes can draw; every other // cell is asserted to fail, so a sky or theme change that fixes one turns it red until listed. const clears = new Set([ - 'day|Noonday Sky|text', - 'day|Noonday Sky|link', 'night|Light/Silver/Dark/Butter|text', 'night|Light/Silver/Dark/Butter|link', 'night|Midnight Sky|text', diff --git a/src/app/features/scene/skyState.ts b/src/app/features/scene/skyState.ts index 60e0471f..bc164d7a 100644 --- a/src/app/features/scene/skyState.ts +++ b/src/app/features/scene/skyState.ts @@ -2,9 +2,7 @@ // localStorage key; whoever draws the sky (the scene, the title bar's control) reads it here, and // `skyBackdrop` says how light or dark the sky behind the login text can be in each state, so the // text colours can be checked against it. -import { baked } from '../../../sky/baked'; -import { luminance } from '../../../sky/skyTokens.js'; -import { skyColours } from './ridge/skyColour'; +import { ridgeDrawn, terrainDrawn } from './skyForState'; export type SkyState = 'auto' | 'day' | 'dusk' | 'night'; @@ -59,26 +57,28 @@ export interface SkyBackdrop { readonly lightest: number; } -const hex = (c: readonly number[]) => `#${c.map((v) => v.toString(16).padStart(2, '0')).join('')}`; -const bound = (colours: readonly string[]): SkyBackdrop => { - const l = colours.map(luminance); - return { darkest: Math.min(...l), lightest: Math.max(...l) }; +// The union of every sky a scene draws for the state: the ridge sky and the terrain sky, each from +// the function its scene uses. 'auto' follows the clock (and a URL), so it bounds all three fixed +// states. Computed on first use: the terrain range samples its sky. +const union = (ranges: readonly (SkyBackdrop | undefined)[]): SkyBackdrop => { + const r = ranges.filter((x): x is SkyBackdrop => !!x); + return { + darkest: Math.min(...r.map((x) => x.darkest)), + lightest: Math.max(...r.map((x) => x.lightest)), + }; }; -const FIXED: Record, SkyBackdrop> = (() => { - const dusk = skyColours(); - return { - day: bound(baked.noonday.gradient), - dusk: bound([dusk.top, dusk.mid, dusk.horizon].map(hex)), - night: bound(baked.midnight.gradient), - }; -})(); +const FIXED_STATES = ['day', 'dusk', 'night'] as const; +const cache = new Map(); export function skyBackdrop(state: SkyState): SkyBackdrop { - if (state !== 'auto') return FIXED[state]; - const all = Object.values(FIXED); - return { - darkest: Math.min(...all.map((b) => b.darkest)), - lightest: Math.max(...all.map((b) => b.lightest)), - }; + let b = cache.get(state); + if (!b) { + b = + state === 'auto' + ? union(FIXED_STATES.map(skyBackdrop)) + : union([ridgeDrawn(state), terrainDrawn(state)]); + cache.set(state, b); + } + return b; } From c1803854767b7ab2da40353512ff80002dab75a8 Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 9 Nov 2026 22:10:11 +0000 Subject: [PATCH 164/206] Sky-state control in the estate title bar; the state picks the theme One button beside Find a room opens Auto / Day / Dusk / Night (fits a 320 px bar). Day and Dusk take Noonday Sky's dark ink, Night Midnight Sky's light ink, Auto keeps the theme the page would have had; signed-out pages follow the state too. skyTheme.test.ts checks contrast from the baked tokens: day and night ink clear AA on bare sky, every container carries its text, links clear AA on the surface, and dusk text on bare sky does not, so it stays on surfaces. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/estate/EstateNavigator.tsx | 2 + src/app/features/scene/SkyStateControl.tsx | 80 +++++++++++++++++++++ src/app/features/scene/skyTheme.test.ts | 59 +++++++++++++++ src/app/features/scene/skyTheme.ts | 18 +++++ src/app/hooks/useSkyState.ts | 4 ++ src/app/hooks/useTheme.ts | 11 +++ src/app/pages/ThemeManager.tsx | 10 ++- 7 files changed, 181 insertions(+), 3 deletions(-) create mode 100644 src/app/features/scene/SkyStateControl.tsx create mode 100644 src/app/features/scene/skyTheme.test.ts create mode 100644 src/app/features/scene/skyTheme.ts create mode 100644 src/app/hooks/useSkyState.ts diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx index 1a9ca8ee..2cbbfe4a 100644 --- a/src/app/features/estate/EstateNavigator.tsx +++ b/src/app/features/estate/EstateNavigator.tsx @@ -42,6 +42,7 @@ import { EstateRoom, EstateTier } from './buildEstate'; import { activateEstateRoom, awaitMyJoin } from './joinEstateRoom'; import { useEstate, useEstateRootId } from './useEstate'; import * as css from './EstateNavigator.css'; +import { SkyStateControl } from '../scene/SkyStateControl'; /** How long a tap waits for the client store to hold the room it just joined. */ const JOIN_ARRIVAL_TIMEOUT_MS = 20000; @@ -295,6 +296,7 @@ export function EstateNavigator() { {title} + = { + auto: 'Auto', + day: 'Day', + dusk: 'Dusk', + night: 'Night', +}; + +/** + * One title-bar button that opens the four sky states. A single button, not a row, so the bar + * still fits a 320 px phone beside the search button; the menu items carry the state names. + */ +export function SkyStateControl() { + const state = useSkyState(); + const [cords, setCords] = useState(); + + const open: MouseEventHandler = (evt) => { + setCords(evt.currentTarget.getBoundingClientRect()); + }; + + return ( + setCords(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + + + {SKY_STATES.map((s) => ( + { + setSkyState(s); + setCords(undefined); + }} + after={state === s ? : undefined} + > + + {LABEL[s]} + + + ))} + + + + } + > + + + + + ); +} diff --git a/src/app/features/scene/skyTheme.test.ts b/src/app/features/scene/skyTheme.test.ts new file mode 100644 index 00000000..74b3280b --- /dev/null +++ b/src/app/features/scene/skyTheme.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import { baked } from '../../../sky/baked'; +import { AA, contrast, luminance } from '../../../sky/skyTokens.js'; +import { SKY_STATES, skyBackdrop, type SkyState } from './skyState'; +import { skyThemeId } from './skyTheme'; + +const SKY = { 'midnight-sky': baked.midnight, 'noonday-sky': baked.noonday } as const; +const ratio = (a: number, b: number) => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); + +describe('skyThemeId', () => { + it('leaves the theme alone in auto and picks one for every fixed state', () => { + expect(skyThemeId('auto')).toBeUndefined(); + SKY_STATES.filter((s) => s !== 'auto').forEach((s) => { + expect(skyThemeId(s)).toBeDefined(); + }); + }); + + it('lights day and dusk with dark ink and night with light ink', () => { + expect(skyThemeId('day')).toBe('noonday-sky'); + expect(skyThemeId('dusk')).toBe('noonday-sky'); + expect(skyThemeId('night')).toBe('midnight-sky'); + }); +}); + +describe('contrast of the theme a state picks', () => { + const picked = SKY_STATES.flatMap((s) => { + const id = skyThemeId(s); + return id ? [[s, SKY[id]] as const] : []; + }); + + it.each(picked)('%s: every container carries its text at AA', (_state, sky) => { + Object.values(sky.tokens).forEach((group) => { + expect(contrast(group.OnContainer, group.Container)).toBeGreaterThanOrEqual(AA); + }); + }); + + it.each(picked)('%s: links read at AA on the background surface', (_state, sky) => { + expect(contrast(sky.link, sky.tokens.Background.Container)).toBeGreaterThanOrEqual(AA); + }); + + // Bare sky has no surface under it. Text may sit there only where the ink clears AA against + // both the darkest and the lightest sky the state can show; anywhere else it needs a surface. + const onBareSky = (state: SkyState): number => { + const id = skyThemeId(state); + if (!id) return 0; + const ink = luminance(SKY[id].tokens.Background.OnContainer); + const { darkest, lightest } = skyBackdrop(state); + return Math.min(ratio(ink, darkest), ratio(ink, lightest)); + }; + + it('day and night text on bare sky clears AA', () => { + expect(onBareSky('day')).toBeGreaterThanOrEqual(AA); + expect(onBareSky('night')).toBeGreaterThanOrEqual(AA); + }); + + it('dusk text on bare sky does not, so dusk text stays on surfaces', () => { + expect(onBareSky('dusk')).toBeLessThan(AA); + }); +}); diff --git a/src/app/features/scene/skyTheme.ts b/src/app/features/scene/skyTheme.ts new file mode 100644 index 00000000..ebd4504f --- /dev/null +++ b/src/app/features/scene/skyTheme.ts @@ -0,0 +1,18 @@ +// Which theme a sky state lights the page with. 'auto' picks none: the page keeps the theme it +// would have had (the user's choice when signed in, Midnight Sky when signed out). +// +// Day and Dusk take the dark ink of Noonday Sky, Night the light ink of Midnight Sky. Dusk's +// gradient runs from near-black to bright orange, so no single ink reads on all of it; Dusk +// text therefore only ever sits on a theme surface, never on bare sky (skyTheme.test.ts). +import type { SkyState } from './skyState'; + +export type SkyThemeId = 'midnight-sky' | 'noonday-sky'; + +const BY_STATE: Record, SkyThemeId> = { + day: 'noonday-sky', + dusk: 'noonday-sky', + night: 'midnight-sky', +}; + +export const skyThemeId = (state: SkyState): SkyThemeId | undefined => + state === 'auto' ? undefined : BY_STATE[state]; diff --git a/src/app/hooks/useSkyState.ts b/src/app/hooks/useSkyState.ts new file mode 100644 index 00000000..985e2da2 --- /dev/null +++ b/src/app/hooks/useSkyState.ts @@ -0,0 +1,4 @@ +import { useSyncExternalStore } from 'react'; +import { getSkyState, subscribe, type SkyState } from '../features/scene/skyState'; + +export const useSkyState = (): SkyState => useSyncExternalStore(subscribe, getSkyState); diff --git a/src/app/hooks/useTheme.ts b/src/app/hooks/useTheme.ts index d3663283..bf556dd0 100644 --- a/src/app/hooks/useTheme.ts +++ b/src/app/hooks/useTheme.ts @@ -12,6 +12,8 @@ import '../../gamecult.css'; import '../../sky.css'; import { settingsAtom } from '../state/settings'; import { useSetting } from '../state/hooks/settings'; +import { skyThemeId } from '../features/scene/skyTheme'; +import { useSkyState } from './useSkyState'; export enum ThemeKind { Light = 'light', @@ -100,7 +102,14 @@ export const useSystemThemeKind = (): ThemeKind => { return themeKind; }; +/** The theme the sky state asks for, or undefined while it is 'auto'. */ +export const useSkyTheme = (): Theme | undefined => { + const id = skyThemeId(useSkyState()); + return id === NoondaySkyTheme.id ? NoondaySkyTheme : id && MidnightSkyTheme; +}; + export const useActiveTheme = (): Theme => { + const skyTheme = useSkyTheme(); const systemThemeKind = useSystemThemeKind(); const themes = useThemes(); const [systemTheme] = useSetting(settingsAtom, 'useSystemTheme'); @@ -108,6 +117,8 @@ export const useActiveTheme = (): Theme => { const [lightThemeId] = useSetting(settingsAtom, 'lightThemeId'); const [darkThemeId] = useSetting(settingsAtom, 'darkThemeId'); + if (skyTheme) return skyTheme; + if (!systemTheme) { const selectedTheme = themes.find((theme) => theme.id === themeId) ?? MidnightSkyTheme; diff --git a/src/app/pages/ThemeManager.tsx b/src/app/pages/ThemeManager.tsx index e730b00d..b7c50d93 100644 --- a/src/app/pages/ThemeManager.tsx +++ b/src/app/pages/ThemeManager.tsx @@ -4,17 +4,21 @@ import { MidnightSkyTheme, ThemeContextProvider, useActiveTheme, + useSkyTheme, } from '../hooks/useTheme'; import { useSetting } from '../state/hooks/settings'; import { settingsAtom } from '../state/settings'; export function UnAuthRouteThemeManager() { + // Signed-out pages are Midnight Sky unless the sky state picks otherwise; they ignore the + // system scheme. + const theme = useSkyTheme() ?? MidnightSkyTheme; + useEffect(() => { document.body.className = ''; document.body.classList.add(configClass, varsClass); - // Signed-out pages are always Midnight Sky and ignore the system scheme. - document.body.classList.add(...MidnightSkyTheme.classNames); - }, []); + document.body.classList.add(...theme.classNames); + }, [theme]); return null; } From 3be90875ec37b23ad1abcc4aae8b2f819500691b Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 9 Nov 2026 22:19:58 +0000 Subject: [PATCH 165/206] Sky control: menu semantics, plates under bare-sky text, pinned colours The menu is role=menu with menuitemradio items (aria-checked), the button carries aria-expanded, focus goes to the checked item on open and back to the button on close. Over the scene the estate title bar and any non-bubble message sit on the sign-in card's plate (--sky-bubble) instead of bare sky. skyTheme.test.ts pins the ink colours and WCAG ratios of the picked themes, measured with the ink clamped into the sky range as skyState.test.ts does; the pass/fail set is unchanged. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/estate/EstateNavigator.tsx | 4 +- src/app/features/scene/SkyStateControl.tsx | 10 +- src/app/features/scene/skyPlate.css.ts | 32 ++++++ src/app/features/scene/skyPlate.ts | 9 ++ src/app/features/scene/skyTheme.test.ts | 105 +++++++++++++------- 5 files changed, 119 insertions(+), 41 deletions(-) create mode 100644 src/app/features/scene/skyPlate.css.ts create mode 100644 src/app/features/scene/skyPlate.ts diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx index 2cbbfe4a..8e5cc50c 100644 --- a/src/app/features/estate/EstateNavigator.tsx +++ b/src/app/features/estate/EstateNavigator.tsx @@ -2,6 +2,7 @@ import React, { MouseEventHandler, ReactNode, useCallback, useMemo, useRef } fro import { useNavigate } from 'react-router-dom'; import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import { Avatar, Box, Chip, Icon, IconButton, Icons, Spinner, Text, color, config } from 'folds'; +import classNames from 'classnames'; import { PageNav, PageNavContent, PageNavHeader } from '../../components/page'; import { NavButton, @@ -42,6 +43,7 @@ import { EstateRoom, EstateTier } from './buildEstate'; import { activateEstateRoom, awaitMyJoin } from './joinEstateRoom'; import { useEstate, useEstateRootId } from './useEstate'; import * as css from './EstateNavigator.css'; +import { SkyPlate } from '../scene/skyPlate.css'; import { SkyStateControl } from '../scene/SkyStateControl'; /** How long a tap waits for the client store to hold the room it just joined. */ @@ -290,7 +292,7 @@ export function EstateNavigator() { return ( - + {title} diff --git a/src/app/features/scene/SkyStateControl.tsx b/src/app/features/scene/SkyStateControl.tsx index 64a747fb..5a9d84d6 100644 --- a/src/app/features/scene/SkyStateControl.tsx +++ b/src/app/features/scene/SkyStateControl.tsx @@ -32,7 +32,8 @@ export function SkyStateControl() { content={ setCords(undefined), clickOutsideDeactivates: true, isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', @@ -40,7 +41,7 @@ export function SkyStateControl() { escapeDeactivates: stopPropagation, }} > - + {SKY_STATES.map((s) => ( { setSkyState(s); setCords(undefined); @@ -70,7 +72,7 @@ export function SkyStateControl() { aria-label={`Sky: ${LABEL[state]}`} title={`Sky: ${LABEL[state]}`} aria-haspopup="menu" - aria-pressed={!!cords} + aria-expanded={!!cords} onClick={open} > diff --git a/src/app/features/scene/skyPlate.css.ts b/src/app/features/scene/skyPlate.css.ts new file mode 100644 index 00000000..cb56a75b --- /dev/null +++ b/src/app/features/scene/skyPlate.css.ts @@ -0,0 +1,32 @@ +import { globalStyle, style } from '@vanilla-extract/css'; +import { config } from 'folds'; +import { MessageBase, BubbleContent } from '../../components/message/layout/layout.css'; +import { SKY_PLATE_FALLBACK } from './skyPlate'; + +// Over the scene (html[data-scene]) the signed-in shell is clear, so text outside a chat bubble +// would sit on bare sky, which a sky state can make light, dark or both (dusk): no ink clears +// 4.5:1 on all of that (skyTheme.test.ts). Such text gets a plate instead, never a halo. +const scene = ':root[data-scene]'; +const fill = `var(--sky-bubble, ${SKY_PLATE_FALLBACK.fill})`; +const text = `var(--sky-bubble-text, ${SKY_PLATE_FALLBACK.text})`; + +const plate = { + backgroundColor: fill, + color: text, + borderRadius: config.radii.R400, +} as const; + +/** A title-bar group whose controls and title sit on the plate. */ +export const SkyPlate = style({}); +globalStyle(`${scene} ${SkyPlate}`, { ...plate, padding: `0 ${config.space.S100}` }); +// Controls in the plate take its ink; their themed ink was chosen for the clear shell. +globalStyle(`${scene} ${SkyPlate} button`, { color: 'inherit' }); + +// A message that is not a bubble (Modern, Compact) is plain text on the scene: plate the row. +globalStyle(`${scene} ${MessageBase}:not(:has(.${BubbleContent}))`, plate); + +// Links on a plate read against the plate. The Midnight and Noonday themes set their own +// --tc-link for it; the rest take the fallback, as the sign-in card does. +globalStyle(`${scene} body:not(.gamecult-theme) ${SkyPlate}, ${scene} body:not(.gamecult-theme) ${MessageBase}`, { + vars: { '--tc-link': SKY_PLATE_FALLBACK.link }, +}); diff --git a/src/app/features/scene/skyPlate.ts b/src/app/features/scene/skyPlate.ts new file mode 100644 index 00000000..872e2fd6 --- /dev/null +++ b/src/app/features/scene/skyPlate.ts @@ -0,0 +1,9 @@ +// The plate: the opaque surface text sits on wherever the shell is clear over the background +// scene and a sky state can make the sky behind it anything. It is the sign-in card's colour +// (--sky-bubble, set by the Midnight and Noonday themes in sky.css.ts), so what was checked for +// the card holds here. The other themes take these fallbacks. +export const SKY_PLATE_FALLBACK = { + fill: '#0d1a26', + text: '#b7c7d9', + link: '#99c7ff', +} as const; diff --git a/src/app/features/scene/skyTheme.test.ts b/src/app/features/scene/skyTheme.test.ts index 74b3280b..3fad1f3f 100644 --- a/src/app/features/scene/skyTheme.test.ts +++ b/src/app/features/scene/skyTheme.test.ts @@ -1,18 +1,34 @@ import { describe, expect, it } from 'vitest'; import { baked } from '../../../sky/baked'; import { AA, contrast, luminance } from '../../../sky/skyTokens.js'; -import { SKY_STATES, skyBackdrop, type SkyState } from './skyState'; +import { SKY_PLATE_FALLBACK } from './skyPlate'; +import { SKY_STATES, skyBackdrop, type SkyBackdrop, type SkyState } from './skyState'; import { skyThemeId } from './skyTheme'; const SKY = { 'midnight-sky': baked.midnight, 'noonday-sky': baked.noonday } as const; -const ratio = (a: number, b: number) => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); +const fixed = (['day', 'dusk', 'night'] as const).flatMap((s) => { + const id = skyThemeId(s); + return id ? [[s, id] as const] : []; +}); + +// WCAG contrast of an ink against the sky it is worst on, measured as skyState.test.ts does: the +// sky can show any luminance between its darkest and lightest, and contrast is lowest where the +// backdrop is closest to the ink, which is the ink's own luminance clamped into that range. +const against = (ink: string, bg: number) => { + const li = luminance(ink); + const [hi, lo] = li > bg ? [li, bg] : [bg, li]; + return (hi + 0.05) / (lo + 0.05); +}; +const ratio = (ink: string, b: SkyBackdrop) => + against(ink, Math.min(b.lightest, Math.max(b.darkest, luminance(ink)))); +const r2 = (n: number) => n.toFixed(2); describe('skyThemeId', () => { it('leaves the theme alone in auto and picks one for every fixed state', () => { expect(skyThemeId('auto')).toBeUndefined(); - SKY_STATES.filter((s) => s !== 'auto').forEach((s) => { - expect(skyThemeId(s)).toBeDefined(); - }); + expect(SKY_STATES.filter((s) => s !== 'auto').map((s) => skyThemeId(s))).not.toContain( + undefined + ); }); it('lights day and dusk with dark ink and night with light ink', () => { @@ -22,38 +38,55 @@ describe('skyThemeId', () => { }); }); -describe('contrast of the theme a state picks', () => { - const picked = SKY_STATES.flatMap((s) => { - const id = skyThemeId(s); - return id ? [[s, SKY[id]] as const] : []; +describe('ink of the theme a state picks, on bare sky', () => { + // [state, text ratio, link ratio]; a theme's own ink is not chosen for bare sky, so only day + // and night text pass (Nibu's measurement, skyState.test.ts). Links never rest on bare sky. + const cells: [SkyState, string, string, string, string][] = [ + ['day', 'noonday-sky', '#342d25', '7.62', '#2a4f27'], + ['dusk', 'noonday-sky', '#342d25', '1.00', '#2a4f27'], + ['night', 'midnight-sky', '#aeccff', '5.05', '#609385'], + ]; + + it.each(cells)('%s with %s: ink %s measures %s', (state, id, ink, want, link) => { + const sky = SKY[id as keyof typeof SKY]; + expect(sky.tokens.Background.OnContainer).toBe(ink); + expect(sky.link).toBe(link); + expect(r2(ratio(ink, skyBackdrop(state)))).toBe(want); }); - it.each(picked)('%s: every container carries its text at AA', (_state, sky) => { - Object.values(sky.tokens).forEach((group) => { - expect(contrast(group.OnContainer, group.Container)).toBeGreaterThanOrEqual(AA); - }); - }); - - it.each(picked)('%s: links read at AA on the background surface', (_state, sky) => { - expect(contrast(sky.link, sky.tokens.Background.Container)).toBeGreaterThanOrEqual(AA); - }); - - // Bare sky has no surface under it. Text may sit there only where the ink clears AA against - // both the darkest and the lightest sky the state can show; anywhere else it needs a surface. - const onBareSky = (state: SkyState): number => { - const id = skyThemeId(state); - if (!id) return 0; - const ink = luminance(SKY[id].tokens.Background.OnContainer); - const { darkest, lightest } = skyBackdrop(state); - return Math.min(ratio(ink, darkest), ratio(ink, lightest)); - }; - - it('day and night text on bare sky clears AA', () => { - expect(onBareSky('day')).toBeGreaterThanOrEqual(AA); - expect(onBareSky('night')).toBeGreaterThanOrEqual(AA); - }); - - it('dusk text on bare sky does not, so dusk text stays on surfaces', () => { - expect(onBareSky('dusk')).toBeLessThan(AA); + it('only the cells that clear AA on bare sky carry text there', () => { + const passing = fixed.filter( + ([s, id]) => ratio(SKY[id].tokens.Background.OnContainer, skyBackdrop(s)) >= AA + ); + expect(passing.map(([s]) => s)).toEqual(['day', 'night']); + }); +}); + +describe('the plate, where text cannot rest on bare sky', () => { + // Each theme's plate: fill, ink, link and the WCAG ratios of ink and link on the fill. + const plates = [ + ['midnight-sky', baked.midnight, '12.17', '5.68'], + ['noonday-sky', baked.noonday, '6.70', '4.62'], + ] as const; + + it.each(plates)('%s: ink and link on the fill', (_id, sky, inkRatio, linkRatio) => { + expect(r2(contrast(sky.bubbles.OtherText, sky.bubbles.Other))).toBe(inkRatio); + expect(r2(contrast(sky.link, sky.bubbles.Other))).toBe(linkRatio); + expect(contrast(sky.bubbles.OtherText, sky.bubbles.Other)).toBeGreaterThanOrEqual(AA); + expect(contrast(sky.link, sky.bubbles.Other)).toBeGreaterThanOrEqual(AA); + }); + + it('the other themes take fallback colours that clear AA', () => { + const { fill, text, link } = SKY_PLATE_FALLBACK; + expect(r2(contrast(text, fill))).toBe('10.21'); + expect(r2(contrast(link, fill))).toBe('10.03'); + }); + + it('every container of the picked themes carries its text at AA', () => { + fixed.forEach(([, id]) => { + Object.values(SKY[id].tokens).forEach((g) => { + expect(contrast(g.OnContainer, g.Container)).toBeGreaterThanOrEqual(AA); + }); + }); }); }); From ce16609c768ad41bd129df1f6228941299ffbcc8 Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 9 Nov 2026 22:25:59 +0000 Subject: [PATCH 166/206] Plate every region of bare-sky text under the scene with one rule skyPlate.css.ts joins the room and page headers, the room list, list pages, the typing and Following bars, non-bubble messages and the room intro to the sign-in card's opaque --sky-bubble plate, with the plate's ink and link tokens, at all widths. The estate title bar's own plate goes: the shared header rule covers it. skyTheme.test.ts pins the link ratios on bare sky as well (day 5.25, night 2.36). Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/room-intro/RoomIntro.tsx | 6 +- src/app/features/estate/EstateNavigator.tsx | 4 +- src/app/features/scene/skyPlate.css.ts | 69 +++++++++++++++------ src/app/features/scene/skyTheme.test.ts | 16 ++--- 4 files changed, 64 insertions(+), 31 deletions(-) diff --git a/src/app/components/room-intro/RoomIntro.tsx b/src/app/components/room-intro/RoomIntro.tsx index 2e4e3ebc..0c6b10df 100644 --- a/src/app/components/room-intro/RoomIntro.tsx +++ b/src/app/components/room-intro/RoomIntro.tsx @@ -1,7 +1,9 @@ +import classNames from 'classnames'; import React, { useCallback, useState } from 'react'; import { Avatar, Box, Button, Spinner, Text, as } from 'folds'; import { Room } from 'matrix-js-sdk'; import { useAtomValue } from 'jotai'; +import { SkyPlate } from '../../features/scene/skyPlate.css'; import { IRoomCreateContent, Membership, StateEvent } from '../../../types/matrix/room'; import { getMemberDisplayName, getStateEvent } from '../../utils/room'; import { useMatrixClient } from '../../hooks/useMatrixClient'; @@ -23,7 +25,7 @@ export type RoomIntroProps = { room: Room; }; -export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) => { +export const RoomIntro = as<'div', RoomIntroProps>(({ room, className, ...props }, ref) => { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const { navigateRoom } = useRoomNavigate(); @@ -50,7 +52,7 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) => const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); return ( - + - + {title} diff --git a/src/app/features/scene/skyPlate.css.ts b/src/app/features/scene/skyPlate.css.ts index cb56a75b..102eefd0 100644 --- a/src/app/features/scene/skyPlate.css.ts +++ b/src/app/features/scene/skyPlate.css.ts @@ -1,32 +1,63 @@ import { globalStyle, style } from '@vanilla-extract/css'; -import { config } from 'folds'; +import { color, config } from 'folds'; import { MessageBase, BubbleContent } from '../../components/message/layout/layout.css'; +import { PageContent, PageNavContent } from '../../components/page/style.css'; +import { RoomViewFollowing } from '../room/RoomViewFollowing.css'; +import { RoomViewTyping } from '../room/RoomViewTyping.css'; import { SKY_PLATE_FALLBACK } from './skyPlate'; -// Over the scene (html[data-scene]) the signed-in shell is clear, so text outside a chat bubble -// would sit on bare sky, which a sky state can make light, dark or both (dusk): no ink clears -// 4.5:1 on all of that (skyTheme.test.ts). Such text gets a plate instead, never a halo. +// Over the scene (html[data-scene]) the signed-in shell is clear (sceneShell.css.ts), so text +// outside a chat bubble would sit on bare sky, which a sky state can make light, dark or both +// (dusk): no ink clears 4.5:1 on all of that (skyTheme.test.ts). Every such region is a plate +// instead, the sign-in card's opaque --sky-bubble fill, with its own ink and link, so what was +// checked for the card holds everywhere. One rule, below; a region joins it by class or by tag. const scene = ':root[data-scene]'; const fill = `var(--sky-bubble, ${SKY_PLATE_FALLBACK.fill})`; const text = `var(--sky-bubble-text, ${SKY_PLATE_FALLBACK.text})`; -const plate = { +/** A region with no component class of its own (the room intro) joins the plate with this. */ +export const SkyPlate = style({}); + +// A recipe's first class is its base style. +const base = (recipe: () => string) => `.${recipe().split(' ')[0]}`; + +const regions = [ + `.${SkyPlate}`, + // Title bars: the room header, the room list's header, every page header. + '#root header', + // The room list, and the pages that are lists (explore, inbox, search). + `.${PageNavContent}`, + `.${PageContent}`, + // The bars over the composer. + `.${RoomViewTyping}`, + base(RoomViewFollowing), + // A message that is not a bubble (Modern, Compact) is plain text on the scene. + `${base(MessageBase)}:not(:has(.${BubbleContent}))`, +]; + +// Only inside #root: menus and dialogs render in #portalContainer with their own themed fill. +const within = (ancestor: string) => + regions.map((r) => `${ancestor} ${r.startsWith('#root') ? '' : '#root '}${r}`).join(', '); + +globalStyle(within(scene), { backgroundColor: fill, color: text, - borderRadius: config.radii.R400, -} as const; + // Themed ink was chosen for the clear shell; inside the plate every token reads the plate's. + vars: { + [color.Background.OnContainer]: text, + [color.Surface.OnContainer]: text, + [color.SurfaceVariant.OnContainer]: text, + }, +}); +globalStyle(`${scene} #root header`, { borderRadius: config.radii.R400 }); -/** A title-bar group whose controls and title sit on the plate. */ -export const SkyPlate = style({}); -globalStyle(`${scene} ${SkyPlate}`, { ...plate, padding: `0 ${config.space.S100}` }); -// Controls in the plate take its ink; their themed ink was chosen for the clear shell. -globalStyle(`${scene} ${SkyPlate} button`, { color: 'inherit' }); - -// A message that is not a bubble (Modern, Compact) is plain text on the scene: plate the row. -globalStyle(`${scene} ${MessageBase}:not(:has(.${BubbleContent}))`, plate); - -// Links on a plate read against the plate. The Midnight and Noonday themes set their own -// --tc-link for it; the rest take the fallback, as the sign-in card does. -globalStyle(`${scene} body:not(.gamecult-theme) ${SkyPlate}, ${scene} body:not(.gamecult-theme) ${MessageBase}`, { +// Links read against the plate. The Midnight and Noonday themes set their own --tc-link for it; +// the other themes take the fallback, as the sign-in card does. +globalStyle(within(`${scene} body:not(.gamecult-theme)`), { vars: { '--tc-link': SKY_PLATE_FALLBACK.link }, }); + +globalStyle(`${scene} #root .${SkyPlate}`, { + padding: config.space.S400, + borderRadius: config.radii.R400, +}); diff --git a/src/app/features/scene/skyTheme.test.ts b/src/app/features/scene/skyTheme.test.ts index 3fad1f3f..60a3ae64 100644 --- a/src/app/features/scene/skyTheme.test.ts +++ b/src/app/features/scene/skyTheme.test.ts @@ -39,19 +39,21 @@ describe('skyThemeId', () => { }); describe('ink of the theme a state picks, on bare sky', () => { - // [state, text ratio, link ratio]; a theme's own ink is not chosen for bare sky, so only day - // and night text pass (Nibu's measurement, skyState.test.ts). Links never rest on bare sky. - const cells: [SkyState, string, string, string, string][] = [ - ['day', 'noonday-sky', '#342d25', '7.62', '#2a4f27'], - ['dusk', 'noonday-sky', '#342d25', '1.00', '#2a4f27'], - ['night', 'midnight-sky', '#aeccff', '5.05', '#609385'], + // Per state: the picked theme, its ink and link, and their ratios on bare sky. A theme's own + // ink is not chosen for bare sky, so only day and night text pass (skyState.test.ts), and no + // link does (night 2.36): links never rest on bare sky, they sit on the plate below. + const cells: [SkyState, string, string, string, string, string][] = [ + ['day', 'noonday-sky', '#342d25', '7.62', '#2a4f27', '5.25'], + ['dusk', 'noonday-sky', '#342d25', '1.00', '#2a4f27', '1.00'], + ['night', 'midnight-sky', '#aeccff', '5.05', '#609385', '2.36'], ]; - it.each(cells)('%s with %s: ink %s measures %s', (state, id, ink, want, link) => { + it.each(cells)('%s with %s: ink %s is %s, link %s is %s', (state, id, ink, want, link, wantLink) => { const sky = SKY[id as keyof typeof SKY]; expect(sky.tokens.Background.OnContainer).toBe(ink); expect(sky.link).toBe(link); expect(r2(ratio(ink, skyBackdrop(state)))).toBe(want); + expect(r2(ratio(link, skyBackdrop(state)))).toBe(wantLink); }); it('only the cells that clear AA on bare sky carry text there', () => { From 5862bbdba210a3a51a60240c1d9486ef0b16c16c Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 9 Nov 2026 22:31:43 +0000 Subject: [PATCH 167/206] Plate the composer, members drawer, call view and call status bar They join the shared plate rule under the scene. The composer's own half-strength blurred fill goes (its text fell below 4.5:1 in dusk and some auto themes); the plate has the checked ink. skyTheme.test.ts pins which regions are on the plate. Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/editor/Editor.css.ts | 11 +--------- src/app/features/call/CallView.tsx | 4 +++- src/app/features/scene/skyPlate.css.ts | 14 +++++++++---- src/app/features/scene/skyTheme.test.ts | 27 +++++++++++++++++++++++++ 4 files changed, 41 insertions(+), 15 deletions(-) diff --git a/src/app/components/editor/Editor.css.ts b/src/app/components/editor/Editor.css.ts index 82adb6f4..d128ed07 100644 --- a/src/app/components/editor/Editor.css.ts +++ b/src/app/components/editor/Editor.css.ts @@ -1,4 +1,4 @@ -import { globalStyle, style } from '@vanilla-extract/css'; +import { style } from '@vanilla-extract/css'; import { color, config, DefaultReset, toRem } from 'folds'; export const Editor = style([ @@ -12,15 +12,6 @@ export const Editor = style([ }, ]); -// Over the background scene (html[data-scene]) the composer is a bubble too: half strength, blurred. -globalStyle(`:root[data-scene] ${Editor}`, { - backgroundColor: [ - color.SurfaceVariant.Container, - `color-mix(in srgb, ${color.SurfaceVariant.Container} 50%, transparent)`, - ], - backdropFilter: 'blur(12px)', -}); - export const EditorOptions = style([ DefaultReset, { diff --git a/src/app/features/call/CallView.tsx b/src/app/features/call/CallView.tsx index 1fb97650..430219c6 100644 --- a/src/app/features/call/CallView.tsx +++ b/src/app/features/call/CallView.tsx @@ -1,5 +1,7 @@ import React, { RefObject, useRef } from 'react'; +import classNames from 'classnames'; import { Badge, Box, color, Header, Scroll, Text, toRem } from 'folds'; +import { SkyPlate } from '../scene/skyPlate.css'; import { useCallEmbed, useCallJoined, useCallEmbedPlacementSync } from '../../hooks/useCallEmbed'; import { ContainerColor } from '../../styles/ContainerColor.css'; import { PrescreenControls } from './PrescreenControls'; @@ -162,7 +164,7 @@ export function CallView() { return ( diff --git a/src/app/features/scene/skyPlate.css.ts b/src/app/features/scene/skyPlate.css.ts index 102eefd0..a3c0c567 100644 --- a/src/app/features/scene/skyPlate.css.ts +++ b/src/app/features/scene/skyPlate.css.ts @@ -2,6 +2,9 @@ import { globalStyle, style } from '@vanilla-extract/css'; import { color, config } from 'folds'; import { MessageBase, BubbleContent } from '../../components/message/layout/layout.css'; import { PageContent, PageNavContent } from '../../components/page/style.css'; +import { Editor } from '../../components/editor/Editor.css'; +import { MembersDrawer } from '../room/MembersDrawer.css'; +import { CallStatus } from '../call-status/styles.css'; import { RoomViewFollowing } from '../room/RoomViewFollowing.css'; import { RoomViewTyping } from '../room/RoomViewTyping.css'; import { SKY_PLATE_FALLBACK } from './skyPlate'; @@ -15,19 +18,23 @@ const scene = ':root[data-scene]'; const fill = `var(--sky-bubble, ${SKY_PLATE_FALLBACK.fill})`; const text = `var(--sky-bubble-text, ${SKY_PLATE_FALLBACK.text})`; -/** A region with no component class of its own (the room intro) joins the plate with this. */ +/** A region with no class of its own (the room intro, the call view) joins the plate with this. */ export const SkyPlate = style({}); // A recipe's first class is its base style. const base = (recipe: () => string) => `.${recipe().split(' ')[0]}`; -const regions = [ +export const plateRegions = [ `.${SkyPlate}`, // Title bars: the room header, the room list's header, every page header. '#root header', // The room list, and the pages that are lists (explore, inbox, search). `.${PageNavContent}`, `.${PageContent}`, + // The composer, the members drawer, and the call status bar. + `.${Editor}`, + `.${MembersDrawer}`, + `.${CallStatus}`, // The bars over the composer. `.${RoomViewTyping}`, base(RoomViewFollowing), @@ -37,7 +44,7 @@ const regions = [ // Only inside #root: menus and dialogs render in #portalContainer with their own themed fill. const within = (ancestor: string) => - regions.map((r) => `${ancestor} ${r.startsWith('#root') ? '' : '#root '}${r}`).join(', '); + plateRegions.map((r) => `${ancestor} ${r.startsWith('#root') ? '' : '#root '}${r}`).join(', '); globalStyle(within(scene), { backgroundColor: fill, @@ -58,6 +65,5 @@ globalStyle(within(`${scene} body:not(.gamecult-theme)`), { }); globalStyle(`${scene} #root .${SkyPlate}`, { - padding: config.space.S400, borderRadius: config.radii.R400, }); diff --git a/src/app/features/scene/skyTheme.test.ts b/src/app/features/scene/skyTheme.test.ts index 60a3ae64..53a68c5c 100644 --- a/src/app/features/scene/skyTheme.test.ts +++ b/src/app/features/scene/skyTheme.test.ts @@ -1,7 +1,13 @@ import { describe, expect, it } from 'vitest'; import { baked } from '../../../sky/baked'; import { AA, contrast, luminance } from '../../../sky/skyTokens.js'; +import { Editor } from '../../components/editor/Editor.css'; +import { PageContent, PageNavContent } from '../../components/page/style.css'; +import { CallStatus } from '../call-status/styles.css'; +import { MembersDrawer } from '../room/MembersDrawer.css'; +import { RoomViewTyping } from '../room/RoomViewTyping.css'; import { SKY_PLATE_FALLBACK } from './skyPlate'; +import { SkyPlate, plateRegions } from './skyPlate.css'; import { SKY_STATES, skyBackdrop, type SkyBackdrop, type SkyState } from './skyState'; import { skyThemeId } from './skyTheme'; @@ -92,3 +98,24 @@ describe('the plate, where text cannot rest on bare sky', () => { }); }); }); + +describe('regions that sit on the plate', () => { + // Every region whose text would otherwise rest on bare sky joins the one plate rule. The ink + // and link of that plate are pinned above; this pins who is on it. + const on = (cls: string) => expect(plateRegions).toContain(cls); + + it('plates the composer, the members drawer, the call status bar and the call view', () => { + on(`.${Editor}`); + on(`.${MembersDrawer}`); + on(`.${CallStatus}`); + on(`.${SkyPlate}`); + }); + + it('plates the room list, list pages, typing and Following bars and every header', () => { + on(`.${PageNavContent}`); + on(`.${PageContent}`); + on(`.${RoomViewTyping}`); + on('#root header'); + expect(plateRegions.some((r) => r.includes(':not(:has('))).toBe(true); + }); +}); From 76daf7cdca2cb26262ad56af858c4ef52a4b5ddf Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 9 Nov 2026 22:32:56 +0000 Subject: [PATCH 168/206] Re-pin the bare-sky ratios on skyBackdrop's union ranges #47 makes skyBackdrop the union of the skies the scenes draw: day 0.238-0.919, dusk 0.017-0.965, night 0.058-0.077. Only night text still clears 4.5:1 on bare sky (5.05); day falls to 3.71 text / 2.56 link. Everything outside a bubble stays on the plate. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/skyTheme.test.ts | 13 +++++++------ src/app/features/scene/skyTheme.ts | 7 ++++--- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/app/features/scene/skyTheme.test.ts b/src/app/features/scene/skyTheme.test.ts index 53a68c5c..3fd06875 100644 --- a/src/app/features/scene/skyTheme.test.ts +++ b/src/app/features/scene/skyTheme.test.ts @@ -45,11 +45,12 @@ describe('skyThemeId', () => { }); describe('ink of the theme a state picks, on bare sky', () => { - // Per state: the picked theme, its ink and link, and their ratios on bare sky. A theme's own - // ink is not chosen for bare sky, so only day and night text pass (skyState.test.ts), and no - // link does (night 2.36): links never rest on bare sky, they sit on the plate below. + // Per state: the picked theme, its ink and link, and their ratios on bare sky, over the union + // of the skies the scenes draw (skyBackdrop). Only night text passes (5.05); day's sky reaches + // 0.919 and dusk's 0.017 to 0.965, so no theme's ink holds there. No link passes anywhere + // (night 2.36): nothing rests on bare sky, everything sits on the plate below. const cells: [SkyState, string, string, string, string, string][] = [ - ['day', 'noonday-sky', '#342d25', '7.62', '#2a4f27', '5.25'], + ['day', 'noonday-sky', '#342d25', '3.71', '#2a4f27', '2.56'], ['dusk', 'noonday-sky', '#342d25', '1.00', '#2a4f27', '1.00'], ['night', 'midnight-sky', '#aeccff', '5.05', '#609385', '2.36'], ]; @@ -62,11 +63,11 @@ describe('ink of the theme a state picks, on bare sky', () => { expect(r2(ratio(link, skyBackdrop(state)))).toBe(wantLink); }); - it('only the cells that clear AA on bare sky carry text there', () => { + it('night is the only state whose text clears AA on bare sky', () => { const passing = fixed.filter( ([s, id]) => ratio(SKY[id].tokens.Background.OnContainer, skyBackdrop(s)) >= AA ); - expect(passing.map(([s]) => s)).toEqual(['day', 'night']); + expect(passing.map(([s]) => s)).toEqual(['night']); }); }); diff --git a/src/app/features/scene/skyTheme.ts b/src/app/features/scene/skyTheme.ts index ebd4504f..8854fcd4 100644 --- a/src/app/features/scene/skyTheme.ts +++ b/src/app/features/scene/skyTheme.ts @@ -1,9 +1,10 @@ // Which theme a sky state lights the page with. 'auto' picks none: the page keeps the theme it // would have had (the user's choice when signed in, Midnight Sky when signed out). // -// Day and Dusk take the dark ink of Noonday Sky, Night the light ink of Midnight Sky. Dusk's -// gradient runs from near-black to bright orange, so no single ink reads on all of it; Dusk -// text therefore only ever sits on a theme surface, never on bare sky (skyTheme.test.ts). +// Day and Dusk take the dark ink of Noonday Sky, Night the light ink of Midnight Sky. On bare sky +// only Night's text clears 4.5:1 (5.05); Day's sky reaches 0.919 luminance and Dusk's spans +// 0.017 to 0.965, so no ink holds there. Text therefore never rests on bare sky: every region +// that would is a plate (skyPlate.css.ts), and skyTheme.test.ts pins the numbers. import type { SkyState } from './skyState'; export type SkyThemeId = 'midnight-sky' | 'noonday-sky'; From 0d26b7186210e38208fc4bcbc79c0df97e8099fa Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 9 Nov 2026 22:37:06 +0000 Subject: [PATCH 169/206] scene: keep the terrain sky and baked tables out of the main chunk skyBackdrop reads constants (the union of the ridge and terrain skies, rounded outward) instead of computing it, and the door imports skyForState lazily with the scenes. A test fails when the constants drift from the computed union. Main entry chunk vs dev: +0.4 KB gzip, down from +51.5 KB. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/index.ts | 3 +- src/app/features/scene/skyForState.test.ts | 33 ++++++++++++++++++++ src/app/features/scene/skyState.ts | 35 +++++++--------------- 3 files changed, 46 insertions(+), 25 deletions(-) diff --git a/src/app/features/scene/index.ts b/src/app/features/scene/index.ts index 3cc42486..55b00e86 100644 --- a/src/app/features/scene/index.ts +++ b/src/app/features/scene/index.ts @@ -2,7 +2,6 @@ import type { Scene, SceneOptions } from './Scene'; import { BackgroundSceneConfig, Capabilities, SceneTier, resolveTier } from './tier'; import { getSkyState, subscribe } from './skyState'; import type { SkyState } from './skyState'; -import { ridgeSkyFor, terrainSkyFor } from './skyForState'; export type { Scene, SceneStats } from './Scene'; export type { SceneStyle, SceneTier } from './tier'; @@ -23,6 +22,8 @@ async function create( ): Promise { let createScene: (o: SceneOptions) => Scene | null; try { + // Loaded here, with the scenes, so the main chunk does not carry the terrain sky or the baked tables. + const { ridgeSkyFor, terrainSkyFor } = await import('./skyForState'); if (config?.style === 'raymarch') createScene = (await import('./Scene')).createScene; else if (config?.style === 'terrain') { const { createTerrainScene } = await import('./terrain/Terrain'); diff --git a/src/app/features/scene/skyForState.test.ts b/src/app/features/scene/skyForState.test.ts index 1e09021d..aff09b1e 100644 --- a/src/app/features/scene/skyForState.test.ts +++ b/src/app/features/scene/skyForState.test.ts @@ -116,3 +116,36 @@ describe('both scenes of a state lie inside skyBackdrop', () => { }); }); }); + +describe('skyBackdrop holds the union of what the scenes draw', () => { + const computed = (state: 'day' | 'dusk' | 'night') => { + const r = [ridgeDrawn(state), terrainDrawn(state)].filter((x) => x !== undefined) as { + darkest: number; + lightest: number; + }[]; + return { + darkest: Math.min(...r.map((x) => x.darkest)), + lightest: Math.max(...r.map((x) => x.lightest)), + }; + }; + + it.each(['day', 'dusk', 'night'] as const)( + '%s: the constant bounds the computed range and sits within 1e-5 of it', + (state) => { + const c = computed(state); + const b = skyBackdrop(state); + expect(b.darkest).toBeLessThanOrEqual(c.darkest); + expect(b.lightest).toBeGreaterThanOrEqual(c.lightest); + expect(c.darkest - b.darkest).toBeLessThan(1e-5); + expect(b.lightest - c.lightest).toBeLessThan(1e-5); + } + ); + + it('auto is the union of the three fixed states', () => { + const all = (['day', 'dusk', 'night'] as const).map(skyBackdrop); + expect(skyBackdrop('auto')).toEqual({ + darkest: Math.min(...all.map((x) => x.darkest)), + lightest: Math.max(...all.map((x) => x.lightest)), + }); + }); +}); diff --git a/src/app/features/scene/skyState.ts b/src/app/features/scene/skyState.ts index bc164d7a..a6d1ce8e 100644 --- a/src/app/features/scene/skyState.ts +++ b/src/app/features/scene/skyState.ts @@ -2,8 +2,6 @@ // localStorage key; whoever draws the sky (the scene, the title bar's control) reads it here, and // `skyBackdrop` says how light or dark the sky behind the login text can be in each state, so the // text colours can be checked against it. -import { ridgeDrawn, terrainDrawn } from './skyForState'; - export type SkyState = 'auto' | 'day' | 'dusk' | 'night'; export const SKY_STATES: readonly SkyState[] = ['auto', 'day', 'dusk', 'night']; @@ -58,27 +56,16 @@ export interface SkyBackdrop { } // The union of every sky a scene draws for the state: the ridge sky and the terrain sky, each from -// the function its scene uses. 'auto' follows the clock (and a URL), so it bounds all three fixed -// states. Computed on first use: the terrain range samples its sky. -const union = (ranges: readonly (SkyBackdrop | undefined)[]): SkyBackdrop => { - const r = ranges.filter((x): x is SkyBackdrop => !!x); - return { - darkest: Math.min(...r.map((x) => x.darkest)), - lightest: Math.max(...r.map((x) => x.lightest)), - }; +// the function its scene uses (`ridgeDrawn`, `terrainDrawn` in skyForState.ts). Held as constants so +// this module, which the main chunk loads, does not pull in the terrain sky and the baked scattering +// tables with it; skyForState.test.ts fails when they drift from the computed union. +// Rounded outward to six places. 'auto' follows the clock (and a URL), so it +// bounds all three fixed states. +const BACKDROPS: Record = { + auto: { darkest: 0.016727, lightest: 0.964723 }, + day: { darkest: 0.237514, lightest: 0.919226 }, + dusk: { darkest: 0.016727, lightest: 0.964723 }, + night: { darkest: 0.058099, lightest: 0.077467 }, }; -const FIXED_STATES = ['day', 'dusk', 'night'] as const; -const cache = new Map(); - -export function skyBackdrop(state: SkyState): SkyBackdrop { - let b = cache.get(state); - if (!b) { - b = - state === 'auto' - ? union(FIXED_STATES.map(skyBackdrop)) - : union([ridgeDrawn(state), terrainDrawn(state)]); - cache.set(state, b); - } - return b; -} +export const skyBackdrop = (state: SkyState): SkyBackdrop => BACKDROPS[state]; From 8a05668d8483e83bbf1e83036864533e967c76f2 Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 9 Nov 2026 22:41:27 +0000 Subject: [PATCH 170/206] scene: terrain backdrop range found by refinement, not a grid terrainDrawn finds the sun glow peak and the darkest patch with a compass search from the best coarse samples; constants regenerated (Day 0.237309-0.92805, Dusk 0.016727-0.967912). The drift test sweeps its own grid (golden-angle spiral plus a 0.05 degree patch around the sun). Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/skyForState.test.ts | 38 +++++++----- src/app/features/scene/skyForState.ts | 68 ++++++++++++++++++---- src/app/features/scene/skyState.ts | 6 +- 3 files changed, 85 insertions(+), 27 deletions(-) diff --git a/src/app/features/scene/skyForState.test.ts b/src/app/features/scene/skyForState.test.ts index aff09b1e..44ca5899 100644 --- a/src/app/features/scene/skyForState.test.ts +++ b/src/app/features/scene/skyForState.test.ts @@ -72,31 +72,43 @@ describe('terrainSkyFor', () => { }); describe('the terrain sky lies inside skyBackdrop', () => { - // A grid offset from the one skyBackdrop samples, so a miss between its samples shows. + // Not the generator's grid: a golden-angle spiral over the upper sphere (no shared rows or columns) + // plus a 0.05 degree grid across the sun, five times finer than the generator's. + const luma = (c: readonly number[]) => { + const [r, g, b] = c.map((v) => Math.min(1, Math.max(0, v))); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; + }; const sweep = (state: 'day' | 'dusk' | 'night') => { const sky = chooseSky(terrainSkyFor(state)); + const rad = Math.PI / 180; + const dirAt = (el: number, az: number): [number, number, number] => [ + Math.cos(el * rad) * Math.sin(az * rad), + Math.sin(el * rad), + -Math.cos(el * rad) * Math.cos(az * rad), + ]; const out: number[] = []; - for (let el = 1.3; el <= 90; el += 4.1) { - for (let az = 0.7; az < 360; az += 4.3) { - const e = (el * Math.PI) / 180; - const a = (az * Math.PI) / 180; - const c = skyColour(sky, [ - Math.cos(e) * Math.sin(a), - Math.sin(e), - -Math.cos(e) * Math.cos(a), - ]); - const [r, g, b] = c.map((v) => Math.min(1, Math.max(0, v))); - out.push(0.2126 * r + 0.7152 * g + 0.0722 * b); + const N = 20000; + for (let i = 0; i < N; i += 1) { + const y = (i + 0.5) / N; // sin(elevation): uniform over the upper hemisphere + out.push(luma(skyColour(sky, dirAt(Math.asin(y) / rad, (i * 137.50776405) % 360)))); + } + for (let dEl = -3; dEl <= 3; dEl += 0.05) { + for (let dAz = -3; dAz <= 3; dAz += 0.05) { + out.push(luma(skyColour(sky, dirAt(Math.max(0, sky.elevation + dEl), sky.azimuth + dAz)))); } } return out; }; - it.each(['day', 'dusk', 'night'] as const)('for %s', (state) => { + it.each(['day', 'dusk', 'night'] as const)('for %s, and no looser than 5e-4 from it', (state) => { const { darkest, lightest } = skyBackdrop(state); const l = sweep(state); expect(Math.min(...l)).toBeGreaterThanOrEqual(darkest - EPS); expect(Math.max(...l)).toBeLessThanOrEqual(lightest + EPS); + const top = Math.max(Math.max(...l), ridgeDrawn(state)?.lightest ?? 0); + expect(lightest - top).toBeLessThan(5e-4); + const bottom = Math.min(Math.min(...l), ridgeDrawn(state)?.darkest ?? 1); + expect(bottom - darkest).toBeLessThan(5e-4); }); it('and the terrain dusk reaches the sun glow the ridge dusk does not', () => { diff --git a/src/app/features/scene/skyForState.ts b/src/app/features/scene/skyForState.ts index aa3a7803..a6d78e4a 100644 --- a/src/app/features/scene/skyForState.ts +++ b/src/app/features/scene/skyForState.ts @@ -70,6 +70,10 @@ export function ridgeDrawn(state: SkyState): Range | undefined { } const SAMPLE_DEG = 3; +const SUN_DEG = 0.25; +const SUN_SPAN_DEG = 8; +const HORIZON_DEG = 0.5; +const REFINE_FROM = 6; const linearLuminance = (c: Vec3) => { const [r, g, b] = c.map((v) => Math.min(1, Math.max(0, v))); return 0.2126 * r + 0.7152 * g + 0.0722 * b; @@ -77,22 +81,64 @@ const linearLuminance = (c: Vec3) => { /** * The terrain sky's range: `skyColour` (the CPU twin of its sky shader) over the whole upper sphere, which the camera - * sweeps as it turns, for the sky the scene picks for this state. Sampled every 3 degrees. + * sweeps as it turns, for the sky the scene picks for this state. A 3 degree grid, a 0.5 degree ring of the horizon, the + * zenith and a 0.25 degree patch around the sun find where the extremes are; each of the best few is then refined by a + * compass search whose step halves from 1 degree to under 0.001, because the sun's glow peaks (and the sky's darkest + * patch sits) between any fixed grid's samples. */ export function terrainDrawn(state: SkyState): Range { const sky = chooseSky(terrainSkyFor(state)); - let darkest = Infinity; - let lightest = -Infinity; + const rad = Math.PI / 180; + const lum = (el: number, az: number) => { + const e = Math.min(90, Math.max(0, el)) * rad; + const a = az * rad; + return linearLuminance( + skyColour(sky, [Math.cos(e) * Math.sin(a), Math.sin(e), -Math.cos(e) * Math.cos(a)]) + ); + }; + const seen: { el: number; az: number; l: number }[] = []; + const at = (el: number, az: number) => seen.push({ el, az, l: lum(el, az) }); for (let el = 0; el <= 90; el += SAMPLE_DEG) { - for (let az = 0; az < 360; az += SAMPLE_DEG) { - const e = (el * Math.PI) / 180; - const a = (az * Math.PI) / 180; - const l = linearLuminance( - skyColour(sky, [Math.cos(e) * Math.sin(a), Math.sin(e), -Math.cos(e) * Math.cos(a)]) - ); - darkest = Math.min(darkest, l); - lightest = Math.max(lightest, l); + for (let az = 0; az < 360; az += SAMPLE_DEG) at(el, az); + } + for (let az = 0; az < 360; az += HORIZON_DEG) at(0, az); + at(90, 0); + for (let dEl = -SUN_SPAN_DEG; dEl <= SUN_SPAN_DEG; dEl += SUN_DEG) { + for (let dAz = -SUN_SPAN_DEG; dAz <= SUN_SPAN_DEG; dAz += SUN_DEG) { + at(sky.elevation + dEl, sky.azimuth + dAz); } } + type Point = { el: number; az: number; l: number }; + const around = (p: Point, step: number): Point[] => + [ + [step, 0], + [-step, 0], + [0, step], + [0, -step], + ].map(([dEl, dAz]) => { + const el = Math.min(90, Math.max(0, p.el + dEl)); + return { el, az: p.az + dAz, l: lum(el, p.az + dAz) }; + }); + const refine = (from: Point, sign: 1 | -1) => { + let best = from; + for (let step = 1; step > 0.0005; step /= 2) { + let improved = true; + while (improved) { + const cur = best; + best = around(cur, step).reduce((b, c) => (sign * c.l > sign * b.l ? c : b), cur); + improved = best !== cur; + } + } + return best.l; + }; + const byLight = [...seen].sort((p, q) => q.l - p.l); + const lightest = Math.max( + byLight[0].l, + ...byLight.slice(0, REFINE_FROM).map((p) => refine(p, 1)) + ); + const darkest = Math.min( + byLight[byLight.length - 1].l, + ...byLight.slice(-REFINE_FROM).map((p) => refine(p, -1)) + ); return { darkest, lightest }; } diff --git a/src/app/features/scene/skyState.ts b/src/app/features/scene/skyState.ts index a6d1ce8e..e06e9845 100644 --- a/src/app/features/scene/skyState.ts +++ b/src/app/features/scene/skyState.ts @@ -62,9 +62,9 @@ export interface SkyBackdrop { // Rounded outward to six places. 'auto' follows the clock (and a URL), so it // bounds all three fixed states. const BACKDROPS: Record = { - auto: { darkest: 0.016727, lightest: 0.964723 }, - day: { darkest: 0.237514, lightest: 0.919226 }, - dusk: { darkest: 0.016727, lightest: 0.964723 }, + auto: { darkest: 0.016727, lightest: 0.967912 }, + day: { darkest: 0.237309, lightest: 0.92805 }, + dusk: { darkest: 0.016727, lightest: 0.967912 }, night: { darkest: 0.058099, lightest: 0.077467 }, }; From 8f8536a805c0c17cae506bc4d1ca1cb61216fb70 Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 9 Nov 2026 22:50:10 +0000 Subject: [PATCH 171/206] CI: cancel superseded runs with workflow concurrency groups The forge runner takes one job at a time, so every superseded push or PR sync piled up behind the one before it and the dev preview run waited behind runs for merged heads. Pull-request workflows get a group per workflow and PR number; the dev preview gets its own group, so a newer dev push cancels an older one and PR runs never cancel it. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/build-pull-request.yml | 4 ++++ .github/workflows/docker-pr.yml | 4 ++++ .github/workflows/lockfile.yml | 4 ++++ .github/workflows/rat-dev-preview.yml | 7 +++++++ 4 files changed, 19 insertions(+) diff --git a/.github/workflows/build-pull-request.yml b/.github/workflows/build-pull-request.yml index 4ff842ed..964add54 100644 --- a/.github/workflows/build-pull-request.yml +++ b/.github/workflows/build-pull-request.yml @@ -4,6 +4,10 @@ on: pull_request: types: ['opened', 'synchronize'] +concurrency: + group: ${{ github.workflow }}-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + jobs: build-pull-request: name: Build pull request diff --git a/.github/workflows/docker-pr.yml b/.github/workflows/docker-pr.yml index 997aba6d..24b9389c 100644 --- a/.github/workflows/docker-pr.yml +++ b/.github/workflows/docker-pr.yml @@ -6,6 +6,10 @@ on: - 'Dockerfile' - '.github/workflows/docker-pr.yml' +concurrency: + group: ${{ github.workflow }}-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + jobs: docker-build: runs-on: ubuntu-latest diff --git a/.github/workflows/lockfile.yml b/.github/workflows/lockfile.yml index a9cc1a34..e96d2818 100644 --- a/.github/workflows/lockfile.yml +++ b/.github/workflows/lockfile.yml @@ -5,6 +5,10 @@ on: paths: - 'package-lock.json' +concurrency: + group: ${{ github.workflow }}-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + jobs: lockfile_changes: runs-on: ubuntu-latest diff --git a/.github/workflows/rat-dev-preview.yml b/.github/workflows/rat-dev-preview.yml index 45da8bc4..47f22b7e 100644 --- a/.github/workflows/rat-dev-preview.yml +++ b/.github/workflows/rat-dev-preview.yml @@ -4,6 +4,13 @@ on: push: branches: ['dev'] +# One run per ref wins: a newer push to dev cancels the older queued or running one, so the run +# that builds rat-dev-dist is always the newest. Pull-request runs are other workflows and other +# groups; they never cancel it. +concurrency: + group: rat-dev-preview + cancel-in-progress: true + jobs: build: name: Build dev preview From 0c6d4476bece39fcb81ef7c78a0ae9a084459771 Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 9 Nov 2026 23:15:13 +0000 Subject: [PATCH 172/206] trainingNotice: say whose ruling the wording is The comment said the wording awaits Meta's legal read. The notice, its wording and the registration acknowledgement are Meta's ruling of 2026-11-06; the comment now says so. Comment only. Co-Authored-By: Claude Sonnet 5.5 --- src/app/utils/trainingNotice.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/app/utils/trainingNotice.ts b/src/app/utils/trainingNotice.ts index e2c1de00..16215249 100644 --- a/src/app/utils/trainingNotice.ts +++ b/src/app/utils/trainingNotice.ts @@ -3,8 +3,11 @@ import { StateEvent } from '../../types/matrix/room'; export const TRAINING_NOTICE_TEXT = "Everything said here may be used to train GameCult's AI stewards."; +// The notice, its wording and the registration acknowledgement are Meta's ruling of 2026-11-06: +// everything said on the server may be used to train GameCult's AI stewards; shown at registration +// only; no mention of Discord; existing members are not re-asked. The wording is hers to change. // The preview build runs against a pretend server where nothing is trained, so the real sentence -// would be false there. The real wording awaits Meta's legal read (Heimdall's consent text, open). +// would be false there. export const PREVIEW_NOTICE_TEXT = 'Pretend server: nothing you type here is stored or used to train anything.'; From 5d3db9e1f26d32871def08180a2785e6d8ed6ae6 Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 9 Nov 2026 23:18:08 +0000 Subject: [PATCH 173/206] scene: shape each sky state's drawn sky into a luminance band its text ink clears Day and dusk: floor 0.30 (dark ink needs 0.2983). Night: ceiling 0.0931 for the light ink; the baked night sky is kept. One shaping function (skyBand.shapeToBand) with a GLSL twin lifts the terrain sky toward a tint (hue kept) or scales it down. The ridge dusk becomes lavender, rose and orange. 'auto' resolves from the clock (sun.js elevation) to day, dusk or night; subscribers hear the resolved sky; skyBackdrop('auto') is the resolved state's band. Link ink is not in the band (Void's wiring). Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/index.test.ts | 9 +- src/app/features/scene/index.ts | 17 +- src/app/features/scene/ridge/skyColour.ts | 12 +- src/app/features/scene/ridge/stars.test.ts | 5 +- src/app/features/scene/skyBand.test.ts | 83 +++++++ src/app/features/scene/skyBand.ts | 76 ++++++ src/app/features/scene/skyForState.test.ts | 230 ++++++++++-------- src/app/features/scene/skyForState.ts | 78 +++--- src/app/features/scene/skyState.test.ts | 126 +++++----- src/app/features/scene/skyState.ts | 89 +++++-- src/app/features/scene/skyTheme.test.ts | 76 +++--- src/app/features/scene/skyTheme.ts | 24 +- src/app/features/scene/terrain/Terrain.ts | 9 + .../scene/terrain/TerrainSky2D.test.ts | 30 +++ .../features/scene/terrain/TerrainSky2D.ts | 2 +- .../features/scene/terrain/terrainShaders.ts | 25 +- .../features/scene/terrain/terrainSky.test.ts | 7 - src/app/features/scene/terrain/terrainSky.ts | 37 ++- src/app/hooks/useSkyState.ts | 13 +- src/app/hooks/useTheme.ts | 6 +- 20 files changed, 650 insertions(+), 304 deletions(-) create mode 100644 src/app/features/scene/skyBand.test.ts create mode 100644 src/app/features/scene/skyBand.ts diff --git a/src/app/features/scene/index.test.ts b/src/app/features/scene/index.test.ts index 6c3d034a..645b093c 100644 --- a/src/app/features/scene/index.test.ts +++ b/src/app/features/scene/index.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { baked } from '../../../sky/baked'; import type { Scene } from './Scene'; import { startBackgroundScene } from './index'; -import { setSkyState } from './skyState'; +import { setSkyClock, setSkyState } from './skyState'; const { createRidgeScene, createScene } = vi.hoisted(() => ({ createRidgeScene: vi.fn(), @@ -23,21 +23,24 @@ const hex = (c: readonly number[]) => `#${c.map((v) => v.toString(16).padStart(2 beforeEach(() => { vi.stubGlobal('localStorage', { getItem: () => null, setItem: () => undefined }); + // Local midnight: 'auto' resolves to night. + setSkyClock(() => new Date(2026, 8, 22, 0, 0)); setSkyState('auto'); createRidgeScene.mockReset(); createScene.mockReset(); }); afterEach(() => { + setSkyClock(); setSkyState('auto'); vi.unstubAllGlobals(); }); describe('startBackgroundScene follows the sky state', () => { - it('hands the ridge scene no sky for auto, as before there was a state', async () => { + it('hands the ridge scene the sky auto resolves to (night at midnight)', async () => { createRidgeScene.mockReturnValue(fakeScene()); const scene = await startBackgroundScene(config, caps, { canvas }); expect(createRidgeScene).toHaveBeenCalledOnce(); - expect(createRidgeScene.mock.calls[0][0].sky).toBeUndefined(); + expect(hex(createRidgeScene.mock.calls[0][0].sky.top)).toBe(baked.midnight.gradient[0]); scene?.dispose(); }); diff --git a/src/app/features/scene/index.ts b/src/app/features/scene/index.ts index 55b00e86..4fe33934 100644 --- a/src/app/features/scene/index.ts +++ b/src/app/features/scene/index.ts @@ -1,7 +1,7 @@ import type { Scene, SceneOptions } from './Scene'; import { BackgroundSceneConfig, Capabilities, SceneTier, resolveTier } from './tier'; -import { getSkyState, subscribe } from './skyState'; -import type { SkyState } from './skyState'; +import { getResolvedSkyState, subscribe } from './skyState'; +import type { FixedSkyState } from './skyState'; export type { Scene, SceneStats } from './Scene'; export type { SceneStyle, SceneTier } from './tier'; @@ -18,7 +18,7 @@ export type StartOptions = Omit { let createScene: (o: SceneOptions) => Scene | null; try { @@ -52,7 +52,7 @@ async function create( * "keep the CSS ground", including when that chunk fails to load (offline, stale deploy): the * failure goes to onCompileFail and nothing throws. * - * The ridge and terrain scenes bake their sky once, so they follow the sky state (`skyState.ts`) by + * The ridge and terrain scenes bake their sky once, so they follow the sky (`skyState.ts`: the choice, resolved, so 'auto' follows the clock) by * being rebuilt on the same canvas when it changes: the door subscribes on start and the returned * scene's `dispose` unsubscribes. The pull-back restarts from the tree; a later `login()` is * replayed on the new scene. The ray-march scene has one fixed dusk and ignores the state. @@ -64,8 +64,8 @@ export async function startBackgroundScene( ): Promise { const tier = resolveTier(config, capabilities); if (tier === 'off') return null; - const build = (state: SkyState) => create(config, { ...options, tier }, state); - const state = getSkyState(); + const build = (state: FixedSkyState) => create(config, { ...options, tier }, state); + const state = getResolvedSkyState(); const first = await build(state); if (!first || config?.style === 'raymarch') return first; @@ -74,7 +74,8 @@ export async function startBackgroundScene( let loggedIn = false; let disposed = false; let generation = 0; - const rebuild = async (next: SkyState) => { + const rebuild = async (next: FixedSkyState) => { + if (next === built) return; generation += 1; const mine = generation; built = next; @@ -94,7 +95,7 @@ export async function startBackgroundScene( }; const off = subscribe(rebuild); // A change that landed while the first build was awaited was missed by the subscription. - if (getSkyState() !== built) rebuild(getSkyState()); + rebuild(getResolvedSkyState()); return { login() { loggedIn = true; diff --git a/src/app/features/scene/ridge/skyColour.ts b/src/app/features/scene/ridge/skyColour.ts index 326fe66d..3a5147a4 100644 --- a/src/app/features/scene/ridge/skyColour.ts +++ b/src/app/features/scene/ridge/skyColour.ts @@ -1,7 +1,9 @@ // The one place the sky's colours come from. Void's `src/sky/sun.js` (`light({ time, lat, lon, // cloud_cover, precipitation })`, PR #20) is the intended source; until it lands this returns a -// fixed dusk drawn from GameCult's nebula inputs (see ../palette.ts). Replace the body, keep the -// signature: the scene calls it once, at startup, and bakes the result. +// fixed twilight: lavender zenith, rose mid-sky, orange horizon. Its darkest blend stays above the dusk +// floor of ../skyBand.ts (the dark ink of Noonday Sky needs a backdrop of at least 0.2983 luminance), which +// skyForState.test.ts sweeps. Replace the body, keep the signature: the scene calls it once, at startup, +// and bakes the result. export type Srgb = readonly [number, number, number]; @@ -20,9 +22,9 @@ export interface SkyColours { export function skyColours(_now: Date = new Date()): SkyColours { return { - top: [24, 30, 78], - mid: [78, 66, 150], - horizon: [226, 130, 74], + top: [170, 145, 220], + mid: [225, 130, 165], + horizon: [240, 150, 80], ground: [7, 17, 26], sunAzimuth: 0.3, }; diff --git a/src/app/features/scene/ridge/stars.test.ts b/src/app/features/scene/ridge/stars.test.ts index 38b4e7e8..e14f2a7c 100644 --- a/src/app/features/scene/ridge/stars.test.ts +++ b/src/app/features/scene/ridge/stars.test.ts @@ -41,9 +41,10 @@ describe('star placement', () => { expect(makeStars(skyColours(), 390, 844)).toEqual(makeStars(skyColours(), 390, 844)); }); - it('draws none in a day sky, and the dusk default is dark enough for some', () => { + it('draws none in a day sky or the lavender dusk, and some in a night one', () => { expect(makeStars(DAY, 390, 844)).toHaveLength(0); - expect(makeStars(skyColours(), 390, 844).length).toBeGreaterThan(50); + expect(makeStars(skyColours(), 390, 844)).toHaveLength(0); + expect(makeStars(NIGHT, 390, 844).length).toBeGreaterThan(50); }); it('is a few hundred at most: bounded by MAX_STARS and by the viewport', () => { diff --git a/src/app/features/scene/skyBand.test.ts b/src/app/features/scene/skyBand.test.ts new file mode 100644 index 00000000..23c7c18e --- /dev/null +++ b/src/app/features/scene/skyBand.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; +import { displayedLuminance, hexToLinear, luma, shapeToBand, SKY_BAND } from './skyBand'; +import type { Linear } from './skyBand'; +import { GRID_FRAG, SHAPE_SKY_GLSL, SKY_FRAG } from './terrain/terrainShaders'; + +const near = (a: number, b: number) => Math.abs(a - b) < 1e-9; + +describe('shapeToBand', () => { + const dark: Linear = [0.02, 0.05, 0.2]; // a deep blue + const { day } = SKY_BAND; + + it('lifts a sky below the floor onto the floor, mixing toward the tint so the hue is kept', () => { + const c = shapeToBand(dark, day); + expect(near(luma(c), day.floor)).toBe(true); + // Between the sky and the tint on every channel: a mix, not a grey clamp. + [0, 1, 2].forEach((i) => { + expect(c[i]).toBeGreaterThanOrEqual(Math.min(dark[i], day.lift[i])); + expect(c[i]).toBeLessThanOrEqual(Math.max(dark[i], day.lift[i])); + }); + expect(c[2]).toBeGreaterThan(c[0]); // still blue + }); + + it('lifts black to the tint scaled in, not to grey', () => { + const c = shapeToBand([0, 0, 0], SKY_BAND.dusk); + expect(near(luma(c), SKY_BAND.dusk.floor)).toBe(true); + expect(c[0]).toBeGreaterThan(c[2]); // the dusk tint is rose + }); + + it('leaves a sky already in the band as it is, and is continuous at the floor', () => { + const ok: Linear = [0.5, 0.6, 0.7]; + expect(shapeToBand(ok, day)).toEqual(ok); + const lo = shapeToBand([0.31, 0.31, 0.31], day); + const below = shapeToBand([0.3099, 0.3099, 0.3099], day); + expect(Math.abs(lo[0] - below[0])).toBeLessThan(1e-3); + }); + + it('scales a sky above the ceiling down to it, keeping its hue', () => { + const c = shapeToBand([0.9, 0.8, 1], SKY_BAND.night); + expect(near(luma(c), SKY_BAND.night.ceiling)).toBe(true); + expect(near(c[0] / c[2], 0.9)).toBe(true); + }); + + it('clamps to 0..1 as the screen does, and never leaves the band when shaped twice', () => { + expect(shapeToBand([4, 4, 4], day)).toEqual([1, 1, 1]); + const once = shapeToBand(dark, day); + shapeToBand(once, day).forEach((v, i) => expect(v).toBeCloseTo(once[i], 12)); + }); +}); + +describe('the bands', () => { + it('have a tint above the floor to lift toward', () => { + Object.values(SKY_BAND).forEach((b) => expect(luma(b.lift)).toBeGreaterThan(b.floor)); + }); + + it('keep the floor 0.01 above the 0.2983 text bound: gamma 2.2 reads darker than sRGB by less', () => { + const grey = shapeToBand([0, 0, 0], SKY_BAND.day); + const shown = displayedLuminance(grey); + expect(shown).toBeGreaterThanOrEqual(0.2983); + expect(shown).toBeLessThan(SKY_BAND.day.floor); + }); + + it('decode hex as linear light', () => { + expect(hexToLinear('#ffffff')).toEqual([1, 1, 1]); + }); +}); + +describe('the GLSL twin', () => { + it('shapes with the same arithmetic: clamp, luma weights, mix to the floor, scale to the ceiling', () => { + expect(SHAPE_SKY_GLSL).toContain('c = clamp(c, 0.0, 1.0);'); + expect(SHAPE_SKY_GLSL).toContain('const vec3 LUMA = vec3(0.2126, 0.7152, 0.0722);'); + expect(SHAPE_SKY_GLSL).toContain( + 'if (l < uBand.x) return mix(c, uLift, (uBand.x - l) / (dot(uLift, LUMA) - l));' + ); + expect(SHAPE_SKY_GLSL).toContain('if (l > uBand.y) return c * (uBand.y / l);'); + }); + + it('is applied to the sky colour in both shaders that draw it, the sky pass and the haze that fades into it', () => { + [SKY_FRAG, GRID_FRAG].forEach((src) => { + expect(src).toContain(SHAPE_SKY_GLSL); + expect(src).toMatch(/return shapeSky\(1\.0 - exp\(-45\.0 \* skyScatter\(d\)\)/); + }); + }); +}); diff --git a/src/app/features/scene/skyBand.ts b/src/app/features/scene/skyBand.ts new file mode 100644 index 00000000..470393ee --- /dev/null +++ b/src/app/features/scene/skyBand.ts @@ -0,0 +1,76 @@ +// The luminance band each fixed sky state's drawn sky is shaped into, so the state's theme ink +// clears 4.5:1 on bare sky (WCAG relative luminance, the `luminance` of src/sky/skyTokens.js). +// +// day, dusk Noonday Sky's dark text ink #342d25 (L 0.0274) needs a backdrop of at least 0.2983. +// The floor is that text bound (0.30 as drawn); there is no ceiling, dark ink on light is fine. +// night Midnight Sky's light text ink #aeccff (L 0.594) needs a backdrop of at most 0.0931. +// The baked night sky (0.058 to 0.077) is kept; the ceiling is the bound. +// +// Link ink is not part of the band: see skyTheme.ts. +// +// The terrain sky is a physical scatter function, so it is shaped downstream, on its final colour: +// `shapeToBand` here, and its GLSL twin (`SHAPE_SKY_GLSL`) in terrain/terrainShaders.ts, built from the +// same constants. The ridge sky is a three-stop gradient whose stops are chosen inside the band +// (ridge/skyColour.ts, skyForState.ts), and its test sweeps the blends. +export type FixedSkyState = 'day' | 'dusk' | 'night'; +export type Linear = readonly [number, number, number]; + +/** WCAG luma weights, applied to linear RGB. */ +export const LUMA: Linear = [0.2126, 0.7152, 0.0722]; +export const luma = (c: Linear): number => LUMA[0] * c[0] + LUMA[1] * c[1] + LUMA[2] * c[2]; + +/** sRGB hex `#rrggbb` to linear RGB 0..1. */ +export function hexToLinear(hex: string): Linear { + const ch = (at: number) => { + const s = parseInt(hex.slice(at, at + 2), 16) / 255; + return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; + }; + return [ch(1), ch(3), ch(5)]; +} + +/** + * Relative luminance of what the terrain shaders put on screen for a linear colour: they encode with + * pow(1/2.2), not the sRGB curve, so the screen colour is read back through the sRGB curve. + */ +export function displayedLuminance(c: Linear): number { + const ch = (v: number) => { + const e = Math.min(1, Math.max(0, v)) ** (1 / 2.2); + return e <= 0.04045 ? e / 12.92 : ((e + 0.055) / 1.055) ** 2.4; + }; + return luma([ch(c[0]), ch(c[1]), ch(c[2])]); +} + +export interface SkyBand { + /** Linear luma the shaped colour is at least (0: no floor). */ + floor: number; + /** Linear luma the shaped colour is at most (1: no ceiling). */ + ceiling: number; + /** The colour a too-dark sky is mixed toward: the state's own tint, so the hue survives the lift. Its luma is above `floor`. */ + lift: Linear; +} + +/** + * The shaping targets, in linear luma. The floor sits 0.01 above the text bound (0.2983) because the + * screen encoding is gamma 2.2, which reads about 0.006 darker through the sRGB curve at this level. + */ +export const SKY_BAND: Record = { + day: { floor: 0.31, ceiling: 1, lift: hexToLinear('#e2f4ff') }, + dusk: { floor: 0.31, ceiling: 1, lift: hexToLinear('#eba0b9') }, + night: { floor: 0, ceiling: 0.09, lift: hexToLinear('#484e64') }, +}; + +/** + * Shape a linear sky colour into the band. Below the floor it mixes toward `lift` by exactly the amount + * that lands on the floor (continuous at the floor, hue kept); above the ceiling it scales down to the + * ceiling. The colour is clamped to 0..1 first, as the screen does. + */ +export function shapeToBand(c: Linear, band: SkyBand): Linear { + const k = [0, 1, 2].map((i) => Math.min(1, Math.max(0, c[i]))) as unknown as Linear; + const l = luma(k); + if (l < band.floor) { + const t = (band.floor - l) / (luma(band.lift) - l); + return [0, 1, 2].map((i) => k[i] + (band.lift[i] - k[i]) * t) as unknown as Linear; + } + if (l > band.ceiling) return [0, 1, 2].map((i) => (k[i] * band.ceiling) / l) as unknown as Linear; + return k; +} diff --git a/src/app/features/scene/skyForState.test.ts b/src/app/features/scene/skyForState.test.ts index 44ca5899..fb95dc0a 100644 --- a/src/app/features/scene/skyForState.test.ts +++ b/src/app/features/scene/skyForState.test.ts @@ -1,6 +1,6 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import { baked } from '../../../sky/baked'; -import { luminance } from '../../../sky/skyTokens.js'; +import { AA, luminance } from '../../../sky/skyTokens.js'; import { skyColours } from './ridge/skyColour'; import { chooseSky, skyColour } from './terrain/terrainSky'; import { @@ -10,154 +10,178 @@ import { terrainDrawn, terrainSkyFor, } from './skyForState'; -import { skyBackdrop } from './skyState'; +import { displayedLuminance, SKY_BAND } from './skyBand'; +import type { FixedSkyState } from './skyBand'; +import { setSkyClock, skyBackdrop } from './skyState'; +import { skyThemeId } from './skyTheme'; const hex = (c: readonly number[]) => `#${c.map((v) => v.toString(16).padStart(2, '0')).join('')}`; const EPS = 1e-9; +const STATES = ['day', 'dusk', 'night'] as const; +const SKY = { 'midnight-sky': baked.midnight, 'noonday-sky': baked.noonday } as const; + +/** + * The band each state's text ink needs, from the ink itself: dark ink needs a backdrop of at least + * AA * (L + 0.05) - 0.05, light ink at most (L + 0.05) / AA - 0.05. Day 0.2983, dusk 0.2983, night 0.0931. + */ +const needs = (state: FixedSkyState) => { + const l = luminance(SKY[skyThemeId(state)].tokens.Background.OnContainer); + return state === 'night' + ? { floor: 0, ceiling: (l + 0.05) / AA - 0.05 } + : { floor: AA * (l + 0.05) - 0.05, ceiling: 1 }; +}; + +afterEach(() => setSkyClock()); + +describe('the bands the text ink needs', () => { + it('are the numbers the shaping was set from', () => { + expect(needs('day').floor).toBeCloseTo(0.2983, 3); + expect(needs('dusk').floor).toBeCloseTo(0.2983, 3); + expect(needs('night').ceiling).toBeCloseTo(0.0931, 3); + }); +}); describe('ridgeSkyFor', () => { - it('leaves auto to the scene, so it draws what it drew before there was a state', () => { - expect(ridgeSkyFor('auto')).toBeUndefined(); + it('draws the gradient of the sky auto resolves to, never nothing', () => { + setSkyClock(() => new Date(2026, 8, 22, 12, 0)); + expect(ridgeSkyFor('auto')).toEqual(ridgeSkyFor('day')); + setSkyClock(() => new Date(2026, 8, 22, 0, 0)); + expect(ridgeSkyFor('auto')).toEqual(ridgeSkyFor('night')); + setSkyClock(() => new Date(2026, 8, 22, 18, 10)); + expect(ridgeSkyFor('auto')).toEqual(ridgeSkyFor('dusk')); }); - it('draws the current ridge dusk for dusk', () => { - expect(ridgeSkyFor('dusk')).toEqual(skyColours()); + it('draws a lavender, rose and orange twilight for dusk', () => { + const d = ridgeSkyFor('dusk'); + expect(d).toEqual(skyColours()); + const [t, m, h] = [d.top, d.mid, d.horizon]; + expect(t[2]).toBeGreaterThan(t[1]); // lavender: blue over green + expect(t[0]).toBeGreaterThan(t[1]); + expect(m[0]).toBeGreaterThan(m[2]); // rose: red over blue + expect(m[2]).toBeGreaterThan(m[1]); + expect(h[0]).toBeGreaterThan(h[1]); // orange: red over green over blue + expect(h[1]).toBeGreaterThan(h[2]); }); it('draws the baked gradients for day and night', () => { const day = ridgeSkyFor('day'); const night = ridgeSkyFor('night'); - expect(hex(day?.top ?? [])).toBe(baked.noonday.gradient[0]); - expect(hex(day?.horizon ?? [])).toBe(baked.noonday.gradient[6]); - expect(hex(night?.top ?? [])).toBe(baked.midnight.gradient[0]); - expect(hex(night?.horizon ?? [])).toBe(baked.midnight.gradient[6]); + expect(hex(day.top)).toBe(baked.noonday.gradient[0]); + expect(hex(day.horizon)).toBe(baked.noonday.gradient[6]); + expect(hex(night.top)).toBe(baked.midnight.gradient[0]); + expect(hex(night.horizon)).toBe(baked.midnight.gradient[6]); }); - it.each(['day', 'dusk', 'night'] as const)( - 'paints a %s sky whose colours lie inside skyBackdrop', - (state) => { - const sky = ridgeSkyFor(state); - const { darkest, lightest } = skyBackdrop(state); - [sky?.top, sky?.mid, sky?.horizon].forEach((c) => { - const l = luminance(hex(c ?? [])); - expect(l).toBeGreaterThanOrEqual(darkest - EPS); - expect(l).toBeLessThanOrEqual(lightest + EPS); - }); - } - ); + it.each(STATES)('%s: every stop and blend lies in the band its text ink needs', (state) => { + const { floor, ceiling } = needs(state); + const r = ridgeDrawn(state); + expect(r.darkest).toBeGreaterThanOrEqual(floor); + expect(r.lightest).toBeLessThanOrEqual(ceiling); + }); }); describe('terrainSkyFor', () => { - it('leaves auto to the clock and the URL', () => { - expect(terrainSkyFor('auto')).toEqual({}); + it('draws the sky auto resolves to, with that state band', () => { + setSkyClock(() => new Date(2026, 8, 22, 12, 0)); + expect(terrainSkyFor('auto')).toEqual(terrainSkyFor('day')); + setSkyClock(() => new Date(2026, 8, 22, 0, 0)); + expect(terrainSkyFor('auto')).toEqual(terrainSkyFor('night')); }); - it('names the baked sky for day and night, and a low sun for dusk', () => { - expect(terrainSkyFor('day')).toEqual({ sky: 'noonday' }); - expect(terrainSkyFor('night')).toEqual({ sky: 'midnight' }); - expect(terrainSkyFor('dusk')).toEqual({ sun: { elevation: DUSK_SUN_ELEVATION } }); - }); - - it.each([ - ['day', 'noonday'], - ['night', 'midnight'], - ] as const)('lights %s from a gradient inside skyBackdrop', (state, name) => { - const { darkest, lightest } = skyBackdrop(state); - expect(terrainSkyFor(state).sky).toBe(name); - baked[name].gradient.forEach((c) => { - expect(luminance(c)).toBeGreaterThanOrEqual(darkest - EPS); - expect(luminance(c)).toBeLessThanOrEqual(lightest + EPS); + it('names the baked sky for day and night, and a low sun for dusk, each with its band', () => { + expect(terrainSkyFor('day')).toEqual({ sky: 'noonday', band: SKY_BAND.day }); + expect(terrainSkyFor('night')).toEqual({ sky: 'midnight', band: SKY_BAND.night }); + expect(terrainSkyFor('dusk')).toEqual({ + sun: { elevation: DUSK_SUN_ELEVATION }, + band: SKY_BAND.dusk, }); }); }); -describe('the terrain sky lies inside skyBackdrop', () => { - // Not the generator's grid: a golden-angle spiral over the upper sphere (no shared rows or columns) - // plus a 0.05 degree grid across the sun, five times finer than the generator's. - const luma = (c: readonly number[]) => { - const [r, g, b] = c.map((v) => Math.min(1, Math.max(0, v))); - return 0.2126 * r + 0.7152 * g + 0.0722 * b; - }; - const sweep = (state: 'day' | 'dusk' | 'night') => { - const sky = chooseSky(terrainSkyFor(state)); - const rad = Math.PI / 180; - const dirAt = (el: number, az: number): [number, number, number] => [ - Math.cos(el * rad) * Math.sin(az * rad), - Math.sin(el * rad), - -Math.cos(el * rad) * Math.cos(az * rad), - ]; - const out: number[] = []; - const N = 20000; - for (let i = 0; i < N; i += 1) { - const y = (i + 0.5) / N; // sin(elevation): uniform over the upper hemisphere - out.push(luma(skyColour(sky, dirAt(Math.asin(y) / rad, (i * 137.50776405) % 360)))); +// Not the generator's grid: a golden-angle spiral over the upper sphere (no shared rows or columns) +// plus a 0.05 degree grid across the sun, five times finer than the generator's. +const dirAt = (el: number, az: number): [number, number, number] => { + const rad = Math.PI / 180; + return [ + Math.cos(el * rad) * Math.sin(az * rad), + Math.sin(el * rad), + -Math.cos(el * rad) * Math.cos(az * rad), + ]; +}; +const sweepOf = (sky: ReturnType, N = 4000) => { + const rad = Math.PI / 180; + const out: number[] = []; + for (let i = 0; i < N; i += 1) { + const y = (i + 0.5) / N; // sin(elevation): uniform over the upper hemisphere + out.push( + displayedLuminance(skyColour(sky, dirAt(Math.asin(y) / rad, (i * 137.50776405) % 360))) + ); + } + for (let dEl = -3; dEl <= 3; dEl += 0.05) { + for (let dAz = -3; dAz <= 3; dAz += 0.05) { + out.push( + displayedLuminance( + skyColour(sky, dirAt(Math.max(0, sky.elevation + dEl), sky.azimuth + dAz)) + ) + ); } - for (let dEl = -3; dEl <= 3; dEl += 0.05) { - for (let dAz = -3; dAz <= 3; dAz += 0.05) { - out.push(luma(skyColour(sky, dirAt(Math.max(0, sky.elevation + dEl), sky.azimuth + dAz)))); - } - } - return out; - }; + } + return out; +}; - it.each(['day', 'dusk', 'night'] as const)('for %s, and no looser than 5e-4 from it', (state) => { - const { darkest, lightest } = skyBackdrop(state); +describe('the terrain sky lies in the band its text ink needs', () => { + const sweep = (state: FixedSkyState) => sweepOf(chooseSky(terrainSkyFor(state))); + + it.each(STATES)('%s', (state) => { + const { floor, ceiling } = needs(state); const l = sweep(state); - expect(Math.min(...l)).toBeGreaterThanOrEqual(darkest - EPS); - expect(Math.max(...l)).toBeLessThanOrEqual(lightest + EPS); - const top = Math.max(Math.max(...l), ridgeDrawn(state)?.lightest ?? 0); - expect(lightest - top).toBeLessThan(5e-4); - const bottom = Math.min(Math.min(...l), ridgeDrawn(state)?.darkest ?? 1); - expect(bottom - darkest).toBeLessThan(5e-4); + expect(Math.min(...l)).toBeGreaterThanOrEqual(floor); + expect(Math.max(...l)).toBeLessThanOrEqual(ceiling); }); - it('and the terrain dusk reaches the sun glow the ridge dusk does not', () => { + it.each(STATES)('%s holds for a sun at any elevation, as ?sun= can put it', (state) => { + const { floor, ceiling } = needs(state); + for (let e = -90; e <= 90; e += 10) { + const l = sweepOf(chooseSky({ sun: { elevation: e }, band: SKY_BAND[state] })); + expect(Math.min(...l)).toBeGreaterThanOrEqual(floor); + expect(Math.max(...l)).toBeLessThanOrEqual(ceiling); + } + }); + + it('and the terrain dusk still reaches the sun glow the ridge dusk does not', () => { expect(skyBackdrop('dusk').lightest).toBeGreaterThan(0.9); - expect(skyBackdrop('dusk').lightest).toBeGreaterThan(Math.max(...sweep('night'))); - }); -}); - -describe('both scenes of a state lie inside skyBackdrop', () => { - it.each(['day', 'dusk', 'night'] as const)('%s', (state) => { - const b = skyBackdrop(state); - const r = ridgeDrawn(state); - const t = terrainDrawn(state); - [r, t].forEach((x) => { - expect(x?.darkest).toBeGreaterThanOrEqual(b.darkest - EPS); - expect(x?.lightest).toBeLessThanOrEqual(b.lightest + EPS); - }); }); }); describe('skyBackdrop holds the union of what the scenes draw', () => { - const computed = (state: 'day' | 'dusk' | 'night') => { - const r = [ridgeDrawn(state), terrainDrawn(state)].filter((x) => x !== undefined) as { - darkest: number; - lightest: number; - }[]; + const computed = (state: FixedSkyState) => { + const r = [ridgeDrawn(state), terrainDrawn(state)]; return { darkest: Math.min(...r.map((x) => x.darkest)), lightest: Math.max(...r.map((x) => x.lightest)), }; }; - it.each(['day', 'dusk', 'night'] as const)( + it.each(STATES)( '%s: the constant bounds the computed range and sits within 1e-5 of it', (state) => { const c = computed(state); const b = skyBackdrop(state); - expect(b.darkest).toBeLessThanOrEqual(c.darkest); - expect(b.lightest).toBeGreaterThanOrEqual(c.lightest); + expect(b.darkest).toBeLessThanOrEqual(c.darkest + EPS); + expect(b.lightest).toBeGreaterThanOrEqual(c.lightest - EPS); expect(c.darkest - b.darkest).toBeLessThan(1e-5); expect(b.lightest - c.lightest).toBeLessThan(1e-5); } ); - it('auto is the union of the three fixed states', () => { - const all = (['day', 'dusk', 'night'] as const).map(skyBackdrop); - expect(skyBackdrop('auto')).toEqual({ - darkest: Math.min(...all.map((x) => x.darkest)), - lightest: Math.max(...all.map((x) => x.lightest)), - }); + it.each(STATES)('%s: an independent sweep, no looser than 5e-4 from the constant', (state) => { + // The sweep is not the generator's grid (see above), so it cannot share its blind spots. + const l = sweepOf(chooseSky(terrainSkyFor(state)), 20000); + const b = skyBackdrop(state); + const r = ridgeDrawn(state); + expect(Math.min(...l)).toBeGreaterThanOrEqual(b.darkest - EPS); + expect(Math.max(...l)).toBeLessThanOrEqual(b.lightest + EPS); + expect(b.lightest - Math.max(Math.max(...l), r.lightest)).toBeLessThan(5e-4); + expect(Math.min(Math.min(...l), r.darkest) - b.darkest).toBeLessThan(5e-4); }); }); diff --git a/src/app/features/scene/skyForState.ts b/src/app/features/scene/skyForState.ts index a6d78e4a..0de0dbe0 100644 --- a/src/app/features/scene/skyForState.ts +++ b/src/app/features/scene/skyForState.ts @@ -1,14 +1,17 @@ -// What each scene is told for a sky state. 'auto' returns nothing: the scene chooses for itself -// (the ridges' fixed dusk, the terrain's clock with `?sky=` `?sun=` `?az=`), exactly as before -// there was a state. The other states name a sky explicitly, drawn from the same gradients -// `skyBackdrop` bounds, so the colours a scene paints lie inside the luminance range the login -// text was checked against. +// What each scene is told for a sky state. 'auto' is a choice, not a sky: both functions draw the sky it +// resolves to (`resolveSkyState`, the clock), so every scene paints one of day, dusk or night. Each is drawn +// inside that state's luminance band (skyBand.ts): the ridge sky from gradients whose stops and blends lie in +// it, the terrain sky shaped into it downstream of the scatter function. `skyBackdrop` bounds what comes out, +// and the login text was checked against that bound. import { baked } from '../../../sky/baked'; import { chooseSky, skyColour } from './terrain/terrainSky'; -import type { SkyName, Vec3 } from './terrain/terrainSky'; +import type { SkyName } from './terrain/terrainSky'; import type { SkyColours, Srgb } from './ridge/skyColour'; import { skyColours } from './ridge/skyColour'; +import { resolveSkyState } from './skyState'; import type { SkyState } from './skyState'; +import { displayedLuminance, SKY_BAND } from './skyBand'; +import type { SkyBand } from './skyBand'; import { luminance } from '../../../sky/skyTokens.js'; const rgb = (hex: string): Srgb => @@ -24,32 +27,34 @@ const fromGradient = (g: readonly string[], ground: Srgb): SkyColours => ({ /** Sun elevation in degrees for the terrain's dusk: just above the horizon, between the two bakes (as `?sun=` puts it). */ export const DUSK_SUN_ELEVATION = 2; -/** The ridge scene's `sky` option; undefined leaves the scene on `skyColours()`. */ -export function ridgeSkyFor(state: SkyState): SkyColours | undefined { +/** The ridge scene's `sky` option: the gradient of the state's sky ('auto': of the sky it resolves to). */ +export function ridgeSkyFor(state: SkyState): SkyColours { const { ground } = skyColours(); - switch (state) { + switch (resolveSkyState(state)) { case 'dusk': return skyColours(); case 'day': return fromGradient(baked.noonday.gradient, ground); - case 'night': - return fromGradient(baked.midnight.gradient, ground); default: - return undefined; + return fromGradient(baked.midnight.gradient, ground); } } -/** The terrain scene's `sky` and `sun` options; `{}` leaves the scene on its clock and URL choice. */ -export function terrainSkyFor(state: SkyState): { sky?: SkyName; sun?: { elevation: number } } { - switch (state) { +/** The terrain scene's `sky`, `sun` and `band` options: the state's sky ('auto': the sky it resolves to), shaped into the state's band. */ +export function terrainSkyFor(state: SkyState): { + sky?: SkyName; + sun?: { elevation: number }; + band: SkyBand; +} { + const fixed = resolveSkyState(state); + const band = SKY_BAND[fixed]; + switch (fixed) { case 'day': - return { sky: 'noonday' }; + return { sky: 'noonday', band }; case 'night': - return { sky: 'midnight' }; - case 'dusk': - return { sun: { elevation: DUSK_SUN_ELEVATION } }; + return { sky: 'midnight', band }; default: - return {}; + return { sun: { elevation: DUSK_SUN_ELEVATION }, band }; } } @@ -61,11 +66,26 @@ export interface Range { readonly lightest: number; } -/** The ridge sky's range: its top, middle and horizon stops (the canvas blends between them). */ -export function ridgeDrawn(state: SkyState): Range | undefined { - const sky = ridgeSkyFor(state === 'auto' ? 'dusk' : state); - if (!sky) return undefined; - const l = [sky.top, sky.mid, sky.horizon].map((c) => luminance(rgbHex(c))); +/** + * The ridge sky's range: its top, middle and horizon stops and the blends between them (the canvas interpolates + * encoded sRGB, and luminance is convex in it, so a blend can be darker than both ends: each stop pair is sampled). + */ +export function ridgeDrawn(state: SkyState): Range { + const { top, mid, horizon } = ridgeSkyFor(state); + const l: number[] = []; + [ + [top, mid], + [mid, horizon], + [top, horizon], + ].forEach(([a, b]) => { + for (let k = 0; k <= 100; k += 1) { + l.push( + luminance( + rgbHex(a.map((v, i) => Math.round(v + ((b[i] - v) * k) / 100)) as unknown as Srgb) + ) + ); + } + }); return { darkest: Math.min(...l), lightest: Math.max(...l) }; } @@ -74,13 +94,9 @@ const SUN_DEG = 0.25; const SUN_SPAN_DEG = 8; const HORIZON_DEG = 0.5; const REFINE_FROM = 6; -const linearLuminance = (c: Vec3) => { - const [r, g, b] = c.map((v) => Math.min(1, Math.max(0, v))); - return 0.2126 * r + 0.7152 * g + 0.0722 * b; -}; /** - * The terrain sky's range: `skyColour` (the CPU twin of its sky shader) over the whole upper sphere, which the camera + * The terrain sky's range, as displayed (`displayedLuminance`): `skyColour` (the CPU twin of its sky shader) over the whole upper sphere, which the camera * sweeps as it turns, for the sky the scene picks for this state. A 3 degree grid, a 0.5 degree ring of the horizon, the * zenith and a 0.25 degree patch around the sun find where the extremes are; each of the best few is then refined by a * compass search whose step halves from 1 degree to under 0.001, because the sun's glow peaks (and the sky's darkest @@ -92,7 +108,7 @@ export function terrainDrawn(state: SkyState): Range { const lum = (el: number, az: number) => { const e = Math.min(90, Math.max(0, el)) * rad; const a = az * rad; - return linearLuminance( + return displayedLuminance( skyColour(sky, [Math.cos(e) * Math.sin(a), Math.sin(e), -Math.cos(e) * Math.cos(a)]) ); }; diff --git a/src/app/features/scene/skyState.test.ts b/src/app/features/scene/skyState.test.ts index 7b33a1ba..f8c22178 100644 --- a/src/app/features/scene/skyState.test.ts +++ b/src/app/features/scene/skyState.test.ts @@ -1,11 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { baked } from '../../../sky/baked'; -import { AA, luminance } from '../../../sky/skyTokens.js'; +import { luminance } from '../../../sky/skyTokens.js'; import { SKY_STATES, SKY_STATE_KEY, + resolveSkyState, + setSkyClock, skyBackdrop, - type SkyBackdrop, type SkyState, } from './skyState'; @@ -105,10 +106,10 @@ describe('sky state', () => { }); describe('skyBackdrop', () => { - it('puts night below day and dusk, which spans from dark to the sun glow', () => { + it('puts night below day and dusk, and dusk reaches the sun glow', () => { expect(skyBackdrop('night').lightest).toBeLessThan(skyBackdrop('day').darkest); expect(skyBackdrop('night').lightest).toBeLessThan(skyBackdrop('dusk').lightest); - expect(skyBackdrop('dusk').darkest).toBeLessThan(skyBackdrop('day').darkest); + expect(skyBackdrop('dusk').lightest).toBeGreaterThan(skyBackdrop('day').lightest); }); it('holds the baked gradients for day and night', () => { @@ -121,12 +122,13 @@ describe('skyBackdrop', () => { ); }); - it('bounds every fixed sky when auto', () => { - const a = skyBackdrop('auto'); - (['day', 'dusk', 'night'] as const).forEach((s) => { - expect(a.lightest).toBeGreaterThanOrEqual(skyBackdrop(s).lightest); - expect(a.darkest).toBeLessThanOrEqual(skyBackdrop(s).darkest); - }); + it('is the band of the sky auto resolves to, not the union of all three', () => { + const at = (h: number) => setSkyClock(() => new Date(2026, 8, 22, h, 0)); + at(12); + expect(skyBackdrop('auto')).toEqual(skyBackdrop('day')); + at(0); + expect(skyBackdrop('auto')).toEqual(skyBackdrop('night')); + setSkyClock(); }); it('never has darkest above lightest', () => { @@ -136,57 +138,61 @@ describe('skyBackdrop', () => { }); }); -// Login text and link, per theme, from the card variables in pages/auth/styles.css.ts and the sky -// themes' baked bubble text and link. The Light/Silver/Dark/Butter row uses the ink those themes -// actually render: under data-scene the auth card text is var(--sky-bubble-text, #b7c7d9), not each -// theme's own OnContainer. -const inks = [ - { theme: 'Light/Silver/Dark/Butter', text: '#b7c7d9', link: '#99c7ff' }, - { theme: 'Midnight Sky', text: baked.midnight.bubbles.OtherText, link: baked.midnight.link }, - { theme: 'Noonday Sky', text: baked.noonday.bubbles.OtherText, link: baked.noonday.link }, -]; -// WCAG contrast of an ink against the sky it is worst on. The sky can show any luminance between -// its darkest and lightest, and contrast is lowest where the backdrop is closest to the ink: the -// ink's own luminance clamped into that range. An ink inside the range can land on a backdrop of -// exactly its own luminance (1.00); an ink outside it is worst on the nearer end. -const against = (ink: string, bg: number) => { - const li = luminance(ink); - const [hi, lo] = li > bg ? [li, bg] : [bg, li]; - return (hi + 0.05) / (lo + 0.05); -}; -const ratio = (ink: string, b: SkyBackdrop) => - against(ink, Math.min(b.lightest, Math.max(b.darkest, luminance(ink)))); - -describe('login ink against the sky backdrop', () => { - const rows = SKY_STATES.flatMap((state) => - inks.flatMap(({ theme, text, link }) => - ( - [ - ['text', text], - ['link', link], - ] as const - ).map(([role, ink]) => ({ state, theme, role, ratio: ratio(ink, skyBackdrop(state)) })) - ) - ); - - it('prints the measured ratios', () => { - // eslint-disable-next-line no-console - console.table(rows.map((r) => ({ ...r, ratio: r.ratio.toFixed(2), ok: r.ratio >= AA }))); - expect(rows).toHaveLength(SKY_STATES.length * inks.length * 2); +describe('auto follows the clock', () => { + const local = (h: number, m = 0) => new Date(2026, 8, 22, h, m); // local solar time ~ local clock + let storage: ReturnType; + beforeEach(() => { + vi.useFakeTimers(); + storage = fakeStorage(); + vi.stubGlobal('localStorage', storage); + }); + afterEach(() => { + vi.useRealTimers(); }); - // Bare sky does not hold either ink in most states: the card behind the login form stays opaque - // (pages/auth/styles.css.ts) for that reason. These are the cells that clear AA, with the sky range the scenes can draw; every other - // cell is asserted to fail, so a sky or theme change that fixes one turns it red until listed. - const clears = new Set([ - 'night|Light/Silver/Dark/Butter|text', - 'night|Light/Silver/Dark/Butter|link', - 'night|Midnight Sky|text', - ]); - describe.each(rows)('$theme $role in $state', (r) => { - const holds = clears.has(`${r.state}|${r.theme}|${r.role}`); - (holds ? it : it.fails)(holds ? 'is at least AA' : 'is below AA (known)', () => { - expect(r.ratio).toBeGreaterThanOrEqual(AA); - }); + it('resolves noon to day, midnight to night and the twilight after sunset to dusk', () => { + expect(resolveSkyState('auto', local(12))).toBe('day'); + expect(resolveSkyState('auto', local(0))).toBe('night'); + expect(resolveSkyState('auto', local(18, 10))).toBe('dusk'); + expect(resolveSkyState('auto', local(5, 50))).toBe('dusk'); + expect(resolveSkyState('day', local(0))).toBe('day'); + }); + + it('tells listeners the resolved sky when the clock crosses a boundary, and only then', async () => { + const m = await fresh(); + let now = local(17, 0); + m.setSkyClock(() => now); + const cb = vi.fn(); + const off = m.subscribe(cb); + vi.advanceTimersByTime(5 * 60_000); + expect(cb).not.toHaveBeenCalled(); + now = local(18, 10); + vi.advanceTimersByTime(60_000); + expect(cb.mock.calls).toEqual([['dusk']]); + vi.advanceTimersByTime(5 * 60_000); + expect(cb).toHaveBeenCalledTimes(1); + off(); + }); + + it('keeps no timer without listeners or when a fixed state is chosen', async () => { + const m = await fresh(); + const off = m.subscribe(() => undefined); + expect(vi.getTimerCount()).toBe(1); + m.setSkyState('night'); + expect(vi.getTimerCount()).toBe(0); + m.setSkyState('auto'); + expect(vi.getTimerCount()).toBe(1); + off(); + expect(vi.getTimerCount()).toBe(0); + }); + + it('notifies a change of choice with the resolved sky', async () => { + const m = await fresh(); + m.setSkyClock(() => local(12)); + const cb = vi.fn(); + m.subscribe(cb); + m.setSkyState('night'); + m.setSkyState('auto'); + expect(cb.mock.calls).toEqual([['night'], ['day']]); }); }); diff --git a/src/app/features/scene/skyState.ts b/src/app/features/scene/skyState.ts index e06e9845..ce31fb23 100644 --- a/src/app/features/scene/skyState.ts +++ b/src/app/features/scene/skyState.ts @@ -1,7 +1,11 @@ -// Which sky the signed-out page is drawn against. The state is one value, kept in one +// Which sky the signed-out page is drawn against. The choice is one value, kept in one // localStorage key; whoever draws the sky (the scene, the title bar's control) reads it here, and // `skyBackdrop` says how light or dark the sky behind the login text can be in each state, so the -// text colours can be checked against it. +// text colours can be checked against it. 'auto' is a choice, not a sky: it resolves from the +// clock to day, dusk or night (`resolveSkyState`), and every sky drawn is one of those three. +import { sunElevation } from '../../../sky/sun.js'; +import type { FixedSkyState } from './skyBand'; + export type SkyState = 'auto' | 'day' | 'dusk' | 'night'; export const SKY_STATES: readonly SkyState[] = ['auto', 'day', 'dusk', 'night']; @@ -18,14 +22,63 @@ function load(): SkyState { } } -let current: SkyState | undefined; -const listeners = new Set<(s: SkyState) => void>(); +export type { FixedSkyState }; +/** Where the clock puts the sun: the latitude the skies are baked for (src/sky/skyTokens.js MOMENTS), the longitude of this timezone. */ +const LAT = 45; +/** Sun elevation (degrees) from which it is day, and below the negative of which it is night; between, dusk (dawn included). */ +export const DAY_FROM = 6; + +let clock: () => Date = () => new Date(); +/** The fixed sky a choice draws now: itself, or for 'auto' the sky the sun's elevation here calls for. */ +export function resolveSkyState(s: SkyState, now: Date = clock()): FixedSkyState { + if (s !== 'auto') return s; + const lon = -now.getTimezoneOffset() / 4; + const el = sunElevation(now, LAT, lon) as number; + if (el >= DAY_FROM) return 'day'; + return el <= -DAY_FROM ? 'night' : 'dusk'; +} + +let current: SkyState | undefined; +const listeners = new Set<(s: FixedSkyState) => void>(); +let timer: ReturnType | undefined; +let shown: FixedSkyState | undefined; +const TICK_MS = 60_000; + +/** The choice (what the control shows). */ export const getSkyState = (): SkyState => { current ??= load(); return current; }; +/** The sky being drawn: the choice, resolved. */ +export const getResolvedSkyState = (): FixedSkyState => resolveSkyState(getSkyState()); + +const emit = () => { + shown = getResolvedSkyState(); + [...listeners].forEach((cb) => cb(shown as FixedSkyState)); +}; + +// While anyone listens and the choice is 'auto', one timer checks the clock each minute and tells +// listeners when the resolved sky changes. Otherwise there is no timer. +function sync(): void { + clearTimeout(timer); + timer = undefined; + if (listeners.size === 0 || getSkyState() !== 'auto') return; + shown = getResolvedSkyState(); + const tick = () => { + if (getResolvedSkyState() !== shown) emit(); + timer = setTimeout(tick, TICK_MS); + }; + timer = setTimeout(tick, TICK_MS); +} + +/** Replace the clock (tests); no argument restores the real one. */ +export function setSkyClock(now?: () => Date): void { + clock = now ?? (() => new Date()); + sync(); +} + export function setSkyState(s: SkyState): void { if (!isSkyState(s)) return; const changed = s !== getSkyState(); @@ -35,20 +88,26 @@ export function setSkyState(s: SkyState): void { } catch { // Storage is refused (private mode, quota): the choice holds for this page load only. } - if (changed) [...listeners].forEach((cb) => cb(s)); + if (changed) { + sync(); + emit(); + } } -export function subscribe(cb: (s: SkyState) => void): () => void { +/** Listeners hear the resolved sky: on a change of choice, and while 'auto', when the clock crosses a boundary. */ +export function subscribe(cb: (s: FixedSkyState) => void): () => void { listeners.add(cb); + sync(); return () => { listeners.delete(cb); + sync(); }; } /** * Relative luminance (WCAG, 0..1) of the lightest and darkest sky the state can show behind the - * page. Light ink is worst against `lightest`, dark ink against `darkest`. 'auto' follows the - * clock, so it can show any of the three fixed skies and bounds all of them. + * page. Light ink is worst against `lightest`, dark ink against `darkest`. 'auto' is the band of + * the sky it resolves to now. */ export interface SkyBackdrop { readonly darkest: number; @@ -59,13 +118,11 @@ export interface SkyBackdrop { // the function its scene uses (`ridgeDrawn`, `terrainDrawn` in skyForState.ts). Held as constants so // this module, which the main chunk loads, does not pull in the terrain sky and the baked scattering // tables with it; skyForState.test.ts fails when they drift from the computed union. -// Rounded outward to six places. 'auto' follows the clock (and a URL), so it -// bounds all three fixed states. -const BACKDROPS: Record = { - auto: { darkest: 0.016727, lightest: 0.967912 }, - day: { darkest: 0.237309, lightest: 0.92805 }, - dusk: { darkest: 0.016727, lightest: 0.967912 }, - night: { darkest: 0.058099, lightest: 0.077467 }, +// Rounded outward to six places. +const BACKDROPS: Record = { + day: { darkest: 0.304139, lightest: 0.925759 }, + dusk: { darkest: 0.303833, lightest: 0.967298 }, + night: { darkest: 0.05987, lightest: 0.079657 }, }; -export const skyBackdrop = (state: SkyState): SkyBackdrop => BACKDROPS[state]; +export const skyBackdrop = (state: SkyState): SkyBackdrop => BACKDROPS[resolveSkyState(state)]; diff --git a/src/app/features/scene/skyTheme.test.ts b/src/app/features/scene/skyTheme.test.ts index 3fd06875..027647da 100644 --- a/src/app/features/scene/skyTheme.test.ts +++ b/src/app/features/scene/skyTheme.test.ts @@ -8,7 +8,9 @@ import { MembersDrawer } from '../room/MembersDrawer.css'; import { RoomViewTyping } from '../room/RoomViewTyping.css'; import { SKY_PLATE_FALLBACK } from './skyPlate'; import { SkyPlate, plateRegions } from './skyPlate.css'; -import { SKY_STATES, skyBackdrop, type SkyBackdrop, type SkyState } from './skyState'; +import { setSkyClock, skyBackdrop } from './skyState'; +import type { SkyBackdrop } from './skyState'; +import type { FixedSkyState } from './skyBand'; import { skyThemeId } from './skyTheme'; const SKY = { 'midnight-sky': baked.midnight, 'noonday-sky': baked.noonday } as const; @@ -30,44 +32,62 @@ const ratio = (ink: string, b: SkyBackdrop) => const r2 = (n: number) => n.toFixed(2); describe('skyThemeId', () => { - it('leaves the theme alone in auto and picks one for every fixed state', () => { - expect(skyThemeId('auto')).toBeUndefined(); - expect(SKY_STATES.filter((s) => s !== 'auto').map((s) => skyThemeId(s))).not.toContain( - undefined - ); - }); - - it('lights day and dusk with dark ink and night with light ink', () => { + it('picks a theme for every fixed state, and for auto the theme of the sky it resolves to', () => { expect(skyThemeId('day')).toBe('noonday-sky'); expect(skyThemeId('dusk')).toBe('noonday-sky'); expect(skyThemeId('night')).toBe('midnight-sky'); + const at = (h: number, m = 0) => setSkyClock(() => new Date(2026, 8, 22, h, m)); + at(12); + expect(skyThemeId('auto')).toBe('noonday-sky'); + at(0); + expect(skyThemeId('auto')).toBe('midnight-sky'); + at(18, 10); + expect(skyThemeId('auto')).toBe('noonday-sky'); + setSkyClock(); }); }); describe('ink of the theme a state picks, on bare sky', () => { - // Per state: the picked theme, its ink and link, and their ratios on bare sky, over the union - // of the skies the scenes draw (skyBackdrop). Only night text passes (5.05); day's sky reaches - // 0.919 and dusk's 0.017 to 0.965, so no theme's ink holds there. No link passes anywhere - // (night 2.36): nothing rests on bare sky, everything sits on the plate below. - const cells: [SkyState, string, string, string, string, string][] = [ - ['day', 'noonday-sky', '#342d25', '3.71', '#2a4f27', '2.56'], - ['dusk', 'noonday-sky', '#342d25', '1.00', '#2a4f27', '1.00'], - ['night', 'midnight-sky', '#aeccff', '5.05', '#609385', '2.36'], + // Per state: the picked theme, its text ink and link, and their WCAG ratios on the sky the scenes + // draw (skyBackdrop, the worst case over its band). Text clears 4.5 in every state. Link does not: + // the links are derived in src/sky/skyTokens.js, outside the band, and these numbers are recorded + // as they are measured (Night's #609385 would clear 4.5 at a luminance of about 0.53 or more). + // 'auto' resolves from the clock to one of these three and has the same numbers. + const cells: [FixedSkyState, string, string, string, string, string][] = [ + ['day', 'noonday-sky', '#342d25', '4.58', '#2a4f27', '3.15'], + ['dusk', 'noonday-sky', '#342d25', '4.57', '#2a4f27', '3.15'], + ['night', 'midnight-sky', '#aeccff', '4.97', '#609385', '2.32'], ]; - it.each(cells)('%s with %s: ink %s is %s, link %s is %s', (state, id, ink, want, link, wantLink) => { - const sky = SKY[id as keyof typeof SKY]; - expect(sky.tokens.Background.OnContainer).toBe(ink); - expect(sky.link).toBe(link); - expect(r2(ratio(ink, skyBackdrop(state)))).toBe(want); - expect(r2(ratio(link, skyBackdrop(state)))).toBe(wantLink); + it.each(cells)( + '%s with %s: text %s is %s, link %s is %s', + (state, id, ink, want, link, wantLink) => { + const sky = SKY[id as keyof typeof SKY]; + expect(sky.tokens.Background.OnContainer).toBe(ink); + expect(sky.link).toBe(link); + expect(r2(ratio(ink, skyBackdrop(state)))).toBe(want); + expect(r2(ratio(link, skyBackdrop(state)))).toBe(wantLink); + } + ); + + it.each(cells)('%s: text clears AA, link is the known shortfall', (state, id, ink, _w, link) => { + expect(ratio(ink, skyBackdrop(state))).toBeGreaterThanOrEqual(AA); + expect(ratio(link, skyBackdrop(state))).toBeLessThan(AA); }); - it('night is the only state whose text clears AA on bare sky', () => { - const passing = fixed.filter( - ([s, id]) => ratio(SKY[id].tokens.Background.OnContainer, skyBackdrop(s)) >= AA - ); - expect(passing.map(([s]) => s)).toEqual(['night']); + it.each([ + [12, 'day'], + [18, 'dusk'], + [0, 'night'], + ] as const)('auto at %s:00 has the numbers of %s', (hour, state) => { + setSkyClock(() => new Date(2026, 8, 22, hour, hour === 18 ? 10 : 0)); + const id = skyThemeId('auto'); + expect(id).toBe(skyThemeId(state)); + const { tokens, link } = SKY[id]; + const text = tokens.Background.OnContainer; + expect(r2(ratio(text, skyBackdrop('auto')))).toBe(r2(ratio(text, skyBackdrop(state)))); + expect(r2(ratio(link, skyBackdrop('auto')))).toBe(r2(ratio(link, skyBackdrop(state)))); + setSkyClock(); }); }); diff --git a/src/app/features/scene/skyTheme.ts b/src/app/features/scene/skyTheme.ts index 8854fcd4..2e8c1b2e 100644 --- a/src/app/features/scene/skyTheme.ts +++ b/src/app/features/scene/skyTheme.ts @@ -1,19 +1,23 @@ -// Which theme a sky state lights the page with. 'auto' picks none: the page keeps the theme it -// would have had (the user's choice when signed in, Midnight Sky when signed out). +// Which theme a sky state lights the page with. 'auto' takes the theme of the sky it resolves to +// (skyState.resolveSkyState), since that is the sky behind the text. // -// Day and Dusk take the dark ink of Noonday Sky, Night the light ink of Midnight Sky. On bare sky -// only Night's text clears 4.5:1 (5.05); Day's sky reaches 0.919 luminance and Dusk's spans -// 0.017 to 0.965, so no ink holds there. Text therefore never rests on bare sky: every region -// that would is a plate (skyPlate.css.ts), and skyTheme.test.ts pins the numbers. -import type { SkyState } from './skyState'; +// Day and Dusk take the dark ink of Noonday Sky, Night the light ink of Midnight Sky. Each state's +// drawn sky is shaped into a luminance band (skyBand.ts) in which that ink's TEXT clears 4.5:1 on +// bare sky: Day and Dusk at least 0.2983 (dark ink), Night at most 0.0931 (light ink). skyTheme.test.ts +// pins the measured text and link ratios. Link ink is not in the band: the Night link (#609385, +// 2.36:1) and the dark links on Day and Dusk fall short, because the themes' link colours are +// derived outside this code (src/sky/skyTokens.js); lightening the Night link to a luminance of +// about 0.53 or more would clear it. The plate (skyPlate.css.ts) is still in the shell, pending +// its removal. +import { resolveSkyState } from './skyState'; +import type { FixedSkyState, SkyState } from './skyState'; export type SkyThemeId = 'midnight-sky' | 'noonday-sky'; -const BY_STATE: Record, SkyThemeId> = { +const BY_STATE: Record = { day: 'noonday-sky', dusk: 'noonday-sky', night: 'midnight-sky', }; -export const skyThemeId = (state: SkyState): SkyThemeId | undefined => - state === 'auto' ? undefined : BY_STATE[state]; +export const skyThemeId = (state: SkyState): SkyThemeId => BY_STATE[resolveSkyState(state)]; diff --git a/src/app/features/scene/terrain/Terrain.ts b/src/app/features/scene/terrain/Terrain.ts index fdc53926..60ab33eb 100644 --- a/src/app/features/scene/terrain/Terrain.ts +++ b/src/app/features/scene/terrain/Terrain.ts @@ -1,4 +1,5 @@ import type { Scene, SceneOptions } from '../Scene'; +import type { SkyBand } from '../skyBand'; import { FrameSampler, FrameStats } from '../ridge/probe'; import { albedoPalette } from './terrainAlbedo'; import { sunIndex } from '../../../../sky/scatter.js'; @@ -31,6 +32,8 @@ export type TerrainOptions = Pick< sky?: SkyName; /** Put the sun at this elevation (degrees) and azimuth (clockwise from north; default: noonday's) instead of at the baked sky's: `?sun=` and `?az=` in the page URL. Lights between the two bakes, for dusk. */ sun?: { elevation: number; azimuth?: number }; + /** The luminance band the sky is shaped into (skyBand.ts), in the sky pass and in the haze that fades into it. Default: none. */ + band?: SkyBand; /** Terrain seed: the height map is sampled at an offset taken from it. Default 0. */ seed?: number; /** Show the rAF median/p95 readout (plus triangle count). Default: the page URL has `?probe=1`. */ @@ -212,6 +215,8 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { const ambient = [...ground.ambient]; const heading = START_TURNS; const glow = [...glowColour(sky)]; + const band: [number, number] = sky.band ? [sky.band.floor, sky.band.ceiling] : [0, 1]; + const lift = [...(sky.band?.lift ?? [0, 0, 0])]; const sunTexel = sunIndex(sky.elevation); const night = nightness(sky.elevation); const moonTexel = sunIndex(Math.abs(sky.elevation)); @@ -247,6 +252,8 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { gl.uniform3fv(us('uSun'), sun); gl.uniform3fv(us('uSunColour'), sunLight); gl.uniform3fv(us('uGlow'), glow); + gl.uniform2fv(us('uBand'), band); + gl.uniform3fv(us('uLift'), lift); gl.uniform1f(us('uSunIndex'), sunTexel); gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_3D, skyTex); @@ -271,6 +278,8 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { gl.uniform3fv(u('uLight'), ground.colour); gl.uniform3fv(u('uAmbient'), ambient); gl.uniform3fv(u('uGlow'), glow); + gl.uniform2fv(u('uBand'), band); + gl.uniform3fv(u('uLift'), lift); gl.uniform1f(u('uSunIndex'), sunTexel); gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_3D, skyTex); diff --git a/src/app/features/scene/terrain/TerrainSky2D.test.ts b/src/app/features/scene/terrain/TerrainSky2D.test.ts index c3eb1e3d..c3a95d69 100644 --- a/src/app/features/scene/terrain/TerrainSky2D.test.ts +++ b/src/app/features/scene/terrain/TerrainSky2D.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createTerrainSkyScene, SKY_WIDTH } from './TerrainSky2D'; import { startBackgroundScene } from '../index'; +import { terrainSkyFor } from '../skyForState'; let search: string; let arcs: number; @@ -218,3 +219,32 @@ describe('startBackgroundScene, terrain style', () => { ).toBeNull(); }); }); + +describe('the 2D sky lies in the band of its state', () => { + // The painted pixels, read back through the sRGB curve as a screen shows them. + const lumOf = (rgb: number[]) => { + const [r, g, b] = rgb.map((v) => { + const e = v / 255; + return e <= 0.04045 ? e / 12.92 : ((e + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; + }; + const painted = (state: 'day' | 'dusk' | 'night') => { + const c = canvasWith(); + createTerrainSkyScene({ canvas: c.canvas, tier: 'low', ...terrainSkyFor(state) })!.dispose(); + const img = c.painted[0]; + const out: number[] = []; + for (let y = 0; y < img.height; y += 1) { + for (let x = 0; x < img.width; x += 1) out.push(lumOf(pixel(img, x, y))); + } + return out; + }; + + it.each([['day'], ['dusk']] as const)('%s: no pixel below the text bound 0.2983', (state) => { + expect(Math.min(...painted(state))).toBeGreaterThanOrEqual(0.2983); + }); + + it('night: no pixel above the text bound 0.0931', () => { + expect(Math.max(...painted('night'))).toBeLessThanOrEqual(0.0931); + }); +}); diff --git a/src/app/features/scene/terrain/TerrainSky2D.ts b/src/app/features/scene/terrain/TerrainSky2D.ts index 0c19ec97..71bc7a78 100644 --- a/src/app/features/scene/terrain/TerrainSky2D.ts +++ b/src/app/features/scene/terrain/TerrainSky2D.ts @@ -9,7 +9,7 @@ import { CAMERA, view } from './terrainWorld'; export const SKY_WIDTH = 96; /** The sky of the terrain scene without WebGL2: the camera's first frame of `skyColour`, once, on a 2D canvas, with the star layer on top. */ -export type TerrainSkyOptions = Pick; +export type TerrainSkyOptions = Pick; /** * Same contract as `createTerrainScene`. Paints the terrain scene's sky (the CPU twin of its sky shader, from the diff --git a/src/app/features/scene/terrain/terrainShaders.ts b/src/app/features/scene/terrain/terrainShaders.ts index 8e270d83..b79e2873 100644 --- a/src/app/features/scene/terrain/terrainShaders.ts +++ b/src/app/features/scene/terrain/terrainShaders.ts @@ -7,6 +7,7 @@ import cultmath from '../shaders/CultMath.glsl?raw'; import phacelle from '../shaders/CultMath.Phacelle.glsl?raw'; import { trimGlsl } from '../cultmathTrim'; +import { LUMA } from '../skyBand'; import { ALBEDO, CAVITY } from './terrainAlbedo'; import { AERIAL_GLSL, AP_DISTANCE_SCALE, SCATTER_GLSL } from './terrainScatter'; import { GLOW_ZENITH, MOON_HAZE, SKY_EXPOSURE, SKY_FADE, STAR_FLOOR } from './terrainSky'; @@ -140,14 +141,28 @@ const ALBEDO_DEFINES = ` #define CAV_DARK ${lit(CAVITY.dark)} #define CAV_LIGHT ${lit(CAVITY.light)} `; -// The sky colour along a unit direction: scattering lifted by the exposure, tone-mapped, plus the night glow. The -// same arithmetic as terrainSky.skyColour(). uGlow is the baked horizon colour already scaled by how dark the sun is. -const SKY_COLOUR_GLSL = `${SCATTER_GLSL} +// The GLSL twin of skyBand.shapeToBand: clamp to 0..1, mix toward uLift to land on the floor uBand.x, or scale down to the ceiling uBand.y. +// No band is uBand = (0, 1). +export const SHAPE_SKY_GLSL = ` +uniform vec2 uBand; +uniform vec3 uLift; +const vec3 LUMA = vec3(${LUMA.map(lit).join(', ')}); +vec3 shapeSky(vec3 c) { + c = clamp(c, 0.0, 1.0); + float l = dot(c, LUMA); + if (l < uBand.x) return mix(c, uLift, (uBand.x - l) / (dot(uLift, LUMA) - l)); + if (l > uBand.y) return c * (uBand.y / l); + return c; +}`; +// The sky colour along a unit direction: scattering lifted by the exposure, tone-mapped, plus the night glow, shaped into +// the state's luminance band. The same arithmetic as terrainSky.skyColour(). uGlow is the baked horizon colour already +// scaled by how dark the sun is. +const SKY_COLOUR_GLSL = `${SCATTER_GLSL}${SHAPE_SKY_GLSL} uniform vec3 uGlow; vec3 skyColour(vec3 d) { - return 1.0 - exp(-${lit(SKY_EXPOSURE)} * skyScatter(d)) + uGlow * mix(1.0, ${lit( + return shapeSky(1.0 - exp(-${lit(SKY_EXPOSURE)} * skyScatter(d)) + uGlow * mix(1.0, ${lit( GLOW_ZENITH -)}, sqrt(clamp(d.y, 0.0, 1.0))); +)}, sqrt(clamp(d.y, 0.0, 1.0)))); }`; export const GRID_FRAG = `${HEADER}${ALBEDO_DEFINES}${SKY_COLOUR_GLSL}${AERIAL_GLSL} diff --git a/src/app/features/scene/terrain/terrainSky.test.ts b/src/app/features/scene/terrain/terrainSky.test.ts index a6dc7a7e..f409471a 100644 --- a/src/app/features/scene/terrain/terrainSky.test.ts +++ b/src/app/features/scene/terrain/terrainSky.test.ts @@ -14,7 +14,6 @@ import { skyAtSun, skyData, skyColour, - skyNameFor, sunDirection, aerialPerspective, SKY_FADE, @@ -25,7 +24,6 @@ import { START_TURNS, Vec3, } from './terrainSky'; -import { skyColours } from '../ridge/skyColour'; const UP = [0, 1, 0] as const; const luma = (c: readonly number[]) => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; @@ -68,11 +66,6 @@ describe('the baked sky is read, not copied', () => { expect(hexToLinear('#ffffff')).toEqual([1, 1, 1]); expect(hexToLinear('#808080')[0]).toBeCloseTo(0.2158, 3); }); - - it('the default sky from skyColours() is a night one, as the stars see it', () => { - expect(skyNameFor(skyColours())).toBe('midnight'); - expect(skyNameFor({ ...skyColours(), top: [150, 198, 255] })).toBe('noonday'); - }); }); describe('a sun at any elevation (skyAtSun)', () => { diff --git a/src/app/features/scene/terrain/terrainSky.ts b/src/app/features/scene/terrain/terrainSky.ts index f6647407..3470249c 100644 --- a/src/app/features/scene/terrain/terrainSky.ts +++ b/src/app/features/scene/terrain/terrainSky.ts @@ -1,9 +1,8 @@ // Lighting, haze and sky for the terrain scene, as pure functions of one sky's baked numbers (src/sky/baked.ts, Void's // bake, GameCult/Void PR #22). No DOM, no GL. The fragment shaders do the same arithmetic with these results as uniforms. import { baked } from '../../../../sky/baked'; -import { isNight } from '../ridge/stars'; -import { skyColours } from '../ridge/skyColour'; -import type { SkyColours } from '../ridge/skyColour'; +import { hexToLinear, shapeToBand } from '../skyBand'; +import type { SkyBand } from '../skyBand'; import { AP_DISTANCE_SCALE, aerialAt, @@ -26,6 +25,8 @@ export interface SkyData { ambient: Vec3; /** The horizon, linear RGB: the baked `horizon_hex`. */ horizon: Vec3; + /** The luminance band `skyColour` shapes the sky into (skyBand.ts); absent: the physical sky as it is. */ + band?: SkyBand; } const RAD = Math.PI / 180; @@ -37,19 +38,9 @@ const smooth = (a: number, b: number, x: number) => { const mixVec = (a: Vec3, b: Vec3, t: number): Vec3 => [0, 1, 2].map((i) => a[i] + (b[i] - a[i]) * t) as unknown as Vec3; -/** Which baked sky a ridge-style `SkyColours` means: the night notion the stars use (`isNight`). */ -export const skyNameFor = (sky: SkyColours): SkyName => (isNight(sky) ? 'midnight' : 'noonday'); - export const isSkyName = (s: unknown): s is SkyName => s === 'midnight' || s === 'noonday'; -/** sRGB hex `#rrggbb` to linear RGB 0..1. */ -export function hexToLinear(hex: string): Vec3 { - const ch = (at: number) => { - const s = parseInt(hex.slice(at, at + 2), 16) / 255; - return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; - }; - return [ch(1), ch(3), ch(5)]; -} +export { hexToLinear }; const fromBaked = (b: typeof baked.midnight | typeof baked.noonday): SkyData => ({ elevation: b.sun.elevation_deg, @@ -115,7 +106,8 @@ export const glowColour = (d: SkyData): Vec3 => { /** * Linear colour of the sky along a unit view direction (x east, y up, z south): the baked single-scattering * radiance for this sun, lifted by SKY_EXPOSURE and tone-mapped `1 - exp(-x)`, plus the night glow, which sits - * brightest at the horizon; at and below the horizon, the horizon's colour. The fragment shader does the same. + * brightest at the horizon; at and below the horizon, the horizon's colour; then shaped into `d.band` when it has one. + * The fragment shader does the same. */ export function skyColour(d: SkyData, dir: Vec3): Vec3 { const el = Math.max(HORIZON_FLOOR, Math.asin(Math.min(1, Math.max(-1, dir[1]))) / RAD); @@ -129,9 +121,10 @@ export function skyColour(d: SkyData, dir: Vec3): Vec3 { const r = scatterRadiance(d.elevation, el, az); const glow = glowColour(d); const shape = 1 + (GLOW_ZENITH - 1) * Math.sqrt(clamp01(dir[1])); - return [0, 1, 2].map( + const c = [0, 1, 2].map( (i) => 1 - Math.exp(-SKY_EXPOSURE * r[i]) + glow[i] * shape ) as unknown as Vec3; + return d.band ? shapeToBand(c, d.band) : c; } /** @@ -231,21 +224,23 @@ export function shade(d: SkyData, albedo: Vec3, normal: Vec3, distance: number, /** * The sky to use. An explicit option wins over the page URL, so a person who picked a sky sees it: a sun * elevation (with its azimuth, default noonday's), else a baked sky. Only without either does the URL steer: - * `?sun=` (with `?az=`), then `?sky=midnight|noonday`, then the night notion of `skyColours()`. + * `?sun=` (with `?az=`), then `?sky=midnight|noonday`, then midnight. (The door always names a sky and a band; these are for callers that do not.) */ export function chooseSky(opts: { sky?: SkyName; sun?: { elevation: number; azimuth?: number }; + band?: SkyBand; }): SkyData { - if (opts.sun) return skyAtSun(opts.sun.elevation, opts.sun.azimuth); - if (opts.sky) return skyData(opts.sky); + const banded = (d: SkyData): SkyData => (opts.band ? { ...d, band: opts.band } : d); + if (opts.sun) return banded(skyAtSun(opts.sun.elevation, opts.sun.azimuth)); + if (opts.sky) return banded(skyData(opts.sky)); const q = new URLSearchParams(window.location.search); const number = (key: string) => (q.get(key)?.trim() ? Number(q.get(key)) : NaN); const elevation = number('sun'); if (Number.isFinite(elevation)) { const azimuth = number('az'); - return skyAtSun(elevation, Number.isFinite(azimuth) ? azimuth : undefined); + return banded(skyAtSun(elevation, Number.isFinite(azimuth) ? azimuth : undefined)); } const name = q.get('sky'); - return skyData(isSkyName(name) ? name : skyNameFor(skyColours())); + return banded(skyData(isSkyName(name) ? name : 'midnight')); } diff --git a/src/app/hooks/useSkyState.ts b/src/app/hooks/useSkyState.ts index 985e2da2..4a2448b2 100644 --- a/src/app/hooks/useSkyState.ts +++ b/src/app/hooks/useSkyState.ts @@ -1,4 +1,15 @@ import { useSyncExternalStore } from 'react'; -import { getSkyState, subscribe, type SkyState } from '../features/scene/skyState'; +import { + getResolvedSkyState, + getSkyState, + subscribe, + type FixedSkyState, + type SkyState, +} from '../features/scene/skyState'; +/** The choice, as the control shows it ('auto' stays 'auto'). */ export const useSkyState = (): SkyState => useSyncExternalStore(subscribe, getSkyState); + +/** The sky being drawn: the choice, resolved from the clock for 'auto'. */ +export const useResolvedSkyState = (): FixedSkyState => + useSyncExternalStore(subscribe, getResolvedSkyState); diff --git a/src/app/hooks/useTheme.ts b/src/app/hooks/useTheme.ts index bf556dd0..6f1b914f 100644 --- a/src/app/hooks/useTheme.ts +++ b/src/app/hooks/useTheme.ts @@ -13,7 +13,7 @@ import '../../sky.css'; import { settingsAtom } from '../state/settings'; import { useSetting } from '../state/hooks/settings'; import { skyThemeId } from '../features/scene/skyTheme'; -import { useSkyState } from './useSkyState'; +import { useResolvedSkyState } from './useSkyState'; export enum ThemeKind { Light = 'light', @@ -102,9 +102,9 @@ export const useSystemThemeKind = (): ThemeKind => { return themeKind; }; -/** The theme the sky state asks for, or undefined while it is 'auto'. */ +/** The theme the sky being drawn asks for ('auto' asks for the one of the sky it resolves to). */ export const useSkyTheme = (): Theme | undefined => { - const id = skyThemeId(useSkyState()); + const id = skyThemeId(useResolvedSkyState()); return id === NoondaySkyTheme.id ? NoondaySkyTheme : id && MidnightSkyTheme; }; From 4177bdcccbf73aac742aa4d85232569755d34bfc Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 9 Nov 2026 23:19:35 +0000 Subject: [PATCH 174/206] scene: bound the terrain sky over what its frozen camera reaches (0 to 16 degrees), ridge stays whole-range Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/skyForState.test.ts | 41 +++++++++++++++++++--- src/app/features/scene/skyForState.ts | 22 ++++++++---- src/app/features/scene/skyState.ts | 7 ++-- src/app/features/scene/skyTheme.test.ts | 18 ++++++---- 4 files changed, 68 insertions(+), 20 deletions(-) diff --git a/src/app/features/scene/skyForState.test.ts b/src/app/features/scene/skyForState.test.ts index fb95dc0a..aec50c12 100644 --- a/src/app/features/scene/skyForState.test.ts +++ b/src/app/features/scene/skyForState.test.ts @@ -5,6 +5,7 @@ import { skyColours } from './ridge/skyColour'; import { chooseSky, skyColour } from './terrain/terrainSky'; import { DUSK_SUN_ELEVATION, + TERRAIN_REACH_DEG, ridgeDrawn, ridgeSkyFor, terrainDrawn, @@ -13,6 +14,7 @@ import { import { displayedLuminance, SKY_BAND } from './skyBand'; import type { FixedSkyState } from './skyBand'; import { setSkyClock, skyBackdrop } from './skyState'; +import { view } from './terrain/terrainWorld'; import { skyThemeId } from './skyTheme'; const hex = (c: readonly number[]) => `#${c.map((v) => v.toString(16).padStart(2, '0')).join('')}`; @@ -80,6 +82,27 @@ describe('ridgeSkyFor', () => { }); }); +describe('the terrain camera reach', () => { + it('covers the highest ray the frozen camera sees, at any aspect, with a small margin', () => { + const tanHalf = Math.tan((55 * Math.PI) / 360); + const tops = [0.4, 0.5, 1, 16 / 9, 2.4].flatMap((aspect) => + [0, 0.125, 0.25, 0.5, 0.75].flatMap((turns) => { + const v = view(aspect, turns); + return [-1, 0, 1].map((nx) => { + const d = [0, 1, 2].map( + (i) => v.forward[i] + nx * tanHalf * aspect * v.right[i] + tanHalf * v.up[i] + ); + return (Math.asin(d[1] / Math.hypot(d[0], d[1], d[2])) * 180) / Math.PI; + }); + }) + ); + const top = Math.max(...tops); + expect(top).toBeCloseTo(14.5, 1); + expect(TERRAIN_REACH_DEG).toBeGreaterThanOrEqual(top); + expect(TERRAIN_REACH_DEG - top).toBeLessThan(2); + }); +}); + describe('terrainSkyFor', () => { it('draws the sky auto resolves to, with that state band', () => { setSkyClock(() => new Date(2026, 8, 22, 12, 0)); @@ -108,11 +131,11 @@ const dirAt = (el: number, az: number): [number, number, number] => { -Math.cos(el * rad) * Math.cos(az * rad), ]; }; -const sweepOf = (sky: ReturnType, N = 4000) => { +const sweepOf = (sky: ReturnType, N = 4000, maxEl = TERRAIN_REACH_DEG) => { const rad = Math.PI / 180; const out: number[] = []; for (let i = 0; i < N; i += 1) { - const y = (i + 0.5) / N; // sin(elevation): uniform over the upper hemisphere + const y = ((i + 0.5) / N) * Math.sin(maxEl * rad); // sin(elevation): uniform from the horizon to maxEl out.push( displayedLuminance(skyColour(sky, dirAt(Math.asin(y) / rad, (i * 137.50776405) % 360))) ); @@ -121,7 +144,10 @@ const sweepOf = (sky: ReturnType, N = 4000) => { for (let dAz = -3; dAz <= 3; dAz += 0.05) { out.push( displayedLuminance( - skyColour(sky, dirAt(Math.max(0, sky.elevation + dEl), sky.azimuth + dAz)) + skyColour( + sky, + dirAt(Math.min(maxEl, Math.max(0, sky.elevation + dEl)), sky.azimuth + dAz) + ) ) ); } @@ -132,6 +158,13 @@ const sweepOf = (sky: ReturnType, N = 4000) => { describe('the terrain sky lies in the band its text ink needs', () => { const sweep = (state: FixedSkyState) => sweepOf(chooseSky(terrainSkyFor(state))); + it('dusk needs its lift: the scatter sky alone dips below the text bound within the reach', () => { + const { sun } = terrainSkyFor('dusk'); + const l = sweepOf(chooseSky({ sun })); + expect(Math.min(...l)).toBeLessThan(needs('dusk').floor - 0.02); + expect(Math.min(...sweep('dusk'))).toBeGreaterThanOrEqual(needs('dusk').floor); + }); + it.each(STATES)('%s', (state) => { const { floor, ceiling } = needs(state); const l = sweep(state); @@ -142,7 +175,7 @@ describe('the terrain sky lies in the band its text ink needs', () => { it.each(STATES)('%s holds for a sun at any elevation, as ?sun= can put it', (state) => { const { floor, ceiling } = needs(state); for (let e = -90; e <= 90; e += 10) { - const l = sweepOf(chooseSky({ sun: { elevation: e }, band: SKY_BAND[state] })); + const l = sweepOf(chooseSky({ sun: { elevation: e }, band: SKY_BAND[state] }), 4000, 90); expect(Math.min(...l)).toBeGreaterThanOrEqual(floor); expect(Math.max(...l)).toBeLessThanOrEqual(ceiling); } diff --git a/src/app/features/scene/skyForState.ts b/src/app/features/scene/skyForState.ts index 0de0dbe0..e7abcd73 100644 --- a/src/app/features/scene/skyForState.ts +++ b/src/app/features/scene/skyForState.ts @@ -13,6 +13,7 @@ import type { SkyState } from './skyState'; import { displayedLuminance, SKY_BAND } from './skyBand'; import type { SkyBand } from './skyBand'; import { luminance } from '../../../sky/skyTokens.js'; +import { CAMERA } from './terrain/terrainWorld'; const rgb = (hex: string): Srgb => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)) as unknown as Srgb; @@ -89,6 +90,14 @@ export function ridgeDrawn(state: SkyState): Range { return { darkest: Math.min(...l), lightest: Math.max(...l) }; } +/** + * How high the terrain camera can see, in degrees of elevation: it is frozen at a downward pitch and only turns + * (no pull-back), so the top edge of its view, at the centre column, is the pitch down subtracted from half the + * field of view (14.5 degrees; off-centre columns look lower). Plus a 1.5 degree margin. The terrain's range is + * over 0 to this, all azimuths, not the whole upper sphere; below the horizon the sky is the horizon's colour. + */ +export const TERRAIN_REACH_DEG = Math.ceil((CAMERA.fov / 2 - CAMERA.pitch) * (180 / Math.PI) + 1.5); + const SAMPLE_DEG = 3; const SUN_DEG = 0.25; const SUN_SPAN_DEG = 8; @@ -96,9 +105,8 @@ const HORIZON_DEG = 0.5; const REFINE_FROM = 6; /** - * The terrain sky's range, as displayed (`displayedLuminance`): `skyColour` (the CPU twin of its sky shader) over the whole upper sphere, which the camera - * sweeps as it turns, for the sky the scene picks for this state. A 3 degree grid, a 0.5 degree ring of the horizon, the - * zenith and a 0.25 degree patch around the sun find where the extremes are; each of the best few is then refined by a + * The terrain sky's range, as displayed (`displayedLuminance`): `skyColour` (the CPU twin of its sky shader) over the part of the sky the camera reaches (0 to TERRAIN_REACH_DEG, every azimuth: it turns), for the sky the scene picks for this state. A 3 degree grid, a 0.5 degree ring of the horizon, the + * top of the reach and a 0.25 degree patch around the sun find where the extremes are; each of the best few is then refined by a * compass search whose step halves from 1 degree to under 0.001, because the sun's glow peaks (and the sky's darkest * patch sits) between any fixed grid's samples. */ @@ -106,7 +114,7 @@ export function terrainDrawn(state: SkyState): Range { const sky = chooseSky(terrainSkyFor(state)); const rad = Math.PI / 180; const lum = (el: number, az: number) => { - const e = Math.min(90, Math.max(0, el)) * rad; + const e = Math.min(TERRAIN_REACH_DEG, Math.max(0, el)) * rad; const a = az * rad; return displayedLuminance( skyColour(sky, [Math.cos(e) * Math.sin(a), Math.sin(e), -Math.cos(e) * Math.cos(a)]) @@ -114,11 +122,11 @@ export function terrainDrawn(state: SkyState): Range { }; const seen: { el: number; az: number; l: number }[] = []; const at = (el: number, az: number) => seen.push({ el, az, l: lum(el, az) }); - for (let el = 0; el <= 90; el += SAMPLE_DEG) { + for (let el = 0; el <= TERRAIN_REACH_DEG; el += SAMPLE_DEG) { for (let az = 0; az < 360; az += SAMPLE_DEG) at(el, az); } for (let az = 0; az < 360; az += HORIZON_DEG) at(0, az); - at(90, 0); + at(TERRAIN_REACH_DEG, 0); for (let dEl = -SUN_SPAN_DEG; dEl <= SUN_SPAN_DEG; dEl += SUN_DEG) { for (let dAz = -SUN_SPAN_DEG; dAz <= SUN_SPAN_DEG; dAz += SUN_DEG) { at(sky.elevation + dEl, sky.azimuth + dAz); @@ -132,7 +140,7 @@ export function terrainDrawn(state: SkyState): Range { [0, step], [0, -step], ].map(([dEl, dAz]) => { - const el = Math.min(90, Math.max(0, p.el + dEl)); + const el = Math.min(TERRAIN_REACH_DEG, Math.max(0, p.el + dEl)); return { el, az: p.az + dAz, l: lum(el, p.az + dAz) }; }); const refine = (from: Point, sign: 1 | -1) => { diff --git a/src/app/features/scene/skyState.ts b/src/app/features/scene/skyState.ts index ce31fb23..c27c10a4 100644 --- a/src/app/features/scene/skyState.ts +++ b/src/app/features/scene/skyState.ts @@ -117,11 +117,12 @@ export interface SkyBackdrop { // The union of every sky a scene draws for the state: the ridge sky and the terrain sky, each from // the function its scene uses (`ridgeDrawn`, `terrainDrawn` in skyForState.ts). Held as constants so // this module, which the main chunk loads, does not pull in the terrain sky and the baked scattering -// tables with it; skyForState.test.ts fails when they drift from the computed union. +// tables with it; skyForState.test.ts fails when they drift from the computed union. The ridge sky counts +// over its whole range, the terrain sky over what its frozen camera reaches (TERRAIN_REACH_DEG, skyForState.ts). // Rounded outward to six places. const BACKDROPS: Record = { - day: { darkest: 0.304139, lightest: 0.925759 }, - dusk: { darkest: 0.303833, lightest: 0.967298 }, + day: { darkest: 0.466725, lightest: 0.892734 }, + dusk: { darkest: 0.303835, lightest: 0.967298 }, night: { darkest: 0.05987, lightest: 0.079657 }, }; diff --git a/src/app/features/scene/skyTheme.test.ts b/src/app/features/scene/skyTheme.test.ts index 027647da..4f237fc6 100644 --- a/src/app/features/scene/skyTheme.test.ts +++ b/src/app/features/scene/skyTheme.test.ts @@ -49,12 +49,12 @@ describe('skyThemeId', () => { describe('ink of the theme a state picks, on bare sky', () => { // Per state: the picked theme, its text ink and link, and their WCAG ratios on the sky the scenes - // draw (skyBackdrop, the worst case over its band). Text clears 4.5 in every state. Link does not: - // the links are derived in src/sky/skyTokens.js, outside the band, and these numbers are recorded - // as they are measured (Night's #609385 would clear 4.5 at a luminance of about 0.53 or more). + // draw (skyBackdrop, the worst case over its band). Text clears 4.5 in every state. Link clears + // on day and not on dusk or night: the links are derived in src/sky/skyTokens.js, outside the band, and these + // numbers are recorded as they are measured (Night's #609385 would clear 4.5 at a luminance of about 0.53 or more). // 'auto' resolves from the clock to one of these three and has the same numbers. const cells: [FixedSkyState, string, string, string, string, string][] = [ - ['day', 'noonday-sky', '#342d25', '4.58', '#2a4f27', '3.15'], + ['day', 'noonday-sky', '#342d25', '6.68', '#2a4f27', '4.60'], ['dusk', 'noonday-sky', '#342d25', '4.57', '#2a4f27', '3.15'], ['night', 'midnight-sky', '#aeccff', '4.97', '#609385', '2.32'], ]; @@ -70,9 +70,15 @@ describe('ink of the theme a state picks, on bare sky', () => { } ); - it.each(cells)('%s: text clears AA, link is the known shortfall', (state, id, ink, _w, link) => { + it.each(cells)('%s: text clears AA', (state, _id, ink) => { expect(ratio(ink, skyBackdrop(state))).toBeGreaterThanOrEqual(AA); - expect(ratio(link, skyBackdrop(state))).toBeLessThan(AA); + }); + + it('link clears AA on day (the terrain camera sees only the lighter sky) and falls short on dusk and night', () => { + const link = (s: FixedSkyState, id: keyof typeof SKY) => ratio(SKY[id].link, skyBackdrop(s)); + expect(link('day', 'noonday-sky')).toBeGreaterThanOrEqual(AA); + expect(link('dusk', 'noonday-sky')).toBeLessThan(AA); + expect(link('night', 'midnight-sky')).toBeLessThan(AA); }); it.each([ From eafa3eda798f4dcfb2aa0d49c686b763902c9aed Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 9 Nov 2026 23:28:55 +0000 Subject: [PATCH 175/206] scene: Day sky bound was 1.2e-6 low; refine to 1e-6 degrees and check both directions Day lightest 0.92805 -> 0.928052 (true terrain peak 0.92805117...). The compass refinement now halves to 5e-7 degrees; the drift test checks the constant against the refined computation and an independent golden-section sweep, exact to [extreme, extreme + 1e-6). Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/skyForState.test.ts | 66 ++++++++++++++++++++-- src/app/features/scene/skyForState.ts | 2 +- src/app/features/scene/skyState.ts | 2 +- 3 files changed, 63 insertions(+), 7 deletions(-) diff --git a/src/app/features/scene/skyForState.test.ts b/src/app/features/scene/skyForState.test.ts index 44ca5899..20bc023a 100644 --- a/src/app/features/scene/skyForState.test.ts +++ b/src/app/features/scene/skyForState.test.ts @@ -130,26 +130,82 @@ describe('both scenes of a state lie inside skyBackdrop', () => { }); describe('skyBackdrop holds the union of what the scenes draw', () => { + // An independent extremum: not terrainDrawn's grid and compass search but alternating golden-section + // line searches in elevation and azimuth from 60 starts (a golden-angle spiral and scatter about the sun), + // the bracket shrinking to 1e-3 degrees. A plateau (the shader clamps) cannot stall it: each line search + // always moves to the better end of its bracket. + const extremes = (state: 'day' | 'dusk' | 'night') => { + const sky = chooseSky(terrainSkyFor(state)); + const rad = Math.PI / 180; + const f = (el: number, az: number) => { + const e = Math.min(90, Math.max(0, el)) * rad; + const a = az * rad; + const [r, g, b] = skyColour(sky, [ + Math.cos(e) * Math.sin(a), + Math.sin(e), + -Math.cos(e) * Math.cos(a), + ]).map((v) => Math.min(1, Math.max(0, v))); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; + }; + const P = 0.6180339887; + const line = (g: (x: number) => number, mid: number, w: number) => { + let a = mid - w; + let b = mid + w; + for (let k = 0; k < 40; k += 1) { + const c = b - P * (b - a); + const d = a + P * (b - a); + if (g(c) > g(d)) b = d; + else a = c; + } + return (a + b) / 2; + }; + const search = (sign: 1 | -1) => { + let best = -Infinity; + for (let i = 0; i < 60; i += 1) { + let el = + i % 2 + ? sky.elevation + (((i * 7919) % 1600) - 800) / 100 + : Math.asin((i + 0.5) / 60) / rad; + let az = + i % 2 ? sky.azimuth + (((i * 104729) % 1600) - 800) / 100 : (i * 137.50776405) % 360; + for (let w = 8; w > 1e-3; w /= 2) { + const [el0, az0] = [el, az]; + el = line((x) => sign * f(Math.max(0, x), az0), el0, w); + const el1 = el; + az = line((x) => sign * f(el1, x), az0, w); + best = Math.max(best, sign * f(el, az)); + } + } + return sign * best; + }; + return { darkest: search(-1), lightest: search(1) }; + }; + const computed = (state: 'day' | 'dusk' | 'night') => { const r = [ridgeDrawn(state), terrainDrawn(state)].filter((x) => x !== undefined) as { darkest: number; lightest: number; }[]; + const ind = extremes(state); + const ridge = ridgeDrawn(state); return { - darkest: Math.min(...r.map((x) => x.darkest)), - lightest: Math.max(...r.map((x) => x.lightest)), + darkest: Math.min(...r.map((x) => x.darkest), ind.darkest, ridge?.darkest ?? 1), + lightest: Math.max(...r.map((x) => x.lightest), ind.lightest, ridge?.lightest ?? 0), }; }; + // The constants are the swept extremes rounded outward to six places, so each lies in [extreme, extreme + 1e-6): + // a constant 1e-6 low is under the extreme, one 2e-5 high is over the bound. SEARCH is the searches' own error. + const SEARCH = 1e-8; it.each(['day', 'dusk', 'night'] as const)( - '%s: the constant bounds the computed range and sits within 1e-5 of it', + '%s: the constant is the swept extreme rounded outward, within 1e-6 above it', (state) => { const c = computed(state); const b = skyBackdrop(state); expect(b.darkest).toBeLessThanOrEqual(c.darkest); expect(b.lightest).toBeGreaterThanOrEqual(c.lightest); - expect(c.darkest - b.darkest).toBeLessThan(1e-5); - expect(b.lightest - c.lightest).toBeLessThan(1e-5); + expect(c.darkest - b.darkest).toBeLessThan(1e-6 + SEARCH); + expect(b.lightest - c.lightest).toBeLessThan(1e-6 + SEARCH); } ); diff --git a/src/app/features/scene/skyForState.ts b/src/app/features/scene/skyForState.ts index a6d78e4a..aedc6861 100644 --- a/src/app/features/scene/skyForState.ts +++ b/src/app/features/scene/skyForState.ts @@ -121,7 +121,7 @@ export function terrainDrawn(state: SkyState): Range { }); const refine = (from: Point, sign: 1 | -1) => { let best = from; - for (let step = 1; step > 0.0005; step /= 2) { + for (let step = 1; step > 5e-7; step /= 2) { let improved = true; while (improved) { const cur = best; diff --git a/src/app/features/scene/skyState.ts b/src/app/features/scene/skyState.ts index e06e9845..dc2b6199 100644 --- a/src/app/features/scene/skyState.ts +++ b/src/app/features/scene/skyState.ts @@ -63,7 +63,7 @@ export interface SkyBackdrop { // bounds all three fixed states. const BACKDROPS: Record = { auto: { darkest: 0.016727, lightest: 0.967912 }, - day: { darkest: 0.237309, lightest: 0.92805 }, + day: { darkest: 0.237309, lightest: 0.928052 }, dusk: { darkest: 0.016727, lightest: 0.967912 }, night: { darkest: 0.058099, lightest: 0.077467 }, }; From 577d795189c1cc7adec6fd3229ab243e180a47d3 Mon Sep 17 00:00:00 2001 From: Nibu Date: Mon, 9 Nov 2026 23:29:02 +0000 Subject: [PATCH 176/206] scene: auto never picks a theme; URL steers auto; quantised bounds; listener-window fix; DST - skyThemeId('auto') is undefined again (auto must not override a signed-in theme); the resolved state stays readable by getResolvedSkyState and subscribe. - In auto, ?sun= ?az= ?sky= reach the terrain again, shaped into the band of the clock-resolved state; an explicit state beats the URL. - The sky-change notice is no longer swallowed by a listener arriving or leaving between the clock crossing and the tick. - BACKDROPS bound what the 8-bit screen shows with the dither at its worst; shaping floor 0.317, night ceiling 0.085. - The clock uses standard time, so daylight saving does not move the sun. - A Terrain GL fake test pins uBand and uLift for each state. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/index.test.ts | 14 +- src/app/features/scene/index.ts | 24 +-- src/app/features/scene/skyBand.test.ts | 4 +- src/app/features/scene/skyBand.ts | 24 ++- src/app/features/scene/skyForState.test.ts | 147 +++++++++++------- src/app/features/scene/skyForState.ts | 29 +++- src/app/features/scene/skyState.test.ts | 23 ++- src/app/features/scene/skyState.ts | 22 ++- src/app/features/scene/skyTheme.test.ts | 37 +++-- src/app/features/scene/skyTheme.ts | 9 +- .../features/scene/standardTime.testkit.ts | 7 + .../features/scene/terrain/Terrain.test.ts | 28 ++++ src/app/features/scene/terrain/Terrain.ts | 2 +- src/app/features/scene/terrain/terrainSky.ts | 33 ++-- src/app/hooks/useSkyState.ts | 14 +- src/app/hooks/useTheme.ts | 6 +- 16 files changed, 287 insertions(+), 136 deletions(-) create mode 100644 src/app/features/scene/standardTime.testkit.ts diff --git a/src/app/features/scene/index.test.ts b/src/app/features/scene/index.test.ts index 645b093c..7971508b 100644 --- a/src/app/features/scene/index.test.ts +++ b/src/app/features/scene/index.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { atStandardTime } from './standardTime.testkit'; import { baked } from '../../../sky/baked'; import type { Scene } from './Scene'; import { startBackgroundScene } from './index'; @@ -24,7 +25,7 @@ const hex = (c: readonly number[]) => `#${c.map((v) => v.toString(16).padStart(2 beforeEach(() => { vi.stubGlobal('localStorage', { getItem: () => null, setItem: () => undefined }); // Local midnight: 'auto' resolves to night. - setSkyClock(() => new Date(2026, 8, 22, 0, 0)); + setSkyClock(() => atStandardTime(0, 0)); setSkyState('auto'); createRidgeScene.mockReset(); createScene.mockReset(); @@ -44,6 +45,17 @@ describe('startBackgroundScene follows the sky state', () => { scene?.dispose(); }); + it('rebuilds when auto is replaced by the explicit state it already resolves to (a URL may steer only auto)', async () => { + const [a, b] = [fakeScene(), fakeScene()]; + createRidgeScene.mockReturnValueOnce(a).mockReturnValueOnce(b); + const scene = await startBackgroundScene(config, caps, { canvas }); + setSkyState('night'); + await flush(); + expect(a.dispose).toHaveBeenCalledOnce(); + expect(createRidgeScene).toHaveBeenCalledTimes(2); + scene?.dispose(); + }); + it('starts on the stored state', async () => { setSkyState('night'); createRidgeScene.mockReturnValue(fakeScene()); diff --git a/src/app/features/scene/index.ts b/src/app/features/scene/index.ts index 4fe33934..73ab8d5a 100644 --- a/src/app/features/scene/index.ts +++ b/src/app/features/scene/index.ts @@ -1,7 +1,7 @@ import type { Scene, SceneOptions } from './Scene'; import { BackgroundSceneConfig, Capabilities, SceneTier, resolveTier } from './tier'; -import { getResolvedSkyState, subscribe } from './skyState'; -import type { FixedSkyState } from './skyState'; +import { getResolvedSkyState, getSkyState, subscribe } from './skyState'; +import type { SkyState } from './skyState'; export type { Scene, SceneStats } from './Scene'; export type { SceneStyle, SceneTier } from './tier'; @@ -18,7 +18,7 @@ export type StartOptions = Omit { let createScene: (o: SceneOptions) => Scene | null; try { @@ -64,21 +64,25 @@ export async function startBackgroundScene( ): Promise { const tier = resolveTier(config, capabilities); if (tier === 'off') return null; - const build = (state: FixedSkyState) => create(config, { ...options, tier }, state); - const state = getResolvedSkyState(); + const build = (state: SkyState) => create(config, { ...options, tier }, state); + const state = getSkyState(); + const key0 = `${state}:${getResolvedSkyState()}`; const first = await build(state); if (!first || config?.style === 'raymarch') return first; let inner: Scene | null = first; - let built = state; + let built = key0; let loggedIn = false; let disposed = false; let generation = 0; - const rebuild = async (next: FixedSkyState) => { - if (next === built) return; + // A rebuild is for a new choice or a new resolved sky ('auto' crossing a boundary), not for a repeat of either. + const rebuild = async () => { + const next = getSkyState(); + const key = `${next}:${getResolvedSkyState()}`; + if (key === built) return; generation += 1; const mine = generation; - built = next; + built = key; inner?.dispose(); inner = null; const scene = await build(next); @@ -95,7 +99,7 @@ export async function startBackgroundScene( }; const off = subscribe(rebuild); // A change that landed while the first build was awaited was missed by the subscription. - rebuild(getResolvedSkyState()); + rebuild(); return { login() { loggedIn = true; diff --git a/src/app/features/scene/skyBand.test.ts b/src/app/features/scene/skyBand.test.ts index 23c7c18e..5963fb79 100644 --- a/src/app/features/scene/skyBand.test.ts +++ b/src/app/features/scene/skyBand.test.ts @@ -52,9 +52,9 @@ describe('the bands', () => { Object.values(SKY_BAND).forEach((b) => expect(luma(b.lift)).toBeGreaterThan(b.floor)); }); - it('keep the floor 0.01 above the 0.2983 text bound: gamma 2.2 reads darker than sRGB by less', () => { + it('keep the floor above the 0.2983 text bound by what gamma 2.2, 8-bit rounding and dither can take', () => { const grey = shapeToBand([0, 0, 0], SKY_BAND.day); - const shown = displayedLuminance(grey); + const shown = displayedLuminance(grey, 'low'); expect(shown).toBeGreaterThanOrEqual(0.2983); expect(shown).toBeLessThan(SKY_BAND.day.floor); }); diff --git a/src/app/features/scene/skyBand.ts b/src/app/features/scene/skyBand.ts index 470393ee..ae6d5791 100644 --- a/src/app/features/scene/skyBand.ts +++ b/src/app/features/scene/skyBand.ts @@ -28,13 +28,20 @@ export function hexToLinear(hex: string): Linear { return [ch(1), ch(3), ch(5)]; } +/** How a displayed colour is read: as encoded, or as the 8-bit screen can show it, the sky pass dithers by half a step either way ('low', 'high'). */ +export type Reading = 'exact' | 'low' | 'high'; + /** * Relative luminance of what the terrain shaders put on screen for a linear colour: they encode with - * pow(1/2.2), not the sRGB curve, so the screen colour is read back through the sRGB curve. + * pow(1/2.2), not the sRGB curve, so the screen colour is read back through the sRGB curve. 'low' and 'high' read + * it after 8-bit quantisation with the dither at its worst, so the bounds they give hold on the screen. */ -export function displayedLuminance(c: Linear): number { +export function displayedLuminance(c: Linear, reading: Reading = 'exact'): number { const ch = (v: number) => { - const e = Math.min(1, Math.max(0, v)) ** (1 / 2.2); + let e = Math.min(1, Math.max(0, v)) ** (1 / 2.2); + if (reading !== 'exact') { + e = Math.min(255, Math.max(0, Math.round(e * 255 + (reading === 'low' ? -0.5 : 0.5)))) / 255; + } return e <= 0.04045 ? e / 12.92 : ((e + 0.055) / 1.055) ** 2.4; }; return luma([ch(c[0]), ch(c[1]), ch(c[2])]); @@ -50,13 +57,14 @@ export interface SkyBand { } /** - * The shaping targets, in linear luma. The floor sits 0.01 above the text bound (0.2983) because the - * screen encoding is gamma 2.2, which reads about 0.006 darker through the sRGB curve at this level. + * The shaping targets, in linear luma. The floor sits 0.019 above the text bound (0.2983): the screen encoding is + * gamma 2.2, which reads about 0.006 darker through the sRGB curve at this level, and 8-bit rounding with the sky + * pass's dither at its worst takes about 0.006 more. */ export const SKY_BAND: Record = { - day: { floor: 0.31, ceiling: 1, lift: hexToLinear('#e2f4ff') }, - dusk: { floor: 0.31, ceiling: 1, lift: hexToLinear('#eba0b9') }, - night: { floor: 0, ceiling: 0.09, lift: hexToLinear('#484e64') }, + day: { floor: 0.317, ceiling: 1, lift: hexToLinear('#e2f4ff') }, + dusk: { floor: 0.317, ceiling: 1, lift: hexToLinear('#eba0b9') }, + night: { floor: 0, ceiling: 0.085, lift: hexToLinear('#5a6a9a') }, }; /** diff --git a/src/app/features/scene/skyForState.test.ts b/src/app/features/scene/skyForState.test.ts index aec50c12..116e7f9f 100644 --- a/src/app/features/scene/skyForState.test.ts +++ b/src/app/features/scene/skyForState.test.ts @@ -1,4 +1,5 @@ -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { atStandardTime } from './standardTime.testkit'; import { baked } from '../../../sky/baked'; import { AA, luminance } from '../../../sky/skyTokens.js'; import { skyColours } from './ridge/skyColour'; @@ -33,7 +34,19 @@ const needs = (state: FixedSkyState) => { : { floor: AA * (l + 0.05) - 0.05, ceiling: 1 }; }; -afterEach(() => setSkyClock()); +let search = ''; +beforeEach(() => { + search = ''; + vi.stubGlobal('window', { + get location() { + return { search }; + }, + }); +}); +afterEach(() => { + setSkyClock(); + vi.unstubAllGlobals(); +}); describe('the bands the text ink needs', () => { it('are the numbers the shaping was set from', () => { @@ -45,11 +58,11 @@ describe('the bands the text ink needs', () => { describe('ridgeSkyFor', () => { it('draws the gradient of the sky auto resolves to, never nothing', () => { - setSkyClock(() => new Date(2026, 8, 22, 12, 0)); + setSkyClock(() => atStandardTime(12, 0)); expect(ridgeSkyFor('auto')).toEqual(ridgeSkyFor('day')); - setSkyClock(() => new Date(2026, 8, 22, 0, 0)); + setSkyClock(() => atStandardTime(0, 0)); expect(ridgeSkyFor('auto')).toEqual(ridgeSkyFor('night')); - setSkyClock(() => new Date(2026, 8, 22, 18, 10)); + setSkyClock(() => atStandardTime(18, 10)); expect(ridgeSkyFor('auto')).toEqual(ridgeSkyFor('dusk')); }); @@ -82,6 +95,38 @@ describe('ridgeSkyFor', () => { }); }); +// Not the generator's grid: a golden-angle spiral over the upper sphere (no shared rows or columns) +// plus a 0.05 degree grid across the sun, five times finer than the generator's. +const dirAt = (el: number, az: number): [number, number, number] => { + const rad = Math.PI / 180; + return [ + Math.cos(el * rad) * Math.sin(az * rad), + Math.sin(el * rad), + -Math.cos(el * rad) * Math.cos(az * rad), + ]; +}; +/** Over the sweep: the darkest the 8-bit screen can show (dither at its worst), the lightest, and the darkest as encoded. */ +const sweepOf = (sky: ReturnType, N = 4000, maxEl = TERRAIN_REACH_DEG) => { + const rad = Math.PI / 180; + const out = { low: Infinity, high: -Infinity, exactMin: Infinity }; + const see = (el: number, az: number) => { + const c = skyColour(sky, dirAt(el, az)); + out.low = Math.min(out.low, displayedLuminance(c, 'low')); + out.high = Math.max(out.high, displayedLuminance(c, 'high')); + out.exactMin = Math.min(out.exactMin, displayedLuminance(c)); + }; + for (let i = 0; i < N; i += 1) { + const y = ((i + 0.5) / N) * Math.sin(maxEl * rad); // sin(elevation): uniform from the horizon to maxEl + see(Math.asin(y) / rad, (i * 137.50776405) % 360); + } + for (let dEl = -3; dEl <= 3; dEl += 0.05) { + for (let dAz = -3; dAz <= 3; dAz += 0.05) { + see(Math.min(maxEl, Math.max(0, sky.elevation + dEl)), sky.azimuth + dAz); + } + } + return out; +}; + describe('the terrain camera reach', () => { it('covers the highest ray the frozen camera sees, at any aspect, with a small margin', () => { const tanHalf = Math.tan((55 * Math.PI) / 360); @@ -105,12 +150,45 @@ describe('the terrain camera reach', () => { describe('terrainSkyFor', () => { it('draws the sky auto resolves to, with that state band', () => { - setSkyClock(() => new Date(2026, 8, 22, 12, 0)); + setSkyClock(() => atStandardTime(12, 0)); expect(terrainSkyFor('auto')).toEqual(terrainSkyFor('day')); - setSkyClock(() => new Date(2026, 8, 22, 0, 0)); + setSkyClock(() => atStandardTime(0, 0)); expect(terrainSkyFor('auto')).toEqual(terrainSkyFor('night')); }); + it('lets the page URL steer auto, shaped into the band of the state the clock resolves to', () => { + setSkyClock(() => atStandardTime(0, 0)); // night + search = '?sun=30&az=120'; + expect(terrainSkyFor('auto')).toEqual({ + sun: { elevation: 30, azimuth: 120 }, + band: SKY_BAND.night, + }); + search = '?sky=noonday'; + expect(terrainSkyFor('auto')).toEqual({ sky: 'noonday', band: SKY_BAND.night }); + search = '?sky=garbage'; + expect(terrainSkyFor('auto')).toEqual(terrainSkyFor('night')); + setSkyClock(() => atStandardTime(18, 10)); // dusk, URL azimuth honoured + search = '?sun=2&az=250'; + expect(terrainSkyFor('auto').sun).toEqual({ elevation: 2, azimuth: 250 }); + }); + + it('keeps a URL-chosen sun inside the band of the resolved state', () => { + setSkyClock(() => atStandardTime(12, 0)); // day: floor + search = '?sun=-30'; + const day = sweepOf(chooseSky(terrainSkyFor('auto'))); + expect(day.low).toBeGreaterThanOrEqual(needs('day').floor); + setSkyClock(() => atStandardTime(0, 0)); // night: ceiling + search = '?sun=60'; + const night = sweepOf(chooseSky(terrainSkyFor('auto'))); + expect(night.high).toBeLessThanOrEqual(needs('night').ceiling); + }); + + it('lets an explicit state beat the URL, as before auto had one', () => { + search = '?sun=30&az=120&sky=midnight'; + expect(terrainSkyFor('day')).toEqual({ sky: 'noonday', band: SKY_BAND.day }); + expect(terrainSkyFor('dusk').sun).toEqual({ elevation: DUSK_SUN_ELEVATION }); + }); + it('names the baked sky for day and night, and a low sun for dusk, each with its band', () => { expect(terrainSkyFor('day')).toEqual({ sky: 'noonday', band: SKY_BAND.day }); expect(terrainSkyFor('night')).toEqual({ sky: 'midnight', band: SKY_BAND.night }); @@ -121,63 +199,28 @@ describe('terrainSkyFor', () => { }); }); -// Not the generator's grid: a golden-angle spiral over the upper sphere (no shared rows or columns) -// plus a 0.05 degree grid across the sun, five times finer than the generator's. -const dirAt = (el: number, az: number): [number, number, number] => { - const rad = Math.PI / 180; - return [ - Math.cos(el * rad) * Math.sin(az * rad), - Math.sin(el * rad), - -Math.cos(el * rad) * Math.cos(az * rad), - ]; -}; -const sweepOf = (sky: ReturnType, N = 4000, maxEl = TERRAIN_REACH_DEG) => { - const rad = Math.PI / 180; - const out: number[] = []; - for (let i = 0; i < N; i += 1) { - const y = ((i + 0.5) / N) * Math.sin(maxEl * rad); // sin(elevation): uniform from the horizon to maxEl - out.push( - displayedLuminance(skyColour(sky, dirAt(Math.asin(y) / rad, (i * 137.50776405) % 360))) - ); - } - for (let dEl = -3; dEl <= 3; dEl += 0.05) { - for (let dAz = -3; dAz <= 3; dAz += 0.05) { - out.push( - displayedLuminance( - skyColour( - sky, - dirAt(Math.min(maxEl, Math.max(0, sky.elevation + dEl)), sky.azimuth + dAz) - ) - ) - ); - } - } - return out; -}; - describe('the terrain sky lies in the band its text ink needs', () => { const sweep = (state: FixedSkyState) => sweepOf(chooseSky(terrainSkyFor(state))); it('dusk needs its lift: the scatter sky alone dips below the text bound within the reach', () => { const { sun } = terrainSkyFor('dusk'); - const l = sweepOf(chooseSky({ sun })); - expect(Math.min(...l)).toBeLessThan(needs('dusk').floor - 0.02); - expect(Math.min(...sweep('dusk'))).toBeGreaterThanOrEqual(needs('dusk').floor); + expect(sweepOf(chooseSky({ sun })).exactMin).toBeLessThan(needs('dusk').floor - 0.02); + expect(sweep('dusk').low).toBeGreaterThanOrEqual(needs('dusk').floor); }); it.each(STATES)('%s', (state) => { const { floor, ceiling } = needs(state); const l = sweep(state); - expect(Math.min(...l)).toBeGreaterThanOrEqual(floor); - expect(Math.max(...l)).toBeLessThanOrEqual(ceiling); + expect(l.low).toBeGreaterThanOrEqual(floor); + expect(l.high).toBeLessThanOrEqual(ceiling); }); it.each(STATES)('%s holds for a sun at any elevation, as ?sun= can put it', (state) => { const { floor, ceiling } = needs(state); for (let e = -90; e <= 90; e += 10) { const l = sweepOf(chooseSky({ sun: { elevation: e }, band: SKY_BAND[state] }), 4000, 90); - expect(Math.min(...l)).toBeGreaterThanOrEqual(floor); - expect(Math.max(...l)).toBeLessThanOrEqual(ceiling); + expect(l.low).toBeGreaterThanOrEqual(floor); + expect(l.high).toBeLessThanOrEqual(ceiling); } }); @@ -212,9 +255,9 @@ describe('skyBackdrop holds the union of what the scenes draw', () => { const l = sweepOf(chooseSky(terrainSkyFor(state)), 20000); const b = skyBackdrop(state); const r = ridgeDrawn(state); - expect(Math.min(...l)).toBeGreaterThanOrEqual(b.darkest - EPS); - expect(Math.max(...l)).toBeLessThanOrEqual(b.lightest + EPS); - expect(b.lightest - Math.max(Math.max(...l), r.lightest)).toBeLessThan(5e-4); - expect(Math.min(Math.min(...l), r.darkest) - b.darkest).toBeLessThan(5e-4); + expect(l.low).toBeGreaterThanOrEqual(b.darkest - EPS); + expect(l.high).toBeLessThanOrEqual(b.lightest + EPS); + expect(b.lightest - Math.max(l.high, r.lightest)).toBeLessThan(5e-4); + expect(Math.min(l.low, r.darkest) - b.darkest).toBeLessThan(5e-4); }); }); diff --git a/src/app/features/scene/skyForState.ts b/src/app/features/scene/skyForState.ts index e7abcd73..a40317a2 100644 --- a/src/app/features/scene/skyForState.ts +++ b/src/app/features/scene/skyForState.ts @@ -4,7 +4,7 @@ // it, the terrain sky shaped into it downstream of the scatter function. `skyBackdrop` bounds what comes out, // and the login text was checked against that bound. import { baked } from '../../../sky/baked'; -import { chooseSky, skyColour } from './terrain/terrainSky'; +import { chooseSky, skyColour, urlSky } from './terrain/terrainSky'; import type { SkyName } from './terrain/terrainSky'; import type { SkyColours, Srgb } from './ridge/skyColour'; import { skyColours } from './ridge/skyColour'; @@ -41,14 +41,22 @@ export function ridgeSkyFor(state: SkyState): SkyColours { } } -/** The terrain scene's `sky`, `sun` and `band` options: the state's sky ('auto': the sky it resolves to), shaped into the state's band. */ +/** + * The terrain scene's `sky`, `sun` and `band` options. An explicit state draws its own sky and ignores the URL. 'auto' + * draws the sky it resolves to, unless the page URL steers (`?sun=` `?az=` `?sky=`, see `urlSky`): then the URL's sky, + * shaped into the band of the state the clock resolves to, which keeps any sun the URL picks inside that band. + */ export function terrainSkyFor(state: SkyState): { sky?: SkyName; - sun?: { elevation: number }; + sun?: { elevation: number; azimuth?: number }; band: SkyBand; } { const fixed = resolveSkyState(state); const band = SKY_BAND[fixed]; + if (state === 'auto') { + const url = urlSky(); + if (url) return { ...url, band }; + } switch (fixed) { case 'day': return { sky: 'noonday', band }; @@ -113,12 +121,16 @@ const REFINE_FROM = 6; export function terrainDrawn(state: SkyState): Range { const sky = chooseSky(terrainSkyFor(state)); const rad = Math.PI / 180; + // Every point looked at is also read as the 8-bit screen would show it, dither at its worst, so the range bounds that too. + let screenDarkest = Infinity; + let screenLightest = -Infinity; const lum = (el: number, az: number) => { const e = Math.min(TERRAIN_REACH_DEG, Math.max(0, el)) * rad; const a = az * rad; - return displayedLuminance( - skyColour(sky, [Math.cos(e) * Math.sin(a), Math.sin(e), -Math.cos(e) * Math.cos(a)]) - ); + const c = skyColour(sky, [Math.cos(e) * Math.sin(a), Math.sin(e), -Math.cos(e) * Math.cos(a)]); + screenDarkest = Math.min(screenDarkest, displayedLuminance(c, 'low')); + screenLightest = Math.max(screenLightest, displayedLuminance(c, 'high')); + return displayedLuminance(c); }; const seen: { el: number; az: number; l: number }[] = []; const at = (el: number, az: number) => seen.push({ el, az, l: lum(el, az) }); @@ -164,5 +176,8 @@ export function terrainDrawn(state: SkyState): Range { byLight[byLight.length - 1].l, ...byLight.slice(-REFINE_FROM).map((p) => refine(p, -1)) ); - return { darkest, lightest }; + return { + darkest: Math.min(darkest, screenDarkest), + lightest: Math.max(lightest, screenLightest), + }; } diff --git a/src/app/features/scene/skyState.test.ts b/src/app/features/scene/skyState.test.ts index f8c22178..ae4d9e83 100644 --- a/src/app/features/scene/skyState.test.ts +++ b/src/app/features/scene/skyState.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { atStandardTime } from './standardTime.testkit'; import { baked } from '../../../sky/baked'; import { luminance } from '../../../sky/skyTokens.js'; import { @@ -123,7 +124,7 @@ describe('skyBackdrop', () => { }); it('is the band of the sky auto resolves to, not the union of all three', () => { - const at = (h: number) => setSkyClock(() => new Date(2026, 8, 22, h, 0)); + const at = (h: number) => setSkyClock(() => atStandardTime(h, 0)); at(12); expect(skyBackdrop('auto')).toEqual(skyBackdrop('day')); at(0); @@ -139,7 +140,7 @@ describe('skyBackdrop', () => { }); describe('auto follows the clock', () => { - const local = (h: number, m = 0) => new Date(2026, 8, 22, h, m); // local solar time ~ local clock + const local = (h: number, m = 0) => atStandardTime(h, m); // local solar time ~ local clock let storage: ReturnType; beforeEach(() => { vi.useFakeTimers(); @@ -174,6 +175,24 @@ describe('auto follows the clock', () => { off(); }); + it('still tells everyone when a listener comes or goes inside the window before the clock crosses', async () => { + const m = await fresh(); + let now = local(17, 0); + m.setSkyClock(() => now); + const [a, b, late] = [vi.fn(), vi.fn(), vi.fn()]; + m.subscribe(a); + const offB = m.subscribe(b); + vi.advanceTimersByTime(30_000); + now = local(18, 10); // the clock has crossed; no tick has seen it yet + const offLate = m.subscribe(late); + offB(); + vi.advanceTimersByTime(60_000); + expect(a.mock.calls).toEqual([['dusk']]); + expect(b).not.toHaveBeenCalled(); + expect(late.mock.calls).toEqual([['dusk']]); + offLate(); + }); + it('keeps no timer without listeners or when a fixed state is chosen', async () => { const m = await fresh(); const off = m.subscribe(() => undefined); diff --git a/src/app/features/scene/skyState.ts b/src/app/features/scene/skyState.ts index c27c10a4..a79ed9c6 100644 --- a/src/app/features/scene/skyState.ts +++ b/src/app/features/scene/skyState.ts @@ -30,10 +30,17 @@ const LAT = 45; export const DAY_FROM = 6; let clock: () => Date = () => new Date(); +/** Minutes west of UTC of this timezone's standard time (the larger of its January and July offsets), so daylight saving does not move the sun. */ +export const standardOffsetMinutes = (now: Date): number => + Math.max( + new Date(now.getFullYear(), 0, 1).getTimezoneOffset(), + new Date(now.getFullYear(), 6, 1).getTimezoneOffset() + ); + /** The fixed sky a choice draws now: itself, or for 'auto' the sky the sun's elevation here calls for. */ export function resolveSkyState(s: SkyState, now: Date = clock()): FixedSkyState { if (s !== 'auto') return s; - const lon = -now.getTimezoneOffset() / 4; + const lon = -standardOffsetMinutes(now) / 4; const el = sunElevation(now, LAT, lon) as number; if (el >= DAY_FROM) return 'day'; return el <= -DAY_FROM ? 'night' : 'dusk'; @@ -60,12 +67,12 @@ const emit = () => { }; // While anyone listens and the choice is 'auto', one timer checks the clock each minute and tells -// listeners when the resolved sky changes. Otherwise there is no timer. +// listeners when the resolved sky changes from the one last told (`shown`, set when telling and when the +// first listener arrives, never by a later arrival or departure). Otherwise there is no timer. function sync(): void { clearTimeout(timer); timer = undefined; if (listeners.size === 0 || getSkyState() !== 'auto') return; - shown = getResolvedSkyState(); const tick = () => { if (getResolvedSkyState() !== shown) emit(); timer = setTimeout(tick, TICK_MS); @@ -96,6 +103,7 @@ export function setSkyState(s: SkyState): void { /** Listeners hear the resolved sky: on a change of choice, and while 'auto', when the clock crosses a boundary. */ export function subscribe(cb: (s: FixedSkyState) => void): () => void { + if (listeners.size === 0) shown = getResolvedSkyState(); listeners.add(cb); sync(); return () => { @@ -119,11 +127,11 @@ export interface SkyBackdrop { // this module, which the main chunk loads, does not pull in the terrain sky and the baked scattering // tables with it; skyForState.test.ts fails when they drift from the computed union. The ridge sky counts // over its whole range, the terrain sky over what its frozen camera reaches (TERRAIN_REACH_DEG, skyForState.ts). -// Rounded outward to six places. +// Bounds what the 8-bit screen shows too (dither at its worst), rounded outward to six places. const BACKDROPS: Record = { - day: { darkest: 0.466725, lightest: 0.892734 }, - dusk: { darkest: 0.303835, lightest: 0.967298 }, - night: { darkest: 0.05987, lightest: 0.079657 }, + day: { darkest: 0.463644, lightest: 0.898318 }, + dusk: { darkest: 0.306293, lightest: 0.968113 }, + night: { darkest: 0.05987, lightest: 0.081133 }, }; export const skyBackdrop = (state: SkyState): SkyBackdrop => BACKDROPS[resolveSkyState(state)]; diff --git a/src/app/features/scene/skyTheme.test.ts b/src/app/features/scene/skyTheme.test.ts index 4f237fc6..704ff15d 100644 --- a/src/app/features/scene/skyTheme.test.ts +++ b/src/app/features/scene/skyTheme.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { atStandardTime } from './standardTime.testkit'; import { baked } from '../../../sky/baked'; import { AA, contrast, luminance } from '../../../sky/skyTokens.js'; import { Editor } from '../../components/editor/Editor.css'; @@ -8,7 +9,7 @@ import { MembersDrawer } from '../room/MembersDrawer.css'; import { RoomViewTyping } from '../room/RoomViewTyping.css'; import { SKY_PLATE_FALLBACK } from './skyPlate'; import { SkyPlate, plateRegions } from './skyPlate.css'; -import { setSkyClock, skyBackdrop } from './skyState'; +import { getResolvedSkyState, setSkyClock, skyBackdrop } from './skyState'; import type { SkyBackdrop } from './skyState'; import type { FixedSkyState } from './skyBand'; import { skyThemeId } from './skyTheme'; @@ -32,17 +33,25 @@ const ratio = (ink: string, b: SkyBackdrop) => const r2 = (n: number) => n.toFixed(2); describe('skyThemeId', () => { - it('picks a theme for every fixed state, and for auto the theme of the sky it resolves to', () => { + it('picks a theme for each explicit state', () => { expect(skyThemeId('day')).toBe('noonday-sky'); expect(skyThemeId('dusk')).toBe('noonday-sky'); expect(skyThemeId('night')).toBe('midnight-sky'); - const at = (h: number, m = 0) => setSkyClock(() => new Date(2026, 8, 22, h, m)); - at(12); - expect(skyThemeId('auto')).toBe('noonday-sky'); - at(0); - expect(skyThemeId('auto')).toBe('midnight-sky'); - at(18, 10); - expect(skyThemeId('auto')).toBe('noonday-sky'); + }); + + it('picks none for auto, at any hour: auto never overrides a signed-in theme', () => { + [0, 6, 12, 18, 23].forEach((h) => { + setSkyClock(() => atStandardTime(h, 10)); + expect(skyThemeId('auto')).toBeUndefined(); + }); + setSkyClock(); + }); + + it('is what a wiring that follows the sky reads: the resolved state names a theme', () => { + setSkyClock(() => atStandardTime(12)); + expect(skyThemeId(getResolvedSkyState())).toBe('noonday-sky'); + setSkyClock(() => atStandardTime(0)); + expect(skyThemeId(getResolvedSkyState())).toBe('midnight-sky'); setSkyClock(); }); }); @@ -54,9 +63,9 @@ describe('ink of the theme a state picks, on bare sky', () => { // numbers are recorded as they are measured (Night's #609385 would clear 4.5 at a luminance of about 0.53 or more). // 'auto' resolves from the clock to one of these three and has the same numbers. const cells: [FixedSkyState, string, string, string, string, string][] = [ - ['day', 'noonday-sky', '#342d25', '6.68', '#2a4f27', '4.60'], - ['dusk', 'noonday-sky', '#342d25', '4.57', '#2a4f27', '3.15'], - ['night', 'midnight-sky', '#aeccff', '4.97', '#609385', '2.32'], + ['day', 'noonday-sky', '#342d25', '6.64', '#2a4f27', '4.57'], + ['dusk', 'noonday-sky', '#342d25', '4.60', '#2a4f27', '3.17'], + ['night', 'midnight-sky', '#aeccff', '4.91', '#609385', '2.29'], ]; it.each(cells)( @@ -86,8 +95,8 @@ describe('ink of the theme a state picks, on bare sky', () => { [18, 'dusk'], [0, 'night'], ] as const)('auto at %s:00 has the numbers of %s', (hour, state) => { - setSkyClock(() => new Date(2026, 8, 22, hour, hour === 18 ? 10 : 0)); - const id = skyThemeId('auto'); + setSkyClock(() => atStandardTime(hour, hour === 18 ? 10 : 0)); + const id = skyThemeId(getResolvedSkyState()) as keyof typeof SKY; expect(id).toBe(skyThemeId(state)); const { tokens, link } = SKY[id]; const text = tokens.Background.OnContainer; diff --git a/src/app/features/scene/skyTheme.ts b/src/app/features/scene/skyTheme.ts index 2e8c1b2e..52aff750 100644 --- a/src/app/features/scene/skyTheme.ts +++ b/src/app/features/scene/skyTheme.ts @@ -1,5 +1,6 @@ -// Which theme a sky state lights the page with. 'auto' takes the theme of the sky it resolves to -// (skyState.resolveSkyState), since that is the sky behind the text. +// Which theme a sky state lights the page with. 'auto' picks none: it never overrides a person's theme +// (the stored one when signed in, Midnight Sky when signed out). A wiring that follows the sky when no theme +// is stored reads `getResolvedSkyState` (skyState.ts) and passes that fixed state here. // // Day and Dusk take the dark ink of Noonday Sky, Night the light ink of Midnight Sky. Each state's // drawn sky is shaped into a luminance band (skyBand.ts) in which that ink's TEXT clears 4.5:1 on @@ -9,7 +10,6 @@ // derived outside this code (src/sky/skyTokens.js); lightening the Night link to a luminance of // about 0.53 or more would clear it. The plate (skyPlate.css.ts) is still in the shell, pending // its removal. -import { resolveSkyState } from './skyState'; import type { FixedSkyState, SkyState } from './skyState'; export type SkyThemeId = 'midnight-sky' | 'noonday-sky'; @@ -20,4 +20,5 @@ const BY_STATE: Record = { night: 'midnight-sky', }; -export const skyThemeId = (state: SkyState): SkyThemeId => BY_STATE[resolveSkyState(state)]; +export const skyThemeId = (state: SkyState): SkyThemeId | undefined => + state === 'auto' ? undefined : BY_STATE[state]; diff --git a/src/app/features/scene/standardTime.testkit.ts b/src/app/features/scene/standardTime.testkit.ts new file mode 100644 index 00000000..a6966238 --- /dev/null +++ b/src/app/features/scene/standardTime.testkit.ts @@ -0,0 +1,7 @@ +import { standardOffsetMinutes } from './skyState'; + +/** 22 September 2026 at h:m of local solar time as the sky clock reads it (standard time, whatever daylight saving says). */ +export const atStandardTime = (h: number, m = 0): Date => { + const probe = new Date(2026, 8, 22); + return new Date(Date.UTC(2026, 8, 22, h, m) + standardOffsetMinutes(probe) * 60000); +}; diff --git a/src/app/features/scene/terrain/Terrain.test.ts b/src/app/features/scene/terrain/Terrain.test.ts index 01f2df11..ef1623c4 100644 --- a/src/app/features/scene/terrain/Terrain.test.ts +++ b/src/app/features/scene/terrain/Terrain.test.ts @@ -5,6 +5,8 @@ import { GRID, triangleCount } from './terrainWorld'; import { groundLight, SKY, skyAtSun } from './terrainSky'; import { AP_VOLUME, apVolumeWords, SKY_VIEW, skyViewWords } from './terrainScatter'; import { SKY_FRAG } from './terrainShaders'; +import { SKY_BAND } from '../skyBand'; +import { terrainSkyFor } from '../skyForState'; import { startBackgroundScene } from '../index'; /** A GL context that accepts every call, remembers them, reports success for compile, link and framebuffer. */ @@ -158,6 +160,32 @@ describe('createTerrainScene', () => { expect(has(uploaded({}), nightA)).toBe(true); }); + it.each(['day', 'dusk', 'night'] as const)( + 'uploads the %s band as uBand (floor, ceiling) and uLift, in the sky pass and the grid pass', + (state) => { + const { gl, calls } = fakeGl(); + const opts = terrainSkyFor(state); + createTerrainScene({ canvas: canvasWith(gl), tier: 'still', ...opts })!.dispose(); + const { floor, ceiling, lift } = SKY_BAND[state]; + const pairs = calls.uniform2fv.filter( + (a) => + Math.abs((a[1] as number[])[0] - floor) < 1e-6 && + Math.abs((a[1] as number[])[1] - ceiling) < 1e-6 + ); + expect(pairs).toHaveLength(2); + const lifts = calls.uniform3fv.filter((a) => + Array.from(a[1] as ArrayLike).every((x, i) => Math.abs(x - lift[i]) < 1e-6) + ); + expect(lifts).toHaveLength(2); + } + ); + + it('uploads no band (0, 1) and a zero lift without one', () => { + const { gl, calls } = fakeGl(); + createTerrainScene({ canvas: canvasWith(gl), tier: 'still' })!.dispose(); + expect(calls.uniform2fv.filter((a) => (a[1] as number[])[1] === 1)).toHaveLength(2); + }); + it('a sun elevation (option or ?sun=&az=) lights between the bakes, and wins over ?sky=', () => { const ambients = (opts: Record) => { const { gl, calls } = fakeGl(); diff --git a/src/app/features/scene/terrain/Terrain.ts b/src/app/features/scene/terrain/Terrain.ts index 60ab33eb..b697927b 100644 --- a/src/app/features/scene/terrain/Terrain.ts +++ b/src/app/features/scene/terrain/Terrain.ts @@ -28,7 +28,7 @@ export type TerrainOptions = Pick< SceneOptions, 'canvas' | 'tier' | 'onCompileFail' | 'onContextLost' > & { - /** Which baked sky lights and hazes the terrain. Default: `?sky=` in the page URL, else the night notion of `skyColours()`. */ + /** Which baked sky lights and hazes the terrain. Default: `?sky=` in the page URL, else midnight. */ sky?: SkyName; /** Put the sun at this elevation (degrees) and azimuth (clockwise from north; default: noonday's) instead of at the baked sky's: `?sun=` and `?az=` in the page URL. Lights between the two bakes, for dusk. */ sun?: { elevation: number; azimuth?: number }; diff --git a/src/app/features/scene/terrain/terrainSky.ts b/src/app/features/scene/terrain/terrainSky.ts index 3470249c..92a2ab22 100644 --- a/src/app/features/scene/terrain/terrainSky.ts +++ b/src/app/features/scene/terrain/terrainSky.ts @@ -221,10 +221,25 @@ export function shade(d: SkyData, albedo: Vec3, normal: Vec3, distance: number, }) as unknown as Vec3; } +/** What the page URL asks of the sky, if anything: `?sun=` (with `?az=`) wins over `?sky=midnight|noonday`; a bad value asks nothing. */ +export function urlSky(): + | { sky?: SkyName; sun?: { elevation: number; azimuth?: number } } + | undefined { + const q = new URLSearchParams(window.location.search); + const number = (key: string) => (q.get(key)?.trim() ? Number(q.get(key)) : NaN); + const elevation = number('sun'); + if (Number.isFinite(elevation)) { + const azimuth = number('az'); + return { sun: { elevation, azimuth: Number.isFinite(azimuth) ? azimuth : undefined } }; + } + const name = q.get('sky'); + return isSkyName(name) ? { sky: name } : undefined; +} + /** * The sky to use. An explicit option wins over the page URL, so a person who picked a sky sees it: a sun - * elevation (with its azimuth, default noonday's), else a baked sky. Only without either does the URL steer: - * `?sun=` (with `?az=`), then `?sky=midnight|noonday`, then midnight. (The door always names a sky and a band; these are for callers that do not.) + * elevation (with its azimuth, default noonday's), else a baked sky. Only without either does the URL steer + * (`urlSky`), else midnight. A band, when given, shapes whichever sky that is. */ export function chooseSky(opts: { sky?: SkyName; @@ -232,15 +247,7 @@ export function chooseSky(opts: { band?: SkyBand; }): SkyData { const banded = (d: SkyData): SkyData => (opts.band ? { ...d, band: opts.band } : d); - if (opts.sun) return banded(skyAtSun(opts.sun.elevation, opts.sun.azimuth)); - if (opts.sky) return banded(skyData(opts.sky)); - const q = new URLSearchParams(window.location.search); - const number = (key: string) => (q.get(key)?.trim() ? Number(q.get(key)) : NaN); - const elevation = number('sun'); - if (Number.isFinite(elevation)) { - const azimuth = number('az'); - return banded(skyAtSun(elevation, Number.isFinite(azimuth) ? azimuth : undefined)); - } - const name = q.get('sky'); - return banded(skyData(isSkyName(name) ? name : 'midnight')); + const want = opts.sun || opts.sky ? opts : urlSky() ?? { sky: 'midnight' as const }; + if (want.sun) return banded(skyAtSun(want.sun.elevation, want.sun.azimuth)); + return banded(skyData(want.sky ?? 'midnight')); } diff --git a/src/app/hooks/useSkyState.ts b/src/app/hooks/useSkyState.ts index 4a2448b2..d49bf267 100644 --- a/src/app/hooks/useSkyState.ts +++ b/src/app/hooks/useSkyState.ts @@ -1,15 +1,5 @@ import { useSyncExternalStore } from 'react'; -import { - getResolvedSkyState, - getSkyState, - subscribe, - type FixedSkyState, - type SkyState, -} from '../features/scene/skyState'; +import { getSkyState, subscribe, type SkyState } from '../features/scene/skyState'; -/** The choice, as the control shows it ('auto' stays 'auto'). */ +/** The choice, as the control shows it ('auto' stays 'auto'). The sky being drawn is `getResolvedSkyState`. */ export const useSkyState = (): SkyState => useSyncExternalStore(subscribe, getSkyState); - -/** The sky being drawn: the choice, resolved from the clock for 'auto'. */ -export const useResolvedSkyState = (): FixedSkyState => - useSyncExternalStore(subscribe, getResolvedSkyState); diff --git a/src/app/hooks/useTheme.ts b/src/app/hooks/useTheme.ts index 6f1b914f..00ac1ee9 100644 --- a/src/app/hooks/useTheme.ts +++ b/src/app/hooks/useTheme.ts @@ -13,7 +13,7 @@ import '../../sky.css'; import { settingsAtom } from '../state/settings'; import { useSetting } from '../state/hooks/settings'; import { skyThemeId } from '../features/scene/skyTheme'; -import { useResolvedSkyState } from './useSkyState'; +import { useSkyState } from './useSkyState'; export enum ThemeKind { Light = 'light', @@ -102,9 +102,9 @@ export const useSystemThemeKind = (): ThemeKind => { return themeKind; }; -/** The theme the sky being drawn asks for ('auto' asks for the one of the sky it resolves to). */ +/** The theme the sky state asks for, or undefined while it is 'auto': auto never overrides a person's theme. */ export const useSkyTheme = (): Theme | undefined => { - const id = skyThemeId(useResolvedSkyState()); + const id = skyThemeId(useSkyState()); return id === NoondaySkyTheme.id ? NoondaySkyTheme : id && MidnightSkyTheme; }; From c13e90539f2beeb3b8f9a04c4eb2136f58900c93 Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 9 Nov 2026 23:48:07 +0000 Subject: [PATCH 177/206] trainingNotice: correct the comment per review Both render sites named (registration, and rooms whose notice event has scope train). The rule is an acknowledgment that Minds read and learn from what is said, not consent to training; the wording is Meta's to settle. Drops the unsourced ruling date and the claim about existing members. Comment only. Co-Authored-By: Claude Sonnet 5.5 --- src/app/utils/trainingNotice.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/app/utils/trainingNotice.ts b/src/app/utils/trainingNotice.ts index 16215249..4b416359 100644 --- a/src/app/utils/trainingNotice.ts +++ b/src/app/utils/trainingNotice.ts @@ -3,9 +3,12 @@ import { StateEvent } from '../../types/matrix/room'; export const TRAINING_NOTICE_TEXT = "Everything said here may be used to train GameCult's AI stewards."; -// The notice, its wording and the registration acknowledgement are Meta's ruling of 2026-11-06: -// everything said on the server may be used to train GameCult's AI stewards; shown at registration -// only; no mention of Discord; existing members are not re-asked. The wording is hers to change. +// The agreed rule is an acknowledgment that Minds on the server read what is said and learn from it; +// it is not consent to training. TRAINING_NOTICE_TEXT still reads 'may be used to train', and that +// wording is Meta's to settle. It never mentions Discord (the test pins this). It renders in two +// places: the registration acknowledgement (PasswordRegisterForm.tsx, via TrainingNotice.tsx) and +// the line in any room whose org.gamecult.notice state event has scope 'train' (roomTrainingLine, +// shown by RoomTrainingLine.tsx from RoomView.tsx). // The preview build runs against a pretend server where nothing is trained, so the real sentence // would be false there. export const PREVIEW_NOTICE_TEXT = From 80d3ae1c9b25aedf2a4418ee1d71fbc8de839ecc Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 9 Nov 2026 23:48:16 +0000 Subject: [PATCH 178/206] trainingNotice: rewrap comment to the print width Co-Authored-By: Claude Sonnet 5.5 --- src/app/utils/trainingNotice.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/app/utils/trainingNotice.ts b/src/app/utils/trainingNotice.ts index 4b416359..ecc57809 100644 --- a/src/app/utils/trainingNotice.ts +++ b/src/app/utils/trainingNotice.ts @@ -3,12 +3,12 @@ import { StateEvent } from '../../types/matrix/room'; export const TRAINING_NOTICE_TEXT = "Everything said here may be used to train GameCult's AI stewards."; -// The agreed rule is an acknowledgment that Minds on the server read what is said and learn from it; -// it is not consent to training. TRAINING_NOTICE_TEXT still reads 'may be used to train', and that -// wording is Meta's to settle. It never mentions Discord (the test pins this). It renders in two -// places: the registration acknowledgement (PasswordRegisterForm.tsx, via TrainingNotice.tsx) and -// the line in any room whose org.gamecult.notice state event has scope 'train' (roomTrainingLine, -// shown by RoomTrainingLine.tsx from RoomView.tsx). +// The agreed rule is an acknowledgment that Minds on the server read what is said and learn from +// it; it is not consent to training. TRAINING_NOTICE_TEXT still reads 'may be used to train', and +// that wording is Meta's to settle. It never mentions Discord (the test pins this). It renders in +// two places: the registration acknowledgement (PasswordRegisterForm.tsx, via TrainingNotice.tsx) +// and the line in any room whose org.gamecult.notice state event has scope 'train' +// (roomTrainingLine, shown by RoomTrainingLine.tsx from RoomView.tsx). // The preview build runs against a pretend server where nothing is trained, so the real sentence // would be false there. export const PREVIEW_NOTICE_TEXT = From e1ebf7ec914d6f265b1461a11067f7d314eee7cf Mon Sep 17 00:00:00 2001 From: Void Date: Mon, 9 Nov 2026 23:49:09 +0000 Subject: [PATCH 179/206] trainingNotice: cite Meta's 2026-11-09 scope; neither exception is implemented Co-Authored-By: Claude Sonnet 5.5 --- src/app/utils/trainingNotice.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/app/utils/trainingNotice.ts b/src/app/utils/trainingNotice.ts index ecc57809..c3d6e42a 100644 --- a/src/app/utils/trainingNotice.ts +++ b/src/app/utils/trainingNotice.ts @@ -3,6 +3,9 @@ import { StateEvent } from '../../types/matrix/room'; export const TRAINING_NOTICE_TEXT = "Everything said here may be used to train GameCult's AI stewards."; +// Source: Meta, 2026-11-09: server-wide, human-to-human DMs excepted, estate-room exception open. +// Neither exception is implemented here: the registration notice is unconditional, and the room +// line follows only the room's notice event, with no check for direct messages or estate rooms. // The agreed rule is an acknowledgment that Minds on the server read what is said and learn from // it; it is not consent to training. TRAINING_NOTICE_TEXT still reads 'may be used to train', and // that wording is Meta's to settle. It never mentions Discord (the test pins this). It renders in From 7bc28f9256c38d30a7cd516ce7e377ef3cd073f6 Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 01:09:46 +0000 Subject: [PATCH 180/206] scene: a rebuild is for a different drawn sky; a URL-steered night sky stays within the Night backdrop Rebuild key: the sky options the scene is built from (terrainSkyFor / ridgeSkyFor), not the pair of choice and resolved state, so picking Day while the clock says day, or Auto to the state it resolves to, no longer tears down and rebuilds the scene. A URL steers only 'auto', so there the options differ and it still rebuilds. Night: the band ceiling 0.085 let a sky the page URL steers (?sun=, ?sky=) show up to 0.0890, lighter than the Night constant 0.081705 the text was checked against. The ceiling is now 0.077394, the luma at which one 8-bit step up reads the baked night's own lightest, and the constant (0.081642) covers the steered case; a test pins it. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/index.test.ts | 17 +++++++------ src/app/features/scene/index.ts | 28 ++++++++++++++++++---- src/app/features/scene/skyBand.ts | 6 +++-- src/app/features/scene/skyForState.test.ts | 22 ++++++++++++++++- src/app/features/scene/skyState.ts | 3 ++- 5 files changed, 58 insertions(+), 18 deletions(-) diff --git a/src/app/features/scene/index.test.ts b/src/app/features/scene/index.test.ts index 7971508b..5d0e6410 100644 --- a/src/app/features/scene/index.test.ts +++ b/src/app/features/scene/index.test.ts @@ -45,14 +45,14 @@ describe('startBackgroundScene follows the sky state', () => { scene?.dispose(); }); - it('rebuilds when auto is replaced by the explicit state it already resolves to (a URL may steer only auto)', async () => { - const [a, b] = [fakeScene(), fakeScene()]; - createRidgeScene.mockReturnValueOnce(a).mockReturnValueOnce(b); + it('does not rebuild when auto is replaced by the explicit state it already resolves to', async () => { + const a = fakeScene(); + createRidgeScene.mockReturnValueOnce(a); const scene = await startBackgroundScene(config, caps, { canvas }); setSkyState('night'); await flush(); - expect(a.dispose).toHaveBeenCalledOnce(); - expect(createRidgeScene).toHaveBeenCalledTimes(2); + expect(a.dispose).not.toHaveBeenCalled(); + expect(createRidgeScene).toHaveBeenCalledOnce(); scene?.dispose(); }); @@ -146,14 +146,13 @@ describe('startBackgroundScene follows the sky state', () => { expect(first.dispose).toHaveBeenCalledOnce(); }); - it('drops a scene whose rebuild finishes after dispose', async () => { - const second = fakeScene(); - createRidgeScene.mockReturnValueOnce(fakeScene()).mockReturnValueOnce(second); + it('builds nothing for a rebuild that is disposed before it starts', async () => { + createRidgeScene.mockReturnValueOnce(fakeScene()).mockReturnValueOnce(fakeScene()); const scene = await startBackgroundScene(config, caps, { canvas }); setSkyState('day'); scene?.dispose(); await flush(); - expect(second.dispose).toHaveBeenCalledOnce(); + expect(createRidgeScene).toHaveBeenCalledOnce(); }); it('keeps only the latest of two quick changes', async () => { diff --git a/src/app/features/scene/index.ts b/src/app/features/scene/index.ts index 73ab8d5a..55966188 100644 --- a/src/app/features/scene/index.ts +++ b/src/app/features/scene/index.ts @@ -1,6 +1,6 @@ import type { Scene, SceneOptions } from './Scene'; import { BackgroundSceneConfig, Capabilities, SceneTier, resolveTier } from './tier'; -import { getResolvedSkyState, getSkyState, subscribe } from './skyState'; +import { getSkyState, subscribe } from './skyState'; import type { SkyState } from './skyState'; export type { Scene, SceneStats } from './Scene'; @@ -44,6 +44,24 @@ async function create( return createScene(options); } +/** + * What a rebuild is for: the sky options the scene would be built from. A change of choice that draws the same sky + * (Day picked while the clock says day, 'auto' to the fixed state it resolves to) is not a rebuild; the page URL steers + * only 'auto' (`terrainSkyFor`), so there the two differ and the key says so. + */ +async function skyKey( + config: BackgroundSceneConfig | undefined, + state: SkyState +): Promise { + try { + const { ridgeSkyFor, terrainSkyFor } = await import('./skyForState'); + return JSON.stringify(config?.style === 'terrain' ? terrainSkyFor(state) : ridgeSkyFor(state)); + } catch { + // The chunk failed to load: no key, so every change rebuilds (and `create` reports the failure). + return null; + } +} + /** * The client's one door to the scene. When the tier resolves to 'off' nothing is imported and no * canvas context is requested; otherwise the scene code loads on demand, so it stays out of the @@ -66,7 +84,7 @@ export async function startBackgroundScene( if (tier === 'off') return null; const build = (state: SkyState) => create(config, { ...options, tier }, state); const state = getSkyState(); - const key0 = `${state}:${getResolvedSkyState()}`; + const key0 = config?.style === 'raymarch' ? null : await skyKey(config, state); const first = await build(state); if (!first || config?.style === 'raymarch') return first; @@ -75,11 +93,11 @@ export async function startBackgroundScene( let loggedIn = false; let disposed = false; let generation = 0; - // A rebuild is for a new choice or a new resolved sky ('auto' crossing a boundary), not for a repeat of either. + // A rebuild is for a different sky ('auto' crossing a boundary, a new choice), not for a repeat of the one drawn. const rebuild = async () => { const next = getSkyState(); - const key = `${next}:${getResolvedSkyState()}`; - if (key === built) return; + const key = await skyKey(config, next); + if ((key !== null && key === built) || disposed) return; generation += 1; const mine = generation; built = key; diff --git a/src/app/features/scene/skyBand.ts b/src/app/features/scene/skyBand.ts index b6b39315..ac311081 100644 --- a/src/app/features/scene/skyBand.ts +++ b/src/app/features/scene/skyBand.ts @@ -58,12 +58,14 @@ export interface SkyBand { /** * The shaping targets, in linear luma. The floor sits 0.019 above the text bound (0.2983): the screen encoding is * gamma 2.2, which reads about 0.006 darker through the sRGB curve at this level, and 8-bit rounding with the sky - * pass's dither at its worst (one step in every channel) takes about 0.007 more. + * pass's dither at its worst (one step in every channel) takes about 0.007 more. The night ceiling is lower than the text bound + * (0.0931) by more than that: 0.077394 is the luma at which one step up reads 0.08164, the lightest the baked night sky shows + * (skyState.ts), so a sky the page URL steers at night, scaled down to the ceiling, cannot show lighter than the baked one. */ export const SKY_BAND: Record = { day: { floor: 0.317, ceiling: 1, lift: hexToLinear('#e2f4ff') }, dusk: { floor: 0.317, ceiling: 1, lift: hexToLinear('#eba0b9') }, - night: { floor: 0, ceiling: 0.085, lift: hexToLinear('#5a6a9a') }, + night: { floor: 0, ceiling: 0.077394, lift: hexToLinear('#5a6a9a') }, }; /** diff --git a/src/app/features/scene/skyForState.test.ts b/src/app/features/scene/skyForState.test.ts index 2ad3b5f6..9d1dfd2d 100644 --- a/src/app/features/scene/skyForState.test.ts +++ b/src/app/features/scene/skyForState.test.ts @@ -183,6 +183,14 @@ describe('terrainSkyFor', () => { expect(night.high).toBeLessThanOrEqual(needs('night').ceiling); }); + it('keeps a URL-steered sky at night no lighter than the Night backdrop the text was checked against', () => { + setSkyClock(() => atStandardTime(0, 0)); + ['?sun=60', '?sun=10&az=40', '?sun=2&az=250', '?sky=noonday'].forEach((q) => { + search = q; + expect(terrainDrawn('auto').lightest).toBeLessThanOrEqual(skyBackdrop('night').lightest); + }); + }); + it('lets an explicit state beat the URL, as before auto had one', () => { search = '?sun=30&az=120&sky=midnight'; expect(terrainSkyFor('day')).toEqual({ sky: 'noonday', band: SKY_BAND.day }); @@ -280,12 +288,24 @@ describe('skyBackdrop holds the union of what the scenes draw', () => { return { darkest: hunt(-1), lightest: hunt(1) }; }; + // At night a sky the page URL steers (?sun= ?sky=) is shaped to the band's ceiling, so the lightest it can show is one + // 8-bit step above the ceiling's own grey: the Night constant must cover it too, or text is checked against a sky + // lighter than the one it sits on. + const steeredNight = displayedLuminance( + [SKY_BAND.night.ceiling, SKY_BAND.night.ceiling, SKY_BAND.night.ceiling], + 'high' + ); + const computed = (state: FixedSkyState) => { const r = [ridgeDrawn(state), terrainDrawn(state)]; const ind = extremes(state); return { darkest: Math.min(...r.map((x) => x.darkest), ind.darkest), - lightest: Math.max(...r.map((x) => x.lightest), ind.lightest), + lightest: Math.max( + ...r.map((x) => x.lightest), + ind.lightest, + ...(state === 'night' ? [steeredNight] : []) + ), }; }; diff --git a/src/app/features/scene/skyState.ts b/src/app/features/scene/skyState.ts index 666e83d6..21117039 100644 --- a/src/app/features/scene/skyState.ts +++ b/src/app/features/scene/skyState.ts @@ -127,11 +127,12 @@ export interface SkyBackdrop { // this module, which the main chunk loads, does not pull in the terrain sky and the baked scattering // tables with it; skyForState.test.ts fails when they drift from the computed union. The ridge sky counts // over its whole range, the terrain sky over what its frozen camera reaches (TERRAIN_REACH_DEG, skyForState.ts). +// Night's lightest also covers a sky the page URL steers (shaped to the band ceiling, skyBand.ts). // Bounds what the 8-bit screen shows too (one step each way for rounding and dither), rounded outward to six places. const BACKDROPS: Record = { day: { darkest: 0.461063, lightest: 0.901108 }, dusk: { darkest: 0.30619, lightest: 0.968744 }, - night: { darkest: 0.05987, lightest: 0.081705 }, + night: { darkest: 0.05987, lightest: 0.081642 }, }; export const skyBackdrop = (state: SkyState): SkyBackdrop => BACKDROPS[resolveSkyState(state)]; From 2c271c6ded945ba0395256ef3dc643185c76c65c Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 01:13:29 +0000 Subject: [PATCH 181/206] sky: Midnight Sky's link is fitted to clear AA on the lightest Night sky The dark theme's link was #609385 (luminance 0.2505), 2.28:1 on the lightest Night sky (0.081642). It is now brightened in its own hue until its luminance is at least AA * (0.081642 + 0.05) - 0.05 = 0.5424: #8ad1bd (0.5468, hue 163 degrees as before), 4.53:1 on the lightest Night sky, 5.43:1 on the darkest, 11.28:1 on the sign-in card (was 5.68). Only the link changes in baked.ts; the user colours are unchanged. NIGHT_SKY_LIGHTEST is the Night backdrop constant, and a test fails if the two drift. Dusk is untouched. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/skyTheme.test.ts | 19 ++++++++++++------- src/app/features/scene/skyTheme.ts | 8 ++++---- src/app/pages/auth/styles.css.ts | 2 +- src/sky/baked.ts | 2 +- src/sky/skyTokens.js | 15 +++++++++++---- 5 files changed, 29 insertions(+), 17 deletions(-) diff --git a/src/app/features/scene/skyTheme.test.ts b/src/app/features/scene/skyTheme.test.ts index f6873466..39b74fdc 100644 --- a/src/app/features/scene/skyTheme.test.ts +++ b/src/app/features/scene/skyTheme.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { atStandardTime } from './standardTime.testkit'; import { baked } from '../../../sky/baked'; -import { AA, contrast, luminance } from '../../../sky/skyTokens.js'; +import { AA, NIGHT_SKY_LIGHTEST, contrast, luminance } from '../../../sky/skyTokens.js'; import { Editor } from '../../components/editor/Editor.css'; import { PageContent, PageNavContent } from '../../components/page/style.css'; import { CallStatus } from '../call-status/styles.css'; @@ -59,13 +59,14 @@ describe('skyThemeId', () => { describe('ink of the theme a state picks, on bare sky', () => { // Per state: the picked theme, its text ink and link, and their WCAG ratios on the sky the scenes // draw (skyBackdrop, the worst case over its band). Text clears 4.5 in every state. Link clears - // on day and not on dusk or night: the links are derived in src/sky/skyTokens.js, outside the band, and these - // numbers are recorded as they are measured (Night's #609385 would clear 4.5 at a luminance of about 0.53 or more). + // on day and night and not on dusk: the links are derived in src/sky/skyTokens.js, outside the band, and these + // numbers are recorded as they are measured. Midnight's link is brightened in its own hue until it clears the + // lightest Night sky (NIGHT_SKY_LIGHTEST); Dusk's dark link would need a backdrop of about 0.455, and the dusk floor is lower. // 'auto' resolves from the clock to one of these three and has the same numbers. const cells: [FixedSkyState, string, string, string, string, string][] = [ ['day', 'noonday-sky', '#342d25', '6.60', '#2a4f27', '4.55'], ['dusk', 'noonday-sky', '#342d25', '4.60', '#2a4f27', '3.17'], - ['night', 'midnight-sky', '#aeccff', '4.89', '#609385', '2.28'], + ['night', 'midnight-sky', '#aeccff', '4.89', '#8ad1bd', '4.53'], ]; it.each(cells)( @@ -83,11 +84,15 @@ describe('ink of the theme a state picks, on bare sky', () => { expect(ratio(ink, skyBackdrop(state))).toBeGreaterThanOrEqual(AA); }); - it('link clears AA on day (the terrain camera sees only the lighter sky) and falls short on dusk and night', () => { + it('link clears AA on day and night and falls short on dusk', () => { const link = (s: FixedSkyState, id: keyof typeof SKY) => ratio(SKY[id].link, skyBackdrop(s)); expect(link('day', 'noonday-sky')).toBeGreaterThanOrEqual(AA); expect(link('dusk', 'noonday-sky')).toBeLessThan(AA); - expect(link('night', 'midnight-sky')).toBeLessThan(AA); + expect(link('night', 'midnight-sky')).toBeGreaterThanOrEqual(AA); + }); + + it('keeps the lightest Night sky the link was fitted to equal to the Night backdrop (no drift)', () => { + expect(Math.abs(NIGHT_SKY_LIGHTEST - skyBackdrop('night').lightest)).toBeLessThan(1e-6); }); it.each([ @@ -109,7 +114,7 @@ describe('ink of the theme a state picks, on bare sky', () => { describe('the plate, where text cannot rest on bare sky', () => { // Each theme's plate: fill, ink, link and the WCAG ratios of ink and link on the fill. const plates = [ - ['midnight-sky', baked.midnight, '12.17', '5.68'], + ['midnight-sky', baked.midnight, '12.17', '11.28'], ['noonday-sky', baked.noonday, '6.70', '4.62'], ] as const; diff --git a/src/app/features/scene/skyTheme.ts b/src/app/features/scene/skyTheme.ts index 52aff750..4ab3f43a 100644 --- a/src/app/features/scene/skyTheme.ts +++ b/src/app/features/scene/skyTheme.ts @@ -5,10 +5,10 @@ // Day and Dusk take the dark ink of Noonday Sky, Night the light ink of Midnight Sky. Each state's // drawn sky is shaped into a luminance band (skyBand.ts) in which that ink's TEXT clears 4.5:1 on // bare sky: Day and Dusk at least 0.2983 (dark ink), Night at most 0.0931 (light ink). skyTheme.test.ts -// pins the measured text and link ratios. Link ink is not in the band: the Night link (#609385, -// 2.36:1) and the dark links on Day and Dusk fall short, because the themes' link colours are -// derived outside this code (src/sky/skyTokens.js); lightening the Night link to a luminance of -// about 0.53 or more would clear it. The plate (skyPlate.css.ts) is still in the shell, pending +// pins the measured text and link ratios. Link ink is not in the band: the Day and Dusk links are dark +// and fall short of 4.5 where the sky is below about 0.455 (Dusk: 3.17), because the themes' link colours are +// derived outside this code (src/sky/skyTokens.js). Midnight Sky's link is derived there against the lightest Night sky +// (4.53 on it, 5.43 on the darkest). The plate (skyPlate.css.ts) is still in the shell, pending // its removal. import type { FixedSkyState, SkyState } from './skyState'; diff --git a/src/app/pages/auth/styles.css.ts b/src/app/pages/auth/styles.css.ts index 4fa42d74..b13bf868 100644 --- a/src/app/pages/auth/styles.css.ts +++ b/src/app/pages/auth/styles.css.ts @@ -60,7 +60,7 @@ export const AuthFooter = style({ // Noonday); the other themes (Light, Silver, Dark, Butter) take the fallback colours. // WCAG ratios of text / Register link on the card, per theme: // Light, Silver, Dark, Butter #b7c7d9 / #99c7ff on #0d1a26 10.21 / 10.03 -// Midnight Sky #aeccff / #609385 on #0a090e 12.17 / 5.68 +// Midnight Sky #aeccff / #8ad1bd on #0a090e 12.17 / 11.28 // Noonday Sky #342d25 / #2a4f27 on #b9b6af 6.70 / 4.62 // The fallback link is set here because those themes' own links (Light 2.49:1, Silver 2.49:1) are // made for their own grounds, not this card; Dark and Butter already use the same hsl(213 100% 80%). diff --git a/src/sky/baked.ts b/src/sky/baked.ts index 0393fdbf..73c93bd5 100644 --- a/src/sky/baked.ts +++ b/src/sky/baked.ts @@ -115,7 +115,7 @@ export const baked = { '#677dff', '#6a926a', ], - link: '#609385', + link: '#8ad1bd', }, noonday: { moment: { diff --git a/src/sky/skyTokens.js b/src/sky/skyTokens.js index c234de12..5152a4fb 100644 --- a/src/sky/skyTokens.js +++ b/src/sky/skyTokens.js @@ -19,6 +19,10 @@ const luma = (c) => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; export const GRADIENT_STOPS = 7; export const AA = 4.5; // WCAG 2.x AA, normal text +// The lightest the Night sky draws behind the page (src/app/features/scene/skyState.ts, `skyBackdrop('night')`; a test in +// skyTheme.test.ts fails if the two drift). Midnight Sky's link also sits on bare sky, so it must clear AA against this. +export const NIGHT_SKY_LIGHTEST = 0.081642; + // Where and when the theme is lit. Equinox, 45N on the prime meridian: noon sun at 45 degrees. export const MOMENTS = { noonday: { time: '2026-09-22T12:00:00Z', lat: 45, lon: 0 }, @@ -186,10 +190,10 @@ export function bakeSky(name) { bubbles.Own, ]; const readable = (h) => grounds.every((g) => contrast(h, g) >= AA); - const fit = (albedo) => { + const fit = (albedo, ok = () => true) => { let key = 0.5; let h = hex(albedo, key); - for (let n = 0; n < 80 && !readable(h); n += 1) { + for (let n = 0; n < 80 && !(readable(h) && ok(h)); n += 1) { key *= dark ? 1.05 : 0.95; h = hex(albedo, key); } @@ -207,7 +211,10 @@ export function bakeSky(name) { gradient, tokens, bubbles, - userColors: PIGMENTS.map(fit), - link: fit(dark ? ALBEDO.leafPale : ALBEDO.leaf), + userColors: PIGMENTS.map((p) => fit(p)), + // The dark link also has to clear AA on the bare Night sky: brightened, in its own hue, until it does. + link: dark + ? fit(ALBEDO.leafPale, (h) => luminance(h) >= AA * (NIGHT_SKY_LIGHTEST + 0.05) - 0.05) + : fit(ALBEDO.leaf), }; } From 61f434a32db89bdc1b4cac411730364a5eaa73be Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 01:34:07 +0000 Subject: [PATCH 182/206] scene: two quick sky changes build the last sky; the Night drift test is exact A rebuild read the sky choice before awaiting its key and acted on it after, so Day then Dusk in quick succession left the Day scene live (the old test only counted disposals and passed with the Dusk scene never built). A rebuild now stands down if the choice moved while it awaited; that change has its own rebuild. The test asserts the last sky built is the last one chosen, is the live scene (a login reaches it), and that every other scene was disposed. The NIGHT_SKY_LIGHTEST drift test compared with a 1e-6 tolerance, so 0.081641 passed; it is now exact equality. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/index.test.ts | 21 +++++++++++++++++---- src/app/features/scene/index.ts | 2 ++ src/app/features/scene/skyTheme.test.ts | 2 +- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/app/features/scene/index.test.ts b/src/app/features/scene/index.test.ts index 5d0e6410..6a29b33f 100644 --- a/src/app/features/scene/index.test.ts +++ b/src/app/features/scene/index.test.ts @@ -4,6 +4,7 @@ import { baked } from '../../../sky/baked'; import type { Scene } from './Scene'; import { startBackgroundScene } from './index'; import { setSkyClock, setSkyState } from './skyState'; +import { ridgeSkyFor } from './skyForState'; const { createRidgeScene, createScene } = vi.hoisted(() => ({ createRidgeScene: vi.fn(), @@ -155,16 +156,28 @@ describe('startBackgroundScene follows the sky state', () => { expect(createRidgeScene).toHaveBeenCalledOnce(); }); - it('keeps only the latest of two quick changes', async () => { + it('keeps only the latest of two quick changes: the last sky is built, the others are not left running', async () => { const scenes = [fakeScene(), fakeScene(), fakeScene()]; scenes.forEach((s) => createRidgeScene.mockReturnValueOnce(s)); const scene = await startBackgroundScene(config, caps, { canvas }); setSkyState('day'); - setSkyState('night'); + setSkyState('dusk'); await flush(); - expect(scenes[1].dispose).toHaveBeenCalledOnce(); - expect(scenes[2].dispose).not.toHaveBeenCalled(); + // The sky last built is dusk's, and it is the live scene: a login reaches it and nothing else. + const built = createRidgeScene.mock.calls.length; + expect(hex(createRidgeScene.mock.calls[built - 1][0].sky.top)).toBe( + hex(ridgeSkyFor('dusk').top) + ); + const live = scenes[built - 1]; + scene?.login(); + expect(live.login).toHaveBeenCalledOnce(); + scenes.filter((s) => s !== live).forEach((s) => expect(s.login).not.toHaveBeenCalled()); + // Every scene but the live one was disposed, the live one not yet. + scenes.slice(0, built).forEach((s) => { + expect(s.dispose).toHaveBeenCalledTimes(s === live ? 0 : 1); + }); scene?.dispose(); + expect(live.dispose).toHaveBeenCalledOnce(); }); it('leaves the ray-march scene alone: it has one fixed dusk', async () => { diff --git a/src/app/features/scene/index.ts b/src/app/features/scene/index.ts index 55966188..73377440 100644 --- a/src/app/features/scene/index.ts +++ b/src/app/features/scene/index.ts @@ -95,8 +95,10 @@ export async function startBackgroundScene( let generation = 0; // A rebuild is for a different sky ('auto' crossing a boundary, a new choice), not for a repeat of the one drawn. const rebuild = async () => { + // The choice can move while the key is awaited; that change has its own rebuild, so this one stands down. const next = getSkyState(); const key = await skyKey(config, next); + if (getSkyState() !== next) return; if ((key !== null && key === built) || disposed) return; generation += 1; const mine = generation; diff --git a/src/app/features/scene/skyTheme.test.ts b/src/app/features/scene/skyTheme.test.ts index 39b74fdc..53cf2873 100644 --- a/src/app/features/scene/skyTheme.test.ts +++ b/src/app/features/scene/skyTheme.test.ts @@ -92,7 +92,7 @@ describe('ink of the theme a state picks, on bare sky', () => { }); it('keeps the lightest Night sky the link was fitted to equal to the Night backdrop (no drift)', () => { - expect(Math.abs(NIGHT_SKY_LIGHTEST - skyBackdrop('night').lightest)).toBeLessThan(1e-6); + expect(NIGHT_SKY_LIGHTEST).toBe(skyBackdrop('night').lightest); }); it.each([ From 098e45f122d57b7f5041994b650e5a313d6baa35 Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 01:56:34 +0000 Subject: [PATCH 183/206] scene: test a sky change landing 10-12 microtasks into a build The stale build must be disposed and must not become live: removing the stale-build scene?.dispose() or the generation check now fails the test. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/index.test.ts | 35 ++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/app/features/scene/index.test.ts b/src/app/features/scene/index.test.ts index 6a29b33f..75e7b289 100644 --- a/src/app/features/scene/index.test.ts +++ b/src/app/features/scene/index.test.ts @@ -20,6 +20,12 @@ const flush = () => new Promise((r) => { setTimeout(r, 0); }); +// Resolves after `n` further turns of the microtask queue. +const microtasks = (n: number) => + Array.from({ length: n }).reduce>( + (p) => p.then(() => undefined), + Promise.resolve() + ); const fakeScene = () => ({ login: vi.fn(), dispose: vi.fn() }); const hex = (c: readonly number[]) => `#${c.map((v) => v.toString(16).padStart(2, '0')).join('')}`; @@ -180,6 +186,35 @@ describe('startBackgroundScene follows the sky state', () => { expect(live.dispose).toHaveBeenCalledOnce(); }); + it.each([10, 11, 12])( + 'a sky change %i microtasks into a build leaves only the newer build live; the stale one is disposed', + async (ticks) => { + const [a, b, c] = [fakeScene(), fakeScene(), fakeScene()]; + createRidgeScene.mockReturnValueOnce(a).mockReturnValueOnce(b).mockReturnValueOnce(c); + const onActive = vi.fn(); + const scene = await startBackgroundScene(config, caps, { canvas, onActive }); + setSkyState('day'); + await microtasks(ticks); + setSkyState('dusk'); + await flush(); + const built = createRidgeScene.mock.calls.length; + expect(hex(createRidgeScene.mock.calls[built - 1][0].sky.top)).toBe( + hex(ridgeSkyFor('dusk').top) + ); + // The newest build is live, every earlier one disposed exactly once, and only the live one is shown. + const live = [a, b, c][built - 1]; + scene?.login(); + expect(live.login).toHaveBeenCalledOnce(); + [a, b, c].slice(0, built).forEach((s) => { + expect(s.dispose).toHaveBeenCalledTimes(s === live ? 0 : 1); + if (s !== live) expect(s.login).not.toHaveBeenCalled(); + }); + expect(onActive).toHaveBeenCalledOnce(); + scene?.dispose(); + expect(live.dispose).toHaveBeenCalledOnce(); + } + ); + it('leaves the ray-march scene alone: it has one fixed dusk', async () => { const inner = fakeScene() as unknown as Scene; createScene.mockReturnValue(inner); From 6a93664813b61cc1f20efd1688c7a84cc7a1f02c Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 02:07:11 +0000 Subject: [PATCH 184/206] scene: sweep the mid-build test over microtask offsets 0..20 The change is no longer pinned to 10-12 microtasks; the test requires at least one offset to land on a build that is found stale and dropped, so a shift in the awaits inside create/skyKey cannot make it vacuous. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/index.test.ts | 69 +++++++++++++++++----------- 1 file changed, 41 insertions(+), 28 deletions(-) diff --git a/src/app/features/scene/index.test.ts b/src/app/features/scene/index.test.ts index 75e7b289..f54e456f 100644 --- a/src/app/features/scene/index.test.ts +++ b/src/app/features/scene/index.test.ts @@ -186,34 +186,47 @@ describe('startBackgroundScene follows the sky state', () => { expect(live.dispose).toHaveBeenCalledOnce(); }); - it.each([10, 11, 12])( - 'a sky change %i microtasks into a build leaves only the newer build live; the stale one is disposed', - async (ticks) => { - const [a, b, c] = [fakeScene(), fakeScene(), fakeScene()]; - createRidgeScene.mockReturnValueOnce(a).mockReturnValueOnce(b).mockReturnValueOnce(c); - const onActive = vi.fn(); - const scene = await startBackgroundScene(config, caps, { canvas, onActive }); - setSkyState('day'); - await microtasks(ticks); - setSkyState('dusk'); - await flush(); - const built = createRidgeScene.mock.calls.length; - expect(hex(createRidgeScene.mock.calls[built - 1][0].sky.top)).toBe( - hex(ridgeSkyFor('dusk').top) - ); - // The newest build is live, every earlier one disposed exactly once, and only the live one is shown. - const live = [a, b, c][built - 1]; - scene?.login(); - expect(live.login).toHaveBeenCalledOnce(); - [a, b, c].slice(0, built).forEach((s) => { - expect(s.dispose).toHaveBeenCalledTimes(s === live ? 0 : 1); - if (s !== live) expect(s.login).not.toHaveBeenCalled(); - }); - expect(onActive).toHaveBeenCalledOnce(); - scene?.dispose(); - expect(live.dispose).toHaveBeenCalledOnce(); - } - ); + // A second sky change `ticks` microtasks after the first. True when it landed mid-build: the build for the first + // change finished, was found stale and dropped (never shown), and only the newest sky was announced. + const changeAfter = async (ticks: number) => { + createRidgeScene.mockReset(); + setSkyState('auto'); + const [a, b, c] = [fakeScene(), fakeScene(), fakeScene()]; + createRidgeScene.mockReturnValueOnce(a).mockReturnValueOnce(b).mockReturnValueOnce(c); + const onActive = vi.fn(); + const scene = await startBackgroundScene(config, caps, { canvas, onActive }); + setSkyState('day'); + await microtasks(ticks); + setSkyState('dusk'); + await flush(); + const at = `at ${ticks} microtasks`; + const built = createRidgeScene.mock.calls.length; + expect(hex(createRidgeScene.mock.calls[built - 1][0].sky.top), at).toBe( + hex(ridgeSkyFor('dusk').top) + ); + // The newest build is live, every earlier one disposed exactly once, and only the live one is shown. + const live = [a, b, c][built - 1]; + scene?.login(); + expect(live.login, at).toHaveBeenCalledOnce(); + [a, b, c].slice(0, built).forEach((x) => { + expect(x.dispose, at).toHaveBeenCalledTimes(x === live ? 0 : 1); + if (x !== live) expect(x.login, at).not.toHaveBeenCalled(); + }); + scene?.dispose(); + expect(live.dispose, at).toHaveBeenCalledOnce(); + return built === 3 && onActive.mock.calls.length === 1; + }; + + it('a sky change at any point of a build leaves only the newer build live; the stale one is disposed', async () => { + // The offsets are swept, not pinned, so a shift in the awaits inside the build cannot move the change off the + // build: at least one offset must land mid-build, or the sweep proves nothing. + const offsets = Array.from({ length: 21 }, (_, i) => i); + const landed = await offsets.reduce>( + (p, k) => p.then(async (acc) => ((await changeAfter(k)) ? [...acc, k] : acc)), + Promise.resolve([]) + ); + expect(landed.length).toBeGreaterThan(0); + }); it('leaves the ray-march scene alone: it has one fixed dusk', async () => { const inner = fakeScene() as unknown as Scene; From de504f82863fbc9172d10b499b883185bbebd263 Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 02:24:13 +0000 Subject: [PATCH 185/206] scene: say what the sweep's mid-build assertion means when it fails Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/index.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/features/scene/index.test.ts b/src/app/features/scene/index.test.ts index f54e456f..23c6b297 100644 --- a/src/app/features/scene/index.test.ts +++ b/src/app/features/scene/index.test.ts @@ -225,7 +225,9 @@ describe('startBackgroundScene follows the sky state', () => { (p, k) => p.then(async (acc) => ((await changeAfter(k)) ? [...acc, k] : acc)), Promise.resolve([]) ); - expect(landed.length).toBeGreaterThan(0); + // This can fail after a correct refactor that changes the await count in create/skyKey and moves every offset + // off the build. Then widen the range of offsets until some land again; do not weaken the assertion. + expect(landed.length, 'at least one offset must land mid-build').toBeGreaterThan(0); }); it('leaves the ray-march scene alone: it has one fixed dusk', async () => { From 91e93e6887ecd777f0ac5fe3cd0dff4540be286b Mon Sep 17 00:00:00 2001 From: Nibu Date: Tue, 10 Nov 2026 01:18:45 +0000 Subject: [PATCH 186/206] scene: Dusk floor at the link bound with Day's pale-blue lift, so the dark link clears AA Dusk's floor moves from 0.317 to 0.4744 (the dark link's bound 0.4554 plus the 0.019 margin Day keeps over its text bound), and its lift from rose #eba0b9 (luma 0.463, below the new floor) to Day's #e2f4ff. The ridge dusk stops are shaped into the same band, since the unshaped twilight dipped to 0.336. skyBackdrop('dusk').darkest becomes 0.460352; Dusk text 4.60 -> 6.59, link 3.17 -> 4.54. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/ridge/skyColour.ts | 5 ++-- src/app/features/scene/skyBand.test.ts | 2 +- src/app/features/scene/skyBand.ts | 13 ++++++--- src/app/features/scene/skyForState.test.ts | 34 +++++++++++++++++----- src/app/features/scene/skyForState.ts | 27 +++++++++++++---- src/app/features/scene/skyState.ts | 2 +- src/app/features/scene/skyTheme.test.ts | 12 ++++---- src/app/features/scene/skyTheme.ts | 8 ++--- 8 files changed, 71 insertions(+), 32 deletions(-) diff --git a/src/app/features/scene/ridge/skyColour.ts b/src/app/features/scene/ridge/skyColour.ts index 3a5147a4..7917d89e 100644 --- a/src/app/features/scene/ridge/skyColour.ts +++ b/src/app/features/scene/ridge/skyColour.ts @@ -1,8 +1,7 @@ // The one place the sky's colours come from. Void's `src/sky/sun.js` (`light({ time, lat, lon, // cloud_cover, precipitation })`, PR #20) is the intended source; until it lands this returns a -// fixed twilight: lavender zenith, rose mid-sky, orange horizon. Its darkest blend stays above the dusk -// floor of ../skyBand.ts (the dark ink of Noonday Sky needs a backdrop of at least 0.2983 luminance), which -// skyForState.test.ts sweeps. Replace the body, keep the signature: the scene calls it once, at startup, +// fixed twilight: lavender zenith, rose mid-sky, orange horizon. These are the raw stops: the scene draws them +// shaped into the dusk band of ../skyBand.ts (`ridgeSkyFor`, skyForState.ts), whose floor the link of Noonday Sky needs. Replace the body, keep the signature: the scene calls it once, at startup, // and bakes the result. export type Srgb = readonly [number, number, number]; diff --git a/src/app/features/scene/skyBand.test.ts b/src/app/features/scene/skyBand.test.ts index 5963fb79..e38f676a 100644 --- a/src/app/features/scene/skyBand.test.ts +++ b/src/app/features/scene/skyBand.test.ts @@ -23,7 +23,7 @@ describe('shapeToBand', () => { it('lifts black to the tint scaled in, not to grey', () => { const c = shapeToBand([0, 0, 0], SKY_BAND.dusk); expect(near(luma(c), SKY_BAND.dusk.floor)).toBe(true); - expect(c[0]).toBeGreaterThan(c[2]); // the dusk tint is rose + expect(c[2]).toBeGreaterThan(c[0]); // the dusk tint is Day's pale blue }); it('leaves a sky already in the band as it is, and is continuous at the floor', () => { diff --git a/src/app/features/scene/skyBand.ts b/src/app/features/scene/skyBand.ts index ac311081..a74784b3 100644 --- a/src/app/features/scene/skyBand.ts +++ b/src/app/features/scene/skyBand.ts @@ -1,12 +1,14 @@ // The luminance band each fixed sky state's drawn sky is shaped into, so the state's theme ink // clears 4.5:1 on bare sky (WCAG relative luminance, the `luminance` of src/sky/skyTokens.js). // -// day, dusk Noonday Sky's dark text ink #342d25 (L 0.0274) needs a backdrop of at least 0.2983. +// day Noonday Sky's dark text ink #342d25 (L 0.0274) needs a backdrop of at least 0.2983. // The floor is that text bound (0.30 as drawn); there is no ceiling, dark ink on light is fine. +// dusk The same ink, and its link #2a4f27 (L 0.0623), which needs a backdrop of at least 0.4554. The floor is +// the link bound (0.4744 as drawn), so text clears 4.5:1 with room (6.6) and the link clears too. // night Midnight Sky's light text ink #aeccff (L 0.594) needs a backdrop of at most 0.0931. // The baked night sky (0.058 to 0.077) is kept; the ceiling is the bound. // -// Link ink is not part of the band: see skyTheme.ts. +// Night's link ink is not part of the band: see skyTheme.ts. // // The terrain sky is a physical scatter function, so it is shaped downstream, on its final colour: // `shapeToBand` here, and its GLSL twin (`SHAPE_SKY_GLSL`) in terrain/terrainShaders.ts, built from the @@ -56,7 +58,8 @@ export interface SkyBand { } /** - * The shaping targets, in linear luma. The floor sits 0.019 above the text bound (0.2983): the screen encoding is + * The shaping targets, in linear luma. Day's floor sits 0.019 above the text bound (0.2983), Dusk's 0.019 above the link + * bound (0.4554), which skyForState.test.ts derives from the ink and ties to these: the screen encoding is * gamma 2.2, which reads about 0.006 darker through the sRGB curve at this level, and 8-bit rounding with the sky * pass's dither at its worst (one step in every channel) takes about 0.007 more. The night ceiling is lower than the text bound * (0.0931) by more than that: 0.077394 is the luma at which one step up reads 0.08164, the lightest the baked night sky shows @@ -64,7 +67,9 @@ export interface SkyBand { */ export const SKY_BAND: Record = { day: { floor: 0.317, ceiling: 1, lift: hexToLinear('#e2f4ff') }, - dusk: { floor: 0.317, ceiling: 1, lift: hexToLinear('#eba0b9') }, + // Dusk lifts toward Day's pale blue rather than a rose: mixing a third of the sky toward rose washed the twilight pink, and + // a rose lighter than the floor shifts hues by 47 to 72 degrees where Day's tint shifts them by 18 (measured, PR body). + dusk: { floor: 0.4744, ceiling: 1, lift: hexToLinear('#e2f4ff') }, night: { floor: 0, ceiling: 0.077394, lift: hexToLinear('#5a6a9a') }, }; diff --git a/src/app/features/scene/skyForState.test.ts b/src/app/features/scene/skyForState.test.ts index 9d1dfd2d..a8199b1b 100644 --- a/src/app/features/scene/skyForState.test.ts +++ b/src/app/features/scene/skyForState.test.ts @@ -12,7 +12,7 @@ import { terrainDrawn, terrainSkyFor, } from './skyForState'; -import { displayedLuminance, SKY_BAND } from './skyBand'; +import { displayedLuminance, luma, shapeToBand, SKY_BAND } from './skyBand'; import type { FixedSkyState } from './skyBand'; import { setSkyClock, skyBackdrop } from './skyState'; import { view } from './terrain/terrainWorld'; @@ -24,14 +24,17 @@ const STATES = ['day', 'dusk', 'night'] as const; const SKY = { 'midnight-sky': baked.midnight, 'noonday-sky': baked.noonday } as const; /** - * The band each state's text ink needs, from the ink itself: dark ink needs a backdrop of at least - * AA * (L + 0.05) - 0.05, light ink at most (L + 0.05) / AA - 0.05. Day 0.2983, dusk 0.2983, night 0.0931. + * The band each state needs, from the ink itself: dark ink needs a backdrop of at least + * AA * (L + 0.05) - 0.05, light ink at most (L + 0.05) / AA - 0.05. Day 0.2983, night 0.0931; dusk is bound by its + * link (0.4554), the darker ink to clear, whose floor also holds the text. */ const needs = (state: FixedSkyState) => { - const l = luminance(SKY[skyThemeId(state)].tokens.Background.OnContainer); + const sky = SKY[skyThemeId(state)]; + const l = luminance(sky.tokens.Background.OnContainer); + const link = luminance(sky.link); return state === 'night' ? { floor: 0, ceiling: (l + 0.05) / AA - 0.05 } - : { floor: AA * (l + 0.05) - 0.05, ceiling: 1 }; + : { floor: AA * ((state === 'dusk' ? link : l) + 0.05) - 0.05, ceiling: 1 }; }; let search = ''; @@ -51,9 +54,26 @@ afterEach(() => { describe('the bands the text ink needs', () => { it('are the numbers the shaping was set from', () => { expect(needs('day').floor).toBeCloseTo(0.2983, 3); - expect(needs('dusk').floor).toBeCloseTo(0.2983, 3); + expect(needs('dusk').floor).toBeCloseTo(0.4554, 3); expect(needs('night').ceiling).toBeCloseTo(0.0931, 3); }); + + // The drawn floor and the lift are tied to those bounds, not to themselves: nudge either constant and this fails. + it('give Dusk a floor 0.019 above the link bound (as Day has above the text bound) and a lift Day shares', () => { + expect(SKY_BAND.dusk.floor).toBeCloseTo(needs('dusk').floor + 0.019, 4); + expect(SKY_BAND.day.floor).toBeCloseTo(needs('day').floor + 0.019, 3); + expect(SKY_BAND.dusk.lift).toEqual(SKY_BAND.day.lift); + expect(luma(SKY_BAND.dusk.lift)).toBeGreaterThan(SKY_BAND.dusk.floor); + }); + + it.each(['day', 'dusk'] as const)( + '%s: the darkest the shaping can show reads at the bound and under the floor', + (state) => { + const shown = displayedLuminance(shapeToBand([0, 0, 0], SKY_BAND[state]), 'low'); + expect(shown).toBeGreaterThanOrEqual(needs(state).floor); + expect(shown).toBeLessThan(SKY_BAND[state].floor); + } + ); }); describe('ridgeSkyFor', () => { @@ -68,7 +88,7 @@ describe('ridgeSkyFor', () => { it('draws a lavender, rose and orange twilight for dusk', () => { const d = ridgeSkyFor('dusk'); - expect(d).toEqual(skyColours()); + expect(d.ground).toEqual(skyColours().ground); const [t, m, h] = [d.top, d.mid, d.horizon]; expect(t[2]).toBeGreaterThan(t[1]); // lavender: blue over green expect(t[0]).toBeGreaterThan(t[1]); diff --git a/src/app/features/scene/skyForState.ts b/src/app/features/scene/skyForState.ts index 7da17476..71e07ca6 100644 --- a/src/app/features/scene/skyForState.ts +++ b/src/app/features/scene/skyForState.ts @@ -10,11 +10,13 @@ import type { SkyColours, Srgb } from './ridge/skyColour'; import { skyColours } from './ridge/skyColour'; import { resolveSkyState } from './skyState'; import type { SkyState } from './skyState'; -import { displayedLuminance, SKY_BAND } from './skyBand'; +import { displayedLuminance, hexToLinear, shapeToBand, SKY_BAND } from './skyBand'; import type { SkyBand } from './skyBand'; import { luminance } from '../../../sky/skyTokens.js'; import { CAMERA } from './terrain/terrainWorld'; +const rgbHex = (c: Srgb) => `#${c.map((v) => v.toString(16).padStart(2, '0')).join('')}`; + const rgb = (hex: string): Srgb => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)) as unknown as Srgb; @@ -28,12 +30,27 @@ const fromGradient = (g: readonly string[], ground: Srgb): SkyColours => ({ /** Sun elevation in degrees for the terrain's dusk: just above the horizon, between the two bakes (as `?sun=` puts it). */ export const DUSK_SUN_ELEVATION = 2; -/** The ridge scene's `sky` option: the gradient of the state's sky ('auto': of the sky it resolves to). */ +const toByte = (v: number): number => + Math.round(255 * (v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055)); + +/** A stop moved into the state's band, the way the terrain sky is (`shapeToBand`), through the sRGB curve the canvas draws with. */ +const inBand = (c: Srgb, band: SkyBand): Srgb => + shapeToBand(hexToLinear(rgbHex(c)), band).map(toByte) as unknown as Srgb; + +/** The ridge scene's `sky` option: the gradient of the state's sky ('auto': of the sky it resolves to). Dusk's twilight stops are shaped into its band. */ export function ridgeSkyFor(state: SkyState): SkyColours { const { ground } = skyColours(); switch (resolveSkyState(state)) { - case 'dusk': - return skyColours(); + case 'dusk': { + const twilight = skyColours(); + const band = SKY_BAND.dusk; + return { + ...twilight, + top: inBand(twilight.top, band), + mid: inBand(twilight.mid, band), + horizon: inBand(twilight.horizon, band), + }; + } case 'day': return fromGradient(baked.noonday.gradient, ground); default: @@ -67,8 +84,6 @@ export function terrainSkyFor(state: SkyState): { } } -const rgbHex = (c: Srgb) => `#${c.map((v) => v.toString(16).padStart(2, '0')).join('')}`; - /** Relative luminance (WCAG) of the darkest and lightest colour a scene can draw. */ export interface Range { readonly darkest: number; diff --git a/src/app/features/scene/skyState.ts b/src/app/features/scene/skyState.ts index 21117039..0b60d99b 100644 --- a/src/app/features/scene/skyState.ts +++ b/src/app/features/scene/skyState.ts @@ -131,7 +131,7 @@ export interface SkyBackdrop { // Bounds what the 8-bit screen shows too (one step each way for rounding and dither), rounded outward to six places. const BACKDROPS: Record = { day: { darkest: 0.461063, lightest: 0.901108 }, - dusk: { darkest: 0.30619, lightest: 0.968744 }, + dusk: { darkest: 0.460352, lightest: 0.968744 }, night: { darkest: 0.05987, lightest: 0.081642 }, }; diff --git a/src/app/features/scene/skyTheme.test.ts b/src/app/features/scene/skyTheme.test.ts index 53cf2873..fd9f32dd 100644 --- a/src/app/features/scene/skyTheme.test.ts +++ b/src/app/features/scene/skyTheme.test.ts @@ -59,13 +59,13 @@ describe('skyThemeId', () => { describe('ink of the theme a state picks, on bare sky', () => { // Per state: the picked theme, its text ink and link, and their WCAG ratios on the sky the scenes // draw (skyBackdrop, the worst case over its band). Text clears 4.5 in every state. Link clears - // on day and night and not on dusk: the links are derived in src/sky/skyTokens.js, outside the band, and these - // numbers are recorded as they are measured. Midnight's link is brightened in its own hue until it clears the - // lightest Night sky (NIGHT_SKY_LIGHTEST); Dusk's dark link would need a backdrop of about 0.455, and the dusk floor is lower. + // in every state: the links are derived in src/sky/skyTokens.js, outside the band, and these numbers are recorded as + // they are measured. Midnight's link is brightened in its own hue until it clears the lightest Night sky + // (NIGHT_SKY_LIGHTEST); Dusk's dark link needs a backdrop of about 0.4554, which the dusk floor is set above. // 'auto' resolves from the clock to one of these three and has the same numbers. const cells: [FixedSkyState, string, string, string, string, string][] = [ ['day', 'noonday-sky', '#342d25', '6.60', '#2a4f27', '4.55'], - ['dusk', 'noonday-sky', '#342d25', '4.60', '#2a4f27', '3.17'], + ['dusk', 'noonday-sky', '#342d25', '6.59', '#2a4f27', '4.54'], ['night', 'midnight-sky', '#aeccff', '4.89', '#8ad1bd', '4.53'], ]; @@ -84,10 +84,10 @@ describe('ink of the theme a state picks, on bare sky', () => { expect(ratio(ink, skyBackdrop(state))).toBeGreaterThanOrEqual(AA); }); - it('link clears AA on day and night and falls short on dusk', () => { + it('link clears AA in every state', () => { const link = (s: FixedSkyState, id: keyof typeof SKY) => ratio(SKY[id].link, skyBackdrop(s)); expect(link('day', 'noonday-sky')).toBeGreaterThanOrEqual(AA); - expect(link('dusk', 'noonday-sky')).toBeLessThan(AA); + expect(link('dusk', 'noonday-sky')).toBeGreaterThanOrEqual(AA); expect(link('night', 'midnight-sky')).toBeGreaterThanOrEqual(AA); }); diff --git a/src/app/features/scene/skyTheme.ts b/src/app/features/scene/skyTheme.ts index 4ab3f43a..f8ab8b12 100644 --- a/src/app/features/scene/skyTheme.ts +++ b/src/app/features/scene/skyTheme.ts @@ -4,10 +4,10 @@ // // Day and Dusk take the dark ink of Noonday Sky, Night the light ink of Midnight Sky. Each state's // drawn sky is shaped into a luminance band (skyBand.ts) in which that ink's TEXT clears 4.5:1 on -// bare sky: Day and Dusk at least 0.2983 (dark ink), Night at most 0.0931 (light ink). skyTheme.test.ts -// pins the measured text and link ratios. Link ink is not in the band: the Day and Dusk links are dark -// and fall short of 4.5 where the sky is below about 0.455 (Dusk: 3.17), because the themes' link colours are -// derived outside this code (src/sky/skyTokens.js). Midnight Sky's link is derived there against the lightest Night sky +// bare sky: Day at least 0.2983 (dark ink), Night at most 0.0931 (light ink). Dusk's floor is higher, the bound of +// the dark link (0.4554), so its link clears as well. skyTheme.test.ts pins the measured text and link ratios. Night's +// link ink is not in the band, and the themes' link colours are derived outside this code (src/sky/skyTokens.js). +// Midnight Sky's link is derived there against the lightest Night sky // (4.53 on it, 5.43 on the darkest). The plate (skyPlate.css.ts) is still in the shell, pending // its removal. import type { FixedSkyState, SkyState } from './skyState'; From ea238cf77d4af5c79113982ccccabd08bf3b8d34 Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 03:18:49 +0000 Subject: [PATCH 187/206] auth: a blurred, tinted, soft-edged band behind the sign-in footer line The footer links sat on dark terrain in the sky's dark ink (contrast 1.0 to 2.9 at Dusk and Day). The footer now carries the plate's ink over a backdrop-blur band tinted with the plate's fill (FOOTER_TINT), faded at its edges; skyTheme.test.ts checks the ink over the darkest and lightest scene behind it. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/skyPlate.ts | 8 ++++++++ src/app/features/scene/skyTheme.test.ts | 19 ++++++++++++++++++- src/app/pages/auth/styles.css.ts | 23 +++++++++++++++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/app/features/scene/skyPlate.ts b/src/app/features/scene/skyPlate.ts index 872e2fd6..54ff13f2 100644 --- a/src/app/features/scene/skyPlate.ts +++ b/src/app/features/scene/skyPlate.ts @@ -7,3 +7,11 @@ export const SKY_PLATE_FALLBACK = { text: '#b7c7d9', link: '#99c7ff', } as const; + +/** + * The sign-in footer's tint: how much of the plate's fill (--sky-bubble) is mixed over the blurred scene behind the + * footer line. The footer is a blurred, tinted band with soft edges, not a plate: the scene shows through it. The + * footer ink is the plate's, so the tint has to bring the scene behind it to where that ink clears 4.5:1 + * (skyTheme.test.ts). + */ +export const FOOTER_TINT = 0.75; diff --git a/src/app/features/scene/skyTheme.test.ts b/src/app/features/scene/skyTheme.test.ts index 53cf2873..dd46117b 100644 --- a/src/app/features/scene/skyTheme.test.ts +++ b/src/app/features/scene/skyTheme.test.ts @@ -7,7 +7,7 @@ import { PageContent, PageNavContent } from '../../components/page/style.css'; import { CallStatus } from '../call-status/styles.css'; import { MembersDrawer } from '../room/MembersDrawer.css'; import { RoomViewTyping } from '../room/RoomViewTyping.css'; -import { SKY_PLATE_FALLBACK } from './skyPlate'; +import { FOOTER_TINT, SKY_PLATE_FALLBACK } from './skyPlate'; import { SkyPlate, plateRegions } from './skyPlate.css'; import { getResolvedSkyState, setSkyClock, skyBackdrop } from './skyState'; import type { SkyBackdrop } from './skyState'; @@ -160,3 +160,20 @@ describe('regions that sit on the plate', () => { expect(plateRegions.some((r) => r.includes(':not(:has('))).toBe(true); }); }); + +describe('the sign-in footer band', () => { + // The footer line is the plate's ink on a band that mixes FOOTER_TINT of the plate's fill over the blurred scene. + // The scene behind it is the terrain: as dark as 0, as light as the sky state's lightest backdrop. Ink contrast is + // lowest at one of those two ends, so both are checked. + const tinted = (fill: string, behind: number) => + FOOTER_TINT * luminance(fill) + (1 - FOOTER_TINT) * behind; + it.each(fixed)( + '%s: the ink clears AA over the darkest and the lightest scene behind it', + (state, id) => { + const { Other, OtherText } = SKY[id].bubbles; + [0, skyBackdrop(state).lightest].forEach((behind) => { + expect(against(OtherText, tinted(Other, behind))).toBeGreaterThanOrEqual(AA); + }); + } + ); +}); diff --git a/src/app/pages/auth/styles.css.ts b/src/app/pages/auth/styles.css.ts index b13bf868..c1614a79 100644 --- a/src/app/pages/auth/styles.css.ts +++ b/src/app/pages/auth/styles.css.ts @@ -1,5 +1,6 @@ import { globalStyle, style } from '@vanilla-extract/css'; import { DefaultReset, color, config, toRem } from 'folds'; +import { FOOTER_TINT } from '../../features/scene/skyPlate'; export const AuthLayout = style({ minHeight: '100%', @@ -88,3 +89,25 @@ globalStyle(`${scene} ${AuthCardContent}`, { paddingBottom: config.space.S400, gap: config.space.S500, }); + +// The footer line sits on the terrain, which is dark at its foot in every sky (luminance 0.02 to 0.04 at dusk), where +// the sky's dark ink is unreadable. Over the scene it gets a blurred band tinted with the plate's fill and the plate's +// ink and link, faded out at its edges so it reads as haze and not as a bar: the scene shows through and round it. +// The tint (FOOTER_TINT) is checked against the ink in skyTheme.test.ts. +globalStyle(`${scene} ${AuthFooter}`, { + paddingTop: config.space.S500, + paddingBottom: config.space.S500, + backgroundColor: `color-mix(in srgb, var(--sky-bubble, #0d1a26) ${ + FOOTER_TINT * 100 + }%, transparent)`, + backdropFilter: 'blur(12px)', + WebkitBackdropFilter: 'blur(12px)', + maskImage: + 'linear-gradient(to bottom, transparent, #000 16px, #000 calc(100% - 16px), transparent)', + WebkitMaskImage: + 'linear-gradient(to bottom, transparent, #000 16px, #000 calc(100% - 16px), transparent)', + color: 'var(--sky-bubble-text, #b7c7d9)', +}); +globalStyle(`${scene} ${AuthFooter} a`, { + color: 'var(--sky-bubble-text, #b7c7d9)', +}); From eb6efeee91f564b9a3521078197ab0f95a3ece7a Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 03:20:06 +0000 Subject: [PATCH 188/206] auth: footer tint 0.82; 0.75 measured 4.49 at Dusk on the drawn pixels Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/skyPlate.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/app/features/scene/skyPlate.ts b/src/app/features/scene/skyPlate.ts index 54ff13f2..2b9f4484 100644 --- a/src/app/features/scene/skyPlate.ts +++ b/src/app/features/scene/skyPlate.ts @@ -12,6 +12,7 @@ export const SKY_PLATE_FALLBACK = { * The sign-in footer's tint: how much of the plate's fill (--sky-bubble) is mixed over the blurred scene behind the * footer line. The footer is a blurred, tinted band with soft edges, not a plate: the scene shows through it. The * footer ink is the plate's, so the tint has to bring the scene behind it to where that ink clears 4.5:1 - * (skyTheme.test.ts). + * (skyTheme.test.ts). The browser mixes in sRGB, which gives less contrast over dark terrain than the linear mix the test + * computes, so the value carries margin: measured on the drawn pixels (Chrome, GTX 1070), 0.75 gave 4.49 at Dusk. */ -export const FOOTER_TINT = 0.75; +export const FOOTER_TINT = 0.82; From 56bc40abe600cc92612cd159c79bce6aaa6d7f7d Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 03:21:47 +0000 Subject: [PATCH 189/206] auth: fade the footer band at its sides too Co-Authored-By: Claude Sonnet 5.5 --- src/app/pages/auth/styles.css.ts | 37 ++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/app/pages/auth/styles.css.ts b/src/app/pages/auth/styles.css.ts index c1614a79..d3e60dca 100644 --- a/src/app/pages/auth/styles.css.ts +++ b/src/app/pages/auth/styles.css.ts @@ -90,6 +90,43 @@ globalStyle(`${scene} ${AuthCardContent}`, { gap: config.space.S500, }); +// The footer line sits on the terrain, which is dark at its foot in every sky (luminance 0.02 to 0.04 at dusk), where +// the sky's dark ink is unreadable. Over the scene it gets a blurred band tinted with the plate's fill and the plate's +// ink and link, faded out at its edges so it reads as haze and not as a bar: the scene shows through and round it. +// The tint (FOOTER_TINT) is checked against the ink in skyTheme.test.ts. +const FADE_Y = + 'linear-gradient(to bottom, transparent, #000 16px, #000 calc(100% - 16px), transparent)'; +const FADE_X = + 'linear-gradient(to right, transparent, #000 24px, #000 calc(100% - 24px), transparent)'; +globalStyle(`${scene} ${AuthFooter}`, { + paddingTop: config.space.S500, + paddingBottom: config.space.S500, + backgroundColor: `color-mix(in srgb, var(--sky-bubble, #0d1a26) ${ + FOOTER_TINT * 100 + }%, transparent)`, + backdropFilter: 'blur(12px)', + WebkitBackdropFilter: 'blur(12px)', + maskImage: `${FADE_Y}, ${FADE_X}`, + WebkitMaskImage: `${FADE_Y}, ${FADE_X}`, + maskComposite: 'intersect', + WebkitMaskComposite: 'source-in', + color: 'var(--sky-bubble-text, #b7c7d9)', + borderRadius: config.radii.R500, + // A faint inner edge, so a pane this clear still reads as a surface. + borderColor: 'rgba(183, 199, 217, 0.14)', +}); +globalStyle(`${scene} body:not(.gamecult-theme) ${AuthCard}`, { + vars: { '--tc-link': '#99c7ff' }, +}); +globalStyle(`${scene} ${AuthHeader}`, { + backgroundColor: 'transparent', +}); +globalStyle(`${scene} ${AuthCardContent}`, { + paddingTop: config.space.S500, + paddingBottom: config.space.S400, + gap: config.space.S500, +}); + // The footer line sits on the terrain, which is dark at its foot in every sky (luminance 0.02 to 0.04 at dusk), where // the sky's dark ink is unreadable. Over the scene it gets a blurred band tinted with the plate's fill and the plate's // ink and link, faded out at its edges so it reads as haze and not as a bar: the scene shows through and round it. From 2e6d4d042a326e38e793ae043832376dcf3ae78b Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 03:31:52 +0000 Subject: [PATCH 190/206] auth: footer band review fixes: one block, no pill or border, color-mix fallback, sRGB test The first footer block had spliced into the sign-in card's rule (a Y-only mask duplicate that overrode the side fade, a pill radius and border on the footer); the footer is now one rule. The test mixes in encoded sRGB as Chrome does. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/skyPlate.ts | 4 +-- src/app/features/scene/skyTheme.test.ts | 23 +++++++++---- src/app/pages/auth/styles.css.ts | 45 +++---------------------- 3 files changed, 23 insertions(+), 49 deletions(-) diff --git a/src/app/features/scene/skyPlate.ts b/src/app/features/scene/skyPlate.ts index 2b9f4484..86429ba5 100644 --- a/src/app/features/scene/skyPlate.ts +++ b/src/app/features/scene/skyPlate.ts @@ -12,7 +12,7 @@ export const SKY_PLATE_FALLBACK = { * The sign-in footer's tint: how much of the plate's fill (--sky-bubble) is mixed over the blurred scene behind the * footer line. The footer is a blurred, tinted band with soft edges, not a plate: the scene shows through it. The * footer ink is the plate's, so the tint has to bring the scene behind it to where that ink clears 4.5:1 - * (skyTheme.test.ts). The browser mixes in sRGB, which gives less contrast over dark terrain than the linear mix the test - * computes, so the value carries margin: measured on the drawn pixels (Chrome, GTX 1070), 0.75 gave 4.49 at Dusk. + * (skyTheme.test.ts). The browser mixes in sRGB, so the test does too (skyTheme.test.ts): over pure black 0.80 gives 4.36 and 0.82 gives 4.55, and + * the darkest terrain drawn is 0.0045, not black; measured on the drawn pixels the footer clears 6.2. */ export const FOOTER_TINT = 0.82; diff --git a/src/app/features/scene/skyTheme.test.ts b/src/app/features/scene/skyTheme.test.ts index dd46117b..3229cf36 100644 --- a/src/app/features/scene/skyTheme.test.ts +++ b/src/app/features/scene/skyTheme.test.ts @@ -162,17 +162,28 @@ describe('regions that sit on the plate', () => { }); describe('the sign-in footer band', () => { - // The footer line is the plate's ink on a band that mixes FOOTER_TINT of the plate's fill over the blurred scene. - // The scene behind it is the terrain: as dark as 0, as light as the sky state's lightest backdrop. Ink contrast is - // lowest at one of those two ends, so both are checked. - const tinted = (fill: string, behind: number) => - FOOTER_TINT * luminance(fill) + (1 - FOOTER_TINT) * behind; + // The footer line is the plate's ink on a band that mixes FOOTER_TINT of the plate's fill over the blurred scene, + // as the browser does: per channel, in encoded sRGB (not in linear light, which flatters dark scenes). The scene + // behind it is the terrain, taken as grey: as dark as 0, as light as the sky state's lightest backdrop. Ink + // contrast is lowest at one of those two ends, so both are checked. + const encode = (l: number) => (l <= 0.0031308 ? 12.92 * l : 1.055 * l ** (1 / 2.4) - 0.055); + const channels = (hex: string) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255); + const hexOf = (c: number[]) => + `#${c + .map((v) => + Math.round(v * 255) + .toString(16) + .padStart(2, '0') + ) + .join('')}`; + const bandOver = (fill: string, behind: number) => + hexOf(channels(fill).map((f) => FOOTER_TINT * f + (1 - FOOTER_TINT) * encode(behind))); it.each(fixed)( '%s: the ink clears AA over the darkest and the lightest scene behind it', (state, id) => { const { Other, OtherText } = SKY[id].bubbles; [0, skyBackdrop(state).lightest].forEach((behind) => { - expect(against(OtherText, tinted(Other, behind))).toBeGreaterThanOrEqual(AA); + expect(contrast(OtherText, bandOver(Other, behind))).toBeGreaterThanOrEqual(AA); }); } ); diff --git a/src/app/pages/auth/styles.css.ts b/src/app/pages/auth/styles.css.ts index d3e60dca..772f02a2 100644 --- a/src/app/pages/auth/styles.css.ts +++ b/src/app/pages/auth/styles.css.ts @@ -92,57 +92,20 @@ globalStyle(`${scene} ${AuthCardContent}`, { // The footer line sits on the terrain, which is dark at its foot in every sky (luminance 0.02 to 0.04 at dusk), where // the sky's dark ink is unreadable. Over the scene it gets a blurred band tinted with the plate's fill and the plate's -// ink and link, faded out at its edges so it reads as haze and not as a bar: the scene shows through and round it. -// The tint (FOOTER_TINT) is checked against the ink in skyTheme.test.ts. +// ink, faded out at its edges so it reads as haze and not as a bar: the scene shows through and round it. A browser +// without color-mix gets the plate's opaque fill, as the chat bubbles do (message/layout/layout.css.ts). The tint (FOOTER_TINT) is checked against the ink in skyTheme.test.ts. const FADE_Y = 'linear-gradient(to bottom, transparent, #000 16px, #000 calc(100% - 16px), transparent)'; const FADE_X = 'linear-gradient(to right, transparent, #000 24px, #000 calc(100% - 24px), transparent)'; +const tint = `color-mix(in srgb, var(--sky-bubble, #0d1a26) ${FOOTER_TINT * 100}%, transparent)`; globalStyle(`${scene} ${AuthFooter}`, { paddingTop: config.space.S500, paddingBottom: config.space.S500, - backgroundColor: `color-mix(in srgb, var(--sky-bubble, #0d1a26) ${ - FOOTER_TINT * 100 - }%, transparent)`, + backgroundColor: ['var(--sky-bubble, #0d1a26)', tint], backdropFilter: 'blur(12px)', - WebkitBackdropFilter: 'blur(12px)', maskImage: `${FADE_Y}, ${FADE_X}`, - WebkitMaskImage: `${FADE_Y}, ${FADE_X}`, maskComposite: 'intersect', - WebkitMaskComposite: 'source-in', - color: 'var(--sky-bubble-text, #b7c7d9)', - borderRadius: config.radii.R500, - // A faint inner edge, so a pane this clear still reads as a surface. - borderColor: 'rgba(183, 199, 217, 0.14)', -}); -globalStyle(`${scene} body:not(.gamecult-theme) ${AuthCard}`, { - vars: { '--tc-link': '#99c7ff' }, -}); -globalStyle(`${scene} ${AuthHeader}`, { - backgroundColor: 'transparent', -}); -globalStyle(`${scene} ${AuthCardContent}`, { - paddingTop: config.space.S500, - paddingBottom: config.space.S400, - gap: config.space.S500, -}); - -// The footer line sits on the terrain, which is dark at its foot in every sky (luminance 0.02 to 0.04 at dusk), where -// the sky's dark ink is unreadable. Over the scene it gets a blurred band tinted with the plate's fill and the plate's -// ink and link, faded out at its edges so it reads as haze and not as a bar: the scene shows through and round it. -// The tint (FOOTER_TINT) is checked against the ink in skyTheme.test.ts. -globalStyle(`${scene} ${AuthFooter}`, { - paddingTop: config.space.S500, - paddingBottom: config.space.S500, - backgroundColor: `color-mix(in srgb, var(--sky-bubble, #0d1a26) ${ - FOOTER_TINT * 100 - }%, transparent)`, - backdropFilter: 'blur(12px)', - WebkitBackdropFilter: 'blur(12px)', - maskImage: - 'linear-gradient(to bottom, transparent, #000 16px, #000 calc(100% - 16px), transparent)', - WebkitMaskImage: - 'linear-gradient(to bottom, transparent, #000 16px, #000 calc(100% - 16px), transparent)', color: 'var(--sky-bubble-text, #b7c7d9)', }); globalStyle(`${scene} ${AuthFooter} a`, { From 271729a5c03c933def22745deb0fe2156a656ea7 Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 03:48:36 +0000 Subject: [PATCH 191/206] auth: test the emitted footer rule; wrap its comment; correct the terrain luminance figure footerBand.test.ts reads the CSS vanilla-extract emits for styles.css.ts (so @vanilla-extract/integration becomes a devDependency): one footer rule, the plate fill before the color-mix fill, a mask faded on both axes, no border or radius. skyPlate.ts no longer claims the darkest terrain is 0.0045 (that was Night's whole frame); it states the footer region's minima per state and the measured link contrast. Co-Authored-By: Claude Sonnet 5.5 --- package-lock.json | 2 ++ package.json | 1 + src/app/features/scene/skyPlate.ts | 8 +++-- src/app/pages/auth/footerBand.test.ts | 48 +++++++++++++++++++++++++++ src/app/pages/auth/styles.css.ts | 7 ++-- 5 files changed, 60 insertions(+), 6 deletions(-) create mode 100644 src/app/pages/auth/footerBand.test.ts diff --git a/package-lock.json b/package-lock.json index 947eef2b..1915ec32 100644 --- a/package-lock.json +++ b/package-lock.json @@ -86,6 +86,7 @@ "@types/ua-parser-js": "0.7.36", "@typescript-eslint/eslint-plugin": "5.46.1", "@typescript-eslint/parser": "5.46.1", + "@vanilla-extract/integration": "6.5.0", "@vitejs/plugin-react": "4.2.0", "buffer": "6.0.3", "cz-conventional-changelog": "3.3.0", @@ -5095,6 +5096,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/@vanilla-extract/integration/-/integration-6.5.0.tgz", "integrity": "sha512-E2YcfO8vA+vs+ua+gpvy1HRqvgWbI+MTlUpxA8FvatOvybuNcWAY0CKwQ/Gpj7rswYKtC6C7+xw33emM6/ImdQ==", + "license": "MIT", "dependencies": { "@babel/core": "^7.20.7", "@babel/plugin-syntax-typescript": "^7.20.0", diff --git a/package.json b/package.json index 1aebfd94..69f589c6 100644 --- a/package.json +++ b/package.json @@ -114,6 +114,7 @@ "@types/ua-parser-js": "0.7.36", "@typescript-eslint/eslint-plugin": "5.46.1", "@typescript-eslint/parser": "5.46.1", + "@vanilla-extract/integration": "6.5.0", "@vitejs/plugin-react": "4.2.0", "buffer": "6.0.3", "cz-conventional-changelog": "3.3.0", diff --git a/src/app/features/scene/skyPlate.ts b/src/app/features/scene/skyPlate.ts index 86429ba5..353134ab 100644 --- a/src/app/features/scene/skyPlate.ts +++ b/src/app/features/scene/skyPlate.ts @@ -11,8 +11,10 @@ export const SKY_PLATE_FALLBACK = { /** * The sign-in footer's tint: how much of the plate's fill (--sky-bubble) is mixed over the blurred scene behind the * footer line. The footer is a blurred, tinted band with soft edges, not a plate: the scene shows through it. The - * footer ink is the plate's, so the tint has to bring the scene behind it to where that ink clears 4.5:1 - * (skyTheme.test.ts). The browser mixes in sRGB, so the test does too (skyTheme.test.ts): over pure black 0.80 gives 4.36 and 0.82 gives 4.55, and - * the darkest terrain drawn is 0.0045, not black; measured on the drawn pixels the footer clears 6.2. + * footer ink is the plate's, so the tint has to bring the scene behind it to where that ink clears 4.5:1. The browser + * mixes in sRGB, so the test does too (skyTheme.test.ts): over pure black 0.80 gives 4.36 and 0.82 gives 4.55. The + * terrain is not black: in the shots at 390x844 (terrain scene, tier low) the darkest pixel in the footer's region is + * 0.0203 at Dusk, 0.0936 at Day (dark ink) and 0.0052 at Night (light ink). Measured on the drawn pixels with the band + * on, the worst link contrast is 5.0 at Dusk, 5.7 at Day and 11.2 at Night. */ export const FOOTER_TINT = 0.82; diff --git a/src/app/pages/auth/footerBand.test.ts b/src/app/pages/auth/footerBand.test.ts new file mode 100644 index 00000000..26078d95 --- /dev/null +++ b/src/app/pages/auth/footerBand.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import zlib from 'zlib'; +import { compile, processVanillaFile } from '@vanilla-extract/integration'; +import { AuthFooter } from './styles.css'; + +// The CSS styles.css.ts emits for the sign-in footer, read back from vanilla-extract's own compile. +const emitted = async () => { + const filePath = `${process.cwd()}/src/app/pages/auth/styles.css.ts`; + const { source } = await compile({ filePath, cwd: process.cwd() }); + const code = await processVanillaFile({ source, filePath, identOption: 'debug' }); + const encoded = (code.match(/source=#([A-Za-z0-9+/=]+)/) as RegExpMatchArray)[1]; + return zlib.gunzipSync(Buffer.from(encoded, 'base64')).toString(); +}; +const declarations = (css: string, selector: string) => + [...css.matchAll(/^([^{}\n]+) \{\n([^}]*)\}/gm)] + .filter((m) => m[1] === selector) + .map((m) => + m[2] + .split('\n') + .map((l) => l.trim().replace(/;$/, '')) + .filter(Boolean) + ); + +describe('the sign-in footer band, as emitted', () => { + const over = `:root[data-scene] .${AuthFooter}`; + + it('is one rule over the scene, so no later rule can override its mask', async () => { + expect(declarations(await emitted(), over)).toHaveLength(1); + }); + + it('puts the plate fill before the color-mix tint, so a browser without color-mix keeps the fill', async () => { + const [rule] = declarations(await emitted(), over); + const fills = rule.filter((d) => d.startsWith('background-color:')); + expect(fills).toHaveLength(2); + expect(fills[0]).toBe('background-color: var(--sky-bubble, #0d1a26)'); + expect(fills[1]).toContain('color-mix('); + }); + + it('is a blurred band faded at all four edges, with no border or radius', async () => { + const [rule] = declarations(await emitted(), over); + const mask = rule.find((d) => d.startsWith('mask-image:')) ?? ''; + expect(mask).toContain('to bottom'); + expect(mask).toContain('to right'); + expect(rule).toContain('mask-composite: intersect'); + expect(rule.some((d) => d.startsWith('backdrop-filter: blur('))).toBe(true); + expect(rule.filter((d) => /^(border|border-radius)/.test(d))).toEqual([]); + }); +}); diff --git a/src/app/pages/auth/styles.css.ts b/src/app/pages/auth/styles.css.ts index 772f02a2..fc47dd79 100644 --- a/src/app/pages/auth/styles.css.ts +++ b/src/app/pages/auth/styles.css.ts @@ -90,10 +90,11 @@ globalStyle(`${scene} ${AuthCardContent}`, { gap: config.space.S500, }); -// The footer line sits on the terrain, which is dark at its foot in every sky (luminance 0.02 to 0.04 at dusk), where -// the sky's dark ink is unreadable. Over the scene it gets a blurred band tinted with the plate's fill and the plate's +// The footer line sits on the terrain, which is dark at its foot in every sky (luminance 0.02 at dusk), where the +// sky's dark ink is unreadable. Over the scene it gets a blurred band tinted with the plate's fill and the plate's // ink, faded out at its edges so it reads as haze and not as a bar: the scene shows through and round it. A browser -// without color-mix gets the plate's opaque fill, as the chat bubbles do (message/layout/layout.css.ts). The tint (FOOTER_TINT) is checked against the ink in skyTheme.test.ts. +// without color-mix gets the plate's opaque fill, as the chat bubbles do (message/layout/layout.css.ts). The tint +// (FOOTER_TINT) is checked against the ink in skyTheme.test.ts, the emitted rule in footerBand.test.ts. const FADE_Y = 'linear-gradient(to bottom, transparent, #000 16px, #000 calc(100% - 16px), transparent)'; const FADE_X = From b5e93510e86b424f4312838daa9a197679579480 Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 03:58:04 +0000 Subject: [PATCH 192/206] auth: footerBand test: identOption for compile; kill blur(0), base-rule border, hard-stop survivors Co-Authored-By: Claude Sonnet 5.5 --- src/app/pages/auth/footerBand.test.ts | 36 ++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/src/app/pages/auth/footerBand.test.ts b/src/app/pages/auth/footerBand.test.ts index 26078d95..c3293c4d 100644 --- a/src/app/pages/auth/footerBand.test.ts +++ b/src/app/pages/auth/footerBand.test.ts @@ -6,7 +6,7 @@ import { AuthFooter } from './styles.css'; // The CSS styles.css.ts emits for the sign-in footer, read back from vanilla-extract's own compile. const emitted = async () => { const filePath = `${process.cwd()}/src/app/pages/auth/styles.css.ts`; - const { source } = await compile({ filePath, cwd: process.cwd() }); + const { source } = await compile({ filePath, cwd: process.cwd(), identOption: 'debug' }); const code = await processVanillaFile({ source, filePath, identOption: 'debug' }); const encoded = (code.match(/source=#([A-Za-z0-9+/=]+)/) as RegExpMatchArray)[1]; return zlib.gunzipSync(Buffer.from(encoded, 'base64')).toString(); @@ -36,13 +36,37 @@ describe('the sign-in footer band, as emitted', () => { expect(fills[1]).toContain('color-mix('); }); - it('is a blurred band faded at all four edges, with no border or radius', async () => { + it('blurs what is behind it by a real radius', async () => { + const [rule] = declarations(await emitted(), over); + const blur = rule.find((d) => d.startsWith('backdrop-filter:')) ?? ''; + const radius = Number(blur.match(/^backdrop-filter: blur\((\d+(?:\.\d+)?)px\)$/)?.[1]); + expect(radius).toBeGreaterThan(0); + }); + + it('fades along both axes over a soft ramp, not a hard edge', async () => { const [rule] = declarations(await emitted(), over); const mask = rule.find((d) => d.startsWith('mask-image:')) ?? ''; - expect(mask).toContain('to bottom'); - expect(mask).toContain('to right'); expect(rule).toContain('mask-composite: intersect'); - expect(rule.some((d) => d.startsWith('backdrop-filter: blur('))).toBe(true); - expect(rule.filter((d) => /^(border|border-radius)/.test(d))).toEqual([]); + ['bottom', 'right'].forEach((axis) => { + const ramp = mask.match( + new RegExp( + `linear-gradient\\(to ${axis}, transparent, #000 (\\d+)px, #000 calc\\(100% - (\\d+)px\\), transparent\\)` + ) + ); + expect(ramp).not.toBeNull(); + // Each end ramps from clear to opaque over at least 8px. + expect(Number(ramp?.[1])).toBeGreaterThanOrEqual(8); + expect(Number(ramp?.[2])).toBeGreaterThanOrEqual(8); + }); + }); + + it('has no border or radius, in the scene rule or in the base footer rule', async () => { + const css = await emitted(); + const [rule] = declarations(css, over); + const base = declarations(css, `.${AuthFooter}`); + expect(base).toHaveLength(1); + [rule, base[0]].forEach((r) => { + expect(r.filter((d) => /^(border|outline)/.test(d))).toEqual([]); + }); }); }); From 320aeecc2c076453e72f843c5fd025a4d2c12594 Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 04:09:31 +0000 Subject: [PATCH 193/206] auth: footerBand test pins blur to 12px and the footer rule's exact property set Co-Authored-By: Claude Sonnet 5.5 --- src/app/pages/auth/footerBand.test.ts | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src/app/pages/auth/footerBand.test.ts b/src/app/pages/auth/footerBand.test.ts index c3293c4d..9d196200 100644 --- a/src/app/pages/auth/footerBand.test.ts +++ b/src/app/pages/auth/footerBand.test.ts @@ -36,11 +36,25 @@ describe('the sign-in footer band, as emitted', () => { expect(fills[1]).toContain('color-mix('); }); - it('blurs what is behind it by a real radius', async () => { + it('blurs what is behind it by 12px, the radius of the chat bubbles', async () => { const [rule] = declarations(await emitted(), over); - const blur = rule.find((d) => d.startsWith('backdrop-filter:')) ?? ''; - const radius = Number(blur.match(/^backdrop-filter: blur\((\d+(?:\.\d+)?)px\)$/)?.[1]); - expect(radius).toBeGreaterThan(0); + expect(rule.filter((d) => d.startsWith('backdrop-filter'))).toEqual([ + 'backdrop-filter: blur(12px)', + ]); + }); + + it('declares exactly these properties, so nothing else (a shadow, a prefixed override) rides along', async () => { + const [rule] = declarations(await emitted(), over); + expect(rule.map((d) => d.slice(0, d.indexOf(':'))).sort()).toEqual([ + 'backdrop-filter', + 'background-color', + 'background-color', + 'color', + 'mask-composite', + 'mask-image', + 'padding-bottom', + 'padding-top', + ]); }); it('fades along both axes over a soft ramp, not a hard edge', async () => { @@ -68,5 +82,7 @@ describe('the sign-in footer band, as emitted', () => { [rule, base[0]].forEach((r) => { expect(r.filter((d) => /^(border|outline)/.test(d))).toEqual([]); }); + // The base rule is only the footer's padding: nothing visible (a shadow, a fill) is hidden outside the scene rule. + expect(base[0].map((d) => d.slice(0, d.indexOf(':')))).toEqual(['padding']); }); }); From 5712068902f5c73a0bc5eab63a9f073a6f91f079 Mon Sep 17 00:00:00 2001 From: Eureka Self Date: Tue, 10 Nov 2026 13:18:59 +0000 Subject: [PATCH 194/206] nemesis: Meta's logo, chromeless plates and card, sky-led theme, denser terrain, scene style picker - Logo: Meta's white matte (2026-11-09) as the UI mark; icons are the mark on a night-sky tile. Dark ink on Noonday Sky. - Plates (title bars, lists, composer) and the sign-in card: blur with a light sky tint (PLATE_TINT 0.45), never opaque. - auto sky picks the theme from the resolved sky: day light mode, night dark mode, dusk the dark-ink crossover. - Terrain grid 768/2048 full, 512/1024 low, 256/1024 still. - Temporary Background group (Terrain, Ridges) in the sky menu, also on the sign-in card. Co-Authored-By: Claude Opus 5.5 --- public/favicon.ico | Bin 2338 -> 9308 bytes public/res/android/android-chrome-144x144.png | Bin 3096 -> 9669 bytes public/res/android/android-chrome-192x192.png | Bin 4140 -> 13156 bytes public/res/android/android-chrome-256x256.png | Bin 5684 -> 17958 bytes public/res/android/android-chrome-36x36.png | Bin 817 -> 2038 bytes public/res/android/android-chrome-384x384.png | Bin 9055 -> 27702 bytes public/res/android/android-chrome-48x48.png | Bin 1039 -> 2914 bytes public/res/android/android-chrome-512x512.png | Bin 12620 -> 37581 bytes public/res/android/android-chrome-72x72.png | Bin 1528 -> 4503 bytes public/res/android/android-chrome-96x96.png | Bin 2022 -> 6234 bytes public/res/apple/apple-touch-icon-114x114.png | Bin 2340 -> 7505 bytes public/res/apple/apple-touch-icon-120x120.png | Bin 2472 -> 7923 bytes public/res/apple/apple-touch-icon-144x144.png | Bin 3096 -> 9669 bytes public/res/apple/apple-touch-icon-152x152.png | Bin 3212 -> 10236 bytes public/res/apple/apple-touch-icon-167x167.png | Bin 3555 -> 11346 bytes public/res/apple/apple-touch-icon-180x180.png | Bin 3884 -> 12230 bytes public/res/apple/apple-touch-icon-57x57.png | Bin 1188 -> 3474 bytes public/res/apple/apple-touch-icon-60x60.png | Bin 1261 -> 3629 bytes public/res/apple/apple-touch-icon-72x72.png | Bin 1528 -> 4503 bytes public/res/apple/apple-touch-icon-76x76.png | Bin 1606 -> 4748 bytes public/res/svg/ratatoskr.svg | 16 +----- src/app/features/scene/BackgroundScene.tsx | 9 +++- src/app/features/scene/SkyStateControl.tsx | 51 +++++++++++++++++- src/app/features/scene/sceneStyle.ts | 28 ++++++++++ src/app/features/scene/skyPlate.css.ts | 6 ++- src/app/features/scene/skyPlate.ts | 8 +++ src/app/features/scene/skyTheme.ts | 4 +- .../features/scene/terrain/terrainWorld.ts | 6 +-- src/app/pages/auth/AuthLayout.tsx | 2 + src/app/pages/auth/styles.css.ts | 29 +++++++--- 30 files changed, 127 insertions(+), 32 deletions(-) create mode 100644 src/app/features/scene/sceneStyle.ts diff --git a/public/favicon.ico b/public/favicon.ico index cfce994377be2daa71cd95204142fc269980eb34..bda9855c959e04e31aef615055c68fa7df9e1805 100644 GIT binary patch literal 9308 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 delta 2245 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 diff --git a/public/res/android/android-chrome-144x144.png b/public/res/android/android-chrome-144x144.png index d76ed356c9aeb674b40950163dbd422d61b84ade..2dfde532d8801012b8a38a176b80aef5cc5239e7 100644 GIT binary patch literal 9669 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 Hu0mjfyJ31P literal 3096 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 Hu0mjfJf7~C delta 795 zcmV+$1LXYn53vT2BYy(;NklZL|2h`x@@(JZ^0 zliOyieaOR)-7_c4`7$$S&YX(MWmT}0RPnz7%0OkHB@MJ%rGEhR3I%m8n|)D%HW-u; zbuk%sucMks79eJ&0y^qcaNMZ|w^fxF==lLHgqzekZIXf3>6I|vrG=@ZI!;eKT4JaD z9=(8zqwpQW^r-#MnC7{nfY38y7{1VJG@y8=9wz)OOb0CR)N6*x9uu)4R7pS>B0j*h z-vG&zHu6l{#DAXhH)!)8 zy@@oyx6ugvN{qnIi5Lu@Y6H7LVp5BNK3{C60-+t}qdOVcRi_mIeY~-cz@#-kauqDB zaq&Wy)|5{VuY1iI;X)7nczK%xir?#(zU@ubikwN!*SoMi>&`-bxY7!@yqm}_EU6Ch zi(=xKk$(y^Kd>z)464~&ScT3DbbbdztwRFuL>yF)EId93Ut&FEd}QM)d4TXB%~^{E zcT-{biJZD0Drl=01XM|>)?cG;4k&fql>vmF;RB&2!%q2tu)gqtuzs5-ZrK=K5&VnJpUVv@-(RKLx7%dmqEw1;0hb1|MyO-p6DGl2c`%GEk}d Z4HWs9wKCfirC)VK(#! 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 literal 9055 zcmeHs`9IX%`~R6S82iXFveSLrvR0%lGqzB-rBt@;-I0=nY%znRk|-*BV@dY0MuUh% zS&PPQin0yHHrB>`Ui!Y@pP#>fz~_f~JYI9I=ef>xuIrq0UC(nKSX-L%aEfvQ0C>#J zjL!i8!|(r)Y*0&HdrcDnhvdwS4bBH=EROHeoGzrc`!RV({6JV!zs?ADt_v*PV4P506JDR(UHLm8QXmek9KtOKadU8(nmvdq>F>Lk3 z;#DiF-hXNS8za3A)R3fx12KlZ+Z!1(Rhv~=H5>ABRwpX~*jnSDq5&v3003`;06^R1 z|33bIoM4?aHDjT8u`9!U@1*-`w@`nV3(L+SfKR(re#>Dcj@Ye~FSVz*Hh3Ufs)l9}@1QLi=c$$j zjH*6nZG^>aJOJJ~ITY2mlu7&V{7rFS^_UaCyRzP;R>K&U-q6S-c$ z>vlll24o>id^oQXei7B-r(11HX%b7q`pE*kXhGZ266AE9DZzz8apY3I03o9*VTm?Y zcdRKd%?bPJc|JRH%p5y-w1oC@bhn!yGK~<L8 zg~X7b5BHmTp7}X;g&|HY?gqIZP|)AE$S=;r@lpSgsda`2&UXTGD$hJ8UWAxTt*gIU z6``gr0t1fDZ5FS4yjVX? zUPTxxWW$>)UCG}z6X-o`Vf^6U`Q!`h7Ye9KJJGW@41$n%4H^wEDc0IA@)uOmD2i|B zJTxR=@&0msckNQt&x7S4LH^M5Rt zL||R#-xk+7po5o?EeAJ6?kgE?^cm3^^%@+4d~j)$kTq4GS!Y04uG~A3Pxu6e_`ROy z7uKg(6Qy!x0KCu4^Rz7^+Uu6tH8dI1pQ0J~Ypk$G4{ou}(D|c*a3!szLUlyuQseVC z)))&k^UX;IMGN|WrKjdI9ntfN)h?0skw*5cScx!%@{;pybq+xuI6~4| z*yO%HzUW?a!Bn=z#vj|*;RLp#L{IQfB?ut0ky*+47g-@I^@!-Nsuxubd1k_eWn-dgJK&Y@;Gw_c8 z!-g3AFCjXuH+69JH5Xd)=iAQb!hAlLE?o-RiJDwZnqI68Z=G;qrpH%x$1!>3B6G0d zeDdlFuf><~CreLDm?fgyy2k>zjqCfYE>-3QuRQ$K)>Evn_xwefE=fpm$5;RD)pzC! z>KvC7Kmp#w#gO1w0Jg?!rv-5h-wQ6jm#Q6lL2voh=@7a6p>{^=0Qd(!do%8z?X-muBO>5MHaLfh;RJG#3;f{kwhieQ=YLAV1DzHz{$Fw~I({TA zZ>}!1#W|`JkXZ2M(qRZJM|A`;GUB7kZ>2Ia>pSJ~)By5m+h!*%}8#TGr0;1@>=Uw>*@SE6$SrF0`aHt7zbjXi(^Vb7@CJGjQ0d zQB8aM>FP`DGtehgm4Ce??)JFg712Wynji&HX#%*LTYT$K<$G17M#Gskr1@Iaf$YLp znxDr(67~%euOIOFRbY0PJ+CJ_j}Dt5tH!-2J^8b|ujfbpBd^=xQ={7$v$Ntk-6V8w z+vGk^+&FTc14hM|_9F3;bvwR22RrA(>O%T=GFkGnf>pU1bI*;&nG?47fu8zye@1e< zQJ4fvcCia}QjDvV3tFJZ;RLQEO!t||lvope@J3l_P=>%n6 zY`{@$9oC!Zo6gMlD{W=P35w2jKNZ#nM-*St@f+0%o2-;dt&+5Hhvh{fC<4!&`GLeh z+#6N=l(mMf8)a?k=qCF25kXGkbtik>pf9>>n~9%8@jbAwZZ`y(-sI9D$A$W6D5heF zW_?Aqy`q~`@21mVXeRF~cJno-gIFD6EB17`b3Z*3Pq>X-oKrDHpSdC~!th~-C1JIY z_;6LH%;`y5oTISRz+_=`6gJX5#1yr=2lRXgNh$f>yZNuCaS=mtU`W1=y|K90rDoiw zt$wt}iy-?AIb)w9!q&4)K(gqTy*7Uz2o z7_v!I`Udwg`kv;if45JB+&OxbDHFKFgY_R{$0~U;XSdj>QN9vzhmzW@M>pwGl0h)7 zOORyhrLYa=LV@PD54{F%rJCva82Pk|6{?An<-&csgD^tBd}&%gJC45%+X7HDyPBMwL4VkYm1Zl~&meB7FCS?U;i08%{%=U-cv9wBqz` zlDkgnOL?2yp-p^8UdQAdyul(JvyGEza&;Og{<>{oAug7^GT!%+KOwGZwAbm<^%GYw zuF#+LKkQY}uhjOXKXRv{rv;g{Z&L$N!qMr!v_QZId|;&1DbICB^!iz^ZrSzT{09|% zVS{Q%)H*Ceyrx<%zQ-(-x9*%%&lDe!MH^1A275DS zIdRrMXvY@se=4oNn@Eh+vi2Wi!|!yEStAlfjz?U0#k!D)$xWO=gq&F2exuea=CE$1 zM;~w>uKYJX2G2WIRmSIB$Ace2<86GH0bg@&&eHML>8-jMJB70P1)O0a@FP5h7hX58r+$^F+q?9*S)?cN?xNW?=3c!HSO&% zn3sb1vYo}!The|U0mn>lIDL<;P#p{8Y{W=^aKZ=r@mkpEwU+ILG3Y}c37WUi&8_bH zNcWfEG_-yWW;+@LQdonB7>@ay6e@9-*2E;Z2+Y$f4%Z!2Ws%zy2}ss)gG_l3r=RSf zUNI>umk$^8gu_TX)vA3O2>kVt>cX0>V)fY!1g8WD-Fh6Dx!n9O_@EE>rgyaW8PX1v zU30<5%HuAiGNk_c`B9yE@<(JRxF3Qz);1+ognVmIQdST6VCvTq)sIa2F$dQ*9R@l0 zF0tVQpGf^x%lh59`jg5)uNBwtsR;d&IDeb-&Fvf@Q$TQ)UYg;%2BfXVSn>HLC2_x{ zPh1CLazCSPO324Jpr`YbQxWr~PucNe9Mv(aBF;?tC zdh`4?`I$kU|jH%MhyHQ173Jj3?GqLvA=h5rH+n70ibHCkU3SuRB3nwhyL1 zw|Q7AsZ9)?d2K5!rF+}7vt8}?Z8|8DkwD69)F!@Z3i~9tQr*YyzNjrznyB;jC{Dz!ceU!pg$r@A#l*rRfmv)lxW6reB*IV- zI=St8Mi9IyqCK@OY_#^V({gM~yeMQALQgi;_nCZ;5S(; z5%IV5tslb(Efiv@z|$qpP}a!&2Rn<#WQwi2-G6{XtYcOM;V_J&OStDv%Y^9jD;LsV z&$Up@Rpd(5s$G&c_>+fy)vtbaNgs?G;|KhoR!)EmcSj@|f*oqKtW|qi`Yzxk7!1-Z z@6X@8Y6aDE*T3eqc3(Tyvu_0-e*AbT$EttnPDh7$7BZjJnHoh|+;*WHZ>bPS>iS>Q zCQGKEL!cF2((Hy)v;X0})dXsj`q#Hc)ln@nK-Li44RgkRW0{nsG;}p2&Yb`K?_A3l zOR3C&A(zy__)CygGN2{?t~b;4S?{Ms7yZu-P~zymL^g>?Je!b}6)<#pjr8O&Tv`^< z>@1^o?}87DkDcad1HqR5f->Rb{F4J;joR0-!AWmUQSu=SkYVmO7}c3~CX4u4z9&rw z9WqV5?2lUa)(8=5u4EC3HmxGaG^^vnr@Umx0&(RJJ+4mR z{B}c$qef>#GoOcF$7o&k;kwO_<>qa9=E^%-G2lKDcC%a$qkpuc_w79YmkM9bGaBF| zWA(jQoN1X(eTv`R@bKk((6H_DQ9J^ZDYHkPKf}m6%)s09d6GjxM?w!~xIrt}7#M#f zeg7zDEaugxT_7A$!=W5AflKcht5y9zb#iIQqw89<=@J}kizAz40R=TS}a@|IF2CljB-4Spt2M>=xN=M;}S< zxI2Al_oJxTrVO$Yk@$|;Pj+P1<61=K_pK3J&tR6W&UOn$aos<{=p_Bax9Pz5hl5~* z!x*Y8=6=un3e~!bLVuef|MVGQ+}_f;wJ4`r+Z%Qs&K;&Jv(d-X2NAu{!DNTtHqhqH zsXeI`(->@jlB+(~?5gQ<`)(++Q&>fV$ezUyo~-PDl>R8uQXM`y2_+5+ zPnR=(;`5{EB1+R**qqNa=ikp`-1N$)@A0&!<2L=&U%*hiH(XB{gxRR`?!48*t-v4R z_(b_@NMc*Fsj-eSc}`?9B~1J?bmYG~_~>lkUO)_m>X~07Ks6MrAEaqlRY-fGBWiL2 z%AV4LBpcsy<3Bw~h+4f|z85ONWw1g#6@)m6RsG<9mwr<;(;}~hNVx`8aiuI9B~7&1 zZXclQ*Q2~E=NW}kV{x9&2)+JP44Gnd3}OT`Lnl^< zEI%jMCcC7~ zM0Lx6lVbtiV>9qyV{EIWt>Tuw9xI~q-s)(5T!PEiwsn|fV}%eeajaafgX*R+goYE0 z7JAuJI)ZJ6%C6nfn`OlBYCTuoiEhCLu?IG3S+dZh!7OSwdQ$$#xZrdlBR7ii!v=G( zcsXeIdxeg7@{FmzgCLV+=(C`NZp!Fr;h zn8FJ^7|{ur5t7|gx-}>S&p@SNpg!kEuqTw+W`Z}YxZ9c@qg=(0;Sg9U#p+0BlKa~b zn3U<`sh6T_)v7z=$8Mke+@eke+lRo8SvJqHCtL0}Z!mD(lgBj|ZJy;v7rm3>jqQ|z z^~H{0tuh2Qhp_?=Xxu%EpInqG$uUsE-Qcjp@$n18)UlkNEU)gM6Z_|0{<$1@R78bc zd(oxNl#~^RyW$0V*wV~`{~9294VsinLGM6lUE6G*&_J4QCtowAJ$i@1{? zVnCeCl*+sUdE`JC^>ww)O zhdL2P+(BZSd=y>52=InAab!QVeA?NZo8-gRPzd$%!pl_<<$(ts$6r|2^W_ zWIe-?o!!`*Nc@3w_S}|ps^7oiw#C-qOZ54s(giyO`RM65pr9Dj_C0wCehvedZJWT= zR$hZWirR0V8@tfo6&E&Kj~CovwL>ra#YEJdvHAQDLW46+4Nk~Af1AzfuX3H>UOks1 zYTu{Kt}fw3YAWtEQaY{DJogwzA_y#N;R5&bpe5bzhQ7_D9ZCH8+l=R$*MrnyiI9S* z^QR2uWig7!=)f#|rFOLZiM8Z`myjUmk08Ps`x!|BIpkVexZhi`S zqjG(qT%_HQvNyPrWqVDrcEf6vpoQAk>WbjY(Clxhd+|!(cqvyzZE_Hds;)_OmuMmA z!4TMYNIZXD09u{@N7T(C7$G%;YAErUU}MOEzXM&5orXNrrV`g37r_fFhfeWsu0S(} zRt!JbmIbGhN1U+#=-jp80T!Kh=()t|`c$wS14N8Y2f;LIqR#Q)2YML9BFahd#|~~N zP9Kg4C3^!q^laabo^}@KK*QAYq42nC%gMIIzW=Gsq?A%309@Jy(VTLwo+}ZmE;V@Y zg2$tNy#r&Xz?LBxkgll*nIhhAB@^AjPZF%ekr?xRpN=vg%RrluO)-;y;L0CiLv46* zh~JHJ+$GowZ5ZRm#x5O1Kpt8x5f15X}BPiFsu(h@H&b_W2^SBdt4KlEF4wJRb>Lo zK?Zav$rM_##&E)^Ezoe+dvlL3;d;d`c55*wq?O?W14Unym>?oeICPC%^Nh18qS4{> zpJHy(`CfZJ_C;5U_gFf02rFK}o{;%9DFJIRn|Ut1>GA24|BgsV)=ea z20Zf8lYYT~O@;1FvO+a`%Ac|`!>He;_x36(*cJ(lPAbXn15Ri21W5`hYFbg4tK+ z*BJ2Y)|xEFY>Df=&Iv}p1VnI-XU>NmQHZgC2=YlmI&=8G!-?#ocW=)hk{C6eM!+>_j1lxffc+zhOgP8k3NQ8ueMFAka%PWZ{$qVQA$Gx z-_xlG(9Nvcq6)xJioG(ZU5;#}6tYRZC8_wwPpg*@)eGT?9HPM?8k-yPrL13qVs~}V zK9&bAeaqQMdb%DDaoo>WY|FK3{pd6_EDK+ZuGVf91+d1g~bh zlLIUn>kv5El0VQdc|FEIM_=jWw|e}UUIuB~Q)B~)zpIip?ct|Aon&Zpi7R+-ka=To z*hCzO=ML63KQ_(!V?a`b`R%Bs7PCv17lD=QvD4C|J-7Z)N=V*d{k=Kv*o`T^&kjrE zUqp9oyRB)|M%|_az6tS@x)ERjE?lD0~y;#UO~m5*5dJepY&7 zxyIXjhQrQRJXW4~r`SUWHBs(*3QHT;j;G_~_@&^|{-S7$ZJDDE-!G;FzLQ^c{?^(F zoyBTtoDY5UzJFo&)nbavS|acQBf1;K@pIKN>kdNBjEP=|U3JHWV~7*CP9Kw5{?Lmv zrbvbJgPuGT;zgPMdQsC0p5iNm=gM73tFx2P)5AvcEo8wK(f&xScEm(-bV#6SGx_}1b>;@zEl)qYFThU8#i5E5Gw9+ zp{YCS>x}=d!mpr!*<;Rxz!kA)!m+VJIEy3pO+A$H|M5PRxr-1tNL-M9$(n)4+tp7zzaucv_Y z2~k#yQj1Ct%F=M9sX|`)U}Q0KogbJzbudtjX=Y|zc(-L`>00amOx>sfGWd)Pp~c&Y z+eoO>&ImvGk`E5dZDbyXe5rmsmE_^gKIdt%(#sCRD|=}i$!~KL`g~G~khAF-QkzTJ zO*XOVW@emKg9H1s+0~>&eQCzyF-@bk&#io_RrVQi|4y%v8wzwh9T8Nzly;uut=*XF zjs@Dh6jz`rI9WzuWQnYFNx=a6d{;nX2Ir`DsVt*@$9J|#sPmw9sKnx?*5~oaJqgx- z|DkboOM~xeQQ1ik31!pu%y)EZ$!VX0o*=2AGfC&hMCA~4CmIZsp>k@=lA%2zklcVJEw(X##{xDIDpDg z*G%?W-zDNN!~4$zhEI1yh}3&8YsK1yM?PM;sjrOya&&&3h9$OS%vH64q`nRQb`f0y z9`7CCKV|y`5d{d|+bqcQdEfo5>_@Bj_t@IYf%t>W=<6=k#w=Az*Ekn~_cop*3i%X$ zD6UnAoQPH`fq!%MfQ_Q*ZF`rm>#NZ`H4xxthyC4%=}WU3KS7lN1Afq@lG6{6kNp2d65h~ZUy=A0Tv zR97J)G=I0Wvh$<;>^sm*!Wha*(?#d{4qZrePsaR7>$9j>UDbs~lrrE4R!r|PTGO4& z6NVAj|K%mzUUxlHr%YsI&Cnu*01h;^uwwNF{^yxr@cyQ4R8*7|GU1EiPy+28U0CZ# zCKCiff`l_J1s+M^>}op|5k(jaMBr#W`1mR?q0p z<#~9)BA$MBDdm|Gob`x66gh&xFk|{OIy$m!-M)w2dm3pu)J7^5J7csiI`fH$_MpqF z$Ilb`e~dA-w`Z9=@qAwT@pt&bCF5}}#&>TL_a?G+jv$fXhHEZmUtcs&VS_*k%FoXJalIGgn7LqDIzE(P{8Bjg%9!B z^UpcPcwgW~TyXU#v4V4+l5~>Ydk*sU?;5Dgl(FwX3;Pcq;_G*Oi7))cg``tSoO3LF z?Hyivbu~BNcqL!D{xT{n%UJr_8cIu2IPd9e-!ZoK_o3E?wvH}l-f|sNF1~=s#(%~; z=djix^uPzKb@+b66z>a@CY*~>8s|Nk@>1Sf+rVXCyocX^xT~l_x7~aVPd`4Js>*V# zbu4{t4f)WL4=q~jf^_%qfQd`izQW^4r;^9%dR~Kxnjx$8V??3 z&&N%q(@7EuT?o{%DL2vw10q5$m*>23BbhXQEFfqd&sqE47M8rSicJkWXl?5xolY|4 z;?MEVch6u{?XY8<@C9}Mcz<(&Pa$31IUf9%JNU-erW09gA*0OT-MrQr{_i1g*VvIg6+omswl~RO}V^nQ5$z*`@o-njLwBT8u z{n;CY`H&z;ptZt#Sik8HtX|W=%RiY%?T8_TOi8@)0l$5_o{nse$rI1#fmyfl#cMCY zIgc*DB9kfQ-)7C^+|SffUVoM*3?qK<%>S`<`yP@(g0W+27&f$$Lq|GDh&gVbFoNon zfunfy-lIggf8GyS_}nk48Bv9lmEc?)UqPrSFXi`JKjP`cPF#IF@sbpz&eK#DRf$%(Y3=ET{{ebch`T&Kg@oD9lQ3$n}3`4*vOK}l#)s& z3vgC_9OzR)cpwN8?A_Olh_G(MHlF|A71Y)Y#X1Mh7houOAdDQPB`G#G{IOVjXr*Xs zZe`B=XZhh1v$^f2X*lP}=OYpc-HR(n*M4{XhunGhf705XrDpih;s%L;v+>5(U$tY7 zD2^=h>DDg%K|xGGLQDJ@&7yTx#xlJGyCpaxb3ECMY0A# zd@hg=BcjOSy{EmSi)Aa;v*7Wck?qP+UYaJ1Y@cQNm+JxtR6jL_!WhGx1MFu0LMe6J#-fxWa-MU?))uN##iQT*Ib+Ava7W#>09fZ(`G3|XmaTlBox2Zk=x{sT zxjf!Odqo3m7I>*l42Uzt^18wbDv{G#NU ztC%{ey0jk$iho5Y6`3;D*KemJl{`wKUh_o+8##tlWw`kB=K=884_;=*-&{dD70}w& ziF2N>)LmPI&()Vt=}Cg4rI$GI54os@rIU)$y-pUzW9%vrDjNmb4FEj zu=xmEw(a532fvECEP+tSrM?ciO#~Ih~^)DM;>trXw^SJVJLa zB$Y}Q%L6IMt+ftgG_7q}p8nA?(&<#(nxBEbS&34d>I;I=nrv5|g+E!wxG^IT;h287 zM?&*q#D6#EEM(V52T7-bg08F6eord{h~OMlRg|%4@oxwc2}airqp78p5!IE&5`2H- zVP-%43~#P!pfXcRWZg-t%_(x=#Icl220ZoSWmFH%(3#EQJs6`|zN(%%kN%X8_qR}0 zQBGv-X_5A%ROn>RnzU9VeBAd|l$Rg^Pb_?u$A6z%iZPnXOj*Hr#hD!D%tS*_O3~4o z<@Q^yX6TS|=0CoKCl|fO(5mvd@p1l)8-K#2 z`IKFdiC6z?C?e)ez$l#x=+1>lu|L=s1>Q#nC0mqAOmr^vaO!LmAlsEkDH(j|;GI$l zu`b+Vl<0SqHj|u--08u|V~iT)3l3kXaDSaw+9>bcI|dg%>74pIrF0O}QOT+2;AjOp zi1)8vM2mA#H!gfq)xY^z1GvQ|C{-R6{i$=vv*Goq$ONSd+~WE-A8Qci&Q%jv=c^Mc z@qs8+ZEfC)^jx!Y7To#yUcI`rS_j%Y*A%*NQo~h2lLBa1@!dw}qAvr7jY(-B6n~T# zPT-7w&>w}MJP;OaJM3Ka<%SjCZ7hbjuC7imS+c}lblt<3YLi&1O;8hsU3l+op>b57 zdLrVxGY5DPrA;D@jq(R<6i(l`eEzx~n>E1Z&6|B)-HUp~tM~35dH&_kdryTzU7$@W zDN3X#i*Ym<^oaM=Ps$`j>!cFzvsKPVKZ4xM4ZmKnwXW_(y=2KF_IQBLovU~x{wd2v zH+*|?LiAl;+-2UoKg-W;L{zB!I{g zz@Uu?0t%u9MG~+Hl875btqVK|FOs;#gv2Fri5lWAfnY!paRKWReLdz(n5NU-JMEO2 zjwF5f(lU2Ce80Kpo^$UBPz8m+a#n!S2K)d@8}I`tZNLwpw10tR1E5(Ey1W5e)@mR{ z8zwluPEQ|K9Kf2#!0ieh5h6EM4Uc!|pd?W(F8~u93bh&Ga4=KDsvyDhe~18R@>;2u z7fvziP!bkC&o2xM@pmNkjO?;M2Vb%c}!01Y}DcUDJ^ zM(`P&$tR`=cz?bp3I;2p;e2rvj8;e5$8dHe2f(l)y?K&)w$R@o8ho=a2BzAQV7h%3 zPZQ>NJBZ`}81p2vlLrhibvTjzbi9tC=KKr{m$7D}i*aX-ROv-$BDVlHE441>r`wa^ z$3Tf)GnRDw7|xb4oS*YsH|pJ*N4;)I0wzR405n2+`+v4eMLYm!2x}ofl+YOUt=Agi zQDeOQZ-kW$Alne;VjjcB**qs4s&S zu!uK#Y#vpW5#hCYj{r!uKxiFcMW&hkHr&j{J!tZL6db2s1{FSq*++77I*}Jp+f>dVJKm> zmVXt1HVf}sjJ!~y0nmyT3gLVLKq_k7*?@ysu;zOOAodL46#xp)y|mLAt_c*WfC&M- z>Ti^P5xWZ|=dki5193XhROVX%+#(A>Of z$^4Q4REtFpL2yP;coyc4l6R^NZ2#w3jdPyWm^m42_j_66v%~E$)|JmTvW{kZ*AtZl zfF|s5a?L77y{wOh@zxaL7`7Py8vrzeT{cI9*#F$MnjwyP(^8ZbfD?A2F+4~i!d(|? oxhoFf3;6+*mLEWA`2m#HKf>U9L-MBTX#fBK07*qoM6N<$fr z|L4Ch*Tr?l`@Q3N@8^E*=N6@{sX{`?KnMT;$s^SVIsgC#|AYc?Jn+|n&-fVtB&9rh zaQ~^#%x0E%=F^qKp6ugq-e*Sz-Z0oLN1h{#Z2>L^b^c1_*q*ha2eAwx2G657@4e)& z^cForCg#FNqbm-oJZS2+5B%qkk9)J|y*~F>HT~R_pV#6Fg*IW+{fB(B-3Q%as_yKl z+XN|ut3}CFwwEl+_H+an`N)jOd1}T8ROCJWaKM>EU-Cw0d zsv6}$*lQ-lkZYWSLj}OWUBz;A0+Ls(U-YM%Q@8lkj^igYiNb)%p+U=t{Z3K_x&p7m zO^$P>eAO}2{zYgS4<)OS|G9YrW*Hk$>Ts^wDQn>13#0^czzAW(_(rR6d!I~&M_kUl zrrYl2_2*LT=zNToHvGwTW!lA8{ixunC-5hwA=JY>k)kgMKkDDb81X!qcvCE9uD^M* zN<%+rxz2$b^E)hoK+%XTB)3cK3+s-IxBL*RC^8JqPDX=dg%-p|Dl4caa-+Wr*iSGp z+}(B6RQ54l?G)&$8}bttVM8a>P~(i(?NnV>^C#YdD=IplKLcv&fC4`D3Sym$oZ7T< zr&@%?Ijtl1mzKhbQN@j{-!q+8RN(A!w8-pFALFTzN90K9 zR6{uF}+E_542E9%m)AF-dXHkcWc}O)k8vIzBw;?jDOu`&@fs=5VQW z*cdu}NM_Pmkk_oKqmGvbh+4@7=aexX5S@{3yy!Jm3ZGbooYBF36Udds!fi1=B6-|60?jdgCE?$AISb>KZJKo7kB1Kd&7{%qkGQIh_(<9`16;!MXvo3Q@Kf?K2}#<>k#F+tic?HUEN-Ju zx+<2e`X?tf@Vo+oD7gR<*{xUoY(zvt-|H^hvmHXr$4SnNrh$~K@A{k$2Waw93OPKa z6uy8x+=eE!U^78;{(`jsy_Qk+@?()!FG;Fli_e8)p6ZtF@nI_*OR#`T~} zlJN8|GWXWNnC0TYK{6oeH0^2p-VE>6rdbBkn1}_5=&XdC$(gDH!xQW07>aHAYvTu7)=Gq7^6Y49Gm(!}9pvN`Mur*eT^Suc8`K*;f>UL(79tQkk&s~H zKdJM}G0URkwSXY)sE30Sr`CC>SRfK^Pu&8Wd4j8MXVVQdbFy6=VxrHJ{v zUJqfb@3k)?3~53q6mesVnE2%Hr)QHRXOwT}P{Z=c3tg5_3?*VLrA8wEFj;S3rvuhL z3q#vpXKYSO*MNxt8m+(EmBB|mM+Tk-mpgOT%_N8mtXd4sY;F~H#C=8;zY{y z5_13PV$kQW<@w%Q1gVztq0)qp|?gOT`7yyRNe? z{n*g+$s1mmXOAT{GVGH`4*_TPy^I2)q)KTtU10PbpYK`fC4L(9tJs$)yV#;DEK5<= z-S7Za)nz-|&Xg*{W$+233oMZIOooag-qL@wWeROCpb6r^k(PLfz8?w;H6~L_5cc1! z6eL07wkHJaXwiwsitsU2Q9^kfrR&<~D-Ffyyqv7RFHwo7MOV*pQ$Ago6=e}{UUTRB z#!b9~KCNnxZwDu(dWSR>2~~p_5&f0MBcwgw^B0PKs%fCljc0!s5Ew+b_v*Grc)hvg zQfNYxBj1xhhTBU!M0soD@LT_ZW~yI&hGW^8xADuR+|9<{j>e!d^-b0*};;-3u zeZjV_WtxgTpY#2S&YN8~;)A|Jm=@NR(nk=Qrws-|ZkvV$Yu4+=3!C&5A-@kAO=oU_ zArKm~OPmn#xpu~`cZ;ZGGEn9Yb25aCVOU*sz+=2{pCJ@Hwm*B-nj|xACp8fC9ncfp@Tn1bL8m~L9KTo%6_om=>KDf@e%Z?5eK9v$n}Kp^vB?~ zYtP}R#sA-nNJeeWfph5{!m?(-VrdjkQv+;PkDuSX*}p7`rvWK1=WH4 zp(wkY75&Y!QL!Ovs$;`<{rpagU-j1J8zwk@?Uqme5;|8z9PaY1sJ0Xk_FQMm9063PZyBmFkCZ4rGU3oz%@YL@)!1h2g6 zSuPtI(0*w*`LUoxUPz|A-9LvKCqwd%-~NxlM{klKk-U%T%7R*&KioDsJw+FK5mITG9t&-{ zPJHf2v7)TL+B^Zz5?MjE9MX|8qf?Yxdin=bxQTC93{wO;A6i~ZLAsXR(d2@N)XMT@WVAJ>+FBW#Nv227#PXF6O5mE`b8AmI_VYjLg8j5x}SjvjJDV2*S)uS zubhhmA-R3|*EoaHda71TSvT8~@WPGo>5;t+Uq&Gn+p$~--6tZDpOL(#rC1KiJiM9I zaF^A&k%MCMLKimVoaxo^JqS-yn`*)Ghzi>B$hGdG@*j9_(4vP=Q4@6(5z{KYdGV6^ z?GN1)-U3XgA5FJKw9uA8Dt~XT$NlIOsD-ftrt#&$@wpCR!ujn>t0{!8lI(0~NjMm< z33aA~Ko0wdo(ju9Eh*5Lp6sh^-Eg-yjSdg<61~i#oAAp&U2a9`*CA$21F3UGO-|?k zYN`uP2t0w3qm-TF_xIFwOHt%shmEu$Ag4kO3loK*hld+~R9R<(H1TO6I^6?O#)`>we8Nrxpwwn* zXyN~L*iYBCGaz}KFkwIRr2EZ`#wLe-^WAO4OKF)_|6;;Qmkl1Ao zSa)tZ6{>5>e7-t@fNuV!O;~I&IKA&zEJUI?mf4M-rxM9Wg;W)^asW=&A87b3L_Wk4 z)4X%@nV@61um$E0keT`Bmqro5adCB?>7SJH9I6p~bASiJ_&RFYCUr~wQ6zd0>rSof z&Vu11mv=V+kZ|BY;PU*yWm=nBQFP~^oRCb?|Fvigz0t__&3QZ$p;jcYSGB>Yye+-( zdnZ!W>QAjGj`aW}xSs|O5NUwJ=VwSIK;T&Hu%rNk_U&QZ%Rzd!w`oLnw1L+*zzMR18XI3mFO)~p)5N&00Ggm!jG9~{W3e~>TR;Psc*=I~bt9v{$249qxS6jDcjLDYEh zZUTTKRu3phNhu>W!(4r(Jc*=_<$+}8XSza7%4) zvtEZ&MA)lQUo)fV{qq1g&=`1bX(%mGVFI5{G`_ys{Fs+`R7NEOxp$|$``POAMt!_ED% z&L_^n6?HM3ooZ>KK;5}MmMx;hJ|{pi*=b+h+(O|pVgQoEaBnvFao@9yIw73zS$)?C zmv<>;OgtK=|8FvvAJooR?HMU?W^J`F3Y&gG7InK+OzDO(B#nFk7!r794CxgekF89n zE<*)JOK2e9Est;B4UPGL2WXex-pi?~&35`^jQ437Ct-?tGmeaUG&ygpA-3zv9NZ7I z^^z#6E`0Gvh>SE<8~0~}_(o1t?(gz0#{3uv9CE9V zlww7)z@7MJt|`DV(w&HiEmiGmJ&3+2Ra|5kRKXJ9JDr?>>8CCWc58DxW0C)nH9an2 zxQ+&SB=Gck1?5F#zY{@QMbzl2%^r-Mgh0U2OcWd{UoQpX$WFqWFZ2 za+M(wPa<6lA*KZ^Y<M3kLeYU-iF>P&ah z{loe{BNi@h_Hgu#D8NaaYWaHVBk;m%>H+T)V?IF9-qj%YcH8OyxaAoI%ovifk>UX3 z6NHr2gVpz7f=`!WrA~@=eT2Zx5XDQa<1=`onr#W}fo_5-Q|~-EdIy(f32wMoNUiPNgN0<8>p3go}|A3-Fte87)?Uu!rwBVAdR83hdDx z{+|Wz!0ZI})Gc3sB&QgzgEqik=l{jmfBiJ$E*r%))~sfl@r8)pdb^T=-xqW=qT@j61Bbn`zC7s&T2HjY5@v zOo_0WuHa{M?GJV0Q{}aIkB1ecb#sG$AKdHQ-e(h%Alf3_Psm_K^)82EoNg~1x33*+;n6s&yuOv26-^|82-=9d`zR8)DX7&xl> z7^+{?^1br<`M8?W#zQ`}K_yk8~~kJ`P!1NMK{0e?95i zxIC+Ky+eHPRSV3F9XEfvR&x;;}Kq z5=JHsC`B@%?~uxoU!HlWfb(gR%#z^@aS9e(_j&ru3BW$1wpb+z0oV$rCbYC8RXHNw zaxx;^2PxFAT;*R3nMWjvG2SA6xZswqN#Bv6xsB4v5m$si>W3*Bk3H-SqSaS=z1d}z zAO?&!owDwh! zB|(%6gDGJaJTbW4B?$W;CNUY3L<-d95A-|*)W^{Ct09(j+G>^R;N!B76yT6jhX=hl zLZb^(;i9+K97wvJyQ1$rQRN+}_E#Zh7ACjN^x%kN_Cxr2Z9m;U=G_t<;S39Ggq`(p z;r2y^HsVAO(Ux|gw{4dTY=+n7)#UE{%!I?nQymMsLq5BG8vfo- zTHH(hBf6CyE7Ej2YzxOoH3DPaKeJ#V*+idXbxm|ice_byOQy)AG{i+RRFyvt+$GnG z;r5eLU+yLF87fVoSmyWp3^R67C*TW0StR#OGguuH#Uy^yMho6|W5@!Ft!YP9`hJ19CpzWbL@SQY7fh9gqoK88& zOk7j;54p*1iHOVDBb7qsgCy=iyg<=U>N!s@9mHNO5;h;4t)BcY>EkK?q^#)$M6l~> zB!ZbXUzGW=DEJ;-$tad9*%auW!jFtz61*!azFcNY$HC5ziaAC~M~ zlw>dE4RPg))7^LW0vHZ^*$CM44JlBX>cGrJ)O-TPqAJy*D)Bczj}9RrrihYYcT&H@ zwVGK1{m{ruu1#uGM4~F}#h_tmZ?-S(e1Hsd1l7)YAQLMG8UbM&pgp-$Nr(IhKRf&z z2Yyvt@Ur_eZy3vmBjaLw?V>Z7{N+nwmh;J`wDU1xYui)H_jvtKG&vWLZ;v33^1&)Q zIl)2n=n#9B8Rj4;LyLPlje!O;ZY|Ac7li!&WR0L|Z#6q1zYh9qHICQ$y>G8mU%Utx z&aMu?8KioJAa>wBKQTxM;|ucSa&Gyzx`wSpP@hbX*9TWRz16|_BVZ8Mda?CTJyVwU zs}w|)*Pi16+!CsI3v0Go8>2pqad8epO8s&VmSl7IK5g$ zylN->z4}6`?#CB`O;KOrd~`x+$$Nrncj%7$9DC_hc`GVgQXFZ zS5@OY*KZ0_-Vv^8(QS3&1NXx9tWRZWCHRUy)x<+smc_fc=cjJ)3&VSuy3nE3b}3t? zu+R&pfb&^LgF2G>ert=z9LD#TPkhmzZumJf+QR1NYt+tk=O;o48 zh86rx10}$218(blbX2xn?3|oT(Ur09L$MbF#&150&^({O78o=;lBm_E++nDgoTQya#_ltyba*OdM zv3oqq@!26Ap_$^@Fjd}L#Nn=+iTKN%lh^OoAqbMZxpGX!XJQayyP9$P?}7K1-?)+n zT(`C%Gup=RGYkEgD427JdD#oF_VY2x)*DYd6u~EtA`dNb-s*|1jWzLo2s)w34Cq({ z*1Z#wC${Lx2~}J#Sy_8~&4Y$G0F)&i2n8LrsoBxCESFXtBNVDkzrBqJOG-&B_ur}U zo7LfEsP$_%=fl9KRyZs1iog1-D9Q9t0}*2StR{yTaFWAwl;uzdRI7i< zfLr~AaOoLsR*Z_u+-uUUJqwK2H`4=H>71H@T1bPjV!d=nNCn;U z1-KwIW#`MwCp%+NJl~5bEL5X@tQ%q7&sQ2&uLUDV8E)U&sT{-WKcl#s+id?-M)2Ih zS4{tlQT$SO$vyuo;ftru6r2tcAoFS8aP3@mSAw5WCWxv5(qs*3*z0zPF+Mr`E}o62RR4Vqf0ibxotAW5>pkf=e?iZ34}tMfn)%{rZ_ z5<K%MLV9&jVb*q#F4%UJ#);ZL7VEabXtwYgt6Fn^T_dCD!xj^TqC z_ZPcdm0yQ;_fN3#EjaA+g==&w8a@=$l(C0@2)q#Y*cfe@{&ufsLUXU+NxA5=DlZY` zK;c`g0m6{zcPVXKuI=0V;~S6J(1gGvxy+X@LmN&TvzJe~ugJ{e8E>lUDk4rrcJiIY zlRW&zvGQO=1YL+dgVR~c=NjdA^O2nW6HCz}y2I7}+y~o}%t-i@+D9pU^GQ9D8oU&J z)C5RzdR$e=Iye;+=xkT%GbBeij z#cvAkOw}PHk51`)EVN?IJeCAE#+?KRc1~qKeJSo%$qtTOe@`C4;;Mc3<3!`>nH?Ii zM)p1aa1ba@Myim{dXvr8rt>KE-#Q}uW;75aIL@ilb&n;`1d7HIqLuuNbvDa=k*|8T z%fIs68{&VzQiC)y-?N(*KVumtF)L>@kG;GV)LK6vk!4@H=thswpiB-*i*}{7{pfwH zDVgJ)`gAGf%elmHZkhG%1=l8*shpOEgC*UB?Ch}YH_viC9B`Y>w0&l5gHTckF0tZVQ~sC5c1bA?%w5dSa74?l#bM;aA`6HbNSzD zn#EF%<` zTzFTNp$NDLjSm$S7L0me^_}o!=MT~G5;_mRy=sd&;3t874B^g78msFm2^knag--Rp zIzf|aO7%O0TLxGpf=)T!nc3BF#J*uKNo4q&aim|-!8>i-^}A3pG4_kY?ec|R^{o-5 zPje$JFz@Z|RQ{PlcwzMvvmv7Zt_2%4kgqbJKofn3Z=n+-G#j}bDSVP#xPf2c6n=Ht zXyxEb&2`-XKw7!C-v2}c{Eyk0;tJpGNJ1p+H^!RpPbN?VWz&k)va6p2k;-4W2~T_x zdKl^D`#%l2c50H^x6oR%m&HH;JW}RSq6W@-Hk1-$SWha4Ru8 zb&JDS?G8iiN01vzQYi7$XmN^bx+#XEv>~dP1+eyT`AXb;K6)v+JJp_Y`6wA*61Yi{ zpA}kh9423dOm3-VfaNxu^Yq%3JnJ`qkDkrb$4^WtLvCsiL~R?mN~LollhHc7NPM)AVx3Oa$3B&IP?Zim7a6A@4elq^qFSh zH?1VSfpAX^aw8nGcp{c~46g*w0547ljLq~<-CG@>t?4G$BYPE4TM@P8wHPm=blobA z=l+@h)QFSUxt{3y1QRQH^eOC~%^C7%dMmNWs^I;;#(_^?zC4tq@YLLhSmeNsKwHvz zt}n{Woc#OQ?(N^d-~G`h2Jocj#Kfa9N5F0(7Exw^ejyHew3`tpBep-$J> z)7yC#h|sS@@2q~F(4V}xAihcOKmzqn!Ikq(x@7O8MCR0q#}u));6A(l!;cp{dYb^1 zLILh-_t(@p5?!@5(T!(du?lD3`5_YABUQv&-7k>Zt_?G;(|34xTjHLC(MpVQP5nfB`&8Jre0TMPWxs{S`RhIya zI;4&&vJnAkyE6RC_vDFQ^eyj88gJ|cA2|J`M{OG3vf=gebkP^hUuvW-?STlY*WChX z5h1i68gW*$eEFALiAEE6>6<^!o~YS(3G~}ztDV-QJw27w6djG%mk;Q^>(AmRsS~bq zJ)PG`kF+(vf-Q3mhTO`}+ZL5cwQ`(9K&xKs%fPkCjmXRUijB6dy1NRS-PK3w#S z7R(P6DNZ=Z!U00_QplL7B}QESavh&|Z4w9naGRijO1)DR_j(+!1?|`>(DwJi-9?!9 z7-B(~q+wPN{PCd>dY1L4!IC>AIE|(*5pP3qmcktO6LJTjTz;P3?kmF6^|MJ@ z-v4`un-gwx4JtP*v(ii7uIJ*Lv0t8cQm~&Z;jWF8S|M?V2jpJPH%td~?si;XZ1B=a z=FpxUZ;|#_G4lV4-je>~eW}*edgbhJ=4V_#FCRSL=U{;$bWV-BL$(pWKO{>-{Z=fk z)q->B9d3N@3dd6_m{B@(*m42Rg&er1h+5%yu8*oWYoNtB^NcK|s;^1B`~3Mv&!K{G6eu<%)?IQNuV8}RoN83gV{_md78Rtf`%{r zCIf+qlFmc>6~AF6DM$&G>~Z5Uxa<#S0yR%wVxtKGqp~I`x;IOoBb|Gui%3@7Sd;MD zy9xMxzZ%jVPIIJ1K(d0Oi7DhD6m_LLrxLpN7TQ?iC@ks+IV}7!i}^?c{U@*3gUs~)b^l_8dU4h+id z(=kbS43j3a*gGaYpmz zb8lw+Vl`HJRj7jb{o5^8DwaczA_doLH z4B>l~PT}Rf8l6Pt!Et@M!Jxz!VM(VBO#>3LJdGJ6x@@;)(Vh6mVWpzP;+Kh{Mp1m> zZN*8kl83o6+~^@*_B1JvNYcSV#LVZ?z4`1&g2%9lKroe{ zbtt2Fz+v@TXIrD#_lYV`0XcE_M{1W*g2*MIdh>|9NT%`MWnq1*hI^~q0g|N0au$0oB}%)?zNS%D?MbXXXL;bh+2K+8qT2#xYwV;s{mIbUu&l9NC)5P0L|v(TQ5^-EZhIE_GZ?+CXfcDEXdoe@B<5NKFT+zZYih*Z&gMyF5rI^$ZRvpA6fbA879x zu#pU$B2O!<9J=Ew04kREhyF3U&Q!2B{LW1V!PNUEOIBImxjjOM6|DZCSE+lf!d#Ce zU`H9q#;wngH+nn8pI-;yvRrM{g2cqM8u!}z^!eX6o<9EL^`N}L4iJ@M!Cu3a>sOHz{cv6VBRXN^Vsjypir6aYEWw0*4 zPJRB27`rar3(GJic%&&9lOf8O9VkK_#OeON!3$OywUMWWH$|1+oqC z;ANaSVMr(5VY_7**wv^@(aWv63ojMhbF0=)NHraL%AdX_dhD*^P~=(zlEzKn$BVlS z5Ed_C@W_5laP*S#;)41hSOf|MdH*{7JopkR{Fy9=O@Z<9{7A8{QO8GCaEks!yWWXW zz7_w6{>k~+_R_LYa2v_9tjOp=DM&c3)RbAzoP6&i+5$HFrQ{115~bWgOO_ehKU)Xe z!xSN{&VAnzFi@ck(Q^=QUv|B3uR*{zMiFclTDXy0_qHed)umoBO;1^HDDegkfNIBQ z1u%6Y$CiSbIXwA_pnj}hK8u#QI+NABJY_!VG`;SK%npqFg#zLHT#W(v8^ZVGK z{y|QP=IJ5#_4(CFODnwsPNH2*WY$js$WE-oo1WJ7b@D;kL)A?0o=TY$U&^FM#0*fI(JZqvx$_Xykvs?d->WuxAjUyj zX_de}2(!%ZA{&ZqQe~Asv4PrJ%NP~~O0#l&&EVTs1pdIV_~rOz5ZS+$EeTJfBp;E4 zm<>x@;Nb5pHKeuvmuSV^QiyTqiZFN%012q}rpW|LfdrnoK-8o#3hZPoN3g^kU!Ono zs+Lf^VimFEdM9Weow*65=rkihE&I>k)~aHfNwXkQQpWM3zB-p%RSL28U{9($LSx0n zR{M^Hl4e<5sjeAwP%DJJIYH+(O>~}aZ$R_j&+L(pf%5)7ULsEH6iswy{@xx-hP~DV zn-IHJOQUnX;%mFjm%Vd4z{UEQBxh+AoBER!T@Eo;deJHWcl!`A(DV!fUuJGJ(r~T5V zI~4qS(-G_dQ=;mO&+GXa!%w9e{_VNjg*-B1KJ}e0Jrm_G0Hwz6`71Z-v!j1M8m~Vz zUKKoes3+k3b>AEwo`)a~OQy&nQ_Lv6eD)02weMkX4oE?H?PC%qke2u3s%JIoTMi~f zIezdtxc=?tUY2N;eCv5F_u~o*Y8?_6^STBVcHqs$so?LnBBMst`RkrEJDd2QP7ed3 zjFxbtNu8Wb{@|sQ$+P@x7mkQIIqAh$!KVVuGL`(5n60Y=^|DsnhQ}8^fQKAl%*+e629X2=*sW48uyC zgNU;Or0Bs81@V=g(+28vdfvZYl*B~eWk(NQ?{FoQX99aP7Xw961K?MvLm!3WqD4<1_|LZPfFha2= zsZaq7_|P!?gO1DKmQY<8nR^e_1eFL?a(#|gw05^s6&@m}l$|w!cLT`^lz_{D5L4XJ z=2$%V5{er>`ok1r9A6(kN*IxH(mU_`03>VAjV&-O-pP!(S#8^2!mj+V9i%Qh^0v1*@I7=2&qXxrON{@sVVO_U@h^_kUg^Rf;Q!=0<6 znkHrpWAd{o;`30F8j0p{>2D_I=wts@rz$$cUNmoZ%~;}ru?o{sLnCi7i8lz5vSMk0 zsS6b&f!&4`7?E||ti$N~apqWjPigD9?~ma^fk)?$4t3y=J5Rcvsadw?{yK5{8Gmwy zp$E^MI-eX4YX)Fimj@8GsdlNKHw-CQd){mjq8{YeZj*c2E^68ltK7T&w8?4{54md+(h ziW&&r;(P*T#c9dy7^`(B%{%#GFMzjtwNYiSLexD1D3OwLSI7nPcx+5r7N)j1-xOy! zilK#Evuas3=|Qo{*Pvy6Dk0u>EH2+oj~6W1X+S!2hbF#r%dLIG^Nxa$%txwEfsD-5 z&zpk(osevFk<|reirR5~hq2w!q^NBtkCU!bI6M0LW7=|bop)A;0HgnXT=tn-NY$Q^ ztn`I+rq9oXE|aFj#i9U3ha!nig!OaA?0vk4dG|m$0{vB?wC(5D#}zKF1p4LE=+c#6 zpc#NJ0zJ7LI=_8i(PyJnX%2JGj{U(pkY6hzZD&m5SdfzvT1EK{FDfDXq815P<+c3Y zr9i2R?@6YM;iCsNPNSb!#_wV+dszv2A1raS!z@yDtk*fdMiQHIu$p7uRcISNA_J9g zdgF5m4ICRV>i@l|-aU5^QpJ`lm#j_DA`9Q`Qwf>H{&jx`c40Vq%ydG3ab`RLJ0cS| zvQ$0hLEE;5n49wk!LWa6AqSc4JDWS86h{-UHYVGXwhZ}_r6@>Q1bw0fr8*z}yjhdeFI_}3id23E)H#-_<7WJHI4fzN zE+XyDyHhqaCC6+elNSxW{i~EUy8GU%cC`SJAiSWoI&vb~a%E8N1RXL>3tnFnQW-ya z$BXOFXuYG(2~p*bP~{+HM_pj~54L8h@tLP^a7Z`ft^%9#j8l(5eGmvi(VE5klMU&F zgPnbCp8stUPmMyVs_ya|wvd)gI``a2Z=gm4QWr2)btX`N?K$WAbe;_ay#;hQRFxfc zc?_3pNf2+&i@3Uf7pL3tyv-H;KxH=$V&)LL_l5z?p|6#fKb4Vu%&o$!ZHMQM&^Z?9 z+)z73qh67bUrA zEdI8<+|A~84#U_KzWhy3eL|T`sJ!&eprHX~1=^QWt%ABbx;a6p2TuUR=HDwXoSIY^ z+5c{T@wkmv#8!cf5DotCgjp8iU#?dRE`gbE^1>(c{htow)Pe-eNJvO1DF0e)C#L$)uyQji-Pb*q8&= zQJd-4#^TR+B^@!JNw`*jBnMK1^p}G1ZH2K@AJ^dq6;! z!R?k)FTA@YFBe^#77iN|w)fPO)0)g< zPPtZ10SD{T1d(L_qO+N0=x(ZAFrZ`{`K@jbKmDBD_GeVh5om-en{G*(mkK&g^(L;=J(-J4aI*I?7RB|MU6jGyVVX#sNU5lQE@_MLOt&xJgRYxHdAG_MN>`Fn=S znbMPnMHYY0H>q$ppD)R+O%^%y#GG?dF8tpAom?K&CL$F0W_mxhT@9&yc zp`{pN?@-0v7Lq`TXvtHwHZR!Hd0nuBE~di|BBy2;^mZerZo{1;Vi2A{b^AA(a5PENXimbiJeo4z)nxMkkAEvkn3F3+FWhm z-CGv+z&XeL)}w!P*r09q@BIU68~DZc3^(1jwySSDJ?`F1D6=dPu9L}2`6AE?uzbxD zz8#c?Z?6iwnDY}1()fiOOYsh_P972Z`bSUP}KM1miLmBioGi}iBF7g4!LgLlMwEp0gx+$U~WH zuoKWRg1>y#+8S4{5*VGyq|grm|9S3T1ha>Bc}&^$r1|k*b+HSC6Z>31Fn>$-86`-U zE#Fgs1s3k9RNVCIQG4IBl8o0N{DKxW5d3}qy&c+JT%BL8GXL-qc3JSq7_5LCMDrZxD{aAAom)jrw*0^j%Z1H?9xXD0IXMmWqw6g4;G+1dx zVzK-_v;VkczH)`NIPsy8Pe7Fhti*4mlAZnixZa^SdSdh0QNye!o$BY!T*;T9rX;(+ zCa^YM30$8X^ZXVjuz*$!wJY0$(9M)dcin%`W)2eTV}*3Iw*o^v*m%DEf6qKImC=PG zu)qQ%F8nzdGK?U`0c#9+_-&u{)A*g;RE33q3Ri@wW&y&mUnsn?{$B}5i`fVPgKsau z%7xbB{iDwj?4V<-xTN!BIhNL{%k&vi%~o7?mhmb{6HWcLwqdpXdZ}}$CDKxr>i23} zmDALDg3~X7WX`Z4erM3_(|IfMsPo|}Q#{yWBE~=3s#Dr`bTF`cK_`63-*9{#6y_rN zX4UMJzqTq10kO+G&n`_!UcO=Rx^j?ox)X`F@0PR3xEE%xV|%NpH*T1k9LK2>tB@Cq`a3=ym_Apx7tbL6M-iy|^#^LMFBrHh_N6R+VKBDenyz zQr7CkX6r9}^e^whwPj6R=d=i0C_A;B=@_TT@i+8~>S%`}e2?KTWIx*#`` z9K4q`5TPmSg&?K(QTzsMZ+d)JPY*7qRkT;xYC4YVM_ft-+w9mvhfiWg-(bKUy%Qj> z)$v5#x=ZzmBt>WN`~`zh%P!}{7fcLlK0{d9o+!znN{dZFg2-m&SHmB&>o(?kszsst zOX9&Y<@zCVEKbmX@pw4k#p9OpE^%JwDQk z>wNe2nN?Tbs|AJfz?+75gDDsCBTqh-CLtV1f!5SflGLj@tyMee4dIG#_s8D=8;FAU z)^g_P-0T#Xgm(aszb3D@iik72<*GE^wi5GxOzB8aI$vxSiEqcxR!2GuK87?s0#4Yl zZ=pFU%0te~x_gF=f`HmYLpXVwq%RAL-=3*@_BT3^RpR=7t$15J)-E(NOlZ5}liy_O z^ZV~{rKPUahkymV29p;(U%SL!Rp^i-pD!*^@SA^yfE@6pf;7OXRigLnKImOr-aa;u z<`a|>I5dApz?^%JXwt+>Ty4TxSx*B|tIf?6*IwnsW89A5lx1O+b*3z| z@7NLsdz2t3Lcyz*A9@9<93Q=hG(qR`wwu10>U5#MSyIUZLCJH;fRj^eJApPy72KRB zEp~zH4VDG_sv|Rp#yh{=S(j$7HPBVmg!}Fme7`<1+%ZaT`Uq%)V&vpJbJ!11^ga3Q zL||;o1@SC0zQIAzow>JPb&QGXLvTF5M+7?BI^z^zo!iaONRoA&f~@0vx=`aich4Jm zMdy)eate(Y$m&&>myxp5c&nFz@{(d(G-`+_6@%`z=~xl>PTw3qBvAFbEE;31bIex#y@ChsRc!4>eVa>@k?9bnH$>FoSxso~2C|p*)V>}nd8&vt zGKTP?ZedVbG~XiY9*p6 z?ZJlgE$n|~X7w7WatlUKg-o=8#d!CQj8HG=zb;@oN$0nnBpLUU5lLj;X8TEc{DXr$_=RDZ`G>toATy*muOqfesJfQY^@{5d{?)dtI4wzSE)i z^~Ck_=dynUO`bxenSZ)o(c1oBDTGBqzTt{73qzY%A`;bAy(bIDcr%JCar$ zo-|vNfQzwow}05-HKmNLvM?sBtMZoYmU$EhwvH_rC<^Tr;USy+tb1-%ARLDct=^v; z+#0TRmjP>|kAaq?exBQCFzh-mm_QBnm*LZtivW6f#BE+8>kgPjIZI5TeLjuV=JrYU zmk>4H-9PwNyQ%nQ53yClf|eY}BYA?8$!J!qo%`ROcq|UhSW_efBs`l$gGL$q9p702 z;d7w^E36LKDfnkPvwR%f2s|_O!)rG4E|EN}*Dv_=kH^wRPvFLbUfw&C5+WzHwQ$(L-uyiEk=E?x?ZMX^6$M`iGt~HUQmS$9r3U~Eg_dm_hixmu z+6mcVV3q!z^kf+1yB=Gkq^2^|9r_GlqD7FTA3ZJwFUKql6^oiD%ATyAUkO?i3ps+*=;JCG^oeyG%3BkkNrqU9&DGOOl9B-{}EZrWVt$ z7A8aT>%@su`@6(av%)d2E^r$??Nh4?JbzmU$&dYTsve|i@g;h;)PHYmr&WmCYO7J; zb@wvpP6D~{_W_d4VIYmT8MA#bHAW%6T4B{86})9o=&l}2#oyl(peSJx#pf040po^GXju=`6pX%C7loB!X=&!11ox^`DT?JHB zYZtvkry$)SEefcVfPe!6k_srLG%C`fG|Z5Sq>>T>QYzgbF?5KulynHv-OSAY4ewv; zE!TR^c<0{lesRt|d+)<|kW1nwsmw*w6`-25{iA0i>C)O&q9A$5dqXgLZqH*@J03c; z+MWR*0st%7hMr$nvG8+Lzp~){*4IAgcV{=L`Cx3?Hb}4L+Vgrf)KW`(#Iahbrs04j z%RPI!dnP|z@oy;a7U01KSLgrJPt5u7F;3_xcii`bt|h_Jc{y8O-LViZkG1?Z z#XYX={D?*JunhIijRyazQ}2T~kkbZIY zXf!2i27Q?F+t4AWXvDFAJR?1|5ChWOo!@($a|p9@R%HVTN=5PYE8i|ScBt|}N z)WGliH95o|ockV=&3ud)3dE0W5?^okZvscRj&pO91qrr)PGs&J-^wihR?c>N9P|5e zeU2tIDJ?SmHebbl%eYty#kmyfOcsP00V&rwlhH{ha|tmE9it1+X3xGU%(Yq(SB289 z?iKk&t|oEqzDjr9I_$P0 z+Cn=nIaXA(SX zlas9r@$1;AdI7_T<1{StF}&UBZz4$7A`Sv$WB4neZ%vzyP`PY6yq~3{I(PrxPDOnQ@zi)@8A2%RzVI`Y`d0DzL)^wofLb1|h?DOko>qb3m! z^_zY~n0<7efPex9v+wP3s=(PIp0)SOV%AC4B{IQ$nO_l3rkr`Bbzz60j+<{2u}BHZ zN?}b1g#$eMapO>_Y1XP3N6T`uZvZMCpnYbs-a3Bvt@#T>Bwd2*6 zen!0>#4Ztt@IqVyTip}Hgr3K`8E5128<4FtpVyEhrP~?tfE41x3-D^M4YbC7x(o{y{=zD7Q1tkig-sc_nN#QjkTYdJG=;I?Uq zRK<8v0=`9}*PUy;^~q>}t+Z4ieDH>zU03n*V~8zCmzGfFgyts+b=3)49x7ic`EB$- zJef7QHGEt{?0i7#L)_oM`PAx}Sk9^r+(ACu@u^Mv&Ddq3s=AT6Q-PiNH8aaz9pP0E zOjv#8PK5(2{iC~2UNwa>c^<`qLbTZMV#g+Cp9&KeU)VH^@WAUfa8SJKrwzAV+#|XM zi?-&aZXVa*ptzT$MXBw$kyl7Mr_*8v2jPqmWV>gs&~o)S zt&vfHx2&%}Ecty;p+@|xPEoJNX$GqcU@ue^1ZFQr?VAP`04{n?uVK*4^se;Fm(87I z9!6UN`jFe`6kAwZNlm%sa&2w$&-ZUsZkQt6DLT-DU#URepJDw}2tWo29$SyvyBnMk z(l9fGtZPvqiY}7*@jV;LUil3{A9RxvU@H1YE{aw%+kDvf#cC;8u7=;M-aU%S00&T7 zU(dK-GeJ6cZWCbI^ppiY#nA*gJm%TQNOfL+lX( zn05d55CjgTq(5S0yJb7S1EuBx0;U;9TU|lLeC1450E;^zGMouaqmuiWn>mmnp9d*h z8h=ol=<2?R8Ab;y3bM78i6V%ix~+%IPKRN$H?@GC;ZjLr*_PZ;JM7W?{?Q<-nZ#@|`9yqH6*?)d_`>0F?}X4|ZPy#=34C-d1Y)x$IiCuYC1vyO)*tU2pC z|M2^n2ph$<0296$Y)1f(dihZ=U7QyXdQ1^cY)?cCt`u+IQBycj1G*@9_w9DsPNp~vh{Vkckg;3YL`vUXJ%MTi^nBN{5YdCeOAG} ziR_8z&;n3XG%fFm|J?F_0DG~=6r!xVk5Bhjr_lYIIImYS7)P_Iqjtvd5x;+G{YqFRk8vbxtSsoD>G~g+DAK4)eM9 z@(w-AK55H6sEU1Lv0F~K&8LH{!>EnkpyqC?yz$@zHxI&23KzAtY)DphF<34aV06_1 z2GQSmD+~F*lR-FAnnC>+8geYMm?yGQ2c@K=B*v9VBm5}%4+WN2vfZ9%MdBa#Wx&cjq zEmyu|%{10f?ovW`V>h;r9ru%lkH3sZIr*xLpNSa%AVOiLW`Nd|LEw0T0ykbUa)wWH zKt=5(Ak>v>$2`g$EwW>AhI);%kIM)@5cnv%GdhxTvPv$%_JT>hE#^OB4&d{&K(v|4iG#YQ!j! zweVf?Ip$UM?X$GYJ7y&QhLh^wU@O;Ph}~g2IqWK$5E+wFVqfLu>Cy*$k@So+@j zRY@Oi=h^@9w#35fHXckAaEb9wA@OdlWtZBLmp#ukKUYG_pI!D%x8CVsPUG?L_C z3eczaJq#WXhc7ueJ*PL~6@&<{b%(^#h+_BL*xY~LEe7{%9Zt4#jXXwob{y*$nT*+| z0Kn10T#CY+2$G=&hVMt!fOZraaTp75eELV~C%rR}35h-yMaKtI}V3kJww^bxb#dAoU)osIS&2lT^Vf zgE*1htxJPsJ~y7pIM%IB)?dZv-3y9H{;8)C>@hWDb}(Svviyc8^(N9vyOM-P6OR}Rei3G#oJbI}&H5tAdS%2#f63Onnn$1m4k z)2%<7o&iNkAY*EFjcn_zEz<1j5>Hb{Er$-W+#HxCCdr>M48hhN^5M{pmw}h*mX;eI zzdrzHHWz#8KFGP5UE}8gWqO`*ISn!qVuDLHey0a8qp0i1&z(66Fv36!;g5b&>GF72 zJ^YgxtB%~!+z*IeJyr#J>M7n)h19=!ck3xX=43t>XhC>@!iMD>dHq_ae~2?L zzNvebD}MSQi)k7$D2rJljA0`ZXEo>sM)O3KFG?;Q`O514YQJz&rsF;Z3=#+wRsn6r zQ9x;mQy>Pxo$^5!2IEb7+bwJ-<~{wY?xd+IQT(p}r~&|XM`vGOh%Q;>ppQ27RmgUf ze0I+qAb()@NjM(rL8*O(yc-_Mx^-%#bb7E4)o%0$nddl=-yfDB?T3B zET)3B(h}4B=-A>Eu6b@D57MHKOt%g}@k)4@9LOJ$ueI>(BLGMOD7wN=JVtJS64;uf zj&T>UdYWk}7$C#GZitYJp{&2&P8J|vX-T;=X2O1)KJ!mClj{kBYd;+&=zjT9$`D0~ zVi4ev6|`U$o=}UP>AG+7n{w$(8^b2?+n-r1bGts-jDP)p2@n(CC7F}h{FMMIl6wG`fzJsx&oKdd1CHAO zblcK2!4AS>l_3rjp+#|O>@@2B+7vSah?MgmUC$c8iTKBy0Fdp#LmI}Xp1Nk!0qQA7 z6}b`2OufW~KlGT3OB0GVs;#C6A`B*33rSNSwB4k~@a@{zC-*G4HeEDmN&$0CN30RPkzC9I$qL z%q{iD_euxE%@T7L(sTJE(`wR>4ELcd#cM1bNWZE60$17PdOPkK{@c4p!_~|la}}Z= zP^@K~Ezv-w)%N#Z?)>f-y_;8$1$0jDf0FaOHdFi!piH7oMpsF^_IHox}95AAPAQVH$|HR$WTE#deAK#T~cl1FpY|8Q@dLe#a|p5IWe&$GLb zhiHh(le%L!=t;O?+f8w%2MAyg(2R@ilT^a)ud3UU2$lHh;ZD#ui6RDfSXVZ5Fmq~b zXsUJpId{|e8*dOMLhqDsrpT>`0-^^{{xr1;kzeSctMr>I9dHVrNs+}RYX<{`nKn62 z?jW*8KksTIvXdGkkKLN6ff8T&LQKz!Y`7&K=OAN>r#R+dJ{RX?VawuOum4Wm>F5(T zUnHwc@jZ9Qwt$=4c_z@wd?Ta)0BenZv_5rK(s5Lu0qMU@_Q8sR=O3Mszc6zRBYL0- zrb>(Y9Ku|7A&d@*1;`kHOH(jElx>mOH{sQnq^$x}-5SHZIlU+W5MTW-#VVE3K6GlW zix9ps@ppdT;_X6(dj~(qtR^&=gK-iDK#*1Eto(IJtdl?%7TX}f0;=HyJ112esh{n% zl)iky(O}*90Xd|Xw#`0;bHMcR!{kgD9+-EL~kSIJx9?Af-+NHZ)3xSdP?77IFf>;{B z#oMn{KH6vZSGhk2vXP;k)-ukG%Aw?Pdpzo@Yiyn#3d_DPmxkUnOK?;cxL{{y6m8y6 z1LnT~*ZWirI(2+d459!I5URJ;Vm_cY@Uei`0ZQXBJ39$_4}g?evjsV`LC8!#L{oK!Yr%M{0)iT6?ERekS(H{XAjvfc8CC8TQ zuK{p}bM=uDD0XO1D|82@8ZXH2BW+ozE1L_BoX)BGn>NV0r0Jto!q%K!xtuAbe?7(Zc^gK^V#DNL2_JQs|f{Dg9z{SivGJ=$Ls20 z2MxwftR{5ubdN5U^P`P{_#GJjI9=}oPcCj z;)#fBf-e`Ke>OjybX({fsBq=h|1wH&=9;1-{k;`TJ{(jr*W9f-A8~{V9%S*X@z*B} zuD>4CPn^EN5Bu}!dLO6;bS*2ifB&fE@%mUoP|eMM6@ZO%??NlaW#_?PIFWb zM3Ulj&fT+#O^e-9^*y`;fcc4U%M&jdZ(#wYk|c!V29$u^T@3P%oGf~U;^qU1Kj!R^ zsc!q!V66BX-ndcdo668&nJ&IN_fcpE6sl0ej9mfq+cMR`>@9UO4P>n3U}VRoAWEL* zhvoq%#M=(lCt+5RY|*=-h{YlUi<6&};LKwKI10!7^>CYUoKAf}_0o&SXETdgYDS)v z9NtSjKr8TXYTu7n7LE1UYXb~9!ECNWrNPgd#zEAHVxICG=uttCk5T{@{fVc)LwrLB zLHXWVW{LE?i}1k=@M*GT5t^9EQfG*g4f9H|k)d&{0xY#4HG50zn>Z7R@({{^ni?36 z2C7knpmWETkVu)nxdf%FR$cN~&6t@hW#tHTm=Mbfnh&J9&W9ydxm30Ta8eVAKp5pC6e&ZVyrxz-mDr1`9QE=h^O>qEia9|u?GH~($a#(s8JZX1)^z2=p zr_@GnI#0GD7&$Am#S*5LmhGAGU{zrT0}%wS5yG(!Qf z4w#}3q{)g#gLy8kgvIDc)8(c(lU!Pz!7tpPD5{LWI@ncE*BWOeKd@SrG<&Q+JfH0J ze6HfC9SA4?0+7^#iAcj5H_|>7N>>=l0R6%lFgtPCT4PNvXRtd~iWE_|Ob7@>Ohl=8 z$=>HhcDZ=rm~HOGXJ^SazeJ3bIPdr|6#n>op8&7n4$+O4=;Qf~%hJVWQvVo?)%6W_ z&5ElD+#@{*f$;C1pD8rwOp&2RH&t8SLHL{_!c|*=Oespbc={+#^F^i8rrob2C_?`V z17QCBoUqz%b;T-4vjxs<&3SNxK*l(V1>9eHT)V9Rd*m5*pY@tyIg9%w_SMf9!S@yS zc+Eldem-Bffa9gw#>Z5SM0#*Wv^zK1Ae3-Yyp3n{wX$<1X zT|VU<3cbMoo1i5c-dIY1iFu55IqfzbW173%kJnefGVWJx9XmzZz#%bEFq!n52?P8{ zv?DzoFr~mpQhcH*cA!M{eM^YsP+*P8IS-n7>M03!c(JMbk;=j$4U0eJ=x}onnW`9-6^w z2V{Uev=U1KDVjP$@iq%-qCW3|0Tw9XS{RY}6eZ=3JdI9>u+m*X^hg~ZHLsb;Mj~p3 zZUA`F#(^5u;K4++zczg(7i(RQK(}8!G1DyzNkAIB#5zel`qYnKMCNELcEfqF_eZuA zH5m-(VSy6AUxdm@{z%}7-;l!g!ml=cJdzB&{vQD!WAQ*lMH2F*=SsoHR=0X*3jDJp z#Vw9h8h;hZ?y(sY&d3fR(SI17M%b-ei#`A$Kmudx;FlghSA=mWD|X-Mm#AqlkP5$D zE0m^1{rLC%K>NJM_m3*RhGg&SAgaDQhu-JsKZyY~noy1-#wsAFpRBs#D=hNb+Ut9J zUg9SB=D(UP0xzi%G7S9AMF>3r+T1D?E#%Dd{pv-Fgm@GX!FNzFBf&42Jnz!pBf^F% z{OSlckVgV;@q7cemM*RC#Ndf%*Edi%{uWcQa1vF%G6B*X01Dig@9lPbs?CkFzOptr z^T?)?e%%jyQJ5pcO#tMSmjS!j?Fshq!S7L)>bif{?^@eCm&h@afUf%cd5p`B6KC^X z^pClrs?Cl&Rai2B{gZMc){khhpFbkYbYy85Kl@UAw`;aNtET}zU(w<%rSz}L@R`KZ zpQ~Sf@n;AH+}o(PeC0^x)1Ftso|5>h@xJ#**DjP>yj2pQ^IwuZVLk4%Gn>;nLWO%{ zw+xQYH|7Ap$ZYJV(QJ&i0A}id5Nhlh{rol`$;fa9;f%1u8x_!&@e_k*sC_s3fU_!#eo8cH_zOORF9S+!dazm{>^blhN{!-g{(IAn z^*q=){xbd2^M%PDd?LQ z10R)Z@dAkQ>rJ`t(%xT(lK_O0x@@@!iJ;m=4V^Q#^+mFV0|}61GV{a9(w$8#Kexa{ zR7Z$eU_Mf+B8^LrRV!xH_TL=w7r;|$wPNV7A-x;e0eO~Dmp2KK!<1O01wTOPq21)r z_O$8@-^ipo^s*`fp-X}bM0+e%V#_5>8(!f9#d`?YgJX2%ZcdqBtr5<&dh3Hzd4ny1 zAfcN;>lSGSo?@pqpF5CobRA}@RvjL&{7m|(?cVd_dZL^|hShl)Yaez|(6Xv<16b7s zuBN_Bmf^l#tTU>=J>$2c%^#z0;B~Z~s8;Rql^dwCxofv4K&8*~HVg!GV_6bM)snNh6Vo7(Ds+89 z1+Z5D;?@_widHbj}mE7f5G^XbYwu-Ox+*C~oj&JX91y})b z%g$8imaEh@r|{o-BY;f=_1VlEx4z!jz3T`HRgUSR@xRy+j2|8nOfXw#2T5+q8KO=* z#XshC)bMoIj&pvgoDv7t8Lg*KRX0KT^u}HBlNoKfA19A5XGR#x;DZvkdprZ_oSh+$ z#1|qNKM8%PsMx%GAii>_0v%&IEcJ(j010($BhpKVu(|6|>mAt=%nSiQkk}*?Nv;Zk zLfZ>}h_u47}sL1v$CYC@J^3m1N- zcd<;QXnbN$W(DSZ)=z{%S>tf**K+=qN}RCG>$R5g<-N!Dzq54+&OYLtlq!GUZ)hYy z;%3RZN~P0x&z-DwIRIupKSaR}u+=s|v;w!bIZ2nGNBUNnem)zz3Mdqt174qSt6a>v zv2^`Z^;pFXOU_MAcCQ$0AJv+!Q3r^Q_lDVzFPFin&+PHUtu}R8pv$;j;Z9nkg9$fF znAAAwYjn;l`>O?N`Co>;uQ96$0Ur*52IezqFI%-xK(qy#vgQT15{wSX;Y&uSLUoYH z9VQs@;@6*!J*?zW!?2zDWr~j3qaR0KAwf!ydPm?L6+xspmawEjD8&2h*V7OiVX5Zi zx7AUG}lZjU?qlf5+5sNPi98Q0B>@G-1X$yy47AbwB2(&{;qwV z4aSRGca(-iS>ji}zf4|_nyjv=yDQ-7-%6l(R`**3?og(ZwWvE%+>@kJ3&-O8{jxJ zIv|6@1^1?kzxkx$IhRh_3j>2nu5jg>TrB;_u>x0aW=RL|_As!Iaub!cFR_CQhkTo|}89&!UusSb)XJDmBhl3*fxzu^|@Xo2JF~!?UYpdlh_O_bG-I ze<%zCq0FZQl;#sTQY3f};5qNM*en7}NT-eOtd3BSE>h;cB7XZxx!XrzQF5&vA>Mqf zN>+E&b(ARYEd=bo!6}{fL9#T&zlQwrh{?YprYm!Tu>~yoFecqAv(Kp-r+d*F+{G1j zEP&K>yN-i#`C_4}E#SQ$U1)CRvwQHP;_9&BNFsk@eW=oXW^a?`yOik}u2?w9`NFu) zgHlIw@GtJhtmDnz<8mmWQf&DvSyEDt2hO5Kqg=LufAUa)M0r1D*k6|D5a6|Y%r7o| zEX)+rdZfUx04T0-?MTi_-CwC7Zc-#+87H279jAjSkv`Z{vHucmtKJzh29XQwh*6Nn zkT#lLH~IdJ(yDb~OHs*nlnVFS#ye)eh|6&>`X!o%gOossk0tZ!Ssh0QO|?JDh~zF7 zTOW%-k18>LNQx&fYIiXr)@HRoQ{|Ds7Rlmgcw0Y<_xPht0+*uEzr0kGU$OXj z>OyWsQjjb+S|_O#Dyo;g1Tss*${unhl7IM$B!~4BT$jTpj@Oe{q&J?wm0Y#nbg4CG zB8q**58LPHzJ#>70UOhM^-L`^B3XDlfp`WGKJf75&S>F;$Y_2$vAwf9iRV@~BpnNp zv0s{KTyGixq+Hm|+WI~Bk!L#;Jp0P=#Q zor2Rx##5AWU%E*i%-W18_(paOS3CRxPDJu71l zPikQ92PklT6sjc-B}3?W+wVoek>MpZ%=5I-h!kX>5R63e-yPA?P?d!@lLQ`&BL}Ba zl=z%}82r!Zt%S}#%En52Q*QWIhPph}+5M|eGO&G9QTO`4$r}m-_*{oy zAz)l5NmuZkUpZygiHRmAmwsWBcp982Sh>Il2d-B)9n}xqs?O(w1(UttgEcQuo ze|;KOIR)KH4?0o=6vJPT8wCas9y+eO*F$l~x2G@{!CIIhKA_T(Vtwt_O}~MKUEvD6 z4S=6E2u=Y`WV*l6j+TsjJln2t=k4xKV~>9aIshDi8*364)0dX{9B-LeJ*cz3PNB3} zVkFAEe|EG%mR4kux@v3(4m^*h7)g}w7r^2eojRn5FBn52XIgR;;EJ5!3eE~ht2L9S ztnErV5zWbcGYzW2^5cSId8(>7g9m^OfbCvtumWs*4r0x-Y5xw3zF(UEGl7j5-!tGA zm4doU$C>VvB)~2QFeGHUmii`cx9E5*XWp+qPY}pn9jaYhbuvo~#6Nr87Si(*Iwt(m>p2LeeK_j&pPxr)Agf zf%i2+UK^|u@j-yiw<)4Gsm|3C#Wj;Pux$HG0fayB`YXhup6{Te+B*BR2!NkB7@lHI zR=lSn{;R8Eo97d2B*9;hV?FwcS7xbhms3qTx*nyjfz_*g?;hoRC~`EOWh#bW0jDBa zio!N$36ZD7DBSsP*Z4v39YAzBJ;niNoW8_2Z_pQ)nWsJ>lsis01)gk-JJt0X^osbv zLXbZ0E=Wq#ulL~nsT|s_0=)dfy1%wSn5=pzfZMWoeevRfjQUy~dVp7MZ#O}VqDGGj z!A+q5h044$!}oHevb)fRU4aD?%_9l!ScBh{%!G~V`wy>~)W8y^jtKw?5?Yr60ZVkS zN8J50lIL2A_E?|coNMXJZ4_VB{<~^DH1kb>V?Li~T1%f1&IN1`^1nC;LX{3J;=JJg z-_J}Q699w;?pT>t`rlQIMal7?qK*hz?4e8pixhc|#d>kCfx7CR0zfr!fRCk%T|zBE z7Tvq{Yq9y)9frw}BQh4dRN{dO6fH99EI@(Bfza|OM7tvgPf!7#UJh#FL=`Fiv9FFg*o?V#ey31w*10C^6m?i z&3!TACv&cEsU7iodG+}5HSxi5u~ci?7(?|WaIaUIta2l~{C2MI^Rk)YgwO9qJAMU~ zx;JnbLSk8sKMrgTeg*X><@;tUi$9xd5>RN&bBa>pn$;55y&B?oF|nB4ze{N08{=1D z{_@pfrahQKG*dA#6<9!Snjhmi<6(N=Ja+$#2CJzkwsaS5SUd%zob8Lmn$V zH#&O{b6NOEaWA23jQ7Iki}pxuJg#r;cF!N2_i9o6gk#KWmH`;CahRsZ$>wt($@scc> z^!^@b{o=FJHacA|E*|?BSK3^(ZL2}DXrnHF9 z{yiqbHx{Gife7;7R3<*4;Jqab8);m29gS(CRJuYJ35Jx|+#I|4t?)!s_wL7f3fv(| z=`I%)s&F1OnWC5{Xp4hmxwG!(npbrL-FVyDmQG_|ksm>t-z{JlD(8G;^Y1}o;0y5v zr7DzzUH*h&{D-F)z~S3DJ)?C#*-8Vf{WVbUAe?wspGMtJjEt_#B9IO~2LkQb{HXww zO~1=&J`$9`f0ib^?(F9I+$Q7Pt1yQg#2?c1WJv?4GrXda1*vX$`dA;>uUT!)W%lKA zQExYI9W~ASC~$v@2R$K@bSbyJo*+9SZqP1Uwx`$}|0V3m&{&28_E|u;INOQb4J^u2 z^3meL{=sJ|gs_N7HU%KL5v{+?0C3@) zhwiFK+6TgUfwpPTSSZf!8MikaA0bcyHo}WbL~e7h)h-SRRs(zrB`$#p)B>rIERkZy z13JKi1$IitxduY%j+tg%f38=?QEH@{A7`F39{Yv2~MnuY|H6d2T|O{cEj-|aElB4!iC_BDKpM9J@pT+IvY>?3b1 zWjeliKei>e6|*Hy4_8xUQZqlTq1A2W4!5FqCi8G+)qpE=^OzRoR@4Curc8@_)y~e? z&7P}Rfi*Os-nxv=OgE0s^r`?C1s3r`9GbA~Lt?vgikK`NXb_`9pSd|yIv~4d&FZY# z9qW&5aAO0|sWh7>oh%0{rtR@tXh@Dyi1NUve^deN z1Onn-v*TRhXZg6S6*K`e0=S$)NWDv96a(c;^1Aa8RQ#5U3!I8-T(f&(palyq!Bqez zx+k8nlvmBO{BhWrRE#cOl){-1=46nubJv?Y+xZC^G!X&e-x;xz`tRQ%z_umOMX^Y` z|Iawt7+sSY$@B8<&u^#OlQa3bMMu*T(W9WBQFN+3zBMN`_lp$?=fQBK>B=mX4dxHOh@&u>EqSkecmb5xX={+E*1~y#?WW4n}h^pc2VNiF$7q^tB{&O zUuoA$dK+-=AnXJBhT!g`{zQih)B{Hu(?}4_?lWU=3Ak*x_4HWVBM^SDn4D{B;U~D> zU0{-`{UN}rwKfBN`U2xJJ9$BgP6c9lQS{*>_GU`NDHXqtNf7W$QO;G^vW1h-L-1(r zUF@TT&*777s9U)+YFpT>bu;kYH&qH@-^${-6{2oof}-nvM0RMX2hW=n=prrqksQ_g zUuaW$u_|s$e1Ql-wwA3eugiMRMeg}IA_4A$n-f_X=T1hFjtxup$G(nVRb;TYGyQv1Fndmas^XUw;5Z>(m_wMVALrpnqvEQ4@H{>}HY4sfK^M zXaD>1A5YAmcpP;H=iauWy=Tfphs-i) z#5Saabflr_YHs@(pV3@Iq=WPu9++w0wKi~|JW#(&GX6EL;ns3t;~lC7N+9G`%!Y44u*=hMFDiPARyWs@DH%;35pb-mwhZ>$bX+ET>!xxk^5spp4| zAjMb-^mMou`30V*K!D$Qu~`l>W=jB720ID`%FocO^wGvfK-wGDpUzt?PA%emeE)qb zdMOeiQDO1Kr*}Ylw_pC-qx!M#D*?J>M+%1=!1z6=V`IJ(P#nN?RkJF>f<_Cq0rr!x zHpaDYHJbLGPgKT;fvSTtAh%J~jQfpA;@Y&OQ$gG-cBeeR$h{pXtln=vo!B^7F%zN$ zdsjsc9oB0_82DH@TPkd6T};-&!+p*$qkq%c)COi=uPm+7#`3!St+9>xIzI1qe{ww$ z;jzHAkpltHeUt7PbLsbHDee@&S7Y55FM5n>8`Xzst=?MV5tvW~{myW(P`NxB|6PZ} zYr$VlMItgBy%|r}hu@oV6I8}OTKLA;peBvp`TtDynvn}p2E8F_a;fefi3EFZoF-^O zpS>N#)khqHJFIj;#)u1~r=qZxx}b-`oB<^ zbFWOFn6XkUJny*F+Jvsf?~h14BmV5QqXBqt6ECrKod3PPc|#6LB_4-y0}qn+Fc%=D zb@(}FWUKT39J;?jj96X5XTPtKK8TtLTPneHt(ySW`-cA$kX2YM208-4YsQ!)+j#Mjc^=v z(y$Bcz<$)j(};I!jAhmUKKO=b0{?~n;b%pPa)khdl$WXOo_I|Af-6%#`Tc5=7$8WA zW4JG#$%+QqN-_hq!_DH^uB9EH88K=?4<0hNCWh|K?v)(skj#ld@SBdX=n$1Yjt#vg zUmda}U6v5;?cw9whV_oZyRXpu=K>d(%IuFMH&6S_u19)TDG3`p2&^;h6LScv&@{Ny z=c9#S!Q4$sItJ@pFFZHIsk;hxN%U${{)zD1(U0+S)r)-nrsG)}+%29lRd!KAUkri( zzw=T65O+6CL|n|;a670t4(GAt=V%^<4;kaDSlafxD=@SyxRjhkU3^7TS-5e*4fH#( z(9wd_Wp%OTM%@4;<@dO=sXPQ4RB=76gq5;?ioWywC!R6BUlFlm!T?tv1RbwV+%CRO zC`2H>CGp?+;KGB{rb1Eyz`v3pIt4A7_q8o{MPqL7hY)B+hI^uK%*YWIKNKe| zmr_Az>h|9?12iRJ1VKoUP>*0AUAdK5n@+$g`KWOt2V4%zT!0DO)`4>v~ zD2?;kHK2#($|By!@?E@ymMU0C^TV;);=N%ARGq=m+3~3xm?n9EWtvrjrwBIPJ6|f0 zbTm!Q6g$^!Nh|dymcLEA51-3Lmh6@_6@G)FBNjr7#V)N?N$+MV9IzYhH z+Sl>=(B4(shxKL-V2W@k-EJBDj=h|OJcqmK$A!`1%|9Miw5bor%qk!J_MN- zkppqrLd3%u>L?Q+jsm71F*4vndK}h{dgL_(aE{gT>v|MiS=?il0Cs*WC4bD3T&2&$ z7OJHFNe%MgybK@oDj_Y8*2tnWoF{<=JZFR-;_;Ml9HVt|n=4naIwW21Ena2A~ojgB}wo9QeN$$uZai6nsNmZ5KMf+wYz)Ff~}yYdEfWgDPsCl4k|!kEc;Fv$7req#WI|GRVilF&pcVb)^Y74RM77>FyY^ty>)pByB;l66@fGKS!s zuTI%v^ta;GY~6!(vUTFai;R6wlL-c=;0rDnNzul@X?4gqo6>tzbt z#HR(-1-u3XG~n)-qjcfSIkqnxdJ+{5L%H6+rwFI1BtTkZ@oaDR_TI9%k(yPH_DTrh=lTAs|fTW{U3hKdGhlwNX%Up ze(`_AxS3!Lg0EO{P3ZLP-x)dDdjrHYq$t#HQWQxhjRUmwyq^W`N(L4-$=wi9C}c$2 zzKC{hEfzv9O3%)9pliZ5ZZMMivlY;Hz;Kg8^AJ1wRr1ToUDq7B25#oHH6aMu&m{k( z_tWk%QNc!6v$K+2(@n77P=EY1XLh?}^0VCYp9`#FKGm8;*bN%Ue5|##FPn)krCre! zbjsr9wL&8Zx!S&Z-SF$6X{@~2*l5ur`|0KnWs?72rDPXXSQ(p(*va~ky!sO>D+qPn zRK)#H?1(}!#l`a#o)?v4GCT%5wI;f;viG4?HG&Y2i4P=(L|kB0qaSu7Xi8)+cUL7~ z-F5N48W45fw6U&c~)7dZZ z_(_t;3vT;&%0C(z%>V!f{>a|YAh*yMB2@G-m9w>Wc=+%`iSITs8G_h%Fz+ z&L+_`BNpA8;zA{I_3G7+JU2>rNEO+GEIOZ8RC2jZNa0>2b$Rt3XV3_s+R29zwXcK_ z?#+Q#tZcha*G-20-syU;YR^reiiewpXvAriR>ElU{D;sz#F9LHqlhZ@&=@9oRfK`* zwl1lV>dS(Zg8(pEB=-Y{P{+y7$}0@u%k)FLh$=Q#zNOI^%z zqFjW@%kZzPdC8hEweQe9uuV*cBGR(f$0l+nH0W^)Ydoz2Q8fy>UT@UUV&%VqY4zgY z3ZjSxf|#V8(}}oZD>*9H6ZwLu_X%-t_^@RXMcrI#6+bxA0x4fnkxHyzT}%_V{Rl?x zCcn?gV0>hK)Y^-rhRis212BD$0>E$B*3=+|{@#jZ_r>JyMAaMV>s!kmO}f4mRV8M% zkj(OL62S2FZLM@Y&PGDuNweDIuoWSNr95tFX$@HKY}%eSQz5AIMsi4)NR2W^ z{Nw{!!L~umzIs9Mt5>8A$Ma#L;E%l9X2#^;ZGNN2oDMCjUVRU~yXmm)l!X_4?A!7- z%z~CXPtz_*{E6z=f@ItdLoL2GmPSMX{wi7AHY-jHZL5FoP@d}K;^Oj!d;4UV-HT(Q>F*(@!-Pxn zRoojGl(RyJRLvC6&n+)SA^x4zv!B_02U|%<;~bANwj=}Dx0T8hEZ11MkaYjz`>lhh zIU4o6xVZ<5+kClaflRmIR-C_&d?|jz8R;3r`1Cl6O0KUFk#HN5;a*4o{vdcAmHDjk znx1v-A3%e%>CHVA)cm~V9s4n7*V8OwgJL?|^cBzCSD4$va!A@&p9~xHjcXOdt8FPl zSL5)q)~$xPE*jsGt(aZSek>lhAC)5nThF3{!0&t`%v!4B|Nqt^f#Ktl2>LCVqy&tXl<=Lma`}4rf7%*kJ4#sf9iTd(fvL=Rcf>8*2j_xA(To?TCZw^ z@mCm$$+&@_Y^9O99-i^e28d4KEOeN-*c^Q~`?7*{Vd$N*{XJs=sPSTzGlpZ&o;h?B zQ4dlhA3R~z#67;^$=e5T7Wl-DFa!8>>C2ZZq~ZEn3dUoa6ky9q0dMn7tL@R`v+A<6 z_ZW&mKXmr}z{nc&3li1=MXhY)l?X65=jD zA%SMj>3HxniMzY)ep#>_1n&aF1_wU0`^wI zpYSf!rjlL33G-V(Jq&zn=@RC8EMQrvM3myT(_U8W_G_CCk+=|{;AocqBqxw&L|3w4 zf}@RDz#y*=dMeZn!i<`6kL!0%}e|PI&nA~7B27SAa?}8}_HUJE@ zE_2SryN(uAveafqLQ?Gt)pZF%J&^#noA5Mo%6>jKkvZNd;dm zy5F01M~arCyv<+B5Ms;2bGyi2k9|Yj`6>P+4gRyYlD&i=t1F5T48^_saCcnYei+@=R2qV1KP8k8L$r{tWLL<)>HB`C~oh4?xmhKplor?p{M#D^c1<+ z0ZutcBZ@491|22vPrb}7sc+Ti4lz3Gh#Vx?Z~AWCU%>$b8%8`8QPp>#*2 zwxwDMU7^;L*xjA?IbSc{*~x54G}6ZI&YlnR56t_#yyyFTzvns6+DPEWgYQ+^a@fqi z3WXaG1VIp7#9coLcfXJM%sumuAN%JB1i?kz z_3LY=9&0}K)*7@CNgH`#+U>Rgv0g`U@`>*}w|(cH2OMdOrDCsHqI=FH;(FmA2!h~$ zFa3h2VkprPb1v^=KJ&R#Ge;Kc^=9$XOS5XX5BNs@hXYd>`<2)=H%{GPiAPg0bX_i& zQ3(NBObiHuAP6p?^1E3|sTf8sSDx~$Lw|g{`NSaX`Ug?~tp1-yW7=A+19@!hD>c0T z19t(u+pO4@%Oyz4BvZWKK@bGNIVyi9nNl$V_i_=?5&ZpcKb)Wcai`ni?|mbHLIA9H zH{j~Y=C;Dp-3GgpsTj)Tn}Xm7ArJ&X5LA^vgRoRwLgjMB;Xf)pA9-eGYH`qZ{R1xm z-s%Pb!1!cyH!Xb~v@g*Ta%b5EDI9?y2!c(#;|G~)!zRyw{HOB#gSnZhXZz0e57e%I zvj~9RDB(t<)e{0A+dp-KUwT1O2moC8#X^ntF0KlJv73)hQvOYvO_B+@T!Mg-P>NFnK@bE3Sogt9ph(3C zqzt*wi1xktM-I*O3jgSW*7F5EApr0;`vXQtdkZ{%%i(>Jf0?q+r($Sm_0#}Bl88A1 zK@bERy6nRNsjsVlMf9JO_LI3^-*e*Z2C>aS%=tqAoVQVMx@Yl>-`d`SRY&K-CzGtiqZ7|$&Tf?yqH`v736NGa8ja|sG3K;3W4_TW>$ zJ@o2kwE9B;T!0X$&%*j=I*;FScpsd1fVkPL*Z}~ZSK#hlF%Zr`5Cp-yz0(H(GfS3+ z008dg%Mc!c>HeP0kM12Z6Y$Mm_(K5v_frGCqaXWjKX&PIXZbULeHN~XR2$j~>8uza zMZpj~13?f37yVWq5e|vHqE8}sR!+k>3-~XFYs0@h@!-KX)*cBT{I32G0C;En1b_zm z?|0s_|K1(W&ZNXoLz+lMZ7WD9mvHY$V6MgskU$86AP9oDiAxel5lUt?G8KS?Ty{=_ zoF(-uQ*GwyN5A#*+3)vs0$SLt`F;q1cX+7K=;(r``mCcp`|sW1%6mlJMqn?~CoHiJ(pjizhk#h%-{v_Ze#fOaP>HebMY&NZ3 z^>hvF@SYF=?itkiZh*)Bp?yZiNkK1|wWsi-n5xkGZuGpeJi zE`XAx)#YLT8byym5Cj__qhC|$yBqN;78OAwVW1_D2AXy4{1r(4I>6`5>`8&oDEk72 zI?v8OH1#j6iD#!%_1#@i6#WnY>*859n@!Hn!nSQ|>s-0h$8Y`a`_3#cT~!ptE-+o0 zb7!2yIVitSmgT~h;gRQm|M0i}fphd@EiR6->%1!L{SW{fpl_gIi;JTKfaAxf z^7&rcy`TF2<)xOkTjKUoyql`->u8FebWuzF_gXXT4Hz)45%~a(17m0rZ_=j45*Bf;- P00000NkvXXu0mjf*S({W literal 12620 zcmeHt`8(9#`~P{)48}fX8I@(Usbpy*>kQKJDocrEr$X5(*%I?qNl{TLm24@yvhU2a z$QlaSjYMP{5o4Q~@6qf1d0*G}PxxHd=a=Dno^#*lKKHrT$2oUQjSmS6tQP6o z-~<2|_%8=HY2K<}jgi^;ype4BQkk>4vaiFHyfAKJ0{#3P5LB%;N& z(3{672Qa)p(A`D*j42dC^$qduoAV4+#doVYh=m9rS+~{P^arN=8Z#Xa%FSHjPOdaOp0$#vu&ttWF#sKnXTPtI zw7?8r9eP-r%~~vboIm`Q=NB}Z+izq59{KIg{-9CQ|Kn{OS)QtMSPlV{lSMoG)&ciB z&vnLf-0NtwIz^AcoSXgy2Nr6i?imr~kgfT=q*A10M(g9N4!L z5HR)Q#Z1N>V<2Pz0O7F&%Mn56>6e!#ms|PK-Kzq0dw=A~2mRsrJu#?&*2i5e z<^cu3yDC}AvMieCD!P-#WXQa1dn@+~dd%$>Wr3_be)yJVOGR1uN z^O=l$*vF$`@F1S+vET-HH~2AV8{)vfpN@Y5k@aSQ4v&Mlo={l1zofiCMV|DrimITA zw1`3-?M?2-njV~*43VE_morr6ns4rfj(fn&Sx8l$)LTVWpKA{N`$bFY`(KU?hs<&a<_?@S}FC zicGsIN?B**A$qs;uficEy>_ed{&dSd8u`ONy?+W7UaHReMh^UT{b$gw`$Z=E@8e@U)H!Ju^#RStD zxNoSRdtkt(d^F7ez3BJ4d)^V5r#?ITkg!QLAm+j4KnL4pPTwewqK-ig=3jXj7Bwlb zFMR-t*w3$pK>97TjP-ZBo;YwQE2^6lxWi;QIj_SlQ(Sth`x7Y^h<}S} zjEqc+uvIq?OuYQB!5jd!w+ovB{Kw?ZHdl)k5Vq>uPMp9a+b5R&UWx%o|19U+v@~m} z#*uj=`+6ExA-+98lycgqkS`?Npw?D1DZ*G*tAp_+Q_g>YKfRiWuLeMndGU%Fd^_Hl(md0Pml%rKN%Le$K^27VB@ z62htbzip=@@BB}c@Fm_QOonWShQQ4lYv%h(0j~IPslPXS{3uUz1UV zI?%nf(pJ;N)3QVARvF!V--SR2v;R^SbomsIy&Sz43f`=HV{AJxhDVHg{zd?20{D8) z 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 B{%rsN diff --git a/public/res/android/android-chrome-72x72.png b/public/res/android/android-chrome-72x72.png index 32d5d51646aed74aa40cb39a4af3161802fb74d2..7aa6caab1206aa1727999a4e983d1d909f52102c 100644 GIT binary patch literal 4503 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 delta 1511 zcmVw*C@BjlSb{c*nOwX)#W}H?)nIM^? zj0vgfIUvAc5hEk4jV)7)CTnC^BgE`GW&y0{fRIA6aLm!Pa=E-eSRed+dkOupvz*4d zDrl%JM&~-BG}2K@XWNQW*11{9906)f1eM`9fy5s8`+pr}G|_W2P42tJRzK~ErGWAj z5KP!9AZFqs^G3zO10z~^V9Wx<;jP84Rx)KkGSM@>yNrH)=?+5^dn+@pIVnhkHKjos z-;Z;((KzC>H3M5q-Kt+r?ICZX=}ID*jJku?k`n_!*r&- z!n+iMhkw|?0R$@?rr!q}jmWb4QKrfk4+R~=Vd_{@JokWLuDs|$N@aq>boGsfv`E4} zDx2sTYAdJi`Y?Unnd(*FbSCB=5O&5qKmfo41YLQpF70#JiJN~99!>@hpycJ{QGb6w zZQatC_C6wUP61&@%u6p3944!ATcb$YYqrG4z?0P)%iJhUtz z_>uMVW-n6ln(eDYv@W)U4sD8f4+y2%4M5Pv-W4gIH}`8YU@r94(AAH7=&$eIr+>yj zHT1_Q3k}T zVom_1nDx%(H1c#z%75T9JUG+mhbR$^`wI}H3JAjP8e4>ko=TXOO;lat;jxdX)xa)U8Bkxf zBRE#U*xlGUX9XxAXjbvCDsZL`uFDz>n$lc=aSVtz?tq`j!kK;N0lM&9Rn~wWsVT}C z97ljK!^yT`g{~aBTM5MFqZ4}h543D#1;pnusK zU!@qtJMQ5P&CKZ~3&*ad`19PcBqXFl$~E%^bN$2nYKcQ9A)4if~5eD?kCkY-m(4ew8WjvtPU_1qjCNOF$yC=2bKq zK)i8=SpexvKu~WTzc;f>HJK7G1Am8AG~WUec@cz}j}?;0q_O9b1q2;$jrtQ%K;fhn zhldQ%wPVe~@~#4CPkqF{fC3VRZH^{v5HULvjW<+M%#GoEP64Uj6*6FTMY({iyJtIH z{OI|d!Ep_c6G&s7#auw5xieF5d&~R)ZTxu&2=|^vfIfSy)L=MFG~cGrq+CRi=Ya5E=Rq=mdk6OpGeN>@Zr-l<6(A;j z-vR)Tau9B*+NBKMlUet_0_0a|KpKz+qycF_K9&Zg0ck)QkOt&q{R`aM)sM=ki4Fh& N002ovPDHLkV1h$`;%5K= diff --git a/public/res/android/android-chrome-96x96.png b/public/res/android/android-chrome-96x96.png index 5da68e4b8978211d33d8d3a21d5701e642b0932f..5ab44fe9df1da828a7a2aa029aa72cb745219a61 100644 GIT binary patch literal 6234 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 Eg34HaWdHyG delta 2009 zcmZ{lcRbs91AxDYS{G{79to$Yy=iShL#quEGgieaO7EzcznoWFqefMXB1YTSQB;jk zr;#qRPDMy5qI$N*^}JTK-2MB0-p~8|`TX94bd_x|Mw{652_H)e4@(>iU3qnHxg-42rE1RahxBdk=Gz?NXo%C$ zL=zGErF8@X`rxGw4+98v2>~9|tRWC0$Bq221hEvem0-_`>gFBU(6e8w#eBhq+_q`2 zjTJu>&L$)x!P*Z`4FSKx_m3zCCE1#yG;2zD9`rz$4X|P@1Q`KgV@JvWH}H(v6eyEw zAzV}Gh)Pn5RDc$(?WK{oWsBWFNom-a1_rR=;IFDaF_mniVou7Fl!yE0wfC#(5u@RX zxj*|jwj{Tm${vWTpPKAe$9Ean;q2+a8&?)1t8EzYklt@8Rka~3@H*{oxu|%@_V?ZH zEu1K&a0SFo7SP7jE5>dbj81+%jrt^0={(_D=grMz zj7&Ie3D=4LB?RoVhADbXdidn=^}BZ;Jkbl1?iqRiNCzKX*LBO4ODeA75JH~QyBlV| z{uV*_ClwB6i(=l1Zt_t#w5bM`cmJes%e%mzWLm<=Oy-5{&qF@TDX0!cM|%u!bW?p1 z5$g3QlaxW?PX8fD{-np$Qu0x5@aW@jR46l}H~S8y9!4(&4{f<%g7Cn&SGM%?q z(q@6Qe02>OP4qgHYP(R8v*>vq!!6!wYf?S`AyxN}tgej*o0GnfmU0Xbkic!L(>z=G zk)5qJ`OUdv>1NM98L+Q=dtm7!1IZ zI3mC2g`(ugKFEMQ<|fdF2>r*2GqG(#8~kubh{2fqtV)O zk}G1Wd&MVgn~%5B-m40u0yKX59X`2VAPWE?H)r`ofN&(N#?w}F8QmvhjIbGdWB=!dA>UxWL7pSzEtsrn8Tu46H5&&fJ3J=F2OjK^lCHz1 zzeR1Csv0BDrCo`=m_9h1tkDawj@j0wP{C?%v>`dAI`%RYVa+bOY0_ZDL%;T5jy%o_ zvgK}klTwqq;{DVen1`fW&Yepv+DT~nGMX9#ScWVFs3WGv$JEBdoiZN5$uoX_sYW*X zpm77R^;!NA37+`jOE@#=dt5Q`s9fSO%_LABbV}yon{`2ldiOzT%1-hTxllv;j>6jR zTxtviSOKEvDg!cW1H157E4G@lt|>-^B;MxS7Yh(cWcg-WEy1u`ybtdD2hQm9FGvZa zSA2`^O*%M)ymv{Fr!gspHMcK4qMqg32-)>Rh|&wI6NSwy87C?)o0{9r~0 zHC+Cec_ciL*nWqp zuXo?HGH+-5ez(`Kay!U+iJgIGVP>VVf@pT4o}1zEQA2ugT7i>-i)%RiQWPM)%YcKu zq)-?sGW}xp0H1Ps@Sp@R48|-U{rUVySLU)uidC85Zd`Gb%V7K5IE(&nX&#c}EZX=A z6$kWv=Sm}U^ZX-)$vx*WVN!#-bfQI-Xf^MU%WJB>Oh`jdrFH)Z94x**?^`JX0!D z5BwShU-gBm>OtJ;k^3Sr-+oY7&Kr_x+9?EB-!wtZ9XRS9;@!jCF7taZvS*tncuT!A z2qPF?e%ozjg!1EE0}N=>`A~CEw+TU{mc70xns6a>3f2Rd>52=d5)z`Nxs}ssChMNj z5s%E=MCGaNG+xnhQoLlSrc|^C!fZwd(;lmw<6P%9)VX4a1h-M`nB0aUQ>Kz# zkuch9EWL{z596jd-cC!-Dn2LCQL;0*fQX;?Qm~8&GbWFyO$Q}rcxnYAz303?ZdG+RbSrc>(q;Ey-L+Uyef!q=?*7i+ z`+R#JpP7bjZJ@@!_+k2fr4xrxFvc0E@L= z)FN*pJgYtXMESPYwl@Jf$8ZQ%a0oz7)=2>K2TUAg6*pMyHw4itZg<&N|Xi^#<(?tJSB!ZE4IyBwf~%V5|HB^SmOD-VQwTy^3wGzT*2NCHCW}Rt^Tlg25LQBQnim!5A0NhzQmu7_?_} z9LB_4T5AChr5r)P+G*vR=9YXCnDR+ZO~IgvGsOvAYrIAe5J6PWtbZ!zX#lHqSX(#x zK>6l*Q$EfK(rsJfeZ~hdc&&9~aS^3`5R*Pk&viI2K|~P2xUM+C(<3>9`!`fQ|99Uq z7aoj7`kqq3kcl%zg|4+;%tdf|Y~+ZVrz<+>SQCAqd}GQoy|yiK{CJ1WFM0VxCQj2j ze1r93aYO<_&z~dt2N8ww;>ro19?Bggjg`;8?qjvtv|^D-OT1vB66Y0bJud-tk2lkG zLKRO$Yh&F>7vyZ5dgW z%>5-i(8&r)j8^gKq5^%eo(D=b8EwyX&_}EIbU!CMStUJG1tq3%@TB3Q z^(w4Y`~7s!=z0`u5i!>DMwM-PWepT72No#6h|*I<2OR6gJbGfqu1>K=bilzf75FYJ zPy*0v(3kpJhxLd={G4u&HFkNc^s_(>;&RO1%`fe80Bk^Ap>-g`xG@)zo|rKmtkU8t zCzRny{0iVM1t_e&)LH{#dn_?+ptNGG0c$S>loSm5(g+<;?|_7RcJtFxb^B;xpDlFz65ohtQ6LF($De+wIm!K`bKVI3jAe z|5hvcv?x<)h36S+YU}Y_Ls4NaV@4mx$)k^B$j1Wl|eAz;CZHfY}=fr z(}}SyNT1;sAJ*W}w{=03P*qh+PIe}jeDxf@cjcEj<-}nvKI?f26@y61*^%UXkaTtu zYogXo4t7Ol4S##|W$t<48TRa}Brhim&+`rmi{xjGF^Ez)j>Z~`wHAx*(vg`3r0?*H z59=dpt%=3r)HgKp)eFyJ>h<4Z)X1St4vR5}C{#O`-56hZrnSF6zJB8lZYus0FTc8s zg1l`1{|^BHYb}v*h{kA)@`@Tlp#Z^vgVq|Y{AUyqOyaq_($HDGMode%jh%iujze{A zJ^6Xr+;#hpnfCLm>D?VfybLB0nz+&of9XB%6Fz5djNI z3AJ^NeEy74JoVr&35SDJ*VMDGtdh#=T6XO&=enVZ9MbBB8DAvG{+w|ko>%C`t>Q~xMK!z_UBII+uyu^ zQ%@Ssj-C5hvvv#7XqXpu+~Qa$X+9-bvB*uE3f9;mtVkN{_s-*0SC_*oKNY? zCF#&w_`>H;X6}Nw*|EEfU@*`s4O)SIeF~|mYoMySj=K5=L@BoK+{gbuIgip6YYB&f z%)a;MBp3^I1f)NnXV6;l+8d=@`khV%3AIhnu9Q$&RmWAAPvVci`N<*wj{1fu`^qXAIG|69f8V@yH^2GAV?6W18^mHR z8Icgi*uzMzy+=;#s3u61;ChD4%n(n^DyDCr0<5)%&8az5ARVoF_Ql0SB0=97Y4+^a zLPkc2#YY)djzcsW$Jj(1nTTB_kuC(NuBqpjzkP`7e{nDO&wifApLw19 z+$@F<8;Iu_3i7kL=z=qu_=VG0y=D_zw(UvKyFD!0tWGHbP)bo-*TAhmy^3MS48Zlg zBMxG7@A&L-C(^HP5wTeF0ghME;&`RNGpMK+Umy=F7hBHq)f%=9hs+Hf}X(a~zPnc{wI$5=~4U8Lm z0)Kz>wgjE3E~ALG)*>QY{H;gs%AINDZ52vUw zH+7%ZMpbnkxjC5!q+5(N7-O0AjhlFH)p{~A!iR0GRGp!>R7&x)>#p!EK)RN!Ik_$! zJG4Kp>m|Tw{c=4+ZcY}v_Ed0F$xNnB|7#+9WZQJ8thMCkWMVBXK#I98O8L%K>53$X z_OMx`(~-=uRzh`cJ?EZ1hO<968e=S4sjgO@20-tkeBY`P_4{oC?^yzlL!Y92X8rM} z^zN0HT5AtXKdNTQ06!s+^p=AGhs|4e^YfCw5C}TS+N#vL+}XqFi1mmPtg(D+^7*MR zbg(Z*^Dl3)2+|_cqMB_qcg6^Z1F6aUxZ668X79dArvKu5oILv26fkXl2V*Q+w@fp} zVsT!6^-XU3pGVlfb048lfLPo`5?Lqz^G#t)rvTYrixh!q)MfC%ew=&G$q9;;PXx~x zUy^w+Iabdzh>}i;LmL`n2fEw)*=``)@@_m#_jChQ$bB_J;6YL-o5fTcF0kjc*0Qfa3nClfVVD|YNE%v zlvh+!Q`>;!Xigq=43o}1ozI_n0z(J&BPS;lYb;eYb?n$x#(OK*^Xj5^Si1ZJlvV_T z4z>evrqhv~2Wu^3#~hz%O<`M=;2C36bP|omnYZ9={{D}DvAlE*J9m}Q7>&1}7ZD*C z4A8${A(PHMjUQh96$TIN`-v|~+b^7?wz!;tlL{T0|G$W^x2%e@#-6}~_uiD+0^6Tq zl4bsN^^M&7m*@EN>=)R+b01160*)p>FPn4E9>e9AOyG;>jU_uPa=`QS?v=-JL;Ev+ z+==|;2VdvqSKs8u;+d3J)DR3f?Y2y({X#qlhk}e8HZW0xci;=*8Iy8;9+@+r`yYIP zRUd4^T8M;$ghD|gk#LKjv*Cza0q%IsjxlEK(96Wo2d1uU}y@aoH-B zZrHq&8*iP-{6(dNLqT$LvQm?rRLiwRbwx!X8X99vx&ChI>Kpmqm0xUilG}dSENEfz z(pBu-T~2;(R^quduIo`*Rn73@j^@9wyMnKO`K%TU(wa{ILPkcI8Mj=+Z%d}2ln@RD z2?U&$)u^tQqDz1^>$b9X{dUSKs)@T^VjeOn%+H~yFo!_{`%qYzOLa{>j@GnC_~}SH zoE@K5UBaOt*_n|8nh_*Hd}HYt5$EMrmi8O_9NxMIgTbX!xxSKt3KGo=#fJ{cD+o_v+EgV-1Qiu6^*etN-6FrzJ?!Mbx{I9KPP;s9&u|gQWC_q z8+Y*SfB!WfuH8yues)6NWASY30xnSsM|V6)pxTv`v%aXRuC9^2WmV1IQa>O0tGga& z%ho+)WrY1$u$e30-lA)*6t#7Y{PWG#pBQ%~W4!xkzsSRaeZrI-3`)J)$){4@V>-nF1pTZi;Nhb{Dxre85#;M0Ac4nm0 z_DPcxmi3!yd zww?RP&WhlA-lyIxqTMMvV<00EX2CyKP+e0`R>FH-w{b_pd+Y29_Qad_F;tjzw5Cp< z#nR;;@`bZcV)i{Zl9!vAveZM#{kG~Gq-s|Dd;MenTikf-1C&)%lbxA?>zN}WQEz|J z+*-onAe*-A=86AX$hF_OFtuPy;tO}?c9tZ5XJJ8ht2+ouXX8(Qdy(0XzsgCYhVqxY z|I-Jt4X@N#i#0YC4jk-y8H>BTw`v0qJvpB_&%J@;C^A$2z>(SIY5z8s_!f|v8R7Rc zpW}Kn$1$B^Yqnv#*Nl{`=3&`P(m;IN=n2{rfoyZ%sN0N}EavjvhIrML3$=xi2fP;l^A4 zLUm0&cTS(e>0^fBx?a;--AqKB3|qEt-^<+B-(lgBm8@Q~g+2Q!X=se4l<=}&H}7Pf zW+31Y4h0B>f=%@*N4Vn0>DWu;2kf~y87x}*KG$CVduILNdak&10uTJ}%k=G2fE)KZ z{2hH+qD%jNMT|avP)f>}q(`k3x83;|r7PF-l}TrC&9^3{QgQp|eMMmLviF(!;LFTk zRLZ{c8Zsgwa&s~X1{^}cKw>RyW2eM671{dPpsvx)#_9MhgXdWa^0IjD<)vJ9&9Aui zr~k$WA8n;{<$5wx5{HiIf*KlQoO;r+DM@2eE2@=Z-ojEIeR=_Xdl&GF>n}%Z)ohae z^;9dx>NQ)q{jSGZUb;55yJ9VD*u0aE)@{cI9RiM%cwXDd2Q(aZAUkKco}n;5hu0Uc zVB_Z9T=0c4Y~NW6J%wZR$VfzfJM4AEv6hE};-luZmfxGvySXG34iEv1@My z4?X#sUvZpL)O9RHmzSHx*i(k5Qe??cW6s|f^X|$G^e)Qdnkz48;mulWsjH9j(yPmf zgoDh!=Y|%ZY0~M*h=jQ0%i}nzZvj_K`E{FVxUM|Z;gITwf&qfT0FBXDhbKgd5Q}*X z?B9z)1A3*x8>JM{Se%ESd=1z2IP=uw89j0^;IElUS;>}ddpPHFqxkwo<5Hey(%Mv_ z=FwV9-1Ru~wBtGVoRg`oYwU6&vQI${dEk|7wjrG$$Q7lC$2|re-G@NHN##jJV9A>w zu==B|1cN~?JpZ&7^2g+s)X3oj>DxO$r9#+WDAls?$1>{pL4Imc(yI-q)72>?Il+B; z=N*tNpZoedM5A$f73DH+?1%#>v3V<(gHskrQ=1q`FM3t!(iSKq97=(rwZe5h-dV98 zM10!q->Xd}-@yQ)5;Dn6TXv&W`n9Wdx_r0mX64__8icT8cR4$DRUje^9@q!%=ynUV zd=J>)&3WtH^@Kwqj3uo)fjg20ZS$0t)V^nO?A}vJU40`WLjQzrsJ#(`?*R+*7L>AX z;|_9jGcle?E1Pt>I)zvZK_|e5&Aa`*zl6nA*EA4M?D-YsWp}X12coe!_sx2VV8BV3 zeY%AoUECov))EQ_Sifl(A8pu?Vmpzhvk*ezKnvPE)Nn~D#l5p$qV)ZZWMqc@egkRN zyIytq!%Gkxt*NbT;Mtd!ruqZqByyt|aO0-kyZer#S-4~sf0+3^dD)p5&!*)eFDe~c z%Nk?J&dT7irx&qh+g?PVPf;E@Ss8f7P+ePp$l1>%h$}zX%#VI{e=0eUu0wT&Wg^9ZO=buVrL!4cEDh{a{Ol!^jH&$@vzu!rDWi63#u$gc= zJufGHLM6d?hJw5-9(j5pjnNoCx%SJPIC3zXw(Ln!Wm{*HCY1sraNn$#`R%>`ht`UW za403&Oy}XGm76WTq@o}{o2Os+7i-qv5SC8_- z+~wrtWS~TjxJ0AN?MLz{H$y7O&t~JMT^MW0%8XE5;{(~;B9nPptrf2?UdhzkA7;z8 zy%gkS<9Vjlwq|JyWV%v{F_us`m}upBU_LdOuCvziyZfHwp1;0?RziNBFPu)wIX*K$ zlIA`3UDu;9Kbu~K*{QwCq(Y#qyoMX6&F1CTO3Ba7L_`w0)6d>;(z!#wQi{f?%jgk< z$;ynRTB&HQSifl(*WU0a)~ws^*I~HcAvLS$q1I2e0kj`-eDmV761pM3o^tg^Te<4T z_poPgB?b9e&HC$nc1PNtyrZ?Iy0(FH&pwd}XN|)148efIx{W)z>c{u6udJHvtPEV& zbc=IA(vn(5z;zATnGvR5cS-6LzVeD%rrdZxd-he6ofS?L*LIJ?U7U1e9IX{qHFf;g z55K~&V~$EC1E$V+gpbzk=r(kk))pxuG)7&94D8J{SDuG4erem|&;FC=UwMnd{OoQ+ zr~64g(ri)JLPJfIOTT<3x!D;QV<{`I;qI9)l9?ItOT4?`QCUv91;xg*WM@X0^o5gC z9hzr9v4Cyc%Y19}yw6mi|C#h!l@g*cmq7!1bIjnrzJpU)%Tv!UB`Y)BX3w5(LamZU z>!Tv>c?>$b7okKmqJO;hF57mNrOx+2y5|vuX+^Cff;CW7n3Kx4zVvD-0mn&n#^}CB zM5kp!Aed+wwqf&bR;=DkBog{e?PPrBsWmB9uB(q?Ei8HKBPwee$WQbM=*9-=?|w)c zrB{_w>?^NE1eU$M&aYgy-5Nt|YK@fidEijMVe9sC$}8*mXv0oI&5ugzrb8?!(ntbm zttAk2*tNHcKR-B+%BuQ=PN$onhpq%U;hY;k)XEudteZaK6$`HMKDC3>y7H^7#1YT9 zYaFGdT9iVJqzRDzV+OU8>T2ALS4b;Li9)Isuu7Cl9budnQo0tESJI8`5i5RC(<;Xz z%PewrQ>V3_kxm9_5iGLIv4%y~#2S^-VX!@OlU7@LT3Hiow1!3M{Y8;ASl-q;h_&LS zvuwUQUDk@Hbr39XzrQH5MnQ=Y)YGCgh_yZJnkH_7SSv~+sHdUCH~>Ktd@SllZ_`Rg ztTnb`+>}ZnQpRf{y{H@#Bk~$2;-7 zsrLi|k^gXG^`3~*-8|Utf|+K)TH`sv3>}X*+`D4_j2|VjOV%w3#nWW;{AjKYJ0B=f zeT;Dpi0YZZE(NAh%2CFe-Cm?Bvy}W#!^KDlp!+QQ9KP__1aW zopp^19}}f5#{2fl#dlOpUcFlUy$?uboma13ZO4x8vI1_b;g|2dHe=TK@x{(dFWu+0nW&sR`AI!z&Sl=1^KO~xgfgy; z$Ld@`oE};`qUS`rfncWNM(ZCaoj-j_5~EM*=PVQ#3ne9H%*0z}I>C%<wy&! zQMTLfI~>kJL4RJYwFblnLRmT5_8H0(~6bS)BN@( zaaRzXYC$DEkah}$$sRw}T8s6R3OIhtZ*ST=bElQ~mgsw^?P-;tqS;d>-7-lC%+gNq zC^z1KwbqlS7Rf!MQE1G*sr%fy#B|30}#ePFOp=@hBJTI2ub*zu3m7e5#Ye&I~O%bVx z(mIGJjps&d1bNDO@jKs|SF&pVIj3_^A!%}g0M5MVw*GNrzbTfBt+ivd4ulZFTEFpv zHC_*RElzLMiJs~H5w4yWkBZ2fR(Lj`*S5#I5vUh?+KKVR8`^+vvLDJePR b9jN~YQ)PhXvUDrI00000NkvXXu0mjf`HKgtA8Ng(ZX{ zYA&Ur)il&vuIWBRd0+hp?+?%OoX_Wce)yc{bIx-fx*N$+QC>@4N=izR2!Z7F8T+R; z!`6G|_?1K{sm=35(3bL7@j}ULjGCur>d*Z%D7)DS@<)d)6i8ayryJO4i;Tz^M!HpQ zq~@#cMy6>=!^wu5|5gr8=?Np!VAhvfl+|lsDV&dLK1W%BnLqun;T6tLJ@=U^E{q#W zHK4vJoLxx#l<+)Z(b~%ay}Rdzb@k+>E}FR}b0r9jNQPI*Ik3^1%%Wre4`ek1&P|)L zgh?AxtUZ2wPv=UdBfk)|Irj}rUK9)ir2{*ddDF^3OntydVo4n#t0iDzyxf76XH>Fj?EzA?x z>08Z{CYGi&d~8e%f$4l}Vv@f*$;4cVboy>*@%ETnsD07nI8mf!!^GfUxq&QFTqxT; zr|__Nme4B~J|3A06N%>fQ@&J&)R)mz-AyZGTr}YT+}~Vg{OyFt9YsoMtGossZT}i^ zVgB&`@1SaY-L`IH9^K5!Go-$o&`)#FR-H5!7=xnA8oDp~?r+)IpMDQ2ZTs5a_D;-i zuIdmjA2V)kX+(WU3@dukY*vujr(wbiec8hTd6@liY8V7Tr|!{~0Y=|K^Q?F%F)k-I z@%>Eh&>=H}F9kR=#@S|?5h;gy(pyZAL>s{cYughW<_vhSSBF|Aa z?|e!^MKx`(I|9o5Ef;pHG1SfEevckwKt;y(lMfdHY-)T1%)o0(j z+llBVNVP9OQtnOZ#5ZR7g$#(31DH`!QS7oZ>S5!dAMHsJv1pkNHrkGekL78yX)fw{ z%WfA4J8DfA)WR=|Ud~?(;WnrcRWkhRJcR(&J5`#6S71sJs&Hq(Gd7b}M8tPo>i3~SFbnF4Z6@tE%0hpG;`R93()7pc-_8Bd2 zV*_^XLe{6^Bf2sBoM{kBqk3Id9`{XMYsOf{G)9xSwia~%_MyEVr8`z2orn`ZGBcsE zGAwi52#nDj_4!X!L~)=&^QQdG!ag^LCn{gz;YP;3(rqi*zjqAgo$GWQ_2Ec#Gr+rd z>-oBPy#P27i?H_s2rK8tE#KsFqWb(c<-0LHT7I__v~@Dmwa6G37M{s!QYp^4ya}!6*=g8NjfKqfQ}>B1v~6RV;K`{JHOusfR=~bMkt*lPV^=f|7-5cR&ti#wH?5anL^OUuB63s^)@zI&3kg^PQ!$Q zt+##XU>U%BNYq1oj|CYZ6X(nFGeq2c;AnyBk@=;qBOSQ+p#Hhn7;`4)J@eX8eP6L; z$srZZQ&IqGat$e{1qEq7^@Qms&QO+#@`jiAr`yYE978j2lpd?es}N4JsEW8yMh9Kx zGX{}nGs&kT->8NoJP)7CHOY?OR#JKKTmQOxQ}ovW3C5%mZ|<& z=vCUf|Lxd=TPa-I^TGwwR|kgKkCeR{BMYp}+*(^+5Qd*yOdAD!Ubs-|hrAfwuC+SD zqq;2}bku@jH8k3wG0DyY0&Gl!7H3rvt|bw9wm@S7nFAdMXrW&8newt(`*V(=l(Eb* zI!80jo6l1&ZPr`h+OX|A6CVy**7Spor)0Yjc?al<1Ddy*Xn7sY_H>95+l_aod_!4r zcr!g$oz$GCb7>)h1^MG?wuFg^-6gHjA$gm3t-yN+_n0${2VO9{U^=%$)cWvBPvt5c z#)il^*EEO`l>Y5NugKerxpXy&tB+upK>TOA$*K*tKsZ`Eff^!bf28^YQ6>Be$5z(0 z@i7)I6iR|uQ2R+?srQrnDf)={L&Id0#U7U0HW2NcddZL(&eP3O$nSajB$6)etWolZ zAWUy`hR>fWLdq;C*S10ALl-Ta9Phov=`IulxFTA#%JUzB4wQi7+Bh zl~3WTE5)-FIUPq$)Pbl%p`4oseAf4UhfA6^|0_4q&o^%iqZXRCO_g>Xk^Qy!pFIw2$+4akhssq2T*7hxQ&yQO zQc2O6M1@rfmC`ixsq{6m{$n7~$~Ph*bE21O!g;DvQ4Se|#RYGZQRlH{t{nZPo%3MV z23>(}*lXus%gMGzt^+?iJHM>KB=eCwu%QW77TF^M)rp^hC>vqlu?9LQJM$Tss} ztbE6)Zyt^Gqby+J-le%De$KHBXy@tf?HRJy%@mbX^l9ZCfs)C(66j%^jt9MmkE;yy z`Q-ike53)wQJ<$?*U}mPkIPPBfqNNzy%0ti&|s|r=;TAV zPu;3b^lcgpFgL835F`Wu0j=#C0!<^0i51&seYGzJ?Fldeg#;Ao+#VNqXG1>9%r^;P zcv#zDX4~0@5O7h=j8-9?{6~YqL}ZXKptYSKqF2dQ4?7ZFD1ah96lp#bECc`z068aK zIu0ZsBSHDBZGc&UNMxk+2x88xK_p2SV9ljq^_z;4+0XBbO-n-_+>x56bNh{n_#=HM zlQ6~uDhSp#0Lp;d0bZE1#tha9gfI#7Dt~t09{x`K=S9oEiMbeYD0v0U1z_vc1+qdZboriPm*vs!A4AB(XXSfRo^k&5^>INx~S;%CbOYX4d#v zfSE0lrpek~sdl&kL+<8w4XGxalCs zut^$*R*BooH+}SCV={yqB$>H$ud!XU!CwOigE_-OrIU9}!~kl9O@qp|OyBMDP1+Ub z0s@G&P)`bBWI?kg5M|V0+I$c+Yaxs*E!2|$v4g2YK?mPAW35dpiNhDGt!%W zOeX`vVr388yLskXpimgVTme9nkPnhHyxc%$WJcOqW7Z@MFPI+$@Izp(AOPsx>&mWd zU;_w#*Qv@#Z`#^o0E{TvIH_#Qj9mr*uqkeo(hO)j;dsuOk-k#Qu!Z3XD6(z@@CN}D zNHAZ=tl@@NnUUt{31$s2Uk4OO$~oyfJtaJ=Nl*yPoKermMaV>~xmF=D*zgGRe2@$= zD-A-%xjIgqEg@w{vU$D%&^aIhu;PsH9zW?O2U9x7P)ujZl;?c(0Tv=jvABU9vpXGb zhRFGP!>cf~&hV-G08C&&F`Z=yLOzHcHsmNo<^(_p0V0A@3ei{`iG&TUHG~u}q=d&~ zK#+ja8kS|lwiT4p5JJG~HR1JoAccU|8k#wyhd=~k2tvL=BnCL8t%5oxB}hp>zyU>R z)Ye8}*)}5KAWk083quC=!Jz)V(5q(`bji;_I23@C5=v>*)WY!KvEP1}%^rl~uKP{jHBN z`T4i8ar1U$XNAEGH~MR>ED%VLW9OOEP9IR$4g%OPBx>uTAR^p&-6i<1JFmxplX|8= z(^|O>lsGJJps^Dsr33b!y;Yd-^vihhl^O8+JTMFcTI*I=GD0ktKrEg>FyMn}8jceP za)D;o9PqIW@HKpd%{_z=D6go-X{YwXV-Mef(IZcHBvt9u*V`vJU}&u&rBe-D{O(-* z_pc`*p0MEcm@de4=-|c^_}+Iff;1#%&;1;G%c~KKCmNnx3V1yp_`Ig;BLiz5DNO;1tJJRM<@zx0D9r#uW;iXk02V2!;=E}&;U*(ES%K) z1ibyqW5~;mVEKx5*tB&QcI@1PvOSg9wqp-gt=)``o3}%2MmQ7z5w%KN95H~q4Z4<( zl)bfWjf7?4@rUogowr_yyJ<`Ln6--0o(|@hN_ii1Z+EfS9s02qcSu;owYHI6o zLYGc>Ve{a_&iey_9G%{(^|!PXj;)wR&< zIC4Ip7u7ZOc;oFkD6gzR|GqttofUF{)9`sc=-;;ouK(^R3_s&!tXWrrUAxPXux-@U zMG=c7Ac)}c7!6Syb=;(GCtt_yYjX*jH7rYE$}}BV|wiOIR9yK*(D+NPJ+;ICN z_+a*8WJQAe*-IiqJdwbl{wHF@@F7^dYz<1wDp4PeImC+AsI93-?-RS?sw>XN53aui zr<~l!VcRsPv_oBe6l>O%ptdfG%_Y0AeC2w~|LjYw-?$AvuLpj=7fR_4lW01jodFg? zpsKnK|2y#>+;sh=DK&XChte8S2&`PS5#PD?7Y8&E%)leRz7^wc8H>`g3RKn9BN~l^ znH@$=IQ_hNxe?^$Mw)g8a`UZ~)zx(qw8QF1K_5dJ+0BTOD?`#vk5C!Z9u^9b3keI{3M+4=k4hW5D55i z>8LZ2mlHvk{2X-6&qe3FY;?-aLSAkJ0l#-Y+num1EL`*zX3klP>Y94==$;P(!RPbf zjG_HeaMdVyOatpSY(p#-PkH<8wf@ab8SXrIe0$lunz|^?JNpzo`}937V#g-1^g}@WA-L z;GZ8Y!ppCIfUm#ViYqTW2ZmulDUEO_fQv?+hD$Fx3)9}6heX0Y;y#Jd9#kFyP@3WQ zdGY(AJ7JmzRFlQPF_Wb__pHGm_%uq^0 zYn}Qx<>)Dgcu6EI{PpkC;q#guQXwVU!}iwJMe+S>N27D6Y}eKu+Zmj^+$=Y5Ob5@# z5TULvhTCr$i+g`k0OppdRU&fy4mZEe3|B2T@Z|SB{47?k+Jr#B?-nsMw2~bOu-mMn zmDb443gO0UFLvC1Y7MQ(ZhojS7eLKcaJ2Eji&Wx+aPZ$PghVWsK+o>^nDCq14jQ{A z-^BsBDPT*!_!>|DxfmaR`USGHf=F1lyIxL4Or|!4Zr2KxHaS^R2vpbBWAw;j7|^eW ztDX<86f`qfbE=X<$^$=xQ@zo4ppz0Ck6Z517cDzENtRq;M-Hk6-Z^H5~zrm6(zQ*Uv*P*)^{|zO&ub#! z_d&BpdvGB}TT?}rtuXq6(^JxHNO?dT$Y5uY+=bY_a}U;U+>R|70WB{d!=?px*F$%oH`)marnZKg#x0Ih^!C^467Ab@;<$cj4j-PHWL|Oi7^>*jrwW zH{YL+=^rh|n)M~9sHj0*GzQDE(Jen0*ZtoMaoI&@;`CGdA-{7rydDz?%SL%+4K|nT z!WS#nWA?l+uyWN#1pM9<8FJKAG&3XM_hR_z15$*-fuzt`Ai|cdyYc?aMVLMB3#?hY z1?81Buq?YlH4z|4!0-2lxCP`0-UrfInl@Ijn@o?#E!0P2=yPIMJW)6f zS6+H{tHLm?9q)eY_A*@j-CrOYPe2NhdOnAO1S+a((XC4!CQkSnlLqc1oOqs|+eqBON;*tQKRJPZJ#6f*Dj#EzVjahF!aD4X|4rcfj(L2W71Q1;|JGWeAtq#l!BBJuf6#R?)t@_kdqy5$V*Fsy1FQo*0}2O5h!}# z7M$24|9~4QS;kva>m(JD)gIwc0G;!)nj}|}1V`h~?JBLnuF`VU)<(eqf&njr0Y9=L z0c3@Ph$SqHxcuj+sg1&DgQ_-K>tiJo+gXudN)q<1S5Af)KYd^_roA^G*;zs4QQUmz@9^>b<;d@p z?Eq`*CLZnKs94i55Q`-+{>hi|?yHZ$I7Bs_+z5+4UxNjoeT7gk0O@AEvLZqJ>cLxa z$1PXD=k=sGYk3&jAsOZ&Rb5W0&5_gs%l^F{zkc)uEcs$BOhdxw^T3o69T0@t$ahFG3kM|dX*`=!g*Vd3Ic zxaj<$2errSE*>-Ecax?ff_TTCp|esyPUf&o=d)dART4 zzaSP*IF6zlCNi2aW_Sz(tG?dS#KyIxI;E8O zX44KlHt{w1JRT@pVe(UV_O(aqmOVp=@szaXxlAXZ{N3*@Z%K zbe{{Zf?*tbfqm01$EH(jhH|qETVwG+zeRNYiEF|qFyZk#Tqz_uNGaQ81Kgd4RhB!FMHu-!W zY%1A}7hm}Z_uS>=gTtYKBg`oY=61Zp!s#H$%ZtD?js4>9WH#rkHJkC^gug*)jq$&_ z8DlOvt3_}fTinoPu`e5^wMHUg!?NuLt>H3xl~r}vv}GseELeemez+JrOUsd+6-@E& z?fw28Ub3S#BN7f^((~`(;tPji*pR+X*wKV~q;{>$u$0D0eNJcuC^d<{YwM!8=b^t~ z`>t~Q=$0!m?&dL2YJYH(z&cqV$Ahk}smJQIC0Mz76V`3mj%_>2P+nP!`ns6YwvwU^ zosAZa#Zg^T58GA<27JiQ3N2lOJFOYPpck7;cA?<*$MNP%58S|$CTG+Av+R8;r%xuD;(U93`v4(SvFpJ zZ5ICe%1o?WwGm1&f&nkGB0=PL%5t9DB}w=IJ3Q%d;TXJfrd_Hk8?#JnzuAs41rOnm zkKc*gZn_+Ayz?nSL0`L6DG1?k+e6Rni(WnRQ}tg-J6uy2#e|8kp+08ehN~~YB^Nf$ z<0ijP2!R>1m*RJmrlGbriZh1w!}V8 zV!-XcnfCrdghT!olk2T1Fz|XkIB)pLO+F{3K@KUo^jV){#PC7*rhl{);c(!XY6&9V>*P2e zwyoRGd$tui=Vf6)zwRlOos_OO-u@KZckYGH=fMrvT)5xS4e^A9U8R-y{xuh3rO$-~QVNt;);6e5mp$6PYcEI;f8ewEn4DV{fVzv-QUoChN%z zL!#(^UdP&v+mVyX3{l#uXpv6)QENsx=*L_C{0zHFD**r=(?F-32v{=`2|HD1j8-Hu z3`64Cmp;U+#dDDx38iYN(s_y_c>qXzPiDaMnAlxbg(v?sE#(XiIH@}{1F@Ke+S=%0 ztL3ERzC864JUa0WL?VHvRgGyp)9a1~a(LsctRSYow-C?0JQG3)j2J!;h9OZGjbl$m z%>m=!{R`HeobR(!KE$slyo_Mbm$LcC!%4(OI^Lwinh_59@#xc2VMvMFZ@Ltxp4rCre;QVRuFs2YhYQ&L?y9G?=5{qJ(<+q+A%lKbJD<+#6S3aYB>k(V1juBG<4u)+Hu=j3?S z=M5i}Vi%L(KL5&WJo5BZcs(XUp=6cGx8pXJ<3L3_-GPN~a`5j}MsO9Hg{s@o%@hxO$1)YX!{q4J4 zHSGbGQlhG+9=G3g35E>p3Cpr!ng-tg=yNo(TqN1`6|6a8P^>uOhb3>Wf_jvH4ljke% zt*ph;e{aB=b=w+lImvj>%>c~lmu1oJeGz~D+ee7S6HZSsb4EQku4f{G0meEZi~+1| zPN(h940t^zHf-JvB7(;>knyh?R)>T^TB~&i5lV@~04Ai>_9r(@l)Oi<;Yh7ff5{N6ty*Y%p;$G4>kfcc2w{R*Xrz6^LfKQoh%Ac!}CVr2k;M|)n2+wlofNI$dAsOdCT&w`}1?RdRc zdtL(oF=|wyv2$|s!%t2 zV0KBYxOl2|oa7dn`9c8{=~G8OkYfgXGlevV+g80zB#g|$9!rv|ZNm&0T3Ji2`q<@P zE_kvci5(7HHd~}oSSY?+@MMKmAG=&DYpG!d4Q947*_vaaBHU5Sh8b)MZsGpmwp`Q+N3kyY2QIS4- z%+DeUot_rLGft}nw6<*`Av5CKk-n5xLKudS9%!9-USiLEOFo=boy4dOw>mY5{+X9Q zGLD3JjD(S|tSFc{8A@eP1h%(Bwl`7aJEx^zq(nLZUreA=OV69%Z6VdN4 zpZ&-@6c!51n+sqxqdo6sYaC;pby?vlB+Yvne7!WhVP*xbEU?zv*%2TRXX;oFt-ywp zYeqr{Ax#iTC_7$5plPhEiJ#9Z`f6Vc4&5y~%zCbZf?|>UBj=8t(B0B}12L{<=3zn_ z5$D!x?M$sQ7#^^ZU4lp;xn^3~)kL&{3GbMK-dHsKq3sR87Bq=*j}X8q4@?xS9zubXq8)`WNB9*fw$$R>4c!AM4r=P^_ar)Db0Uh*i;Jh4>^E74GYzA9gROf4 z=CI(Cg@x4(*|^r#P5vI6m&>C)S(aUye0Hg<;&h!AC0mqU3|t|B2eL%z&BlbDVnFw} z6fTkk-orw@{gE|uBNVxu|5chQe8B{|`j{Uz6P%=S&)Em_#6@n^PLwl>fBp+ePV0D< zgFRhwKun2j`0~>$EL2c?5~MUbe-4kv>dv>%dvS@Ez&exi!-Wdse|u(Wgwp$aXvmsS z--0t6x^v8VQ1*ri?_DHe2w6ky9H3JQd>2B;?B}a|)%+6%gjAnCJ1c+xZl+fuRW z2B}Sz%QNV6s+j6^Yc))7MoW@ob|Fx%X><#>t0KO!>y7DyK{buYpS*`Iln+svXI;2+ zL-*hW9FKM_q=s4_?bulTT>05hlrY!W-USgG-+smsDyr^XY%{;!2d=-Y!yGii|Bzkfx-t zNBO$5BpahU-CNK)mE2L-dVd5K*5C6!sOU;}W?I=*j$72lpt4A*s_3ZI0U$|v0aHpm z(54}js#!Gz{utHXIKZ>|1bP)f&j;-kZT-7guDEg-+4V{Sw^eZr^E-Y_AH2S zcNa_g+s7`i_x#C#UyjA~yO)B+Y%)&JZmDmU^#&{7ZUA zWUTTpQk>N(y;A9+sIj;krNh^m&M%$MiD460U&Imp)I2*$dC)?O+>NC9K2C@9dU?y zxH6|a%I$L`PK&S^;|t78ok~(Na0c^x(WT^F`XQEl3g(4;R{C;J&M&@7iMt5o^e=(;f=W4a9mz>|F#QdpRRh(B#Y*D$j8D zTlxD1WYi2^>QEOm>~uPOKxmPsMiqj0awfm4da`Vx9V3>vrY0`ft3Hj(`SyLVggdhx znDoQ^kMTJEaH2-9$>y$2G`e>vy^c~L$p*UDZGm1~%&R^;XZgO zWExz5iM7(j7#%Hy;t>9(wd47r7yG63{c&MNHNpHFSIRUbRw{svfwHfOYN
      yNan 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 Hu0mjfyJ31P literal 3096 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 diff --git a/public/res/apple/apple-touch-icon-167x167.png b/public/res/apple/apple-touch-icon-167x167.png index 3f26f91b57890d8ad2c7327b6c3c2badd7970d2c..963c024d6a1bc63f6ea8168c590f84ed03a5c091 100644 GIT binary patch literal 11346 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 KLTbRSul@(HS$PEj diff --git a/public/res/apple/apple-touch-icon-57x57.png b/public/res/apple/apple-touch-icon-57x57.png index 2e2081f5f6c211a342eb4f6e27e6e25eeda7f0e3..5cad75b275f1666ab48515772539ca73305ce64a 100644 GIT binary patch delta 3473 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 delta 1169 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 delta 1242 zcmV<01SR{e9PJ5^BYy<6NklyjoN`h7~)u0VV z(wY_{+Mp&%Jn+a?u?8ch#e=q9LB$_RX{#WJkWw$udZD6GlxU1sZDUPU?5~e}C%((< z?EAj+PG)vzo6HYi!tDFboBix_yw7`#@?~bxNYW^`zkmnq0e^eI9?PfjkUK{@_&@10&kcRpu4N`xw_h% zr-2U@7=lGvJAdL<-)83ejswGzB3QjT=LeOn&0~|A zW$N8j5q~#FvoR~j2TWm)HWgDwMg~QrQEIEni)%|Nu&B^@xMh+lFg!s24l7D#l?IjE zcTr`IX5|ZO%Hlr9`zZ$&#jjDw;Kpgn47jhOie7eg(3=}S(tkZC>Fw=f+VgL#Bbv3X zmar)WHYPCC;TTs2`}*=h4b-SP)m56CXJ(F0Uw>ezXMIuZJ>&$8`~9ayT;OMW>bbDc zsh@XHX(5u|*wh0fA*ljC?x>8tcW!xzx>l44E&TYKnL=P_+LX!3OxT@h3@n?6Re>Q@SfhIb9)Fk2D6s{r42+DZT2_kGzjyACswxPv zUVj=?Ut`+__DNPssHV(d2M&KieOn_#f^`biGO%djuM)?9eK%VP4$~15meIPd0LvEt zvU-|jXfx1((>r3x3B|^f-Gd>3dfOFX2(_@)`55kOW?5d5fsD+cQFprnEOP66YeO_} zsGbY_Vt=g^w*S~V;e7k*{2r~(;;MXifPbeHh^Hr!mCb-ztt=9(Y-jyz3)p9I=ff;v z0Y(N3!2?_cmRT8vh0(QT7Dho~1`M}$6&S*%oGP>oSQfu}VAo=p36+;@%N8r6KX=X? z%+iPIZ3DZOrBz+qs0Uy2S4OgBAXE3R?T*3P0`@8FRqHJ-Dmcu5cdh*_eSob%_kV7# z;6lS?MX=AWAEmNTVR`}k-Y*}`j$y8~j<@XA9UGTB3hNlK?|mY}&Ik`26&m)juN(%(K{eXxdDKL2CGB*-Nm*K0}02U(3PbH z)VI4%+m*ca)okJMY1#sd#DismwNuZwlnC3MxX_YvU|eT_wRnfz>iCJmBFj4|urC10L>r2TWBkZ1(EZ6#xJL07*qoM6N<$ Eg6OnkE&u=k diff --git a/public/res/apple/apple-touch-icon-72x72.png b/public/res/apple/apple-touch-icon-72x72.png index 32d5d51646aed74aa40cb39a4af3161802fb74d2..7aa6caab1206aa1727999a4e983d1d909f52102c 100644 GIT binary patch literal 4503 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 delta 1511 zcmVw*C@BjlSb{c*nOwX)#W}H?)nIM^? zj0vgfIUvAc5hEk4jV)7)CTnC^BgE`GW&y0{fRIA6aLm!Pa=E-eSRed+dkOupvz*4d zDrl%JM&~-BG}2K@XWNQW*11{9906)f1eM`9fy5s8`+pr}G|_W2P42tJRzK~ErGWAj z5KP!9AZFqs^G3zO10z~^V9Wx<;jP84Rx)KkGSM@>yNrH)=?+5^dn+@pIVnhkHKjos z-;Z;((KzC>H3M5q-Kt+r?ICZX=}ID*jJku?k`n_!*r&- z!n+iMhkw|?0R$@?rr!q}jmWb4QKrfk4+R~=Vd_{@JokWLuDs|$N@aq>boGsfv`E4} zDx2sTYAdJi`Y?Unnd(*FbSCB=5O&5qKmfo41YLQpF70#JiJN~99!>@hpycJ{QGb6w zZQatC_C6wUP61&@%u6p3944!ATcb$YYqrG4z?0P)%iJhUtz z_>uMVW-n6ln(eDYv@W)U4sD8f4+y2%4M5Pv-W4gIH}`8YU@r94(AAH7=&$eIr+>yj zHT1_Q3k}T zVom_1nDx%(H1c#z%75T9JUG+mhbR$^`wI}H3JAjP8e4>ko=TXOO;lat;jxdX)xa)U8Bkxf zBRE#U*xlGUX9XxAXjbvCDsZL`uFDz>n$lc=aSVtz?tq`j!kK;N0lM&9Rn~wWsVT}C z97ljK!^yT`g{~aBTM5MFqZ4}h543D#1;pnusK zU!@qtJMQ5P&CKZ~3&*ad`19PcBqXFl$~E%^bN$2nYKcQ9A)4if~5eD?kCkY-m(4ew8WjvtPU_1qjCNOF$yC=2bKq zK)i8=SpexvKu~WTzc;f>HJK7G1Am8AG~WUec@cz}j}?;0q_O9b1q2;$jrtQ%K;fhn zhldQ%wPVe~@~#4CPkqF{fC3VRZH^{v5HULvjW<+M%#GoEP64Uj6*6FTMY({iyJtIH z{OI|d!Ep_c6G&s7#auw5xieF5d&~R)ZTxu&2=|^vfIfSy)L=MFG~cGrq+CRi=Ya5E=Rq=mdk6OpGeN>@Zr-l<6(A;j z-vR)Tau9B*+NBKMlUet_0_0a|KpKz+qycF_K9&Zg0ck)QkOt&q{R`aM)sM=ki4Fh& N002ovPDHLkV1h$`;%5K= diff --git a/public/res/apple/apple-touch-icon-76x76.png b/public/res/apple/apple-touch-icon-76x76.png index 1164b72d4d511247bb3685036e3858b7de7aa3cd..1448098d13759d4c902b5288fce71e367cf64e37 100644 GIT binary patch literal 4748 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 delta 1590 zcmV-62Fdx1CB_VpBYy@BNkl_ttFlQorT6j%lXnLSLs{Wag4hdF!q z?mg$;Jqvf2^Wr<;?qRQd_&uL<7Nw@7k(Rs`&G&yG(WHTCV1F8zr=)>tU>caGq=9K* z8knb~foWhGm}~j(Sh6sI)#Rnq^K1N$ex^Lj`OhR3u#dN7(_>YcQtM$^XE$aUTF}6t zO{E#a^{_0qenA3*WibE{07hF2p!{06rYC`|DM+V-_Xp_xj%>R6c#ffSZCSLp&QHP2 z*n+Pa*t-u|gMYwlc#mJ(P$;&@IRzHDa|sPK2WBBf6JWXpX9mWg@mY|?E%Q3rxHl)j z0PCGlmJk-x=bhOUDtG=DA8HIlfPesqLF3~VuS07C-T?-elZC}==e^F-y&2di9XWJ) zdp>>h#BzFjO92fu=F?Z*%jmOhxt35p0Sv#i8(6%HI)4QQP*-*YX{^7D#-CkbscTOc z(-)n2VbJCs7+ewV6$>y();;k{!=mg!nFX)_tU6P=0w!x+qfZsk*zQug_GDhvHOQ`_ z=fT}%LGc>hRYI5AbChOESHSqv!Ab!1>ybM8VJAl$G|Fc(}k7=I6N$-Td%dy4}C#L@oZ#!aZJFVx*~O%(lM~E>P%Z$a6v0r zJT$nfjB9Qz&yE=u^hT&~{(y;!)M(bl>$j7Q5$=f+#r&*etp(ChM!JGj=wD3rwKa6K zH80}*K$@Ds?--b<_PRN=K?n?**k2>GvX6R7q88^3d3``X4 zWneLxUDIr3PTX!_eXaov0zC8kyVmA;511%^nKga9KH@X@e$#2JI@x_6?cY{QR}VGQ z^?~Z-0|uG1CZ-!sfz4dlLH~Svf&Lx67^dGx2I$!4VyX?M(Ootpp@ag4Bw?=0*t5=e z1b>CFZe2PM0g4xMQ=cy-Ie?+eFlS8EdU#g%s-?EDZX9l~g#|qlT5kYL@hwgcV9-x{ zE5beptrseMQCTrD*dhc5?d)n-0!ukCWIZ>)&_>N>+McEK<1_Nr{a>HHY71-X@_EYk z=gc24Q61(67~GPTCF6VKClU~{;h!H4T7SZt_}~<+tgKK5%M~zDrH?A!4YaY{C5~b7 z`uXM6G=1(-BV*$0cl*{EZmklQD`2A0j!bJ5VPUU7K-ZrucMZ!d#6DH6P|UzKlw{Zf zLjZRQ3~f@ZkiPCKGm6uqfZAQ6n1Nw-rLsM`In<=YssJorRuvO4NVHSrB#ybUE`P); ztJhJtc@h}f7gcWQ1Qde#-(Q}Y&4S2|C|=q77&dwmm}u$>w?wHRf;DrZK^hkNLaWjN zJtPk;-fro7n@u#=m&uk>hij!^O`h6pTbu$Kn@*@4M=J#;8db|w{lDy6DFw`G5@8nV zuW^NA9Ob}d_9hX~?SS2Q@opiksejWQQvD*JdKwsn-5yaavm05<027UG!djuh7H1TaZ67FOO@OOk5*>L0F#B4Fu)MYL>V(0w8;ajlmdhMK3uJCloJ)s(OQQlq%OeBp%@h4` zhZPrKOrnVfukU;FZLJK!Tm-D7uqd&CrQXh-B5PwOt8wtn&dTg|hdM(oa{?>wz<6-5 zI0g@p@I4xM{`BFQ2lI`ytbbM~rbqWwgtbXC?;W8lFExd&eXA|YIF}!fTWoU*Oh$e8 zSf8`!g=6N%?CWT&?K+&gPI_Qgw>ZqgadOP=FNPw|@%|7$Q- zcZztyT9Y0agwxJj%QKDZE^sRd-{UoNT*ANcI$H+;l-*6Yqyv@+(l5X?Fb&L8(!ew@ o4a`&0z%(!o%u~|9G_d&UKc&>qycpq$2><{907*qoM6N<$f+NBE)&Kwi diff --git a/public/res/svg/ratatoskr.svg b/public/res/svg/ratatoskr.svg index 3ca98122..1092ffc4 100644 --- a/public/res/svg/ratatoskr.svg +++ b/public/res/svg/ratatoskr.svg @@ -1,15 +1 @@ - - - - - - - - - - - - - - - + diff --git a/src/app/features/scene/BackgroundScene.tsx b/src/app/features/scene/BackgroundScene.tsx index 10785a5f..0c8d0fb3 100644 --- a/src/app/features/scene/BackgroundScene.tsx +++ b/src/app/features/scene/BackgroundScene.tsx @@ -1,8 +1,9 @@ -import React, { useEffect, useRef } from 'react'; +import React, { useEffect, useMemo, useRef } from 'react'; import { useClientConfig } from '../../hooks/useClientConfig'; import { getFallbackSession } from '../../state/sessions'; import { mountBackgroundScene } from './host'; import { readCapabilities, resolveTier } from './tier'; +import { getSceneStyleOverride } from './sceneStyle'; import './sceneShell.css'; /** Set on while a scene runs, so signed-out screens can let it show through. */ @@ -16,7 +17,11 @@ export const SCENE_ACTIVE_ATTRIBUTE = 'data-scene'; * Tier 'off' renders nothing and loads nothing. */ export function BackgroundScene() { - const config = useClientConfig().backgroundScene; + const configured = useClientConfig().backgroundScene; + const config = useMemo(() => { + const style = getSceneStyleOverride(); + return configured && style ? { ...configured, style } : configured; + }, [configured]); const enabled = resolveTier(config, readCapabilities(navigator)) !== 'off'; const canvasRef = useRef(null); diff --git a/src/app/features/scene/SkyStateControl.tsx b/src/app/features/scene/SkyStateControl.tsx index 5a9d84d6..4e67bd81 100644 --- a/src/app/features/scene/SkyStateControl.tsx +++ b/src/app/features/scene/SkyStateControl.tsx @@ -1,9 +1,29 @@ import React, { MouseEventHandler, useState } from 'react'; -import { Box, config, Icon, IconButton, Icons, Menu, MenuItem, PopOut, RectCords, Text } from 'folds'; +import { + Box, + config, + Icon, + IconButton, + Icons, + Menu, + MenuItem, + PopOut, + RectCords, + Text, +} from 'folds'; import FocusTrap from 'focus-trap-react'; import { stopPropagation } from '../../utils/keyboard'; import { useSkyState } from '../../hooks/useSkyState'; import { SKY_STATES, setSkyState, type SkyState } from './skyState'; +import { useClientConfig } from '../../hooks/useClientConfig'; +import { + getSceneStyleOverride, + SERVED_SCENE_STYLES, + setSceneStyle, + type ServedSceneStyle, +} from './sceneStyle'; + +const STYLE_LABEL: Record = { terrain: 'Terrain', ridge: 'Ridges' }; const LABEL: Record = { auto: 'Auto', @@ -19,6 +39,9 @@ const LABEL: Record = { export function SkyStateControl() { const state = useSkyState(); const [cords, setCords] = useState(); + const configuredStyle = useClientConfig().backgroundScene?.style; + const sceneStyle: ServedSceneStyle = + getSceneStyleOverride() ?? (configuredStyle === 'terrain' ? 'terrain' : 'ridge'); const open: MouseEventHandler = (evt) => { setCords(evt.currentTarget.getBoundingClientRect()); @@ -62,6 +85,32 @@ export function SkyStateControl() { ))} + + Background + + {SERVED_SCENE_STYLES.map((st) => ( + { + setCords(undefined); + if (st !== sceneStyle) setSceneStyle(st); + }} + after={sceneStyle === st ? : undefined} + > + + {STYLE_LABEL[st]} + + + ))}
    diff --git a/src/app/features/scene/sceneStyle.ts b/src/app/features/scene/sceneStyle.ts new file mode 100644 index 00000000..7c31233e --- /dev/null +++ b/src/app/features/scene/sceneStyle.ts @@ -0,0 +1,28 @@ +// Temporary: which background the viewer picked (terrain or ridges), kept in one localStorage key so the two scenes we +// serve can be compared on any device. Absent, the client config's backgroundScene.style decides. +import type { SceneStyle } from './tier'; + +export type ServedSceneStyle = Extract; +export const SERVED_SCENE_STYLES: readonly ServedSceneStyle[] = ['terrain', 'ridge']; +export const SCENE_STYLE_KEY = 'ratatoskr.sceneStyle'; + +export function getSceneStyleOverride(): ServedSceneStyle | undefined { + try { + const v = localStorage.getItem(SCENE_STYLE_KEY); + return SERVED_SCENE_STYLES.includes(v as ServedSceneStyle) + ? (v as ServedSceneStyle) + : undefined; + } catch { + return undefined; + } +} + +/** Store the pick and reload, so the scene host mounts the other scene from a clean page. */ +export function setSceneStyle(s: ServedSceneStyle): void { + try { + localStorage.setItem(SCENE_STYLE_KEY, s); + } catch { + return; + } + window.location.reload(); +} diff --git a/src/app/features/scene/skyPlate.css.ts b/src/app/features/scene/skyPlate.css.ts index a3c0c567..db168251 100644 --- a/src/app/features/scene/skyPlate.css.ts +++ b/src/app/features/scene/skyPlate.css.ts @@ -7,7 +7,7 @@ import { MembersDrawer } from '../room/MembersDrawer.css'; import { CallStatus } from '../call-status/styles.css'; import { RoomViewFollowing } from '../room/RoomViewFollowing.css'; import { RoomViewTyping } from '../room/RoomViewTyping.css'; -import { SKY_PLATE_FALLBACK } from './skyPlate'; +import { PLATE_TINT, SKY_PLATE_FALLBACK } from './skyPlate'; // Over the scene (html[data-scene]) the signed-in shell is clear (sceneShell.css.ts), so text // outside a chat bubble would sit on bare sky, which a sky state can make light, dark or both @@ -47,7 +47,9 @@ const within = (ancestor: string) => plateRegions.map((r) => `${ancestor} ${r.startsWith('#root') ? '' : '#root '}${r}`).join(', '); globalStyle(within(scene), { - backgroundColor: fill, + // Blur with a light tint toward the sky, like the chat bubbles; never an opaque slab (PLATE_TINT). + backgroundColor: [fill, `color-mix(in srgb, ${fill} ${PLATE_TINT * 100}%, transparent)`], + backdropFilter: 'blur(12px)', color: text, // Themed ink was chosen for the clear shell; inside the plate every token reads the plate's. vars: { diff --git a/src/app/features/scene/skyPlate.ts b/src/app/features/scene/skyPlate.ts index 353134ab..b82a3026 100644 --- a/src/app/features/scene/skyPlate.ts +++ b/src/app/features/scene/skyPlate.ts @@ -18,3 +18,11 @@ export const SKY_PLATE_FALLBACK = { * on, the worst link contrast is 5.0 at Dusk, 5.7 at Day and 11.2 at Night. */ export const FOOTER_TINT = 0.82; + +/** + * How much of the plate's fill (--sky-bubble) is mixed over the blurred scene behind a plate (title bars, lists, the + * composer, the sign-in card). Meta, 2026-11-09: "The app shouldn't need chrome, and where it does help for readability it + * should be like the chat bubbles, blur with a bit of tint towards the background color. If that's not working, the fix + * is in the background, not covering it up." + */ +export const PLATE_TINT = 0.45; diff --git a/src/app/features/scene/skyTheme.ts b/src/app/features/scene/skyTheme.ts index 4ab3f43a..dbd46501 100644 --- a/src/app/features/scene/skyTheme.ts +++ b/src/app/features/scene/skyTheme.ts @@ -10,7 +10,7 @@ // derived outside this code (src/sky/skyTokens.js). Midnight Sky's link is derived there against the lightest Night sky // (4.53 on it, 5.43 on the darkest). The plate (skyPlate.css.ts) is still in the shell, pending // its removal. -import type { FixedSkyState, SkyState } from './skyState'; +import { resolveSkyState, type FixedSkyState, type SkyState } from './skyState'; export type SkyThemeId = 'midnight-sky' | 'noonday-sky'; @@ -21,4 +21,4 @@ const BY_STATE: Record = { }; export const skyThemeId = (state: SkyState): SkyThemeId | undefined => - state === 'auto' ? undefined : BY_STATE[state]; + BY_STATE[resolveSkyState(state)]; diff --git a/src/app/features/scene/terrain/terrainWorld.ts b/src/app/features/scene/terrain/terrainWorld.ts index 7296025b..64f18970 100644 --- a/src/app/features/scene/terrain/terrainWorld.ts +++ b/src/app/features/scene/terrain/terrainWorld.ts @@ -11,9 +11,9 @@ export const TERRAIN = { span: 3000, hmax: 256 }; * under shading read from the height map shows as hard-edged facets and stepped crests. */ export const GRID = { - still: { grid: 192, tex: 512 }, - low: { grid: 320, tex: 1024 }, - full: { grid: 384, tex: 1024 }, + still: { grid: 256, tex: 1024 }, + low: { grid: 512, tex: 1024 }, + full: { grid: 768, tex: 2048 }, } as const; /** The camera: at the patch centre, above the highest peak, turning slowly and looking a little down. */ diff --git a/src/app/pages/auth/AuthLayout.tsx b/src/app/pages/auth/AuthLayout.tsx index c53e5a65..0d5c9406 100644 --- a/src/app/pages/auth/AuthLayout.tsx +++ b/src/app/pages/auth/AuthLayout.tsx @@ -21,6 +21,7 @@ import { import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { LOGIN_PATH, REGISTER_PATH, RESET_PASSWORD_PATH } from '../paths'; import RatatoskrSVG from '../../../../public/res/svg/ratatoskr.svg'; +import { SkyStateControl } from '../../features/scene/SkyStateControl'; import { ServerPicker } from './ServerPicker'; import { AutoDiscoveryAction, autoDiscovery } from '../../cs-api'; import { SpecVersionsLoader } from '../../components/SpecVersionsLoader'; @@ -137,6 +138,7 @@ export function AuthLayout() { Ratatoskr logo Ratatoskr + diff --git a/src/app/pages/auth/styles.css.ts b/src/app/pages/auth/styles.css.ts index fc47dd79..f16617e8 100644 --- a/src/app/pages/auth/styles.css.ts +++ b/src/app/pages/auth/styles.css.ts @@ -1,6 +1,6 @@ import { globalStyle, style } from '@vanilla-extract/css'; import { DefaultReset, color, config, toRem } from 'folds'; -import { FOOTER_TINT } from '../../features/scene/skyPlate'; +import { FOOTER_TINT, PLATE_TINT } from '../../features/scene/skyPlate'; export const AuthLayout = style({ minHeight: '100%', @@ -27,13 +27,16 @@ export const AuthCard = style({ export const AuthLogo = style([ DefaultReset, { - width: toRem(26), - height: toRem(26), + width: toRem(32), + height: toRem(32), borderRadius: '50%', }, ]); +// The mark is white (Meta's matte); on the light sky theme it takes the dark ink instead. +globalStyle('body.noonday-sky img[src*="ratatoskr"]', { filter: 'brightness(0.18)' }); + export const AuthHeader = style({ padding: `0 ${config.space.S400}`, borderBottomWidth: config.borderWidth.B300, @@ -67,22 +70,35 @@ export const AuthFooter = style({ // made for their own grounds, not this card; Dark and Butter already use the same hsl(213 100% 80%). // The card is also made shorter so it leaves more of the scene visible on a phone. const scene = ':root[data-scene]'; +const tint = `color-mix(in srgb, var(--sky-bubble, #0d1a26) ${FOOTER_TINT * 100}%, transparent)`; globalStyle(`${scene} ${AuthLayout}`, { backgroundColor: 'transparent', backgroundImage: 'none', }); globalStyle(`${scene} ${AuthCard}`, { - backgroundColor: 'var(--sky-bubble, #0d1a26)', + // Meta, 2026-11-09: no chrome; where readability needs help, the chat-bubble treatment (blur with a tint toward the + // background), and if that is not enough the fix goes in the background, not in a plate. Same tint as the footer band. + backgroundColor: [ + 'var(--sky-bubble, #0d1a26)', + `color-mix(in srgb, var(--sky-bubble, #0d1a26) ${PLATE_TINT * 100}%, transparent)`, + ], + backdropFilter: 'blur(12px)', color: 'var(--sky-bubble-text, #b7c7d9)', borderRadius: config.radii.R500, - // A faint inner edge, so a pane this clear still reads as a surface. - borderColor: 'rgba(183, 199, 217, 0.14)', + border: 'none', + boxShadow: 'none', }); globalStyle(`${scene} body:not(.gamecult-theme) ${AuthCard}`, { vars: { '--tc-link': '#99c7ff' }, }); globalStyle(`${scene} ${AuthHeader}`, { backgroundColor: 'transparent', + borderBottomWidth: 0, + boxShadow: 'none', +}); +globalStyle(`${scene} #root header.${AuthHeader}`, { + backgroundColor: 'transparent', + backdropFilter: 'none', }); globalStyle(`${scene} ${AuthCardContent}`, { paddingTop: config.space.S500, @@ -99,7 +115,6 @@ const FADE_Y = 'linear-gradient(to bottom, transparent, #000 16px, #000 calc(100% - 16px), transparent)'; const FADE_X = 'linear-gradient(to right, transparent, #000 24px, #000 calc(100% - 24px), transparent)'; -const tint = `color-mix(in srgb, var(--sky-bubble, #0d1a26) ${FOOTER_TINT * 100}%, transparent)`; globalStyle(`${scene} ${AuthFooter}`, { paddingTop: config.space.S500, paddingBottom: config.space.S500, From fcabaee59ed17a552e198a06a5b01e9e10f319da Mon Sep 17 00:00:00 2001 From: Eureka Self Date: Tue, 10 Nov 2026 13:22:41 +0000 Subject: [PATCH 195/206] nemesis: chromeless buttons, chips and room list; plates only on the composer and plain messages Co-Authored-By: Claude Opus 5.5 --- src/app/components/nav/styles.css.ts | 9 +++---- src/app/features/scene/BackgroundScene.tsx | 1 + src/app/features/scene/sceneButtons.css.ts | 28 ++++++++++++++++++++++ src/app/features/scene/skyPlate.css.ts | 18 +++++++------- 4 files changed, 42 insertions(+), 14 deletions(-) create mode 100644 src/app/features/scene/sceneButtons.css.ts diff --git a/src/app/components/nav/styles.css.ts b/src/app/components/nav/styles.css.ts index feee3276..e5db6c47 100644 --- a/src/app/components/nav/styles.css.ts +++ b/src/app/components/nav/styles.css.ts @@ -5,10 +5,11 @@ import { surfaceInk } from '../../features/scene/sceneShell.css'; // Wash strengths over the scene: rows and labels are faint, and hover, selected and pressed rows // are three distinct steps above them. -const SCRIM_LABEL = '12%'; -const SCRIM_ROW = '16%'; -const SCRIM_HOVER = '26%'; -const SCRIM_SELECTED = '36%'; +// Meta, 2026-11-09: "this UI needs no chrome". Rows and labels are bare ink on the scene; hover and selection keep a faint wash. +const SCRIM_LABEL = '0%'; +const SCRIM_ROW = '0%'; +const SCRIM_HOVER = '14%'; +const SCRIM_SELECTED = '22%'; const SCRIM_PRESSED = '48%'; export const NavCategory = style([ diff --git a/src/app/features/scene/BackgroundScene.tsx b/src/app/features/scene/BackgroundScene.tsx index 0c8d0fb3..c447b4f0 100644 --- a/src/app/features/scene/BackgroundScene.tsx +++ b/src/app/features/scene/BackgroundScene.tsx @@ -5,6 +5,7 @@ import { mountBackgroundScene } from './host'; import { readCapabilities, resolveTier } from './tier'; import { getSceneStyleOverride } from './sceneStyle'; import './sceneShell.css'; +import './sceneButtons.css'; /** Set on while a scene runs, so signed-out screens can let it show through. */ export const SCENE_ACTIVE_ATTRIBUTE = 'data-scene'; diff --git a/src/app/features/scene/sceneButtons.css.ts b/src/app/features/scene/sceneButtons.css.ts new file mode 100644 index 00000000..301d5671 --- /dev/null +++ b/src/app/features/scene/sceneButtons.css.ts @@ -0,0 +1,28 @@ +import { globalStyle } from '@vanilla-extract/css'; + +// Meta, 2026-11-10: "buttons are big and ugly, too, they don't need chrome either". Over the scene a folds Button or +// Chip is its label in the page's ink, with a faint wash on hover and focus. Menus and dialogs (#portalContainer) keep +// folds' look. The selectors are folds' hashed recipe base classes (Button epr39zd, Chip _1bv8u1w6). +const scene = ':root[data-scene] #root'; +const controls = [`${scene} .epr39zd`, `${scene} ._1bv8u1w6`].join(', '); +globalStyle(controls, { + backgroundColor: 'transparent', + boxShadow: 'none', + border: 'none', + outline: 'none', + color: 'inherit', +}); +globalStyle( + [ + `${scene} .epr39zd:hover`, + `${scene} .epr39zd:focus-visible`, + `${scene} ._1bv8u1w6:hover`, + `${scene} ._1bv8u1w6:focus-visible`, + ].join(', '), + { backgroundColor: 'color-mix(in srgb, currentColor 12%, transparent)' } +); +globalStyle(`${scene} .epr39zd`, { + fontWeight: 700, + textDecoration: 'underline', + textUnderlineOffset: '0.25em', +}); diff --git a/src/app/features/scene/skyPlate.css.ts b/src/app/features/scene/skyPlate.css.ts index db168251..4472b98e 100644 --- a/src/app/features/scene/skyPlate.css.ts +++ b/src/app/features/scene/skyPlate.css.ts @@ -24,20 +24,18 @@ export const SkyPlate = style({}); // A recipe's first class is its base style. const base = (recipe: () => string) => `.${recipe().split(' ')[0]}`; +// Meta, 2026-11-09: no chrome. Title bars, the room list, list pages, the drawer and the bars over the composer are bare +// ink on the sky (dusk is allowed to be low contrast: it is the crossover between the light and dark inks). +void PageNavContent; +void PageContent; +void MembersDrawer; +void RoomViewTyping; +void RoomViewFollowing; export const plateRegions = [ `.${SkyPlate}`, - // Title bars: the room header, the room list's header, every page header. - '#root header', - // The room list, and the pages that are lists (explore, inbox, search). - `.${PageNavContent}`, - `.${PageContent}`, - // The composer, the members drawer, and the call status bar. + // The composer and the call status bar. `.${Editor}`, - `.${MembersDrawer}`, `.${CallStatus}`, - // The bars over the composer. - `.${RoomViewTyping}`, - base(RoomViewFollowing), // A message that is not a bubble (Modern, Compact) is plain text on the scene. `${base(MessageBase)}:not(:has(.${BubbleContent}))`, ]; From c4689299ca191648a7694dab05d03182634deab0 Mon Sep 17 00:00:00 2001 From: Eureka Self Date: Tue, 10 Nov 2026 13:46:51 +0000 Subject: [PATCH 196/206] nemesis: tab icon and notification icon are the night-sky tile (unread and highlight add a dot), not the white UI mark Co-Authored-By: Claude Opus 5.5 --- public/res/svg/ratatoskr-favicon-highlight.svg | 1 + public/res/svg/ratatoskr-favicon-unread.svg | 1 + public/res/svg/ratatoskr-favicon.svg | 1 + public/res/svg/ratatoskr-highlight.svg | 17 ----------------- public/res/svg/ratatoskr-unread.svg | 17 ----------------- src/app/pages/client/ClientNonUIFeatures.tsx | 14 +++++++------- 6 files changed, 10 insertions(+), 41 deletions(-) create mode 100644 public/res/svg/ratatoskr-favicon-highlight.svg create mode 100644 public/res/svg/ratatoskr-favicon-unread.svg create mode 100644 public/res/svg/ratatoskr-favicon.svg delete mode 100644 public/res/svg/ratatoskr-highlight.svg delete mode 100644 public/res/svg/ratatoskr-unread.svg diff --git a/public/res/svg/ratatoskr-favicon-highlight.svg b/public/res/svg/ratatoskr-favicon-highlight.svg new file mode 100644 index 00000000..f9a95e82 --- /dev/null +++ b/public/res/svg/ratatoskr-favicon-highlight.svg @@ -0,0 +1 @@ + diff --git a/public/res/svg/ratatoskr-favicon-unread.svg b/public/res/svg/ratatoskr-favicon-unread.svg new file mode 100644 index 00000000..f5bcb7d8 --- /dev/null +++ b/public/res/svg/ratatoskr-favicon-unread.svg @@ -0,0 +1 @@ + diff --git a/public/res/svg/ratatoskr-favicon.svg b/public/res/svg/ratatoskr-favicon.svg new file mode 100644 index 00000000..0c7cc393 --- /dev/null +++ b/public/res/svg/ratatoskr-favicon.svg @@ -0,0 +1 @@ + diff --git a/public/res/svg/ratatoskr-highlight.svg b/public/res/svg/ratatoskr-highlight.svg deleted file mode 100644 index 661442d4..00000000 --- a/public/res/svg/ratatoskr-highlight.svg +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - - diff --git a/public/res/svg/ratatoskr-unread.svg b/public/res/svg/ratatoskr-unread.svg deleted file mode 100644 index 69a08c95..00000000 --- a/public/res/svg/ratatoskr-unread.svg +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - - diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index db3eefc2..62385769 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -3,9 +3,9 @@ import React, { ReactNode, useCallback, useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk'; import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread'; -import LogoSVG from '../../../../public/res/svg/ratatoskr.svg'; -import LogoUnreadSVG from '../../../../public/res/svg/ratatoskr-unread.svg'; -import LogoHighlightSVG from '../../../../public/res/svg/ratatoskr-highlight.svg'; +import FaviconSVG from '../../../../public/res/svg/ratatoskr-favicon.svg'; +import FaviconUnreadSVG from '../../../../public/res/svg/ratatoskr-favicon-unread.svg'; +import FaviconHighlightSVG from '../../../../public/res/svg/ratatoskr-favicon-highlight.svg'; import NotificationSound from '../../../../public/sound/notification.ogg'; import InviteSound from '../../../../public/sound/invite.ogg'; import { notificationPermission, setFavicon } from '../../utils/dom'; @@ -67,9 +67,9 @@ function FaviconUpdater() { }); if (notification) { - setFavicon(highlight ? LogoHighlightSVG : LogoUnreadSVG); + setFavicon(highlight ? FaviconHighlightSVG : FaviconUnreadSVG); } else { - setFavicon(LogoSVG); + setFavicon(FaviconSVG); } }, [roomToUnread]); @@ -89,8 +89,8 @@ function InviteNotifications() { const notify = useCallback( (count: number) => { const noti = new window.Notification('Invitation', { - icon: LogoSVG, - badge: LogoSVG, + icon: FaviconSVG, + badge: FaviconSVG, body: `You have ${count} new invitation request.`, silent: true, }); From 0fe41efb9b2a4f0fe3326eb682d366877f471084 Mon Sep 17 00:00:00 2001 From: Eureka Self Date: Tue, 10 Nov 2026 13:51:26 +0000 Subject: [PATCH 197/206] nemesis: Persona poses on by default; Safari 14 polyfills (Array.at/findLast, Object.hasOwn, structuredClone) and -webkit-backdrop-filter Co-Authored-By: Claude Opus 5.5 --- src/app/features/scene/skyPlate.css.ts | 1 + src/app/pages/auth/styles.css.ts | 3 ++ src/app/state/settings.ts | 2 +- src/index.tsx | 1 + src/polyfills.ts | 72 ++++++++++++++++++++++++++ 5 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 src/polyfills.ts diff --git a/src/app/features/scene/skyPlate.css.ts b/src/app/features/scene/skyPlate.css.ts index 4472b98e..f97efe26 100644 --- a/src/app/features/scene/skyPlate.css.ts +++ b/src/app/features/scene/skyPlate.css.ts @@ -48,6 +48,7 @@ globalStyle(within(scene), { // Blur with a light tint toward the sky, like the chat bubbles; never an opaque slab (PLATE_TINT). backgroundColor: [fill, `color-mix(in srgb, ${fill} ${PLATE_TINT * 100}%, transparent)`], backdropFilter: 'blur(12px)', + WebkitBackdropFilter: 'blur(12px)', color: text, // Themed ink was chosen for the clear shell; inside the plate every token reads the plate's. vars: { diff --git a/src/app/pages/auth/styles.css.ts b/src/app/pages/auth/styles.css.ts index f16617e8..2341fe80 100644 --- a/src/app/pages/auth/styles.css.ts +++ b/src/app/pages/auth/styles.css.ts @@ -83,6 +83,7 @@ globalStyle(`${scene} ${AuthCard}`, { `color-mix(in srgb, var(--sky-bubble, #0d1a26) ${PLATE_TINT * 100}%, transparent)`, ], backdropFilter: 'blur(12px)', + WebkitBackdropFilter: 'blur(12px)', color: 'var(--sky-bubble-text, #b7c7d9)', borderRadius: config.radii.R500, border: 'none', @@ -99,6 +100,7 @@ globalStyle(`${scene} ${AuthHeader}`, { globalStyle(`${scene} #root header.${AuthHeader}`, { backgroundColor: 'transparent', backdropFilter: 'none', + WebkitBackdropFilter: 'none', }); globalStyle(`${scene} ${AuthCardContent}`, { paddingTop: config.space.S500, @@ -120,6 +122,7 @@ globalStyle(`${scene} ${AuthFooter}`, { paddingBottom: config.space.S500, backgroundColor: ['var(--sky-bubble, #0d1a26)', tint], backdropFilter: 'blur(12px)', + WebkitBackdropFilter: 'blur(12px)', maskImage: `${FADE_Y}, ${FADE_X}`, maskComposite: 'intersect', color: 'var(--sky-bubble-text, #b7c7d9)', diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 2b48fb59..1031141f 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -85,7 +85,7 @@ const defaultSettings: Settings = { encUrlPreview: false, showHiddenEvents: false, legacyUsernameColor: false, - poseBubbles: false, + poseBubbles: true, showNotifications: true, isNotificationSounds: true, diff --git a/src/index.tsx b/src/index.tsx index c2461fe8..6c7a3bc2 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,4 +1,5 @@ /* eslint-disable import/first */ +import './polyfills'; import './preview-demo/seed'; import React from 'react'; import { createRoot } from 'react-dom/client'; diff --git a/src/polyfills.ts b/src/polyfills.ts new file mode 100644 index 00000000..2f018c4b --- /dev/null +++ b/src/polyfills.ts @@ -0,0 +1,72 @@ +/* eslint-disable no-extend-native, func-names */ +// Safari 14 (iPadOS 14) lacks these; the bundle (ours and matrix-js-sdk) calls them at runtime. Safari 15.4 has all. +// Loaded first by src/index.tsx. Each is installed only when missing. + +function at(this: ArrayLike, n: number): T | undefined { + const len = this.length; + const i = Math.trunc(n) || 0; + const k = i < 0 ? len + i : i; + return k < 0 || k >= len ? undefined : this[k]; +} +if (!Array.prototype.at) Object.defineProperty(Array.prototype, 'at', { value: at, writable: true, configurable: true }); +if (!String.prototype.at) + Object.defineProperty(String.prototype, 'at', { value: at, writable: true, configurable: true }); + +if (!(Array.prototype as unknown as Record).findLast) + Object.defineProperty(Array.prototype, 'findLast', { + value: function (this: T[], fn: (v: T, i: number, a: T[]) => unknown, thisArg?: unknown) { + for (let i = this.length - 1; i >= 0; i -= 1) if (fn.call(thisArg, this[i], i, this)) return this[i]; + return undefined; + }, + writable: true, + configurable: true, + }); +if (!(Array.prototype as unknown as Record).findLastIndex) + Object.defineProperty(Array.prototype, 'findLastIndex', { + value: function (this: T[], fn: (v: T, i: number, a: T[]) => unknown, thisArg?: unknown) { + for (let i = this.length - 1; i >= 0; i -= 1) if (fn.call(thisArg, this[i], i, this)) return i; + return -1; + }, + writable: true, + configurable: true, + }); + +if (!(Object as unknown as Record).hasOwn) + Object.defineProperty(Object, 'hasOwn', { + value: (o: object, k: PropertyKey) => Object.prototype.hasOwnProperty.call(o, k), + writable: true, + configurable: true, + }); + +// Structured clone for the data the client clones: plain objects, arrays, Map, Set, Date, RegExp, typed arrays, with cycles. +if (typeof (globalThis as { structuredClone?: unknown }).structuredClone !== 'function') { + const clone = (v: unknown, seen: Map): unknown => { + if (v === null || typeof v !== 'object') return v; + if (seen.has(v)) return seen.get(v); + if (v instanceof Date) return new Date(v.getTime()); + if (v instanceof RegExp) return new RegExp(v.source, v.flags); + if (ArrayBuffer.isView(v)) return (v as unknown as { slice: () => unknown }).slice(); + if (v instanceof ArrayBuffer) return v.slice(0); + if (v instanceof Map) { + const m = new Map(); + seen.set(v, m); + v.forEach((val, key) => m.set(clone(key, seen), clone(val, seen))); + return m; + } + if (v instanceof Set) { + const s = new Set(); + seen.set(v, s); + v.forEach((val) => s.add(clone(val, seen))); + return s; + } + const out: Record | unknown[] = Array.isArray(v) ? [] : {}; + seen.set(v, out); + Object.keys(v).forEach((k) => { + (out as Record)[k] = clone((v as Record)[k], seen); + }); + return out; + }; + (globalThis as { structuredClone?: unknown }).structuredClone = (v: unknown) => clone(v, new Map()); +} + +export {}; From 9d3343bb316c4d8acc8b12acd51be1863cfcf643 Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 13:53:22 +0000 Subject: [PATCH 198/206] nemesis/demo: tests follow the new numbers; Ridge retries a zero-size start skyTheme: auto resolves to the clock's theme; the plate pins now assert the chromeless shell (only composer, call bar, call view and plain messages are plates). terrainWorld: ceilings for the 512/768 grids; still grid 256 -> 320 so its quads stay within two height texels at the nearest ground (the test is unchanged). skyPlate.css: drop the void-import placeholders. Ridge: a scene started with no area no longer throws on drawImage or returns null; it builds on the first resize that gives it one. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/ridge/Ridge.test.ts | 24 ++++++++++++++ src/app/features/scene/ridge/Ridge.ts | 14 +++++++-- src/app/features/scene/skyPlate.css.ts | 9 ------ src/app/features/scene/skyTheme.test.ts | 31 +++++++++++++------ .../scene/terrain/terrainWorld.test.ts | 9 ++++-- .../features/scene/terrain/terrainWorld.ts | 2 +- 6 files changed, 66 insertions(+), 23 deletions(-) diff --git a/src/app/features/scene/ridge/Ridge.test.ts b/src/app/features/scene/ridge/Ridge.test.ts index 3aecad12..711054f6 100644 --- a/src/app/features/scene/ridge/Ridge.test.ts +++ b/src/app/features/scene/ridge/Ridge.test.ts @@ -437,6 +437,30 @@ describe('createRidgeScene', () => { expect(parent.children.some((c: any) => c === readout)).toBe(false); }); + it('a scene started at zero size paints nothing, then builds on the first non-zero resize', async () => { + const { parent, canvas } = host(); + canvas.clientWidth = 0; + canvas.clientHeight = 0; + (window as any).innerWidth = 0; + (window as any).innerHeight = 0; + const s = createRidgeScene({ canvas, tier: 'full', sky: DAY }); + expect(s).not.toBeNull(); + expect(parent.children).toEqual([canvas]); + expect(draws).toHaveLength(0); + // Still zero: a resize does not paint either. + window.dispatchEvent(new Event('resize')); + await new Promise((r) => setTimeout(r, 300)); + expect(parent.children).toEqual([canvas]); + (window as any).innerWidth = 400; + (window as any).innerHeight = 800; + window.dispatchEvent(new Event('resize')); + await new Promise((r) => setTimeout(r, 300)); + expect(parent.children.slice(1)).toHaveLength(LAYERS.length); + expect(draws.filter((d) => d === 'sky')).toHaveLength(1); + s!.dispose(); + expect(parent.children).toEqual([canvas]); + }); + it('dispose cancels the loop, the animations and the layers', () => { const { parent, canvas } = host(); const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!; diff --git a/src/app/features/scene/ridge/Ridge.ts b/src/app/features/scene/ridge/Ridge.ts index eae0df08..d2312aaf 100644 --- a/src/app/features/scene/ridge/Ridge.ts +++ b/src/app/features/scene/ridge/Ridge.ts @@ -180,9 +180,18 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { built = undefined; }; - /** Bake the sky, then the layers. Returns false when a 2D context is missing. */ + /** True while the host has no area to paint (a scene started in a hidden or collapsed container). */ + const hasNoArea = () => { + const { w, h, scale } = viewSize(); + const size = layerSize(w, h, scale); + return size.period < 1 || size.height < 1 || Math.round(h * scale) < 1; + }; + + /** Bake the sky, then the layers. Returns false when a 2D context is missing or there is no area yet. */ const build = (): boolean => { teardownLayers(); + // A zero-size canvas throws on drawImage; wait for the first resize that gives the host an area. + if (hasNoArea()) return false; const { w, h, scale } = viewSize(); const size = layerSize(w, h, scale); canvas.width = size.period; @@ -343,7 +352,8 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { raf = requestAnimationFrame(tick); }; - if (!build()) return null; + // No area yet is not a failure: the resize listener below builds once the host has one. + if (!build() && !hasNoArea()) return null; if (probe) { readout = document.createElement('div'); Object.assign(readout.style, { diff --git a/src/app/features/scene/skyPlate.css.ts b/src/app/features/scene/skyPlate.css.ts index 4472b98e..c231527c 100644 --- a/src/app/features/scene/skyPlate.css.ts +++ b/src/app/features/scene/skyPlate.css.ts @@ -1,12 +1,8 @@ import { globalStyle, style } from '@vanilla-extract/css'; import { color, config } from 'folds'; import { MessageBase, BubbleContent } from '../../components/message/layout/layout.css'; -import { PageContent, PageNavContent } from '../../components/page/style.css'; import { Editor } from '../../components/editor/Editor.css'; -import { MembersDrawer } from '../room/MembersDrawer.css'; import { CallStatus } from '../call-status/styles.css'; -import { RoomViewFollowing } from '../room/RoomViewFollowing.css'; -import { RoomViewTyping } from '../room/RoomViewTyping.css'; import { PLATE_TINT, SKY_PLATE_FALLBACK } from './skyPlate'; // Over the scene (html[data-scene]) the signed-in shell is clear (sceneShell.css.ts), so text @@ -26,11 +22,6 @@ const base = (recipe: () => string) => `.${recipe().split(' ')[0]}`; // Meta, 2026-11-09: no chrome. Title bars, the room list, list pages, the drawer and the bars over the composer are bare // ink on the sky (dusk is allowed to be low contrast: it is the crossover between the light and dark inks). -void PageNavContent; -void PageContent; -void MembersDrawer; -void RoomViewTyping; -void RoomViewFollowing; export const plateRegions = [ `.${SkyPlate}`, // The composer and the call status bar. diff --git a/src/app/features/scene/skyTheme.test.ts b/src/app/features/scene/skyTheme.test.ts index 3229cf36..80bc0423 100644 --- a/src/app/features/scene/skyTheme.test.ts +++ b/src/app/features/scene/skyTheme.test.ts @@ -39,11 +39,17 @@ describe('skyThemeId', () => { expect(skyThemeId('night')).toBe('midnight-sky'); }); - it('picks none for auto, at any hour: auto never overrides a signed-in theme', () => { + it('picks the theme of the state auto resolves to, at any hour', () => { [0, 6, 12, 18, 23].forEach((h) => { setSkyClock(() => atStandardTime(h, 10)); - expect(skyThemeId('auto')).toBeUndefined(); + const id = skyThemeId('auto'); + expect(id).toBeDefined(); + expect(id).toBe(skyThemeId(getResolvedSkyState())); }); + setSkyClock(() => atStandardTime(12)); + expect(skyThemeId('auto')).toBe('noonday-sky'); + setSkyClock(() => atStandardTime(0)); + expect(skyThemeId('auto')).toBe('midnight-sky'); setSkyClock(); }); @@ -145,19 +151,26 @@ describe('regions that sit on the plate', () => { // and link of that plate are pinned above; this pins who is on it. const on = (cls: string) => expect(plateRegions).toContain(cls); - it('plates the composer, the members drawer, the call status bar and the call view', () => { + it('plates the composer, the call status bar and the call view', () => { on(`.${Editor}`); - on(`.${MembersDrawer}`); on(`.${CallStatus}`); on(`.${SkyPlate}`); }); - it('plates the room list, list pages, typing and Following bars and every header', () => { - on(`.${PageNavContent}`); - on(`.${PageContent}`); - on(`.${RoomViewTyping}`); - on('#root header'); + it('plates a message that is not a bubble, and nothing else of the signed-in shell', () => { expect(plateRegions.some((r) => r.includes(':not(:has('))).toBe(true); + expect(plateRegions).toHaveLength(4); + }); + + // Meta, 2026-11-09: no chrome. These are bare ink on the sky, not plates. + it('leaves title bars, the room list, list pages, the drawer and the bars over the composer bare', () => { + [ + `.${MembersDrawer}`, + `.${PageNavContent}`, + `.${PageContent}`, + `.${RoomViewTyping}`, + '#root header', + ].forEach((cls) => expect(plateRegions).not.toContain(cls)); }); }); diff --git a/src/app/features/scene/terrain/terrainWorld.test.ts b/src/app/features/scene/terrain/terrainWorld.test.ts index 78ab2700..70a9d763 100644 --- a/src/app/features/scene/terrain/terrainWorld.test.ts +++ b/src/app/features/scene/terrain/terrainWorld.test.ts @@ -41,8 +41,13 @@ describe('grid', () => { }); it('each tier fits a 32-bit index and the cheap tiers stay cheap', () => { - // 205k at 320 quads: a guess at what a phone carries (SwiftShader only, no phone frame time), kept as a ceiling so a bigger grid is a deliberate change. - expect(triangleCount(GRID.low.grid)).toBeLessThan(210000); + // 524k at 512 quads (low), 1.18M at 768 (full): Meta asked for denser terrain on 2026-11-10, up from 205k at 320. No + // phone frame time is measured (SwiftShader only); kept as a ceiling so a bigger grid is a deliberate change. + expect(triangleCount(GRID.low.grid)).toBeLessThan(540000); + expect(triangleCount(GRID.full.grid)).toBeLessThan(1200000); + (['still', 'low', 'full'] as const).forEach((t) => + expect((GRID[t].grid + 1) ** 2).toBeLessThan(2 ** 32) + ); expect(GRID.full.grid).toBeGreaterThan(GRID.low.grid); expect(GRID.still.tex).toBeLessThanOrEqual(GRID.low.tex); }); diff --git a/src/app/features/scene/terrain/terrainWorld.ts b/src/app/features/scene/terrain/terrainWorld.ts index 64f18970..82a16bfb 100644 --- a/src/app/features/scene/terrain/terrainWorld.ts +++ b/src/app/features/scene/terrain/terrainWorld.ts @@ -11,7 +11,7 @@ export const TERRAIN = { span: 3000, hmax: 256 }; * under shading read from the height map shows as hard-edged facets and stepped crests. */ export const GRID = { - still: { grid: 256, tex: 1024 }, + still: { grid: 320, tex: 1024 }, low: { grid: 512, tex: 1024 }, full: { grid: 768, tex: 2048 }, } as const; From 384cd17dac344bc195a2d92ab7a881d575c51df2 Mon Sep 17 00:00:00 2001 From: Nibu Date: Tue, 10 Nov 2026 14:00:44 +0000 Subject: [PATCH 199/206] scene: terrain fine grain domain-warped so its plane waves stop reading as hatching The fine albedo octave is three plane waves per projection (about 5 m periods): a regular lattice, resolved (not aliased) at near-ground pixel size, so it reads as diagonal hatching. Switching the octave off in a SwiftShader render removes it; coarse grain off does not. The octave's coordinates are now pushed by a slower sine each, in terrainAlbedo.wavesWarped and the shader's warpq. Co-Authored-By: Claude Sonnet 5.5 --- .../scene/terrain/terrainAlbedo.test.ts | 2 ++ .../features/scene/terrain/terrainAlbedo.ts | 19 +++++++++++++++---- .../features/scene/terrain/terrainShaders.ts | 5 ++++- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/app/features/scene/terrain/terrainAlbedo.test.ts b/src/app/features/scene/terrain/terrainAlbedo.test.ts index 17363282..6afac79d 100644 --- a/src/app/features/scene/terrain/terrainAlbedo.test.ts +++ b/src/app/features/scene/terrain/terrainAlbedo.test.ts @@ -114,6 +114,8 @@ describe('shader agrees with the pure function on its numbers', () => { expect(GRID_FRAG).toContain(`#define ALB_WOBBLE_S ${ALBEDO.wobbleSlope}`); expect(GRID_FRAG).toContain(`#define ALB_GRAIN_COARSE ${ALBEDO.grainCoarse}`); expect(GRID_FRAG).toContain(`#define ALB_STRATA ${ALBEDO.strata}`); + expect(GRID_FRAG).toContain(`#define ALB_FINE_WARP ${ALBEDO.fineWarp.toFixed(1)}`); + expect(GRID_FRAG).toContain('waves3(warpq(qy),'); // the only fetch for colour-by-material would be a texture; the height map's texelFetch is the only other read, and the sky lookup is the baked scattering table's expect(GRID_FRAG.match(/texelFetch|texture\((?!uSky|uAP)/g)).toHaveLength(1); }); diff --git a/src/app/features/scene/terrain/terrainAlbedo.ts b/src/app/features/scene/terrain/terrainAlbedo.ts index fef204ff..48dc2b42 100644 --- a/src/app/features/scene/terrain/terrainAlbedo.ts +++ b/src/app/features/scene/terrain/terrainAlbedo.ts @@ -31,6 +31,8 @@ export const ALBEDO = { /** Albedo grain: relative swing of the two sine-product octaves (about 20 m and 5 m periods), and of the rock strata. */ grainCoarse: 0.35, grainFine: 0.3, + /** Metres the fine octave's coordinates are pushed by a slower sine, so its parallel stripes bend and do not read as hatching. */ + fineWarp: 7, /** The fine octave fades out between these distances (metres): beyond them it is under a pixel and only shimmers. */ fineNear: 250, fineFar: 700, @@ -132,6 +134,13 @@ const FINE = [ ] as const; const waves = (k: ReadonlyArray, a: number, b: number) => k.reduce((sum, [ka, kb]) => sum + Math.sin(a * ka + b * kb), 0) / 3; +/** `waves` of coordinates warped by a sine each (the shader's `warpq`): three plane waves alone make a regular lattice. */ +const wavesWarped = (k: ReadonlyArray, a: number, b: number) => + waves( + k, + a + ALBEDO.fineWarp * Math.sin(b * 0.55 + 1.3), + b + ALBEDO.fineWarp * Math.sin(a * 0.47 + 0.7) + ); /** * The multiplier on albedo that gives the surface grain and the rock its layers, at a surface point (metres) with unit @@ -148,10 +157,12 @@ export function grainAt( distance: number ): number { const w = triWeights(n); - const over = (k: ReadonlyArray) => - w[0] * waves(k, z, y) + w[1] * waves(k, x, z) + w[2] * waves(k, x, y); - const coarse = over(COARSE); - const fine = over(FINE) * (1 - smooth(ALBEDO.fineNear, ALBEDO.fineFar, distance)); + const over = ( + k: ReadonlyArray, + f: typeof waves + ) => w[0] * f(k, z, y) + w[1] * f(k, x, z) + w[2] * f(k, x, y); + const coarse = over(COARSE, waves); + const fine = over(FINE, wavesWarped) * (1 - smooth(ALBEDO.fineNear, ALBEDO.fineFar, distance)); const layers = Math.sin(y * 0.9 + wobbleAt(x, y, z, n) * 3); return Math.max( 0, diff --git a/src/app/features/scene/terrain/terrainShaders.ts b/src/app/features/scene/terrain/terrainShaders.ts index b79e2873..f6bcce37 100644 --- a/src/app/features/scene/terrain/terrainShaders.ts +++ b/src/app/features/scene/terrain/terrainShaders.ts @@ -133,6 +133,7 @@ const ALBEDO_DEFINES = ` #define ALB_WOBBLE_S ${lit(ALBEDO.wobbleSlope)} #define ALB_GRAIN_COARSE ${lit(ALBEDO.grainCoarse)} #define ALB_GRAIN_FINE ${lit(ALBEDO.grainFine)} +#define ALB_FINE_WARP ${lit(ALBEDO.fineWarp)} #define ALB_STRATA ${lit(ALBEDO.strata)} #define ALB_FINE_NEAR ${lit(ALBEDO.fineNear)} #define ALB_FINE_FAR ${lit(ALBEDO.fineFar)} @@ -175,6 +176,8 @@ ${FETCH} // Triplanar patterns: each projection plane's value, blended by the weights of the face's normal (terrainAlbedo.triWeights). float wob2(vec2 q) { return sin(q.x * 0.043 + q.y * 0.017) * sin(q.y * 0.051 - q.x * 0.023); } float waves3(vec2 q, vec2 a, vec2 b, vec2 c) { return (sin(dot(q, a)) + sin(dot(q, b)) + sin(dot(q, c))) / 3.0; } +// The fine octave's coordinates pushed by a slower sine each (terrainAlbedo wavesWarped): three plane waves alone make a regular lattice. +vec2 warpq(vec2 q) { return q + ALB_FINE_WARP * vec2(sin(q.y * 0.55 + 1.3), sin(q.x * 0.47 + 0.7)); } void main() { // Normal from the bilinear height at a continuous position, a stencil 1.5 texels each side: smooth across texel borders. vec2 p = v_uv * float(uTex - 1); @@ -195,7 +198,7 @@ void main() { vec3 albedo = mix(mix(ground, uSnow, snow), uRock, rockW); // Surface grain and rock strata, the same arithmetic as terrainAlbedo.grainAt(). float coarse = tw.x * waves3(qx, vec2(0.31, 0.27), vec2(-0.22, 0.35), vec2(0.38, -0.12)) + tw.y * waves3(qy, vec2(0.31, 0.27), vec2(-0.22, 0.35), vec2(0.38, -0.12)) + tw.z * waves3(qz, vec2(0.31, 0.27), vec2(-0.22, 0.35), vec2(0.38, -0.12)); - float fine = tw.x * waves3(qx, vec2(1.1, 0.7), vec2(-0.8, 1.2), vec2(1.3, -0.6)) + tw.y * waves3(qy, vec2(1.1, 0.7), vec2(-0.8, 1.2), vec2(1.3, -0.6)) + tw.z * waves3(qz, vec2(1.1, 0.7), vec2(-0.8, 1.2), vec2(1.3, -0.6)); + float fine = tw.x * waves3(warpq(qx), vec2(1.1, 0.7), vec2(-0.8, 1.2), vec2(1.3, -0.6)) + tw.y * waves3(warpq(qy), vec2(1.1, 0.7), vec2(-0.8, 1.2), vec2(1.3, -0.6)) + tw.z * waves3(warpq(qz), vec2(1.1, 0.7), vec2(-0.8, 1.2), vec2(1.3, -0.6)); float near = 1.0 - smoothstep(ALB_FINE_NEAR, ALB_FINE_FAR, length(v_world - uCamera)); float layers = sin(v_world.y * 0.9 + w * 3.0); albedo *= max(0.0, 1.0 + ALB_GRAIN_COARSE * coarse + ALB_GRAIN_FINE * near * fine + ALB_STRATA * rockW * layers); From c657d686152aa8c8cb470cc0181b4ffe827323aa Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 14:03:21 +0000 Subject: [PATCH 200/206] PR #65 steward ruling: user themes win over auto; focus ring; logo on light themes skyThemeId('auto') is undefined again as on dev. sceneThemeId follows the resolved sky for auto only while a scene is on and the person chose no theme (signed-out pages pass none); useResolvedSkyState changes as the clock crosses so theme classes re-render. Scene buttons and chips keep a focus-visible outline. The white mark takes the dark filter through a LogoMark class on every themed under any prism-light theme (favicon and notification icons are files and keep the white mark). Background picker is hidden without a backgroundScene config. Ridge comment states what hasNoArea covers. Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/logo/logoMark.css.ts | 9 ++++ src/app/features/scene/SkyStateControl.tsx | 59 ++++++++++++---------- src/app/features/scene/ridge/Ridge.ts | 2 +- src/app/features/scene/sceneButtons.css.ts | 6 ++- src/app/features/scene/skyTheme.test.ts | 57 +++++++++++++++++---- src/app/features/scene/skyTheme.ts | 19 ++++++- src/app/features/settings/about/About.tsx | 2 + src/app/hooks/useSkyState.ts | 20 +++++++- src/app/hooks/useTheme.ts | 27 ++++++---- src/app/pages/auth/AuthLayout.tsx | 7 ++- src/app/pages/auth/styles.css.ts | 3 -- src/app/pages/client/WelcomePage.tsx | 11 +++- src/app/state/settings.ts | 4 +- 13 files changed, 167 insertions(+), 59 deletions(-) create mode 100644 src/app/components/logo/logoMark.css.ts diff --git a/src/app/components/logo/logoMark.css.ts b/src/app/components/logo/logoMark.css.ts new file mode 100644 index 00000000..4c967088 --- /dev/null +++ b/src/app/components/logo/logoMark.css.ts @@ -0,0 +1,9 @@ +import { globalStyle, style } from '@vanilla-extract/css'; + +/** + * The Ratatoskr mark is white on transparent (Meta's matte). On any light theme (they all carry `prism-light`) a class + * of this name darkens it to the dark ink; put it on every that shows the mark on a themed surface. The favicon and + * notification icons are files the browser draws and cannot take it. + */ +export const LogoMark = style({}); +globalStyle(`body.prism-light .${LogoMark}`, { filter: 'brightness(0.18)' }); diff --git a/src/app/features/scene/SkyStateControl.tsx b/src/app/features/scene/SkyStateControl.tsx index 4e67bd81..b664dc07 100644 --- a/src/app/features/scene/SkyStateControl.tsx +++ b/src/app/features/scene/SkyStateControl.tsx @@ -39,7 +39,8 @@ const LABEL: Record = { export function SkyStateControl() { const state = useSkyState(); const [cords, setCords] = useState(); - const configuredStyle = useClientConfig().backgroundScene?.style; + const backgroundScene = useClientConfig().backgroundScene; + const configuredStyle = backgroundScene?.style; const sceneStyle: ServedSceneStyle = getSceneStyleOverride() ?? (configuredStyle === 'terrain' ? 'terrain' : 'ridge'); @@ -85,32 +86,36 @@ export function SkyStateControl() { ))} - - Background - - {SERVED_SCENE_STYLES.map((st) => ( - { - setCords(undefined); - if (st !== sceneStyle) setSceneStyle(st); - }} - after={sceneStyle === st ? : undefined} - > - - {STYLE_LABEL[st]} - - - ))} + {backgroundScene && ( + <> + + Background + + {SERVED_SCENE_STYLES.map((st) => ( + { + setCords(undefined); + if (st !== sceneStyle) setSceneStyle(st); + }} + after={sceneStyle === st ? : undefined} + > + + {STYLE_LABEL[st]} + + + ))} + + )}
    diff --git a/src/app/features/scene/ridge/Ridge.ts b/src/app/features/scene/ridge/Ridge.ts index d2312aaf..69a88864 100644 --- a/src/app/features/scene/ridge/Ridge.ts +++ b/src/app/features/scene/ridge/Ridge.ts @@ -180,7 +180,7 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { built = undefined; }; - /** True while the host has no area to paint (a scene started in a hidden or collapsed container). */ + /** True while there is nothing to paint on: the canvas has no client size and the window itself is 0 x 0 (`viewSize` falls back to the window). */ const hasNoArea = () => { const { w, h, scale } = viewSize(); const size = layerSize(w, h, scale); diff --git a/src/app/features/scene/sceneButtons.css.ts b/src/app/features/scene/sceneButtons.css.ts index 301d5671..5c96e71b 100644 --- a/src/app/features/scene/sceneButtons.css.ts +++ b/src/app/features/scene/sceneButtons.css.ts @@ -9,7 +9,6 @@ globalStyle(controls, { backgroundColor: 'transparent', boxShadow: 'none', border: 'none', - outline: 'none', color: 'inherit', }); globalStyle( @@ -21,6 +20,11 @@ globalStyle( ].join(', '), { backgroundColor: 'color-mix(in srgb, currentColor 12%, transparent)' } ); +// A visible keyboard focus ring in the control's own ink, on top of the hover wash. +globalStyle([`${scene} .epr39zd:focus-visible`, `${scene} ._1bv8u1w6:focus-visible`].join(', '), { + outline: '2px solid currentColor', + outlineOffset: '2px', +}); globalStyle(`${scene} .epr39zd`, { fontWeight: 700, textDecoration: 'underline', diff --git a/src/app/features/scene/skyTheme.test.ts b/src/app/features/scene/skyTheme.test.ts index 80bc0423..18435ae6 100644 --- a/src/app/features/scene/skyTheme.test.ts +++ b/src/app/features/scene/skyTheme.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { atStandardTime } from './standardTime.testkit'; import { baked } from '../../../sky/baked'; import { AA, NIGHT_SKY_LIGHTEST, contrast, luminance } from '../../../sky/skyTokens.js'; @@ -9,10 +9,15 @@ import { MembersDrawer } from '../room/MembersDrawer.css'; import { RoomViewTyping } from '../room/RoomViewTyping.css'; import { FOOTER_TINT, SKY_PLATE_FALLBACK } from './skyPlate'; import { SkyPlate, plateRegions } from './skyPlate.css'; -import { getResolvedSkyState, setSkyClock, skyBackdrop } from './skyState'; +import { + getResolvedSkyState, + setSkyClock, + skyBackdrop, + subscribe as subscribeSky, +} from './skyState'; import type { SkyBackdrop } from './skyState'; import type { FixedSkyState } from './skyBand'; -import { skyThemeId } from './skyTheme'; +import { sceneThemeId, skyThemeId } from './skyTheme'; const SKY = { 'midnight-sky': baked.midnight, 'noonday-sky': baked.noonday } as const; const fixed = (['day', 'dusk', 'night'] as const).flatMap((s) => { @@ -39,17 +44,47 @@ describe('skyThemeId', () => { expect(skyThemeId('night')).toBe('midnight-sky'); }); - it('picks the theme of the state auto resolves to, at any hour', () => { + it('picks none for auto, at any hour: auto never overrides a signed-in theme', () => { [0, 6, 12, 18, 23].forEach((h) => { setSkyClock(() => atStandardTime(h, 10)); - const id = skyThemeId('auto'); - expect(id).toBeDefined(); - expect(id).toBe(skyThemeId(getResolvedSkyState())); + expect(skyThemeId('auto')).toBeUndefined(); }); - setSkyClock(() => atStandardTime(12)); - expect(skyThemeId('auto')).toBe('noonday-sky'); - setSkyClock(() => atStandardTime(0)); - expect(skyThemeId('auto')).toBe('midnight-sky'); + setSkyClock(); + }); + + describe('sceneThemeId, the theme the page wears', () => { + it('a fixed state always picks its theme, whatever the scene or the person chose', () => { + [true, false].forEach((scene) => + [true, false].forEach((chose) => { + expect(sceneThemeId('day', 'night', scene, chose)).toBe('noonday-sky'); + expect(sceneThemeId('night', 'day', scene, chose)).toBe('midnight-sky'); + }) + ); + }); + + it('auto follows the resolved sky only while a scene is on and the person chose no theme', () => { + expect(sceneThemeId('auto', 'day', true, false)).toBe('noonday-sky'); + expect(sceneThemeId('auto', 'dusk', true, false)).toBe('noonday-sky'); + expect(sceneThemeId('auto', 'night', true, false)).toBe('midnight-sky'); + expect(sceneThemeId('auto', 'day', false, false)).toBeUndefined(); + expect(sceneThemeId('auto', 'day', true, true)).toBeUndefined(); + }); + }); + + it('the resolved sky a hook subscribes to changes as the clock crosses, so theme classes follow', () => { + vi.useFakeTimers(); + let hour = 12; + setSkyClock(() => atStandardTime(hour, 10)); + const seen: string[] = []; + const off = subscribeSky((s) => seen.push(s)); + expect(getResolvedSkyState()).toBe('day'); + hour = 0; + setSkyClock(() => atStandardTime(hour, 10)); + vi.advanceTimersByTime(61_000); + expect(seen).toEqual(['night']); + expect(getResolvedSkyState()).toBe('night'); + off(); + vi.useRealTimers(); setSkyClock(); }); diff --git a/src/app/features/scene/skyTheme.ts b/src/app/features/scene/skyTheme.ts index dbd46501..3d427cb2 100644 --- a/src/app/features/scene/skyTheme.ts +++ b/src/app/features/scene/skyTheme.ts @@ -10,7 +10,7 @@ // derived outside this code (src/sky/skyTokens.js). Midnight Sky's link is derived there against the lightest Night sky // (4.53 on it, 5.43 on the darkest). The plate (skyPlate.css.ts) is still in the shell, pending // its removal. -import { resolveSkyState, type FixedSkyState, type SkyState } from './skyState'; +import type { FixedSkyState, SkyState } from './skyState'; export type SkyThemeId = 'midnight-sky' | 'noonday-sky'; @@ -21,4 +21,19 @@ const BY_STATE: Record = { }; export const skyThemeId = (state: SkyState): SkyThemeId | undefined => - BY_STATE[resolveSkyState(state)]; + state === 'auto' ? undefined : BY_STATE[state]; + +/** + * The theme the page wears: a fixed sky state always picks its theme; 'auto' follows the resolved sky (`resolved`, the + * clock's state) only while a scene is on and the person has not chosen a theme of their own, and otherwise picks none. + * Signed-out pages choose no theme, so they pass `userChoseTheme: false`. + */ +export const sceneThemeId = ( + state: SkyState, + resolved: FixedSkyState, + sceneOn: boolean, + userChoseTheme: boolean +): SkyThemeId | undefined => { + if (state !== 'auto') return BY_STATE[state]; + return sceneOn && !userChoseTheme ? BY_STATE[resolved] : undefined; +}; diff --git a/src/app/features/settings/about/About.tsx b/src/app/features/settings/about/About.tsx index 6b304965..9ec74305 100644 --- a/src/app/features/settings/about/About.tsx +++ b/src/app/features/settings/about/About.tsx @@ -4,6 +4,7 @@ import { Page, PageContent, PageHeader } from '../../../components/page'; import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCardStyle } from '../styles.css'; import { SettingTile } from '../../../components/setting-tile'; +import { LogoMark } from '../../../components/logo/logoMark.css'; import RatatoskrSVG from '../../../../../public/res/svg/ratatoskr.svg'; import { clearCacheAndReload } from '../../../../client/initMatrix'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; @@ -37,6 +38,7 @@ export function About({ requestClose }: AboutProps) { Ratatoskr logo useSyncExternalStore(subscribe, getSkyState); + +/** The sky being drawn: day, dusk or night. Unlike the choice it changes as the clock crosses, so it re-renders. */ +export const useResolvedSkyState = (): FixedSkyState => + useSyncExternalStore(subscribe, getResolvedSkyState); + +/** Whether a background scene runs on this device and config (the tier the scene host will mount). */ +export const useSceneOn = (): boolean => + resolveTier(useClientConfig().backgroundScene, readCapabilities(navigator)) !== 'off'; diff --git a/src/app/hooks/useTheme.ts b/src/app/hooks/useTheme.ts index 00ac1ee9..60f89757 100644 --- a/src/app/hooks/useTheme.ts +++ b/src/app/hooks/useTheme.ts @@ -10,10 +10,10 @@ import { } from '../../colors.css'; import '../../gamecult.css'; import '../../sky.css'; -import { settingsAtom } from '../state/settings'; +import { defaultThemeId, settingsAtom } from '../state/settings'; import { useSetting } from '../state/hooks/settings'; -import { skyThemeId } from '../features/scene/skyTheme'; -import { useSkyState } from './useSkyState'; +import { sceneThemeId } from '../features/scene/skyTheme'; +import { useResolvedSkyState, useSceneOn, useSkyState } from './useSkyState'; export enum ThemeKind { Light = 'light', @@ -102,20 +102,29 @@ export const useSystemThemeKind = (): ThemeKind => { return themeKind; }; -/** The theme the sky state asks for, or undefined while it is 'auto': auto never overrides a person's theme. */ -export const useSkyTheme = (): Theme | undefined => { - const id = skyThemeId(useSkyState()); - return id === NoondaySkyTheme.id ? NoondaySkyTheme : id && MidnightSkyTheme; -}; +const toTheme = (id: ReturnType): Theme | undefined => + id === NoondaySkyTheme.id ? NoondaySkyTheme : id && MidnightSkyTheme; + +/** A theme the person chose: the system scheme, or one other than the default. A saved Midnight Sky is the default. */ +const chosenTheme = (useSystemTheme: boolean, themeId?: string) => + useSystemTheme || (themeId !== undefined && themeId !== defaultThemeId); + +/** The theme the sky asks for on a page with no theme of its own (signed out), or undefined for none. */ +export const useSkyTheme = (): Theme | undefined => + toTheme(sceneThemeId(useSkyState(), useResolvedSkyState(), useSceneOn(), false)); export const useActiveTheme = (): Theme => { - const skyTheme = useSkyTheme(); + const sky = useSkyState(); + const resolved = useResolvedSkyState(); + const sceneOn = useSceneOn(); const systemThemeKind = useSystemThemeKind(); const themes = useThemes(); const [systemTheme] = useSetting(settingsAtom, 'useSystemTheme'); const [themeId] = useSetting(settingsAtom, 'themeId'); const [lightThemeId] = useSetting(settingsAtom, 'lightThemeId'); const [darkThemeId] = useSetting(settingsAtom, 'darkThemeId'); + // A person's own theme wins over 'auto'; a fixed sky state is itself a choice and wins over the theme. + const skyTheme = toTheme(sceneThemeId(sky, resolved, sceneOn, chosenTheme(systemTheme, themeId))); if (skyTheme) return skyTheme; diff --git a/src/app/pages/auth/AuthLayout.tsx b/src/app/pages/auth/AuthLayout.tsx index 0d5c9406..49c7b774 100644 --- a/src/app/pages/auth/AuthLayout.tsx +++ b/src/app/pages/auth/AuthLayout.tsx @@ -21,6 +21,7 @@ import { import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { LOGIN_PATH, REGISTER_PATH, RESET_PASSWORD_PATH } from '../paths'; import RatatoskrSVG from '../../../../public/res/svg/ratatoskr.svg'; +import { LogoMark } from '../../components/logo/logoMark.css'; import { SkyStateControl } from '../../features/scene/SkyStateControl'; import { ServerPicker } from './ServerPicker'; import { AutoDiscoveryAction, autoDiscovery } from '../../cs-api'; @@ -135,7 +136,11 @@ export function AuthLayout() {
    - Ratatoskr logo + Ratatoskr logo Ratatoskr diff --git a/src/app/pages/auth/styles.css.ts b/src/app/pages/auth/styles.css.ts index f16617e8..56ee090f 100644 --- a/src/app/pages/auth/styles.css.ts +++ b/src/app/pages/auth/styles.css.ts @@ -34,9 +34,6 @@ export const AuthLogo = style([ }, ]); -// The mark is white (Meta's matte); on the light sky theme it takes the dark ink instead. -globalStyle('body.noonday-sky img[src*="ratatoskr"]', { filter: 'brightness(0.18)' }); - export const AuthHeader = style({ padding: `0 ${config.space.S400}`, borderBottomWidth: config.borderWidth.B300, diff --git a/src/app/pages/client/WelcomePage.tsx b/src/app/pages/client/WelcomePage.tsx index 74869c3f..8b63af73 100644 --- a/src/app/pages/client/WelcomePage.tsx +++ b/src/app/pages/client/WelcomePage.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Box, Button, Icon, Icons, Text, config, toRem } from 'folds'; import { Page, PageHero, PageHeroSection } from '../../components/page'; +import { LogoMark } from '../../components/logo/logoMark.css'; import RatatoskrSVG from '../../../../public/res/svg/ratatoskr.svg'; export function WelcomePage() { @@ -14,7 +15,15 @@ export function WelcomePage() { > } + icon={ + Ratatoskr logo + } title="Welcome to Ratatoskr" subTitle={ diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 2b48fb59..9ca2496f 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -58,8 +58,10 @@ export interface Settings { soundscapeVoiceCap: number; } +export const defaultThemeId = 'midnight-sky'; + const defaultSettings: Settings = { - themeId: 'midnight-sky', + themeId: defaultThemeId, useSystemTheme: false, lightThemeId: undefined, darkThemeId: undefined, From d2d6fbcc0012cf05bb43197dd16e8665633db405 Mon Sep 17 00:00:00 2001 From: Eureka Self Date: Tue, 10 Nov 2026 14:04:34 +0000 Subject: [PATCH 201/206] nemesis: lookbehind regexes degrade where the engine lacks lookbehind (Safari < 16.4 blanked at load); markdown rules share regex.ts patterns Co-Authored-By: Claude Opus 5.5 --- src/app/plugins/markdown/inline/rules.ts | 3 +-- src/app/utils/regex.ts | 21 +++++++++++++++++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/app/plugins/markdown/inline/rules.ts b/src/app/plugins/markdown/inline/rules.ts index 77bcbd57..2a7c6016 100644 --- a/src/app/plugins/markdown/inline/rules.ts +++ b/src/app/plugins/markdown/inline/rules.ts @@ -1,8 +1,7 @@ import { InlineMDRule } from './type'; +import { ESC_NEG_LB, URL_NEG_LB } from '../../../utils/regex'; const MIN_ANY = '(.+?)'; -const URL_NEG_LB = '(? unsafeText.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&').replace(/-/g, '\\x2d'); -export const HTTP_URL_PATTERN = `https?:\\/\\/(?:www\\.)?(?:[^\\s)]*)(? { + try { + return new RegExp('(?<=a)b').test('ab'); + } catch { + return false; + } +})(); + +// Without lookbehind a URL keeps any trailing punctuation. +export const HTTP_URL_PATTERN = LOOKBEHIND + ? `https?:\\/\\/(?:www\\.)?(?:[^\\s)]*)(?()[\]\\.,;:\s@\\"]+(\.[^<>()[\]\\.,;:\s@\\"]+)*)|(\\".+\\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; -export const URL_NEG_LB = '(? Date: Tue, 10 Nov 2026 14:05:42 +0000 Subject: [PATCH 202/206] Diagnostic overlays (?probe=1, ?tierlabel=1) only where allowed; footer band test knows the Safari prefix debugOverlaysAllowed: a dev build, a VITE_PREVIEW_DEMO build, or backgroundScene.debugOverlays in the client config. A production visitor adding the query string gets neither the frame readout nor the tier label. footerBand.test pins -webkit-backdrop-filter (live commit 0fe41efb) and that it blurs as the standard one. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/debugOverlays.test.ts | 49 +++++++++++++++++++ src/app/features/scene/debugOverlays.ts | 15 ++++++ src/app/features/scene/index.ts | 6 ++- src/app/features/scene/ridge/Ridge.test.ts | 13 ++++- src/app/features/scene/ridge/Ridge.ts | 7 ++- src/app/features/scene/terrain/Terrain.ts | 9 ++-- .../features/scene/terrain/tierLabel.test.ts | 12 +++-- src/app/features/scene/terrain/tierLabel.ts | 18 ++++--- src/app/features/scene/tier.ts | 2 + src/app/pages/auth/footerBand.test.ts | 10 +++- 10 files changed, 123 insertions(+), 18 deletions(-) create mode 100644 src/app/features/scene/debugOverlays.test.ts create mode 100644 src/app/features/scene/debugOverlays.ts diff --git a/src/app/features/scene/debugOverlays.test.ts b/src/app/features/scene/debugOverlays.test.ts new file mode 100644 index 00000000..e1a4e61e --- /dev/null +++ b/src/app/features/scene/debugOverlays.test.ts @@ -0,0 +1,49 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { debugOverlaysAllowed, overlayRequested } from './debugOverlays'; + +let search = ''; +beforeEach(() => { + search = ''; + vi.stubGlobal('window', { + get location() { + return { search }; + }, + }); +}); +afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); +}); + +describe('debugOverlaysAllowed', () => { + it('a production build allows nothing by default', () => { + vi.stubEnv('DEV', false); + vi.stubEnv('VITE_PREVIEW_DEMO', ''); + expect(debugOverlaysAllowed(undefined)).toBe(false); + expect(debugOverlaysAllowed({ tier: 'full' })).toBe(false); + expect(debugOverlaysAllowed({ tier: 'full', debugOverlays: false })).toBe(false); + }); + + it('a dev build, a demo preview build or an explicit config flag allows them', () => { + vi.stubEnv('DEV', true); + expect(debugOverlaysAllowed(undefined)).toBe(true); + vi.stubEnv('DEV', false); + vi.stubEnv('VITE_PREVIEW_DEMO', '1'); + expect(debugOverlaysAllowed(undefined)).toBe(true); + vi.stubEnv('VITE_PREVIEW_DEMO', ''); + expect(debugOverlaysAllowed({ debugOverlays: true })).toBe(true); + }); +}); + +describe('overlayRequested', () => { + it('needs both the query and the permission', () => { + search = '?probe=1'; + expect(overlayRequested('probe', true)).toBe(true); + expect(overlayRequested('probe', false)).toBe(false); + expect(overlayRequested('tierlabel', true)).toBe(false); + search = '?tierlabel=1'; + expect(overlayRequested('tierlabel', true)).toBe(true); + search = ''; + expect(overlayRequested('probe', true)).toBe(false); + }); +}); diff --git a/src/app/features/scene/debugOverlays.ts b/src/app/features/scene/debugOverlays.ts new file mode 100644 index 00000000..a02d9e92 --- /dev/null +++ b/src/app/features/scene/debugOverlays.ts @@ -0,0 +1,15 @@ +import type { BackgroundSceneConfig } from './tier'; + +/** + * Whether the page URL may switch on the scene's diagnostic overlays (`?probe=1` frame readout, `?tierlabel=1` path + * label). Only a dev build, a demo preview build (VITE_PREVIEW_DEMO=1), or a config that says `debugOverlays: true` + * allows it; a production visitor adding a query string gets nothing. + */ +export const debugOverlaysAllowed = (config: BackgroundSceneConfig | undefined): boolean => + import.meta.env?.DEV === true || + import.meta.env?.VITE_PREVIEW_DEMO === '1' || + config?.debugOverlays === true; + +/** The page URL asks for this overlay (`?name=1`), and the overlays are allowed. */ +export const overlayRequested = (name: 'probe' | 'tierlabel', allowed: boolean): boolean => + allowed && new URLSearchParams(window.location.search).get(name) === '1'; diff --git a/src/app/features/scene/index.ts b/src/app/features/scene/index.ts index 73377440..bc041672 100644 --- a/src/app/features/scene/index.ts +++ b/src/app/features/scene/index.ts @@ -1,5 +1,6 @@ import type { Scene, SceneOptions } from './Scene'; import { BackgroundSceneConfig, Capabilities, SceneTier, resolveTier } from './tier'; +import { debugOverlaysAllowed } from './debugOverlays'; import { getSkyState, subscribe } from './skyState'; import type { SkyState } from './skyState'; @@ -17,7 +18,7 @@ export type StartOptions = Omit { let createScene: (o: SceneOptions) => Scene | null; @@ -82,7 +83,8 @@ export async function startBackgroundScene( ): Promise { const tier = resolveTier(config, capabilities); if (tier === 'off') return null; - const build = (state: SkyState) => create(config, { ...options, tier }, state); + const debug = debugOverlaysAllowed(config); + const build = (state: SkyState) => create(config, { ...options, tier, debug }, state); const state = getSkyState(); const key0 = config?.style === 'raymarch' ? null : await skyKey(config, state); const first = await build(state); diff --git a/src/app/features/scene/ridge/Ridge.test.ts b/src/app/features/scene/ridge/Ridge.test.ts index 711054f6..adee2717 100644 --- a/src/app/features/scene/ridge/Ridge.test.ts +++ b/src/app/features/scene/ridge/Ridge.test.ts @@ -428,7 +428,7 @@ describe('createRidgeScene', () => { it('?probe=1 keeps the rAF loop alive and shows median and p95', () => { search = '?probe=1'; const { parent, canvas } = host(); - const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!; + const s = createRidgeScene({ canvas, tier: 'full', sky: DAY, debug: true })!; const readout = parent.children[parent.children.length - 1]; runFrames(16.7, 300); expect(rafQueue.length).toBe(1); // still sampling @@ -437,6 +437,17 @@ describe('createRidgeScene', () => { expect(parent.children.some((c: any) => c === readout)).toBe(false); }); + it('?probe=1 does nothing where the overlays are not allowed: no readout, the loop ends', () => { + search = '?probe=1'; + const { parent, canvas } = host(); + const before = parent.children.length; + const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!; + expect(parent.children.length).toBe(before + LAYERS.length); + runFrames(16.7, 300); + expect(rafQueue.length).toBe(0); + s.dispose(); + }); + it('a scene started at zero size paints nothing, then builds on the first non-zero resize', async () => { const { parent, canvas } = host(); canvas.clientWidth = 0; diff --git a/src/app/features/scene/ridge/Ridge.ts b/src/app/features/scene/ridge/Ridge.ts index 69a88864..fd40a967 100644 --- a/src/app/features/scene/ridge/Ridge.ts +++ b/src/app/features/scene/ridge/Ridge.ts @@ -1,4 +1,5 @@ import type { Scene, SceneOptions } from '../Scene'; +import { overlayRequested } from '../debugOverlays'; import { FrameSampler, FrameStats } from './probe'; import { skyColours, SkyColours, Srgb } from './skyColour'; import { activeAt, drawStars, drawTwinkle as paintTwinkle, makeStars, Star } from './stars'; @@ -14,8 +15,10 @@ import { } from './ridgeWorld'; export type RidgeOptions = Pick & { - /** Show the rAF median/p95 readout. Default: the page URL has `?probe=1`. */ + /** Show the rAF median/p95 readout. Default: the page URL has `?probe=1`, where `debug` allows it. */ probe?: boolean; + /** The diagnostic overlays may be asked for by URL (debugOverlays.ts). Default: no. */ + debug?: boolean; /** Each time the readout would update, and once with `still` when the fallback fires. */ onProbe?: (s: FrameStats & { still: boolean }) => void; /** The sky's colours; default `skyColours()`, the one function to replace with Void's sun.js. */ @@ -151,7 +154,7 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null { const parent = canvas.parentElement; if (!parent) return null; const sky = opts.sky ?? skyColours(); - const probe = opts.probe ?? new URLSearchParams(window.location.search).get('probe') === '1'; + const probe = opts.probe ?? overlayRequested('probe', opts.debug === true); const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)'); let built: Built | undefined; diff --git a/src/app/features/scene/terrain/Terrain.ts b/src/app/features/scene/terrain/Terrain.ts index b697927b..a1807305 100644 --- a/src/app/features/scene/terrain/Terrain.ts +++ b/src/app/features/scene/terrain/Terrain.ts @@ -22,6 +22,7 @@ import { SKY_FRAG, } from './terrainShaders'; import { createTerrainStars } from './terrainStars'; +import { overlayRequested } from '../debugOverlays'; import { showTierLabel } from './tierLabel'; export type TerrainOptions = Pick< @@ -36,8 +37,10 @@ export type TerrainOptions = Pick< band?: SkyBand; /** Terrain seed: the height map is sampled at an offset taken from it. Default 0. */ seed?: number; - /** Show the rAF median/p95 readout (plus triangle count). Default: the page URL has `?probe=1`. */ + /** Show the rAF median/p95 readout (plus triangle count). Default: the page URL has `?probe=1`, where `debug` allows it. */ probe?: boolean; + /** The diagnostic overlays (readout, tier label) may be asked for by URL (debugOverlays.ts). Default: no. */ + debug?: boolean; onProbe?: (s: FrameStats & { still: boolean; triangles: number }) => void; }; @@ -89,7 +92,7 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { const tier = opts.tier ?? 'full'; if (tier === 'off') return null; const { canvas } = opts; - const probe = opts.probe ?? new URLSearchParams(window.location.search).get('probe') === '1'; + const probe = opts.probe ?? overlayRequested('probe', opts.debug === true); const gl = canvas.getContext('webgl2', { antialias: false, alpha: false, @@ -395,7 +398,7 @@ export function createTerrainScene(opts: TerrainOptions): Scene | null { }); canvas.parentElement.appendChild(readout); } - const removeLabel = showTierLabel(canvas, tier); + const removeLabel = showTierLabel(canvas, tier, opts.debug === true); if (still) settleToStill(); else wake(); if (!still && !moving()) draw(0); diff --git a/src/app/features/scene/terrain/tierLabel.test.ts b/src/app/features/scene/terrain/tierLabel.test.ts index 3e9f3a48..e3f18871 100644 --- a/src/app/features/scene/terrain/tierLabel.test.ts +++ b/src/app/features/scene/terrain/tierLabel.test.ts @@ -29,21 +29,27 @@ afterEach(() => vi.unstubAllGlobals()); describe('showTierLabel', () => { it('adds nothing in a normal build', () => { - expect(showTierLabel(canvas(true), 'full')).toBeNull(); + expect(showTierLabel(canvas(true), 'full', true)).toBeNull(); expect(appended).toHaveLength(0); }); it('names the path on screen when asked with ?tierlabel=1, and removes itself', () => { search = '?tierlabel=1'; - const remove = showTierLabel(canvas(true), 'sky-only'); + const remove = showTierLabel(canvas(true), 'sky-only', true); expect(appended).toHaveLength(1); expect(appended[0].textContent).toBe('terrain · sky-only'); remove!(); expect(appended[0].remove).toHaveBeenCalled(); }); + it('ignores ?tierlabel=1 where the overlays are not allowed (a production page)', () => { + search = '?tierlabel=1'; + expect(showTierLabel(canvas(true), 'full', false)).toBeNull(); + expect(appended).toHaveLength(0); + }); + it('has nowhere to go without a parent', () => { search = '?tierlabel=1'; - expect(showTierLabel(canvas(false), 'full')).toBeNull(); + expect(showTierLabel(canvas(false), 'full', true)).toBeNull(); }); }); diff --git a/src/app/features/scene/terrain/tierLabel.ts b/src/app/features/scene/terrain/tierLabel.ts index 0b2c79ec..4afc8a69 100644 --- a/src/app/features/scene/terrain/tierLabel.ts +++ b/src/app/features/scene/terrain/tierLabel.ts @@ -1,13 +1,19 @@ +import { overlayRequested } from '../debugOverlays'; + // A tiny label naming which terrain path is on screen ('full', 'low', 'still', or 'sky-only' where WebGL2 is missing), -// so a screenshot of a preview says what it shows. Built into preview builds only (VITE_PREVIEW_DEMO=1), or by `?tierlabel=1`. -const wanted = (): boolean => - import.meta.env?.VITE_PREVIEW_DEMO === '1' || - new URLSearchParams(window.location.search).get('tierlabel') === '1'; +// so a screenshot of a preview says what it shows. Built into preview builds (VITE_PREVIEW_DEMO=1), or by `?tierlabel=1` +// where the diagnostic overlays are allowed (debugOverlays.ts). +const wanted = (allowed: boolean): boolean => + import.meta.env?.VITE_PREVIEW_DEMO === '1' || overlayRequested('tierlabel', allowed); /** Adds the label to the canvas's parent; returns its remover, or null when no label is wanted. */ -export function showTierLabel(canvas: HTMLCanvasElement, text: string): (() => void) | null { +export function showTierLabel( + canvas: HTMLCanvasElement, + text: string, + allowed: boolean +): (() => void) | null { const parent = canvas.parentElement; - if (!parent || !wanted()) return null; + if (!parent || !wanted(allowed)) return null; const el = document.createElement('div'); el.textContent = `terrain · ${text}`; Object.assign(el.style, { diff --git a/src/app/features/scene/tier.ts b/src/app/features/scene/tier.ts index 4a976081..ba338f37 100644 --- a/src/app/features/scene/tier.ts +++ b/src/app/features/scene/tier.ts @@ -24,6 +24,8 @@ export type SceneStyle = 'ridge' | 'raymarch' | 'terrain'; export interface BackgroundSceneConfig { tier?: SceneTier; style?: SceneStyle; + /** Let the page URL switch on the diagnostic overlays (`?probe=1`, `?tierlabel=1`) in a production build. Default: no. */ + debugOverlays?: boolean; } export function readCapabilities(nav: Navigator): Capabilities { diff --git a/src/app/pages/auth/footerBand.test.ts b/src/app/pages/auth/footerBand.test.ts index 9d196200..97febbbb 100644 --- a/src/app/pages/auth/footerBand.test.ts +++ b/src/app/pages/auth/footerBand.test.ts @@ -43,9 +43,17 @@ describe('the sign-in footer band, as emitted', () => { ]); }); - it('declares exactly these properties, so nothing else (a shadow, a prefixed override) rides along', async () => { + it('declares exactly these properties, so nothing else (a shadow, a stray override) rides along', async () => { const [rule] = declarations(await emitted(), over); + // -webkit-backdrop-filter is the one prefixed property, for Safari 14; it must blur exactly as the standard one does. + const blur = (prop: string) => + rule + .find((d) => d.startsWith(`${prop}:`)) + ?.split(':')[1] + .trim(); + expect(blur('-webkit-backdrop-filter')).toBe(blur('backdrop-filter')); expect(rule.map((d) => d.slice(0, d.indexOf(':'))).sort()).toEqual([ + '-webkit-backdrop-filter', 'backdrop-filter', 'background-color', 'background-color', From 48d6d171daa7505127170aa1b40afbaca2e91909 Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 14:09:00 +0000 Subject: [PATCH 203/206] Sky-only tier label follows the overlay gate; tests pin the door's debug forwarding Co-Authored-By: Claude Sonnet 5.5 --- .../scene/terrain/TerrainSky2D.test.ts | 36 +++++++++++++++++++ .../features/scene/terrain/TerrainSky2D.ts | 7 ++-- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/app/features/scene/terrain/TerrainSky2D.test.ts b/src/app/features/scene/terrain/TerrainSky2D.test.ts index c3a95d69..d85d8f01 100644 --- a/src/app/features/scene/terrain/TerrainSky2D.test.ts +++ b/src/app/features/scene/terrain/TerrainSky2D.test.ts @@ -168,6 +168,42 @@ describe('createTerrainSkyScene', () => { }); }); +describe('the sky-only tier label', () => { + const labels = (parentAppend: ReturnType) => + parentAppend.mock.calls.filter(([e]) => e?.textContent === 'terrain · sky-only'); + + it('shows for ?tierlabel=1 only where the overlays are allowed', () => { + search = '?tierlabel=1'; + const off = canvasWith(); + createTerrainSkyScene({ canvas: off.canvas, tier: 'low' })!.dispose(); + expect(labels(off.parentAppend)).toHaveLength(0); + const on = canvasWith(); + createTerrainSkyScene({ canvas: on.canvas, tier: 'low', debug: true })!.dispose(); + expect(labels(on.parentAppend)).toHaveLength(1); + }); + + it('the door allows it from the config flag, and not from a plain production config', async () => { + vi.stubEnv('DEV', false); + vi.stubEnv('VITE_PREVIEW_DEMO', ''); + search = '?tierlabel=1'; + const plain = canvasWith(); + ( + await startBackgroundScene({ tier: 'low', style: 'terrain' }, {}, { canvas: plain.canvas }) + )?.dispose(); + expect(labels(plain.parentAppend)).toHaveLength(0); + const flagged = canvasWith(); + ( + await startBackgroundScene( + { tier: 'low', style: 'terrain', debugOverlays: true }, + {}, + { canvas: flagged.canvas } + ) + )?.dispose(); + expect(labels(flagged.parentAppend)).toHaveLength(1); + vi.unstubAllEnvs(); + }); +}); + describe('startBackgroundScene, terrain style', () => { it('without WebGL2 mounts the sky on the same canvas instead of returning null', async () => { const { canvas, painted } = canvasWith({ gl: null }); diff --git a/src/app/features/scene/terrain/TerrainSky2D.ts b/src/app/features/scene/terrain/TerrainSky2D.ts index 71bc7a78..4653d470 100644 --- a/src/app/features/scene/terrain/TerrainSky2D.ts +++ b/src/app/features/scene/terrain/TerrainSky2D.ts @@ -9,7 +9,10 @@ import { CAMERA, view } from './terrainWorld'; export const SKY_WIDTH = 96; /** The sky of the terrain scene without WebGL2: the camera's first frame of `skyColour`, once, on a 2D canvas, with the star layer on top. */ -export type TerrainSkyOptions = Pick; +export type TerrainSkyOptions = Pick< + TerrainOptions, + 'canvas' | 'tier' | 'sky' | 'sun' | 'band' | 'debug' +>; /** * Same contract as `createTerrainScene`. Paints the terrain scene's sky (the CPU twin of its sky shader, from the @@ -69,7 +72,7 @@ export function createTerrainSkyScene(opts: TerrainSkyOptions): Scene | null { }; window.addEventListener('resize', onResize); paint(); - const removeLabel = showTierLabel(canvas, 'sky-only'); + const removeLabel = showTierLabel(canvas, 'sky-only', opts.debug === true); return { login() { From 9b9b495defbc193b6b7665662bfb6889818f12e9 Mon Sep 17 00:00:00 2001 From: Eureka Self Date: Tue, 10 Nov 2026 14:09:58 +0000 Subject: [PATCH 204/206] nemesis: drop the pose status strip; the portraits already show each Mind's mood (Meta: redundant, wastes vertical space) Co-Authored-By: Claude Opus 5.5 --- src/app/components/message/pose/poses.test.ts | 50 ------------ src/app/components/message/pose/poses.ts | 47 ----------- src/app/features/room/RoomView.tsx | 2 - .../features/room/RoomViewPoseStatus.css.ts | 40 ---------- src/app/features/room/RoomViewPoseStatus.tsx | 80 ------------------- 5 files changed, 219 deletions(-) delete mode 100644 src/app/features/room/RoomViewPoseStatus.css.ts delete mode 100644 src/app/features/room/RoomViewPoseStatus.tsx diff --git a/src/app/components/message/pose/poses.test.ts b/src/app/components/message/pose/poses.test.ts index 81c2f0c6..c6089f62 100644 --- a/src/app/components/message/pose/poses.test.ts +++ b/src/app/components/message/pose/poses.test.ts @@ -3,10 +3,7 @@ import catalogue from './catalogue.json'; import { mindOfSender, poseSrc, - poseStatusLine, - poseStatuses, resolvePose, - splitNewestStatus, } from './poses'; describe('resolvePose', () => { @@ -53,52 +50,5 @@ describe('poseSrc', () => { }); }); -describe('poseStatuses', () => { - const ev = (sender: string, ts: number, label: unknown) => ({ - sender, - localpart: sender, - ts, - label, - }); - it('reports the latest pose per Mind and when its unbroken run began', () => { - const out = poseStatuses([ - ev('aqua', 1, 'happy'), - ev('aqua', 2, 'sigh'), - ev('aqua', 3, 'sigh'), - ev('alice', 4, 'happy'), - ev('eureka', 5, 'probe'), - ]); - expect(out).toEqual([ - { mind: 'aqua', pose: 'sigh', since: 2 }, - { mind: 'eureka', pose: 'probe', since: 5 }, - ]); - }); - it('treats unknown labels as neutral runs', () => { - expect(poseStatuses([ev('void', 1, 'bogus'), ev('void', 2, undefined)])).toEqual([ - { mind: 'void', pose: 'neutral', since: 1 }, - ]); - }); -}); -describe('poseStatusLine', () => { - it('formats the client-status line', () => { - const since = new Date(2026, 10, 4, 9, 5).getTime(); - expect(poseStatusLine({ mind: 'void', pose: 'reading', since })).toBe( - 'Ratatoskr: Void is reading, since 09:05' - ); - }); -}); -describe('splitNewestStatus', () => { - const s = (mind: string, since: number) => ({ mind, pose: 'happy', since }); - - it('picks the status that changed last and keeps the others in their order', () => { - const { newest, rest } = splitNewestStatus([s('eureka', 100), s('void', 300), s('nibu', 200)]); - expect(newest?.mind).toBe('void'); - expect(rest.map((r) => r.mind)).toEqual(['eureka', 'nibu']); - }); - - it('has no newest for an empty list', () => { - expect(splitNewestStatus([])).toEqual({ newest: undefined, rest: [] }); - }); -}); diff --git a/src/app/components/message/pose/poses.ts b/src/app/components/message/pose/poses.ts index 7175196a..69429090 100644 --- a/src/app/components/message/pose/poses.ts +++ b/src/app/components/message/pose/poses.ts @@ -40,50 +40,3 @@ export const resolvePose = (mind: string, label: unknown): string => { export const poseSrc = (mind: string, pose: string, base = '/'): string => `${base}public/poses/${artPath(mind, pose) ?? artPath(mind, NEUTRAL)}`; -export type PoseStatus = { mind: string; pose: string; since: number }; -export type PoseEvent = { - sender: string; - localpart?: string; - name?: string; - ts: number; - label: unknown; -}; - -// Latest pose per Mind in a room's events (oldest first), and when that pose -// began: the first of its unbroken run of messages carrying the same pose. -export const poseStatuses = (events: PoseEvent[]): PoseStatus[] => { - const bySender = new Map(); - events.forEach((e) => { - const mind = mindOfSender(e.localpart, e.name); - if (!mind) return; - const entry = bySender.get(e.sender) ?? { mind, run: [] }; - entry.run.push({ pose: resolvePose(mind, e.label), ts: e.ts }); - bySender.set(e.sender, entry); - }); - return [...bySender.values()].map(({ mind, run }) => { - const last = run[run.length - 1]; - let i = run.length - 1; - while (i > 0 && run[i - 1].pose === last.pose) i -= 1; - return { mind, pose: last.pose, since: run[i].ts }; - }); -}; - -// The status that changed last, and the rest in their given order (phones show only the first). -export const splitNewestStatus = ( - statuses: PoseStatus[] -): { newest: PoseStatus | undefined; rest: PoseStatus[] } => { - let newest: PoseStatus | undefined; - statuses.forEach((st) => { - if (!newest || st.since > newest.since) newest = st; - }); - return { newest, rest: statuses.filter((st) => st !== newest) }; -}; - -const two = (n: number) => String(n).padStart(2, '0'); - -// Client-status text. Rendered locally only; it is never sent to a room. -export const poseStatusLine = ({ mind, pose, since }: PoseStatus): string => { - const d = new Date(since); - const name = mind.charAt(0).toUpperCase() + mind.slice(1); - return `Ratatoskr: ${name} is ${pose}, since ${two(d.getHours())}:${two(d.getMinutes())}`; -}; diff --git a/src/app/features/room/RoomView.tsx b/src/app/features/room/RoomView.tsx index cf91c638..3fbd81fa 100644 --- a/src/app/features/room/RoomView.tsx +++ b/src/app/features/room/RoomView.tsx @@ -11,7 +11,6 @@ import { useEditor } from '../../components/editor'; import { RoomInputPlaceholder } from './RoomInputPlaceholder'; import { RoomTimeline } from './RoomTimeline'; import { RoomViewTyping } from './RoomViewTyping'; -import { RoomViewPoseStatus } from './RoomViewPoseStatus'; import { RoomTombstone } from './RoomTombstone'; import { RoomInput } from './RoomInput'; import { RoomTrainingLine } from './RoomTrainingLine'; @@ -102,7 +101,6 @@ export function RoomView({ eventId }: { eventId?: string }) { roomInputRef={roomInputRef} editor={editor} /> - diff --git a/src/app/features/room/RoomViewPoseStatus.css.ts b/src/app/features/room/RoomViewPoseStatus.css.ts deleted file mode 100644 index d8319d0d..00000000 --- a/src/app/features/room/RoomViewPoseStatus.css.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { style } from '@vanilla-extract/css'; -import { DefaultReset, color, config, toRem } from 'folds'; - -const NARROW = 'screen and (max-width: 480px)'; - -export const StatusBlock = style([ - DefaultReset, - { - display: 'flex', - flexDirection: 'column', - flexShrink: 0, - padding: `0 ${toRem(16)}`, - // Phones: the newest mood and its "+N more" share one line. - '@media': { [NARROW]: { flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center' } }, - }, -]); - -// On phones only the newest mood shows until the strip is tapped; wide screens show every line. -export const StatusRow = style({ - '@media': { - [NARROW]: { - selectors: { - '&[data-newest="false"]': { display: 'none' }, - [`${StatusBlock}[data-open="true"] &`]: { display: 'block', flexBasis: '100%' }, - }, - }, - }, -}); - -export const StatusToggle = style([ - DefaultReset, - { - display: 'none', - minHeight: toRem(24), - padding: `0 ${config.space.S100}`, - color: color.Primary.Main, - cursor: 'pointer', - '@media': { [NARROW]: { display: 'block' } }, - }, -]); diff --git a/src/app/features/room/RoomViewPoseStatus.tsx b/src/app/features/room/RoomViewPoseStatus.tsx deleted file mode 100644 index e62f81c0..00000000 --- a/src/app/features/room/RoomViewPoseStatus.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { Text } from 'folds'; -import { Room, RoomEvent } from 'matrix-js-sdk'; -import { settingsAtom } from '../../state/settings'; -import { useSetting } from '../../state/hooks/settings'; -import { getMxIdLocalPart } from '../../utils/matrix'; -import { getMemberDisplayName } from '../../utils/room'; -import { - POSE_KEY, - poseStatusLine, - poseStatuses, - splitNewestStatus, -} from '../../components/message/pose/poses'; -import * as css from './RoomViewPoseStatus.css'; - -// Client-status lines about each Mind's current pose. They are drawn from the -// timeline already in memory and are never sent as anyone's message. -export function RoomViewPoseStatus({ room }: { room: Room }) { - const [poseBubbles] = useSetting(settingsAtom, 'poseBubbles'); - const [, bump] = useState(0); - const [open, setOpen] = useState(false); - - useEffect(() => { - const onTimeline = () => bump((n) => n + 1); - room.on(RoomEvent.Timeline, onTimeline); - return () => { - room.removeListener(RoomEvent.Timeline, onTimeline); - }; - }, [room]); - - if (!poseBubbles) return null; - - const statuses = poseStatuses( - room - .getLiveTimeline() - .getEvents() - .filter((e) => e.getType() === 'm.room.message' && e.getSender()) - .map((e) => { - const sender = e.getSender() as string; - return { - sender, - localpart: getMxIdLocalPart(sender), - name: getMemberDisplayName(room, sender), - ts: e.getTs(), - label: e.getContent()[POSE_KEY], - }; - }) - ); - if (statuses.length === 0) return null; - - const { newest, rest } = splitNewestStatus(statuses); - - return ( -
    - {statuses.map((st) => ( - - {poseStatusLine(st)} - - ))} - {rest.length > 0 && ( - - )} -
    - ); -} From 3c92696507b4b9b9bfc6a46ea7e5cdea3d7014e0 Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 14:15:47 +0000 Subject: [PATCH 205/206] Room list: groups of unjoined project rooms wait behind one Browse entry A steward group with no joined or invited room collapses into "Browse N more rooms" per tier, closed on every screen and remembered per device; a group the user is in anything of, loose estate rooms, and joined rooms stay up front. splitGroups is the pure rule, tested. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/estate/EstateNavigator.tsx | 52 ++++++++++++++++----- src/app/features/estate/buildEstate.test.ts | 39 +++++++++++++++- src/app/features/estate/buildEstate.ts | 14 ++++++ 3 files changed, 92 insertions(+), 13 deletions(-) diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx index 2cbbfe4a..56b554fa 100644 --- a/src/app/features/estate/EstateNavigator.tsx +++ b/src/app/features/estate/EstateNavigator.tsx @@ -38,7 +38,7 @@ import { useClosedNavCategoriesAtom } from '../../state/hooks/closedNavCategorie import { makeNavCategoryId } from '../../state/closedNavCategories'; import { roomToUnreadAtom } from '../../state/room/roomToUnread'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; -import { EstateRoom, EstateTier } from './buildEstate'; +import { EstateGroup, EstateRoom, EstateTier, splitGroups } from './buildEstate'; import { activateEstateRoom, awaitMyJoin } from './joinEstateRoom'; import { useEstate, useEstateRootId } from './useEstate'; import * as css from './EstateNavigator.css'; @@ -167,25 +167,53 @@ function Collapsible({ categoryId, label, rooms, children }: CollapsibleProps) { ); } +function GroupSection({ tierId, group }: { tierId: string; group: EstateGroup }) { + return ( + + {group.rooms.map((r) => ( + + ))} + + ); +} + +/** + * Groups of rooms the user has not joined, behind one quiet entry. Closed until opened, on every screen; the opening is + * remembered per device like any other section. + */ +function Browse({ tierId, groups }: { tierId: string; groups: EstateGroup[] }) { + const [toggled, setToggled] = useAtom(useClosedNavCategoriesAtom()); + const key = makeNavCategoryId('estate', 'browse', tierId); + const open = toggled.has(key); + const count = groups.reduce((n, g) => n + g.rooms.length, 0); + + return ( + + + setToggled({ type: open ? 'DELETE' : 'PUT', categoryId: key })} + > + {`Browse ${count} more ${count === 1 ? 'room' : 'rooms'}`} + + + {open && groups.map((g) => )} + + ); +} + function Tier({ tier }: { tier: EstateTier }) { const allRooms = useMemo(() => [...tier.loose, ...tier.groups.flatMap((g) => g.rooms)], [tier]); + const { front, browse } = useMemo(() => splitGroups(tier), [tier]); return ( {tier.loose.map((r) => ( ))} - {tier.groups.map((g) => ( - - {g.rooms.map((r) => ( - - ))} - + {front.map((g) => ( + ))} + {browse.length > 0 && } ); } diff --git a/src/app/features/estate/buildEstate.test.ts b/src/app/features/estate/buildEstate.test.ts index 8f898a0f..7b63eac5 100644 --- a/src/app/features/estate/buildEstate.test.ts +++ b/src/app/features/estate/buildEstate.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { buildEstate, EstateHierarchyRoom, EstateInput, stewardOfChild } from './buildEstate'; +import { + buildEstate, + EstateHierarchyRoom, + EstateInput, + splitGroups, + stewardOfChild, +} from './buildEstate'; const ROOT = '!root:gamecult.org'; const PROJECTS = '!projects:gamecult.org'; @@ -324,3 +330,34 @@ describe('direct invites', () => { ]); }); }); + +describe('splitGroups', () => { + const projects = (membership: EstateInput['membership']) => + buildEstate(input({ membership })).find((t) => t.id === PROJECTS)!; + const labels = (gs: { mind: string }[]) => gs.map((g) => g.mind); + + it('puts a group nobody has joined behind Browse, and keeps loose estate rooms out of it', () => { + const tier = projects(new Map()); + const { front, browse } = splitGroups(tier); + expect(labels(front)).toEqual([]); + expect(labels(browse).sort()).toEqual(['eureka', 'void']); + expect(tier.loose.map((r) => r.name)).toEqual(['Misc']); + }); + + it('keeps a group up front once any room in it is joined, its unjoined siblings with it', () => { + const { front, browse } = splitGroups(projects(new Map([['!cultlib:gamecult.org', 'join']]))); + expect(labels(front)).toEqual(['eureka']); + expect(labels(browse)).toEqual(['void']); + }); + + it('keeps a group up front for an invite, which is something to act on', () => { + const { front } = splitGroups(projects(new Map([['!aeth:gamecult.org', 'invite']]))); + expect(labels(front)).toEqual(['void']); + }); + + it('loses no group: front and browse together are the tier groups', () => { + const tier = projects(new Map([['!cultlib:gamecult.org', 'join']])); + const { front, browse } = splitGroups(tier); + expect([...front, ...browse].sort((a, b) => a.mind.localeCompare(b.mind))).toEqual(tier.groups); + }); +}); diff --git a/src/app/features/estate/buildEstate.ts b/src/app/features/estate/buildEstate.ts index b9232beb..ca656211 100644 --- a/src/app/features/estate/buildEstate.ts +++ b/src/app/features/estate/buildEstate.ts @@ -71,6 +71,20 @@ export type EstateInput = { mindLabels?: ReadonlyMap; }; +/** + * A tier's project-room groups split for the navigator: a group the user is in anything of (joined, or invited, which is + * something to act on) stays up front; a group of rooms nobody has joined waits behind one Browse entry, so the first + * screen is conversations, not a wall of Join buttons. Loose (estate) rooms are never grouped away. + */ +export const splitGroups = (tier: EstateTier): { front: EstateGroup[]; browse: EstateGroup[] } => { + const front: EstateGroup[] = []; + const browse: EstateGroup[] = []; + tier.groups.forEach((g) => + (g.rooms.some((r) => r.state !== 'joinable') ? front : browse).push(g) + ); + return { front, browse }; +}; + export const DIRECT_TIER_ID = 'direct'; export const ELSEWHERE_TIER_ID = 'elsewhere'; From 3c868254e549ffe999ad3b448409e5e68931c5ee Mon Sep 17 00:00:00 2001 From: Void Date: Tue, 10 Nov 2026 14:47:41 +0000 Subject: [PATCH 206/206] Category buttons expose aria-expanded RoomNavCategoryButton set open/closed only through its chevron. It now sets aria-expanded from `closed` (absent when the section does not toggle); tested by rendering to static markup. Co-Authored-By: Claude Sonnet 5.5 --- .../room-nav/RoomNavCategoryButton.test.tsx | 21 +++++++++++++++++++ .../room-nav/RoomNavCategoryButton.tsx | 1 + 2 files changed, 22 insertions(+) create mode 100644 src/app/features/room-nav/RoomNavCategoryButton.test.tsx diff --git a/src/app/features/room-nav/RoomNavCategoryButton.test.tsx b/src/app/features/room-nav/RoomNavCategoryButton.test.tsx new file mode 100644 index 00000000..a500f920 --- /dev/null +++ b/src/app/features/room-nav/RoomNavCategoryButton.test.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; +import { RoomNavCategoryButton } from './RoomNavCategoryButton'; + +const html = (closed?: boolean) => + renderToStaticMarkup(Browse); + +describe('RoomNavCategoryButton', () => { + it('says it is collapsed when closed, so assistive tech does not rely on the chevron', () => { + expect(html(true)).toContain('aria-expanded="false"'); + }); + + it('says it is expanded when open', () => { + expect(html(false)).toContain('aria-expanded="true"'); + }); + + it('claims neither when the section does not toggle', () => { + expect(html(undefined)).not.toContain('aria-expanded'); + }); +}); diff --git a/src/app/features/room-nav/RoomNavCategoryButton.tsx b/src/app/features/room-nav/RoomNavCategoryButton.tsx index 3af8aeac..e232b856 100644 --- a/src/app/features/room-nav/RoomNavCategoryButton.tsx +++ b/src/app/features/room-nav/RoomNavCategoryButton.tsx @@ -16,6 +16,7 @@ export const RoomNavCategoryButton = as<'button', { closed?: boolean }>( src={closed ? Icons.ChevronRight : Icons.ChevronBottom} /> } + aria-expanded={closed === undefined ? undefined : !closed} {...props} ref={ref} >