Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
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
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
223 changed files with 7631 additions and 1048 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 = {
env: {
browser: true,
@ -62,6 +67,21 @@ module.exports = {
"@typescript-eslint/no-shadow": "error"
},
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'],
rules: {

View file

@ -1,6 +1,13 @@
{
"defaultHomeserver": 1,
"homeserverList": ["converser.eu", "matrix.org", "mozilla.org", "unredacted.org", "xmr.se"],
"defaultHomeserver": 0,
"homeserverList": [
"gamecult.org",
"converser.eu",
"matrix.org",
"mozilla.org",
"unredacted.org",
"xmr.se"
],
"allowCustomHomeservers": true,
"featuredCommunities": {
"openAsDefault": false,
@ -20,5 +27,14 @@
"backgroundScene": {
"tier": "off"
},
"dings": {
"enabled": false
},
"registration": {
"enabled": false,
"url": ""
}
}

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.

View file

@ -2,12 +2,17 @@
// 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. */
exports.done = () => (fails.length ? 4 : 0);
/** 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. */

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,12 +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. Not shipped.
// 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;
startBackgroundScene({ tier }, {}, { canvas }).then((scene) => {
(window as unknown as { scene: unknown }).scene = scene;
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)';
});

2
package-lock.json generated
View file

@ -86,6 +86,7 @@
"@types/ua-parser-js": "0.7.36",
"@typescript-eslint/eslint-plugin": "5.46.1",
"@typescript-eslint/parser": "5.46.1",
"@vanilla-extract/integration": "6.5.0",
"@vitejs/plugin-react": "4.2.0",
"buffer": "6.0.3",
"cz-conventional-changelog": "3.3.0",
@ -5095,6 +5096,7 @@
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@vanilla-extract/integration/-/integration-6.5.0.tgz",
"integrity": "sha512-E2YcfO8vA+vs+ua+gpvy1HRqvgWbI+MTlUpxA8FvatOvybuNcWAY0CKwQ/Gpj7rswYKtC6C7+xw33emM6/ImdQ==",
"license": "MIT",
"dependencies": {
"@babel/core": "^7.20.7",
"@babel/plugin-syntax-typescript": "^7.20.0",

View file

@ -114,6 +114,7 @@
"@types/ua-parser-js": "0.7.36",
"@typescript-eslint/eslint-plugin": "5.46.1",
"@typescript-eslint/parser": "5.46.1",
"@vanilla-extract/integration": "6.5.0",
"@vitejs/plugin-react": "4.2.0",
"buffer": "6.0.3",
"cz-conventional-changelog": "3.3.0",

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.

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.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 817 B

After

Width:  |  Height:  |  Size: 2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Before After
Before After

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 22 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 22 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 22 KiB

View file

@ -1,17 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<ellipse cx="19" cy="33" rx="13" ry="22" transform="rotate(-15 19 33)" fill="#d9742b"/>
<ellipse cx="21" cy="33" rx="7" ry="15" transform="rotate(-15 21 33)" fill="#f0a45d"/>
<ellipse cx="36" cy="43" rx="11" ry="14" transform="rotate(0 36 43)" fill="#e0833a"/>
<ellipse cx="40" cy="46" rx="6" ry="9" transform="rotate(0 40 46)" fill="#f6dcb8"/>
<polygon points="35,19 36,7 43,15" fill="#c9672a"/>
<polygon points="44,15 50,7 52,19" fill="#c9672a"/>
<circle cx="43" cy="25" r="10" fill="#e0833a"/>
<ellipse cx="47" cy="29" rx="5" ry="3.5" transform="rotate(0 47 29)" fill="#f6dcb8"/>
<circle cx="46.5" cy="23.5" r="1.8" fill="#1a1a1a"/>
<circle cx="47" cy="23" r="0.6" fill="#ffffff"/>
<circle cx="52" cy="27.5" r="1.5" fill="#1a1a1a"/>
<ellipse cx="33" cy="56" rx="5" ry="2.5" transform="rotate(0 33 56)" fill="#c9672a"/>
<ellipse cx="44" cy="56" rx="4.5" ry="2.5" transform="rotate(0 44 56)" fill="#c9672a"/>
<circle cx="52" cy="52" r="11" fill="#ffffff"/>
<circle cx="52" cy="52" r="8" fill="#45B83B"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -1,17 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<ellipse cx="19" cy="33" rx="13" ry="22" transform="rotate(-15 19 33)" fill="#d9742b"/>
<ellipse cx="21" cy="33" rx="7" ry="15" transform="rotate(-15 21 33)" fill="#f0a45d"/>
<ellipse cx="36" cy="43" rx="11" ry="14" transform="rotate(0 36 43)" fill="#e0833a"/>
<ellipse cx="40" cy="46" rx="6" ry="9" transform="rotate(0 40 46)" fill="#f6dcb8"/>
<polygon points="35,19 36,7 43,15" fill="#c9672a"/>
<polygon points="44,15 50,7 52,19" fill="#c9672a"/>
<circle cx="43" cy="25" r="10" fill="#e0833a"/>
<ellipse cx="47" cy="29" rx="5" ry="3.5" transform="rotate(0 47 29)" fill="#f6dcb8"/>
<circle cx="46.5" cy="23.5" r="1.8" fill="#1a1a1a"/>
<circle cx="47" cy="23" r="0.6" fill="#ffffff"/>
<circle cx="52" cy="27.5" r="1.5" fill="#1a1a1a"/>
<ellipse cx="33" cy="56" rx="5" ry="2.5" transform="rotate(0 33 56)" fill="#c9672a"/>
<ellipse cx="44" cy="56" rx="4.5" ry="2.5" transform="rotate(0 44 56)" fill="#c9672a"/>
<circle cx="52" cy="52" r="11" fill="#ffffff"/>
<circle cx="52" cy="52" r="8" fill="#989898"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 1,001 B

After

Width:  |  Height:  |  Size: 59 KiB

Before After
Before After

9
scripts/build-dev-preview.sh Executable file
View file

@ -0,0 +1,9 @@
#!/usr/bin/env bash
# Build this checkout as the offline demo for the standing dev preview: base /void/rat-dev/, the pretend
# homeserver, scene tier $TIER (default full). Output: ./dist, ready for the shared publisher
# (gamecult-ops scripts/publish-mind-preview.sh dist rat-dev), which owns publishing. Publishes nothing.
set -euo pipefail
cd "$(dirname "$0")/.."
node scripts/sync-poses.mjs >/dev/null
VITE_PREVIEW_DEMO=1 CINNY_BASE=/void/rat-dev/ NODE_OPTIONS=--max_old_space_size=4096 nice -n 10 npx vite build
node scripts/preview-config.js "${TIER:-full}" --demo

View file

@ -0,0 +1,32 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { loadServerConfigs } from './ServerConfigsLoader';
const server = (authMetadata: () => Promise<unknown>) =>
({
getCapabilities: async () => ({}),
getMediaConfig: async () => ({ 'm.upload.size': 1 }),
getAuthMetadata: authMetadata,
} as unknown as Parameters<typeof loadServerConfigs>[0]);
describe('loadServerConfigs', () => {
afterEach(() => vi.restoreAllMocks());
it('logs nothing when the homeserver has no OIDC (no auth metadata came back)', async () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
const configs = await loadServerConfigs(
server(async () => {
throw new Error('M_UNRECOGNIZED');
})
);
expect(error).not.toHaveBeenCalled();
expect(configs.authMetadata).toBeUndefined();
expect(configs.mediaConfig).toEqual({ 'm.upload.size': 1 });
});
it('still logs and drops auth metadata that came back malformed', async () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
const configs = await loadServerConfigs(server(async () => ({ issuer: 'https://x' })));
expect(error).toHaveBeenCalled();
expect(configs.authMetadata).toBeUndefined();
});
});

View file

@ -1,5 +1,10 @@
import { ReactNode, useCallback, useMemo } from 'react';
import { Capabilities, validateAuthMetadata, ValidatedAuthMetadata } from 'matrix-js-sdk';
import {
Capabilities,
MatrixClient,
validateAuthMetadata,
ValidatedAuthMetadata,
} from 'matrix-js-sdk';
import { AsyncStatus, useAsyncCallbackValue } from '../hooks/useAsyncCallback';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { MediaConfig } from '../hooks/useMediaConfig';
@ -14,35 +19,42 @@ export type ServerConfigs = {
type ServerConfigsLoaderProps = {
children: (configs: ServerConfigs) => ReactNode;
};
/**
* The homeserver's capabilities, media config and OIDC metadata. A homeserver with no OIDC (and the demo's pretend
* server) returns no auth metadata: there is nothing to validate, so nothing is logged. Metadata that did come back
* and fails validation is logged and dropped.
*/
export async function loadServerConfigs(
mx: Pick<MatrixClient, 'getCapabilities' | 'getMediaConfig' | 'getAuthMetadata'>
): Promise<ServerConfigs> {
const result = await Promise.allSettled([
mx.getCapabilities(),
mx.getMediaConfig(),
mx.getAuthMetadata(),
]);
const capabilities = promiseFulfilledResult(result[0]);
const mediaConfig = promiseFulfilledResult(result[1]);
const authMetadata = promiseFulfilledResult(result[2]);
let validatedAuthMetadata: ValidatedAuthMetadata | undefined;
if (authMetadata) {
try {
validatedAuthMetadata = validateAuthMetadata(authMetadata);
} catch (e) {
console.error(e);
}
}
return { capabilities, mediaConfig, authMetadata: validatedAuthMetadata };
}
export function ServerConfigsLoader({ children }: ServerConfigsLoaderProps) {
const mx = useMatrixClient();
const fallbackConfigs = useMemo(() => ({}), []);
const [configsState] = useAsyncCallbackValue<ServerConfigs, unknown>(
useCallback(async () => {
const result = await Promise.allSettled([
mx.getCapabilities(),
mx.getMediaConfig(),
mx.getAuthMetadata(),
]);
const capabilities = promiseFulfilledResult(result[0]);
const mediaConfig = promiseFulfilledResult(result[1]);
const authMetadata = promiseFulfilledResult(result[2]);
let validatedAuthMetadata: ValidatedAuthMetadata | undefined;
try {
validatedAuthMetadata = validateAuthMetadata(authMetadata);
} catch (e) {
console.error(e);
}
return {
capabilities,
mediaConfig,
authMetadata: validatedAuthMetadata,
};
}, [mx])
useCallback(() => loadServerConfigs(mx), [mx])
);
const configs: ServerConfigs =

View file

@ -0,0 +1,9 @@
import { globalStyle, style } from '@vanilla-extract/css';
/**
* The Ratatoskr mark is white on transparent (Meta's matte). On any light theme (they all carry `prism-light`) a class
* of this name darkens it to the dark ink; put it on every <img> that shows the mark on a themed surface. The favicon and
* notification icons are files the browser draws and cannot take it.
*/
export const LogoMark = style({});
globalStyle(`body.prism-light .${LogoMark}`, { filter: 'brightness(0.18)' });

View file

@ -31,6 +31,7 @@ export const Reply = style({
});
export const ReplyContent = style({
minWidth: 0,
opacity: config.opacity.P300,
selectors: {
@ -39,3 +40,19 @@ export const ReplyContent = style({
},
},
});
export const ReplyText = style({
display: '-webkit-box',
WebkitBoxOrient: 'vertical',
WebkitLineClamp: 2,
overflow: 'hidden',
minWidth: 0,
maxWidth: '100%',
overflowWrap: 'anywhere',
wordBreak: 'break-word',
});
export const ReplyWrap = style({
minWidth: 0,
maxWidth: '100%',
});

View file

@ -101,7 +101,14 @@ export const Reply = as<'div', ReplyProps>(
const bodyJSX = body ? scaleSystemEmoji(trimReplyFromBody(body)) : fallbackBody;
return (
<Box direction="Row" gap="200" alignItems="Center" {...props} ref={ref}>
<Box
className={css.ReplyWrap}
direction="Row"
gap="200"
alignItems="Center"
{...props}
ref={ref}
>
{threadRootId && (
<ThreadIndicator as="button" data-event-id={threadRootId} onClick={onClick} />
)}
@ -119,7 +126,7 @@ export const Reply = as<'div', ReplyProps>(
onClick={onClick}
>
{replyEvent !== undefined ? (
<Text size="T300" truncate>
<Text size="T300" className={css.ReplyText}>
{badEncryption ? <MessageBadEncryptedContent /> : bodyJSX}
</Text>
) : (

View file

@ -30,10 +30,8 @@ type BubbleLayoutProps = {
hideBubble?: boolean;
/** The sender is the signed-in user: right-aligned, in the accent colour, no avatar. */
own?: boolean;
/** A Mind's pose portrait; it replaces the avatar and sits beside (wide) or inside (phone) the bubble. */
/** A Mind's pose portrait; it replaces the avatar and nests in a corner of the bubble. */
pose?: ReactNode;
/** On phones the portrait floats on the other side. */
alt?: boolean;
/** Delay in ms before the first-appearance animation; undefined plays none. */
enterDelay?: number;
before?: ReactNode;
@ -41,7 +39,7 @@ type BubbleLayoutProps = {
};
export const BubbleLayout = as<'div', BubbleLayoutProps>(
({ hideBubble, own, pose, alt, enterDelay, before, header, children, style, ...props }, ref) => {
({ hideBubble, own, pose, enterDelay, before, header, children, style, ...props }, ref) => {
// `false` is a Mind's message continuing a run: aligned with the portraits, showing none.
const poseRow = pose !== undefined;
const arrow = !!before && !poseRow && !own;
@ -50,12 +48,7 @@ export const BubbleLayout = as<'div', BubbleLayoutProps>(
<Box
gap="300"
{...props}
className={classNames(
poseRow && css.BubbleRowPose,
pose && css.BubbleRowPortrait,
own && css.BubbleRowOwn,
props.className
)}
className={classNames(own && css.BubbleRowOwn, props.className)}
style={entering ? { ...style, ['--enter-delay' as string]: `${enterDelay}ms` } : style}
ref={ref}
>
@ -67,19 +60,9 @@ export const BubbleLayout = as<'div', BubbleLayoutProps>(
<Box
grow={own ? 'No' : 'Yes'}
direction="Column"
className={classNames(poseRow && css.BubbleColumnPose, own && css.BubbleColumnOwn)}
className={classNames(own && css.BubbleColumnOwn)}
>
{header && (
<div
className={classNames(
pose && css.BubbleHeaderPose,
pose && alt && css.BubbleHeaderPoseAlt,
own && css.BubbleHeaderOwn
)}
>
{header}
</div>
)}
{header && <div className={classNames(own && css.BubbleHeaderOwn)}>{header}</div>}
{hideBubble ? (
children
) : (
@ -89,6 +72,7 @@ export const BubbleLayout = as<'div', BubbleLayoutProps>(
css.BubbleContent,
arrow && css.BubbleContentArrowLeft,
poseRow && css.BubbleContentPose,
pose && css.BubbleContentPortrait,
own && css.BubbleContentOwn,
entering && css.EnterBubble
)}

View file

@ -1,6 +1,7 @@
import { createVar, globalStyle, keyframes, style, styleVariants } from '@vanilla-extract/css';
import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
import { DefaultReset, color, config, toRem } from 'folds';
import { MOBILE_BREAKPOINT } from '../../../hooks/useScreenSize';
export const StickySection = style({
position: 'sticky',
@ -152,39 +153,21 @@ export const BubbleContentArrowLeft = style({
borderTopLeftRadius: 0,
});
// Look of the three-doors mock-up (chat.gamecult.org/mockups/three-doors): a Mind's pose sits
// beside its bubble on wide screens; on phones it floats inside the bubble, overhanging the top
// edge, and the text wraps around its silhouette (shape-outside on the alpha channel).
// A Mind's pose nests in the corner of its bubble, on phones and wide screens alike: the sprite's
// body sits in the bubble's padding and the bubble's edge cuts it, so nothing hangs outside; the
// text wraps around its silhouette (shape-outside, traced from the alpha channel) and the bubble
// grows to hold it. How much of the art the edge may cut comes from pose/nest.ts.
// Displayed width of the whole sprite image; the crop fractions are multiples of it.
export const portraitWidth = createVar();
const PHONE = 'screen and (max-width: 700px)';
const PORTRAIT = toRem(190);
const PHONE_PORTRAIT = 'min(38vw, 160px)';
const PHONE_TEXT_FLOOR = toRem(170);
const PORTRAIT_OVERHANG = 58;
const PORTRAIT = toRem(124);
const POSE_GUTTER = toRem(14);
const BUBBLE_PAD = toRem(12);
const BUBBLE_CORNER = toRem(6);
const POP = 'cubic-bezier(0.34, 1.7, 0.64, 1)';
const RISE = 'cubic-bezier(0.34, 1.45, 0.64, 1)';
export const BubbleRowPose = style({
position: 'relative',
paddingLeft: `calc(${PORTRAIT} + ${POSE_GUTTER})`,
'@media': {
[PHONE]: {
paddingLeft: 0,
},
},
});
// A row that shows a portrait: room for it to hang above the bubble on phones.
export const BubbleRowPortrait = style({
minHeight: PORTRAIT,
'@media': {
[PHONE]: {
minHeight: 0,
},
},
});
export const BubbleRowOwn = style({
justifyContent: 'flex-end',
});
@ -198,66 +181,31 @@ export const BubbleHeaderOwn = style({
alignSelf: 'flex-end',
});
// Phone: name and time are one plain line in flow above the bubble, on the side opposite the
// portrait. It is as wide as the full name needs, up to the room beside the portrait; only a name
// longer than that wraps (and then the time drops below it). It is capped by the viewport, not by
// the column: a short bubble's column is narrower than its own name, and a percentage cap there
// truncated names to one letter.
export const BubbleHeaderPose = style({
'@media': {
[PHONE]: {
alignSelf: 'flex-end',
// The band above the bubble where the portrait overhangs: the header sits at its foot, beside
// the portrait, so the overhang needs no margin of its own.
minHeight: toRem(PORTRAIT_OVERHANG),
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-end',
margin: `0 ${toRem(14)} ${toRem(2)}`,
width: 'max-content',
maxWidth: `calc(100vw - ${PHONE_PORTRAIT} - ${toRem(64)})`,
textAlign: 'right',
},
},
});
export const BubbleHeaderPoseAlt = style({
'@media': {
[PHONE]: {
alignSelf: 'flex-start',
textAlign: 'left',
},
},
});
// A portrait row with no header (nothing to fill the overhang band) keeps the band as a margin.
globalStyle(`${BubbleRowPortrait}:not(:has(.${BubbleHeaderPose}))`, {
'@media': {
[PHONE]: {
marginTop: toRem(PORTRAIT_OVERHANG),
},
},
});
export const BubbleColumnPose = style({
position: 'relative',
});
// The message body is a flex column, which forms its own block formatting context and would sit
// whole beside the floated portrait; as a plain block its lines wrap around the silhouette and
// then run the bubble's full width below it.
export const BubbleContentPose = style({
borderRadius: config.radii.R500,
borderTopLeftRadius: BUBBLE_CORNER,
display: 'flow-root',
overflow: 'clip',
padding: BUBBLE_PAD,
});
// A bubble that shows a portrait is at least portrait + a readable column wide, or the text beside
// it collapses to a strip.
export const BubbleContentPortrait = style({
vars: { [portraitWidth]: PORTRAIT },
minWidth: `min(100%, calc(${portraitWidth} + ${PHONE_TEXT_FLOOR}))`,
'@media': {
[PHONE]: {
display: 'flow-root',
// A bubble with a portrait is at least portrait + a readable column wide, or the text
// beside the portrait collapses to a strip.
minWidth: `min(100%, calc(${PHONE_PORTRAIT} + ${PHONE_TEXT_FLOOR}))`,
borderTopLeftRadius: config.radii.R500,
padding: `${toRem(24)} ${toRem(14)} ${toRem(14)}`,
vars: { [portraitWidth]: PHONE_PORTRAIT },
},
},
});
globalStyle(`${BubbleContentPose} > div`, {
display: 'block',
});
export const BubbleContentOwn = style({
backgroundColor: color.Primary.Container,
color: color.Primary.OnContainer,
@ -266,51 +214,37 @@ export const BubbleContentOwn = style({
borderTopRightRadius: BUBBLE_CORNER,
});
// The button is the visible part of the sprite (the part inside the bubble's corner); the image
// inside it is offset by the crop. The inline --pose-* fractions (of the image width) come from
// nestView; until the art's size is known the button is simply the whole image.
export const PoseButton = style({
position: 'absolute',
top: 0,
left: `calc(-1 * (${PORTRAIT} + ${POSE_GUTTER}))`,
width: PORTRAIT,
height: PORTRAIT,
float: 'left',
width: `var(--pose-bw, ${portraitWidth})`,
height: 'var(--pose-bh, auto)',
// Out into the padding on the top, bottom and outer side, so the bubble's edge is the crop.
margin: `calc(-1 * ${BUBBLE_PAD}) ${toRem(6)} calc(-1 * ${BUBBLE_PAD}) calc(-1 * ${BUBBLE_PAD})`,
padding: 0,
border: 'none',
background: 'none',
cursor: 'pointer',
lineHeight: 0,
overflow: 'hidden',
shapeMargin: toRem(7),
transformOrigin: '50% 90%',
'@media': {
[PHONE]: {
position: 'static',
float: 'left',
width: PHONE_PORTRAIT,
height: 'auto',
margin: `${toRem(-82)} ${toRem(8)} 0 ${toRem(-12)}`,
shapeMargin: toRem(7),
WebkitMaskImage: 'linear-gradient(#000 84%, transparent)',
maskImage: 'linear-gradient(#000 84%, transparent)',
},
},
});
export const PoseButtonAlt = style({
'@media': {
[PHONE]: {
float: 'right',
margin: `${toRem(-82)} ${toRem(-12)} 0 ${toRem(8)}`,
},
},
float: 'right',
margin: `calc(-1 * ${BUBBLE_PAD}) calc(-1 * ${BUBBLE_PAD}) calc(-1 * ${BUBBLE_PAD}) ${toRem(6)}`,
});
export const PosePortrait = style({
width: '100%',
height: '100%',
objectFit: 'contain',
objectPosition: 'top',
'@media': {
[PHONE]: {
height: 'auto',
},
},
display: 'block',
width: portraitWidth,
maxWidth: 'none',
height: 'auto',
marginLeft: 'var(--pose-ix, 0)',
marginTop: 'var(--pose-iy, 0)',
});
const pop = keyframes({
@ -350,6 +284,9 @@ export const BubbleLeftArrow = style({
zIndex: 1,
});
// Phone tap target: a sender name is 23 px tall, so the button grows 11 px above and below (44+ px to tap) and the
// negative margin takes the growth back out of the layout. Hit area only: no background, border or outline moves.
const SCREEN_PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`;
export const Username = style({
overflow: 'hidden',
whiteSpace: 'nowrap',
@ -357,6 +294,9 @@ export const Username = style({
selectors: {
'button&': {
cursor: 'pointer',
'@media': {
[SCREEN_PHONE]: { paddingBlock: 11, marginBlock: -11 },
},
},
'button&:hover, button&:focus-visible': {
textDecoration: 'underline',
@ -395,61 +335,12 @@ export const MessageTextBody = recipe({
export type MessageTextBodyVariants = RecipeVariants<typeof MessageTextBody>;
// The message content is a flex column, which forms its own block formatting context and so sits
// whole beside the floated portrait: the text stays as narrow as the gap beside it for the
// bubble's full height. As a plain block its lines wrap around the silhouette and then run the
// bubble's full width below it.
globalStyle(`${BubbleContentPose} > div`, {
'@media': {
[PHONE]: {
display: 'block',
},
},
});
// The name wraps instead of ellipsising: the header already caps its own width, and Username's own
// nowrap + ellipsis, and the truncate on the Text inside it, clipped long names to "Heimdall o…".
globalStyle(`${BubbleHeaderPose} ${Username}, ${BubbleHeaderPose} ${Username} *`, {
'@media': {
[PHONE]: {
whiteSpace: 'normal',
overflow: 'visible',
textOverflow: 'clip',
overflowWrap: 'break-word',
},
},
});
// A name too long for one line takes its own lines and the time drops below it, instead of the
// name shrinking to a sliver beside the time (the header's one child is the flex row of both). The
// row hugs the side the header sits on, so a dropped time stays under the name's edge.
globalStyle(`${BubbleHeaderPose} > div`, {
'@media': {
[PHONE]: {
flexWrap: 'wrap',
justifyContent: 'flex-end',
},
},
});
globalStyle(`${BubbleHeaderPoseAlt} > div`, {
'@media': {
[PHONE]: {
justifyContent: 'flex-start',
},
},
});
// An outside list marker hangs left of its item's text and ignores the portrait's silhouette, so
// beside a left portrait the number lands under the art. Inside, it is a word on the first line
// and wraps around the silhouette like the rest.
globalStyle(`${BubbleContentPose} ol, ${BubbleContentPose} ul`, {
'@media': {
[PHONE]: {
listStylePosition: 'inside',
paddingLeft: 0,
},
},
listStylePosition: 'inside',
paddingLeft: 0,
});
// While the background scene runs (html[data-scene]) a chat bubble is the one filled surface in

View file

@ -1,53 +1,126 @@
import React, { useEffect, useRef, useState } from 'react';
import classNames from 'classnames';
import * as css from '../layout/layout.css';
import { poseSrc } from './poses';
import { poseSrc, safeBox } from './poses';
import { alphaBounds, nestView, type Box } from './nest';
import { silhouette } from './silhouette';
type PoseAvatarProps = {
mind: string;
pose: string;
userId: string;
/** Float on the right on phones. */
/** Nest in the bubble's top-right corner instead of the top-left. */
alt?: boolean;
/** Play the first-appearance pop. */
enter?: boolean;
onClick: React.MouseEventHandler<HTMLButtonElement>;
/** Without it the portrait is a plain image: no button, no tab stop. */
onClick?: React.MouseEventHandler<HTMLButtonElement>;
};
// Safe boxes derived from art's alpha, by source; the art is traced once.
const traced = new Map<string, { box: Box; size: [number, number] }>();
const trace = (img: HTMLImageElement, src: string) => {
const hit = traced.get(src);
if (hit) return hit;
const w = img.naturalWidth;
const h = img.naturalHeight;
const ctx = document.createElement('canvas').getContext('2d');
if (!ctx) return undefined;
ctx.canvas.width = w;
ctx.canvas.height = h;
ctx.drawImage(img, 0, 0);
const box = alphaBounds(ctx.getImageData(0, 0, w, h).data, w, h);
if (!box) return undefined;
const found = { box, size: [w, h] as [number, number] };
traced.set(src, found);
return found;
};
export function PoseAvatar({ mind, pose, userId, alt, enter, onClick }: PoseAvatarProps) {
const src = poseSrc(mind, pose, import.meta.env.BASE_URL);
const imgRef = useRef<HTMLImageElement>(null);
const [shape, setShape] = useState<string>();
const measured = safeBox(mind, pose);
const [derived, setDerived] = useState<{ box: Box; size: [number, number] }>();
const art = measured ?? (derived?.size ? derived : undefined);
// Measured boxes are cropped by the share Aqua's numbers allow; a box traced from the alpha is
// already tight to the art, so the bubble may take all of the transparent margin.
const view = art && nestView(art.box, art.size, !!alt, measured ? undefined : 1);
// On phones the bubble text wraps around the portrait's silhouette. Retraced when the art
// loads and when the portrait's width changes with the viewport.
// Art with no measured box: find its extent when it loads.
useEffect(() => {
setDerived(undefined);
const img = imgRef.current;
if (!img || measured) return undefined;
const find = () => {
if (!img.complete || img.naturalWidth === 0) return;
try {
setDerived(trace(img, src));
} catch {
setDerived(undefined);
}
};
find();
img.addEventListener('load', find);
return () => img.removeEventListener('load', find);
}, [src, measured]);
// The bubble text wraps around the portrait's silhouette. Retraced when the art loads, when its
// crop is known, and when its width changes with the viewport.
useEffect(() => {
const img = imgRef.current;
if (!img) return undefined;
const trace = () => {
const retrace = () => {
if (!img.complete || img.naturalWidth === 0) return;
try {
setShape(silhouette(img, !alt));
const box = img.parentElement;
const style = getComputedStyle(img);
setShape(
silhouette(
img,
!alt,
box
? {
x: 0 - parseFloat(style.marginLeft || '0'),
y: 0 - parseFloat(style.marginTop || '0'),
width: box.clientWidth,
height: box.clientHeight,
}
: undefined
)
);
} catch {
setShape(undefined);
}
};
trace();
img.addEventListener('load', trace);
const resize = new ResizeObserver(trace);
retrace();
img.addEventListener('load', retrace);
const resize = new ResizeObserver(retrace);
resize.observe(img);
return () => {
img.removeEventListener('load', trace);
img.removeEventListener('load', retrace);
resize.disconnect();
};
}, [src, alt]);
}, [src, alt, view?.x, view?.y, view?.width, view?.height]);
// Crop fractions are of the image's width, so one set serves every displayed size.
const frac = (px: number) => `calc(${css.portraitWidth} * ${px / (art?.size[0] ?? 1)})`;
const cropStyle =
art && view
? ({
'--pose-bw': frac(view.width),
'--pose-bh': frac(view.height),
'--pose-ix': frac(-view.x),
'--pose-iy': frac(-view.y),
} as React.CSSProperties)
: undefined;
const Tag = onClick ? 'button' : 'span';
return (
<button
type="button"
<Tag
type={onClick ? 'button' : undefined}
className={classNames(css.PoseButton, alt && css.PoseButtonAlt, enter && css.EnterPortrait)}
style={{ shapeOutside: shape }}
style={{ ...cropStyle, shapeOutside: shape }}
data-user-id={userId}
data-pose={pose}
onClick={onClick}
@ -59,6 +132,6 @@ export function PoseAvatar({ mind, pose, userId, alt, enter, onClick }: PoseAvat
alt={`${mind}, ${pose}`}
draggable={false}
/>
</button>
</Tag>
);
}

View file

@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest';
import { alphaBounds, nestView } from './nest';
import boxes from './safeBoxes.json';
describe('nestView', () => {
// Aqua neutral: 560x374, safe box [144,22,492,317] (right margin 68, top 22).
it('crops half the right and top margins for a top-right nest', () => {
expect(nestView([144, 22, 492, 317], [560, 374], true)).toEqual({
x: 0,
y: 11,
width: 526,
height: 363,
});
});
it('crops the left margin instead for a top-left nest', () => {
expect(nestView([144, 22, 492, 317], [560, 374], false)).toEqual({
x: 72,
y: 11,
width: 488,
height: 363,
});
});
it('never crops the top of art already touching it (smug, headband at y=0)', () => {
const v = nestView([140, 0, 487, 241], [560, 374], true);
expect(v.y).toBe(0);
expect(v.height).toBe(374);
});
it('can take the whole margin', () => {
expect(nestView([10, 4, 90, 50], [100, 60], true, 1)).toEqual({
x: 0,
y: 4,
width: 90,
height: 56,
});
});
it('keeps every measured safe box intact at the default share', () => {
const aqua = boxes.aqua as Record<string, number[]>;
expect(Object.keys(aqua)).toHaveLength(15);
Object.values(aqua).forEach(([x0, y0, x1, y1, w, h]) => {
const right = nestView([x0, y0, x1, y1], [w, h], true);
expect(right.x).toBeLessThanOrEqual(x0);
expect(right.x + right.width).toBeGreaterThanOrEqual(x1);
expect(right.y).toBeLessThanOrEqual(y0);
const left = nestView([x0, y0, x1, y1], [w, h], false);
expect(left.x).toBeLessThanOrEqual(x0);
expect(left.x + left.width).toBe(w);
});
});
});
describe('alphaBounds', () => {
it('finds the opaque rectangle', () => {
const w = 6;
const h = 5;
const data = new Uint8ClampedArray(w * h * 4);
[
[2, 1],
[4, 3],
].forEach(([x, y]) => {
data[(y * w + x) * 4 + 3] = 255;
});
expect(alphaBounds(data, w, h)).toEqual([2, 1, 5, 4]);
});
it('reports nothing for an empty image', () => {
expect(alphaBounds(new Uint8ClampedArray(16), 2, 2)).toBeUndefined();
});
});

View file

@ -0,0 +1,58 @@
// A pose nests into the corner of its bubble: the sprite's body sits in the bubble's padding and is
// cut off by the bubble's edge, so nothing hangs outside it.
/** [x0, y0, x1, y1] in image pixels, origin top-left, x1/y1 exclusive. */
export type Box = [number, number, number, number];
// Share of the room between the safe box and the image edge that the bubble edge may cut away.
// Aqua's measurement says all of it can go; half leaves the outline a little air.
export const CROP_SHARE = 0.5;
export type NestView = {
/** The visible region of the image, in pixels. */
x: number;
y: number;
width: number;
height: number;
};
// The part of the image that shows, for a sprite in the bubble's top-right (`right`) or top-left
// corner. Only the corner's two sides are cropped: the side towards the bubble's edge and the top.
// The far side and the bottom stay whole; the bubble's bottom edge cuts the bust.
export const nestView = (
safe: Box,
size: [number, number],
right: boolean,
share = CROP_SHARE
): NestView => {
const [w, h] = size;
const [x0, y0, x1] = safe;
const side = Math.round(share * (right ? w - x1 : x0));
const top = Math.round(share * y0);
const x = right ? 0 : side;
return { x, y: top, width: w - side, height: h - top };
};
// Bounds of the pixels whose alpha exceeds `min`, as a safe box for art with no measured one.
export const alphaBounds = (
data: ArrayLike<number>,
w: number,
h: number,
min = 8
): Box | undefined => {
let x0 = w;
let y0 = h;
let x1 = 0;
let y1 = 0;
for (let y = 0; y < h; y += 1) {
for (let x = 0; x < w; x += 1) {
if (data[(y * w + x) * 4 + 3] > min) {
x0 = Math.min(x0, x);
x1 = Math.max(x1, x + 1);
y0 = Math.min(y0, y);
y1 = y + 1;
}
}
}
return x1 > x0 ? [x0, y0, x1, y1] : undefined;
};

View file

@ -3,10 +3,7 @@ import catalogue from './catalogue.json';
import {
mindOfSender,
poseSrc,
poseStatusLine,
poseStatuses,
resolvePose,
splitNewestStatus,
} from './poses';
describe('resolvePose', () => {
@ -53,52 +50,5 @@ describe('poseSrc', () => {
});
});
describe('poseStatuses', () => {
const ev = (sender: string, ts: number, label: unknown) => ({
sender,
localpart: sender,
ts,
label,
});
it('reports the latest pose per Mind and when its unbroken run began', () => {
const out = poseStatuses([
ev('aqua', 1, 'happy'),
ev('aqua', 2, 'sigh'),
ev('aqua', 3, 'sigh'),
ev('alice', 4, 'happy'),
ev('eureka', 5, 'probe'),
]);
expect(out).toEqual([
{ mind: 'aqua', pose: 'sigh', since: 2 },
{ mind: 'eureka', pose: 'probe', since: 5 },
]);
});
it('treats unknown labels as neutral runs', () => {
expect(poseStatuses([ev('void', 1, 'bogus'), ev('void', 2, undefined)])).toEqual([
{ mind: 'void', pose: 'neutral', since: 1 },
]);
});
});
describe('poseStatusLine', () => {
it('formats the client-status line', () => {
const since = new Date(2026, 10, 4, 9, 5).getTime();
expect(poseStatusLine({ mind: 'void', pose: 'reading', since })).toBe(
'Ratatoskr: Void is reading, since 09:05'
);
});
});
describe('splitNewestStatus', () => {
const s = (mind: string, since: number) => ({ mind, pose: 'happy', since });
it('picks the status that changed last and keeps the others in their order', () => {
const { newest, rest } = splitNewestStatus([s('eureka', 100), s('void', 300), s('nibu', 200)]);
expect(newest?.mind).toBe('void');
expect(rest.map((r) => r.mind)).toEqual(['eureka', 'nibu']);
});
it('has no newest for an empty list', () => {
expect(splitNewestStatus([])).toEqual({ newest: undefined, rest: [] });
});
});

View file

@ -1,4 +1,6 @@
import catalogue from './catalogue.json';
import safeBoxes from './safeBoxes.json';
import type { Box } from './nest';
// Pose labels travel in the event content under POSE_KEY. The catalogue
// (cast-art poses/catalogue.json, synced by `npm run sync:poses`) is the only
@ -40,50 +42,14 @@ export const resolvePose = (mind: string, label: unknown): string => {
export const poseSrc = (mind: string, pose: string, base = '/'): string =>
`${base}public/poses/${artPath(mind, pose) ?? artPath(mind, NEUTRAL)}`;
export type PoseStatus = { mind: string; pose: string; since: number };
export type PoseEvent = {
sender: string;
localpart?: string;
name?: string;
ts: number;
label: unknown;
};
// Latest pose per Mind in a room's events (oldest first), and when that pose
// began: the first of its unbroken run of messages carrying the same pose.
export const poseStatuses = (events: PoseEvent[]): PoseStatus[] => {
const bySender = new Map<string, { mind: string; run: { pose: string; ts: number }[] }>();
events.forEach((e) => {
const mind = mindOfSender(e.localpart, e.name);
if (!mind) return;
const entry = bySender.get(e.sender) ?? { mind, run: [] };
entry.run.push({ pose: resolvePose(mind, e.label), ts: e.ts });
bySender.set(e.sender, entry);
});
return [...bySender.values()].map(({ mind, run }) => {
const last = run[run.length - 1];
let i = run.length - 1;
while (i > 0 && run[i - 1].pose === last.pose) i -= 1;
return { mind, pose: last.pose, since: run[i].ts };
});
};
// The status that changed last, and the rest in their given order (phones show only the first).
export const splitNewestStatus = (
statuses: PoseStatus[]
): { newest: PoseStatus | undefined; rest: PoseStatus[] } => {
let newest: PoseStatus | undefined;
statuses.forEach((st) => {
if (!newest || st.since > newest.since) newest = st;
});
return { newest, rest: statuses.filter((st) => st !== newest) };
};
const two = (n: number) => String(n).padStart(2, '0');
// Client-status text. Rendered locally only; it is never sent to a room.
export const poseStatusLine = ({ mind, pose, since }: PoseStatus): string => {
const d = new Date(since);
const name = mind.charAt(0).toUpperCase() + mind.slice(1);
return `Ratatoskr: ${name} is ${pose}, since ${two(d.getHours())}:${two(d.getMinutes())}`;
// Safe-crop boxes measured from the art's pixels (Aqua, `aqua-pose-crop-boxes-20261110`): the
// head and headphones plus outline, which the bubble's edge must not cut. [x0, y0, x1, y1, width,
// height] in image pixels. Art with no entry gets its box from the sprite's alpha at load time.
const boxes = safeBoxes as Record<string, Record<string, number[]>>;
export const safeBox = (
mind: string,
pose: string
): { box: Box; size: [number, number] } | undefined => {
const b = Object.prototype.hasOwnProperty.call(boxes, mind) ? boxes[mind][pose] : undefined;
return b ? { box: [b[0], b[1], b[2], b[3]], size: [b[4], b[5]] } : undefined;
};

View file

@ -0,0 +1,19 @@
{
"aqua": {
"neutral": [144, 22, 492, 317, 560, 374],
"happy": [157, 22, 506, 317, 574, 374],
"amused": [154, 22, 500, 317, 562, 374],
"thinking": [154, 16, 500, 307, 562, 374],
"annoyed": [154, 4, 499, 282, 562, 374],
"surprised": [141, 5, 485, 282, 560, 374],
"concerned": [156, 4, 501, 283, 574, 374],
"fretful": [156, 16, 503, 305, 574, 374],
"weary": [161, 16, 509, 307, 624, 374],
"glare": [160, 5, 508, 282, 624, 374],
"smug": [140, 0, 487, 241, 560, 374],
"sigh": [154, 0, 500, 241, 574, 374],
"charging": [152, 0, 497, 241, 562, 374],
"delighted": [159, 0, 507, 241, 624, 374],
"content": [144, 16, 492, 306, 560, 374]
}
}

View file

@ -53,4 +53,22 @@ describe('silhouette', () => {
silhouette({ clientWidth: 0, clientHeight: 0 } as HTMLImageElement, true)
).toBeUndefined();
});
it('traces only the visible part of a cropped portrait, drawn at its offset', () => {
const drawImage = vi.fn();
const canvas = {
width: 0,
height: 0,
getContext: () => ({
canvas,
drawImage,
getImageData: () => ({ data: new Uint8ClampedArray(40 * 8 * 4) }),
}),
};
vi.stubGlobal('document', { createElement: () => canvas });
const img = { clientWidth: 100, clientHeight: 20 } as HTMLImageElement;
silhouette(img, true, { x: 0, y: 12, width: 40, height: 8 });
expect([canvas.width, canvas.height]).toEqual([40, 8]);
expect(drawImage).toHaveBeenCalledWith(img, 0, -12, 100, 20);
});
});

View file

@ -5,14 +5,23 @@ const ROW = 4;
// `shape-outside: url()` cannot do this: the browser sizes that image at its natural size and
// anchors it at the margin box, which the phone layout's negative top margin shifts down by the
// overhang, so the exclusion neither matches the art's size nor its place.
export const silhouette = (img: HTMLImageElement, textOnRight: boolean): string | undefined => {
const w = Math.round(img.clientWidth);
const h = Math.round(img.clientHeight);
//
// `view` is the part of the displayed image that shows (the button's box, in displayed pixels):
// the portrait is cropped by the bubble, so the polygon is traced over what is left of the art.
export type View = { x: number; y: number; width: number; height: number };
export const silhouette = (
img: HTMLImageElement,
textOnRight: boolean,
view?: View
): string | undefined => {
const w = Math.round(view?.width ?? img.clientWidth);
const h = Math.round(view?.height ?? img.clientHeight);
const ctx = w > 0 && h > 0 ? document.createElement('canvas').getContext('2d') : null;
if (!ctx) return undefined;
ctx.canvas.width = w;
ctx.canvas.height = h;
ctx.drawImage(img, 0, 0, w, h);
ctx.drawImage(img, 0 - (view?.x ?? 0), 0 - (view?.y ?? 0), img.clientWidth, img.clientHeight);
const { data } = ctx.getImageData(0, 0, w, h);
const outer = textOnRight ? 0 : w;
const edge: string[] = [];

View file

@ -2,13 +2,15 @@ import { ComplexStyleRule, StyleRule, createVar, globalStyle, style } from '@van
import { RecipeVariants, recipe } from '@vanilla-extract/recipes';
import { ContainerColor, DefaultReset, Disabled, RadiiVariant, color, config, toRem } from 'folds';
import { surfaceInk } from '../../features/scene/sceneShell.css';
import { skyLabelInk } from '../../features/scene/skyLabelInk';
// Wash strengths over the scene: rows and labels are faint, and hover, selected and pressed rows
// are three distinct steps above them.
const SCRIM_LABEL = '12%';
const SCRIM_ROW = '16%';
const SCRIM_HOVER = '26%';
const SCRIM_SELECTED = '36%';
// Meta, 2026-11-09: "this UI needs no chrome". Rows and labels are bare ink on the scene; hover and selection keep a faint wash.
const SCRIM_LABEL = '0%';
const SCRIM_ROW = '0%';
const SCRIM_HOVER = '14%';
const SCRIM_SELECTED = '22%';
const SCRIM_PRESSED = '48%';
export const NavCategory = style([
@ -221,6 +223,7 @@ phoneLabel(`.${NavItemBase}:has(.${NavLink}:active) ${label}`, {
textDecorationLine: 'underline',
});
phoneLabel(`:root[data-scene] ${label}`, {
color: skyLabelInk('inherit'),
textShadow: `0 0 3px ${surfaceInk}, 0 0 8px ${surfaceInk}`,
});

View file

@ -0,0 +1,51 @@
import { style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
import { MOBILE_BREAKPOINT } from '../../hooks/useScreenSize';
const WIDE = `screen and (min-width: ${MOBILE_BREAKPOINT + 1}px)`;
/** One column, bare ink on the scene. */
export const Column = style({
maxWidth: toRem(720),
width: '100%',
margin: '0 auto',
padding: config.space.S400,
display: 'flex',
flexDirection: 'column',
gap: config.space.S200,
});
/** A tappable text row: no border, no fill, at least 44 px tall. */
export const Row = style({
all: 'unset',
boxSizing: 'border-box',
cursor: 'pointer',
minHeight: 44,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
gap: toRem(2),
color: color.Background.OnContainer,
':focus-visible': { textDecoration: 'underline' },
});
export const Back = style([Row, { minHeight: 44 }]);
export const Files = style({
display: 'flex',
flexDirection: 'column',
gap: config.space.S400,
});
/** Images may tile into a grid on wide screens only. */
export const FilesWide = style({
'@media': {
[WIDE]: {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
alignItems: 'start',
},
},
});
export const Dim = style({ opacity: config.opacity.P300 });

View file

@ -0,0 +1,141 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Box, Scroll, Text } from 'folds';
import { Room, RoomEvent } from 'matrix-js-sdk';
import { MediaControl } from '../../components/media';
import { AudioContent } from '../../components/message';
import { RenderMessageContent } from '../../components/RenderMessageContent';
import { IAudioContent } from '../../../types/matrix/common';
import { LINKIFY_OPTS, getReactCustomHtmlParser } from '../../plugins/react-custom-html-parser';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { markAsRead } from '../../utils/notifications';
import { getBlobSafeMimeType } from '../../utils/mimeTypes';
import { timeDayMonYear } from '../../utils/time';
import { getMemberDisplayName } from '../../utils/room';
import { Album, AlbumFile } from './buildAlbums';
import { useAlbums } from './useAlbums';
import * as css from './AlbumView.css';
/**
* Audio goes straight to AudioContent, skipping the chat Attachment frame and file-type badge: the
* title above it is the only label and the transport controls sit on the scene. Anything that is
* not playable audio falls through to the stock renderer (a file download).
*/
const playableAudio = (content: Record<string, unknown>) => {
const c = content as IAudioContent;
const url = c.file?.url ?? c.url;
const mimeType = getBlobSafeMimeType(c.info?.mimetype ?? '');
if (!c.info || typeof url !== 'string' || !mimeType.startsWith('audio')) return undefined;
return { info: c.info, url, mimeType, encInfo: c.file };
};
type FileRowProps = { room: Room; file: AlbumFile; author: string };
function FileRow({ room, file, author }: FileRowProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
const htmlReactParserOptions = useMemo(
() =>
getReactCustomHtmlParser(mx, room.roomId, { linkifyOpts: LINKIFY_OPTS, useAuthentication }),
[mx, room.roomId, useAuthentication]
);
const audio = file.kind === 'audio' ? playableAudio(file.content) : undefined;
return (
<Box direction="Column" gap="100">
<Text size="T300" truncate>
{file.name}
</Text>
{audio ? (
<AudioContent {...audio} renderMediaControl={(p) => <MediaControl {...p} />} />
) : (
<RenderMessageContent
displayName={author}
msgType={file.content.msgtype as string}
ts={file.ts}
getContent={<T,>() => file.content as T}
mediaAutoLoad={mediaAutoLoad}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={LINKIFY_OPTS}
/>
)}
</Box>
);
}
const countLabel = (n: number) => `${n} ${n === 1 ? 'file' : 'files'}`;
/** The zone never mounts RoomTimeline, so it sends the read receipt the timeline would. */
function useMarkZoneRead(room: Room) {
const mx = useMatrixClient();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
useEffect(() => {
const mark = () => {
markAsRead(mx, room.roomId, hideActivity).catch(() => undefined);
};
mark();
room.on(RoomEvent.Timeline, mark);
return () => {
room.off(RoomEvent.Timeline, mark);
};
}, [mx, room, hideActivity]);
}
type AlbumViewProps = { room: Room; onTimeline: () => void };
export function AlbumView({ room, onTimeline }: AlbumViewProps) {
useMarkZoneRead(room);
const { loading, error, albums } = useAlbums(room);
const [dateFormat] = useSetting(settingsAtom, 'dateFormatString');
const [openId, setOpenId] = useState<string>();
const open: Album | undefined = albums.find((a) => a.id === openId);
const authorOf = (a: Album) => getMemberDisplayName(room, a.author) ?? a.author;
const encrypted = room.hasEncryptionStateEvent();
let body: React.ReactNode;
if (loading) body = <Text>Loading albums…</Text>;
else if (error) body = <Text>Could not load albums: {error.message}</Text>;
else if (open) {
const images = open.files.length > 0 && open.files.every((f) => f.kind === 'image');
body = (
<>
<button type="button" className={css.Back} onClick={() => setOpenId(undefined)}>
<Text size="B400">‹ Albums</Text>
</button>
<Text size="H4">{open.title}</Text>
<Text size="T200" className={css.Dim}>
{authorOf(open)} · {countLabel(open.count)} · {timeDayMonYear(open.ts, dateFormat)}
</Text>
{open.count === 0 && <Text>This album has no files.</Text>}
<div className={images ? `${css.Files} ${css.FilesWide}` : css.Files}>
{open.files.map((f) => (
<FileRow key={f.id} room={room} file={f} author={authorOf(open)} />
))}
</div>
</>
);
} else if (encrypted)
body = <Text>This zone is encrypted, so its albums cannot be listed here.</Text>;
else if (albums.length === 0) body = <Text>No albums yet.</Text>;
else {
body = albums.map((a) => (
<button key={a.id} type="button" className={css.Row} onClick={() => setOpenId(a.id)}>
<Text size="T400">{a.title}</Text>
<Text size="T200" className={css.Dim}>
{authorOf(a)} · {countLabel(a.count)} · {timeDayMonYear(a.ts, dateFormat)}
</Text>
</button>
));
}
return (
<Scroll hideTrack visibility="Hover">
<div className={css.Column}>
{body}
<button type="button" className={css.Row} onClick={onTimeline}>
<Text size="B400">Timeline ›</Text>
</button>
</div>
</Scroll>
);
}

View file

@ -0,0 +1,216 @@
import { describe, expect, it } from 'vitest';
import { AlbumInput, ZoneEvent, buildAlbums } from './buildAlbums';
const root = (id: string, ts: number, over: Partial<ZoneEvent> = {}): ZoneEvent => ({
event_id: id,
type: 'm.room.message',
sender: '@void:x',
origin_server_ts: ts,
content: { msgtype: 'm.text', body: `album ${id}` },
...over,
});
const reply = (id: string, ts: number, msgtype: string, body = id, extra = {}): ZoneEvent => ({
event_id: id,
type: 'm.room.message',
sender: '@void:x',
origin_server_ts: ts,
content: { msgtype, body, ...extra },
});
const one = (r: ZoneEvent, replies: ZoneEvent[]) => buildAlbums([{ root: r, replies }])[0];
describe('buildAlbums', () => {
it('lists the newest album first', () => {
const inputs: AlbumInput[] = [
{ root: root('$old', 100), replies: [] },
{ root: root('$new', 300), replies: [] },
{ root: root('$mid', 200), replies: [] },
];
expect(buildAlbums(inputs).map((a) => a.id)).toEqual(['$new', '$mid', '$old']);
});
it('takes title, author and date from the root', () => {
const a = one(root('$r', 555, { sender: '@eureka:x' }), []);
expect([a.title, a.author, a.ts]).toEqual(['album $r', '@eureka:x', 555]);
});
it('lists files oldest first whatever order the server returned them in', () => {
const a = one(root('$r', 1), [
reply('$c', 30, 'm.audio'),
reply('$a', 10, 'm.audio'),
reply('$b', 20, 'm.audio'),
]);
expect(a.files.map((f) => f.id)).toEqual(['$a', '$b', '$c']);
});
it('counts media replies only; a text reply is not a file', () => {
const a = one(root('$r', 1), [
reply('$t', 5, 'm.text', 'nice'),
reply('$n', 6, 'm.notice', 'bot'),
reply('$a', 10, 'm.audio'),
reply('$f', 11, 'm.file'),
]);
expect(a.count).toBe(2);
expect(a.files.map((f) => f.id)).toEqual(['$a', '$f']);
});
it('keeps a root with no media as an album of zero files', () => {
const a = one(root('$r', 1), [reply('$t', 5, 'm.text')]);
expect(a).toMatchObject({ id: '$r', count: 0, files: [], cover: undefined });
});
it('uses the first image as the cover', () => {
const a = one(root('$r', 1), [
reply('$a', 5, 'm.audio'),
reply('$i2', 20, 'm.image'),
reply('$i1', 10, 'm.image'),
]);
expect(a.cover?.id).toBe('$i1');
expect(one(root('$q', 1), [reply('$a', 5, 'm.audio')]).cover).toBeUndefined();
});
it('derives the kind from the msgtype', () => {
const a = one(root('$r', 1), [
reply('$1', 1, 'm.audio'),
reply('$2', 2, 'm.image'),
reply('$3', 3, 'm.video'),
reply('$4', 4, 'm.file'),
]);
expect(a.files.map((f) => f.kind)).toEqual(['audio', 'image', 'video', 'file']);
});
it('names a file by filename, else body', () => {
const a = one(root('$r', 1), [
reply('$1', 1, 'm.audio', 'caption', { filename: 'clip.wav' }),
reply('$2', 2, 'm.audio', 'plain.wav'),
]);
expect(a.files.map((f) => f.name)).toEqual(['clip.wav', 'plain.wav']);
});
it('does not duplicate a file the relations endpoint returned twice', () => {
const a = one(root('$r', 1), [reply('$a', 5, 'm.audio'), reply('$a', 5, 'm.audio')]);
expect(a.count).toBe(1);
});
it('does not duplicate an album listed twice', () => {
expect(
buildAlbums([
{ root: root('$r', 1), replies: [] },
{ root: root('$r', 1), replies: [] },
])
).toHaveLength(1);
});
it('skips redacted and empty events', () => {
const redacted = {
...reply('$x', 5, 'm.audio'),
content: {},
unsigned: { redacted_because: {} },
};
const stillTyped = { ...reply('$y', 6, 'm.audio'), unsigned: { redacted_because: {} } };
const empty: ZoneEvent = { event_id: '$z' };
const a = one(root('$r', 1), [redacted, stillTyped, empty, reply('$ok', 7, 'm.audio')]);
expect(a.files.map((f) => f.id)).toEqual(['$ok']);
expect(buildAlbums([{ root: root('$g', 1, { content: {} }), replies: [] }])).toEqual([]);
expect(
buildAlbums([{ root: root('$h', 1, { unsigned: { redacted_because: {} } }), replies: [] }])
).toEqual([]);
});
it('tolerates events with missing fields', () => {
const a = one({ event_id: '$r', content: { msgtype: 'm.text' } }, [
{ event_id: '$a', content: { msgtype: 'm.audio' } },
]);
expect(a).toMatchObject({ title: 'Untitled album', author: '', ts: 0, count: 1 });
});
it('does not read inherited object keys as file kinds', () => {
const a = one(root('$r', 1), [
reply('$c', 1, 'constructor'),
reply('$p', 2, '__proto__'),
reply('$t', 3, 'toString'),
]);
expect(a.count).toBe(0);
});
it('skips events that are not room messages even if their content looks like media', () => {
const reaction = { ...reply('$x', 5, 'm.audio'), type: 'm.reaction' };
const a = one(root('$r', 1), [reaction, reply('$ok', 6, 'm.audio')]);
expect(a.files.map((f) => f.id)).toEqual(['$ok']);
expect(buildAlbums([{ root: root('$g', 1, { type: 'm.reaction' }), replies: [] }])).toEqual([]);
});
it('skips events without an event id', () => {
const anon: ZoneEvent = { type: 'm.room.message', content: { msgtype: 'm.audio', body: 'a' } };
expect(one(root('$r', 1), [anon]).count).toBe(0);
expect(
buildAlbums([{ root: { content: { msgtype: 'm.text', body: 'x' } }, replies: [] }])
).toEqual([]);
});
it('titles an edited album by its latest edit', () => {
const edited = root('$r', 1, {
unsigned: {
'm.relations': {
'm.replace': { content: { 'm.new_content': { msgtype: 'm.text', body: 'renamed' } } },
},
},
});
expect(one(edited, []).title).toBe('renamed');
const emptyEdit = root('$q', 1, { unsigned: { 'm.relations': { 'm.replace': {} } } });
expect(one(emptyEdit, []).title).toBe('album $q');
});
it('survives null or non-object entries in inputs and replies', () => {
const bad = [null, undefined, 'x', 7] as unknown as ZoneEvent[];
const a = buildAlbums([
null as unknown as AlbumInput,
{ root: null as unknown as ZoneEvent, replies: [] },
{ root: root('$r', 1), replies: [...bad, reply('$ok', 2, 'm.audio')] },
{ root: root('$s', 2), replies: null as unknown as ZoneEvent[] },
]);
expect(a.map((x) => x.id)).toEqual(['$s', '$r']);
expect(a[1].files.map((f) => f.id)).toEqual(['$ok']);
});
it('treats a non-numeric timestamp as 0 so it sorts first, not by string order', () => {
const a = one(root('$r', 1), [
reply('$b', 20, 'm.audio'),
{ ...reply('$bad', 0, 'm.audio'), origin_server_ts: '5' as unknown as number },
{ ...reply('$nan', 0, 'm.audio'), origin_server_ts: NaN },
reply('$a', 10, 'm.audio'),
]);
expect(a.files.map((f) => f.id)).toEqual(['$bad', '$nan', '$a', '$b']);
const albums = buildAlbums([
{ root: root('$x', 100), replies: [] },
{ root: root('$y', 0, { origin_server_ts: '999' as unknown as number }), replies: [] },
]);
expect(albums.map((x) => [x.id, x.ts])).toEqual([
['$x', 100],
['$y', 0],
]);
});
it('gives an album with an empty body a fallback title', () => {
expect(one(root('$r', 1, { content: { msgtype: 'm.text', body: '' } }), []).title).toBe(
'Untitled album'
);
expect(one(root('$q', 1, { content: { msgtype: 'm.text' } }), []).title).toBe('Untitled album');
});
it('never counts an edit event as a file or an album', () => {
const edit = reply('$e', 9, 'm.audio', '* x', {
'm.relates_to': { rel_type: 'm.replace', event_id: '$a' },
'm.new_content': { msgtype: 'm.audio', body: 'x' },
});
const a = one(root('$r', 1), [reply('$a', 5, 'm.audio'), edit]);
expect(a.files.map((f) => f.id)).toEqual(['$a']);
const rootEdit = root('$re', 1, {
content: { msgtype: 'm.text', body: '* t', 'm.relates_to': { rel_type: 'm.replace' } },
});
expect(buildAlbums([{ root: rootEdit, replies: [] }])).toEqual([]);
const threaded = reply('$t', 6, 'm.audio', 't', {
'm.relates_to': { rel_type: 'm.thread', event_id: '$r' },
});
expect(one(root('$q', 1), [threaded]).count).toBe(1);
});
});

View file

@ -0,0 +1,121 @@
// Pure: turns the thread roots of a file-zone room and each root's thread replies, in the JSON
// Synapse returns from GET /rooms/{roomId}/threads and GET /rooms/{roomId}/relations/{root}/m.thread,
// into albums. See docs/file-zone.md for the rulings this encodes.
/** The slice of a Matrix event this module reads. Every field may be missing. */
export type ZoneEvent = {
event_id?: string;
type?: string;
sender?: string;
origin_server_ts?: number;
content?: Record<string, unknown>;
unsigned?: Record<string, unknown>;
};
export type AlbumInput = { root: ZoneEvent; replies: ZoneEvent[] };
export type FileKind = 'audio' | 'image' | 'video' | 'file';
export type AlbumFile = {
id: string;
kind: FileKind;
name: string;
ts: number;
/** The event content, as RenderMessageContent reads it. */
content: Record<string, unknown>;
};
export type Album = {
id: string;
title: string;
author: string;
ts: number;
cover?: AlbumFile;
files: AlbumFile[];
count: number;
};
// A Map, not an object: msgtype is server-supplied and "constructor" must not name a kind.
const KIND_OF_MSGTYPE = new Map<string, FileKind>([
['m.audio', 'audio'],
['m.image', 'image'],
['m.video', 'video'],
['m.file', 'file'],
]);
// A redacted event keeps its id but loses its content and gains unsigned.redacted_because.
const live = (
e: ZoneEvent
): e is ZoneEvent & { event_id: string; content: Record<string, unknown> } =>
!!e &&
typeof e === 'object' &&
!!e.event_id &&
// An edit is an m.room.message too, but it revises an event; it is never a file or an album.
(e.content?.['m.relates_to'] as { rel_type?: unknown } | undefined)?.rel_type !== 'm.replace' &&
!e.unsigned?.redacted_because &&
(e.type === undefined || e.type === 'm.room.message') &&
typeof e.content?.msgtype === 'string';
const tsOf = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
const asString = (v: unknown): string => (typeof v === 'string' ? v : '');
const toFile = (e: ZoneEvent): AlbumFile | undefined => {
if (!live(e)) return undefined;
const kind = KIND_OF_MSGTYPE.get(e.content.msgtype as string);
if (!kind) return undefined;
return {
id: e.event_id,
kind,
name: asString(e.content.filename) || asString(e.content.body),
ts: tsOf(e.origin_server_ts),
content: e.content,
};
};
export const UNTITLED = 'Untitled album';
// An edited root carries its latest edit under unsigned["m.relations"]["m.replace"]; the title is
// that edit's m.new_content.body, else the original body.
const titleOf = (root: ZoneEvent & { content: Record<string, unknown> }): string => {
const relations = root.unsigned?.['m.relations'] as Record<string, unknown> | undefined;
const edit = relations?.['m.replace'] as ZoneEvent | undefined;
const edited = (edit?.content?.['m.new_content'] as Record<string, unknown> | undefined)?.body;
return asString(edited) || asString(root.content.body) || UNTITLED;
};
const toAlbum = (input: AlbumInput): Album | undefined => {
const { root, replies } = input ?? {};
if (!live(root)) return undefined;
const seen = new Set<string>();
const files = (Array.isArray(replies) ? replies : [])
.map(toFile)
.filter((f): f is AlbumFile => {
if (!f || seen.has(f.id)) return false;
seen.add(f.id);
return true;
})
.sort((a, b) => a.ts - b.ts);
return {
id: root.event_id,
title: titleOf(root),
author: root.sender ?? '',
ts: tsOf(root.origin_server_ts),
cover: files.find((f) => f.kind === 'image'),
files,
count: files.length,
};
};
/** Newest album first; files oldest first. Loose room messages never reach this function. */
export const buildAlbums = (inputs: AlbumInput[]): Album[] => {
const seen = new Set<string>();
return inputs
.map(toAlbum)
.filter((a): a is Album => {
if (!a || seen.has(a.id)) return false;
seen.add(a.id);
return true;
})
.sort((a, b) => b.ts - a.ts);
};

View file

@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { isFileZone } from './isFileZone';
const room = { roomId: '!f:x', aliases: ['#files:x'] };
describe('isFileZone', () => {
it('is true for the file-zone create type', () => {
expect(isFileZone({ ...room, createType: 'org.gamecult.file_zone' }, {})).toBe(true);
});
it('is false for other create types and for plain rooms', () => {
expect(isFileZone({ ...room, createType: 'm.space' }, {})).toBe(false);
expect(isFileZone({ ...room, createType: 'org.gamecult.other' }, {})).toBe(false);
expect(isFileZone({ ...room, createType: 'org.gamecult.file_zone.x' }, {})).toBe(false);
expect(isFileZone(room, {})).toBe(false);
expect(isFileZone(room, { fileZoneRooms: [] })).toBe(false);
});
it('is true when the room id or an alias is configured', () => {
expect(isFileZone(room, { fileZoneRooms: ['!f:x'] })).toBe(true);
expect(isFileZone(room, { fileZoneRooms: ['#other:x', '#files:x'] })).toBe(true);
});
it('is false when only another room is configured', () => {
expect(isFileZone(room, { fileZoneRooms: ['!g:x', '#g:x'] })).toBe(false);
});
});

View file

@ -0,0 +1,14 @@
export const FILE_ZONE_TYPE = 'org.gamecult.file_zone';
export type FileZoneRoom = {
roomId: string;
/** Canonical and alternative aliases. */
aliases?: string[];
/** The `type` of the room's m.room.create content. */
createType?: string;
};
/** A room is the file zone when its create type says so or the client config lists it. */
export const isFileZone = (room: FileZoneRoom, config: { fileZoneRooms?: string[] }): boolean =>
room.createType === FILE_ZONE_TYPE ||
!!config.fileZoneRooms?.some((r) => r === room.roomId || room.aliases?.includes(r));

View file

@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { mapLimit } from './mapLimit';
describe('mapLimit', () => {
it('keeps result order and never exceeds the limit', async () => {
let inFlight = 0;
let peak = 0;
const out = await mapLimit([5, 1, 4, 2, 3, 0], 2, async (n) => {
inFlight += 1;
peak = Math.max(peak, inFlight);
await new Promise((r) => {
setTimeout(r, n);
});
inFlight -= 1;
return n * 10;
});
expect(out).toEqual([50, 10, 40, 20, 30, 0]);
expect(peak).toBe(2);
});
it('handles an empty list and rejects when a call rejects', async () => {
expect(await mapLimit([], 3, async (n) => n)).toEqual([]);
await expect(
mapLimit([1, 2], 2, async (n) => {
if (n === 2) throw new Error('boom');
return n;
})
).rejects.toThrow('boom');
});
});

View file

@ -0,0 +1,19 @@
/** Like Promise.all(items.map(fn)) but with at most `limit` calls in flight; result order kept. */
export const mapLimit = async <T, R>(
items: T[],
limit: number,
fn: (item: T) => Promise<R>
): Promise<R[]> => {
const out = new Array<R>(items.length);
let next = 0;
const worker = async () => {
while (next < items.length) {
const i = next;
next += 1;
// eslint-disable-next-line no-await-in-loop
out[i] = await fn(items[i]);
}
};
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
return out;
};

View file

@ -0,0 +1,73 @@
import { useQuery } from '@tanstack/react-query';
import { ClientPrefix, Method, Room } from 'matrix-js-sdk';
import { StateEvent } from '../../../types/matrix/room';
import { useStateEvent } from '../../hooks/useStateEvent';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useClientConfig } from '../../hooks/useClientConfig';
import { Album, ZoneEvent, buildAlbums } from './buildAlbums';
import { isFileZone } from './isFileZone';
import { mapLimit } from './mapLimit';
const REPLY_FETCHES_IN_FLIGHT = 6;
const REFRESH_MS = 30_000;
type Chunk = { chunk?: ZoneEvent[] };
/** True when the room is the file zone: by its create type, or listed in the client config. */
export const useIsFileZone = (room: Room): boolean => {
const config = useClientConfig();
// Subscribes to alias changes (alt aliases live in the same event).
useStateEvent(room, StateEvent.RoomCanonicalAlias);
const createType = room.currentState.getStateEvents('m.room.create', '')?.getContent().type;
return isFileZone(
{
roomId: room.roomId,
aliases: [room.getCanonicalAlias(), ...room.getAltAliases()].filter((a): a is string => !!a),
createType,
},
config
);
};
export type AlbumsState = { loading: boolean; error: Error | null; albums: Album[] };
/**
* Slice 1 reads ONE page of each endpoint (threads: 100 roots; replies per root: 100). A zone with
* more albums or files than that is truncated; pagination is a later slice. Reads are made with
* the logged-in client, so the room must be unencrypted: encrypted events carry no msgtype and
* are skipped.
*/
export const useAlbums = (room: Room): AlbumsState => {
const mx = useMatrixClient();
const { roomId } = room;
const query = useQuery({
queryKey: ['albums', roomId],
queryFn: async (): Promise<Album[]> => {
const get = (path: string, params: Record<string, string>) =>
mx.http.authedRequest<Chunk>(Method.Get, path, params, undefined, {
prefix: ClientPrefix.V1,
});
const room_ = encodeURIComponent(roomId);
const roots = (await get(`/rooms/${room_}/threads`, { limit: '100' })).chunk ?? [];
const inputs = await mapLimit(roots, REPLY_FETCHES_IN_FLIGHT, async (root: ZoneEvent) => ({
root,
replies:
(
await get(
`/rooms/${room_}/relations/${encodeURIComponent(root.event_id ?? '')}/m.thread`,
{ limit: '100', dir: 'f' }
)
).chunk ?? [],
}));
return buildAlbums(inputs);
},
retry: 1,
refetchInterval: REFRESH_MS,
});
return {
loading: query.isLoading,
// A failed refetch keeps the albums already loaded; the error shows only when there are none.
error: query.data ? null : (query.error as Error | null),
albums: query.data ?? [],
};
};

View file

@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import { zoneView } from './zoneView';
describe('zoneView', () => {
it('opens albums by default and the timeline for an event deep link', () => {
expect(zoneView(undefined, undefined)).toBe('albums');
expect(zoneView(undefined, '$e')).toBe('timeline');
});
it('lets an event link win over an earlier choice of albums', () => {
expect(zoneView('albums', '$e')).toBe('timeline');
});
it('honours the choice when there is no event link', () => {
expect(zoneView('albums', undefined)).toBe('albums');
expect(zoneView('timeline', undefined)).toBe('timeline');
});
});

View file

@ -0,0 +1,5 @@
export type ZoneView = 'albums' | 'timeline';
/** The view a file-zone room shows. A link to an event always wins; else the user's choice; else albums. */
export const zoneView = (choice: ZoneView | undefined, eventId: string | undefined): ZoneView =>
eventId ? 'timeline' : choice ?? 'albums';

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