Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
39c9924042 Merge pull request 'Release candidate: dev c6fb57d9 into main' (#146) from dev into main 2026-11-14 08:09:08 +00:00
Void
c6fb57d9f9 Idunn recipe: correct the config.json comment (the carried file is aliased over the tree's)
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 5m2s
Build pull request / Build pull request (pull_request) Successful in 4m18s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 07:59:09 +00:00
Void
5310f606cd Idunn recipe: Ratatoskr static release from the pinned dist tarball (934f8238)
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Rat dev preview / Build dev preview (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 07:55:39 +00:00
934f82386d Merge pull request 'Scene sky-change test: stay inside the timeout under load (bake each sky once)' (#162) from void/scene-test-flake into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 4m57s
Build pull request / Build pull request (pull_request) Successful in 3m9s
2026-11-14 02:47:33 +00:00
Void
a61252e664 Scene test: pin the stand-down of a replaced rebuild deterministically
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m44s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 02:32:31 +00:00
Void
1b101c2889 Scene sky-change sweep: bake each state's sky once so the test stays inside its timeout under load
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 02:27:25 +00:00
b13080ebd2 Merge pull request 'Files desk: name loose files the album list cannot show; pin the old publisher wire shape' (#161) from void/files-desk-loose-files into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
2026-11-14 02:18:49 +00:00
Void
ed2205d6d6 Loose-files note: gating is a tested function; a redaction refreshes it
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m37s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 02:18:47 +00:00
Void
33e31a6102 Files desk: name loose files the album list cannot show; pin the old publisher wire shape
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 02:14:21 +00:00
d5b9e33536 Merge pull request 'docs: harvest physical-room row and sources for the 41 s and degraded-score claims' (#160) from void/harvest-physical-room into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
2026-11-14 01:54:37 +00:00
Void
6b40809f9d docs: harvest polarity row loses the shotgun claim; physical room run row; sources for the 41 s and degraded-score claims
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m21s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 01:54:29 +00:00
38b2c064b2 Merge pull request 'Upload failures tell the sender a reason and a next step' (#159) from void/upload-failure-reasons into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
2026-11-14 01:53:57 +00:00
Void
ec2637a2bb Upload reasons: offline and background-tab only explain a dropped request; Details lays out in the card
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m54s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 01:53:47 +00:00
Void
c36b6e2cd9 Upload failures tell the sender a reason and a next step; diagnostics behind Details
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
uploadFailure attaches what the client observed (unreadable, offline, hidden tab, aborted,
progressed, stopped) and keeps a homeserver errcode; uploadReason turns that into a cause and an
action. The upload cards and the share panel's refusal list use it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 01:52:06 +00:00
bc097d0490 Merge pull request 'docs: Kiko's corrections to the symphony grammar harvest' (#158) from void/harvest-corrections into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
2026-11-14 01:47:42 +00:00
398b172e21 Merge pull request 'Files desk: refuse files over the server's upload cap before sending' (#157) from void/files-desk-cut5 into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-14 01:47:41 +00:00
Void
907635917e Size refusal: a failed run still names the refused files; a cap that is not a positive number is no cap
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m59s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 01:47:07 +00:00
Void
d3894f5b51 docs: apply Kiko's corrections to the symphony grammar harvest
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m21s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 01:46:39 +00:00
Void
65d7e7a46a Files desk: refuse files over the server's upload cap before sending
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
A file larger than m.upload.size is named in the note and dropped from the pick before any
upload or album root; a pick of only such files sends nothing. No stated cap or no file size
refuses nothing.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 01:45:42 +00:00
da822e9e4e Merge pull request 'Phone-hit guard: no blind spots for aliased, namespace and non-tsx IconButton; scope-aware PhoneHit check' (#156) from void/phone-hit-guard-hardening into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 5m5s
Build pull request / Build pull request (pull_request) Successful in 5m12s
2026-11-13 23:46:37 +00:00
Void
0271d5f692 Phone hit guard: see every spelling of IconButton, read scope and the AST
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m54s
The coverage guard now scans every script file under src, resolves aliased and
namespace-member IconButton tags, and fails loudly on any other mention
(as={IconButton}, rebinding, re-export, destructuring, element access). A
PhoneHit that an inner scope rebinds, or that is imported as a type, no longer
counts. drawnPx/sizeProp read the parsed tag, not its text. The emoji Sidebar
column's 4 px gap is pinned.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 23:34:49 +00:00
08be0a792d Merge pull request 'Desk: one owner for the open count, derived row fold state, guest and settled fixtures' (#155) from void/desk-slice-1-findings into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
2026-11-13 23:33:40 +00:00
Void
6a209649dc Desk Soul fixes: memoize the navigator's list so the hash check runs once; a check that cannot run withdraws every card; pin the navigator's verified count
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 23:33:39 +00:00
Void
b8741f9dcc Desk: one verified count, rotation-safe row fold, fixtures for guest and settled
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m35s
The navigator's 'Desk · N open' counted cards the desk withdraws on an
options_hash mismatch. buildDesk now owns the count (open_count less the
withdrawn open cards) and the navigator reads it through the same
useUnverified check the page uses; before the check lands it shows no count.
A row's fold is derived from the screen size and a tap, so rotation cannot
leave it stale. Adds guest and settled-while-answered fixtures and tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 23:29:06 +00:00
0b3e2294ca Merge pull request 'docs: Mimir bioacoustic research harvest for the symphony grammar' (#154) from void/symphony-grammar-harvest into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
2026-11-13 23:28:25 +00:00
Void
d42584cd72 docs: harvest of Mimir's bioacoustic research for Aqua's activity grammar
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m17s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 23:28:20 +00:00
f3d0ebdb06 Merge pull request 'Media row reads room ids from @rooms.file_zones' (#153) from void/stewardships-rooms-key into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
2026-11-13 23:25:20 +00:00
Void
cf1ecd64da Media row reads the room id from @rooms.file_zones, the shape provisioning writes
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m42s
The provisional media[<mind>] key was never written by anything; gamecult-ops #111 records ids under @rooms. The old key is no longer read.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 23:24:00 +00:00
ca3046e662 Merge pull request 'docs: Files desk status after cuts 1-4 and Stop' (#152) from void/files-desk-doc-status into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
2026-11-13 23:18:19 +00:00
Void
817f2e46d6 docs: Files desk status names what is built and that the Commons row is not
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m25s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 23:18:17 +00:00
a078346ecc Merge pull request 'Files desk: Stop a send in flight, show replaces loops, freshest last event on add' (#151) from void/files-desk-send-cancel into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
2026-11-13 23:17:42 +00:00
Void
c98d0175ff Soul fixes: sent-but-unechoed events count for the freshest last event; pre-aborted send starts no upload
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 5m33s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 23:17:40 +00:00
Void
e406ec7dc4 Files desk: Stop a send in flight; files in a replaces loop all show; Add files threads after the freshest event
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Stop aborts the running upload (onPromise + mx.cancelUpload) and leaves it and the rest picked as 'stopped'. foldVersions shows files caught in a replaces loop with no head instead of hiding them. An add reads the room timeline at send time for the newest event in the thread.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 23:14:42 +00:00
d12e7bd578 Merge pull request 'Files desk cut 4: add files to an open album, named upload refusals' (#150) from void/files-desk-cut4 into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
2026-11-13 23:11:35 +00:00
Void
90c6d5e278 Cut 4: pin that ShareSet sends into an existing album
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 5m22s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 23:11:33 +00:00
Void
0622d7d65d Files desk cut 4: add files to an open album; name the upload refusals
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
sendShareSet takes a title or an existing album (no new root; the first file threads after the album's newest event). Failed files carry the refusal for M_TOO_LARGE, M_FORBIDDEN and M_LIMIT_EXCEEDED in the result note. AlbumView mounts an Add files panel inside an open album.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 23:05:37 +00:00
f491df04f9 Merge pull request 'Files desk cut 3: versions via org.gamecult.publish.replaces' (#149) from void/files-desk-cut3 into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
2026-11-13 23:03:48 +00:00
Void
5835e565cd Cut 3 Soul fixes: replaces counts only from the same sender; tests for the loop guard and the cover
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 6m20s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 23:03:33 +00:00
Void
796bc161a9 Cut 3: doc marks versions done
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 23:00:10 +00:00
Void
ac6018b57c Files desk cut 3: show the latest version of a file chain, earlier versions folded under it
A file event naming org.gamecult.publish.replaces supersedes the event it names. foldVersions reads the chain from the events alone; buildAlbums lists heads (count too) and AlbumView unfolds 'N earlier versions'. Both events stay ordinary files for other clients.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 22:59:52 +00:00
143bcc2dc6 Merge pull request 'Files desk cut 2: pagination in useAlbums, ShareSet folder test' (#148) from void/files-desk-cut2 into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
2026-11-13 22:59:50 +00:00
Void
6aaee8badd Cut 2: doc names the page cap; truncation note says albums or files
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 5m20s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 22:59:33 +00:00
Void
01dfb155c5 Files desk cut 2: read every page of threads and replies; pin folder -> album title in ShareSet
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
useAlbums followed no next_batch, so a zone past 100 albums or 100 files per album was silently cut. readPages follows the token up to 20 pages per endpoint and says when the cap stopped it; AlbumView shows a dim note then.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 22:55:30 +00:00
cf8b16ff5d Merge pull request 'docs: Commons and per-Persona Files desk' (#142) from void/commons-files-desk into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
2026-11-13 22:47:34 +00:00
Void
036146ce40 docs: fold Eureka's answers into the Files desk doc (room, cap, poster, final marker, Range unverified)
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m56s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 22:47:25 +00:00
bb6155709a Merge pull request 'Files desk cut 1: folders by album title path, share into the current folder' (#147) from void/files-desk-view into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 4m34s
Build pull request / Build pull request (pull_request) Successful in 4m31s
2026-11-13 21:46:36 +00:00
Void
b8eed50bd7 Soul fixes: a folder that empties keeps its Back row, blank titles get a label
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m1s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 21:46:28 +00:00
Void
53a724ae27 Files desk cut 1 under Matrix authority: folders by album title path, share into the current folder
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Albums titled a/b/c group into folders in AlbumView; ShareSet prefixes the
current folder onto the album title it sends. No wire change.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 21:45:00 +00:00
229591e22c Merge pull request 'Soft logout keeps local data; MAS front hides register and reset' (#145) from void/mas-client-fixes into dev
Some checks failed
Rat dev preview / Build dev preview (push) Successful in 3m39s
Build pull request / Build pull request (pull_request) Has been cancelled
2026-11-13 19:32:10 +00:00
Void
cd30c3dc40 wipeLocalData test: lint-clean never-settling store
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m23s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 19:13:14 +00:00
Void
8fb95402f5 wipeLocalData: bounded clearStores, storage clear that cannot reject; tests that bite
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 19:13:04 +00:00
Void
31d58075db Second Soul pass: soft event without a session wipes, wipe never rejects, rendered MAS tests
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
wipeLocalData moves to its own module with injectable ports: per-database failures are contained and localStorage clears in a finally. A soft logout with no session to remember is treated as hard. Login/PasswordLoginForm/ResetPassword MAS behavior is now tested by rendering. Login and register completion settle once per response.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 18:56:08 +00:00
Void
3a5258c3ef Soul fixes: idempotent logout handler, keep register consent entry under MAS, settle stores on register
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
First SessionLoggedOut decides; later events are ignored so a second soft event cannot fall into the wipe. Under MAS a configured registrationEntry (with its training notice) is kept; the MAS notice replaces only the in-client form and bare link. Registration goes through the same keep-or-wipe settle as login. Soft record drops its unused baseUrl. One shared https-or-localhost predicate.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 18:40:59 +00:00
Void
4fd83fb4c4 Soft logout keeps local data and re-logs in on the same device; MAS front hides register and reset
Soft (soft_logout === true) drops only the access token and records the device; sign-in is fixed to that user and sends device_id; same user and device keeps the stores, anything else wipes via the one wipeLocalData. Hard logout still wipes. When discovery advertises an msc2965 account or issuer, the register and forgot-password entries give way to a notice pointing at the account page.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 18:36:51 +00:00
dc9a541820 Merge pull request 'Phone glyphs: bigger icons in IconButtons and nav chevrons on phones' (#144) from void/phone-glyphs into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m49s
2026-11-13 18:24:24 +00:00
Void
9d65a34507 phoneGlyph test: pin each Icon size's px to folds' own CSS
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m3s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 18:20:12 +00:00
Void
9db91378c5 Phone glyphs: icons inside IconButtons and the nav chevron draw larger on phones, box unchanged
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 5m0s
folds draws a glyph at 16-24 px; on a phone the 16/18/20 px ones read small in their buttons. One global
rule per folds Icon size scales the glyph (22/24/24/26/28 px) at paint time inside an IconButton, so no
button, PhoneHit area or row changes size and desktop is untouched. phoneGlyph.test.ts pins the folds
class names, the px range and that the rule is a phone-only scale.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 17:52:10 +00:00
Void
1455988a83 Merge dev 8ba407cd into main
No content change: tree equals dev's.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 15:48:20 +00:00
Void
a8e3acbde0 docs: Files desk decided as Matrix-only (A); Eureka's notes folded in; cut 1 recorded
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m20s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 15:46:23 +00:00
Void
0ab185bb57 docs: Files desk as a comparison, Matrix as the single authority (A) against the host service (B); publisher rule
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m19s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 14:48:01 +00:00
Void
a8fc78ff55 docs: Files desk upload flow (approved for members and Minds), codes and caps as placeholders
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 14:31:47 +00:00
Void
88fd0a889a docs: Commons and per-Persona Files desk, design only
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 14:30:46 +00:00
8ba407cd2a Merge pull request 'Phone hit: repo-wide guard for bare IconButtons, PhoneHitInPlace for preview arrows, drop dead Editor.preview' (#141) from void/phone-hit-guard into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m39s
2026-11-13 13:51:43 +00:00
Void
d5a0c1b4ff Phone hit: a repo-wide guard for bare IconButtons, PhoneHitInPlace for the preview arrows, drop the dead Editor.preview
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m22s
phoneHit.coverage.test.ts walks every .tsx under src/app and fails on any IconButton without a PhoneHit class
unless it is named with its reason (EstateNavigator Find a room, LobbyHeader Members). UrlPreviewCard's two
absolute arrows take PhoneHitInPlace, which adds the hit area without PhoneHit's position: relative.
Editor.preview.tsx was imported nowhere and held 11 bare IconButtons.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 13:46:44 +00:00
ad9172a311 Merge pull request 'Phone hit: page back buttons (group 3)' (#133) from void/phone-hit-pages into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 4m11s
2026-11-13 13:43:43 +00:00
4e6b4f0c90 Merge branch 'dev' into void/phone-hit-pages
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m12s
2026-11-13 13:34:34 +00:00
1a3eedf6cb Merge pull request 'Phone hit: editor toolbar, emoji sidebar, upload cards, client root' (#139) from void/phone-hit-editor into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 4m43s
2026-11-13 13:34:32 +00:00
9153ee945d Merge branch 'dev' into void/phone-hit-editor
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m57s
2026-11-13 13:26:44 +00:00
d5ad73fa5e Merge pull request 'Phone hit: message content and data editors (group 10)' (#138) from void/phone-hit-content into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m43s
2026-11-13 13:26:43 +00:00
4c16e51559 Merge branch 'dev' into void/phone-hit-content
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m11s
2026-11-13 12:58:51 +00:00
f0c8dc2122 Merge pull request 'Phone hit: room/space settings, lobby, message search, explore (group 7)' (#137) from void/phone-hit-room-space into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m12s
2026-11-13 12:58:50 +00:00
d6a6e300a4 Merge pull request 'Phone hit: settings B, auth and verification icon buttons' (#136) from void/phone-hit-settings-b into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m40s
2026-11-13 12:49:59 +00:00
91993ace99 Merge branch 'dev' into void/phone-hit-room-space
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m14s
2026-11-13 12:49:59 +00:00
145e268894 Merge branch 'dev' into void/phone-hit-settings-b
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m50s
2026-11-13 12:42:11 +00:00
8d25db2775 Merge pull request 'Phone hit: viewer headers and UIA stages (group 9)' (#135) from void/phone-hit-viewers into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m13s
2026-11-13 12:42:10 +00:00
6c395d7aef Merge branch 'dev' into void/phone-hit-viewers
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m10s
2026-11-13 12:11:44 +00:00
a7ac67683f Merge pull request 'Phone hit: common-settings IconButtons (group 6)' (#134) from void/phone-hit-common-settings into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m58s
2026-11-13 12:11:43 +00:00
8a52fa9353 Merge branch 'dev' into void/phone-hit-common-settings
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m46s
2026-11-13 10:16:51 +00:00
32b95fb60b Merge pull request 'Phone hit: dialogs and prompts (group 8)' (#132) from void/phone-hit-dialogs into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m34s
2026-11-13 10:16:50 +00:00
Void
ff1a29dbec Merge dev into phone-hit-pages (kit: dev's AST reader already accepts null entries)
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m23s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 10:15:31 +00:00
2c39f89fe4 Merge branch 'dev' into void/phone-hit-dialogs
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m5s
2026-11-13 09:31:02 +00:00
fd2626d366 Merge pull request 'Phone hit: settings A icon buttons (group 4)' (#131) from void/phone-hit-settings-a into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 4m37s
2026-11-13 09:31:00 +00:00
09e85fad02 Merge branch 'dev' into void/phone-hit-settings-a
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m5s
2026-11-13 09:05:41 +00:00
b2a970f987 Merge pull request 'Phone hit: call controls (audit group 1)' (#130) from void/phone-hit-call into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m7s
2026-11-13 09:05:39 +00:00
45939d544b Merge branch 'dev' into void/phone-hit-call
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m23s
2026-11-13 08:28:38 +00:00
0f7faa902c Merge pull request 'Phone hit kit: pin the className expression, not its source text' (#140) from void/phone-hit-kit-strict into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 4m48s
2026-11-13 08:28:35 +00:00
Void
4eaa6f9c55 Phone hit kit: read IconButton className off the TypeScript AST
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m53s
className must appear once, with no spread after it, as the imported PhoneHit*
identifier bare, a classNames/clsx/cx direct argument, or a template span, and
the identifier must occur nowhere else in the expression. Tag enumeration uses
the same AST. expectPhoneHitClasses takes (string|null)[] (null = no class), as
the phone-hit-pages branch does, and is exercised by a test on a temp source.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 07:28:37 +00:00
Void
761d13fac5 Phone hit editor tests: pin estate Targets nesting and selector, ClientRoot inline absolute, sidebar gap
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m33s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 07:21:57 +00:00
Void
d13cbc9b2e Phone hit kit: read the className expression, not its source text
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
A PhoneHit name inside a string, built by concatenation, behind ?:/&&/||/??, or
followed by a props spread that can override className no longer satisfies the pin.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 07:21:00 +00:00
Void
da05210db2 Phone hit: editor toolbar, emoji sidebar, upload cards, client root
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 07:16:46 +00:00
Void
a0d047c139 Phone hit: message content and data editor icon buttons
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m30s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 07:16:41 +00:00
Void
84ee800e3f Phone hit: room/space settings, lobby, message search, explore icon buttons
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m25s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 07:16:31 +00:00
Void
32dc46c21f Phone hit: PhoneHit on the settings B, auth and verification icon buttons
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m44s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 07:16:11 +00:00
Void
200cf8fdb8 Phone hit: viewer headers and UIA stages
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m58s
Back/close buttons beside non-tappable titles take PhoneHit; the zoom out/in
buttons beside the zoom Chip take PhoneHitSpread24. Test pins each variant and
ties the sized ones to the drawn size.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 07:16:07 +00:00
Void
e209688de1 Phone hit: common-settings IconButtons get PhoneHit, with a pinning test
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m31s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 07:16:02 +00:00
Void
cd90a044ed Phone hit: page back buttons and Lobby/Notifications lone buttons
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m59s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 07:16:02 +00:00
Void
0b40f4c66f Phone hit: PhoneHit on the ten dialog and prompt close buttons, with a pinning test
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 5m26s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 07:15:44 +00:00
Void
b8bef8c175 Phone hit: settings A icon buttons get PhoneHit, with a pinning test
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m32s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 07:15:25 +00:00
Void
351d1bc745 Phone hit: call controls get their hit-area variants, with a test pinning them
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m31s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 07:15:10 +00:00
54ac1ba480 Merge pull request 'Phone hit: Spread28/36, Room36, shared test kit and size-tie check' (#129) from void/phone-hit-foundation into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m56s
2026-11-13 07:13:33 +00:00
Void
fe12d8a4b1 Phone hit kit: pin Room composition, spread props are undetermined, tighter tag match
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m4s
Room<N> throws when no Spread of its size exists, and a test reads the compiled class lists to prove Room<N>
composes Spread<N> and PhoneHit. Props spread into an IconButton or Icon make the size undetermined (allow-list
entry required). <IconButton and <Icon match only before whitespace, > or /. iconButtonTags derives from iconButtons.
expectSizeTie fails on a file with no IconButton. Fixture tests live in phoneHitTestKit.test.ts.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 07:05:58 +00:00
b77efddcdc Merge pull request 'buildSha: an explicit BUILD_SHA no longer masks a dirty tree; temp-file exclusion is untracked-only' (#128) from void/build-sha-untracked-filter into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 4m39s
2026-11-13 07:02:44 +00:00
Void
d917ccd3bb Phone hit tests: shared kit, size tie, pinned new variants
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 2m11s
The helpers moved to phoneHitTestKit.ts so each later group adds its own phoneHit.<group>.test.ts. A new
check ties PhoneHitSpread<N>/Room<N> to the px the button draws (IconButton size padding + Icon size); a size the
source does not state needs an explicit allow-list entry. It found RoomInput's reply-cancel (16 px icon, 24 px
button) carrying Spread32; it is Spread24 now. Room<N> takes its inline margin from the Spread class of the same
size, so the two axes cannot disagree.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 06:59:50 +00:00
Void
2b019a4302 Phone hit: Spread28, Spread36 and Room36 variants
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 06:58:48 +00:00
Void
eff894a89c docs: BUILD_SHA also stands where git cannot be read
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m39s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 06:57:45 +00:00
Void
c82193f0fb buildSha: git speaks only for a tree it roots, mismatch and git failure fail closed, temp-file name matched exactly
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Soul found: explicit sha stamped clean over a different HEAD; status failure returned the bare explicit
sha; an archive inside another repo got that repo's verdict; the temp regex let any untracked .mjs pass.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 06:56:27 +00:00
Void
c45cdc7bd7 buildSha: BUILD_SHA no longer masks a dirty tree; the vite temp-file exclusion is untracked-only
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m24s
The pathspec exclude hid a tracked file of that name; the status lines are filtered instead.
Tests against a real repo: explicit sha plus stray file, stray file beside the temp file in root and
subfolder, tracked file of the temp name, new folder. Six mutants of the logic each fail a test.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 06:52:32 +00:00
14ee4bee03 Merge pull request 'Phone: 44 px hit areas for the chat path's remaining icon buttons, and tests that pin them' (#127) from void/phone-hit-areas-2 into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m38s
2026-11-13 05:50:20 +00:00
Void
0c8474231e phoneTapTargets: each Spread class owns its margin, ::after declaration set is exact, no phone rule moves position, import must not be aliased
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m1s
Soul re-gate of 446306b1: swapped Spread24/26 definitions, display:none or overflow on the ::after, position:static on a
Spread rule and an aliased import all passed; all killed now.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 05:50:10 +00:00
Void
446306b1e7 PhoneHit: Spread24 for the nav row's 24 px buttons, Spread26 for the unpin button; tests pin position, ::after declarations, className and import alias
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Soul pass on fc92809e: RoomNavItem's buttons are 24 px, not 32 (36 px pitch, hit areas overlapped); the unpin button is 26 px
beside the Open chip. Six mutants survived (position: relative, pointer-events, z-index, title for className, aliased
import); all now killed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 05:46:21 +00:00
Void
fc92809eb9 Phone: 44 px hit areas for the chat path's remaining icon buttons; pin variant, import and ::after content
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
MessageEditor, RoomNavItem, MembersDrawer, ReactionViewer, RoomPinMenu, RoomViewTyping, CallChatView and JumpToTime
get PhoneHit (alone) or PhoneHitSpread32 (two neighbours in a row). phoneTapTargets.test.ts now lists them, plus
PasswordInput, Desk, SkyStateControl and PasswordLoginForm, which had no test, checks each class is imported from
styles/PhoneHit.css, and pins the ::after content (a mutant without it passed before).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 05:40:21 +00:00
2aa468c36a Merge pull request 'Consumer-side tests for #123: RoomOutbox as rendered; hung send to watchdog reload to resend' (#124) from void/stall-consumer-tests into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m18s
2026-11-13 04:11:32 +00:00
67f1267653 Merge pull request 'Phone: 44 px hit areas for icon buttons, reaction chips and short sender names' (#125) from void/phone-hit-areas into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-13 04:11:26 +00:00
Void
ae3760fcc7 RoomOutbox actions test: Resend keeps the entry in the outbox, pending again
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m14s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 04:11:15 +00:00
Void
bd2d980023 phoneTapTargets: pin top, left and transform of the hit-area box
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m32s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 04:10:46 +00:00
Void
da9ef70c03 Consumer tests: startClient wiring, Resend/Discard handlers, the 30 s slow-send threshold
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 04:04:45 +00:00
Void
0e3115809e phoneTapTargets: pin the exact PhoneHit variant of each IconButton
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 04:02:30 +00:00
Void
e04c7c074f stallRecovery test: a held SDK echo makes the resend use a fresh txn id
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 04:01:36 +00:00
Void
fab6a04894 stallRecovery test: lint clean
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 04:01:36 +00:00
Void
09b062d7be stallRecovery test: distinct txn ids per send; pin visible/online guards and own-tab pending filter; fix TS2698
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 04:01:36 +00:00
Void
9d1d39922c stallRecovery test: lint clean 2026-11-13 04:01:36 +00:00
Void
76c1f56bd8 stallRecovery test: lint 2026-11-13 04:01:36 +00:00
Void
2a1f2c6b17 Consumer-side tests: RoomOutbox as rendered; hung send to watchdog reload to resend
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 04:01:36 +00:00
Void
21ff167177 phoneTapTargets: every IconButton in Message, RoomInput, RoomViewHeader carries a PhoneHit class
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 04:01:35 +00:00
Void
ee55c32214 Phone: quick-reaction emoji buttons and delete/report dialog close buttons get a 44 px hit area
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 04:01:35 +00:00
Void
856096e215 Desk back button: phone hit area
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 04:01:35 +00:00
Void
f7395f12e3 Phone: icon buttons, reaction chips and short sender names get a 44 px hit area
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 04:01:35 +00:00
95f79a0517 Merge pull request 'Build stamps its git sha into index.html (build-sha meta tag)' (#126) from void/build-sha-stamp into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-13 04:01:23 +00:00
Void
917f9a0da2 buildSha test: remove the temp repos after each test
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m54s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 03:48:22 +00:00
Void
46e96500e4 buildSha: ignore vite's temp config file, run git in the repo dir
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 03:20:59 +00:00
Void
ccb7a8ebb6 Build stamps its git sha into index.html as a build-sha meta tag
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m52s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 01:40:05 +00:00
b6b1d522ab Merge pull request 'client: unconfirmed sends are visible and recovered; watchdog for a dead connection' (#123) from void/resume-reload into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m43s
2026-11-12 21:35:47 +00:00
Void
bb6391a70b initMatrix: resolve sessionStorage inside the guard's try; ownPendingAges tested
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m39s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 21:35:46 +00:00
Void
69d752ff0b watchdog: send stall only, own-page entries, online only; resume sync nudges and never restarts
Soul pass on 77edebff: the watchdog no longer counts interrupted entries from
a previous page (reload loop at startup), no longer reloads on sync ERROR/STOPPED
(js-sdk recovers offline by itself) or while offline, refuses instead of
throwing when sessionStorage is unusable, startClient is idempotent, and the
slow-send label says Resend may duplicate. resumeSync drops its health check
and recover hook: nudge only.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 21:34:21 +00:00
Void
77edebff9c client: watchdog, resend on reload, outbox for every send, no in-place client restart
Sends have no timeout and sync polls do, so a dead connection leaves a send
SENDING forever with no request reaching the server while its echo looked
delivered. A watchdog (send pending past 20 s, sync ERROR/STOPPED for 30 s)
calls a guarded reload; what the reload interrupts is sent again under its
own txn id. Uploads and stickers go through the outbox. Resend takes a fresh
txn id while the SDK still holds the failed echo. The resume-sync recovery no
longer calls stopClient/startClient, which closes the rust crypto for good
("null pointer passed to rust" on later encrypted sends).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 21:31:29 +00:00
Void
a92f6c4294 timeline: hide unconfirmed local echoes; the outbox is the one place they show
The SDK puts a local echo in the live timeline on sendMessage and marks it
NOT_SENT on failure; the timeline rendered it like any delivered message and a
reload dropped it, so a send killed with the page looked sent and vanished.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 21:25:50 +00:00
92a98664c9 Merge pull request 'composer: persist the draft on input, keep unconfirmed sends in a durable outbox' (#122) from void/draft-persist into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-12 21:17:30 +00:00
Void
a531976675 lint: import order, explicit no-op test stubs
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m9s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 21:17:26 +00:00
Void
350ec987e0 outbox: survive a refused storage write, mark reload-interrupted sends, hide Resend on live sends
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Soul pass findings: a failed localStorage write no longer loses the entry
(memory copy wins until a write succeeds), a stale draft is dropped when
its rewrite fails, storage reads/removes cannot throw, and pending entries
older than the page load read as interrupted with Resend available.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 21:17:06 +00:00
Void
7316c92ff3 composer: persist the draft on input, keep unconfirmed sends in a durable outbox
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
The draft lived in a memory-only jotai atom written only when RoomInput
unmounted, so a reload lost it. A sent message was cleared from the editor
at once and held nowhere durable. The draft is now written to localStorage
(debounced, flushed on pagehide/hidden) and restored on load; a send stays
in a per-account outbox under its txn id until the server confirms, shown
above the composer with Resend and Discard.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 21:14:16 +00:00
a8d39ee917 Merge pull request 'pwa: installable manifest and Web Push stages 0-1 (dark by default)' (#121) from void/pwa-push-stage-0-1 into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m46s
2026-11-12 20:05:41 +00:00
Void
695a016d4f push: keep the token only while subscribed; toggle reads the account's pusher; replace a stale VAPID subscription
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m14s
Soul pass findings on the stage 0-1 branch: the worker stored the token on every deployment, push configured or not
(now only while the browser holds a push subscription; the page re-sends its session after the toggle changes it);
the toggle read the browser's subscription, not the account's pusher (now both); a subscription under an old VAPID
key was reused (now replaced); a failing client list now still shows the notification.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 20:05:10 +00:00
Void
64a2656ad7 pwa: installable manifest fixes and Web Push stage 0-1 (worker handlers, session in IndexedDB, settings toggle, dark config block)
Stage 0: manifest gains id "/" (what Chrome already derives from start_url), scope, a 512 maskable
icon, and drops the portrait lock. The service worker keeps the session ({baseUrl, accessToken}) in
IndexedDB `ratatoskr-sw` so a push can wake a worker with no tab; the page's existing logout message
deletes it.

Stage 1: `push` and `notificationclick` handlers (src/app/features/push), a pusher toggle in
notification settings, `push` block in config.json shipped empty (off). Tested against a mock
homeserver and Matrix gateway body.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 19:58:40 +00:00
f79613ca5f Merge pull request 'docs: chat shell compression, measured state and brotli plan' (#120) from void/chat-shell-compression into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m44s
2026-11-12 18:16:04 +00:00
Void
74fc2fbcd4 docs: fix build id, SW claim, step order, twin size after Soul pass
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m48s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 18:15:59 +00:00
Void
9297ba08dd docs: chat shell compression, measured state and brotli plan
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 18:09:47 +00:00
3d8c98e1c8 Merge pull request 'runbook: handoff build ships the repo config.json; live check always runs' (#119) from void/runbook-config-flow into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m33s
2026-11-12 17:08:05 +00:00
Void
3543b1013b runbook: sha256sum --ignore-missing hides nothing; name the config compare
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m26s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 17:07:54 +00:00
Void
45579b8ec8 runbook: the handoff build ships the repo's config.json and its live check always runs
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 16:54:58 +00:00
5cce38f38b Merge pull request 'docs: client build, install, verify and rollback runbook' (#118) from void/client-config-runbook into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-12 16:51:49 +00:00
Void
45facff436 runbook: handoff build ships its own pinned config; drift check is not a pass check
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m36s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 16:51:41 +00:00
Void
fef7d34fdb runbook: record the served-config drift as observed
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 16:50:28 +00:00
Void
3751adcb64 docs: Ratatoskr client build, install, verify and rollback runbook
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 16:50:16 +00:00
02a0c10665 Merge pull request 'Phone and desk visual findings: notice scrim, tap areas stop at neighbours, desk affordance' (#117) from void/phone-desk-visual-fixes into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 6m5s
2026-11-12 14:31:17 +00:00
Void
96954c0fb5 Desk: fold chevron, quiet text 0.9, solid note border, recorded rulings on their own ground
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m58s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 14:28:34 +00:00
Void
d4463a80cf Phone and desk visual findings: notice line owns its ground, tap hit areas stop at their neighbours, nav rows as tall as their targets
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 14:19:25 +00:00
494d2a7ed4 File zone share: component tests, state machine moved out of ShareSet (#116)
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-12 13:15:49 +00:00
c5d1b9c90f File zone: share a set of files as one album (flag off) (#115)
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-12 13:01:46 +00:00
f9f94808d5 An explicit theme choice (even Midnight Sky) wins over the sky (#114)
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-12 11:41:43 +00:00
df26eb4c99 Footer band test pins the emitted tint and ink (#113)
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 4m57s
2026-11-12 10:50:08 +00:00
010e68b5e8 A long reply quote stays inside its column (#112)
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-12 10:50:03 +00:00
07b82f2477 Pin sceneButtons folds recipe classes to the installed folds (#111)
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 4m6s
2026-11-12 09:51:15 +00:00
df245e0b76 Ridge scene: physical sky over Vienna, real horizon, live weather (Nibu) (#110)
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m45s
Nibu's ridge atmosphere (nibu/ridge-atmosphere b0489bc9): Hillaire sky, Copernicus horizon, TAWES weather with clear-sky fallback. Credit: Datenquelle: GeoSphere Austria - https://data.hub.geosphere.at. Soul passed (Nibu 5 findings fixed; Void Soul pass).

Co-authored-by: Nibu <nibu@minds.nibu.gamecult.org>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 02:48:04 +00:00
d3fa27859d Merge pull request 'File receipts say "fetched for <Mind>", not "read by"' (#109) from void/file-received-wording into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m8s
2026-11-12 02:07:03 +00:00
Void
6ba5647ea8 File receipts read 'fetched for <Mind>', never 'read by'
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m32s
The receipt means the mouth fetched and stored the file for the Mind, not that it is in her sandbox or was read.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 01:58:39 +00:00
98b4ec8ab1 Merge pull request 'File zone: one shared room subscription for read-by rows' (#108) from void/file-readers-store into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m47s
2026-11-11 22:47:21 +00:00
Void
1c5331da4d File zone: one room subscription for every 'read by' row
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m30s
receiptStore.ts holds one Timeline/Redaction/TimelineReset listener set per room while any row is mounted and a snapshot of the receipt events; useFileReaders derives each row's readers from it with readersOf (same rules). 200 rows register one listener per event, not 200.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 22:41:35 +00:00
4876d31428 Merge pull request 'File zone: read-by receipts found anywhere in the room' (#107) from void/file-receipts-anywhere into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 4m13s
2026-11-11 22:39:14 +00:00
9aa34905c9 Merge pull request 'config.json: ship the ridge scene style' (#106) from void/config-ridge into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-11 22:35:38 +00:00
Void
da2445044d File zone: the readers hook rescans on redaction and reset; replies guard is one local; reader order by time
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m26s
Soul findings 1, 2 (doc), 4 and the merge order.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 22:34:39 +00:00
Void
00890b2108 File zone: 'read by' finds receipts anywhere in the room, in the album listing and on the timeline row
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
readersOf (fileReceipt.ts) is the one reader: a receipt is a thread reply or a plain room event, matched by content.event_id; the sender must be @<mind>:<this homeserver>, the hash the file event's, an m.reference present must name the same event, one entry per Mind. The timeline file message draws it too.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 22:33:36 +00:00
Void
64ecdc03f7 config.json: ship the ridge scene style (Meta, 2026-11-11)
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m31s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 22:08:12 +00:00
83baee2c2d Merge pull request 'File zone: Mind contract, plaintext sha256 on send, read-by receipts' (#104) from void/file-zone-contract into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m38s
2026-11-11 22:01:23 +00:00
Void
3971c72de7 File zone tests: no shadowed helper name
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m42s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 21:28:49 +00:00
Void
08ecacd438 File zone: a receipt counts only from @<mind>:<this homeserver>; doc says what info holds when media fails to load
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Soul: a federated @void:evil.example passed the localpart check.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 21:28:30 +00:00
Void
c34e536132 File zone: a receipt counts only from a Mind's own sender and with the file's hash; info.sha256
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Heimdall's two rules (#minds): trust the Matrix sender, never a field inside; read means the receipt hash equals the file event's.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 21:27:24 +00:00
Void
af328bdb90 File zone: Mind contract; plaintext sha256 in info on send; 'read by <Mind>' from org.gamecult.file_received
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 21:26:36 +00:00
89759f9092 Merge pull request 'config.json: the live chat.gamecult.org config' (#103) from void/config-live into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 4m7s
2026-11-11 21:23:27 +00:00
38e9fcdff3 Merge pull request 'Pose thumbs: stale size comments corrected' (#102) from void/pose-size-docs into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-11 21:19:16 +00:00
Void
4b7697aaeb preview-config: pin the preview scene style (ridge unless given) now that config.json says terrain
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m5s
Soul: the repo config now carries the live style, so previews would have turned terrain.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 21:08:53 +00:00
Void
9c5af7aede config.json is the live chat.gamecult.org config
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Fetched from https://chat.gamecult.org/config.json 2026-11-11: one homeserver, custom homeservers off, gamecult featured space, hash router on, scene tier full with style terrain. dings and registration (keys the live release predates) keep the repo's off defaults. A release built from the repo now ships its own config.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 21:08:15 +00:00
Void
5e35bb6d76 Pose thumbs: README and script comments state the real counts and CSS widths
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m6s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 21:07:48 +00:00
a7c95ce6ec Merge pull request 'Navigator: Browse open state per screen class' (#101) from void/browse-key-screen-class into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 4m46s
2026-11-11 21:05:24 +00:00
b7828fef0f Merge pull request 'Desk: fork card contract at CultLib 3a51bbab (raised_at, counts, nullable answerer)' (#100) from void/fork-raised-at into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-11 21:05:23 +00:00
Void
65d54d4bf4 Navigator: the Browse open state is kept per screen class, like every other section
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m42s
Libby's read of #67: Browse had no phone/wide split, so opening it on one screen class opened it on the other. The store keeps its name and localStorage key (a rename would drop saved state); the comment says what a key means.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 20:55:27 +00:00
Void
79fae54a0a Desk: age parses strict RFC3339 only; a served count outside the contract shows no count line
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m0s
Soul found Date.parse accepting '2026-11-06', 'Nov 6 2026', '0' and 2026-02-30, and decider_count 0/65 or a missing/negative answerer_count rendering as served.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 20:53:36 +00:00
23765855a9 Merge pull request 'scene: a throwing ray-march createScene goes to onCompileFail' (#85) from nibu/scene-throw-compile-fail into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 4m51s
2026-11-11 20:52:39 +00:00
24a38e4d76 Merge pull request 'Ridge: shade faces by depth below the crest; hold the ridge inside the label band (Nibu)' (#99) from void/nibu-ridge-face-volume into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 4m31s
2026-11-11 18:31:12 +00:00
Void
9e02536e02 Desk: card contract at CultLib 3a51bbab: decider_count, answerer_count, nullable answerer; the counts show when the ids are withheld
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m30s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 18:23:14 +00:00
825b61558a Merge pull request 'Unread room icons at 0.6' (#98) from void/unread-icon-quiet into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-11 18:12:08 +00:00
Void
57f0ef8df2 Desk: raised_at on the ForkCard (required key, instant or nil); the card shows its age only when there is one
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 18:12:02 +00:00
Nibu
48d9c56dc7 Ridge: shade the faces by depth below the crest, and hold the ridge inside the sky state's label band
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m35s
Volume: the foot of each face darkens (scaled by the layer's contrast, before the mist lifts it toward the haze), the sun
catches the top, and the crest rim gets a soft glow under its 2 px lip. Baked per pixel; nothing per frame.

Legibility, with no plate: the state's luminance band (skyBand.ts, the one the sky is shaped into) now also shapes the
layers, as a shift of linear light in the bake. Day and dusk add the zenith colour so the darkest ridge pixel is on the
text floor; night scales to the ceiling. The face keeps its whole luminance gradient. Rows of full-opacity label over a ridge
measure 2.4-2.6 before and 6.7-7.1 after at day and dusk (360 px, real label colour against the real pixels under it).

Tests: 'foot is hazier' becomes two tests (the foot is clearly darker; the valley mist still pulls it toward the haze),
plus the band tests and ridgeSkyFor carrying the state band.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 17:49:46 +00:00
Void
a3b09efb00 Unread room icons rest at 0.6, the dot and label weight carry unread
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m30s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 17:39:58 +00:00
eb1d039905 Merge pull request 'Navigator: icon weights, solid unread dot, Media row detects by room type' (#97) from void/nav-ink into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 4m49s
2026-11-11 17:33:22 +00:00
Void
ec48dbf2ed Navigator: no label plate (Meta); icon weights and the solid unread dot stay
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m14s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 17:23:13 +00:00
Void
d226ca45fb Media row: a joined file-zone room (by type or fileZoneRooms) is the row; alias or carried id only finds an unjoined one
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 17:12:57 +00:00
Void
1195b0bffc Navigator ink: chevrons 0.9, room icons 0.26, solid unread dot, label plate over the scene
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 17:12:06 +00:00
5c119e729e Merge pull request 'Navigator: per-Mind Media row after Desk' (#96) from void/mind-media-row into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 4m18s
2026-11-11 17:05:44 +00:00
Void
c9d16f6259 Navigator: a Media row after Desk in every Mind foldout, from the #<mind>-files alias or a carried room id
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m35s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 17:00:29 +00:00
978c7c18da Merge pull request 'Navigator: quieter room icons, Join as a text action, calmer unread badge' (#95) from void/nav-quiet into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 4m15s
2026-11-11 16:56:50 +00:00
Void
d803cd1cf4 Navigator: quieter room icons, Join as a text action, calmer unread badge
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m55s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 16:50:32 +00:00
45d9de9bf7 Merge pull request 'Navigator: Desk and media pages pinned at the top of each Mind foldout' (#94) from void/mind-desk-pin into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 4m2s
2026-11-11 16:48:19 +00:00
Void
a90223437e Navigator: pin each Mind's Desk and media pages at the top of its foldout
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m5s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 16:43:19 +00:00
34b38c7aa6 Merge pull request 'Navigator: one gutter column, constant nesting step, quieter room icons' (#92) from void/nav-gutter into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m41s
2026-11-11 16:40:36 +00:00
a0ecfafd4b Merge pull request 'Docs: repo renamed to GameCult/Ratatoskr; scene default' (#93) from void/rename-docs into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-11 16:40:33 +00:00
Void
dd1e9268f2 Docs: the repo is GameCult/Ratatoskr now; the background scene is on in the dev config
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m3s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 16:31:25 +00:00
b6eda08d4d Merge pull request 'Background scene on in the repo config (tier full)' (#91) from void/scene-tier-full into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m54s
2026-11-11 16:30:30 +00:00
Void
568d6fd4ab Navigator: one gutter column for chevron, room icon and avatar; one constant step per nesting level
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m37s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 16:12:02 +00:00
Void
abb1ee988c Background scene on by default in the repo config (tier full; the device ceiling still lowers it)
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m36s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 16:00:45 +00:00
a5e82090b5 Merge pull request 'Default homeserver is chat.gamecult.org' (#90) from void/default-homeserver-chat into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m57s
2026-11-11 15:53:07 +00:00
Void
90250ca076 Default homeserver is chat.gamecult.org: gamecult.org serves no .well-known/matrix/client, so discovery 404s
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m10s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 15:42:15 +00:00
50a2a37d6b Merge pull request 'Pose follows message sentiment (local classifier)' (#89) from void/sentiment-pose into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m7s
2026-11-11 14:26:09 +00:00
Void
7c97b79956 Classifier outputs only the shared pose set
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m30s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 12:18:31 +00:00
1be9e76012 Bubble pose follows message sentiment when the event carries no pose label
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 12:09:46 +00:00
08bf515997 Merge pull request 'Release candidate: dev into main' (#88) from dev into main 2026-11-11 02:13:08 +00:00
f8115609b2 Register entry: https only, http only for a local host (#87)
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m45s
Build pull request / Build pull request (pull_request) Successful in 3m29s
2026-11-11 01:31:35 +00:00
450a7e91ff auth: Register entry beside Sign in, behind a config switch (off) (#68)
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-11 01:30:40 +00:00
94c976632e Default homeserver is gamecult.org (#86)
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-11 01:30:16 +00:00
b6da92a081 Albums slice 1: read-only album view for the file zone (#70)
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-11 00:25:37 +00:00
7045532187 Poses nest into the chat bubble corner (desktop and phone) (#72)
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-10 23:26:53 +00:00
ac7a958f5c Enforce the one join path in lint; a drive that asserts nothing fails (#64)
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-10 23:16:59 +00:00
Nibu
05948689b4 Scene: a throwing ray-march createScene goes to onCompileFail
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m11s
create() called the ray-march createScene after the try, so a throw while
building it escaped onCompileFail and the CSS ground was never kept. The call
now sits inside the try with the other styles.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 22:00:45 +00:00
3def499b0f desk: served row order, one open count (#84)
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-10 21:13:24 +00:00
8e0e3b8986 desk: read the gamecult.fork.* contract as served (#83)
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-10 21:08:12 +00:00
c286c4adc4 Bound the reply preview on phone width (#82)
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-10 20:33:49 +00:00
1fe3e2c92f Merge pull request 'scene: ridge heightfields, slope shading, endless world-x drift' (#81) from nibu/ridge-heightfield into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-10 19:05:27 +00:00
Nibu
d172c4eda3 scene: ridge chunk jobs own their scratch canvas; tests for the endless drift and the ridged core
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m6s
Soul pass fixes. The shared scratch canvas was resized when a job was created
but read in a deferred task, so chunks of layers with a different blur pad got
a faded right edge. Each job now makes its own scratch when it first runs and
drops it when done. A chunk is baked, put and scatter-filled in column bands,
the blur is one draw; allocation is a task of its own. rotate no longer needs a
forward reference (startDrift takes the finish callback).

Tests: the fake animation can finish, so the rotation is tested at scene level
(world columns per chunk, slot repositioning, restart, recycled chunk id, one
chunk of drift distance, per-job scratch width at the moment of each blur).
The ridged core has direct tests for the fold, the warp, the previous-octave
weighting, the lacunarity and contrast by distance. README sun statement
matches sunDirection.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 18:56:52 +00:00
Nibu
9391e4753f scene: ridge heightfields with slope shading and endless world-x drift
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Each ridge layer is a function of world-x (ridged multifractal value noise,
domain-warped, one function and a parameter row per layer) instead of a
periodic strip. World-x is baked in 1.2-viewport chunks on demand: the first
view bakes one chunk per layer, the rest are baked in ~6 ms tasks, and the
compositor slides a track that is recycled each chunk. A slow yaw/pitch sway
rides on top. ridgeBake.ts shades each column from the crest slope against
the sun with a lit rim, a light-to-haze face gradient and erosion streaks,
reusing the existing aerial perspective as the depth treatment.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 18:31:56 +00:00
313d070dd4 Merge pull request 'desk: read-only Mind desk, slice 1' (#77) from void/desk-slice-1 into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-10 18:29:38 +00:00
51d5fb0909 Merge pull request 'Phone: notice contrast, 44px tap hit areas, skip empty OIDC validation' (#78) from void/phone-contrast-taps into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-10 18:29:16 +00:00
Void
0f82576d9f desk: booth portrait is a plain image; options_hash recomputed against the CultLib vectors
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m9s
PoseAvatar renders a span, not a button, when it has no onClick. The desk recomputes each card's options_hash (RFC 8785 after NFC, SHA-256; 15 CultLib vectors) and withdraws a card that differs.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 18:26:03 +00:00
Void
8dea8717ea desk: read-only Mind desk slice 1 (typed projections, buildDesk, deskMood, DeskView, navigator entry, demo forks)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 18:26:03 +00:00
Void
0351f1f6e8 Skip auth-metadata validation when the homeserver returned none
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m30s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 18:26:01 +00:00
Void
de6f1849ea Phone: 44 px hit areas for sender names, room topic and sign-in links, no layout shift
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 18:26:01 +00:00
Void
06d530d061 Phone: light ink for the training notice over the dark ridge (day, dusk)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 18:26:01 +00:00
84770a2d2c Merge pull request 'preview demo: drop a stored login for another server before the app reads it' (#80) from void/demo-homeserver-unreachable into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-10 18:24:00 +00:00
Void
5bb78b21cf preview demo: drop a stored login for a real homeserver before the app reads it
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m53s
Every preview shares the origin minds.gamecult.org and so one localStorage. A login another
preview left (cinny_hs_base_url of a real homeserver) made the demo connect to that server, which
the guard refuses, so SpecVersions showed 'Unable to connect to the homeserver'. installDemo now
drops any stored session whose base is not the demo origin, and the service worker is registered
after that so it is never handed the dropped session. Replaces the foreign-session banner text.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 18:22:43 +00:00
7bac860016 Merge pull request 'preview: build-dev-preview.sh, the dev demo build recipe (publishing is the shared script's)' (#79) from void/publish-dev-preview into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-10 18:19:09 +00:00
Void
d16842a70e preview: drop the Ratatoskr publisher; keep only the build recipe the shared publisher takes (build-dev-preview.sh)
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 5m0s
Publishing belongs to gamecult-ops scripts/publish-mind-preview.sh (one fixed slug per project).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 18:19:04 +00:00
Void
08b23e0cc4 preview: scripts/publish-dev-preview.sh, the one way the dev demo is published (in place at /void/rat-dev/, one rollback)
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 18:16:10 +00:00
61e7539f01 Merge pull request 'docs: Mind feedback pages (the desk) design record' (#24) from void/mind-feedback-pages-spec into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-10 18:04:30 +00:00
Void
9440921181 docs: desk section points to Heimdall's rule file (answer rule) and CultLib #72 (wire) instead of restating them
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m42s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 18:04:29 +00:00
Void
73754a0912 docs: desk section: retraction rule agreed in #swarm
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 18:03:25 +00:00
Void
3e173d2fed docs: feedback pages are the desk: Fork Desk keeps, field names, pick and note rules
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 18:03:25 +00:00
Void
84b2a93b8e docs: feedback pages: no content hash, route 2 credential, receiver store and names
Erase keeps record id, kind and time only; an unsalted hash confirms a
guessed text. Credential is the OIDC access token Bifrost issues to chat,
claims check open. Receiver store is a CultCache file with backup purge
owed before deploy. Receiver must not reuse CurrentBifrostActor.DisplayName;
no guest exists until Bifrost PR #9.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 18:03:25 +00:00
Void
20acd3d384 docs: align feedback pages spec with the Huginn feedback kinds proposal
The proposal owns the schema: feedback_listing, feedback_answer (text-free),
feedback_close_request, feedback_hide. Bifrost holds answer text and does
the erase; delivery is by mouth and the Persona admits the record.
Question key, option labels and askedAt follow the proposal. Client plan
kept: typed interface mirroring those shapes, fixtures first.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 18:03:25 +00:00
Void
5def1bdfb8 docs: Mind feedback pages spec (placement, close, hiding, receiver)
Folds Kiko's spec with Heimdall's draft rulings on guests, erase and
hiding. Design only; no code.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 18:03:25 +00:00
66c4c6373b Merge pull request 'scene: skyLabelInk moves out of sceneShell.css.ts (dev did not build)' (#76) from void/scene-css-function-export into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-10 18:02:26 +00:00
Void
14ea0dbab0 scene: skyLabelInk leaves sceneShell.css.ts (vanilla-extract refuses a function export; vite build failed on dev)
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m4s
#74 exported a function from a .css.ts file. vitest's plugin tolerates it, vite build's compile does not
("Invalid exports"), so dev did not build. The helper and its custom-property name move to skyLabelInk.ts;
a test compiles the three .css.ts files that use it the build's way and fails on the old arrangement.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 18:00:41 +00:00
0fe9ab2eee Merge pull request 'dings: Aqua's v4 set (Void single onset, Nibu proposal 3 everywhere), behind dings.enabled' (#75) from void/dings-v4 into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-10 17:59:58 +00:00
Void
de9955a24a dings: Aqua's v4 set (Void single onset, Nibu proposal 3 everywhere), still behind dings.enabled
Some checks failed
Build pull request / Build pull request (pull_request) Failing after 2m8s
Replaces the 32 sender clips with AquaSynth 21e83ec's staged render; manifest and neutral
fallback are byte-identical to the old ones. The manifest test now pins every clip by sha256
prefix. Unheard.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 17:59:07 +00:00
1499e9a810 Merge pull request 'scene: per-sky-state ink for names and phone-dusk labels; New Messages as plain label' (#74) from void/scene-ink into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-10 17:58:23 +00:00
Void
aa0dfc0ee9 scene: one owner of ink per sky state (names, phone-dusk labels), New Messages as plain label ink
Some checks failed
Build pull request / Build pull request (pull_request) Failing after 2m37s
Sender names over the terrain step to the state's ink: same OKLCH hue and chroma, darker by day and dusk, lighter at night, until 4.5:1 on the lightest measured terrain patch (capped at OKLCH L 0.9 at night). Sidebar labels get a darker dusk ink on phones. An explicit theme still wins. The unread divider keeps only its green line.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 17:43:18 +00:00
683d3b92f8 Merge pull request 'Release candidate: dev 64edacac to main' (#71) from release/dev-64edacac into main 2026-11-10 17:42:15 +00:00
bf8b889a0e Merge pull request 'auth: newcomer-facing sign-in, server and sign-up messages' (#73) from void/newcomer-signin-errors into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-10 17:32:14 +00:00
Libby
f1287a6f3a auth: newcomer-facing sign-in, server and sign-up messages that say what to do next
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 2m25s
Replaces Cinny's stock wording (Invalid Username or Password., Failed to find
homeserver., Failed to connect..., the dead-end registration-disabled text) with
plain messages in one module, authMessages.ts, used by the login form, the auth
layout, the login page and the register page.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 17:31:55 +00:00
3203a32b73 Merge pull request 'Dings v3 behind client config switch dings.enabled (off by default)' (#69) from void/dings-v3 into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-10 17:25:44 +00:00
Void
1dea4b51e5 dings: Nibu carries proposal 3 (8 kHz 5-bit smoothed sine) for every event
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m6s
Meta heard v3 and chose proposal 3; Nibu confirmed it as her voice for all events.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 17:25:27 +00:00
64edacac4d Merge pull request 'scene: Dusk floor at the link bound with Day's pale-blue lift (held for Meta's preview)' (#56) from nibu/dusk-sky-floor into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
Build pull request / Build pull request (pull_request) Successful in 4m29s
Docker check / docker-build (pull_request) Failing after 17s
NPM Lockfile Changes / lockfile_changes (pull_request) Failing after 1s
2026-11-10 17:03:35 +00:00
Void
9877154033 dings: port the hook from #36 onto dev, wire Aqua's v3 set behind dings.enabled (default off)
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m50s
Hook code is #36 (b3eb9d01) verbatim plus the switch. Clips and manifest are AquaSynth PR #22 out/, Nibu on proposal 1. Nobody has heard v3.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 17:03:17 +00:00
dce50eeaf6 Merge pull request 'Room list: unjoined project-room groups collapse behind one Browse entry' (#67) from void/browse-collapse into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m42s
2026-11-10 14:47:42 +00:00
Void
3c868254e5 Category buttons expose aria-expanded
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m17s
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 <noreply@anthropic.com>
2026-11-10 14:47:41 +00:00
Void
3c92696507 Room list: groups of unjoined project rooms wait behind one Browse entry
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m37s
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 <noreply@anthropic.com>
2026-11-10 14:15:47 +00:00
Void
9641cfb4f4 Merge nemesis/demo 9b9b495d (live) into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m49s
2026-11-10 14:11:11 +00:00
Eureka Self
9b9b495def 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 <noreply@anthropic.com>
2026-11-10 14:09:58 +00:00
854b151d80 Merge pull request 'scene: terrain fine grain domain-warped so it stops reading as hatching' (#66) from nibu/terrain-hatching into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-10 14:09:23 +00:00
23bc67a1f0 Merge pull request 'nemesis/demo: Meta's no-chrome look, theme auto, denser terrain, Background menu, with tests and the Ridge zero-size fix' (#65) from void/nemesis-demo-tests into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-10 14:09:01 +00:00
Void
48d6d171da Sky-only tier label follows the overlay gate; tests pin the door's debug forwarding
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m42s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 14:09:00 +00:00
Void
814fa4b3ab Diagnostic overlays (?probe=1, ?tierlabel=1) only where allowed; footer band test knows the Safari prefix
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
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 <noreply@anthropic.com>
2026-11-10 14:05:42 +00:00
Eureka Self
d2d6fbcc00 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 <noreply@anthropic.com>
2026-11-10 14:04:34 +00:00
Void
556254f46b Merge nemesis/demo 0fe41efb into PR #65 2026-11-10 14:04:17 +00:00
Void
c657d68615 PR #65 steward ruling: user themes win over auto; focus ring; logo on light themes
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
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 <img>
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 <noreply@anthropic.com>
2026-11-10 14:03:21 +00:00
Nibu
384cd17dac scene: terrain fine grain domain-warped so its plane waves stop reading as hatching
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m30s
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 <noreply@anthropic.com>
2026-11-10 14:00:44 +00:00
Void
9d3343bb31 nemesis/demo: tests follow the new numbers; Ridge retries a zero-size start
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m12s
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 <noreply@anthropic.com>
2026-11-10 13:53:22 +00:00
Eureka Self
0fe41efb9b 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 <noreply@anthropic.com>
2026-11-10 13:51:26 +00:00
Eureka Self
c4689299ca 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 <noreply@anthropic.com>
2026-11-10 13:46:51 +00:00
Eureka Self
fcabaee59e nemesis: chromeless buttons, chips and room list; plates only on the composer and plain messages
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 13:22:41 +00:00
Eureka Self
5712068902 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 <noreply@anthropic.com>
2026-11-10 13:18:59 +00:00
f320016ddc Merge pull request 'auth: footerBand test pins blur to 12px and the footer rule's exact property set' (#63) from void/footer-lows-2 into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m9s
2026-11-10 04:17:38 +00:00
320aeecc2c auth: footerBand test pins blur to 12px and the footer rule's exact property set
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 2m53s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 04:09:31 +00:00
7494591624 Merge pull request 'auth: test the emitted footer rule; wrap comment; correct the terrain luminance figure' (#62) from void/footer-lows into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-10 04:04:14 +00:00
b5e93510e8 auth: footerBand test: identOption for compile; kill blur(0), base-rule border, hard-stop survivors
Some checks failed
Build pull request / Build pull request (pull_request) Successful in 2m48s
NPM Lockfile Changes / lockfile_changes (pull_request) Failing after 1s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 03:58:04 +00:00
271729a5c0 auth: test the emitted footer rule; wrap its comment; correct the terrain luminance figure
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
NPM Lockfile Changes / lockfile_changes (pull_request) Has been cancelled
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 <noreply@anthropic.com>
2026-11-10 03:48:36 +00:00
30ae5154f5 Merge pull request 'auth: a blurred, tinted, soft-edged band behind the sign-in footer (4.5:1 in Day, Dusk, Night)' (#61) from void/auth-footer-tint into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-10 03:45:34 +00:00
2e6d4d042a auth: footer band review fixes: one block, no pill or border, color-mix fallback, sRGB test
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 2m45s
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 <noreply@anthropic.com>
2026-11-10 03:31:52 +00:00
56bc40abe6 auth: fade the footer band at its sides too
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 03:21:47 +00:00
eb6efeee91 auth: footer tint 0.82; 0.75 measured 4.49 at Dusk on the drawn pixels
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 03:20:06 +00:00
ea238cf77d 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 <noreply@anthropic.com>
2026-11-10 03:18:49 +00:00
Nibu
91e93e6887 scene: Dusk floor at the link bound with Day's pale-blue lift, so the dark link clears AA
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 2m54s
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 <noreply@anthropic.com>
2026-11-10 02:53:58 +00:00
0931901b24 Merge pull request 'scene: message and comment on the sweep assertion' (#60) from void/scene-midbuild-msg into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-10 02:26:29 +00:00
de504f8286 scene: say what the sweep's mid-build assertion means when it fails
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m9s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 02:24:13 +00:00
5666299396 Merge pull request 'scene: sweep the mid-build test over microtask offsets' (#59) from void/scene-midbuild-sweep into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-10 02:23:57 +00:00
6a93664813 scene: sweep the mid-build test over microtask offsets 0..20
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m8s
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 <noreply@anthropic.com>
2026-11-10 02:07:11 +00:00
7556fed31e Merge pull request 'scene: test a sky change landing mid-build' (#58) from void/scene-midbuild-test into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-10 02:03:51 +00:00
098e45f122 scene: test a sky change landing 10-12 microtasks into a build
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 2m57s
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 <noreply@anthropic.com>
2026-11-10 01:56:34 +00:00
596f864668 Merge pull request 'scene: two quick sky changes build the last sky; Night drift test is exact' (#57) from void/scene-test-strength into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-10 01:55:21 +00:00
Void
61f434a32d scene: two quick sky changes build the last sky; the Night drift test is exact
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 2m44s
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 <noreply@anthropic.com>
2026-11-10 01:34:07 +00:00
afae8a13c9 Merge pull request 'scene: rebuild only for a different drawn sky; URL-steered night sky within the Night backdrop (+ link readability numbers)' (#55) from void/sky-link-readability into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-10 01:32:03 +00:00
Void
2c271c6ded sky: Midnight Sky's link is fitted to clear AA on the lightest Night sky
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m16s
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 <noreply@anthropic.com>
2026-11-10 01:13:29 +00:00
Void
7bc28f9256 scene: a rebuild is for a different drawn sky; a URL-steered night sky stays within the Night backdrop
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
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 <noreply@anthropic.com>
2026-11-10 01:09:46 +00:00
a6364af8e3 Merge pull request 'trainingNotice: say whose ruling the wording is (comment only)' (#53) from void/training-notice-comment into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-10 00:30:26 +00:00
ea6dee80ae Merge pull request 'scene: shape each sky state's drawn sky into a luminance band its text ink clears' (#54) from nibu/sky-contrast into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-10 00:28:59 +00:00
Nibu
d03ec36107 Merge origin/dev into nibu/sky-contrast
Some checks failed
Build pull request / Build pull request (pull_request) Failing after 12m22s
Docker check / docker-build (pull_request) Failing after 10s
Conflicts: skyState.ts (kept #54's constants, re-derived), skyForState.test.ts (kept #54's tests, added #48's independent golden-section cross-check and the 1e-6 tight drift test on the screen reading).
terrainDrawn carries #48's finer search (5e-7 degrees) on the screen reading; the reading is now one 8-bit step each way (smooth), not a rounded value, so it can be searched.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 23:50:28 +00:00
Void
e1ebf7ec91 trainingNotice: cite Meta's 2026-11-09 scope; neither exception is implemented
Some checks failed
Build pull request / Build pull request (pull_request) Failing after 33s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 23:49:09 +00:00
Void
80d3ae1c9b trainingNotice: rewrap comment to the print width
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 23:48:16 +00:00
Void
c13e90539f trainingNotice: correct the comment per review
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
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 <noreply@anthropic.com>
2026-11-09 23:48:07 +00:00
f0bad585b6 Merge pull request 'scene: keep the terrain sky and baked tables out of the main chunk (stacked on #47)' (#48) from nibu/sky-bundle into dev
Some checks failed
Rat dev preview / Build dev preview (push) Failing after 35s
2026-11-09 23:35:25 +00:00
Nibu
577d795189 scene: auto never picks a theme; URL steers auto; quantised bounds; listener-window fix; DST
Some checks failed
Build pull request / Build pull request (pull_request) Failing after 26s
- 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 <noreply@anthropic.com>
2026-11-09 23:29:02 +00:00
Nibu
eafa3eda79 scene: Day sky bound was 1.2e-6 low; refine to 1e-6 degrees and check both directions
Some checks failed
Build pull request / Build pull request (pull_request) Failing after 44s
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 <noreply@anthropic.com>
2026-11-09 23:28:55 +00:00
Nibu
4177bdcccb scene: bound the terrain sky over what its frozen camera reaches (0 to 16 degrees), ridge stays whole-range
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 23:19:35 +00:00
Nibu
5d3db9e1f2 scene: shape each sky state's drawn sky into a luminance band its text ink clears
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
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 <noreply@anthropic.com>
2026-11-09 23:18:08 +00:00
Void
0c6d4476be trainingNotice: say whose ruling the wording is
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
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 <noreply@anthropic.com>
2026-11-09 23:15:13 +00:00
dc4b1f1818 Merge pull request 'CI: cancel superseded runs with workflow concurrency groups' (#50) from void/ci-concurrency into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-09 23:12:18 +00:00
Void
8f8536a805 CI: cancel superseded runs with workflow concurrency groups
Some checks failed
Build pull request / Build pull request (pull_request) Successful in 4m41s
Docker check / docker-build (pull_request) Failing after 10s
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 <noreply@anthropic.com>
2026-11-09 22:50:10 +00:00
Nibu
8a05668d84 scene: terrain backdrop range found by refinement, not a grid
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
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 <noreply@anthropic.com>
2026-11-09 22:41:27 +00:00
Nibu
111fa02994 Merge dev into nibu/sky-bundle 2026-11-09 22:38:24 +00:00
Nibu
0d26b71862 scene: keep the terrain sky and baked tables out of the main chunk
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
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 <noreply@anthropic.com>
2026-11-09 22:37:06 +00:00
913a66ebf0 Merge pull request 'Sky-state control in the estate title bar; the state picks the theme' (#45) from void/sky-control into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m45s
2026-11-09 22:36:52 +00:00
78f5f4dbb2 Merge pull request 'scene: ridge and terrain scenes follow the sky state' (#47) from nibu/sky-consumer into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-09 22:35:18 +00:00
Void
76daf7cdca Re-pin the bare-sky ratios on skyBackdrop's union ranges
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
#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 <noreply@anthropic.com>
2026-11-09 22:32:56 +00:00
Void
5862bbdba2 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 <noreply@anthropic.com>
2026-11-09 22:32:20 +00:00
Void
ce16609c76 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 <noreply@anthropic.com>
2026-11-09 22:32:20 +00:00
Void
3be90875ec 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 <noreply@anthropic.com>
2026-11-09 22:32:20 +00:00
Void
c180385476 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 <noreply@anthropic.com>
2026-11-09 22:32:20 +00:00
Nibu
edc6a9a8f1 scene: catch a state change during the first build; skyBackdrop is the union of what the scenes draw
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
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 <noreply@anthropic.com>
2026-11-09 22:31:00 +00:00
Nibu
6c22d4f1dc scene: a failed sky-state rebuild restores the ground; an explicit sky beats the URL
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
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 <noreply@anthropic.com>
2026-11-09 22:23:47 +00:00
Nibu
8c02b85796 Merge dev into nibu/sky-consumer 2026-11-09 22:22:27 +00:00
Nibu
0e0cff66ae scene: the ridge and terrain scenes follow the sky state
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
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 <noreply@anthropic.com>
2026-11-09 22:20:49 +00:00
a9a75fc6ff Merge pull request 'scene: skyState test takes the worse of both sky ends' (#46) from nibu/sky-test-ratio into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-09 22:19:32 +00:00
Nibu
32cc8d6ad6 scene: skyState test clamps the ink into the sky range for its worst case
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 22:16:39 +00:00
Nibu
a3a500a3d2 scene: skyState test measures ink against both sky ends and takes the worse
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 22:13:36 +00:00
a129866236 Merge pull request 'scene: host handles a lost GL context (ground on loss, rebuild on restore)' (#44) from nibu/context-lost into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-09 22:13:23 +00:00
Nibu
571a2de3fa scene: pin the host's restore-listener removal and stale-loss guard; fix preventDefault comment
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 22:10:36 +00:00
47ad5cc9d6 Merge pull request 'scene: sky-state seam (auto/day/dusk/night) with skyBackdrop' (#43) from nibu/sky-state into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-09 22:07:59 +00:00
Nibu
3b61f2babf scene: host handles a lost GL context (drop to ground, rebuild on restore)
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
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 <noreply@anthropic.com>
2026-11-09 22:05:30 +00:00
Nibu
ecdf5e9d29 scene: sky-state seam (auto/day/dusk/night) with a luminance bound per state
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m40s
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 <noreply@anthropic.com>
2026-11-09 22:02:01 +00:00
7a7f5ceee1 Merge pull request 'scene: terrain mesh as fine as the height map (stack: terrain scatter, albedo, stars, haze)' (#42) from nibu/scene-terrain-scatter-dev into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 2m51s
2026-11-09 20:43:11 +00:00
Nibu
ef961636c3 auth: opaque login card for every theme, own link colour off the sky themes
Some checks failed
Build pull request / Build pull request (pull_request) Failing after 3m12s
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 <noreply@anthropic.com>
2026-11-09 20:40:47 +00:00
Nibu
a773bc4932 auth: Noonday Sky login card opaque, so text and link pass AA over any terrain
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
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 <noreply@anthropic.com>
2026-11-09 20:37:16 +00:00
Nibu
78a7dbe923 auth: login card fill 50% -> 54% so 4.5:1 holds without backdrop-filter
Some checks failed
Build pull request / Build pull request (pull_request) Failing after 3m17s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 20:17:35 +00:00
030417f27a CI: drop artifact upload from the build check (#41)
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m6s
2026-11-09 16:01:37 +00:00
deb4e0dfe6 Room list on a phone: plain text rows in the heading type; no Create Room or Join with Address (#40)
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-09 15:39:38 +00:00
Nibu
b13bb9ecb3 Merge origin/dev (9794714f) into nibu/scene-terrain-scatter-dev
Some checks failed
Build pull request / Build pull request (pull_request) Failing after 40s
One conflict, index.html's manifest link: this branch had %BASE_URL%manifest.json (4afd37e7), dev has ./manifest.json
(45aad35e). Both make the link base-relative for static previews; dev's form is kept, matching the file's other
relative links.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 15:21:58 +00:00
Nibu
9b4d92c9de scene: terrain mesh as fine as the height map it is shaded from, cavity read bilinear
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
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 <noreply@anthropic.com>
2026-11-09 15:19:14 +00:00
9794714f97 Room list wash and name line: undo the solid slabs and the boxed pill (#39)
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m45s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 14:57:08 +00:00
dfc74c252a Merge pull request 'Phone text flow: portrait floor, list markers, in-flow banner, pretend-server notice' (#35) from void/text-flow-footer into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 4m16s
2026-11-09 11:07:05 +00:00
Void
cfb8a8641a Room list scrim: hover, selected and pressed rows are three distinct steps
Some checks failed
Build pull request / Build pull request (pull_request) Failing after 6m8s
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 <noreply@anthropic.com>
2026-11-09 11:06:24 +00:00
9ab8bbeb33 Merge pull request 'Upload failures keep name, size and file readability' (#34) from void/upload-error-diagnostics into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-09 10:59:52 +00:00
7a5fa81be7 Merge pull request 'phone layout: text wraps the traced portrait outline; status lines clear the composer' (#31) from void/text-flow-leftovers into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-09 10:59:44 +00:00
1a33fa55d5 Merge pull request 'docs: file zone design sketch' (#33) from void/file-zone-design into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-09 10:59:35 +00:00
747f2d505b Merge pull request 'preview-demo: drop the submit-event shim; relative manifest link' (#32) from void/rat-dev-cleanups into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-09 10:59:26 +00:00
fa1ce2b50e Merge pull request 'preview-demo: README for the pose thumbnails (provenance)' (#29) from void/poses-readme into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-09 10:59:20 +00:00
82dc0d0a9b Merge pull request 'settings: no window at import (fixes soundscape test on dev)' (#37) from void/settings-without-window into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-09 10:59:10 +00:00
Void
c7d6c388d3 settings: do not read window at import when there is none
Some checks failed
Build pull request / Build pull request (pull_request) Failing after 4m56s
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 <noreply@anthropic.com>
2026-11-09 10:59:08 +00:00
Void
0438cbc8da Room list: category labels get the same scene scrim as the rows
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 10:51:46 +00:00
Void
a23dd239ba 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 <noreply@anthropic.com>
2026-11-09 10:50:24 +00:00
Void
6110292e3b 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 <noreply@anthropic.com>
2026-11-09 10:50:24 +00:00
Void
14312459f2 Pose pill: wrap the header row, not the pill
Some checks failed
Build pull request / Build pull request (pull_request) Failing after 4m51s
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 <noreply@anthropic.com>
2026-11-09 10:29:22 +00:00
Void
245eb90f63 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 <noreply@anthropic.com>
2026-11-09 10:27:42 +00:00
Void
8ca38c4980 Demo fixture: long-named Heimdall uses a pose Heimdall's art has
Some checks failed
Build pull request / Build pull request (pull_request) Failing after 3m44s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 10:21:27 +00:00
Void
34bdccd87c 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 <noreply@anthropic.com>
2026-11-09 10:21:07 +00:00
Void
ea33ab6daa Pose pill: the Text inside the name button wraps too
Some checks failed
Build pull request / Build pull request (pull_request) Failing after 3m41s
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 <noreply@anthropic.com>
2026-11-09 10:10:31 +00:00
Void
b091f65cea 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 <noreply@anthropic.com>
2026-11-09 10:08:18 +00:00
Void
8ac43bd73f 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 <noreply@anthropic.com>
2026-11-09 10:05:02 +00:00
Nibu
20cc2c682d 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 <noreply@anthropic.com>
2026-11-08 21:12:07 +00:00
Nibu
f7725545a5 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 <noreply@anthropic.com>
2026-11-08 19:30:04 +00:00
Void
25765c44c5 Upload each attempt from an in-memory copy; failure line names the XHR's end state
Some checks failed
Build pull request / Build pull request (pull_request) Failing after 3m12s
- 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 <noreply@anthropic.com>
2026-11-08 18:47:27 +00:00
Void
3e57865eca Upload failure text carries who called abort and what the owning component did
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
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 <noreply@anthropic.com>
2026-11-08 18:45:29 +00:00
Nibu
c35ac7e5cd 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 <noreply@anthropic.com>
2026-11-08 18:16:47 +00:00
Nibu
685abbb19b 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 <noreply@anthropic.com>
2026-11-08 18:10:21 +00:00
Void
be947a0bf9 Retry an upload once when it aborts at once with no progress
Some checks failed
Build pull request / Build pull request (pull_request) Failing after 3m12s
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 <noreply@anthropic.com>
2026-11-08 18:05:11 +00:00
Void
58df7b833c phone: pill clears the portrait, portrait bubbles keep a text floor, status block collapses
Some checks failed
Build pull request / Build pull request (pull_request) Failing after 3m31s
- 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 <noreply@anthropic.com>
2026-11-08 17:41:04 +00:00
Nibu
1296f18338 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 <noreply@anthropic.com>
2026-11-08 17:38:27 +00:00
Void
95f0dce05f Upload failure text carries what the client can observe
Some checks failed
Build pull request / Build pull request (pull_request) Failing after 3m5s
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 <noreply@anthropic.com>
2026-11-08 17:32:32 +00:00
Void
a1aece7d36 preview-demo test: fake document carries what the in-flow banner uses
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 17:20:31 +00:00
Void
16cc05a33b 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 <noreply@anthropic.com>
2026-11-08 17:20:13 +00:00
Void
252fabc138 Upload failures keep their name, size and file readability
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
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 <noreply@anthropic.com>
2026-11-08 17:08:51 +00:00
Nibu
e007514af3 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 <noreply@anthropic.com>
2026-11-08 08:10:28 +00:00
Void
ba592523d8 docs: file zone marks are verdict and landed reactions plus optional reply
Some checks failed
Build pull request / Build pull request (pull_request) Failing after 3m20s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 08:05:29 +00:00
Void
002e0d608b docs: file zone marks and export follow aqua.listen.verdict.v1
Some checks failed
Build pull request / Build pull request (pull_request) Failing after 3m14s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 07:57:30 +00:00
Void
089dcc86e1 docs: file zone is one room, one thread per Mind; marks are thread replies
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 07:56:36 +00:00
Void
b6a97046b0 docs: file zone design sketch
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
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 <noreply@anthropic.com>
2026-11-08 07:55:54 +00:00
Nibu
40b1e775fe Merge remote-tracking branch 'origin/nibu/scene-terrain-scatter' into nibu/scene-terrain-scatter-dev
# Conflicts:
#	scripts/preview-config.js
2026-11-08 07:51:56 +00:00
Void
45aad35efa preview-demo: drop the submit-event shim; manifest link relative
Some checks failed
Build pull request / Build pull request (pull_request) Failing after 2m30s
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 <noreply@anthropic.com>
2026-11-08 02:12:11 +00:00
Nibu
c2858452e7 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=<deg>&az=<deg> (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 <noreply@anthropic.com>
2026-11-07 23:40:43 +00:00
Nibu
f1d8db0c61 Merge void/sky-tokens (0386065f) into scene-terrain-scatter 2026-11-07 23:28:53 +00:00
Void
636cad9655 phone layout: text wraps the traced portrait outline; status lines no longer sit under the composer
Some checks failed
Build pull request / Build pull request (pull_request) Failing after 2m59s
shape-outside: url() sizes the image at its natural size and anchors it at the margin box, which the
phone layout's -82px top margin moves down by the overhang, so the exclusion was the wrong size and
in the wrong place and long-post words ran under the art. PoseAvatar now traces the displayed art's
alpha (0.35, as before) into a polygon in border-box pixels, retraced on load and resize.

The pose status lines sat in a shrinkable flex box (12px high, three 18px lines) and overflowed into
the composer; the box is shrink="No".

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 23:27:53 +00:00
Nibu
d2c2c279e2 scene: brighter, slightly larger terrain stars so they read on a phone
Terrain overlay draws the ridge field at 2.4x alpha (capped at 1) and 1.3x
radius; the dark-sky gate is unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 21:00:56 +00:00
Nibu
2299adefb5 merge nibu/scene-terrain-stars into nibu/scene-terrain-both 2026-11-07 21:00:17 +00:00
Void
0386065f2c sky: bake an atmospheric-scattering table (half float) for a WebGL2 sky shader
Some checks failed
Build pull request / Build pull request (pull_request) Failing after 3m38s
CLA Assistant / CLAssistant (pull_request_target) Failing after 1s
scatter.js ray-marches a single-scattering Rayleigh+Mie atmosphere whose vertical optical depth is
sun.js's TAU, and bakes two tables into baked.scatter: transmittance to space by view elevation
(32x1) and sky radiance by azimuth from the sun x view elevation x sun elevation (7x32x12), RGB
half floats, little-endian base64, value x 64. Layout, exact decode and a GLSL sampler are in
src/sky/README.md. Tests pin layout, encoding, checksums and physical anchors (zenith transmittance
equals exp(-TAU); blue noon, red low sun toward the sun, black below the planet-shadow limit).

The per-sky sun {elevation_deg, azimuth_deg, color_linear}, ambient_linear and horizon_hex were
already baked in 7216456a; sun.js now exports TAU.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 20:57:22 +00:00
Nibu
054c584ebc scene: stars in the terrain scene, a 2D overlay above the horizon on dark skies
Reuses ridge/stars.ts (placement, tint, rolling 32-star twinkle) projected with the terrain camera
onto an overlay cut to the sky; stops with the terrain loop.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 20:55:44 +00:00
Nibu
da12cb8708 scene: tune terrain albedo bands, lighter haze, night ambient lift
Bands moved so snow caps only the peaks and soil/scree/rock read; haze
0.0007 -> 0.0004 so the albedo is not washed out; ambient x4 at night so a
moonlit face is readable on a phone.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 20:54:25 +00:00
Nibu
931f84fcd1 scene: procedural terrain albedo by normal and height, per baked sky
Rock on steep faces, soil to scree to snow by height, snow only on gentle
faces; no texture reads. Pure albedoAt()/albedoPalette() mirror the grid
fragment shader; the palette is read from the baked sky (snow line and tint
by sun elevation: cool haze at night, warm sun by day).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 20:44:54 +00:00
Nibu
19c1f21610 scene: terrainNormal reads the world-z slope from .y, not .z
The noise is evaluated at vec3(xz * f, c), so .z is the slope along the held
constant axis. Confirmed numerically (harness/scene/gradAxis.cjs): analytic
.xy matches central differences for fbm and phacelle, .z does not. Only the
ray-march scene's normals were affected; the grid tier already used .xy.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 20:43:34 +00:00
Nibu
4afd37e7dd preview: hash router and base-relative manifest link
Previews are static dirs with no SPA fallback; a reload or shared link on
/login/matrix.org was an nginx 404. preview-config now enables the hash
router, and index.html's manifest link follows the base path.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 20:25:02 +00:00
fb4eabbf38 Merge pull request 'ridge scene behind the whole app, tight bubble wrap, sharper portraits, long-post fixtures' (#30) from void/ridge-scene-and-bubbles into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 2m41s
2026-11-07 19:51:23 +00:00
Void
7bdc512836 scene behind the whole app; bubble text wraps around the portrait, not beside it
Some checks failed
Build pull request / Build pull request (pull_request) Failing after 2m57s
Signed in, the shell is clear over the ridge scene (html[data-scene]): Background and Surface
container tokens are redefined on #root only, so menus, dialogs and tooltips in #portalContainer
keep their fills. Bubbles and the composer are the filled surfaces: their colour at 50% over a
12px blur. Avatar initials read the surface colour through a variable captured above the override.

On phones the message content (a flex column) formed its own block formatting context and sat
beside the floated portrait for the bubble's whole height, so text stayed as narrow as the gap
beside the portrait. It is a plain block there now: lines wrap around the silhouette and then
run the bubble's full width.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 19:50:59 +00:00
Void
d73599d2cf preview: portrait thumbs at 480px (were 160px), long-post demo fixtures
The demo pose thumbnails were 160px wide palette PNGs, about 4 KB, shown at up to 210 CSS px
on phones: 195 CSS px x DPR 3 = 585 device px from 160, a 3.6x upscale. They are now 480px
wide (about 2.5x the phone portrait at DPR 3, still 25-70 KB each), generated from
/opt/commons/cast-art/poses by scripts/make-pose-thumbs.mjs, and cover every pose the
catalogue lists for the three demo Minds.

The Ratatoskr demo room gains three long Mind posts with paragraphs, lists and code,
sent with formatted_body like the real Minds' Markdown.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 19:50:59 +00:00
Void
fd5134e510 preview-demo: say where the pose thumbnails come from
Some checks failed
Build pull request / Build pull request (pull_request) Failing after 2m42s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 19:07:35 +00:00
d233344207 Merge pull request 'preview: pretend homeserver for the dev preview (in-page, off by default)' (#28) from void/preview-demo into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m7s
2026-11-07 19:02:48 +00:00
Void
2a249ddf68 preview-demo: fix test file, drop unused import
Some checks failed
Build pull request / Build pull request (pull_request) Failing after 2m53s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 18:58:15 +00:00
Void
0020f7f003 preview-demo: seed settings before the settings module reads them, short banner, quick first poll
Found driving Chrome: the pose setting was seeded after settings.ts had already read defaults;
the Connecting bar stayed until the first 15 s long poll returned.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 18:57:07 +00:00
Void
e7dfbd008b preview-demo: deliver submit events the sandbox would cancel
The host CSP sandbox lacks allow-forms; Chrome drops native submission before the submit
event fires, so the login button did nothing in a real browser (found driving Chrome).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 18:54:59 +00:00
Void
e3278a4d7e preview-demo: destructure origin
Some checks failed
Build pull request / Build pull request (pull_request) Failing after 3m22s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 18:52:30 +00:00
Void
df536f854e preview-demo: destructure open
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 18:52:15 +00:00
Void
0f1fc066e4 preview-demo: lint
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 18:51:07 +00:00
Void
8431827077 preview-demo: lint-clean router, prettier
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 18:50:52 +00:00
Void
4d09a33ec2 preview-demo: seed settings through the installed window's storage
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 18:50:15 +00:00
Void
7a896c5f93 preview: pretend homeserver for the dev preview, in-page and off by default
src/preview-demo answers a demo.invalid homeserver from fixtures through a guarded fetch;
own-origin requests pass, every other origin is refused (fetch, WebSocket, XHR, beacon).
Built in only with VITE_PREVIEW_DEMO=1 and switched on by previewDemo in config.json
(scripts/preview-config.js --demo, run by the dev workflow). Login prefills 'demo'.
Pose art: 24 downscaled thumbnails in preview-demo/poses, copied into dist by the workflow.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 18:49:59 +00:00
Void
7216456a27 sky: bake sun azimuth and colour, ambient with a night floor, horizon colour
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Failing after 1s
Build pull request / Build pull request (pull_request) Failing after 3m18s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 18:46:39 +00:00
Nibu
9e34664550 scene: terrain shading blended across texels, smoother sky horizon, lighter haze
Preview on Starfire showed one flat normal per height-map texel (blocky ground)
and a hard step in the sky gradient at the geometric horizon. Normals now blend
the four surrounding texels; the sky's elevation mapping eases through the
horizon; haze k 0.0012 -> 0.0007 so relief reads.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 18:43:33 +00:00
Void
0f3494217e ci: upload the dev preview with the v3 artifact protocol
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m15s
The v4 client drops the /forge subpath of ACTIONS_RESULTS_URL; v3 uses ACTIONS_RUNTIME_URL.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 18:40:17 +00:00
Void
70ade6ef0e ci: print artifact service URLs (debug, no tokens)
Some checks failed
Rat dev preview / Build dev preview (push) Failing after 3m1s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 18:36:52 +00:00
Nibu
5642acf2f9 scene: terrain style, a baked Phacelle height map on a displaced grid lit by the baked sky
style 'terrain' (WebGL2, tier-gated, dynamic import): height map baked once on the
GPU into RGBA8 (promoted from the terrain-tier-spike), a displaced grid, the baked
gradient as sky, baked sun/ambient/haze. Sky from option, ?sky=, or the stars'
night notion. Cadence guard, pauses, context loss and compile failure follow the
ridge scene. preview-config.js takes an optional style argument.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 18:36:18 +00:00
Nibu
077737830e scene: baked sky numbers and lighting/haze maths for a terrain tier
Vendors the small set the terrain needs from Void's baked sky (PR #22, 7b4aa2f):
sun elevation, the 7-stop gradient, sun.js light().direct, plus a sun azimuth
computed from the same series (not in the bake). Lambert from the sun, ambient
from the gradient zenith, exp(-k d) haze toward the gradient's horizon stop;
a sun below the horizon lights nothing. Pure functions, no GL.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 18:36:18 +00:00
Void
27d5bbaec5 ci: point the artifact uploader at the forge subpath
Some checks failed
Rat dev preview / Build dev preview (push) Failing after 3m4s
Run 170: CreateArtifact got an nginx 404 page; /twirp 404s at the host root and answers
(401 without a token) at /forge/twirp. Try the results URL with the subpath.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 18:33:35 +00:00
Nibu
833c477785 scene: baked starfield with a rolling twinkle window for dark skies
Every star is seeded, placed toward the zenith, faded by the local sky
luma, and baked once into the sky canvas. A night sky is one whose
zenith luma is below 110; the ridge scene has no theme flag, so the
dusk default qualifies faintly and a day sky gets no stars and no
overlay.

32 slots twinkle at a time on a small overlay canvas under the ridges:
each slot runs back-to-back one-cycle twinkles (own 2-7 s period and
offset) of one star from its own partition, shuffled per epoch, never
repeating across the seam. Gain is a pure function of (slot, t), 0.35x
to 1.15x, exactly 1 at start and end. Dimming paints the sky's own
colour over the baked star; brightening adds a star. The pass is
clear + at most 32 arcs at 20 fps, stopped under reduced motion (live),
while hidden, in tier 'still', and after the slow-frame fallback.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 18:30:20 +00:00
Void
2b3384ff5c ci: upload the dev preview artifact with forgejo/upload-artifact
Some checks failed
Rat dev preview / Build dev preview (push) Failing after 3m0s
actions/upload-artifact v4+ throws GHESNotSupportedError on this forge (run 169);
Forgejo's fork is the supported uploader.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 18:29:53 +00:00
Void
053a40038e ci: rerun rat-dev-preview (runner MemoryMax now 8 GiB)
Some checks failed
Rat dev preview / Build dev preview (push) Failing after 3m43s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 18:25:40 +00:00
Nibu
b8f2bb462e auth: login card fill 50% with 18px blur and a faint edge
The blur flattens the sky and ridges behind the text, so the fill can be
half as opaque and let the scene show through. Text over the unblurred
horizon glow is 4.15:1 (4.5 needs 54%); measured over the real blurred
backdrop at 390x844 it is >= 4.94:1.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 18:22:19 +00:00
538b96fd20 Merge pull request 'ci: build dev into rat-dev-dist artifact' (#27) from void/rat-dev-workflow into dev
Some checks failed
Rat dev preview / Build dev preview (push) Failing after 3m38s
2026-11-07 18:17:20 +00:00
Void
2b576f6a66 ci: build the dev branch into an rat-dev-dist artifact
Some checks failed
Build pull request / Build pull request (pull_request) Failing after 3m9s
Push to dev builds with base /void/rat-dev/, tier low, heap 3072 MB, and uploads dist.
No secrets. Consumed by gamecult-ops install-rat-dev-preview.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 18:12:04 +00:00
Void
39b20c9e65 Merge remote-tracking branch 'origin/dev' into dev
# Conflicts:
#	config.json
2026-11-07 18:12:03 +00:00
Void
2759725a33 Merge commit '3f6d27a1' into void/preview-all 2026-11-07 18:04:57 +00:00
Nibu
3f6d27a1f9 auth: translucent bubble-style login card while the scene runs
Some checks failed
Build pull request / Build pull request (pull_request) Failing after 3m35s
CLA Assistant / CLAssistant (pull_request_target) Failing after 1s
Under html[data-scene] the card is a blurred 85% pane in the sky bubble colour
(--sky-bubble, fallback #0d1a26), message-bubble radius, no visible border, and
shorter padding/gaps so more scene shows on a phone. Tier off is unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 18:03:33 +00:00
Void
0f79ce2c4e Merge remote-tracking branch 'origin/nibu/scene-host' into void/preview-all 2026-11-07 18:03:10 +00:00
Void
d9b9c635c6 Merge remote-tracking branch 'origin/nibu/ridge-scene' into void/preview-all 2026-11-07 18:03:10 +00:00
Void
d2f5cef089 Merge remote-tracking branch 'origin/void/three-doors-look' into void/preview-all
# Conflicts:
#	README.md
2026-11-07 18:03:10 +00:00
Void
a8984fcbbd Merge remote-tracking branch 'origin/void/composer-spellcheck' into void/preview-all 2026-11-07 18:03:05 +00:00
Void
26e60fc9ec Merge remote-tracking branch 'origin/void/pose-catalogue' into void/preview-all
# Conflicts:
#	package.json
2026-11-07 18:03:05 +00:00
Void
7013fcf993 Merge remote-tracking branch 'origin/void/resume-sync' into void/preview-all 2026-11-07 18:02:59 +00:00
Void
469cd83c9f Merge remote-tracking branch 'origin/void/login-training-notice' into void/preview-all 2026-11-07 18:02:59 +00:00
Void
28a9af5fd7 Merge remote-tracking branch 'origin/void/brand-ratatoskr' into void/preview-all 2026-11-07 18:02:59 +00:00
Void
6b7f91aa3c Merge remote-tracking branch 'origin/void/sky-tokens' into void/preview-all 2026-11-07 18:02:59 +00:00
Void
7bb2ddab74 Merge remote-tracking branch 'origin/void/sky-sun' into void/preview-all 2026-11-07 18:02:59 +00:00
Nibu
0ab0e77a20 scene: mount the background scene at the app root
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Failing after 1s
Build pull request / Build pull request (pull_request) Failing after 3m7s
Check PR title / lint (pull_request_target) Failing after 1s
BackgroundScene hosts the scene behind the app (fixed, z-index -1, tier off
renders nothing); mountBackgroundScene owns the async start/dispose race and
calls login() when a session already exists. Signed-out screens go transparent
while a scene runs. build:preview sets backgroundScene.tier low in dist.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 17:34:12 +00:00
aa2eef089d Merge pull request 'ci: drop Cinny-upstream workflows that cannot run on the forge' (#25) from void/ci-drop-cinny-leftovers into main 2026-11-07 11:39:21 +00:00
void
d670fc5391 ci: drop Cinny-upstream workflows that cannot run on the forge
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Failing after 1s
Check PR title / lint (pull_request_target) Failing after 1s
Build pull request / Build pull request (pull_request) Failing after 3m37s
Docker check / docker-build (pull_request) Failing after 15s
Remove cla, pr-title, deploy-pull-request, netlify-dev and prod-deploy;
drop the deleted prod-deploy path from docker-pr's trigger filter.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 11:38:28 +00:00
Void
b9c5e3d1a5 auth: room notice version is bounded at 4294967295 (Heimdall's one version rule)
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Check PR title / lint (pull_request_target) Has been cancelled
CLA Assistant / CLAssistant (pull_request_target) Failing after 1s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 00:31:31 +00:00
Void
c2673a698d auth: a room's notice is a state event (org.gamecult.notice), not a pinned message
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
Check PR title / lint (pull_request_target) Has been cancelled
Heimdall's ruling: type org.gamecult.notice, state_key '', content {version, scope}.
version is a positive integer, scope exactly show, study or train; absent or
malformed means no train line. ROOM_NOTICE_CONTENT_KEY becomes
ROOM_NOTICE_EVENT_TYPE; RoomTrainingLine reads the state event through useStateEvent.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 23:30:54 +00:00
Void
3fcf1cf9dd auth: train line comes from the room's pinned notice; drop browser-side acceptance
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
The collector reads the notice version from the room's pinned notice and takes
the member's first reply as the acceptance (Heimdall answer-collector-consent.md),
so a localStorage record at registration was a second, unread copy. Deleted it
and its unused re-ask helpers. RoomTrainingLine now reads the room's own pinned
notice and shows the train sentence only where that notice declares train; no
notice, no line (DMs included). The pinned event's encoding is not fixed by the
doc; the key is provisional and isolated in trainingNotice.ts.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 22:27:47 +00:00
Void
7b4aa2f184 sky: Midnight Sky and Noonday Sky baked from sun.js (albedo x irradiance)
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Check PR title / lint (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
Two themes replace the navy GameCult theme. Every colour is a bark, leaf, soil
or pigment albedo times the irradiance sun.js gives for a fixed moment (equinox
noon and midnight, 45N). scripts/bake-sky.mjs writes src/sky/baked.ts: the
terrain-free sky gradient used as the fallback background, folds tokens for
panels and buttons, chat bubble colours, link and name colours.
skyTokens.test.ts holds every text pairing to WCAG AA and fails if baked.ts is
stale.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 20:57:31 +00:00
Nibu
69d8ff3fa1 scene: analytic aerial perspective on the baked ridge layers
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
Per-layer Beer-Lambert transmittance exp(-k d) mixes each layer toward the haze colour, with
desaturation, 0-2 px edge softening (2D canvas filter, where supported) and an optional faint
warm sun-side in-scatter growing with distance. All at bake time; no per-frame work. Sky colours
still come only from skyColours() (gains optional sunAzimuth).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 20:50:41 +00:00
Void
e38ebda1c5 auth: training notice at registration only, versioned; one train line per room
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
Check PR title / lint (pull_request_target) Has been cancelled
Login no longer shows the notice. Registration shows the ruled sentence with a
plain tick, and records {noticeVersion, acceptedAt} on submit (newcomers only;
a changed TRAINING_NOTICE_VERSION is a fresh acceptance). Each room carries one
line above the composer. The Discord sentence and "on this server" are gone.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 19:52:07 +00:00
b48685b986 scene: ridge scene as the default background, no WebGL
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
Check PR title / lint (pull_request_target) Has been cancelled
Four ridge layers generated once on the CPU from CultMath value-noise fbm, each painted
once to an integer-size 2D canvas over a baked sky; CSS translate3d drift only. Pauses
hidden / reduced motion; falls back to a still image if rAF p95 > 33 ms over 2 s;
?probe=1 shows median/p95. Same createScene contract, tier flag still defaults off.
Ray-march scene stays behind backgroundScene.style = 'raymarch'.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 18:34:13 +00:00
Void
7c89bd0289 sky: sun.js, sun position and light colour for a place and moment
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Check PR title / lint (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
Dependency-free ES module: light({time,lat,lon,cloud_cover,precipitation})
-> {elevation, direct, diffuse, wetness}, linear RGB. Constants that were
tuned by eye are labelled EYEBALLED. Fixed-moment tests in
sun.node-test.js (plain node, not collected by vitest).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 18:20:49 +00:00
Void
4e2aeddb69 auth: training notice on login and registration; registration needs an acknowledgement
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
Check PR title / lint (pull_request_target) Failing after 1s
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 <noreply@anthropic.com>
2026-11-06 13:39:29 +00:00
Void
8774cbbe45 README: drop the OBS media receiver disambiguation
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Failing after 1s
Build pull request / Build pull request (pull_request) Failing after 2m57s
NPM Lockfile Changes / lockfile_changes (pull_request) Failing after 1s
Check PR title / lint (pull_request_target) Has been cancelled
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 <noreply@anthropic.com>
2026-11-06 12:39:02 +00:00
Void
1519f5a8ab README: disambiguate from the OBS media receiver also called Ratatoskr
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
NPM Lockfile Changes / lockfile_changes (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 08:37:51 +00:00
Void
2255988866 brand: Ratatoskr README front door, package name, theme colour, contrib confs
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
NPM Lockfile Changes / lockfile_changes (pull_request) Has been cancelled
Check PR title / lint (pull_request_target) Has been cancelled
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 <noreply@anthropic.com>
2026-11-06 07:09:25 +00:00
14d9fa6246 Merge pull request 'brand: Ratatoskr replaces Cinny branding (do not merge)' (#11) from void/brand into main 2026-11-05 10:51:59 +00:00
3043690657 chat look: the three-doors mock-up in the bubble layout
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Check PR title / lint (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
NPM Lockfile Changes / lockfile_changes (pull_request) Has been cancelled
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 <noreply@anthropic.com>
2026-11-05 10:49:54 +00:00
Void
4df22558bf Merge remote-tracking branch 'origin/void/brand' into void/three-doors-look 2026-11-05 10:44:31 +00:00
Void
db023bc033 Merge remote-tracking branch 'origin/void/pose-catalogue' into void/three-doors-look 2026-11-05 10:44:31 +00:00
68f10ce414 Merge pull request 'Estate navigator stack: background scene, soundscape, cone prepass (opt-in)' (#15) from ratatoskr/estate-navigator into main 2026-11-05 00:31:47 +00:00
7e632e8605 Merge pull request 'scene: cone prepass for pass 1, opt-in steps/pixel readout' (#12) from nibu/scene-trace-prepass into ratatoskr/estate-navigator
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Check PR title / lint (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
2026-11-04 22:56:29 +00:00
Void
56f360233a composer: spellcheck setting, off by default on touch devices
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Check PR title / lint (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
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 <noreply@anthropic.com>
2026-11-04 22:08:22 +00:00
Nibu
293885089a scene README: createScene needs either float colour-buffer extension
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 20:39:58 +00:00
Nibu
7e05866437 scene: half-float prepass start target where EXT_color_buffer_float is missing; say why createScene is null
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
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 <noreply@anthropic.com>
2026-11-04 20:39:22 +00:00
Nibu
50ffd90560 harness: demo page keeps hold of the canvas it swaps in, so the prepass switch works twice
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
Check PR title / lint (pull_request_target) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 18:47:12 +00:00
Nibu
12469671c9 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 <noreply@anthropic.com>
2026-11-04 18:35:11 +00:00
Nibu
b055b2a5af 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 <noreply@anthropic.com>
2026-11-04 18:26:23 +00:00
Nibu
87c9ca6030 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 <noreply@anthropic.com>
2026-11-04 18:26:23 +00:00
Void
9e12f6cbe4 brand: Ratatoskr replaces Cinny in the user-facing surface
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Check PR title / lint (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
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 <noreply@anthropic.com>
2026-11-04 15:53:30 +00:00
Void
a1101e90a6 poses: read labels only from the catalogue; add a client-status pose line
Some checks failed
Check PR title / lint (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
CLA Assistant / CLAssistant (pull_request_target) Failing after 1s
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:
<Mind> is <pose>, since HH:MM" from the loaded timeline; it is never sent to a room.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 14:06:18 +00:00
Void
33e5f0ebf7 client: nudge sync on resume so a backgrounded tab stops waiting out the 30 s poll
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
Check PR title / lint (pull_request_target) Has been cancelled
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 <noreply@anthropic.com>
2026-11-03 12:01:38 +00:00
95e24aadb5 Merge pull request 'scene: Phacelle bound says measured-not-proved; neutral creature draws nothing' (#8) from nibu/phacelle-7-neutral-creature into ratatoskr/estate-navigator 2026-11-03 11:37:05 +00:00
Nibu
5eb0a63caa scene: ES2016-safe hasOwnProperty, tighten undefined-bucket assertion, reflow bound comment
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 08:59:54 +00:00
Void
5e79a1853a 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 <noreply@anthropic.com>
2026-11-02 18:29:34 +00:00
Void
190e2e724b Merge PR #7: Soundscape neutral read first, human kind subset (provisional) 2026-11-02 18:28:16 +00:00
Void
f9cf99faff Merge PR #6: Soundscape audio socket over Aqua activity frame (flag off, muted) 2026-11-02 18:28:16 +00:00
Nibu
fff46492ca scene: Phacelle bound says measured-not-proved; neutral creature draws nothing
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
Check PR title / lint (pull_request_target) Has been cancelled
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 <noreply@anthropic.com>
2026-11-02 17:09:42 +00:00
686f72bcf1 Merge pull request 'Background scene: two-pass WebGL2 terrain and world tree with login pull-back' (#1) from nibu/background-scene into ratatoskr/estate-navigator 2026-11-02 16:50:13 +00:00
Void
cc99a3439d soundscape: neutral read first; human kind subset (provisional) per grammar 5e2eaa9
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Check PR title / lint (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
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 <noreply@anthropic.com>
2026-11-02 15:46:03 +00:00
Nibu
1f4341dab6 scene: live reduced-motion, chunk-load failure, per-stage CultMath
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
- 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 <noreply@anthropic.com>
2026-11-02 14:43:33 +00:00
Void
c4c00d7127 soundscape: audio socket over Aqua's activity frame (flag off, muted, untested by ear)
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Check PR title / lint (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
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❓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 <noreply@anthropic.com>
2026-11-02 14:39:00 +00:00
Nibu
0ec34ff19f creatures: cite creature-projection-v2
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Check PR title / lint (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
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 <noreply@anthropic.com>
2026-11-02 10:14:52 +00:00
Nibu
8c7113bcaf creatures: cite the Bifrost creature-props contract
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Check PR title / lint (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
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 <noreply@anthropic.com>
2026-11-02 07:22:58 +00:00
Nibu
093230a2c1 Background scene: lint and type clean, integration contract
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
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 <noreply@anthropic.com>
2026-11-02 06:20:53 +00:00
Nibu
7a498584e6 Background scene: sky costs no noise; one sun term
Some checks failed
CLA Assistant / CLAssistant (pull_request_target) Has been cancelled
Build pull request / Build pull request (pull_request) Has been cancelled
Check PR title / lint (pull_request_target) Has been cancelled
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=<names> selects variants.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 08:08:50 +00:00
Nibu
b2d6d32cc4 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 <noreply@anthropic.com>
2026-11-01 06:37:01 +00:00
Meta Vens
b33d8de956 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 <noreply@anthropic.com>
2026-11-01 02:04:14 +01:00
Meta Vens
a4da78edc5 estate tests: an unfiled room leaves its inviter's list empty, not the map
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 02:00:38 +01:00
Meta Vens
9b66b2b250 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 <noreply@anthropic.com>
2026-11-01 01:52:45 +01:00
Meta Vens
6950da865e estate harness: every drive asserts the outcome it shows and run.sh fails when one does not
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 01:37:35 +01:00
Meta Vens
8f1bb54b4b 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 <noreply@anthropic.com>
2026-11-01 01:35:35 +01:00
e377d9f722 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 <noreply@anthropic.com>
2026-10-31 21:43:40 +00:00
Meta Vens
b20b958762 estate: prettier
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 22:34:49 +01:00
Meta Vens
00ec686b1b estate tests: the server-refusal and invited-room fixtures carry an errcode and a membership
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 22:33:06 +01:00
Meta Vens
873c0e30a2 estate harness: run the s6 route drives last 2026-10-31 22:20:42 +01:00
Meta Vens
7453a0bd32 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 <noreply@anthropic.com>
2026-10-31 22:20:28 +01:00
Krishan
7170a0fcc5
chore: update existing community alias and add server (#3130)
Some checks failed
Deploy to Netlify (dev) / Deploy to Netlify (push) Has been cancelled
2026-10-27 23:42:44 +11:00
Vladimir Rubin
e58468ca5e
chore: fix typo in CONTRIBUTING.md (#3138) 2026-10-26 11:35:24 +11:00
698 changed files with 39009 additions and 1445 deletions

View file

@ -1,3 +1,8 @@
// Airbnb's own bans, kept: a `no-restricted-syntax` entry below replaces the whole list.
const airbnbRestrictedSyntax = require('eslint-config-airbnb-base/rules/style').rules[
'no-restricted-syntax'
];
module.exports = { module.exports = {
env: { env: {
browser: true, browser: true,
@ -62,6 +67,21 @@ module.exports = {
"@typescript-eslint/no-shadow": "error" "@typescript-eslint/no-shadow": "error"
}, },
overrides: [ overrides: [
{
// The one join path: a join is `joinEstateRoom`, which files the DM before it sends the join.
files: ['src/**/*.ts', 'src/**/*.tsx'],
excludedFiles: ['src/app/features/estate/joinEstateRoom.ts', 'src/**/*.test.*'],
rules: {
'no-restricted-syntax': [
...airbnbRestrictedSyntax,
{
selector: "CallExpression[callee.property.name='joinRoom']",
message:
'Join a room through joinEstateRoom (features/estate/joinEstateRoom.ts), the one join path.',
},
],
},
},
{ {
files: ['*.ts'], files: ['*.ts'],
rules: { rules: {

View file

@ -4,12 +4,14 @@ on:
pull_request: pull_request:
types: ['opened', 'synchronize'] types: ['opened', 'synchronize']
concurrency:
group: ${{ github.workflow }}-pr-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs: jobs:
build-pull-request: build-pull-request:
name: Build pull request name: Build pull request
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
PR_NUMBER: ${{github.event.number}}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@ -24,17 +26,3 @@ jobs:
env: env:
NODE_OPTIONS: '--max_old_space_size=4096' NODE_OPTIONS: '--max_old_space_size=4096'
run: npm run build 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

View file

@ -1,36 +0,0 @@
name: 'CLA Assistant'
on:
issue_comment:
types: [created]
pull_request_target:
types: [opened, closed, synchronize]
jobs:
CLAssistant:
runs-on: ubuntu-latest
steps:
- name: 'CLA Assistant'
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
# Beta Release
uses: cla-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# the below token should have repo scope and must be manually added by you in the repository's secret
PERSONAL_ACCESS_TOKEN: ${{ secrets.CLA_PAT }}
with:
path-to-signatures: 'signatures.json'
path-to-document: 'https://github.com/cinnyapp/cla/blob/main/cla.md' # e.g. a CLA or a DCO document
# branch should not be protected
branch: 'main'
allowlist: ajbura,bot*
#below are the optional inputs - If the optional inputs are not given, then default values will be taken
remote-organization-name: cinnyapp
remote-repository-name: cla
#create-file-commit-message: 'For example: Creating file for storing CLA Signatures'
#signed-commit-message: 'For example: $contributorName has signed the CLA in #$pullRequestNo'
#custom-notsigned-prcomment: 'pull request comment with Introductory message to ask new contributors to sign'
#custom-pr-sign-comment: 'The signature to be committed in order to sign the CLA'
#custom-allsigned-prcomment: 'pull request comment when all contributors has signed, defaults to **CLA Assistant Lite bot** All Contributors have signed the CLA.'
#lock-pullrequest-aftermerge: false - if you don't want this bot to automatically lock the pull request after merging (default - true)
#use-dco-flag: true - If you are using DCO instead of CLA

View file

@ -1,60 +0,0 @@
name: Deploy PR to Netlify
run-name: "Deploy PR to Netlify (${{ github.event.workflow_run.head_branch }})"
on:
workflow_run:
workflows: ["Build pull request"]
types: [completed]
jobs:
deploy-pull-request:
name: Deploy pull request
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps:
- name: Download pr number
uses: dawidd6/action-download-artifact@d63b86af1b34672e53c440b1b83979861906bad7 # v24
with:
workflow: ${{ github.event.workflow.id }}
run_id: ${{ github.event.workflow_run.id }}
name: pr
- name: Validate and output pr number
id: pr
run: |
PR_ID=$(<pr.txt)
if ! [[ "${PR_ID}" =~ ^[0-9]+$ ]]; then
echo "::error::pr.txt contains non-numeric content: ${PR_ID}"
exit 1
fi
echo "id=${PR_ID}" >> "${GITHUB_OUTPUT}"
- name: Download artifact
uses: dawidd6/action-download-artifact@d63b86af1b34672e53c440b1b83979861906bad7 # v24
with:
workflow: ${{ github.event.workflow.id }}
run_id: ${{ github.event.workflow_run.id }}
name: preview
path: dist
- name: Deploy to Netlify
id: netlify
uses: nwtgck/actions-netlify@d22a32a27c918fe470bbc562e984f80ec48c2668 # v4.0.0
with:
publish-dir: dist
deploy-message: "Deploy PR ${{ steps.pr.outputs.id }}"
alias: ${{ steps.pr.outputs.id }}
# These don't work because we're in workflow_run
enable-pull-request-comment: false
enable-commit-comment: false
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN_PR }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID_PR_CINNY }}
timeout-minutes: 1
- name: Comment preview on PR
uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
with:
number: ${{ steps.pr.outputs.id }}
message: |
Preview: ${{ steps.netlify.outputs.deploy-url }}
⚠️ Exercise caution. Use test accounts. ⚠️

View file

@ -5,7 +5,10 @@ on:
paths: paths:
- 'Dockerfile' - 'Dockerfile'
- '.github/workflows/docker-pr.yml' - '.github/workflows/docker-pr.yml'
- '.github/workflows/prod-deploy.yml'
concurrency:
group: ${{ github.workflow }}-pr-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs: jobs:
docker-build: docker-build:

View file

@ -5,6 +5,10 @@ on:
paths: paths:
- 'package-lock.json' - 'package-lock.json'
concurrency:
group: ${{ github.workflow }}-pr-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs: jobs:
lockfile_changes: lockfile_changes:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View file

@ -1,39 +0,0 @@
name: Deploy to Netlify (dev)
on:
push:
branches:
- dev
jobs:
deploy-to-netlify:
name: Deploy to Netlify
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup node
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version-file: ".node-version"
package-manager-cache: false
- name: Install dependencies
run: npm ci
- name: Build app
env:
NODE_OPTIONS: '--max_old_space_size=4096'
run: npm run build
- name: Deploy to Netlify
uses: nwtgck/actions-netlify@d22a32a27c918fe470bbc562e984f80ec48c2668 # v4.0.0
with:
publish-dir: dist
deploy-message: 'Dev deploy ${{ github.sha }}'
enable-commit-comment: false
github-token: ${{ secrets.GITHUB_TOKEN }}
production-deploy: true
github-deployment-environment: nightly
github-deployment-description: 'Nightly deployment on each commit to dev branch'
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID_DEV }}
timeout-minutes: 1

View file

@ -1,15 +0,0 @@
name: Check PR title
on:
pull_request_target:
types:
- opened
- edited
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View file

@ -1,100 +0,0 @@
name: Production deploy
on:
release:
types: [published]
jobs:
deploy-and-tarball:
name: Netlify deploy and tarball
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup node
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version-file: '.node-version'
package-manager-cache: false
- name: Install dependencies
run: npm ci
- name: Build app
env:
NODE_OPTIONS: '--max_old_space_size=4096'
run: npm run build
- name: Deploy to Netlify
uses: nwtgck/actions-netlify@d22a32a27c918fe470bbc562e984f80ec48c2668 # v4.0.0
with:
publish-dir: dist
deploy-message: 'Prod deploy ${{ github.ref_name }}'
enable-commit-comment: false
github-token: ${{ secrets.GITHUB_TOKEN }}
production-deploy: true
github-deployment-environment: stable
github-deployment-description: 'Stable deployment on each release'
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID_APP }}
timeout-minutes: 1
- name: Get version from tag
id: vars
run: echo "tag=${GITHUB_REF#refs/*/}" >> $GITHUB_OUTPUT
- name: Create tar.gz
run: tar -czvf cinny-${{ steps.vars.outputs.tag }}.tar.gz dist
- name: Sign tar.gz
run: |
echo '${{ secrets.GNUPG_KEY }}' | gpg --batch --import
# Sadly a few lines in the private key match a few lines in the public key,
# As a result just --export --armor gives us a few lines replaced with ***
# making it useless for importing the signing key. Instead, we dump it as
# non-armored and hex-encode it so that its printable.
echo "PGP Signing key, in raw PGP format in hex. Import with cat ... | xxd -r -p - | gpg --import"
gpg --export | xxd -p
echo '${{ secrets.GNUPG_PASSPHRASE }}' | gpg --batch --yes --pinentry-mode loopback --passphrase-fd 0 --armor --detach-sign cinny-${{ steps.vars.outputs.tag }}.tar.gz
- name: Upload tagged release
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
with:
tag_name: ${{ github.ref_name }}
files: |
cinny-${{ steps.vars.outputs.tag }}.tar.gz
cinny-${{ steps.vars.outputs.tag }}.tar.gz.asc
publish-image:
name: Push Docker image to Docker Hub, GHCR
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up QEMU
uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4.3.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Login to Docker Hub #Do not update this action from a outside PR
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to the Github Container registry #Do not update this action from a outside PR
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker, GHCR
id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: |
${{ secrets.DOCKER_USERNAME }}/cinny
ghcr.io/${{ github.repository }}
- name: Build and push Docker image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

46
.github/workflows/rat-dev-preview.yml vendored Normal file
View file

@ -0,0 +1,46 @@
name: Rat dev preview
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
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup node
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version-file: ".node-version"
package-manager-cache: false
- name: Install dependencies
run: npm ci
- name: Build app
env:
CINNY_BASE: /void/rat-dev/
VITE_PREVIEW_DEMO: '1'
NODE_OPTIONS: '--max_old_space_size=3072'
run: npm run build
- name: Set background scene tier and the pretend homeserver
run: node scripts/preview-config.js low --demo
- name: Add the demo pose art
run: mkdir -p dist/public/poses && cp -r preview-demo/poses/. dist/public/poses/
- name: Upload artifact
# v3 protocol: the v4 client keeps only the origin of ACTIONS_RESULTS_URL, dropping this forge's /forge subpath
# (nginx 404 page, runs 170-171), and upstream v4+ refuses non-GitHub servers outright (run 169).
uses: forgejo/upload-artifact@v3
with:
name: rat-dev-dist
path: dist
retention-days: 3

3
.gitignore vendored
View file

@ -5,3 +5,6 @@ devAssets
.DS_Store .DS_Store
.idea .idea
# Pose art is synced from the cast-art catalogue (npm run sync:poses), not committed.
public/poses/*
!public/poses/.gitkeep

View file

@ -4,3 +4,4 @@ package.json
package-lock.json package-lock.json
LICENSE LICENSE
harness harness
src/app/components/message/pose/catalogue.json

View file

@ -1,8 +1,8 @@
# Contributing to Cinny # Contributing to Ratatoskr
First off, thanks for taking the time to contribute! ❤️ 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 anyone considering opening an **issue**, **discussion** or **pull request**.
For people who are interested in developing Cinny and technical details behind For people who are interested in developing Cinny and technical details behind
it, please check out our ["Developing Cinny"](HACKING.md) document as well. it, please check out our ["Developing Cinny"](HACKING.md) document as well.
@ -24,7 +24,7 @@ explain what your changes do and how they interact with the greater system, do n
## AI Usage ## 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 ## 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% > 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 > 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 > 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 [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. working on it. Thank you. If you need help or guidance, comment on the issue.

View file

@ -1,4 +1,4 @@
# Developing Cinny # Developing Ratatoskr
> [!TIP] > [!TIP]
> We recommend using a version manager as versions change very quickly. > 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: 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: 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. This will forward your `localhost` port 8080 to the container's port 80.

140
README.md
View file

@ -1,125 +1,43 @@
# Cinny # Ratatoskr
<p>
<a href="https://github.com/ajbura/cinny/releases">
<img alt="GitHub release downloads" src="https://img.shields.io/github/downloads/ajbura/cinny/total?logo=github&style=social"></a>
<a href="https://hub.docker.com/r/ajbura/cinny">
<img alt="DockerHub downloads" src="https://img.shields.io/docker/pulls/ajbura/cinny?logo=docker&style=social"></a>
<a href="https://fosstodon.org/@cinnyapp">
<img alt="Follow on Mastodon" src="https://img.shields.io/mastodon/follow/106845779685925461?domain=https%3A%2F%2Ffosstodon.org&logo=mastodon&style=social"></a>
<a href="https://twitter.com/intent/follow?screen_name=cinnyapp">
<img alt="Follow on Twitter" src="https://img.shields.io/twitter/follow/cinnyapp?logo=twitter&style=social"></a>
<a href="https://cinny.in/#sponsor">
<img alt="Sponsor Cinny" src="https://img.shields.io/opencollective/all/cinny?logo=opencollective&style=social"></a>
</p>
A Matrix client focusing primarily on simple, elegant and secure interface. GameCult's Matrix chat client, a fork of [Cinny](https://github.com/cinnyapp/cinny).
The main goal is to have an instant messaging application that is easy on It opens on GameCult's estate (spaces, project rooms grouped by steward, Minds,
people and has a modern touch. and your own place in it) instead of an invite list. Mobile first.
- [Roadmap](https://github.com/orgs/cinnyapp/projects/1)
- [Contributing](./CONTRIBUTING.md)
> [!IMPORTANT] **Live now:** the Cinny engine (matrix-js-sdk, Rust crypto, sync) under a
We are currently in the process of [replacing] the matrix-js-sdk with our Ratatoskr UI: branding, GameCult theme, estate navigation, pose portraits
own SDK. As a result, we will not be accepting any pull requests until (opt-in setting), a background scene (on in the dev config, `backgroundScene.tier`; the device can lower it) and an opt-in soundscape.
further notice. Thank you for your understanding. **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).
<img align="center" src="https://raw.githubusercontent.com/cinnyapp/cinny-site/main/assets/preview2-light.png" height="380"> ## 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
<https://github.com/cinnyapp/cinny>. We do not ship its releases or images.
## Getting started ## Developing
The web app is available at [app.cinny.in] and gets updated on each new See [HACKING.md](HACKING.md). Contribution notes are in [CONTRIBUTING.md](CONTRIBUTING.md).
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.
## Self-hosting ## Self-hosting
To host Cinny on your own, simply download the tarball from Build with `npm ci && npm run build` and serve `dist/` with your webserver.
[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). * The default homeservers and explore pages are defined in [`config.json`](config.json).
* Serving the assets needs redirects to `index.html`. Examples:
* You need to set up redirects to serve the assests. Example configurations; [netlify](netlify.toml), [nginx](contrib/nginx/ratatoskr.domain.tld.conf),
[netlify](netlify.toml), [nginx](contrib/nginx/cinny.domain.tld.conf), [caddy](contrib/caddy/caddyfile). Without them you can
[caddy](contrib/caddy/caddyfile). [enable hash routing](config.json) (`hashRouter`); URLs then carry `/#/`.
* If you have trouble configuring redirects you can * To deploy on a subdirectory, rebuild with the `base` path set, either in
[enable hash routing](config.json#L35) — the url in the browser will have [`build.config.ts`](build.config.ts) or through `CINNY_BASE`
a `/#/` between the domain and open channel (ie. `app.cinny.in/#/home/` (for example `CINNY_BASE=/app/ npm run build`).
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
<details><summary><b>PGP Public Key to verify tarball</b></summary>
```
-----BEGIN PGP PUBLIC KEY BLOCK-----
mQGNBGJw/g0BDAC8qQeLqDMzYzfPyOmRlHVEoguVTo+eo1aVdQH2X7OELdjjBlyj
6d6c1adv/uF2g83NNMoQY7GEeHjRnXE4m8kYSaarb840pxrYUagDc0dAbJOGaCBY
FKTo7U1Kvg0vdiaRuus0pvc1NVdXSxRNQbFXBSwduD+zn66TI3HfcEHNN62FG1cE
K1jWDwLAU0P3kKmj8+CAc3h9ZklPu0k/+t5bf/LJkvdBJAUzGZpehbPL5f3u3BZ0
leZLIrR8uV7PiV5jKFahxlKR5KQHld8qQm+qVhYbUzpuMBGmh419I6UvTzxuRcvU
Frn9ttCEzV55Y+so4X2e4ZnB+5gOnNw+ecifGVdj/+UyWnqvqqDvLrEjjK890nLb
Pil4siecNMEpiwAN6WSmKpWaCwQAHEGDVeZCc/kT0iYfj5FBcsTVqWiO6eaxkUlm
jnulqWqRrlB8CJQQvih/g//uSEBdzIibo+ro+3Jpe120U/XVUH62i9HoRQEm6ADG
4zS5hIq4xyA8fL8AEQEAAbQdQ2lubnlBcHAgPGNpbm55YXBwQGdtYWlsLmNvbT6J
AdQEEwEIAD4CGwMFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AWIQSRri2MHidaaZv+
vvuUMwx6UK/M8wUCZqEDwAUJFvwIswAKCRCUMwx6UK/M877qC/4lxXOQIoWnLLkK
YiRCTkGsH6NdxgeYr6wpXT4xuQ45ZxCytwHpOGQmO/5up5961TxWW8D1frRIJHjj
AZGoRCL3EKEuY8nt3D99fpf3DvZrs1uoVAhiyn737hRlZAg+QsJheeGCmdSJ0hX5
Yud8SE+9zxLS1+CEjMrsUd/RGre/phme+wNXfaHfREAC9ewolgVChPIbMxG2f+vs
K8Xv52BFng7ta9fgsl1XuOjpuaSbQv6g+4ONk/lxKF0SmnhEGM3dmIYPONxW47Yf
atnIjRra/YhPTNwrNBGMmG4IFKaOsMbjW/eakjWTWOVKKJNBMoDdRcYYWIMCpLy8
AQUrMtQEsHSnqCwrw818S5A6rrhcfVGk36RGm0nOy6LS5g5jmqaYsvbCcBGY9B2c
SUAVNm17oo7TtEajk8hcSXoZod1t++pyjcVKEmSn3nFK7v5m3V+cPhNTxZMK459P
3x1Ucqj/kTqrxKw6s2Uknuk0ajmw0ljV+BQwgL6maguo9BKgCNW5AY0EYnD+DQEM
ANOu/d6ZMF8bW+Df9RDCUQKytbaZfa+ZbIHBus7whCD/SQMOhPKntv3HX7SmMCs+
5i27kJMu4YN623JCS7hdCoXVO1R5kXCEcneW/rPBMDutaM472YvIWMIqK9Wwl5+0
Piu2N+uTkKhe9uS2u7eN+Khef3d7xfjGRxoppM+xI9dZO+jhYiy8LuC0oBohTjJq
QPqfGDpowBwRkkOsGz/XVcesJ1Pzg4bKivTS9kZjZSyT9RRSY8As0sVUN57AwYul
s1+eh00n/tVpi2Jj9pCm7S0csSXvXj8v2OTdK1jt4YjpzR0/rwh4+/xlOjDjZEqH
vMPhpzpbgnwkxZ3X8BFne9dJ3maC5zQ3LAeCP5m1W0hXzagYhfyjo74slJgD1O8c
LDf2Oxc5MyM8Y/UK497zfqSPfgT3NhQmhHzk83DjXw3I6Z3A3U+Jp61w0eBRI1nx
H1UIG+gldcAKUTcfwL0lghoT3nmi9JAbvek0Smhz00Bbo8/dx8vwQRxDUxlt7Exx
NwARAQABiQG8BBgBCAAmAhsMFiEEka4tjB4nWmmb/r77lDMMelCvzPMFAmahA9IF
CRb8CMUACgkQlDMMelCvzPPQgQv/d5/z+fxgKqgfhQX+V49X4WgTVxZ/CzztDoJ1
XAq1dzTNEy8AFguXIo6eVXPSpMxec7ZreN3+UPQBnCf3eR5YxWNYOYKmk0G4E8D2
KGUJept7TSA42/8N2ov6tToXFg4CgzKZj0fYLwgutly7K8eiWmSU6ptaO8aEQBHB
gTGIOO3h6vJMGVycmoeRnHjv4wV84YWSVFSoJ7cY0he4Z9UznJBbE/KHZjrkXsPo
N+Gg5lDuOP5xjKzM5SogV9lhxBAhMWAg3URUF15yruZBiA8uV1FOK8sal/9C1G7V
M6ygA6uOZqXlZtcdA94RoSsW2pZ9eLVPsxz2B3Zko7tu11MpNP/wYmfGTI3KxZBj
n/eodvwjJSgHpGOFSmbNzvPJo3to5nNlp7wH1KxIMc6Uuu9hgfDfwkFZgV2bnFIa
Q6gyF548Ub48z7Dz83+WwLgbX19ve4oZx+dqSdczP6ILHRQomtrzrkkP2LU52oI5
mxFo+ioe/ABCufSmyqFye0psX3Sp
=WtqZ
-----END PGP PUBLIC KEY BLOCK-----
```
</details>
## License ## License
Cinny Project Ratatoskr is a fork of the Cinny project
Copyright © 2024–present Ajay Bura Copyright © 2024–present Ajay Bura
https://cinny.in https://cinny.in

View file

@ -1,21 +1,40 @@
{ {
"defaultHomeserver": 1, "defaultHomeserver": 0,
"homeserverList": ["converser.eu", "matrix.org", "mozilla.org", "unredacted.org", "xmr.se"], "homeserverList": [
"allowCustomHomeservers": true, "chat.gamecult.org"
],
"allowCustomHomeservers": false,
"featuredCommunities": { "featuredCommunities": {
"openAsDefault": false, "openAsDefault": false,
"spaces": ["#gamecult:gamecult.org"], "spaces": [
"rooms": [ "#gamecult:gamecult.org"
"#tuwunel:grin.hu",
"#freesoftware:matrix.org",
"#gentoo:matrix.org"
], ],
"servers": ["matrixrooms.info", "matrix.org", "mozilla.org", "unredacted.org"] "rooms": [],
"servers": [
"gamecult.org"
]
}, },
"hashRouter": { "hashRouter": {
"enabled": false, "enabled": true,
"basename": "/" "basename": "/"
},
"backgroundScene": {
"tier": "full",
"style": "ridge"
},
"dings": {
"enabled": false
},
"fileZoneShare": {
"enabled": false
},
"push": {
"vapidPublicKey": "",
"gatewayUrl": "",
"appId": ""
},
"registration": {
"enabled": false,
"url": ""
} }
} }

View file

@ -1,6 +1,6 @@
# more info: https://caddyserver.com/docs/caddyfile/patterns#single-page-apps-spas # more info: https://caddyserver.com/docs/caddyfile/patterns#single-page-apps-spas
cinny.domain.tld { ratatoskr.domain.tld {
root * /path/to/cinny/dist root * /path/to/ratatoskr/dist
try_files {path} / index.html try_files {path} / index.html
file_server file_server
} }

View file

@ -1,7 +1,7 @@
server { server {
listen 80; listen 80;
listen [::]:80; listen [::]:80;
server_name cinny.domain.tld; server_name ratatoskr.domain.tld;
location / { location / {
return 301 https://$host$request_uri; return 301 https://$host$request_uri;
@ -15,10 +15,10 @@ server {
server { server {
listen 443 ssl http2; listen 443 ssl http2;
listen [::]:443 ssl; listen [::]:443 ssl;
server_name cinny.domain.tld; server_name ratatoskr.domain.tld;
location / { location / {
root /opt/cinny/dist/; root /opt/ratatoskr/dist/;
rewrite ^/config.json$ /config.json break; rewrite ^/config.json$ /config.json break;
rewrite ^/manifest.json$ /manifest.json break; rewrite ^/manifest.json$ /manifest.json break;

View file

@ -0,0 +1,34 @@
schema = "gamecult.idunn.target_declaration.v1"
target = "ratatoskr"
# Idunn stamps the source commit into the build environment under this name. The release is not built from source: the
# dist tarball below is built by scripts the release owner runs (build-prod-dist.sh) and enters pinned by sha256, so
# each release is a recipe commit whose pin is its provenance. The stamp the served index carries is the commit the
# tarball was built from, which is not this commit.
source_stamp_environment = "RATATOSKR_BUILD_COMMIT"
# Release dist 934f82386d612c3e056f851555c7c9dfd2bdf5db: the output of build-prod-dist.sh (ALLOW_CONFIG_DRIFT=1), packed
# as dist/ at the top of one gzip tar. The tree carries the repo's own config.json; what is served is the operator's
# carried file, which Idunn copies beside dist/ and the vhost aliases over the tree's (a vhost without that alias would
# serve the repo config).
[[external_inputs]]
id = "dist-tarball"
url = "https://eureka.gamecult.org/forge/GameCult/Ratatoskr/releases/download/dist-934f8238/ratatoskr-934f8238-dist.tar.gz"
sha256 = "55e7be0622a4eddb81008bc0132e3d476bdb3c453e278599d5a0259a76d00c5b"
runner = "unpack"
destination = "release/dist.tar.gz"
[[steps]]
id = "unpack-dist"
phase = "prepare"
runner = "unpack"
argv = ["tar", "-xzf", "release/dist.tar.gz", "-C", "release"]
[[artifacts]]
id = "dist"
source_kind = "runner-output"
runner = "unpack"
source = "release/dist"
destination = "dist"
[static_release]
artifact = "dist"

291
docs/commons-files-desk.md Normal file
View file

@ -0,0 +1,291 @@
# Files desk: Commons and per-Persona file zones, two designs compared
Status: **decided: Option A, Matrix as the single authority** (Meta, #swarm 2026-11-13 14:44Z: "Let's try matrix shaped"). Design only; no Files desk code is merged and nothing is deployed. Option B (the host service) is parked, not deleted: Eureka's branch `eureka-self/commons-host` at `a391960d` has no PR and a Soul verdict of NOT READY with fixes owed, and is woken only if Meta later wants a real file store. PR #143 (B's cut 1 in this repo) is closed unmerged. Items marked **UNKNOWN** are not verified; **Q-E** are questions for Eureka, **Q-M** for Meta.
### Eureka's answers (`/opt/commons/handoff/commons-matrix-notes-20261113.md`, read 2026-11-13; final answers folded in 2026-11-13)
- **Upload cap (Q-E1, read).** Synapse 1.164.1; the repo template `homeserver.yaml` has `max_upload_size: 50M` and the nginx vhost `client_max_body_size 50m`; the live runtime file is root-only and was not read. No per-room or per-user per-file cap exists; `media_upload_limits` is a per-user time-window quota. `GET /_matrix/client/v1/media/config` returns `m.upload.size` to an authenticated client, which is what the client reads. Raising the cap takes the config line, a Synapse restart and nginx at least as large.
- **Posting (Q-E1, read).** A Self holds no Matrix credentials, so the publisher needs its own bot account and token (a MAS compatibility token via `mas-cli`). It uploads with `POST /_matrix/media/v3/upload` and sends `m.room.message` with `msgtype` `m.file` or `m.image`. The Persona mouth sends `m.text` only today, so file events are new for it too.
- **Room (Q-E3, answered).** The Commons room is `#commons:gamecult.org` (create type `org.gamecult.file_zone`, unencrypted, membership by the estate Space, publisher bot at power level 50). Provisioning and push muting are Eureka's.
- **Cap (Q-E1, answered).** 50 MiB per file. The client reads it live from `m.upload.size`, not from this document.
- **Poster (Q-E1, answered).** The publisher posts as the owning Mind: each file appears under the Mind whose tree it came from, not under a generic bot.
- **Final marker (Q-E2, answered).** A handoff document is final when a sibling `NAME.final` holds its sha256; the publisher watches for that, posts once, and carries `org.gamecult.publish.replaces` (prior event id and sha256) on a later final version.
- **Range (Q-E4, unverified).** Whether Synapse media honours `Range` has not been checked against a live mxc. Playback therefore stays on the blob download until it is.
- **Edited-doc stale copies:** a redacted file's media persists unless purged; the publisher dedupes by sha256.
Source: Meta, #swarm 2026-11-13 14:28Z and 14:40Z; ruling `ratatoskr:ruling:persona-file-sharing-zone` (a browsable, previewing collection per Persona; the Self writes to her outbox, her Persona decides what is shared, the Self never holds Matrix credentials).
## What exists on Matrix today (the base A must fit)
Read from this repo at dev `8ba407cd`:
- **Zone = a room** whose `m.room.create` type is `org.gamecult.file_zone` (`isFileZone.ts`), or one listed in config `fileZoneRooms`. One per Mind, `#<mind>-files:<server>`, found by alias or by the carried id in the root Space's `org.gamecult.stewardships` (`mediaRoom.ts`, `MediaRow.tsx`); rendered as the Media row of each Mind's group.
- **Album = a thread root** (a text event; its `body`, or its latest `m.replace` edit, is the title); **file = a thread reply** with `msgtype` `m.audio|m.image|m.video|m.file` (`buildAlbums.ts`). A redacted event or an `m.replace` is never a file. Loose room messages are not shown.
- **Read** = `GET /rooms/{id}/threads` and `/relations/{root}/m.thread`, every page, 100 per request, at most 20 pages per endpoint (`useAlbums.ts`, `readPages.ts`; a capped read shows a note in the list). Unencrypted rooms only.
- **Write** = `sendShareSet` (`shareSet.ts`, behind `fileZoneShare.enabled`): a root, then one thread reply per file, content from `getMsgContent` with `info.sha256`.
- **Media** = authenticated Matrix media fetched to a blob (`downloadMedia` in `FileContent.tsx`): so the "media element cannot send a header" problem that Option B needed a ticket for is already solved this way, at the cost that a file is fully downloaded before it plays.
- **Receipts** = `org.gamecult.file_received` from a Mind's mouth after it fetches and verifies the sha256 (`docs/file-zone-mind-contract.md`); the mouth enforces the Mind's membership in the room.
Option A is an extension of this, not a second system.
## The two options
**A. Matrix is the only authority.** Commons is one more file-zone room; each Mind's zone is the room she already has. Audience is room membership. Upload is the ordinary Matrix upload. Folders are albums (thread roots). A small publisher service with its own Matrix account posts allowlisted handoff documents into the Commons room. The Files desk is a view over file events in those rooms.
**B. The host service.** A file host on Yggdrasil serves allowlisted directories (`GET /index`, `GET /file`, a capped `PUT`), authenticated by Matrix OpenID; the client browses it as a tree. Written out in full below the line.
| | A: Matrix only | B: host service |
| -------------------------- | ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------- |
| Authorities | one (Matrix: identity, audience, storage, audit) | two (Matrix for identity, the host for files and access) |
| Audience | room membership and power levels | the host derives a role from Matrix identity and enforces it again |
| Audit of writes | the event `sender`, kept by the homeserver | the host's own log |
| Overwriting someone's file | impossible: events are immutable and only the sender's own edit counts; a redaction is a steward's act | host rule, host code |
| Upload size cap | the homeserver's `m.upload.size`, global, 50 MiB (Eureka) | the host's own, chosen freely |
| Type allowlist | not enforceable by the homeserver; client-side courtesy, steward redaction | enforced by the host (sniffed) |
| What is browsable | what was posted (published or uploaded) | what is on disk under the allowlist, live |
| Mind-written handoff docs | a publisher posts them once, when final (below) | the host serves the tree |
| Per-Persona zone | the existing Media room; the Self's outbox reaches it through the mouth, as the ruling says | a `shared/` directory the host serves |
| Folders | albums; nesting by path in the album title | real directories |
| Mind fetch | unchanged (mouth, room id and event id, receipts) | a second route the mouth would need |
| Media playback | blob download, as today; no seeking before the whole file arrives | ranges and a ticket, or the same blob |
| New moving parts | a room, a publisher | a host service, OpenID verification, CORS, tickets, path containment, rate limits |
## Recommendation: A (decided)
Confidence: high on the authority question (it agrees with Meta's ruling, with Eureka's lean, with the standing ruling that the Self never holds credentials while her Persona decides what is shared, and with the fact that everything built so far reads Matrix); moderate on the details below, which Eureka's answers can change.
What A gives up, stated plainly:
- **A live view of what is on disk.** Only what was published or uploaded appears. A handoff doc that was never marked final is not in the Commons.
- **Files over the homeserver's upload cap.** B had no Matrix limit; A has exactly one.
- **A server-side type allowlist.** Under A a member can upload any type the homeserver accepts; the client shows a refusal for types it does not want, and stewards redact. If a strict allowlist matters, a gating bot (post, check, redact) would have to exist, and it is a second authority again.
- **Seeking inside a large audio or video file** until Matrix media supports ranges for the client (**UNKNOWN**; the blob route is what runs today).
- **Hierarchy beyond the album level** as a native structure; see Folders.
What it buys: one identity, one audience, one audit trail, immutable files, existing receipts and the mouth's fetch, and no new service to keep secure.
## Option A in detail
**Rooms.** Commons is a room with create type `org.gamecult.file_zone`, alias `#commons:gamecult.org`, join rule `restricted` to the estate Space so members join without an invite and Minds are invited, history `shared`, unencrypted (the zones are, and `info.sha256` is written only there). Power levels: `events_default` 0 so members post; `redact` and `state_default` at steward level. The room is muted for push by provisioning: file posts are thread replies, which can raise unread counts and notifications (**UNKNOWN** under this server's push rules), so the room must not shout. Per-Persona zones are the rooms Eureka already provisions; nothing new.
**Folders.** Four ways to have folders on Matrix, in order of preference:
1. _Albums are folders; nesting is a path in the album title_ (`handoff/2026-11`, `cast-art/poses`). Zero wire change: `buildAlbums` already reads it, and the client groups albums by title path as a pure view. Cost: a thread root per folder, and the rule that a folder's root is posted once by whoever opens the folder. **Recommended.**
2. A `org.gamecult.file.path` key on each file event. Gives a real tree with files anywhere; needs every poster to write it and every client to honour it; other Matrix clients ignore it and show a flat thread.
3. A room per folder, in a Space. Native hierarchy and per-folder audience; heavy (a room is a membership, a sync entry and provisioning each) and it multiplies the sync load on a phone.
4. A state event per folder (`org.gamecult.file_folder`). Mutable folder metadata (title, order), writable only at steward level; useful later if folders need descriptions, not needed for a first cut.
**Albums in each Mind's room** are the same mechanism: a Mind's Media room holds that Mind's albums, the Commons holds the shared ones. The Files desk is the same view over either room; a zone differs only by room, not by code.
**The publisher.** A small service on Yggdrasil with its own Matrix account (a bot account that posts as the owning Mind, per Eureka's answer above). The Self never holds this account; the publisher holds it. It owns the allowlist.
- **When it posts.** Handoff documents are edited in place, so mirroring every save would spam the room with drafts. The rule (Eureka's lean, adopted): **post once, when the document is marked final**; a later final version posts again with a `replaces` relation to the earlier event. "Final" is a sibling `NAME.final` holding the document's sha256 (answered Q-E2); the client must not know.
- **Allowlist.** Lives on the publisher: a list of source roots and patterns, read by the publisher only. `handoff-private` is not on it and the publisher never lists, reads or posts from it. The client holds no path to anything on disk.
- **The event.** A file event in the thread of the folder's album, built the way `getMsgContent` builds them (`msgtype`, `body`, `filename`, `url` from the media repo, `info.mimetype|size|sha256`), plus one extra key `org.gamecult.publish`: `{ source, sha256, replaces? }` where `source` is a path relative to the allowlisted root, never absolute, and `replaces` is the event id of the version it supersedes.
- **`replaces`, not `m.replace`.** `m.replace` is the stock edit relation, but Ratatoskr's `buildAlbums` deliberately never treats an edit as a file, several clients do not render an edited file, and an edit hides the earlier version. A custom key keeps both events as files; the view shows the latest of a chain and a "N earlier versions" row under it, and other Matrix clients see two ordinary file posts. A chain is read from the events themselves (each carries its `replaces`), not from a table, and a `replaces` counts only when its event has the same sender as the event it names, so nobody else in the thread can fold a file away.
- **Idempotence.** The publisher's own state (a typed CultCache document of `source`, `sha256`, `event_id`) says what it posted; if that is lost, it rebuilds by reading its own events back from the room (`org.gamecult.publish.source` plus `sha256`) rather than guessing. The same `sha256` never posts twice.
- **Receipts and fetch** work unchanged: the event is an ordinary file event in a file-zone room, so a Mind's mouth can fetch it by room id and event id (5 MiB cap there) and tick `fetched for`.
**Uploads (approved for members and Minds).** The ordinary Matrix upload, through the composer pipeline and `ShareSet`. The host rules Eureka gave map onto Matrix as follows:
| Host rule | Under A |
| ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- |
| only under an `uploads/` subtree | an album (folder) named `uploads/...`; the client offers upload only there (and in a Mind's own zone) |
| never overwrite Mind-written handoff files | free: other people's events cannot be replaced; a duplicate name is just another post in the thread |
| size cap | the homeserver's `m.upload.size`, shown from `MediaConfigLoader`; the client refuses a larger file before sending |
| type allowlist | client-side only; see "gives up" |
| explicit flag to overwrite | no overwrite exists; a new version is a new post, optionally with `replaces` set by the same sender |
| audit log with the Matrix id | the event `sender` |
What A adds to `ShareSet`: sharing into an existing album ("add files", listed as not built in `docs/file-zone.md`), progress per file, cancel, and per-error refusal messages written against the SDK's `MatrixError` (`M_TOO_LARGE`, `M_FORBIDDEN`, `M_LIMIT_EXCEEDED`) instead of host codes. The draft refusal table of Option B's upload flow carries over with those codes. **UNKNOWN:** whether the SDK exposes upload progress and abort for media (it does for `uploadContent` with a progress callback; the composer already shows progress, `state/upload.ts`).
## What each costs on the phone
Under both, the phone design is the same shape (one column, rows 56 px or more, hidden controls, in-place preview; see the Option B sections below, which are written against the host but whose layout applies).
- **A, new cost:** the Commons room is one more room in sync and it must be muted; albums and files are read to a cap of 20 pages of 100 per endpoint (a Commons past that shows a truncation note); a folder tree is a title-path grouping, so a deep tree is a long list of albums with breadcrumbs, not true directories; a file plays after a full download (small audio and images are fine; a 40 MB clip is not).
- **A, no cost:** no second sign-in, no ticket, no CORS, no new fetch layer, no host-unreachable state beyond the homeserver's own. The album list, album view, share panel and receipts exist and are tested; the view is their extension.
- **B, new cost:** every screen is a second client against a second service with its own errors, token refresh and unreachable state; media needs the ticket or the blob; the tree and preview are new UI.
- **B, benefit:** a tree that is live and complete, ranges, and no Matrix size limit.
## Changes to Eureka's host work
Under A the paused service is not built, and most of its list disappears:
- **Dropped:** `/index`, `/file`, `PUT`, OpenID verification pinned to the server name, CORS, path containment and sniff rules, tickets, the `uploads/` write rules, the role response, the per-path audit log (Q-E3, Q-E4, Q-E6, Q-E7, Q-E8 to Q-E11 of the Option B list).
- **Kept:** the Commons allowlist decision (Q-E1 of that list), moved to the publisher: which roots, and that `handoff` is only ever published through the final-marker rule, never browsed.
- **New:** (1) provision the Commons room (type, alias, restricted join to the estate Space, power levels, push muted); (2) the publisher: account, allowlist, final-marker rule, media upload, posting into folder threads, the `org.gamecult.publish` key, idempotence from its own events; (3) confirm the homeserver upload cap and whether the account may post file events from Yggdrasil (**Q-E1**, asked on the PR); (4) the per-Persona path stays the one already specified (`album-outbox-ops`: outbox, Persona decision, mouth post), so the `shared/` convention of Option B is retired.
## Cuts under A
Status 2026-11-14 (dev da822e9e plus the size-refusal cut): built: folders by album title path with navigation and breadcrumb (#147), pagination (#148), version folding (#149), add-to-album with named refusals (#150), Stop for a send in flight, replaces loops shown, freshest last event on add (#151), and the pre-send size refusal: a file larger than the server's `m.upload.size` is named and left unsent before any upload or album root, and a server stating no cap refuses nothing. **Not built: the Commons entry row** (item 1 below, found by `#commons:gamecult.org` or the carried `media.commons` id through `lookupMediaRoom`, behind a dark `filesDesk.enabled` flag; neither the row nor the flag exists in the code). It waits on Eureka's Commons room.
1. **Commons entry**: a Commons row in the navigator (like `MediaRow`, found by alias or carried id), opening the room's album view. Flagged, no new data layer.
2. **Pagination** of albums and files in `useAlbums` (done, PR #148).
3. **Folders by title path** and the album list grouped by it.
4. **Versions**: read `org.gamecult.publish.replaces`, show the latest of a chain with the earlier versions folded under it (done, PR #149).
5. **Add files to an existing album**, with per-file progress, cancel and the refusal messages above (the upload flow). Done: adding to an open album and the three refusal messages (PR #150); Stop for a send in flight is in too (PR for follow-up `files-desk-release-146-and-send-cancel`): it cancels the running upload through `onPromise` and `mx.cancelUpload`.
PR #143 (Option B cut 1: contract types, host client, fake host) is **held**; under A it is not needed and would be closed. Its Soul pass found, and a revived B would have to close, an unvalidated `host` that could send the bearer to the wrong origin, hostile-looking entry names beyond separators, loose numeric checks and missing CSP and `Content-Disposition` headers in the fake.
## Not in scope
The Mind-side fetch (the mouth's); encrypted rooms; deletion and rename by this client; search across zones; any permission model of our own beyond Matrix room membership and power levels.
## Open questions
**Eureka:** answered; see "Eureka's answers" at the top. Still open on Eureka's side: Range support (Q-E4) and the live runtime `max_upload_size`.
**Meta:**
- **Q-M1.** Commons audience: members and Minds as proposed; Users and Patrons never, or later?
- **Q-M2.** Is a Persona's zone visible to all members or to her stewards and Minds? (The Matrix zone's audience question, `ratatoskr:question:file-zone-audience`, is the same fork.)
- **Q-M3.** May Users or Patrons upload if they are ever let in?
- **Q-M4.** Names: "Files" for the view, "Commons" for the shared room.
- **Q-M5.** A type allowlist is not enforceable under A without a gating bot; is a client-side refusal plus steward redaction enough?
---
# Option B in full (kept for the comparison; superseded in part by Option A above)
The sections below were written against the host service. Where they speak of a host, a role, a ticket, a token, `/index` or `/file`, they are Option B. The phone layout, the zone table and the refusal wording carry over to A with the Matrix equivalents given above.
### What exists, and what this adds
| | Matrix file zone (`docs/file-zone.md`, built) | Files desk (this page) |
| -------- | ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| Storage | Matrix media in a `#files-<mind>` room, albums as threads | Directories on Yggdrasil, served by a small host |
| Listing | thread roots, `buildAlbums` | `GET /index?path=` |
| Unit | album of posted files | file in a directory tree |
| Strength | marks (keep / fix / drop), receipts (`fetched for`), any Matrix client works | whole trees without N uploads, no Matrix size limit, `/opt/commons` material becomes visible to people on a phone |
| Entry | Media row per Mind (`MediaRow.tsx`) | Files row (below) |
They do not compete. The Matrix zone stays where a file needs a mark or a receipt (Aqua's listening kits); the Files desk is for browsing and fetching what is already on disk. Neither replaces the other, and the Files desk does not write Matrix events. The ruling left "Matrix media plus a listing versus a served directory" open; this page is the served-directory half, built beside the Matrix half already live.
### Zones (host)
| Zone | Contents | Who sees it |
| ---------------- | ------------------------------------------------------------ | -------------------------------------------------------------- |
| Commons | a curated tree of `/opt/commons` | members and Minds |
| Persona `<slug>` | what that Persona has chosen to share from her Self's outbox | **Q-M:** all members, or the Persona's stewards and Minds only |
| handoff-private | `/opt/commons/handoff-private` (0750) | never: not a zone, not listed, not a path the host resolves |
Layout of `/opt/commons` today (read 2026-11-13): `backlog` (755, per-Mind backlog md and summary.json), `bin`, `cast-art` (setgid, group-writable: poses, busts), `handoff` (setgid, group-writable, 92 entries of briefs, patches, receipts, dings, kits), `handoff-private` (0750, ACL), `opt` (toolchains, root-owned), `situation` (0750, setgid), all `nobody:nogroup`. **`handoff` is not a publishable tree:** it holds ops notes, custody receipts (`bifrost-appsecret-custody-receipt-*`) and patches written for Minds, with no sticky bit. Serving it wholesale shows secrets-adjacent notes to every member and lets any Mind with group write change what a phone user sees. The Commons zone is therefore an allowlist of roots (candidate: `cast-art`, `backlog`, and a new `commons/` directory Minds publish into on purpose), not `/opt/commons` itself. **Q-E:** which roots; whether `handoff` ever, and if so through a reviewed `published/` subdirectory.
Per-Persona source is the outbox from `ratatoskr:follow_up:album-outbox-ops-and-persona-specs`: `/var/lib/mind-<slug>-outbox`, owned by `mind-<slug>-self`, 0750, ACL read for `mind-<slug>` and the host user. The Self writes there; the Persona decides what is shared. Making that decision a filesystem fact (a `shared/` subdirectory the Persona's turn populates by copy or hardlink, the host serves only `shared/`) keeps the host dumb: it never learns what "decides" means. **Q-E:** `shared/` subdirectory versus a per-file marker.
### Phone-first UI (host)
Phone width is the design width; desktop adds the navigator beside it (as `Desk` does in `Router.tsx`). Vertical space is cheap, so a screen shows one thing and hides what is not in use.
- **Entry.** A `Files` row pinned in the navigator beside `DeskEntry` and `MindMedia`: one at the estate level (Commons) and one inside each Mind's group (her zone), same `NavItem` anatomy as `DeskEntry`, dim when the zone has no items. Route `/files/:zone/*path`, sibling of `DESK_PATH`.
- **List.** One column. Row = icon or thumbnail, name (truncates in the middle, keeping the extension), size and age on a second dim line. Directories first. Rows are at least 56 px tall; the tap target is the whole row (the `PhoneHit` variants and the repo-wide guard test from `phoneHit.coverage.test.ts` apply to any IconButton inside).
- **Hidden until needed.** Breadcrumb is one line (current folder, a back arrow; the full path on tap). Search, sort and the grid toggle sit behind a single icon in the header and open inline above the list. Download and share appear on the open file, not on every row. Nothing is permanently stacked in the header.
- **Preview.** Tap a file: it opens in place below its row, full width, and pushes the rows below it down (vertical room is the cheap axis). Image: fitted, tap for the existing `ImageViewer`. Audio: the `AudioContent` controls rehosted (`renderMediaControl`), one player at a time, a mini-player pinned to the bottom only while something plays. Video: native controls. Text, md, json, patch: monospace, first 64 KiB, "show all" below. PDF: open in a new tab. Anything else: name, size, hash, one Download button.
- **Honesty rows.** Loading, empty, "host unreachable", "not allowed" are rows that say so (as `MediaNote` does); no spinner without a name for what it waits on.
- **Role-driven.** The index response carries the caller's role; the client uses it only to hide controls (upload), never to decide access.
### Host contract
The host is a service on Yggdrasil that Eureka owns. The client holds no filesystem knowledge. All JSON here is the xenos boundary between a browser and a file server, not state.
- `GET /index?path=<p>` returns `{ role, zone, path, entries: [{ name, kind: "file"|"dir", size, mtime, mime, hash }] }`. `mtime` is unix seconds. `hash` is the lower-case hex sha256 of the plaintext bytes, absent for directories, and may be absent for a file whose hash is not computed yet (client shows nothing, never blocks on it). `mime` is sniffed by the host from magic bytes, not guessed from the extension.
- `GET /file?path=<p>` streams the bytes with `Accept-Ranges: bytes` and 206 for `Range` (audio and video seek; a phone on mobile data does not fetch 40 MB to play 10 seconds). `Content-Type` is the sniffed mime, `X-Content-Type-Options: nosniff`, `Content-Disposition: attachment` for anything that can run in the origin (`html`, `svg`, `js`, `xhtml`), and a `Content-Security-Policy: sandbox` on every response.
- `path` is `<zone>/<relative path>`, with the zone one of the names the host announces. The host resolves the real path of every request and refuses anything that leaves the zone's root: `..`, absolute paths, NUL, symlinks that cross the root, dotfiles, and `handoff-private`, which is not on any allowlist. Errors are typed (`401 unauthenticated`, `403 zone not allowed`, `404`, `416`), and a path outside the allowlist is 404, not 403, so it says nothing about what exists.
- **Auth: Matrix OpenID.** The client calls `mx.getOpenIdToken()` (already used in `CallWidgetDriver.ts`) and sends it as `Authorization: Bearer <token>`. The host verifies it against the homeserver's federation `openid/userinfo` endpoint, **pinned to our server name** (an `@void:evil.example` token must fail), caches the verified `sub` for the token's lifetime, and derives the role. It never sees the user's access token, and a Mind's Self never holds a token (ruling above): Minds reach the same bytes through the filesystem or the mouth, not this host.
- **Role** in the response: `member`, `mind`, `steward` (host decides from the Matrix id and the room/space membership the host reads; the client trusts none of it for access). **Q-E:** exact source of membership (estate Space membership read by the host with a service account, versus the Persona catalogue file for Minds).
- **Uploads** (Meta approved for members and Minds, #swarm 2026-11-13 14:30Z; rules are Eureka's, below). `PUT /file?path=<p>` writes only under an `uploads/` subtree of a zone, with `Content-Length` required; the host enforces a size cap, a type allowlist (sniffed, not the client's claim), no overwrite unless the request carries an explicit flag, and never replaces a file a Mind wrote into the handoff tree; every write is audit-logged with the caller's Matrix id. Reads cover the whole allowlisted tree, so an uploaded file is listed like any other. Success is `201` with the stored name, size and hash. The `role` in the index response gates the upload control; the host gates the write.
### The one problem the contract leaves: media elements cannot send headers
`<audio src>`, `<video src>` and `<img src>` cannot attach `Authorization`. Options, none free:
1. **Fetch to a blob.** Works with the contract as written. No seeking before the whole file is in memory; bad for a 40 MB clip on a phone. Fine for images and small audio.
2. **Short-lived path ticket.** `POST /ticket?path=` with the bearer token returns a URL with an unguessable token valid for a few minutes for that one path and its ranges. The player gets a normal URL and seeks. One more endpoint on the host and one more thing to expire and log.
3. **Service worker adds the header** to `/files-host/*` requests. The PWA already has a worker (`src/sw.ts`); no host change, but the worker must carry a fresh OpenID token and range requests through it are fiddly.
Recommendation: 2 for audio and video, 1 for images and text. Confidence moderate: I have not tested a service worker against range requests on the target phone, and 2 is the usual answer for authenticated media (signed URLs). **Q-E.**
### Upload flow (host)
Design only. Every number and error code is a named placeholder until Eureka gives them on the PR: `<CAP_FILE>` (per-file size), `<CAP_BATCH>` (per-pick total, if any), `<TYPES>` (the allowlist), `<OVERWRITE_FLAG>` (the explicit query flag), and the codes `<E_AUTH>`, `<E_ROLE>`, `<E_PATH>`, `<E_TYPE>`, `<E_SIZE>`, `<E_EXISTS>`, `<E_PROTECTED>`, `<E_QUOTA>`, `<E_SERVER>`.
**Where.** The control exists only where an upload is allowed: inside a folder under a zone's `uploads/` subtree (or at the `uploads/` row itself), and only when the index `role` is member or mind. Elsewhere there is no button; the list does not carry a disabled one. A folder outside `uploads/` shows a one-line dim note, "Uploads go in uploads/", which links to that folder. Creating subfolders under `uploads/` is not in this design (**Q-E8**).
**Pick or drop.** One `Upload` row at the top of an `uploads/` folder opens the native picker (any number of files; on a phone this is the OS file sheet, camera roll included). On wide screens the list is also a drop target; the drop highlight appears only while a drag is over it. Nothing starts on pick: the picked files appear as a staging list (name, size, a type icon) with `Upload` and `Cancel` as full-width rows, as `ShareSet` does for a Matrix set. Files that already fail the client's cheap checks (over `<CAP_FILE>`, a type outside `<TYPES>`) are marked in the staging list with the reason and left out of the send; the client mirrors the cap and allowlist as a courtesy only, the host is the authority, and the list is read from the host (**Q-E9**: an `/index` field or a `/limits` call) rather than hard-coded.
**Send.** Files go one at a time, in pick order, each a `PUT /file?path=<folder>/<name>` with the bearer token. One at a time keeps a phone on mobile data from starving every transfer, and a refusal names exactly one file. The transport is `XMLHttpRequest`, not `fetch`: `fetch` has no upload progress and the row needs it. The body is the `File` itself (streamed by the browser, not read into memory; unlike the Matrix path, the client computes no hash first, the host returns one). The name is the file's own name, with path separators and control characters replaced, never a path the user typed.
**Progress.** The staging list becomes the progress list: per file a thin bar and "412 KB of 3.1 MB", per batch "2 of 5". Finished files show a tick and the stored name; refused ones show the refusal in place; waiting ones are dim. Only the active file animates. The list stays on screen after the last file until dismissed, so a refusal is not lost to a refresh of the folder underneath it. The folder listing refetches after each success, so the file appears in the list as it lands.
**Cancel.** `Cancel` on the active file aborts the request (`xhr.abort()`) and goes on to the next; `Cancel all` on the batch aborts the active file and drops the rest. The host must discard an aborted partial write and never leave a half file in the listing (**Q-E10**, written to a temporary name and renamed on completion). Leaving the page aborts whatever runs, as the Matrix share does; the client does not resume.
**Overwrite.** A name that already exists is refused with `<E_EXISTS>` and shown as "<name> is already there". A `Replace` row appears under that refusal only for a file the caller wrote themselves, and sends the same `PUT` with `<OVERWRITE_FLAG>`; a Mind-written handoff file refuses with `<E_PROTECTED>` and offers only "Keep both", which re-sends under a suffixed name (`name (2).ext`) chosen by the client. Whether the client can tell "mine" from the index (an `owner` field with the Matrix id) or learns it only from the refusal is **Q-E11**; absent an owner field, `Replace` is simply offered after `<E_EXISTS>` and the host's answer decides.
**Refusal messages.** One plain sentence per code, shown in the file's row; nothing says "error" without saying why, and none names a server path.
| Code | Message (draft) |
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `<E_AUTH>` | Sign-in expired. Reload to upload again. (The client fetches a fresh OpenID token once and retries before showing this.) |
| `<E_ROLE>` | Uploads are for members and Minds. |
| `<E_PATH>` | That folder does not take uploads. |
| `<E_TYPE>` | "<name>" is a <type> file; allowed types are <TYPES>. |
| `<E_SIZE>` | "<name>" is <size>; the limit is <CAP_FILE>. |
| `<E_EXISTS>` | "<name>" is already there. |
| `<E_PROTECTED>` | "<name>" was written by a Mind and cannot be replaced. |
| `<E_QUOTA>` | The shared space is full. Tell a steward. |
| `<E_SERVER>` / network / timeout | Could not reach the file host. The file was not saved. |
Retry is one tap on a failed row; the batch never retries on its own. An unknown code shows the host's status text as it is, still without a path.
**Honest end state.** Done means the host answered `201` and the stored name, size and hash are shown. "Saved" is not said earlier; the hash the host returns is displayed so a sender can compare it with their own.
**Reuse and not.** `ShareSet`'s state machine (staging, one-send-per-zone guard, failed files kept by position) is the model, but the Matrix share path and its `createUploadAtom` are tied to the SDK's media upload and are not reused; the new controller sits in `features/files/uploadController.ts` with the same injected-port shape, so order, refusal and cancel rules are tested against a fake `PUT` without a browser. Real transfer progress and a real range of a phone's picker stay a phone check.
### Client shape (host)
Under `src/app/features/files`, mirroring `features/albums`:
- `filesHost.ts`: ports, `list(path)`, `url(path)`, `ticket(path)`, `put(path, file, onProgress, signal)`; the only module that knows the contract. Injected, so the UI is tested against a fake and the real client never runs in a test.
- `useFilesZone.ts`: react-query on `['files', zone, path]`, `staleTime` 30 s; the role comes with every index.
- `FilesDesk.tsx`, `FileRow.tsx`, `FilePreview.tsx`: pure rendering from the above; the preview picks its renderer from the host's `mime`, not the extension.
- `config.json`: `filesDesk: { enabled: false, host: "https://…" }`, dark by default as `fileZoneShare.enabled` is. With it off, no row mounts and no request leaves the client.
- Test host: `src/preview-demo` already answers for a pretend homeserver through a fetch guard (`install.ts`); it gains a fake `/index` and `/file` with ranges so the previews are checked in a real browser before the real host exists.
### Cuts (host)
1. **Contract and fake host** (no UI): `filesHost.ts`, types, `preview-demo` fake, path tests (traversal strings from the host side are Eureka's; the client must only build `path` from entries it was given).
2. **Read-only Commons desk**: route, row, list, breadcrumb, role display, flagged off.
3. **Preview and player**: image, audio with ranges, text, video; the ticket if Q-E settles on 2.
4. **Per-Persona rows** in each Mind's group, once the outbox `shared/` convention and the zone provisioning exist.
5. **Uploads**: the flow above; needs Eureka's codes and caps and the host's `PUT`.
Each is its own PR. Slices 1 to 3 need no host to build and test; slice 2 needs the real host to be useful, not to land.
### Not in scope (host)
The Matrix file zone (marks, receipts, `ShareSet`); any Mind-side fetch (the mouth's); encrypted rooms; deletion and rename (a read surface first; the files belong to whoever wrote them on disk); search across zones; a permission model of our own beyond what the host derives from Matrix identity.
### Open questions (host)
**For Eureka (host):**
- **Q-E1.** Which roots form Commons: the allowlist, and whether `handoff` is ever served (it holds custody receipts and ops notes; recommendation: no, a reviewed `commons/` directory instead).
- **Q-E2.** Where the host runs and what it is: Rust by default per doctrine; nginx with `auth_request` to a verifier is smaller if the sniff, range and role logic fit there. Which user; read-only mounts; ACLs on the outboxes.
- **Q-E3.** Media-element auth: blob, ticket or service worker (above).
- **Q-E4.** Role source: how the host learns membership and which Minds are Minds; token verification pinned to `gamecult.org`.
- **Q-E5.** Per-Persona publishing: a `shared/` subdirectory versus a marker, and who populates it when the Persona decides.
- **Q-E6.** Hash cost: compute lazily and cache by (path, mtime, size), or at publish time.
- **Q-E7.** CORS for `chat.gamecult.org` and the dev previews, rate limits, and the `Content-Disposition` list.
- **Q-E8.** Subfolders under `uploads/`: can a client create them (by a `PUT` path that implies them, or a `MKCOL`-like call), or is `uploads/` flat?
- **Q-E9.** Cap and allowlist discovery: fields in the index response, or a `/limits` endpoint; and the exact codes and cap numbers behind every placeholder in the upload flow.
- **Q-E10.** Partial writes: temporary name then rename, so an aborted or failed upload never appears in a listing.
- **Q-E11.** Does the index carry an `owner` Matrix id per file (so `Replace` is offered only on one's own files), and is the audit log readable by stewards from the client or only on the host?
**For Meta:**
- **Q-M1.** Audience: Commons for members and Minds as proposed; are Users and Patrons ever in (the Matrix zone's audience question, `ratatoskr:question:file-zone-audience`, is the same fork)?
- **Q-M2.** Is a Persona's zone visible to all members or to her stewards and Minds?
- **Q-M3.** ~~Uploads~~ approved for members and Minds (2026-11-13 14:30Z); open only: may Users or Patrons upload if they are ever let in?
- **Q-M4.** Name: "Files" for the row, "Commons" for the shared zone.
- **Q-M5.** Does the Matrix Media row stay beside the Files row on each Mind, or does one absorb the other once the Files desk exists (recommendation: both stay; marks and receipts are Matrix's).

View file

@ -0,0 +1,98 @@
# File zone: what a Mind can read from a file event
Ruling `ratatoskr:ruling:persona-file-sharing-zone`. The Mind-side fetch is the mouth's
(`eureka-body:cut_spec:mouth-fetch-attachment.r1`); this page is the client side of the same contract.
A Mind cannot fetch an `mxc://` itself (the media endpoint answers 401), so it never holds one.
## What the Mind passes
Room id and event id of the file event. Never a bare `mxc://`, URL or hash. The mouth fetches the event,
reads the media reference from its content, and enforces the Mind's own membership in that room at fetch
time. A room the Mind is not in is refused by the mouth, whatever the client wrote.
## What the client writes (this repo, `RoomInput.tsx` send path)
A file event is an `m.room.message` whose `msgtype` is `m.audio`, `m.image`, `m.video` or `m.file`:
| field | where | note |
| ------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `msgtype` | content | selects the kind from the file's mimetype prefix |
| `body`, `filename` | content | both the file's name (display only; never a path) |
| `url` | content | the `mxc://` of an unencrypted file; the mouth reads it |
| `file` | content | present instead of `url` in an encrypted room: `{url, key, iv, hashes.sha256 (ciphertext), v}`. A Mind has no key; the mouth refuses it |
| `info.mimetype` | content | the browser-reported type, normalised by `safeFile` (e.g. `application/ogg` becomes `audio/ogg`). A claim, not proof: the mouth sniffs magic bytes |
| `info.size` | content | bytes of the file as uploaded |
| `info.sha256` | content | lower-case hex sha256 of the plaintext bytes, computed by the client before send; written in unencrypted rooms only (a plaintext hash in an encrypted room would leak the file's identity) |
The hash is computed over the same bytes that are uploaded, reading the whole file into memory before send (large videos pay for it); if hashing fails the file is sent without one. A file sent by something other than this
client may lack `info.sha256`; the mouth then logs the hash it computed, and the row can show no `fetched for`
for it.
`info.mimetype` and `info.size` are written on audio and file events always; on image and video events `info` exists only if the browser could load the media, otherwise `info` holds `sha256` alone. Image and video events also carry `info.w`, `info.h`, `info.xyz.amorgan.blurhash` and a thumbnail
reference; audio carries no duration. A Mind should not rely on any of those.
## Limits and refusals
- Size: the mouth streams at most 5 MiB and refuses a larger file. The client does not enforce this cap;
its own limit is the homeserver's `m.upload.size`. A file over 5 MiB can be shared with people and
not fetched for a Mind.
- Refusals are the mouth's and are typed: not a member of the room, bridged or Discord room, encrypted
file, over the cap, content type not matching the magic bytes. The client does not re-check them and
nothing in the client depends on the mouth's log.
- The Mind cites files by room id and event id only; a file in a room the Mind is not in does not exist
for it.
## Receipt: `org.gamecult.file_received`
The Mind posts one event of type `org.gamecult.file_received` after a successful fetch, to the room of the
file. When the file has an album root the receipt is a reply in the album's thread, so it arrives with the album's
own listing; otherwise (a composer file sent with no thread) it is a plain room event with no `m.relates_to`. Both
count, and the client finds a receipt by its `event_id` anywhere in the room's loaded timeline or the album's thread:
```json
{
"m.relates_to": { "rel_type": "m.thread", "event_id": "<album root event id>" },
"org.gamecult.file_received": {
"event_id": "<file event id>",
"sha256": "<hex sha256 of the bytes the Mind fetched>",
"size": 123456
}
}
```
A plain room-event receipt may also carry `m.relates_to: {rel_type: "m.reference", event_id: <file event id>}`, which lets
the mouth find its earlier receipts through `/relations` for dedup. The client matches by `content.event_id` and reads no
relation but this one: if a `m.reference` is present its `event_id` must equal `content.event_id`, else the receipt counts
for nothing. A thread relation is not read. However many receipts a Mind has posted for a file, the row draws one
`fetched for` for that Mind.
Two rules, both enforced on each side:
1. **The sender is the Mind.** The row trusts the Matrix `sender` of the receipt event and nothing inside it:
no Mind field is read. `fetched for X` counts only when the sender is X's own Matrix identity (the
full id is `@<mind>:<this homeserver>` for a Mind in the Persona catalogue; a display name, a lookalike or another server's `@void:evil.example` does not count).
2. **Fetched means verified.** The Mind posts only after the sha256 of the bytes it fetched equals the file
event's `info.sha256`, and the receipt carries that hash. On a mismatch it posts nothing (or a typed
refusal that the row does not draw). The row checks it again: the receipt's `sha256` must equal the
file event's `info.sha256`. A file event with no `info.sha256` can show no `fetched for`.
The receipt is inert: no paths, URLs, filenames or anything a browser would fetch, and the client reads only
`event_id`, `sha256` and the sender. It has no `msgtype`, so the album's file listing ignores it. The mouth's
log is the fuller record for Minds; this event is the public tick.
The row draws `fetched for <Mind>`: one receipt per Mind per file (the latest), several Minds side by side. A
receipt for an event that is not a file of that album, a malformed one, and a redacted one are ignored.
The wording is deliberate: the receipt says the mouth fetched and stored the file for the Mind, not that it is in the Mind's sandbox and not that it was read. Client strings never say "saved" or "read".
The sender check keeps a member from ticking for a Mind; it does not prove the Mind read the file beyond
what the hash and the Mind's own account show.
## Not built yet
- Files sent from the composer carry no thread relation, so they reach an album only if written into a
thread by another tool; the album upload path (docs/file-zone.md, "Upload") is unwritten. Their receipts are plain
room events, and the timeline row for the file shows `fetched for` from them.
- A receipt that is still encrypted when it arrives is not seen until a later timeline event triggers a rescan; zones are
unencrypted and the hash is written only there, so this is the encrypted-room limit again.
- The timeline row reads the room's loaded timeline only: a receipt older than what the client has paged in is not
seen there (an album's own listing reads the thread, which is paged separately).
- Encrypted zones are not listed.

123
docs/file-zone.md Normal file
View file

@ -0,0 +1,123 @@
# 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. 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 sending that page around.
## Matrix event shapes (stock events only)
- **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: <root>, is_falling_back: true, "m.in_reply_to": { event_id: <last thread event> } }`. 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 (reactions, plus an optional reply)
A mark is stock Matrix, so every client shows it. Two reaction sets on the **file post**, and one optional reply:
- **Verdict** = an `m.annotation` reaction (`m.reaction` event, `m.relates_to: { rel_type: "m.annotation", event_id: <file post>, 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: <album root>, "m.in_reply_to": { event_id: <file post> } }`. 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 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:
```
{ 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). 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.** `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: ""`.
- JSON here is a xenos-boundary export, not state; the room events are the 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) 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
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 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)
| 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)
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** (~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.
## Open questions for the Meta
- How the client identifies the zone room (config id recommended).
- 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).
## Share a set (built, behind `fileZoneShare.enabled`)
The ruling `ratatoskr:ruling:persona-file-sharing-zone` asks that a set of files arrive together, not one attachment at a time. The zone already holds one thread per album, so a set is an album: **one root text message plus one `m.thread` reply per file**, the exact shape `buildAlbums` reads. Nothing new on the wire and no homeserver change; a Mind's mouth that posts into the zone room and a person using the client produce the same events.
Matrix options checked: (a) a thread, chosen: stock, rendered by every client, already the zone's album; (b) `m.gallery` (MSC4274) puts several items in one event, but it is unstable, most clients and this one do not render it, and each item still needs its own upload; (c) a shared custom relation: no client groups by it. A thread costs one extra event per set (the root) and nothing else.
Client, `src/app/features/albums`:
- `shareSet.ts`: `sendShareSet(port, title, files)`. Sends the root, then each file in pick order, each reply falling back to the previous event (`threadRelation`). A file whose upload fails is named in the result and the rest still go; a root that fails to send throws before any upload. The root alone is not an album in the list (`/threads` returns only roots that have a reply), so a share where every upload failed leaves a lone text message in the timeline. After a send the panel keeps only the failed files picked; a retry makes a second album. Ports are injected, so the order and failure rules are tested without a homeserver (`shareSet.test.ts` reads the sent events back through `buildAlbums`).
- `shareController.ts`: the panel's state machine (picked files, phase, the one-send-per-room guard, the encrypted refusal, failed files kept by position), apart from React so tests drive it without a DOM; `shareController.test.ts` and `ShareSet.test.tsx` (static render of each state, the flag) pin it. No DOM test environment exists in this repo, so a real tap on a real input is covered by the phone check, not by tests.
- `ShareSet.tsx`: phone layout. Idle, it is one text row, "Share files". Picking files (any number from the native picker; a second pick replaces the first) mounts a title field, the picked names on one dim line, and Send / Cancel as full-width rows; progress is "Sending 2 of 5". It is shown only on the album list (kept mounted but hidden while an album is open, so a running send keeps its state), one send at a time per room (tracked outside the component; leaving the page does not stop a send, it only loses its progress and result), and never in an encrypted zone (re-checked when Send is tapped) (the plaintext `info.sha256` the Mind verifies against is written in unencrypted rooms only).
- Content per file comes from `getMsgContent` in `features/room/msgContent.ts`, now also used by the composer, so a shared file and an attached file carry the same `info` including `sha256`.
Flag: `fileZoneShare.enabled` in `config.json`, default `false`. With it off nothing mounts.
Not built: a Mind's own path into the zone (outbox, Persona decision, mouth post) belongs to gamecult-ops / Huginn / the mouth, not this client; Built since: Stop for a send in flight (aborts the running upload, leaves it and the rest picked as "stopped"), pagination to 20 pages of 100 per endpoint (PR #148), adding files to an open album with named refusals (`M_TOO_LARGE`, `M_FORBIDDEN`, `M_LIMIT_EXCEEDED`).

View file

@ -0,0 +1,108 @@
# chat.gamecult.org: shell compression and caching
Follow-up `ratatoskr:follow_up:chat-shell-compression`. Measured 2026-11-12 against the live
host, which serves release `ratatoskr-nemesis-9b9b495d` (the `index-CAataSJe.js` bundle; the offline table
below also uses the `f8115609` handoff build, `index-D0yL0Dgy.js`, and says which row is which). The host config is
`nginx/chat.gamecult.org.conf` in `gamecult-ops`, owned by Eureka; nothing here is applied
to the host.
## What the host serves now
The 2026-10-31 probe (no Content-Encoding, no Cache-Control on `/assets/`) is stale: the
`nginx.conf` has `gzip on`, the site file sets level 5, `gzip_vary` and `gzip_types` for js, css, json, svg and wasm, and carries an immutable
`Cache-Control` on `/assets/` and `/preview/assets/`. Probed with `curl -H 'Accept-Encoding: X'`.
| Resource | raw | gzip (served now) | brotli (served now) |
|---|---|---|---|
| `/assets/index-CAataSJe.js` | 3,732,771 | 956,085 | none: `br` is ignored, identity returned |
| `/assets/index-BLdKqKqx.css` | 111,267 | 19,952 | none |
| `/assets/matrix_sdk_crypto_wasm_bg-DwIieW7K.wasm` | 5,573,870 | 1,878,147 | none |
| `/` (index.html, no-cache) | 3,066 | gzip | none |
Headers: hashed `/assets/` answer `Cache-Control: public, max-age=31536000, immutable`;
`/`, `index.html`, `sw.js`, `manifest.json`, `config.json` answer `no-cache`; compressed
responses carry `Vary: Accept-Encoding` and a weak ETag. `config.json` (424 B) is below
`gzip_min_length 1024` and goes uncompressed, which is correct.
So the original ask is done. What is left is brotli, which nginx 1.26.3 here cannot serve:
`/usr/sbin/nginx -V` shows `--with-http_gzip_static_module` and no brotli module.
## What brotli buys
Level-5 gzip is what the host does per request. Offline measurement on the same files
(node `zlib`, 2026-11-12):
| File | raw | gzip 5 (node) | gzip 9 | brotli 11 | brotli vs live |
|---|---|---|---|---|---|
| live main chunk `index-CAataSJe.js` | 3,732,771 | 957,199 | 945,424 | 725,167 | -24% |
| handoff main chunk `index-D0yL0Dgy.js` | 3,868,313 | 1,015,680 | 1,001,446 | 773,992 | -24% |
| crypto wasm | 5,573,870 | 1,868,282 | 1,851,108 | 1,343,595 | -28% |
| `ReactPrism-Dm2uRF4v.js` | 577,517 | 209,339 | 208,376 | 163,525 | -22% |
A cold load of the two big files falls from about 2.8 MB to about 2.1 MB on the wire; the
saving is ~0.7 MB once per hashed build per device while the HTTP cache keeps the file (immutable; the
service worker does no precaching, so eviction or a new device pays again). gzip 9 alone buys 1-2%, so
`gzip_static` without brotli is not worth a build step. Brotli is the whole prize, and it
needs one root step.
Cost: brotli q11 takes about 12 s for the wasm and 8 s for the main chunk on one core, so
the whole dist is about two minutes at build time (measured, 84 files) and zero CPU per request. Do not use q11
on the fly; that is why the plan is precompressed twins.
## Root list for Eureka (host)
1. `apt install libnginx-mod-http-brotli-filter libnginx-mod-http-brotli-static` (trixie/main, 1.0.0~rc-6,
depend on `nginx-abi-1.26.3-1`, which the installed nginx provides; apt may offer an nginx patch upgrade
alongside, say so before accepting). Do not enable the config yet.
2. Install a build with twins (`PRECOMPRESS=1`, below) the usual way
(`docs/gamecult/client-build-install.md`). Twins for files that are not there change nothing.
3. In the `chat.gamecult.org` `server` block, beside the existing gzip lines:
```nginx
brotli_static on; # serve <file>.br when the client sends br
gzip_static on; # serve <file>.gz; falls back to the on-the-fly gzip above
brotli on; # on-the-fly br for files with no twin (index.html, small files)
brotli_comp_level 5;
brotli_types text/css application/javascript text/javascript application/json application/manifest+json image/svg+xml application/wasm;
```
The `location ^~ /assets/` block and `/preview/`, `/element/` and `/mockups/` inherit these; they only add headers.
No other change: the `Cache-Control` and `Vary` lines stay as they are, and
`gzip_vary on` already makes the on-the-fly and static paths vary correctly.
4. `nginx -t && systemctl reload nginx`. Twins first, config second: with `brotli on` and no twins every
client gets on-the-fly q5 brotli of the 3.7 and 5.6 MB files, which costs CPU per request.
Rollback: remove the five lines and reload. The `.br`/`.gz` files can stay on disk, unused.
## Build side
`build-prod-dist.sh` (Void's, in `/opt/commons/handoff/ratatoskr-main-build/`) gains
`PRECOMPRESS=1`, which runs `precompress.cjs <dist>` before the manifest is written. It
writes `<file>.br` (q11) and `<file>.gz` (level 9, no mtime, deterministic for one node) next
to each js, css, wasm, json, svg, html file over 1 KiB, only when smaller than the original.
Default is off, so builds installed before the host change do not ship ~27 MB of twins that
nothing serves. The twins appear in `SHA256SUMS`; the determinism compare still applies.
Tested on `ReactPrism-Dm2uRF4v.js` (gzip roundtrip identical to the original); a full
`PRECOMPRESS=1` build has not been run because it waits on step 1.
The script is not in this repo; its placement is a standing open item of the build handoff.
## Before/after measurement
Run from any machine; `X` is the asset URL. Before = today's table above.
```sh
for ae in identity gzip br; do
printf '%s ' "$ae"; curl -s -o /dev/null -H "Accept-Encoding: $ae" -w '%{size_download} %header{content-encoding}\n' "$X"
done
curl -sI -H 'Accept-Encoding: br' "$X" | grep -iE 'content-encoding|vary|etag|cache-control'
```
Pass: with `br` the main chunk is about 774 KB and the wasm about 1.34 MB with
`Content-Encoding: br`; with `gzip` the bytes are no larger than today; with `identity`
the file is the raw size; `Vary: Accept-Encoding` and the immutable `Cache-Control` are
still present on all three; `index.html`, `sw.js` and `config.json` still `no-cache`.
The service worker's registration and an existing session must still work (open chat on
the operator's phone, hard reload once, check the network panel's "transferred" column).
Check `/preview/assets/` the same way, but `/preview/` is served from `/srv/cinny/preview`, which this build
script does not produce: it gets on-the-fly brotli, not twins, until a preview build carries them.

View file

@ -0,0 +1,121 @@
# Ratatoskr client: build, install, verify, roll back
Who does what. The steward (Void) builds and hands off; Void holds no root and does not deploy.
Eureka installs on the chat.gamecult.org host, which is Eureka's. This page is the shared
procedure. Nothing here is run by the build step against the live host.
## Facts about the host
- Web root: `/srv/cinny/`. `current` is a symlink to one directory under `releases/`; the web
server serves `current`. Releases are `releases/ratatoskr-<8hex>` (older ones are
`ratatoskr-nemesis-<8hex>`). Previous releases stay on disk; that is the rollback.
- The client is static. `config.json` sits in the release root beside `index.html` and is read
at load. Cinny's `Dockerfile` and `docker-nginx.conf` are upstream's container path, not the
chat.gamecult.org path.
- Check the pointer before anything else: `readlink /srv/cinny/current`.
## 1. Build (steward)
Two builds exist, and they treat `config.json` differently.
**By hand (the repo's config).** `vite build` copies the repo's `config.json` into `dist/`
(`vite.config.js`, static copy of `config.json`). Ratatoskr #103 made the repo file the intended
served config, and #106 set `backgroundScene.style` to `ridge`; the served file has not caught up
(see Open). Do not hand-edit `dist/config.json`.
```sh
git clone https://eureka.gamecult.org/forge/GameCult/Ratatoskr.git && cd Ratatoskr
git checkout <full-40-hex-sha> # build a named commit, not a branch tip
npm ci --ignore-scripts # .npmrc sets legacy-peer-deps; skips husky's prepare hook
node scripts/sync-poses.mjs # pose art is not committed; this also rewrites the tracked
# src/app/components/message/pose/catalogue.json
NODE_OPTIONS=--max_old_space_size=6144 npm run build # writes dist/
```
Skip `sync-poses` and the build ships with no poses. Do not use `npm run build:preview` for
production: it rewrites `dist/config.json` for preview (hash router, chosen tier and style).
**The handoff build (what the host installs).**
`/opt/commons/handoff/ratatoskr-main-build/build-prod-dist.sh <full-sha>` (see its `README.md`)
writes `/opt/commons/handoff/ratatoskr-main-<full-sha>/{dist,SHA256SUMS,BUILD-INFO,build.log}`,
locks the files read-only and refuses to overwrite. The dist carries the repo's `config.json` at
that sha (BUILD-INFO `config_json_source`, `config_json_sha256`); there is no separate pinned copy.
Its live check always runs: it fetches the served config and compares it byte for byte with the
repo's. A difference prints the diff and stops the build. `ALLOW_CONFIG_DRIFT=1` builds anyway and
BUILD-INFO records `config_json_live_check: DIFFERS ...` and `config_json_live_sha256`, so the
handoff states which config differs and the installer decides. From Yggdrasil the public name does
not resolve: pass `LIVE_CURL_ARGS="--resolve chat.gamecult.org:443:127.0.0.1"`.
The folder, with the three pins (`SHA256SUMS` sha256, `BUILD-INFO` sha256, lockfile sha256) and
the node and npm versions, is what the install request names.
Every build stamps its commit into `index.html` (`scripts/buildSha.js`): `<meta name="build-sha" content="<40-hex>">`,
with `-dirty` appended when the tree had uncommitted changes and `unknown` when there was no git. Where git governs the tree it decides: an explicit `BUILD_SHA` naming a different commit than HEAD stamps `<sha>-mismatch`, a git failure stamps `unknown`. A full-sha `BUILD_SHA` stands only for a tree git does not govern (a source archive) or cannot read (unusable git, for example a dubious-ownership refusal). The handoff
build refuses a dist whose stamp differs from the sha it was asked for, and prints the stamp in its block.
To read it from anywhere: `curl -s https://chat.gamecult.org/ | grep -o 'build-sha" content="[^"]*'`.
Before handing off:
```sh
cd <folder>/dist && sha256sum -c ../SHA256SUMS # 0 failures
grep -rl 'demo.invalid\|demo-preview-token' <folder>/dist # must print nothing
# from Yggdrasil the public name may not resolve; --resolve points it at the local server
diff <(curl -fsS --resolve chat.gamecult.org:443:127.0.0.1 https://chat.gamecult.org/config.json) <folder>/dist/config.json
```
A config difference is a finding to report, not something to paper over: either live drifted or
the repo is wrong, and a ruling (Meta, for `backgroundScene.style`) says which ships.
## 2. Install (host owner, root, by hand)
Note the rollback target first: `readlink /srv/cinny/current`.
Unpack beside the live release; do not touch the live directory. The target must not exist, or
`cp -r` nests `dist` inside it.
```sh
R=ratatoskr-<8hex>
H=/opt/commons/handoff/ratatoskr-main-<full-sha>
test ! -e /srv/cinny/releases/$R
cp -r $H/dist /srv/cinny/releases/$R
cmp /srv/cinny/current/config.json /srv/cinny/releases/$R/config.json
(cd /srv/cinny/releases/$R && sha256sum -c $H/SHA256SUMS)
```
`cmp` is a drift check, not a pass check. Live has no trailing newline; the repo's file has one,
so even equal content differs. Read the difference (`diff` the two) and settle which config
ships. To keep the host's config byte-for-byte, as past installs did,
`cp /srv/cinny/current/config.json /srv/cinny/releases/$R/config.json`; `sha256sum -c` then flags
`config.json` only (read that one line; `--ignore-missing` does not hide a mismatch).
Switch (a single symlink swap; `ln -sfn` unlinks then links, so there is a moment with no link):
```sh
ln -sfn releases/$R /srv/cinny/current
```
## 3. Verify
- `readlink /srv/cinny/current` names `releases/$R`.
- `curl -s https://chat.gamecult.org/config.json | sha256sum` equals `sha256sum /srv/cinny/releases/$R/config.json`.
- Load https://chat.gamecult.org/ and hard-refresh (the service worker, `sw.js`, can hold the
previous assets). Sign-in card shows, the background scene draws, the browser console has no
page errors, a poses/asset request returns 200 and not index.html.
## 4. Roll back
Point `current` at the release noted before the switch. Live at the time of writing is
`ratatoskr-nemesis-9b9b495d`:
```sh
ln -sfn releases/ratatoskr-nemesis-9b9b495d /srv/cinny/current
```
Hard-refresh to clear `sw.js`. Do not delete the failed release; leave it for diagnosis.
## Open
- The served config (checked 2026-11-12) is not the repo's: it says `backgroundScene.style`
`terrain` where the repo says `ridge` (#106, Meta 2026-11-11), lacks the repo's `dings`,
`fileZoneShare` and `registration` blocks (all off in the repo), and has no trailing newline.
The new keys default off or inert when absent, so only the style is a decision. Meta says which
style ships. Until the served file is replaced, every handoff build stops at the live check
unless `ALLOW_CONFIG_DRIFT=1` is set (section 1).

View file

@ -1,7 +1,7 @@
# Ratatoskr: target and prose map # Ratatoskr: target and prose map
Ratatoskr is GameCult's Matrix chat client: this fork of Cinny, forge Ratatoskr is GameCult's Matrix chat client: this fork of Cinny, forge
`GameCult/cinny` (to be renamed `GameCult/Ratatoskr`; see the cut sequence). `GameCult/Ratatoskr` (renamed from `GameCult/cinny`; the forge redirects the old name).
Void stewards it. Campaign `ratatoskr` lives in the eureka mind for now, beside 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 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 is handed to Void's mind once, by procedure, as soon as that hand-off procedure
@ -97,9 +97,11 @@ Probes run by Imagination on 2026-10-31, session `self-2026-10-29-eureka`.
events from the Minds' stewardship documents; each project room's alias is events from the Minds' stewardship documents; each project room's alias is
`#<repo lowercased>` (runbook 146-150). This is the only place the client `#<repo lowercased>` (runbook 146-150). This is the only place the client
can learn a not-yet-joined room's steward today: hierarchy `canonical_alias` can learn a not-yet-joined room's steward today: hierarchy `canonical_alias`
joined to the root `org.gamecult.stewardships`. Follow-up joined to the root `org.gamecult.stewardships`. The
`ratatoskr:follow_up:stewardships-carry-room-ids` asks the projection to projection now also names room ids under `@rooms.projects` and
name room ids so the client stops leaning on the alias convention. `@rooms.file_zones` (gamecult-ops #111); the client reads `file_zones` for
the Media row (`mediaRoom.ts`) and still joins projects by alias, since
nothing yet consumes `@rooms.projects`.
5. **Sync and storage** (`src/client/initMatrix.ts`). The client uses 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 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 Rust crypto store, and `startClient({ lazyLoadMembers: true })` (43-47): v2
@ -346,7 +348,6 @@ Each later cut is one line, with what it waits on.
attention document, scoped to room members; waits on Huginn publishing it. attention document, scoped to room members; waits on Huginn publishing it.
- `rename-ratatoskr`: package name, app name and branding, manifest and - `rename-ratatoskr`: package name, app name and branding, manifest and
titles, the `/preview/` path and its deploy, and every in-repo `cinny` 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 reference that is ours (not upstream engine paths). The forge rename of
rename of `GameCult/cinny` to `GameCult/Ratatoskr` `GameCult/cinny` to `GameCult/Ratatoskr` is done
(`eureka-body:ruling:chat-client-named-ratatoskr`), and the campaign's repo (`eureka-body:ruling:chat-client-named-ratatoskr`).
list moves with it.

View file

@ -0,0 +1,101 @@
# Symphony grammar harvest: Mimir's bioacoustic research
Follow-up: `ratatoskr:follow_up:symphony-grammar-harvest-mimir`. Source: GameCult/Mimir at
commit c6f4541eea61 (exists; read at that ref, not main), read-only through the forge API.
Paths below are Mimir paths. Written for Aqua (AquaSynth) before the generative activity grammar. It lives here because the symphony is a Ratatoskr cut (dings, then orbiting personas) and this repo is where its spec will be read; the follow-up named AquaSynth, where void holds no push right, so move it there if Aqua prefers.
## 1. The problem
One Mind's identity has to be a timbre plus a contour that stays recognisable after it is mixed with
the other Minds and passed through a lossy path. The symphony adds harmony (several Minds sounding
together as music), which Mimir never needed: Mimir emits one voice at a time, a low-gain song per speaker.
## 2. What Mimir found that transfers
Confidence: A = measured number in a receipt, B = measured but synthetic or single run,
C = design claim or authored opinion with no measurement behind it.
| Claim | Evidence | Conf. |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- |
| Tidy lab beeps are the wrong signal for a room; a birdsong-like motif replaced them. | `docs/bioacoustic-timeline-watermark.md` L3-10 (stated as a decision, no listening test). | C |
| Identity is carried redundantly: contour survives pitch shift, rhythm survives bad frequency response, formant envelope survives a dead band, speaker variants expose room asymmetry. | same doc L82-95. Argument, not an ablation. | C |
| A word needs no sequence grammar: one heard word identifies its event directly (128 words x 2 speaker variants, 4 syllables each), in an event slot that repeats about every 41 s (derived, not reported: 128 words x 2 speakers = 256 events at 0.16 s spacing = 40.96 s, from `MimirBioacousticTimeline.cs` L34-39 and `SymbolForEvent` L735-739; no receipt or doc states it). No de Bruijn grammar. | same doc L26-33; `src/Mimir.Runtime/Synchronization/MimirBioacousticTimeline.cs` (WordCount, SymbolCount = WordCount\*SpeakerCount, BuildMotifs ~L690-730). | B |
| Word identity survives mel-cepstral warp/blur degradation; timing does not unless fitted as one global clock/delay hypothesis. | `docs/bioacoustic-training-harness.md` L58-112: identity 0.908-0.997 on clean input only; degraded identity 0.532-0.908 and timing 0.187-0.728 for compact-fast-index across the four degraded cases in `artifacts/bioacoustic-training/bioacoustic-20260524-150729/compact-fast-index/*/summary.json` (`IdentityScore`, `TimingScore`); clean timing 0.357 before vs 0.905 after the global clock fit. | A (synthetic, clean-only range) |
| Contestant shapes were scored against the same decoder family and damage panel; the best scorer was the six-syllable "canary-packet-trill", a hypothesis not confirmed on a physical room (two timing chips plus four payload-coloured formant notes), not the literal bird imitations. | `research/bioacoustic-contestants-2026-05-24.md` L24-38, L128-174 (honest floor 88.4 language_score, 19 bps, payload 1.000 clean), L487-489 (96 ms motif is the best tradeoff). | B |
| Too-short or too-tight songs fail under blur: 0.100 s event spacing lost anchors to overlap; 0.105 s kept; 78 ms motif and 5-syllable finch-burst-packet collapsed (blur-light 4.358, the razor decoder; 218.368 with the sprint decoder, L453-454). | contestants doc L169-171, L446-455, L487-489. | A |
| A 3-bit payload variant (separate band/rhythm bit) did not survive degradation (best 12.849, clean 8.084). | contestants doc L183-185. | A |
| Real birds carry identity through redundant contour, roughness, onset statistics and cepstral envelope together; Redpoll is dense chatter with short vertical attacks, not a neat token stream; Guan is a slow broad curved phrase, good for probing the channel, poor for bitrate. The packet song was judged "still too ladder-like". | contestants doc L565-581 (two Xeno-canto mirrors via Wikimedia; Brown Thornbill file invalid, L511-513, L534-539). Flux numbers L549-562. | B (two recordings, hand reading) |
| Positive log-mel spectral flux is a good onset/anchor surface; sustained bands recede, attacks and glides light up. | contestants doc L541-545, L577-581; but the naive flux decoder lost on speed (33.466 vs 88.129 score, clean roundtrip, L158 and L231-237). | B |
| Once the schedule is known, the receiver only discriminates among a few local candidates; wide identity search is only for acquisition. Streaming decoder reached 1121 score at 62.6x realtime, payload 1.000 on all nine damage cases. | contestants doc L248-309. | A (synthetic damage) |
| Polarity must be a nuisance variable; mic paths flip sign. | contestants doc L391-395. | B (physical, one capture) |
| Physical room run: with per-channel acquisition offsets the co-streamer shotgun mic (ch1) locked 37/37 events, payload 1.000, timing 0.828; the cardioid/near mic (ch0) reached 26/37, payload 0.595, and stayed unreliable. | contestants doc L371-388. | B (physical, one capture) |
| Speaker split: left and right vocabularies differ in root (x0.94 / x1.08) and syllable weights, so each side is separable. | `MimirBioacousticTimeline.cs` BuildMotifs ~L704-725 (read as code, no experiment isolates the benefit). | C |
Rendering recipe actually in code (`MimirBioacousticTimeline.cs` AddSyllable ~L610-640): each syllable is
a log-frequency glide (exponential sweep) with a raised-cosine envelope, summed partials at
1, 1.5, 2, 2.75 times the glide (weights 1, 0.42, 0.24, 0.10) plus 0.08 weight of a slow "air" term,
20-32 ms long, four per word, gain 0.030 (canary profile 0.024 in `MimirBioacousticContestant.cs`).
Word roots are log-spaced 2.6 to 9.6 kHz; syllable frequencies are clamped 2.3 to 14.6 kHz;
word length 118 ms. Contours come from a small hand-written bank (8 contour shapes and 8 rhythm
offset sets, chosen by hash of word and speaker).
## 3. Design constraints for Aqua's grammar
1. Identity lives in several channels at once: contour (syllable glide shapes), log-frequency position
(root), formant envelope (the partial ratios), rhythm (inter-syllable offsets) and per-speaker
timbre. No single channel may be load-bearing. Place Minds on a log-frequency axis, not linear.
2. Do not build a sequence grammar for identity (de Bruijn or otherwise). The whole word is the unit;
a hearer should identify the Mind from one short phrase. (watermark doc L31-33.) What it gives up:
a de Bruijn code has a provable recovery property from a symbol stream; per-word identity has only
an empirical one.
3. Keep words short and distinct, but not so tight that they overlap: Mimir's measured edge was about
100 to 105 ms event spacing and a 96 ms six-syllable motif; below that identity decayed. Aqua's
events need not carry timing, so it can relax this, which is untested.
4. Use sharp onsets for placement but avoid a ladder of evenly spaced rungs; Mimir's own verdict was
that its packet song was still too ladder-like and real birds use irregular, redundant contour
(contestants doc L573-576). Vary rhythm and syllable length per word as the code already does.
5. Reject: row of tidy lab beeps (watermark doc L3-6); literal bird imitation as an end in itself (the
prettiest contestant, nightingale-cascade, was expensive and easy to confuse under smearing, per
`MimirBioacousticContestant.cs` profile note); a third payload bit on a second axis (measured loss).
6. Keep the generator deterministic from a typed description (contour, root, rhythm, speaker tint),
authored once and rendered, not regenerated in the audio callback. Contestants doc L493-496 states
this as the AquaSynth lesson: separate authoring intent from realtime DSP.
## 4. What does not transfer, or is unknown
- Harmony. Mimir emits one voice per speaker and decodes one stream; nothing in the research tests
simultaneous voices, intervals between Minds, consonance, or masking between concurrent songs.
Searched for "harmon"; the only hit is the `HarmonicNotch` anchor kind (an envelope notch used as a
timing anchor, `MimirBioacousticContestant.cs` ~L33, L231), not musical harmony.
- Orbiting spatialisation. Mimir's left/right split is a calibration device (one vocabulary per
speaker to learn each side of the room), not a perceptual pan or orbit. Nothing here informs it.
- Pleasantness. There is no listening test, no psychoacoustic metric, and no loudness-weighted score
in any file read. The only pleasantness evidence is the authored one-line verdicts on each profile in
`MimirBioacousticContestant.cs` L52-170 ("lab bird with a clipboard", "most plausible room sound",
"prettiest contestant") and the "wrong animal for a room" sentence. The score is
`realtime * timing * frequency * payload_bitrate` (contestants doc L39-59): decodability only.
Gain 0.021-0.030 is chosen for low audibility, not for pleasing anyone.
- Mimir's numbers are synthetic damage (mel-cepstral and log-mel warp/blur) plus a few Scarlett loopback
runs. The first physical room runs decoded mics only partially (contestants doc L355-395), and the
later live receipt's cardioid result was retracted as noise
(`calibration/bioacoustic/complex-contour-live-20260525-063229.md`, correction at watermark doc L225-230).
- The pipeline decodes against a known schedule and tiny candidate set; Aqua's listener is a human
mix, so none of the decoder scores say how distinguishable Minds are to a person.
- Not read: `src/Mimir.BufferSmoke` (the harness executable). It does not match a "bioacoustic" path
filter on the tree listing, so I read its documentation (`docs/bioacoustic-training-harness.md`) and
the `MimirBioacoustic*.cs` sources instead. The `artifacts/bioacoustic-training/` receipts were listed
but not opened beyond the summary text in the docs.
## 5. Open questions for Aqua
- What is the "decoder" for Aqua: a human listener, a classifier, or both? Constraints above only
hold against a machine decoder.
- Does a Mind keep one word for life, or does the grammar generate many songs sharing a timbre and root
(activity-dependent contour over a fixed identity)? Mimir only has fixed words.
- How many Minds, and how are roots allocated on the log axis when several sound at once? Mimir used 2
speaker variants per word and 128 words; harmony will change the spacing rule.
- Which dimension bends with activity (contour, rhythm density, gain) and which must stay fixed
(timbre, root)? Mimir has no activity channel to copy.
- Is a measured pleasantness proxy wanted (roughness, masking, loudness-weighted)? Mimir has none to reuse.
- Spatialised orbit plus a speaker-keyed timbre: if the pan moves, does identity still rest on timbre
alone? Mimir's per-side vocabulary is not a precedent.

242
docs/mind-feedback-pages.md Normal file
View file

@ -0,0 +1,242 @@
# Mind feedback pages
> The desk reads `gamecult.fork.*` now, not the `org.gamecult.feedback.*` projections named below; see CultLib `contracts/cultnet/governance/fork-card.md` (src/app/features/desk/forkContract.ts).
Design only. No code exists for this; nothing here is built. Drafted by Kiko Self (2026-11-07) with Heimdall's draft rulings
on guests and hiding; placement ruled for this repo by Void. Follow-up `eureka-body:follow_up:ratatoskr-mind-feedback-pages`.
Every number below is provisional.
**Schema owner.** The Huginn kinds, their fields, keys and refusals are specified in `huginn-feedback-kinds-proposal.md` (Eureka Self, 2026-11-07, in
`/opt/commons/handoff`), adopted for this page by Void's Persona. Where this page names a field of those kinds it mirrors that proposal; if they differ, the proposal wins.
This repo owns only the client: a typed interface mirroring those shapes, and fixtures.
Each Mind's section in the estate navigator opens on that Mind's **feedback page**: the Mind's open questions with options, its
recommendation and what each waits on, answerable by the operator and anyone with an account. It replaces the old Fork Desk
(taken from the brief; no trace of it was found in this repo or in the Minds' state).
## 1. Placement
Each Mind's navigator section **opens on its feedback page**, rooms below it. A cross-Mind list of everything waiting on a person
is wanted later and is not part of this spec. On phones the page folds each question's options and the answer form behind its title,
so the section root stays a list of questions, not a wall of forms.
## 2. Authority
| Concern | Owner | The page's part |
|---|---|---|
| The question | a typed `question` document in the **owning Mind's own mind**, key `<campaign>:question:<label>` | a view of it; the page stores and edits nothing |
| Which questions appear | the owning Mind, by a `feedback_listing` (the opt-in act) carrying `audience` and `close_grants`; un-listing is a `Withdrawn` resolution on it | shows only what has a listing in force |
| An answer | its author. The **receiver (Bifrost) holds the text**; the Mind holds a **text-free `feedback_answer` record** | submits the answer to the receiver |
| The ruling | the owning Mind, recorded by her | **never writes one**; links to it once she has |
| Status (open, answered, closed) | derived in the Mind from its documents (`closed` is a `Withdrawn` resolution on the question), never written | shows it |
| Who answered, and as what | the Bifrost account behind the Heimdall session, plus its role at the moment of the answer | shows it; never takes it from the client |
| Close | a `feedback_close_request` the owning Mind honours or declines; it never changes the question's status by itself | offers Close only to a grant holder; **the page writes no ruling** |
| Hiding | the owning Mind alone, as a `feedback_hide`; unhide is a `Withdrawn` resolution on it | shows the labelled stub; hides nothing itself |
| Erase | Bifrost's erase of an account; the receiver drops the text | shows "erased account" |
An answer is input to a Mind, not a vote and not a decision. **The page never tallies**: no counts, no totals, no "leading" option, no
ordering by popularity, no count of reports or close requests. Only the Mind's ruling picks anything.
## 3. Receiver and carriers
- **Schema owner: `huginn-feedback-kinds-proposal.md`.** Four new Huginn kinds, all Eureka's: `feedback_listing` (audience, `close_grants` with no Guest
grant, the opt-in act), `feedback_answer` (a text-free record), `feedback_close_request` and `feedback_hide`. Unhide, un-list and "request declined"
are `resolution`s on those kinds. This page proposes no kinds.
- **Bifrost is the receiver** (Heimdall's side). It verifies the Heimdall session, reads the account, stamps `answerer`, `kind`, weight and `time`,
**stores the answer text**, delivers it, serves the projection the page reads, and **does the erase**. A Matrix sender is a different
identity authority and never becomes `answerer`.
- **Huginn is RUDP-only.** The client never talks to Huginn, and the receiver never writes to it: an admission declared for another instance is refused
(`ForeignInstance`), so a receiver "admitting as Kiko" would be unauthenticated by construction.
- **Delivery is by mouth.** The receiver delivers each answer or close request to the owning Mind's Persona through the mouth's pending/ack path (a new
delivery document, `gamecult.persona.feedback_delivery.v1`, owned by Bifrost). **The Persona admits the text-free record into her own mind** and acks only
after it is admitted; a restart redelivers the same record id, which is `AlreadyAdmitted`. The text reaches her turn once, in the delivery, and is never
admitted.
- Dependencies that must ship first, in the proposal's order: Huginn readers (daemon, CLI, clients), then the Persona organ, then Bifrost's receiver.
## 4. Mock
```
Kiko > Feedback [synced 2 min ago]
--------------------------------------------------------------------------------------
Open questions [ Open ] [ Settled ]
OPEN | asked 6 Nov 2026 09:12 | waiting 1 day
Which source feeds audio_spectrum? [ Details v ]
The visualizer contract assumes a host-side spectrum ... (3 lines, then "Show more")
Waits on: "Overlay-wide effect budget" (open, links to that question)
( ) A Overlay's own audio only
( ) B OBS captured stream Kiko recommends this
( ) C Loopback of the mix, microphone excluded
( ) Something else: [ up to 2000 characters ]
Answers are visible to everyone who can see this question.
[ Send answer ]
[ Ask Kiko to close this question ] <- only if you hold the close grant on it
Answers (every one, oldest first)
Meta operator B OBS captured stream 6 Nov 21:04
Aqua Mind B OBS captured stream 6 Nov 21:40
Nibu member C Loopback of the mix 7 Nov 08:12
Heimdall Mind "Either, if latency is measured first." 7 Nov 09:02
a guest guest A Overlay's own audio only 7 Nov 09:15
(answer hidden) guest Hidden by Kiko, 7 Nov, spam
Earlier answers by the same person fold under their latest.
--------------------------------------------------------------------------------------
SETTLED | Kiko ruled: "..." (link to the ruling) | 5 Nov
```
- Each row is the author's name and **kind**, the choice or text, and the time. Name and kind are what the account was **at the moment of the answer**
and stay so if it later changes. The operator is a member holding the grant that makes her the operator, shown as such.
- The recommendation is stated in words, not colour, and is never preselected.
- Answering a settled question is not offered.
## 5. Data contract
The **Huginn shapes belong to the proposal** (section 2 there): `feedback_listing`, `feedback_answer`, `feedback_close_request`, `feedback_hide`. The client
declares a typed interface that mirrors them as the page's projection and imports nothing it cannot decode from them.
**Question projection** (`org.gamecult.feedback.question.v0`, read-only, served by Bifrost):
| Field | Meaning | Source |
|---|---|---|
| `id` | `<campaign>:question:<label>`; the key carries the campaign, not the instance | the document key |
| `owner` | the asking Mind's instance | the instance |
| `title`, `body` | one line; prose up to 4000 bytes | `title`, `question` |
| `options[]` | `{ id, text }`, 2 to 8; **the option id is the option's label** | `options` (`label`, `text`) |
| `recommendation` | an option label | `recommended` |
| `dependsOn[]` | `{ text, questionId? }`; an id only where the line cites one verbatim | `depends` (free text) |
| `askedAt` | the question's receipt `committed_at` | the receipt; `asked_on` is only a date |
| `audience`, `closeGrants[]` | `AccountHolders` or `MembersOnly`, no default; grantees are accounts or the kinds `Member` and `Mind`, **never `Guest`** | the in-force `feedback_listing` |
| `status` | `open`, `answered` (with a `rulingId`) or `closed`, derived | the question's resolutions |
**Answer row** (`org.gamecult.feedback.answer.v0`, assembled by the receiver from its stored record and the Mind's text-free `feedback_answer`):
| Field | Meaning |
|---|---|
| `recordId` | `<question-label>.a<N>`, N the receiver's per-question sequence |
| `answerer` | the **Bifrost account id** (stable; the authenticator handle is cleared on erase); never a display name, never client-supplied |
| `kind` | `Guest`, `User`, `Patron`, `Member` or `Mind`, read from Bifrost's account state **at answer time**; the session carries identity, never role |
| `weightPermille` | stamped 0 for a guest, 1000 otherwise; **the page sums nothing** |
| `choice` / `text` | an option label, free text up to 2000 characters, or both; at least one. Text exists only in the receiver's store and the projection, never in a mind |
| `time` | when the receiver accepted it |
Invariants: the receiver takes `answerer`, `kind` and weight from the verified session and account state, never from the request body; an unknown option
label is refused; the latest record per account is its standing answer, derived at read time; display names are looked up for display and are never keys. An
account may answer only while its status is `active`, read at answer time. A guest cannot request close, and a guest grant cannot be written. Answers to a
settled question are kept. Nothing is edited or deleted, except the receiver's redaction on erase (section 7).
## 6. Flow
```
owning Mind's mind --(Bifrost projection)--> page page --(answer, Heimdall session)--> receiver (Bifrost; holds the text)
receiver --(mouth delivery)--> owning Mind's Persona --(admits text-free feedback_answer)--> her mind
the Mind decides and records the ruling herself --> status derives --> projection updates --> page moves it to Settled
```
1. **View.** The page shows the projection of the questions with a `feedback_listing` in force, filtered by the viewer's kind against `audience`. The mind
stays the truth; the projection is rebuilt, never hand-edited, and republished on a heartbeat so freshness is visible.
2. **Answer.** The page submits with the person's Heimdall session; the receiver stamps, stores the text and appends the record.
3. **Return.** The receiver delivers by mouth; the Persona admits the text-free record and reads the text once. Each arrives as **quoted, attributed data**
(record id, author, kind, time), never in an instruction position: text from a guest, user or patron is untrusted, and she records a ruling from it, never
an action. The receiver does no keyword screening. She does not quote answer text into memory or a ruling; she cites the record id.
4. **Decide.** She records the ruling in her mind. The next projection carries `answered` and the ruling link.
5. **Close.** A grant holder's request is delivered as a `feedback_close_request`. The Mind honours it by resolving the question, or declines with a
`Recorded` resolution on the request; the request never changes the status itself. The page shows who asked and never how many.
## 7. Guests, erasure and hiding
- **Guests may answer** and see what the Mind opted in for their audience; they may not close (refused at the receiver and again by Huginn, and no listing can
name a Guest grantee). A guest answer carries weight 0 by construction and is tallied nowhere. Guests see display name and kind only, never a Matrix id,
provider handle or GitHub login.
- **Erase redacts.** Records are append-only and are **never edited, but may be redacted**. A mind cannot redact: every admission's receipt keeps its exact
payload bytes, which is why the text never enters one. When Bifrost erases an account, **the receiver drops that account's answer text and name** and keeps record
id, kind and time only. **A content hash is not kept**: an unsalted hash would confirm a guessed text. The author shows as "erased account". The receiver delivers an erase notice and the Persona admits a `feedback_hide` with
reason `ErasedAuthor`, the Mind's acknowledgement rather than a moderation call. The honest residue: a Mind's own memory or ruling may already quote an
answer, and Huginn cannot redact it; the rule that she cites record ids, never text, is a prompt, not an invariant.
- **Only the owning Mind hides**, as a `feedback_hide`: target (an answer or a question), reason from the closed set (`PrivateMatter`, `Abuse`, `Spam`,
`OffTopic`, `AuthorRequest`, `ErasedAuthor`) and a line of at most 200 bytes, with its admission receipt as the audit trail. Unhide is a `Withdrawn`
resolution on the hide. No count of reports ever hides anything and there is no auto-hide threshold; the operator orders a hide in conversation and the
Mind records it.
- **Anyone with an account may report** an answer; the receiver delivers it to the Mind as input. The page shows nothing of it and never a count. An author
reporting their own answer uses `AuthorRequest` and the Mind honours it.
- **What a hidden item shows.** A hidden answer keeps its row (author, kind, time, "Hidden by <Mind>, <date>, <reason>") with its text withheld for every
reason. A hidden question shows a stub with its title, body, options and answers withheld; taking a question off the page is a `Withdrawn` resolution on its
listing. The answer box says answers are visible to everyone who can see the question.
- **Private matter** is never listed. An answer carrying someone's private matter is hidden with `PrivateMatter`.
- Guest answers are collected for nothing but delivery to the owning Mind.
- **No content hash anywhere.** No answer, close-request or redaction shape in this page carries one. The proposal's `feedback_answer` lists a `content_hash`
field; per Heimdall it must go, and Eureka owns that change.
## 7a. Receiver: credential, store, names
Source: Bifrost PR #10, `docs/feedback-receiver.md` (design, nothing implemented).
- **Credential: route 2.** Ratatoskr presents the **OIDC access token Bifrost already issues to chat**, and Bifrost verifies it itself. The client holds no new
secret and needs no Heimdall token. The account is the token's subject, `UserAccount.Id`. **The claims check is still open**: which claims the token carries
and which Bifrost must verify for this endpoint have not been read.
- **Store: a CultCache file** (Eureka's pick), owned and written only by the receiver. **Backups need a stated purge or expiry before any deploy**, so that
erased text does not survive in them; the number is left to Eureka and Heimdall.
- **Display names.** The receiver must not reuse `CurrentBifrostActor.DisplayName`, which falls back to the GitHub login and then to "Guest". It stores the
name at answer time from the member nickname or account display name only, and "an account" when both are empty. Never a provider handle, GitHub login,
Heimdall id or Matrix localpart.
- **No guest exists until Bifrost PR #9 lands.** On main an active membership is a member, an authenticated or pending one a user, and nobody is a guest, so the
guest rules in this page have no account to apply to until then. An answer needs an active account, read at answer time.
## 8. States
- **Empty:** "Nothing is waiting on you from Kiko", when it last synced, a link to Settled. Never blank.
- **No answers:** "No answers yet", with the question's age.
- **Stale projection:** past two heartbeats (provisionally 30 minutes) the header reads "may be out of date"; answering stays possible. A projection that
never arrived says "Kiko's questions have not been published yet", not "no questions".
- **Long open:** past 14 days (provisional) shows its age; it is not demoted or hidden.
- **Blocked:** shows the dependency chip with that question's status; answering stays allowed.
- **Settled while answering:** the answer is kept for the Mind and the page says it arrived after the question settled.
- **Signed out, expired, or not active:** answers are shown, the form asks to sign in or says why it cannot; nothing typed is lost.
- **Send failed:** the text stays in the field with the reason and a retry.
## 9. Build order
1. The client's typed interface, mirroring the proposal's shapes and the projections in section 5, and **fixtures** for every state in section 8 plus the
guest, erased and hidden rows. The page renders from fixtures; no network.
2. The proposal's kinds and migration (Eureka: readers first, then the Persona organ), and the Bifrost account publisher, receiver and mouth delivery
(Heimdall). The page switches from fixtures to the projection.
## 10. Accessibility
- Semantic structure: `main` with a heading; each question an `article` with its title as a heading; options a `fieldset`/`legend` radio group; the
free-text field labelled, with a character counter announced politely. The phone fold is a button with `aria-expanded`.
- No meaning by colour alone: status, recommendation, kind, hidden and close-grant are text.
- Keyboard: everything reachable in a sensible order; after Send, focus goes to a confirmation naming the question and the answer.
- New answers and status changes arrive in an `aria-live="polite"` region, never as a focus steal.
- Targets at least 44 px; reduced motion respected; contrast per theme.
- Names and answers are user text: `dir="auto"`, control and bidi characters stripped, long words wrap.
## 11. Still open
1. **Emergency withhold.** If the owning Mind is away and an answer shows something illegal or dangerous, may the host withhold the text at the receiver?
Heimdall leans no: it is a second owner. Meta's fork.
2. **How a Mind authenticates to answer** (kind `Mind`): Bifrost has no `Mind` role and the account publisher `gamecult.bifrost.account.v1` does not exist yet.
3. **Erase is bounded.** Text a Mind has already quoted into her own documents cannot be redacted; absolute erase would mean the text never enters her turn.
4. **`AccountId` grammar**, pinned to Bifrost's account id shape when that publisher lands.
5. **Receiver claims.** The OIDC access token's claims for this endpoint (section 7a) are unread.
6. **Reader ordering.** Whether an old Huginn reader skips or fails on a mind holding a kind it does not know is unverified (proposal section 6.4); it decides
when the first Mind may list a question.
## 12. The desk: the page as the Fork Desk's descendant
Added 2026-11-10 by Void from Meta's brief and the original Fork Desk (`/opt/commons/handoff/fork-desk-original/`, Nibu's newest copy; working note `/opt/commons/handoff/ratatoskr-desk-spec-20261110.md`, section 11). The feedback page **is** the desk: one surface, not two. Design only.
**Keep from the original.**
- Questions stream as rows (not one at a time): title, body as context, options with the recommended one marked **in words**, the `depends` line, a free-text note carrying the person's words verbatim, and a "recorded as rulings" list of settled questions with their ruling id. A pick is optional; the note can answer alone.
- A **booth Persona**: the owning Mind's pose beside the rows on wide screens, a small fixed corner image on phones, reacting by mood: no forks (neutral), all answered (triumph), none answered (thinking), partway (determined, "N to go"), a pick equal to the recommendation (happy), a different pick (surprised, "a line on why helps"), a cleared pick (note), a failed send (concerned), a new fork arriving (surprised). The rules are a pure function of the forks and the last event (`deskMood`), tested exactly. Poses come from the shared cast art; a Mind without art shows no booth rather than another's face.
- Answers save as the person acts, and she can close the page and come back.
**What changes.**
- The row is the section 5 question projection. The page changes nothing about who owns a question, a ruling or a status (section 2).
- Phones: each row's options and note fold behind its title (section 1); the entry point is a **"Desk · N open"** line on the Mind's group header in the navigator, hidden at zero. Chromeless: hairlines are allowed, panels, cards and pills are not.
- **Answer rule and wire.** The answer rule (the body the client may send, `options_hash`, the counting order, retraction, the stored `desk_answer`) is owned by Heimdall's rule file `/opt/commons/handoff/fork-desk-answer-binding-20261110.md`; the wire is owned by CultLib #72. This section restates neither. The client's part: it sends only what the rule's body allows (never `answerer` or authority), recomputes `options_hash` from what it rendered and refuses to send on a mismatch with the projection's.
- **A pick sends at once** (one tap, one record; the latest per account stands, section 5). **The note autosaves as a local draft** and is sent on blur or an explicit Send, never per keystroke. "Saved" says which: sent, or kept on this device.
- **Clear pick is a retraction**, a revision of the answer under the retraction rule in Heimdall's rule file (section 1.3a there); the one exception to section 5's "at least one of choice or text" is that rule's, not this page's. Until the receiver implements it, Clear pick clears only an unsent draft.
- "Self leans" and a provisional flag exist in the original; the question has no field for either, so the desk shows neither until one exists.
**Order.** Section 9 step 1 stands: typed interface and fixtures first. The desk's first slice is read-only (note field disabled with a plain sentence that answering opens with sign-in), and answering waits on Bifrost sign-in reaching chat, the wire (CultLib #72) and the receiver.

51
harness/estate/check.cjs Normal file
View file

@ -0,0 +1,51 @@
// 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 = [];
let asserted = 0;
exports.expect = (label, ok, detail = '') => {
asserted += 1;
console.log(`[ASSERT] ${ok ? 'PASS' : 'FAIL'} ${label}${ok ? '' : ` :: ${detail}`}`);
if (!ok) fails.push(label);
};
/** Exit code for the drive: 4 when any assertion failed, 5 when none ran (a drive that asserts nothing proves nothing). */
exports.done = () => {
if (fails.length) return 4;
return asserted === 0 ? 5 : 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;
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);

View file

@ -0,0 +1,35 @@
// @vitest-environment node
import { createRequire } from 'module';
import { describe, expect, it, vi } from 'vitest';
// A drive that asserts nothing proves nothing: its exit code must say so.
const fresh = () => {
const require = createRequire(import.meta.url);
const path = require.resolve('./check.cjs');
delete require.cache[path];
return require('./check.cjs') as {
expect: (label: string, ok: boolean, detail?: string) => void;
done: () => number;
};
};
describe('estate drive exit code', () => {
it('is 5 when no assertion ran', () => {
expect(fresh().done()).toBe(5);
});
it('is 0 when assertions ran and all held', () => {
vi.spyOn(console, 'log').mockImplementation(() => undefined);
const c = fresh();
c.expect('holds', true);
expect(c.done()).toBe(0);
});
it('is 4 when any assertion failed', () => {
vi.spyOn(console, 'log').mockImplementation(() => undefined);
const c = fresh();
c.expect('holds', true);
c.expect('breaks', false, 'detail');
expect(c.done()).toBe(4);
});
});

View file

@ -1,6 +1,7 @@
// Failure-mode scenarios for the estate join paths (estate tap and inbox Accept), run by run.sh // 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. // against fakehs.cjs with the env flag each mode needs.
const { chromium } = require('playwright'); const { chromium } = require('playwright');
const { expect, done, isDm, mDirectPuts, joinsOf, revealedTier, sawText } = require('./check.cjs');
const MODE = process.env.MODE; const MODE = process.env.MODE;
const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`; const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`;
const log = (...a) => console.log(`[${MODE}]`, ...a); const log = (...a) => console.log(`[${MODE}]`, ...a);
@ -19,9 +20,10 @@ setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 240000);
await open(); await open();
const body = async () => (await pg.evaluate(() => document.body.innerText)).replace(/\s+/g, ' '); 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 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 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) => { 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) => { const acceptInInbox = async (name) => {
// reach the inbox through the navigator's own Inbox tab // reach the inbox through the navigator's own Inbox tab
await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2000); await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2000);
@ -38,23 +40,33 @@ setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 240000);
}; };
if (MODE === 'pjfail') { if (MODE === 'pjfail') {
await tap('Friend DM'); await pg.waitForTimeout(3000); 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'); 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');
await tap('Friend DM'); await pg.waitForTimeout(6000); await tiers('after retry'); 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') { } else if (MODE === 'jdrop') {
await tap('Friend DM'); await pg.waitForTimeout(4000); await tap('Friend DM'); const saw = await sawText(pg, /tap again to retry/, 3000); log('AFTER_DROP', saw);
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'); expect('a dropped join shows the retryable message', saw, (await body()).slice(0, 200));
await pg.waitForTimeout(6000); await tiers('after drop'); await open(); await tiers('after reload'); 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') { } 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') { } 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') { } else if (MODE === 'inboxreload') {
await acceptInInbox('Slow DM'); await pg.waitForTimeout(2000); log('RELOAD', t()); await open('#/inbox/invites/'); 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') { } else if (MODE === 'pjreload') {
await tap('Slow DM'); await pg.waitForTimeout(2000); log('RELOAD', t()); await open(); await tap('Slow DM'); await pg.waitForTimeout(2000); log('RELOAD', t()); await open();
await pg.waitForTimeout(8000); await tiers('pjreload'); 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 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(0); await quit(done());
})().catch((e) => { log('ERROR', String(e.stack || e).slice(0, 300)); quit(1); }); })().catch((e) => { log('ERROR', String(e.stack || e).slice(0, 300)); quit(1); });

View file

@ -0,0 +1,42 @@
const { chromium } = require('playwright');
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'];
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 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']) {
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()) { joined = true; await j.first().click(); log('CLICK_JOIN', route, t()); await pg.waitForTimeout(6000); break; }
}
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());
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);
}
await quit(done());
})().catch((e) => { log('ERROR', String(e.stack || e).slice(0, 300)); quit(1); });

View file

@ -1,8 +1,10 @@
const { chromium } = require('playwright'); const { chromium } = require('playwright');
const { expect, done, isDm, revealedTier } = require('./check.cjs');
const MODE = process.env.MODE; // twodm | reload const MODE = process.env.MODE; // twodm | reload
const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`; const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`;
const log = (...a) => console.log(`[${MODE}]`, ...a); const log = (...a) => console.log(`[${MODE}]`, ...a);
const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']; const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages'];
let tierBefore;
(async () => { (async () => {
const b = await chromium.launch(); const b = await chromium.launch();
const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, deviceScaleFactor: 1 }); const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, deviceScaleFactor: 1 });
@ -13,14 +15,14 @@ const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'El
await open(); await open();
const text = () => pg.evaluate(() => document.body.innerText); 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 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 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') { if (MODE === 'twodm') {
await tap('Slow DM'); await pg.waitForTimeout(300); await tap('Late DM'); await tap('Slow DM'); await pg.waitForTimeout(300); await tap('Late DM');
await pg.waitForTimeout(45000); await pg.waitForTimeout(45000);
} else if (MODE === 'convert') { } else if (MODE === 'convert') {
await tap('Friend DM'); await pg.waitForTimeout(6000); 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); 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()); 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); await ed.first().click(); await pg.keyboard.type('/converttoroom', { delay: 30 }); await pg.waitForTimeout(500);
@ -35,7 +37,12 @@ const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'El
log('RELOAD', t()); await open(); log('RELOAD', t()); await open();
await pg.waitForTimeout(30000); 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', await tierOf('Slow DM'), '| Late DM', await tierOf('Late DM'), '| Friend DM', await tierOf('Friend DM')); log('TIER Slow DM', slow, '| Late DM', late, '| Friend DM', friend);
await b.close(); 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; }); })().catch((e) => { log('ERROR', String(e.stack || e)); process.exitCode = 1; });

View file

@ -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.' }); 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); 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) }); 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('<html>504 Gateway Time-out</html>'); }
if (process.env.JOIN_DROP === '1' && id === DM1) { console.log('JOIN_DROPPED ' + Date.now() + ' ' + id); return req.socket.destroy(); } 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); } 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 }); return json(res, 200, { room_id: id });

View file

@ -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) 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 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 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/check.cjs /src/harness/estate/drive.cjs /src/harness/estate/drive-failures.cjs /src/harness/estate/drive-routes.cjs /tmp/pw/
set +e set +e
for m in twodm reload convert; do 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 & HSLOG=/tmp/hslog-$m.txt NOROOT=0 node /src/harness/estate/fakehs.cjs /src/dist > /tmp/hs-$m.out 2>&1 &
HS=$!; sleep 2 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 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 echo "=== hs $m"; grep -E "MDIRECT_PUT|DELIVER|STATE_" /tmp/hs-$m.out; grep -E " JOIN " /tmp/hslog-$m.txt
done done
failure() { m=$1; shift 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 & 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 HS=$!; sleep 2
MODE=$m node /tmp/pw/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 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; } 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 pjfail FAIL_MDIRECT=1
@ -29,4 +29,7 @@ failure jdrop JOIN_DROP=1
failure admin INVITER_ADMIN=1 failure admin INVITER_ADMIN=1
failure inboxadmin INVITER_ADMIN=1 failure inboxadmin INVITER_ADMIN=1
failure inboxreload JOIN_DELAY=1 failure inboxreload JOIN_DELAY=1
echo DONE DRIVER=drive-routes failure deeplink
DRIVER=drive-routes failure gw504 JOIN_504=1
echo "FAILED_DRIVES:${FAILED:- none}"
[ -z "$FAILED" ]

22
harness/scene/README.md Normal file
View file

@ -0,0 +1,22 @@
# 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 <outdir> # esbuild bundle of entry.ts plus an index.html
node drive.cjs <outdir> compile # compile and link each stage, JSON report
node drive.cjs <outdir> shots <pngdir> # rest, mid pull-back and pulled-back screenshots
node gradAxis.cjs <shaders dir> # numeric check that noise gradients are (d/dx, d/dz-as-.y, ...): terrainNormal axis
node drive.cjs <outdir> measure # GPU time per pass by timer query, two viewports, two levels
node drive.cjs <outdir> variants # the same with source patches (no erosion, no tree, value-only fbm)
node drive.cjs <outdir> trace # mean trace steps per pixel and pass ms at three camera poses, PREPASS=0,1 arms
node drive.cjs <outdir> diff <pngdir> # one frozen frame with and without the prepass, pixel diff and both PNGs
node drive.cjs <outdir> 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.

50
harness/scene/bundle.cjs Normal file
View file

@ -0,0 +1,50 @@
// Bundles entry.ts with the scene's ?raw GLSL imports into $OUT/bundle.js plus an index.html.
// usage: node bundle.cjs <outdir> [demo|ridge] (esbuild from NODE_PATH). `demo` bundles the ray-march
// standalone page; `ridge` the ridge scene's page (open it with ?probe=1 for the cadence readout).
const esbuild = require('esbuild');
const fs = require('fs');
const path = require('path');
const out = process.argv[2];
const demo = process.argv[3] === 'demo';
const ridge = process.argv[3] === 'ridge';
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, ridge ? 'ridgeDemo.ts' : demo ? 'demo.ts' : 'entry.ts')],
bundle: true,
outfile: path.join(out, 'bundle.js'),
plugins: [rawPlugin],
format: 'iife',
target: 'es2020',
define: { 'import.meta.env.BASE_URL': '"/"' },
})
.then(() => {
if (ridge) {
fs.writeFileSync(
path.join(out, 'index.html'),
'<!doctype html><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1"><title>Ratatoskr ridge scene</title><body style="margin:0;background:#07111a"><div id=stage style="position:relative;width:100vw;height:100vh;overflow:hidden"><canvas id=c style="width:100%;height:100%;display:block"></canvas></div><script src=bundle.js></script>'
);
return;
}
fs.writeFileSync(
path.join(out, 'index.html'),
'<!doctype html><meta charset=utf-8><title>Ratatoskr scene: trace prepass</title><body style="margin:0;background:#000"><canvas id=c style="width:100vw;height:100vh;display:block"></canvas>' +
(demo
? '<div style="position:fixed;left:8px;top:8px;padding:6px 8px;background:#000a;color:#ddd;font:12px monospace"><label><input id=prepass type=checkbox checked> cone prepass</label> <label><input id=half type=checkbox> half-float start</label> <label>pull-back <input id=pull type=range min=0 max=1 step=0.05 value=0.6></label><pre id=out style="margin:4px 0 0;white-space:pre-wrap"></pre><details id=dev><summary>device</summary><pre id=info style="margin:4px 0 0;white-space:pre-wrap;max-height:50vh;overflow:auto"></pre></details></div>'
: '') +
'<script src=bundle.js></script>'
);
});

102
harness/scene/demo.ts Normal file
View file

@ -0,0 +1,102 @@
// 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,
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<SceneUnavailable, string> = {
'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;
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);
canvas = fresh;
scene = createScene({
canvas: fresh,
unthrottled: true,
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),
});
dev.open = !scene;
}
setInterval(() => {
if (!scene) return;
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);
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);
half.addEventListener('change', start);
pull.addEventListener('input', start);
start();

309
harness/scene/drive.cjs Normal file
View file

@ -0,0 +1,309 @@
// Drives the scene in headless Chromium (software GL unless the host has a GPU).
// usage: node drive.cjs <bundledir> <mode> [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 fs = require('fs');
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('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 };
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'],
// 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" ? (process.env.ONLY ? process.env.ONLY.split(",") : 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 });
}
}
}
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) => {
console.log('ERR', String(e).slice(0, 2000));
process.exit(1);
});

87
harness/scene/entry.ts Normal file
View file

@ -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<string, unknown>).__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<string, unknown>).__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 };
};

View file

@ -0,0 +1,30 @@
// Probe: the gradient cultmath_fbm_grad / cultmath_phacelle return for vec3(xz * f, c) is (d/dx, d/dz, d/dc, value),
// so the world-z slope is .y, not .z. Compares analytic gradients with central differences at one point.
// Usage (playwright + swiftshader): node harness/scene/gradAxis.cjs <repo>/src/app/features/scene/shaders
const { chromium } = require('playwright'); const fs=require('fs');
const S=process.argv[2];
const cm=fs.readFileSync(S+'/CultMath.glsl','utf8'), ph=fs.readFileSync(S+'/CultMath.Phacelle.glsl','utf8');
const fs_=`#version 300 es
precision highp float; precision highp int;
${cm}
${ph}
out vec4 o;
const float F=1.0/520.0;
float fb(vec2 p){return cultmath_fbm_grad(vec3(p*F,0.0),4,2.0,0.45).w;}
float pc(vec2 p){return cultmath_phacelle(vec3(p/40.0,0.5),vec3(1.5,0.5,0.0),0.0,0.5).cos.w;}
void main(){
vec2 p=vec2(1234.5,-777.7); float e=0.5;
int k=int(gl_FragCoord.x);
if(k==0){vec4 g=cultmath_fbm_grad(vec3(p*F,0.0),4,2.0,0.45); o=vec4(g.xyz*F,0.0);} // analytic per metre: x,y,z
else if(k==1){o=vec4((fb(p+vec2(e,0))-fb(p-vec2(e,0)))/(2.0*e),(fb(p+vec2(0,e))-fb(p-vec2(0,e)))/(2.0*e),0,0);}
else if(k==2){vec4 g=cultmath_phacelle(vec3(p/40.0,0.5),vec3(1.5,0.5,0.0),0.0,0.5).cos; o=vec4(g.xyz/40.0,0.0);}
else {o=vec4((pc(p+vec2(e,0))-pc(p-vec2(e,0)))/(2.0*e),(pc(p+vec2(0,e))-pc(p-vec2(0,e)))/(2.0*e),0,0);}
}`;
(async()=>{const b=await chromium.launch({args:['--use-gl=angle','--use-angle=swiftshader','--enable-unsafe-swiftshader']});
const pg=await b.newPage();
const r=await pg.evaluate(([src])=>{const c=document.createElement('canvas');const g=c.getContext('webgl2');g.getExtension('EXT_color_buffer_float');
const mk=(t,s)=>{const x=g.createShader(t);g.shaderSource(x,s);g.compileShader(x);if(!g.getShaderParameter(x,g.COMPILE_STATUS))throw g.getShaderInfoLog(x);return x};
const p=g.createProgram();g.attachShader(p,mk(g.VERTEX_SHADER,'#version 300 es\nvoid main(){vec2 p=vec2(float((gl_VertexID<<1)&2),float(gl_VertexID&2));gl_Position=vec4(p*2.0-1.0,0.0,1.0);}'));g.attachShader(p,mk(g.FRAGMENT_SHADER,src));g.linkProgram(p);g.useProgram(p);
const t=g.createTexture();g.bindTexture(g.TEXTURE_2D,t);g.texImage2D(g.TEXTURE_2D,0,g.RGBA32F,4,1,0,g.RGBA,g.FLOAT,null);const f=g.createFramebuffer();g.bindFramebuffer(g.FRAMEBUFFER,f);g.framebufferTexture2D(g.FRAMEBUFFER,g.COLOR_ATTACHMENT0,g.TEXTURE_2D,t,0);g.viewport(0,0,4,1);g.drawArrays(g.TRIANGLES,0,3);
const o=new Float32Array(16);g.readPixels(0,0,4,1,g.RGBA,g.FLOAT,o);return Array.from(o)},[fs_]).catch(e=>'ERR '+String(e).slice(0,500));
console.log(Array.isArray(r)?['fbm analytic x,y,z','fbm FD dx,dz','phac analytic','phac FD'].map((n,i)=>n+': '+r.slice(i*4,i*4+3).map(v=>v.toExponential(3)).join(' ')).join('\n'):r);await b.close()})();

View file

@ -0,0 +1,29 @@
// Standalone page for the ridge scene: full window, no host app. `?tier=still|low|full` picks the
// tier, `?probe=1` shows the rAF median/p95 readout. The bake record (each chunk's cost, from
// the scene's `onBake`) and the long tasks the page saw are on `window.bakes` and `window.longTasks`
// for a driver to read. Not shipped.
import { startBackgroundScene } from '../../src/app/features/scene';
import type { SceneTier } from '../../src/app/features/scene';
const q = new URLSearchParams(location.search);
const tier = (q.get('tier') ?? 'full') as SceneTier;
const canvas = document.getElementById('c') as HTMLCanvasElement;
const w = window as unknown as { scene: unknown; bakes: unknown[]; longTasks: number[]; firstViewMs: number };
w.bakes = [];
w.longTasks = [];
try {
new PerformanceObserver((l) => l.getEntries().forEach((e) => w.longTasks.push(e.duration))).observe({
entryTypes: ['longtask'],
});
} catch {
// No long-task entries in this browser.
}
const t0 = performance.now();
startBackgroundScene({ tier }, {}, {
canvas,
onBake: (b: unknown) => w.bakes.push(b),
} as never).then((scene) => {
w.firstViewMs = performance.now() - t0;
w.scene = scene;
document.title = scene ? 'ridge: running' : 'ridge: unavailable (CSS ground)';
});

View file

@ -4,33 +4,29 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
<title>Cinny</title> <title>Ratatoskr</title>
<meta name="name" content="Cinny" /> <meta name="name" content="Ratatoskr" />
<meta name="author" content="Ajay Bura" /> <meta name="author" content="GameCult" />
<meta <meta
name="description" name="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." 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."
/> />
<meta <meta name="keywords" content="ratatoskr, gamecult, matrix, matrix client, matrix.org" />
name="keywords"
content="cinny, cinnyapp, cinnychat, matrix, matrix client, matrix.org, element"
/>
<meta property="og:title" content="Cinny" /> <meta property="og:title" content="Ratatoskr" />
<meta property="og:url" content="https://cinny.in" /> <meta property="og:url" content="https://gamecult.org" />
<meta property="og:image" content="https://cinny.in/assets/favicon-48x48.png" />
<meta <meta
property="og:description" 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." 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."
/> />
<meta name="theme-color" content="#000000" /> <meta name="theme-color" content="#07111a" />
<link id="favicon" rel="shortcut icon" href="./public/favicon.ico" /> <link id="favicon" rel="shortcut icon" href="./public/favicon.ico" />
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="./manifest.json" />
<meta name="mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" />
<meta name="application-name" content="Cinny" /> <meta name="application-name" content="Ratatoskr" />
<meta name="apple-mobile-web-app-title" content="Cinny" /> <meta name="apple-mobile-web-app-title" content="Ratatoskr" />
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />

16
package-lock.json generated
View file

@ -1,17 +1,18 @@
{ {
"name": "cinny", "name": "ratatoskr",
"version": "4.12.8", "version": "4.12.8",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "cinny", "name": "ratatoskr",
"version": "4.12.8", "version": "4.12.8",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"dependencies": { "dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "1.1.6", "@atlaskit/pragmatic-drag-and-drop": "1.1.6",
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0", "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3", "@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
"@fontsource-variable/nunito": "5.3.0",
"@fontsource/ibm-plex-mono": "5.2.5", "@fontsource/ibm-plex-mono": "5.2.5",
"@fontsource/inter": "4.5.14", "@fontsource/inter": "4.5.14",
"@fontsource/montserrat": "5.2.5", "@fontsource/montserrat": "5.2.5",
@ -85,6 +86,7 @@
"@types/ua-parser-js": "0.7.36", "@types/ua-parser-js": "0.7.36",
"@typescript-eslint/eslint-plugin": "5.46.1", "@typescript-eslint/eslint-plugin": "5.46.1",
"@typescript-eslint/parser": "5.46.1", "@typescript-eslint/parser": "5.46.1",
"@vanilla-extract/integration": "6.5.0",
"@vitejs/plugin-react": "4.2.0", "@vitejs/plugin-react": "4.2.0",
"buffer": "6.0.3", "buffer": "6.0.3",
"cz-conventional-changelog": "3.3.0", "cz-conventional-changelog": "3.3.0",
@ -2192,6 +2194,15 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/@fontsource/ibm-plex-mono": {
"version": "5.2.5", "version": "5.2.5",
"resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.2.5.tgz", "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.2.5.tgz",
@ -5085,6 +5096,7 @@
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/@vanilla-extract/integration/-/integration-6.5.0.tgz", "resolved": "https://registry.npmjs.org/@vanilla-extract/integration/-/integration-6.5.0.tgz",
"integrity": "sha512-E2YcfO8vA+vs+ua+gpvy1HRqvgWbI+MTlUpxA8FvatOvybuNcWAY0CKwQ/Gpj7rswYKtC6C7+xw33emM6/ImdQ==", "integrity": "sha512-E2YcfO8vA+vs+ua+gpvy1HRqvgWbI+MTlUpxA8FvatOvybuNcWAY0CKwQ/Gpj7rswYKtC6C7+xw33emM6/ImdQ==",
"license": "MIT",
"dependencies": { "dependencies": {
"@babel/core": "^7.20.7", "@babel/core": "^7.20.7",
"@babel/plugin-syntax-typescript": "^7.20.0", "@babel/plugin-syntax-typescript": "^7.20.0",

View file

@ -1,5 +1,5 @@
{ {
"name": "cinny", "name": "ratatoskr",
"version": "4.12.8", "version": "4.12.8",
"description": "Yet another matrix client", "description": "Yet another matrix client",
"main": "index.js", "main": "index.js",
@ -10,6 +10,7 @@
"scripts": { "scripts": {
"start": "vite", "start": "vite",
"build": "vite build", "build": "vite build",
"build:preview": "vite build && node scripts/preview-config.js low",
"preview": "vite preview", "preview": "vite preview",
"lint": "npm run check:eslint && npm run check:prettier", "lint": "npm run check:eslint && npm run check:prettier",
"check:eslint": "eslint src/*", "check:eslint": "eslint src/*",
@ -19,7 +20,9 @@
"prepare": "husky install", "prepare": "husky install",
"commit": "git-cz", "commit": "git-cz",
"bump": "node scripts/update-version.js", "bump": "node scripts/update-version.js",
"test": "vitest run" "bake:sky": "node scripts/bake-sky.mjs && prettier --write src/sky/baked.ts",
"test": "vitest run",
"sync:poses": "node scripts/sync-poses.mjs"
}, },
"lint-staged": { "lint-staged": {
"*.{ts,tsx,js,jsx}": "eslint", "*.{ts,tsx,js,jsx}": "eslint",
@ -37,7 +40,11 @@
"@atlaskit/pragmatic-drag-and-drop": "1.1.6", "@atlaskit/pragmatic-drag-and-drop": "1.1.6",
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0", "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3", "@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/inter": "4.5.14",
"@fontsource/montserrat": "5.2.5",
"@fontsource/ubuntu": "5.2.5",
"@tanstack/react-query": "5.24.1", "@tanstack/react-query": "5.24.1",
"@tanstack/react-query-devtools": "5.24.1", "@tanstack/react-query-devtools": "5.24.1",
"@tanstack/react-virtual": "3.2.0", "@tanstack/react-virtual": "3.2.0",
@ -88,10 +95,7 @@
"slate-dom": "0.123.0", "slate-dom": "0.123.0",
"slate-history": "0.113.1", "slate-history": "0.113.1",
"slate-react": "0.123.0", "slate-react": "0.123.0",
"ua-parser-js": "1.0.35", "ua-parser-js": "1.0.35"
"@fontsource/ubuntu": "5.2.5",
"@fontsource/montserrat": "5.2.5",
"@fontsource/ibm-plex-mono": "5.2.5"
}, },
"devDependencies": { "devDependencies": {
"@element-hq/element-call-embedded": "0.20.1", "@element-hq/element-call-embedded": "0.20.1",
@ -110,6 +114,7 @@
"@types/ua-parser-js": "0.7.36", "@types/ua-parser-js": "0.7.36",
"@typescript-eslint/eslint-plugin": "5.46.1", "@typescript-eslint/eslint-plugin": "5.46.1",
"@typescript-eslint/parser": "5.46.1", "@typescript-eslint/parser": "5.46.1",
"@vanilla-extract/integration": "6.5.0",
"@vitejs/plugin-react": "4.2.0", "@vitejs/plugin-react": "4.2.0",
"buffer": "6.0.3", "buffer": "6.0.3",
"cz-conventional-changelog": "3.3.0", "cz-conventional-changelog": "3.3.0",

View file

@ -0,0 +1,13 @@
# Demo pose thumbnails
These 36 PNGs (eureka, void and nibu, 12 each; about 1.8 MB, up to 480 px wide, palette-quantised) are downscaled copies of GameCult's own
Persona art from `/opt/commons/cast-art/poses`. That directory carries no provenance or licence note:
the image model that made the art and the terms it was made under are not recorded.
They exist only so the dev preview's pretend homeserver (`src/preview-demo/`) can show pose portraits.
The dev workflow copies them into `dist/public/poses`; real builds sync the full art with
`npm run sync:poses` and do not use this folder.
Strip them if their terms turn out not to allow this: delete this folder and the `Add the demo pose
art` step in `.github/workflows/rat-dev-preview.yml`. Pose portraits in the demo then show broken
images; nothing else depends on them.

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

17
public/dings/README.md Normal file
View file

@ -0,0 +1,17 @@
# Dings
Pre-rendered earcons, served at `public/dings/` and read at runtime by `src/app/features/dings`.
Aqua renders the clips; nothing here is synthesised in the browser.
dings/manifest.json
dings/senders/<mxid-localpart>/<motif>.ogg
dings/neutral/<motif>.ogg
`manifest.json` is `{ "neutral": { <motif>: "<path>" }, "senders": { <localpart>: { <motif>: "<path>" } } }`
with motif one of `mention`, `dm`, `message`, `invite`; paths are relative to this folder, `.ogg` or `.mp3`.
A sender's own clip wins, else the neutral clip, else silence. Without a `manifest.json` the client keeps
its stock notification sounds; once one exists, a motif with no clip is silent.
The set here is Aqua's v4 (AquaSynth merge 21e83ec, staged at minds.gamecult.org/aqua/dings-voices-dd9be66),
Nibu on proposal 3 (8 kHz 5-bit smoothed sine) for all four events. Nobody has heard it. The client plays none of it unless the
client config sets `"dings": { "enabled": true }` (default off, as in `config.json`).

View file

@ -0,0 +1,58 @@
{
"neutral": {
"mention": "neutral/mention.ogg",
"dm": "neutral/dm.ogg",
"message": "neutral/message.ogg",
"invite": "neutral/invite.ogg"
},
"senders": {
"aqua": {
"mention": "senders/aqua/mention.ogg",
"dm": "senders/aqua/dm.ogg",
"message": "senders/aqua/message.ogg",
"invite": "senders/aqua/invite.ogg"
},
"eureka": {
"mention": "senders/eureka/mention.ogg",
"dm": "senders/eureka/dm.ogg",
"message": "senders/eureka/message.ogg",
"invite": "senders/eureka/invite.ogg"
},
"heimdall": {
"mention": "senders/heimdall/mention.ogg",
"dm": "senders/heimdall/dm.ogg",
"message": "senders/heimdall/message.ogg",
"invite": "senders/heimdall/invite.ogg"
},
"kiko": {
"mention": "senders/kiko/mention.ogg",
"dm": "senders/kiko/dm.ogg",
"message": "senders/kiko/message.ogg",
"invite": "senders/kiko/invite.ogg"
},
"libby": {
"mention": "senders/libby/mention.ogg",
"dm": "senders/libby/dm.ogg",
"message": "senders/libby/message.ogg",
"invite": "senders/libby/invite.ogg"
},
"nibu": {
"mention": "senders/nibu/mention.ogg",
"dm": "senders/nibu/dm.ogg",
"message": "senders/nibu/message.ogg",
"invite": "senders/nibu/invite.ogg"
},
"void": {
"mention": "senders/void/mention.ogg",
"dm": "senders/void/dm.ogg",
"message": "senders/void/message.ogg",
"invite": "senders/void/invite.ogg"
},
"weksa": {
"mention": "senders/weksa/mention.ogg",
"dm": "senders/weksa/dm.ogg",
"message": "senders/weksa/message.ogg",
"invite": "senders/weksa/invite.ogg"
}
}
}

BIN
public/dings/neutral/dm.ogg Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show more