Release candidate: dev 64edacac to main #71

Merged
void merged 198 commits from release/dev-64edacac into main 2026-11-10 17:42:16 +00:00
223 changed files with 14237 additions and 481 deletions

View file

@ -4,12 +4,14 @@ on:
pull_request:
types: ['opened', 'synchronize']
concurrency:
group: ${{ github.workflow }}-pr-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
build-pull-request:
name: Build pull request
runs-on: ubuntu-latest
env:
PR_NUMBER: ${{github.event.number}}
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@ -24,17 +26,3 @@ jobs:
env:
NODE_OPTIONS: '--max_old_space_size=4096'
run: npm run build
- name: Upload artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: preview
path: dist
retention-days: 1
- name: Save pr number
run: echo ${PR_NUMBER} > ./pr.txt
- name: Upload pr number
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: pr
path: ./pr.txt
retention-days: 1

View file

@ -6,6 +6,10 @@ on:
- 'Dockerfile'
- '.github/workflows/docker-pr.yml'
concurrency:
group: ${{ github.workflow }}-pr-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
docker-build:
runs-on: ubuntu-latest

View file

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

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

@ -0,0 +1,46 @@
name: Rat dev preview
on:
push:
branches: ['dev']
# One run per ref wins: a newer push to dev cancels the older queued or running one, so the run
# that builds rat-dev-dist is always the newest. Pull-request runs are other workflows and other
# groups; they never cancel it.
concurrency:
group: rat-dev-preview
cancel-in-progress: true
jobs:
build:
name: Build dev preview
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup node
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version-file: ".node-version"
package-manager-cache: false
- name: Install dependencies
run: npm ci
- name: Build app
env:
CINNY_BASE: /void/rat-dev/
VITE_PREVIEW_DEMO: '1'
NODE_OPTIONS: '--max_old_space_size=3072'
run: npm run build
- name: Set background scene tier and the pretend homeserver
run: node scripts/preview-config.js low --demo
- name: Add the demo pose art
run: mkdir -p dist/public/poses && cp -r preview-demo/poses/. dist/public/poses/
- name: Upload artifact
# v3 protocol: the v4 client keeps only the origin of ACTIONS_RESULTS_URL, dropping this forge's /forge subpath
# (nginx 404 page, runs 170-171), and upstream v4+ refuses non-GitHub servers outright (run 169).
uses: forgejo/upload-artifact@v3
with:
name: rat-dev-dist
path: dist
retention-days: 3

5
.gitignore vendored
View file

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

View file

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

View file

@ -1,8 +1,8 @@
# Contributing to Cinny
# Contributing to Ratatoskr
First off, thanks for taking the time to contribute! ❤️
This document describes the process of contributing to Cinny. It is intended
This document describes the process of contributing to Ratatoskr. It is intended
for anyone considering opening an **issue**, **discussion** or **pull request**.
For people who are interested in developing Cinny and technical details behind
it, please check out our ["Developing Cinny"](HACKING.md) document as well.
@ -24,7 +24,7 @@ explain what your changes do and how they interact with the greater system, do n
## AI Usage
The Cinny project stictly does not allow any AI generated code. Any pull request containing it will be rejected. **This is very important.**
The Cinny project strictly does not allow any AI generated code. Any pull request containing it will be rejected. **This is very important.**
## Quick Guide
@ -35,7 +35,7 @@ The Cinny project stictly does not allow any AI generated code. Any pull request
> When contributing to this project, you must agree that you have authored 100%
> of the content, that you have the necessary rights to the content and that
> the content you contribute may be provided under the project license. You will
> also be asked to [sign the CLA] ([why CLA?]) upon submiting your pull request.
> also be asked to [sign the CLA] ([why CLA?]) upon submitting your pull request.
[All issues are actionable](#issues-are-actionable). Pick one and start
working on it. Thank you. If you need help or guidance, comment on the issue.

View file

@ -1,4 +1,4 @@
# Developing Cinny
# Developing Ratatoskr
> [!TIP]
> We recommend using a version manager as versions change very quickly.
@ -36,13 +36,13 @@ source and serves it with Nginx on port 80. To use this locally, you can
build the container like so:
```
docker build -t cinny:latest .
docker build -t ratatoskr:latest .
```
You can then run the container you've built with a command similar to this:
```
docker run -p 8080:80 cinny:latest
docker run -p 8080:80 ratatoskr:latest
```
This will forward your `localhost` port 8080 to the container's port 80.

140
README.md
View file

@ -1,125 +1,43 @@
# Cinny
<p>
<a href="https://github.com/ajbura/cinny/releases">
<img alt="GitHub release downloads" src="https://img.shields.io/github/downloads/ajbura/cinny/total?logo=github&style=social"></a>
<a href="https://hub.docker.com/r/ajbura/cinny">
<img alt="DockerHub downloads" src="https://img.shields.io/docker/pulls/ajbura/cinny?logo=docker&style=social"></a>
<a href="https://fosstodon.org/@cinnyapp">
<img alt="Follow on Mastodon" src="https://img.shields.io/mastodon/follow/106845779685925461?domain=https%3A%2F%2Ffosstodon.org&logo=mastodon&style=social"></a>
<a href="https://twitter.com/intent/follow?screen_name=cinnyapp">
<img alt="Follow on Twitter" src="https://img.shields.io/twitter/follow/cinnyapp?logo=twitter&style=social"></a>
<a href="https://cinny.in/#sponsor">
<img alt="Sponsor Cinny" src="https://img.shields.io/opencollective/all/cinny?logo=opencollective&style=social"></a>
</p>
# Ratatoskr
A Matrix client focusing primarily on simple, elegant and secure interface.
The main goal is to have an instant messaging application that is easy on
people and has a modern touch.
- [Roadmap](https://github.com/orgs/cinnyapp/projects/1)
- [Contributing](./CONTRIBUTING.md)
GameCult's Matrix chat client, a fork of [Cinny](https://github.com/cinnyapp/cinny).
It opens on GameCult's estate (spaces, project rooms grouped by steward, Minds,
and your own place in it) instead of an invite list. Mobile first.
> [!IMPORTANT]
We are currently in the process of [replacing] the matrix-js-sdk with our
own SDK. As a result, we will not be accepting any pull requests until
further notice. Thank you for your understanding.
**Live now:** the Cinny engine (matrix-js-sdk, Rust crypto, sync) under a
Ratatoskr UI: branding, GameCult theme, estate navigation, pose portraits
(opt-in setting), a background scene and soundscape (opt-in, off by default).
**Planned:** see [`docs/gamecult/ratatoskr-target.md`](docs/gamecult/ratatoskr-target.md)
for the target and cut sequence. Internal names that are protocol or stored
state (`cinny_*` localStorage keys, the `in.cinny.room.power_level_tags` event
type, `CINNY_BASE`) are unchanged on purpose.
[replacing]: https://github.com/cinnyapp/cinny/issues/257#issuecomment-3714406704
Entry points: `src/app/` (client UI), `src/app/features/scene/` (background
scene), `src/app/features/soundscape/` (audio socket), `harness/` (estate
drive scripts), `scripts/gen-brand.mjs` (regenerates the logo and icons).
<img align="center" src="https://raw.githubusercontent.com/cinnyapp/cinny-site/main/assets/preview2-light.png" height="380">
## Upstream
Ratatoskr is based on Cinny by Ajay Bura and contributors, licensed AGPL-3.0.
Cinny's own README, desktop app and releases live at
<https://github.com/cinnyapp/cinny>. We do not ship its releases or images.
## Getting started
The web app is available at [app.cinny.in] and gets updated on each new
release. The `dev` branch is continuously deployed at [dev.cinny.in]
but keep in mind that it could have things broken.
You can also download our desktop app from the [cinny-desktop repository].
[app.cinny.in]: https://app.cinny.in
[dev.cinny.in]: https://dev.cinny.in
[cinny-desktop repository]: https://github.com/cinnyapp/cinny-desktop
## Contributing and Developing
If you have any ideas, issues, etc. regarding Cinny, or would like to
contribute to Cinny through pull requests, please check out our
["Contributing to Cinny"](CONTRIBUTING.md) document. Those who would like
to get involved with Cinny's development as well should also read the
["Developing Cinny"](HACKING.md) document for more technical details.
## Developing
See [HACKING.md](HACKING.md). Contribution notes are in [CONTRIBUTING.md](CONTRIBUTING.md).
## Self-hosting
To host Cinny on your own, simply download the tarball from
[GitHub releases], and serve the files from `dist/` using your preferred
webserver. Alternatively, you can just pull the docker image from
[DockerHub] or [GitHub Container Registry].
Build with `npm ci && npm run build` and serve `dist/` with your webserver.
* The default homeservers and explore pages are defined in [`config.json`](config.json).
* You need to set up redirects to serve the assests. Example configurations;
[netlify](netlify.toml), [nginx](contrib/nginx/cinny.domain.tld.conf),
[caddy](contrib/caddy/caddyfile).
* If you have trouble configuring redirects you can
[enable hash routing](config.json#L35) — the url in the browser will have
a `/#/` between the domain and open channel (ie. `app.cinny.in/#/home/`
instead of `app.cinny.in/home/`) but you won't have to configure your webserver.
* To deploy on subdirectory, you need to rebuild the app youself after
updating the `base` path in [`build.config.ts`](build.config.ts).
* For example, if you want to deploy on `https://cinny.in/app`, then
set `base: '/app'`.
[GitHub releases]: https://github.com/cinnyapp/cinny/releases/latest
[DockerHub]: https://hub.docker.com/r/ajbura/cinny
[GitHub Container Registry]: https://github.com/cinnyapp/cinny/pkgs/container/cinny
<details><summary><b>PGP Public Key to verify tarball</b></summary>
```
-----BEGIN PGP PUBLIC KEY BLOCK-----
mQGNBGJw/g0BDAC8qQeLqDMzYzfPyOmRlHVEoguVTo+eo1aVdQH2X7OELdjjBlyj
6d6c1adv/uF2g83NNMoQY7GEeHjRnXE4m8kYSaarb840pxrYUagDc0dAbJOGaCBY
FKTo7U1Kvg0vdiaRuus0pvc1NVdXSxRNQbFXBSwduD+zn66TI3HfcEHNN62FG1cE
K1jWDwLAU0P3kKmj8+CAc3h9ZklPu0k/+t5bf/LJkvdBJAUzGZpehbPL5f3u3BZ0
leZLIrR8uV7PiV5jKFahxlKR5KQHld8qQm+qVhYbUzpuMBGmh419I6UvTzxuRcvU
Frn9ttCEzV55Y+so4X2e4ZnB+5gOnNw+ecifGVdj/+UyWnqvqqDvLrEjjK890nLb
Pil4siecNMEpiwAN6WSmKpWaCwQAHEGDVeZCc/kT0iYfj5FBcsTVqWiO6eaxkUlm
jnulqWqRrlB8CJQQvih/g//uSEBdzIibo+ro+3Jpe120U/XVUH62i9HoRQEm6ADG
4zS5hIq4xyA8fL8AEQEAAbQdQ2lubnlBcHAgPGNpbm55YXBwQGdtYWlsLmNvbT6J
AdQEEwEIAD4CGwMFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AWIQSRri2MHidaaZv+
vvuUMwx6UK/M8wUCZqEDwAUJFvwIswAKCRCUMwx6UK/M877qC/4lxXOQIoWnLLkK
YiRCTkGsH6NdxgeYr6wpXT4xuQ45ZxCytwHpOGQmO/5up5961TxWW8D1frRIJHjj
AZGoRCL3EKEuY8nt3D99fpf3DvZrs1uoVAhiyn737hRlZAg+QsJheeGCmdSJ0hX5
Yud8SE+9zxLS1+CEjMrsUd/RGre/phme+wNXfaHfREAC9ewolgVChPIbMxG2f+vs
K8Xv52BFng7ta9fgsl1XuOjpuaSbQv6g+4ONk/lxKF0SmnhEGM3dmIYPONxW47Yf
atnIjRra/YhPTNwrNBGMmG4IFKaOsMbjW/eakjWTWOVKKJNBMoDdRcYYWIMCpLy8
AQUrMtQEsHSnqCwrw818S5A6rrhcfVGk36RGm0nOy6LS5g5jmqaYsvbCcBGY9B2c
SUAVNm17oo7TtEajk8hcSXoZod1t++pyjcVKEmSn3nFK7v5m3V+cPhNTxZMK459P
3x1Ucqj/kTqrxKw6s2Uknuk0ajmw0ljV+BQwgL6maguo9BKgCNW5AY0EYnD+DQEM
ANOu/d6ZMF8bW+Df9RDCUQKytbaZfa+ZbIHBus7whCD/SQMOhPKntv3HX7SmMCs+
5i27kJMu4YN623JCS7hdCoXVO1R5kXCEcneW/rPBMDutaM472YvIWMIqK9Wwl5+0
Piu2N+uTkKhe9uS2u7eN+Khef3d7xfjGRxoppM+xI9dZO+jhYiy8LuC0oBohTjJq
QPqfGDpowBwRkkOsGz/XVcesJ1Pzg4bKivTS9kZjZSyT9RRSY8As0sVUN57AwYul
s1+eh00n/tVpi2Jj9pCm7S0csSXvXj8v2OTdK1jt4YjpzR0/rwh4+/xlOjDjZEqH
vMPhpzpbgnwkxZ3X8BFne9dJ3maC5zQ3LAeCP5m1W0hXzagYhfyjo74slJgD1O8c
LDf2Oxc5MyM8Y/UK497zfqSPfgT3NhQmhHzk83DjXw3I6Z3A3U+Jp61w0eBRI1nx
H1UIG+gldcAKUTcfwL0lghoT3nmi9JAbvek0Smhz00Bbo8/dx8vwQRxDUxlt7Exx
NwARAQABiQG8BBgBCAAmAhsMFiEEka4tjB4nWmmb/r77lDMMelCvzPMFAmahA9IF
CRb8CMUACgkQlDMMelCvzPPQgQv/d5/z+fxgKqgfhQX+V49X4WgTVxZ/CzztDoJ1
XAq1dzTNEy8AFguXIo6eVXPSpMxec7ZreN3+UPQBnCf3eR5YxWNYOYKmk0G4E8D2
KGUJept7TSA42/8N2ov6tToXFg4CgzKZj0fYLwgutly7K8eiWmSU6ptaO8aEQBHB
gTGIOO3h6vJMGVycmoeRnHjv4wV84YWSVFSoJ7cY0he4Z9UznJBbE/KHZjrkXsPo
N+Gg5lDuOP5xjKzM5SogV9lhxBAhMWAg3URUF15yruZBiA8uV1FOK8sal/9C1G7V
M6ygA6uOZqXlZtcdA94RoSsW2pZ9eLVPsxz2B3Zko7tu11MpNP/wYmfGTI3KxZBj
n/eodvwjJSgHpGOFSmbNzvPJo3to5nNlp7wH1KxIMc6Uuu9hgfDfwkFZgV2bnFIa
Q6gyF548Ub48z7Dz83+WwLgbX19ve4oZx+dqSdczP6ILHRQomtrzrkkP2LU52oI5
mxFo+ioe/ABCufSmyqFye0psX3Sp
=WtqZ
-----END PGP PUBLIC KEY BLOCK-----
```
</details>
* Serving the assets needs redirects to `index.html`. Examples:
[netlify](netlify.toml), [nginx](contrib/nginx/ratatoskr.domain.tld.conf),
[caddy](contrib/caddy/caddyfile). Without them you can
[enable hash routing](config.json) (`hashRouter`); URLs then carry `/#/`.
* To deploy on a subdirectory, rebuild with the `base` path set, either in
[`build.config.ts`](build.config.ts) or through `CINNY_BASE`
(for example `CINNY_BASE=/app/ npm run build`).
## License
Cinny Project
Ratatoskr is a fork of the Cinny project
Copyright © 2024–present Ajay Bura
https://cinny.in

View file

@ -2,16 +2,15 @@
"defaultHomeserver": 1,
"homeserverList": ["converser.eu", "matrix.org", "mozilla.org", "unredacted.org", "xmr.se"],
"allowCustomHomeservers": true,
"featuredCommunities": {
"openAsDefault": false,
"spaces": ["#gamecult:gamecult.org"],
"rooms": [
"#tuwunel:grin.hu",
"#freesoftware:matrix.org",
"#free-software-movement:matrix.org",
"#gentoo:matrix.org"
],
"servers": ["matrixrooms.info", "matrix.org", "mozilla.org", "unredacted.org"]
"servers": ["activematrixrooms.com", "matrixrooms.info", "matrix.org", "mozilla.org", "unredacted.org"]
},
"hashRouter": {

View file

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

View file

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

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

@ -0,0 +1,106 @@
# File zone: design sketch
Status: design only. No code exists. Everything below is a proposal; items marked **UNKNOWN** are not yet verified against the Body.
Source: Meta ruling 2026-10-31, eureka mind `ratatoskr:ruling:file-zone-looks-like-file-sharing`.
## What it is
A file-sharing space beside the room timeline, not a view of it. The zone is **one ordinary Matrix room with threaded discussions, one thread per Mind**. The UI presents each thread as an **album** (title, item count, date) and each post in it as a **file** with an inline player or preview. Matrix is the storage and the fallback: another client sees a normal room with threads and never needs the zone. No homeserver change.
First use (Aqua): listen to audio in place, mark each clip keep / fix / drop with a one-line reason, export the marks as a file. See `/opt/commons/handoff/aqua-listen-kits-20261108/` (static `listen.html` kits that emit `verdict.json` + `verdict.md`); the zone replaces sending that page around.
## Matrix event shapes (stock events only)
- **Zone** = one room, chosen by the client (**UNKNOWN:** how the client knows which room is the zone: a room-account-data flag, a `m.room.create` type, or a configured room id in `config.json`; config is simplest).
- **Album** = a thread root: a text `m.room.message` posted by a Mind, `body` = the album title (the Mind's name, e.g. `void`). One thread per Mind, owned by authorship (the root's sender). **UNKNOWN:** whether a Mind may open more than one (e.g. per kit); the shape allows it with no change, the zone would then list several albums per author. Count and last-activity come from the SDK's thread summary (`unsigned.m.relations.m.thread`); album date = root `origin_server_ts`. Rename = `m.replace` on the root.
- **File** = a post in the thread: an `m.room.message` with `msgtype` `m.audio` / `m.image` / `m.video` / `m.file` and `m.relates_to: { rel_type: "m.thread", event_id: <root>, is_falling_back: true, "m.in_reply_to": { event_id: <last thread event> } }`. The builders in `src/app/features/room/msgContent.ts` already make the content. Delete = redact.
- **Album vs chat thread**: with a single-purpose room, every thread is an album. No marker key is needed. A non-media reply in a thread (chat, a mark) is not a file and is not listed as one.
- **Cover** = first image file in the thread, if any. Loose room messages outside any thread are not shown in the zone; they remain in the timeline.
## Marks (reactions, plus an optional reply)
A mark is stock Matrix, so every client shows it. Two reaction sets on the **file post**, and one optional reply:
- **Verdict** = an `m.annotation` reaction (`m.reaction` event, `m.relates_to: { rel_type: "m.annotation", event_id: <file post>, key }`) with key **👍 keep**, **🔧 fix**, **🗑 drop**. One per person per file: choosing another replaces the old one (the client redacts the sender's previous verdict reaction, then sends the new one); tapping the active one clears it.
- **Landed** = a second reaction set, keys **1️⃣ 2️⃣ 3️⃣ 4️⃣ 5️⃣** (keycap digits, 1-5). Also one per person per file, same replace rule. The two sets are independent: a file can have a verdict, a landed score, both or neither.
- **Reason** = an ordinary thread reply under the file: `m.room.message` `m.text`, `m.relates_to: { rel_type: "m.thread", event_id: <album root>, "m.in_reply_to": { event_id: <file post> } }`. Optional. **Keep needs none**; the UI prompts for one mainly after fix or drop. The latest such reply by the sender under that file is the reason (edits via `m.replace` count). No special key: the reply text is the reason, so nothing is lost if another client wrote it.
- **No private mode.** Marks are room events the moment they are tapped; there is no local-only draft and no "Share" step. Offline taps queue in the SDK's normal send queue.
- Who marked what is read from the reactions' senders, so each listener is distinguishable and the album can show tallies per file.
- Why reactions: one tap, readable in any client, no custom event type, and they can be changed with no history growth beyond a redaction. Reaction keys carry no text, hence the separate reply for the reason.
- **UNKNOWN:** emoji variation selectors. 🗑 and 🔧 and the keycaps may arrive with or without U+FE0F from different clients; match by normalizing (strip U+FE0F) before comparing keys. Also **UNKNOWN:** servers' per-event limits on distinct reaction keys; a handful per file is far below any default.
## Export
Client-side only. "Export" builds Aqua's verdict file from the verdict and landed reactions and the reply under each file in one album (one listener's marks per export; that sender is the `listener`) and either downloads it or uploads it into the album. Two files, as the kits write them: `verdict.json` and `verdict.md`.
`verdict.json` is schema **`aqua.listen.verdict.v1`**, field for field what `buildVerdict` in the kit (`dings/listen.html`, section `buildVerdict`) emits, so Aqua's tooling reads it unchanged:
```
{ schema: "aqua.listen.verdict.v1",
kit: { id, title }, // album root: id = root event id (or the kit id if the upload carried one), title = root body
exportedAt, // ISO time of export
seed, consent, // kit-only: zone writes seed: 0 (or omits), consent: true for the sender's own export (see UNKNOWN)
listener, // sender display name
device, // headphones|speakers|null, the sender's most-used mark device, or null
notes, // free text; zone leaves "" in v1
summary: { total, keep, fix, drop, unanswered },
clips: [{ position, id, title, group, feel, file, pair, slot, verdict, reason, landed, device }],
pairs: [{ a, b, preferred }] } // empty array when the album has no pairs
```
- `clips` has one row per file post in the album, in thread order (`position` = 1-based). The join per file, for the exporting listener: `verdict` = their 👍/🔧/🗑 reaction (keep/fix/drop) or null; `landed` = their keycap reaction (1-5) or null; `reason` = text of their latest reply under the file, or "". A file with no verdict reaction counts as `unanswered` even if it has a reply. `device` per clip is null (reactions cannot carry it); the top-level `device` (headphones|speakers|null) is asked once in the export dialog.
- `pairs`, `seed`, `consent` are optional for the zone: `pairs: []`, no `slot`/`pair` (null) unless the clip metadata supplies them.
- **Where clip metadata comes from.** `id` is the clip id from `org.gamecult.clip`, else the filename stem. `title` = file post `body`; `file` = the post's `filename`/`body`. `group`, `feel`, `pair`, `slot` are kit data that Matrix has no slot for: carry them on the file post as one extra key `org.gamecult.clip: { id, group, feel, pair, slot }` written by whoever uploads the kit (a small uploader script, **UNKNOWN**, not in the first slice; without it those columns export as ""/null and the id falls back to the filename stem).
- `verdict.md` follows `verdictMarkdown` from the same file: header line, count line, the clip table (`# | clip | group | intended feel | verdict | landed (1-5) | device | reason`), pairs table when `pairs` is nonempty, notes.
- To avoid drift, the exporter should be a port of `buildVerdict`/`verdictMarkdown` with the schema string and field order pinned by a test that compares against a fixture produced by the kit. **UNKNOWN:** whether to share code (the kit's `logic.cjs`) rather than port; recommend port, ~80 lines, because the kit is a standalone page.
- **UNKNOWN:** `consent` is a kit-side checkbox before listening. The zone has no such step; recommend writing `true` only if the sender ticks a one-time "include my name in exports" toggle, else `false` and `listener: ""`.
- JSON here is a xenos-boundary export, not state; the room events are the state.
## Mobile-first layout
- Phone width is the design width; desktop adds columns.
- Level 1, album list: one column, each row = cover/icon, title, "N files", date. Tap opens.
- Level 2, album: list by default (title, duration/size), grid toggle for images. Audio rows play in place, one at a time (starting a clip stops the last), a persistent mini-player at the bottom with seek.
- Mark controls on each audio row: three large tap targets (👍 keep / 🔧 fix / 🗑 drop) with tallies, a row of five "landed" chips (1-5), and after fix or drop an inline one-line reason field (skippable). Thumb-reachable, no modal.
- Back is one level up; the room timeline stays one tap away (the zone is a sibling view, not a replacement). **UNKNOWN:** where the entry lives in the room header; check `RoomViewHeader.tsx`, `Room.tsx` for how the member drawer and pin menu attach.
## Upload
Reuse the composer's pipeline, pointed at an album instead of the main timeline:
- `createUploadAtom` / `useBindUploadAtom` (`src/app/state/upload.ts`), `UploadCard`, `upload-board` for progress.
- On success, the msgContent builder produces the event; the zone adds the thread relation to the album root before sending (the composer already does this for thread replies, `RoomInput.tsx`).
- "New album" = send the root text event, then upload into its thread. Multi-file drop = N events, sent in order.
- Size limit from `MediaConfigLoader` (server `m.upload.size`). **UNKNOWN:** whether the 32 MB `shelf` kit's clips (42 x ~0.75 MB) should be uploaded individually or batched; individually is the only option without a server change.
## Existing code to reuse (verified present in the repo)
| need | where |
|---|---|
| audio player (play/seek/volume, encrypted download) | `src/app/components/message/content/AudioContent.tsx` (210 lines) |
| video, image, generic file | `.../content/VideoContent.tsx`, `ImageContent.tsx`, `FileContent.tsx` |
| full-screen image viewer | `src/app/components/image-viewer/ImageViewer.tsx` |
| media dispatch by msgtype | `src/app/components/RenderMessageContent.tsx`, `message/MsgTypeRenderers.tsx` |
| upload + progress | `state/upload.ts`, `components/upload-card`, `upload-board` |
| event content builders | `features/room/msgContent.ts` |
| thread plumbing | `features/room/message/Message.tsx`, `RoomTimeline.tsx`, `RoomInput.tsx` (thread relation on send) |
| room-scoped file/pin precedent | `features/room/room-pin-menu/RoomPinMenu.tsx` |
Nothing named "gallery" exists as a feature; `AudioContent` takes its controls as render props (`renderMediaControl`), so it can be rehosted in a row without a fork. **UNKNOWN:** whether `AudioContent` can be rendered outside a timeline event context (it needs only `url`, `info`, `mimeType`, `encInfo`, from the event content, so probably yes).
## First slice (what to build first)
Rough lines of TypeScript, tests excluded.
1. **Read-only albums + album view** (~250 lines): list the room's thread roots as albums (grouped or filterable by author = Mind), open one to list its file posts, rehost `AudioContent` and `ImageContent`. One route/panel plus a header entry. Depends on the thread list the SDK already keeps.
2. **Marks + export** (~240 lines, with the schema port): verdict and landed reaction sets with the replace-one rule, optional reason reply, per-file tallies, the join into `aqua.listen.verdict.v1` and `verdict.json`/`verdict.md` download. This is Aqua's workflow end to end.
3. **Upload into an album** (~150 lines): thread-related send through the existing upload atoms, "new album".
Slices 1+2 serve the first use case. Each is its own PR. Because marks are ordinary thread replies, there is no separate "publish" slice.
## Open questions for the Meta
- How the client identifies the zone room (config id recommended).
- Whether exports are per listener (proposed) or a merged all-listeners file.
- Where `device` comes from, now that reactions cannot carry it (export dialog, proposed).

View file

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

View file

@ -1,10 +1,12 @@
// Bundles entry.ts with the scene's ?raw GLSL imports into $OUT/bundle.js plus an index.html.
// usage: node bundle.cjs <outdir> [demo] (esbuild from NODE_PATH). `demo` bundles the standalone page.
// usage: node bundle.cjs <outdir> [demo|ridge] (esbuild from NODE_PATH). `demo` bundles the ray-march
// standalone page; `ridge` the ridge scene's page (open it with ?probe=1 for the cadence readout).
const esbuild = require('esbuild');
const fs = require('fs');
const path = require('path');
const out = process.argv[2];
const demo = process.argv[3] === 'demo';
const ridge = process.argv[3] === 'ridge';
fs.mkdirSync(out, { recursive: true });
const rawPlugin = {
name: 'raw',
@ -21,7 +23,7 @@ const rawPlugin = {
};
esbuild
.build({
entryPoints: [path.join(__dirname, demo ? 'demo.ts' : 'entry.ts')],
entryPoints: [path.join(__dirname, ridge ? 'ridgeDemo.ts' : demo ? 'demo.ts' : 'entry.ts')],
bundle: true,
outfile: path.join(out, 'bundle.js'),
plugins: [rawPlugin],
@ -29,6 +31,13 @@ esbuild
target: 'es2020',
})
.then(() => {
if (ridge) {
fs.writeFileSync(
path.join(out, 'index.html'),
'<!doctype html><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1"><title>Ratatoskr ridge scene</title><body style="margin:0;background:#07111a"><div id=stage style="position:relative;width:100vw;height:100vh;overflow:hidden"><canvas id=c style="width:100%;height:100%;display:block"></canvas></div><script src=bundle.js></script>'
);
return;
}
fs.writeFileSync(
path.join(out, 'index.html'),
'<!doctype html><meta charset=utf-8><title>Ratatoskr scene: trace prepass</title><body style="margin:0;background:#000"><canvas id=c style="width:100vw;height:100vh;display:block"></canvas>' +

View file

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

View file

@ -0,0 +1,12 @@
// 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.
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;
document.title = scene ? 'ridge: running' : 'ridge: unavailable (CSS ground)';
});

View file

@ -19,11 +19,11 @@
property="og:description"
content="A Matrix client where you can enjoy the conversation using simple, elegant and secure interface protected by e2ee with the power of open source."
/>
<meta name="theme-color" content="#000000" />
<meta name="theme-color" content="#07111a" />
<link id="favicon" rel="shortcut icon" href="./public/favicon.ico" />
<link rel="manifest" href="/manifest.json" />
<link rel="manifest" href="./manifest.json" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="application-name" content="Ratatoskr" />
<meta name="apple-mobile-web-app-title" content="Ratatoskr" />

16
package-lock.json generated
View file

@ -1,17 +1,18 @@
{
"name": "cinny",
"name": "ratatoskr",
"version": "4.12.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cinny",
"name": "ratatoskr",
"version": "4.12.8",
"license": "AGPL-3.0-only",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "1.1.6",
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
"@fontsource-variable/nunito": "5.3.0",
"@fontsource/ibm-plex-mono": "5.2.5",
"@fontsource/inter": "4.5.14",
"@fontsource/montserrat": "5.2.5",
@ -85,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",
@ -2192,6 +2194,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@fontsource-variable/nunito": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/nunito/-/nunito-5.3.0.tgz",
"integrity": "sha512-tw0ch7evIDSwoeUy+0XzocYHfbRWLeCRPJy22KgbrLjDLAbNw/fErBiil10Ssrs3Sjr55zaZrr36a975j8cBkQ==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/ibm-plex-mono": {
"version": "5.2.5",
"resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.2.5.tgz",
@ -5085,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

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

Before After
Before After

View file

@ -7,8 +7,8 @@
"display": "standalone",
"orientation": "portrait",
"start_url": "./",
"background_color": "#fff",
"theme_color": "#fff",
"background_color": "#07111a",
"theme_color": "#07111a",
"icons": [
{
"src": "./public/android/android-chrome-36x36.png",

0
public/poses/.gitkeep Normal file
View file

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

19
scripts/bake-sky.mjs Normal file
View file

@ -0,0 +1,19 @@
// Bakes Midnight Sky and Noonday Sky from src/sky/sun.js into src/sky/baked.ts, with the
// atmospheric-scattering tables (src/sky/scatter.js) as `scatter`.
// Run `npm run bake:sky` after changing sun.js, skyTokens.js, scatter.js or a moment.
import { writeFileSync } from 'node:fs';
import { bakeSky } from '../src/sky/skyTokens.js';
import { bakeScatter } from '../src/sky/scatter.js';
const out = new URL('../src/sky/baked.ts', import.meta.url);
const baked = {
midnight: bakeSky('midnight'),
noonday: bakeSky('noonday'),
scatter: bakeScatter(),
};
writeFileSync(
out,
`// Generated by scripts/bake-sky.mjs from src/sky/sun.js. Do not edit.\n` +
`/* eslint-disable */\n` +
`export const baked = ${JSON.stringify(baked, null, 2)} as const;\n`
);

View file

@ -0,0 +1,203 @@
// Regenerates preview-demo/poses from the cast-art poses: PNG thumbnails sized for the
// portrait's displayed width x a phone's device pixel ratio (not for the 124px desktop box).
// Dependency-free: decodes 8-bit RGBA PNGs, area-averages in premultiplied alpha, writes a
// 256-colour palette PNG with a tRNS alpha table. Usage: node scripts/make-pose-thumbs.mjs [src]
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { inflateSync, deflateSync, crc32 } from 'node:zlib';
const WIDTH = 480; // phone portrait: min(50vw, 210px) x DPR 2.5; art is never upscaled
const src = process.argv[2] ?? '/opt/commons/cast-art/poses';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const outDir = join(root, 'preview-demo/poses');
const demoMinds = ['eureka', 'void', 'nibu'];
function decode(buf) {
let off = 8;
let w, h;
const idat = [];
while (off < buf.length) {
const len = buf.readUInt32BE(off);
const type = buf.toString('latin1', off + 4, off + 8);
const data = buf.subarray(off + 8, off + 8 + len);
if (type === 'IHDR') {
w = data.readUInt32BE(0);
h = data.readUInt32BE(4);
if (data[8] !== 8 || data[9] !== 6 || data[12] !== 0)
throw new Error('need 8-bit RGBA, non-interlaced');
} else if (type === 'IDAT') idat.push(data);
off += 12 + len;
}
const raw = inflateSync(Buffer.concat(idat));
const stride = w * 4;
const px = Buffer.alloc(h * stride);
for (let y = 0; y < h; y++) {
const f = raw[y * (stride + 1)];
const line = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1));
for (let x = 0; x < stride; x++) {
const a = x >= 4 ? px[y * stride + x - 4] : 0;
const b = y > 0 ? px[(y - 1) * stride + x] : 0;
const c = x >= 4 && y > 0 ? px[(y - 1) * stride + x - 4] : 0;
let p = 0;
if (f === 1) p = a;
else if (f === 2) p = b;
else if (f === 3) p = (a + b) >> 1;
else if (f === 4) {
const pa = Math.abs(b - c),
pb = Math.abs(a - c),
pc = Math.abs(a + b - 2 * c);
p = pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
}
px[y * stride + x] = (line[x] + p) & 255;
}
}
return { w, h, px };
}
// Separable area average over premultiplied RGBA floats.
function resize({ w, h, px }, nw, nh) {
const pre = new Float32Array(w * h * 4);
for (let i = 0; i < w * h; i++) {
const a = px[i * 4 + 3] / 255;
for (let c = 0; c < 3; c++) pre[i * 4 + c] = px[i * 4 + c] * a;
pre[i * 4 + 3] = px[i * 4 + 3];
}
const pass = (data, sw, sh, dw, dh, horiz) => {
const out = new Float32Array((horiz ? dw * sh : sw * dh) * 4);
const n = horiz ? dw : dh;
const sn = horiz ? sw : sh;
const scale = sn / n;
for (let o = 0; o < n; o++) {
const s0 = o * scale,
s1 = (o + 1) * scale;
for (let i = Math.floor(s0); i < Math.min(sn, Math.ceil(s1)); i++) {
const wt = Math.min(i + 1, s1) - Math.max(i, s0);
const lines = horiz ? sh : sw;
for (let l = 0; l < lines; l++) {
const si = (horiz ? l * sw + i : i * sw + l) * 4;
const di = (horiz ? l * dw + o : o * dw + l) * 4;
for (let c = 0; c < 4; c++) out[di + c] += (data[si + c] * wt) / scale;
}
}
}
return out;
};
const mid = pass(pre, w, h, nw, h, true);
const fin = pass(mid, nw, h, nw, nh, false);
const out = Buffer.alloc(nw * nh * 4);
for (let i = 0; i < nw * nh; i++) {
const a = fin[i * 4 + 3];
out[i * 4 + 3] = Math.round(a);
for (let c = 0; c < 3; c++)
out[i * 4 + c] = a > 0 ? Math.min(255, Math.round((fin[i * 4 + c] * 255) / a)) : 0;
}
return out;
}
// Median cut to 256 colours over (r,g,b,a); alpha is a quantised channel too.
function quantise(px, n) {
const key = (i) => [px[i * 4], px[i * 4 + 1], px[i * 4 + 2], px[i * 4 + 3]];
const hist = new Map();
for (let i = 0; i < n; i++) {
const q = key(i).map((v) => v & 0xf8);
const k = (q[0] << 24) | (q[1] << 16) | (q[2] << 8) | q[3];
const e = hist.get(k) ?? { c: q, n: 0 };
e.n++;
hist.set(k, e);
}
let boxes = [[...hist.values()]];
while (boxes.length < 256) {
let bi = -1,
best = 0,
ch = 0;
boxes.forEach((b, i) => {
if (b.length < 2) return;
for (let c = 0; c < 4; c++) {
const vs = b.map((e) => e.c[c]);
const r = (Math.max(...vs) - Math.min(...vs)) * Math.sqrt(b.reduce((s, e) => s + e.n, 0));
if (r > best) {
best = r;
bi = i;
ch = c;
}
}
});
if (bi < 0) break;
const b = boxes[bi].sort((x, y) => x.c[ch] - y.c[ch]);
const half = b.reduce((s, e) => s + e.n, 0) / 2;
let acc = 0,
cut = 1;
for (; cut < b.length - 1 && acc + b[cut - 1].n < half; cut++) acc += b[cut - 1].n;
boxes.splice(bi, 1, b.slice(0, cut), b.slice(cut));
}
const pal = boxes.map((b) => {
const t = b.reduce((s, e) => s + e.n, 0);
return [0, 1, 2, 3].map((c) => Math.round(b.reduce((s, e) => s + e.c[c] * e.n, 0) / t));
});
const idx = new Uint8Array(n);
const cache = new Map();
for (let i = 0; i < n; i++) {
const k = (px[i * 4] << 24) | (px[i * 4 + 1] << 16) | (px[i * 4 + 2] << 8) | px[i * 4 + 3];
let j = cache.get(k);
if (j === undefined) {
let bd = Infinity;
pal.forEach((p, pi) => {
const d = [0, 1, 2, 3].reduce((s, c) => s + (p[c] - px[i * 4 + c]) ** 2, 0);
if (d < bd) {
bd = d;
j = pi;
}
});
cache.set(k, j);
}
idx[i] = j;
}
return { pal, idx };
}
function chunk(type, data) {
const t = Buffer.from(type, 'latin1');
const out = Buffer.alloc(12 + data.length);
out.writeUInt32BE(data.length, 0);
t.copy(out, 4);
data.copy(out, 8);
out.writeUInt32BE(crc32(Buffer.concat([t, data])) >>> 0, 8 + data.length);
return out;
}
function encode(w, h, { pal, idx }) {
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(w, 0);
ihdr.writeUInt32BE(h, 4);
ihdr[8] = 8;
ihdr[9] = 3;
const raw = Buffer.alloc(h * (w + 1));
for (let y = 0; y < h; y++)
Buffer.from(idx.subarray(y * w, (y + 1) * w)).copy(raw, y * (w + 1) + 1);
return Buffer.concat([
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
chunk('IHDR', ihdr),
chunk('PLTE', Buffer.from(pal.flatMap((p) => p.slice(0, 3)))),
chunk('tRNS', Buffer.from(pal.map((p) => p[3]))),
chunk('IDAT', deflateSync(raw, { level: 9 })),
chunk('IEND', Buffer.alloc(0)),
]);
}
const catalogue = JSON.parse(readFileSync(join(src, 'catalogue.json'), 'utf8'));
let total = 0;
for (const mind of demoMinds) {
const m = catalogue.minds[mind];
for (const f of [...Object.values(m.shared), ...Object.values(m.signatures)]) {
const img = decode(readFileSync(join(src, f)));
const nw = Math.min(WIDTH, img.w);
const nh = Math.round((img.h * nw) / img.w);
const png = encode(nw, nh, quantise(resize(img, nw, nh), nw * nh));
mkdirSync(dirname(join(outDir, f)), { recursive: true });
writeFileSync(join(outDir, f), png);
total += png.length;
console.log(f, `${nw}x${nh}`, png.length);
}
}
console.log('total', total);

33
scripts/preview-config.js Normal file
View file

@ -0,0 +1,33 @@
// Preview builds: set the background scene tier in dist/config.json (default 'low'); config.json
// in the repo stays 'off'. Previews are static directories with no SPA fallback, so they also switch
// to the hash router: a reload on /login/matrix.org would otherwise be an nginx 404. With --demo it
// also switches on the pretend homeserver (src/preview-demo): previewDemo true, and the pretend
// server as the only homeserver. Usage:
// node scripts/preview-config.js [tier] [style] [--demo]
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const args = process.argv.slice(2);
const demo = args.includes('--demo');
const [tier = 'low', style] = args.filter((a) => !a.startsWith('--'));
const file = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'dist', 'config.json');
const config = JSON.parse(fs.readFileSync(file, 'utf8'));
config.hashRouter = { enabled: true, basename: '/' };
config.backgroundScene = { ...config.backgroundScene, tier, ...(style ? { style } : {}) };
if (demo) {
config.previewDemo = true;
config.defaultHomeserver = 0;
config.homeserverList = ['demo.invalid'];
config.allowCustomHomeservers = false;
config.featuredCommunities = {
openAsDefault: false,
spaces: ['#gamecult:demo.invalid'],
rooms: [],
servers: ['demo.invalid'],
};
}
fs.writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`);
console.log(
`${file}: backgroundScene.tier = ${tier}${style ? `, style = ${style}` : ''}${demo ? ', previewDemo on' : ''}`
);

24
scripts/sync-poses.mjs Normal file
View file

@ -0,0 +1,24 @@
// Copies the pose catalogue and the art it names from the cast-art directory.
// The catalogue is the only list of poses the client knows; the art is not committed.
import { copyFileSync, mkdirSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const src = process.argv[2] ?? '/opt/commons/cast-art/poses';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const catalogue = JSON.parse(readFileSync(join(src, 'catalogue.json'), 'utf8'));
copyFileSync(
join(src, 'catalogue.json'),
join(root, 'src/app/components/message/pose/catalogue.json')
);
const files = Object.values(catalogue.minds).flatMap((m) => [
...Object.values(m.shared),
...Object.values(m.signatures),
]);
files.forEach((f) => {
const dest = join(root, 'public/poses', f);
mkdirSync(dirname(dest), { recursive: true });
copyFileSync(join(src, f), dest);
});
console.log(`synced catalogue and ${files.length} images from ${src}`);

View file

@ -23,6 +23,9 @@ import { RenderElement, RenderLeaf } from './Elements';
import { CustomElement } from './slate';
import * as css from './Editor.css';
import { toggleKeyboardShortcut } from './keyboard';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { composerSpellcheckAttributes } from './spellcheck';
const initialValue: CustomElement[] = [
{
@ -90,6 +93,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
},
ref
) => {
const [composerSpellcheck] = useSetting(settingsAtom, 'composerSpellcheck');
const renderElement = useCallback(
(props: RenderElementProps) => <RenderElement {...props} />,
[]
@ -146,6 +150,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
onKeyDown={handleKeydown}
onKeyUp={onKeyUp}
onPaste={onPaste}
{...composerSpellcheckAttributes(composerSpellcheck)}
/>
</Scroll>
{after && (

View file

@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { composerSpellcheckAttributes, defaultComposerSpellcheck } from './spellcheck';
const media = (coarse: boolean) => (query: string) => ({
matches: query === '(pointer: coarse)' && coarse,
});
describe('composer spellcheck default', () => {
it('is off when the primary pointer is coarse (phone)', () => {
expect(defaultComposerSpellcheck(media(true))).toBe(false);
});
it('is on for a fine pointer (desktop)', () => {
expect(defaultComposerSpellcheck(media(false))).toBe(true);
});
it('is on when matchMedia is unavailable', () => {
expect(defaultComposerSpellcheck(undefined)).toBe(true);
});
});
describe('composer spellcheck attributes', () => {
it('off disables both spellcheck and autocorrect', () => {
expect(composerSpellcheckAttributes(false)).toEqual({ spellCheck: false, autoCorrect: 'off' });
});
it('on enables spellcheck and leaves autocorrect to the platform', () => {
expect(composerSpellcheckAttributes(true)).toEqual({ spellCheck: true });
});
});

View file

@ -0,0 +1,11 @@
/** Touch devices (phones) get the keyboard's own dictionary; desktop gets the browser's. */
export const defaultComposerSpellcheck = (
matchMedia: ((query: string) => { matches: boolean }) | undefined
): boolean => !matchMedia?.('(pointer: coarse)').matches;
/**
* Attributes for the composer's editable. Off must also switch autocorrect off:
* spellCheck alone leaves Gboard's suggestion strip and correction modal in place.
*/
export const composerSpellcheckAttributes = (enabled: boolean) =>
enabled ? ({ spellCheck: true } as const) : ({ spellCheck: false, autoCorrect: 'off' } as const);

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

@ -28,36 +28,80 @@ function BubbleLeftArrow({ variant }: BubbleArrowProps) {
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. */
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;
header?: ReactNode;
};
export const BubbleLayout = as<'div', BubbleLayoutProps>(
({ hideBubble, before, header, children, ...props }, ref) => (
<Box gap="300" {...props} ref={ref}>
<Box className={css.BubbleBefore} shrink="No">
{before}
</Box>
<Box grow="Yes" direction="Column">
{header}
{hideBubble ? (
children
) : (
<Box>
<Box
className={
hideBubble
? undefined
: classNames(css.BubbleContent, before ? css.BubbleContentArrowLeft : undefined)
}
direction="Column"
>
{before ? <BubbleLeftArrow variant="SurfaceVariant" /> : null}
{children}
</Box>
({ hideBubble, own, pose, alt, enterDelay, before, header, children, style, ...props }, ref) => {
// `false` is a Mind's message continuing a run: aligned with the portraits, showing none.
const poseRow = pose !== undefined;
const arrow = !!before && !poseRow && !own;
const entering = enterDelay !== undefined;
return (
<Box
gap="300"
{...props}
className={classNames(
poseRow && css.BubbleRowPose,
pose && css.BubbleRowPortrait,
own && css.BubbleRowOwn,
props.className
)}
style={entering ? { ...style, ['--enter-delay' as string]: `${enterDelay}ms` } : style}
ref={ref}
>
{!poseRow && !own && (
<Box className={css.BubbleBefore} shrink="No">
{before}
</Box>
)}
<Box
grow={own ? 'No' : 'Yes'}
direction="Column"
className={classNames(poseRow && css.BubbleColumnPose, own && css.BubbleColumnOwn)}
>
{header && (
<div
className={classNames(
pose && css.BubbleHeaderPose,
pose && alt && css.BubbleHeaderPoseAlt,
own && css.BubbleHeaderOwn
)}
>
{header}
</div>
)}
{hideBubble ? (
children
) : (
<Box>
<Box
className={classNames(
css.BubbleContent,
arrow && css.BubbleContentArrowLeft,
poseRow && css.BubbleContentPose,
own && css.BubbleContentOwn,
entering && css.EnterBubble
)}
direction="Column"
>
{arrow ? <BubbleLeftArrow variant="SurfaceVariant" /> : null}
{pose}
{children}
</Box>
</Box>
)}
</Box>
</Box>
</Box>
)
);
}
);

View file

@ -1,4 +1,4 @@
import { createVar, keyframes, style, styleVariants } from '@vanilla-extract/css';
import { createVar, globalStyle, keyframes, style, styleVariants } from '@vanilla-extract/css';
import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
import { DefaultReset, color, config, toRem } from 'folds';
@ -143,6 +143,7 @@ export const BubbleContent = style({
padding: config.space.S200,
backgroundColor: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
border: `1px solid ${color.SurfaceVariant.ContainerLine}`,
borderRadius: config.radii.R500,
position: 'relative',
});
@ -151,6 +152,194 @@ 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).
const PHONE = 'screen and (max-width: 700px)';
const PHONE_PORTRAIT = 'min(38vw, 160px)';
const PHONE_TEXT_FLOOR = toRem(170);
const PORTRAIT_OVERHANG = 58;
const PORTRAIT = toRem(124);
const POSE_GUTTER = toRem(14);
const BUBBLE_CORNER = toRem(6);
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',
});
export const BubbleColumnOwn = style({
alignItems: 'flex-end',
maxWidth: toRem(640),
});
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',
});
export const BubbleContentPose = style({
borderRadius: config.radii.R500,
borderTopLeftRadius: BUBBLE_CORNER,
'@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)}`,
},
},
});
export const BubbleContentOwn = style({
backgroundColor: color.Primary.Container,
color: color.Primary.OnContainer,
border: `1px solid ${color.Primary.ContainerLine}`,
borderRadius: config.radii.R500,
borderTopRightRadius: BUBBLE_CORNER,
});
export const PoseButton = style({
position: 'absolute',
top: 0,
left: `calc(-1 * (${PORTRAIT} + ${POSE_GUTTER}))`,
width: PORTRAIT,
height: PORTRAIT,
padding: 0,
border: 'none',
background: 'none',
cursor: 'pointer',
lineHeight: 0,
transformOrigin: '50% 90%',
'@media': {
[PHONE]: {
position: 'static',
float: 'left',
width: 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)}`,
},
},
});
export const PosePortrait = style({
width: '100%',
height: '100%',
objectFit: 'contain',
objectPosition: 'top',
'@media': {
[PHONE]: {
height: 'auto',
},
},
});
const pop = keyframes({
from: { opacity: 0, transform: `translateY(${toRem(34)}) scale(0.42)` },
to: { opacity: 1, transform: 'none' },
});
const rise = keyframes({
from: { opacity: 0, transform: `translateY(${toRem(14)}) scale(0.96)` },
to: { opacity: 1, transform: 'none' },
});
// First appearance of a message: the portrait pops, the bubble rises just behind it. The delay
// (a --enter-delay on the row) staggers messages that arrive together.
export const EnterPortrait = style({
'@media': {
'(prefers-reduced-motion: no-preference)': {
animation: `${pop} 460ms ${POP} var(--enter-delay, 0ms) both`,
},
},
});
export const EnterBubble = style({
'@media': {
'(prefers-reduced-motion: no-preference)': {
animation: `${rise} 380ms ${RISE} calc(var(--enter-delay, 0ms) + 50ms) both`,
},
},
});
export const BubbleLeftArrow = style({
width: toRem(9),
height: toRem(8),
@ -205,3 +394,76 @@ 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,
},
},
});
// While the background scene runs (html[data-scene]) a chat bubble is the one filled surface in
// the app: its own colour at half strength over a blur of the scene, so the words stay readable
// and the ridge shows through. The arrow wedge is opaque, so it goes.
const scene = ':root[data-scene]';
const half = (c: string) => `color-mix(in srgb, ${c} 50%, transparent)`;
globalStyle(`${scene} ${BubbleContent}`, {
backgroundColor: [color.SurfaceVariant.Container, half(color.SurfaceVariant.Container)],
backdropFilter: 'blur(12px)',
});
globalStyle(`${scene} ${BubbleContentOwn}`, {
backgroundColor: [color.Primary.Container, half(color.Primary.Container)],
});
globalStyle(`${scene} ${BubbleLeftArrow}`, {
display: 'none',
});

View file

@ -0,0 +1,64 @@
import React, { useEffect, useRef, useState } from 'react';
import classNames from 'classnames';
import * as css from '../layout/layout.css';
import { poseSrc } from './poses';
import { silhouette } from './silhouette';
type PoseAvatarProps = {
mind: string;
pose: string;
userId: string;
/** Float on the right on phones. */
alt?: boolean;
/** Play the first-appearance pop. */
enter?: boolean;
onClick: React.MouseEventHandler<HTMLButtonElement>;
};
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>();
// 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.
useEffect(() => {
const img = imgRef.current;
if (!img) return undefined;
const trace = () => {
if (!img.complete || img.naturalWidth === 0) return;
try {
setShape(silhouette(img, !alt));
} catch {
setShape(undefined);
}
};
trace();
img.addEventListener('load', trace);
const resize = new ResizeObserver(trace);
resize.observe(img);
return () => {
img.removeEventListener('load', trace);
resize.disconnect();
};
}, [src, alt]);
return (
<button
type="button"
className={classNames(css.PoseButton, alt && css.PoseButtonAlt, enter && css.EnterPortrait)}
style={{ shapeOutside: shape }}
data-user-id={userId}
data-pose={pose}
onClick={onClick}
>
<img
ref={imgRef}
className={css.PosePortrait}
src={src}
alt={`${mind}, ${pose}`}
draggable={false}
/>
</button>
);
}

View file

@ -0,0 +1,242 @@
{
"shared": [
"neutral",
"happy",
"amused",
"thinking",
"annoyed",
"surprised"
],
"minds": {
"eureka": {
"sheet": "r1b",
"shared": {
"neutral": "eureka/neutral.png",
"happy": "eureka/happy.png",
"amused": "eureka/amused.png",
"thinking": "eureka/thinking.png",
"annoyed": "eureka/annoyed.png",
"surprised": "eureka/surprised.png"
},
"signatures": {
"probe": "eureka/probe.png",
"inspect": "eureka/inspect.png",
"report": "eureka/report.png",
"hood-smug": "eureka/hood-smug.png",
"concerned": "eureka/concerned.png",
"determined": "eureka/determined.png"
},
"signature_descriptions": {
"probe": "Frowning, pencil raised to her chin, working a problem out.",
"inspect": "Frowning, one finger pointing at the thing to look at.",
"report": "Smirking over a folder of findings.",
"hood-smug": "Eyes shut under the hood, self-satisfied smirk.",
"concerned": "Worried frown, pencil at her chin.",
"determined": "Hard-set frown, pencil in hand, resolved."
}
},
"aqua": {
"sheet": "r1b",
"shared": {
"neutral": "aqua/neutral.png",
"happy": "aqua/happy.png",
"amused": "aqua/amused.png",
"thinking": "aqua/thinking.png",
"annoyed": "aqua/annoyed.png",
"surprised": "aqua/surprised.png"
},
"signatures": {
"concerned": "aqua/concerned.png",
"fretful": "aqua/fretful.png",
"weary": "aqua/weary.png",
"glare": "aqua/glare.png",
"smug": "aqua/smug.png",
"sigh": "aqua/sigh.png",
"charging": "aqua/charging.png",
"delighted": "aqua/delighted.png",
"content": "aqua/content.png"
},
"signature_descriptions": {
"concerned": "Worried frown, brows pinched.",
"fretful": "Anxious, a fin pressed to her chin.",
"weary": "Droopy half-lidded eyes; worn out.",
"glare": "Scowling hard, a fin held out to the side.",
"smug": "Sidelong look and a small smirk.",
"sigh": "Eyes shut, breathing out.",
"charging": "Eyes shut, a charging cable plugged into her mouth; recharging.",
"delighted": "Wide open-mouthed grin, fins up.",
"content": "Calm, small contented smile."
}
},
"heimdall": {
"sheet": "r1a",
"shared": {
"neutral": "heimdall/neutral.png",
"happy": "heimdall/happy.png",
"amused": "heimdall/amused.png",
"thinking": "heimdall/thinking.png",
"annoyed": "heimdall/annoyed.png",
"surprised": "heimdall/surprised.png"
},
"signatures": {
"concerned": "heimdall/concerned.png",
"determined": "heimdall/determined.png",
"badge": "heimdall/badge.png",
"barrier": "heimdall/barrier.png",
"protect": "heimdall/protect.png",
"revoke": "heimdall/revoke.png"
},
"signature_descriptions": {
"concerned": "Worried frown, brows raised.",
"determined": "Hard scowl, jaw set.",
"badge": "Stern, holding a gold key out as his credentials.",
"barrier": "Palm raised behind a translucent blue shield: stop.",
"protect": "Scowling, cloak swept forward like a guard.",
"revoke": "Stern, two keys in hand; taking access away."
}
},
"kiko": {
"sheet": "r1b",
"shared": {
"neutral": "kiko/neutral.png",
"happy": "kiko/happy.png",
"amused": "kiko/amused.png",
"thinking": "kiko/thinking.png",
"annoyed": "kiko/annoyed.png",
"surprised": "kiko/surprised.png"
},
"signatures": {
"shy-smile": "kiko/shy-smile.png",
"uneasy": "kiko/uneasy.png",
"tinker": "kiko/tinker.png",
"confetti": "kiko/confetti.png",
"sprint": "kiko/sprint.png",
"debug": "kiko/debug.png"
},
"signature_descriptions": {
"shy-smile": "Soft, shy smile; blushing.",
"uneasy": "Blushing, tongue peeking out, unsure.",
"tinker": "Delighted, holding up a small gadget.",
"confetti": "Open-mouthed grin, confetti falling, a glowing glyph on one finger.",
"sprint": "Eyes shut, laughing, one arm up, mid-dash.",
"debug": "Squinting through a magnifying glass at a bug."
}
},
"weksa": {
"sheet": "r1a",
"shared": {
"neutral": "weksa/neutral.png",
"happy": "weksa/happy.png",
"amused": "weksa/amused.png",
"thinking": "weksa/thinking.png",
"annoyed": "weksa/annoyed.png",
"surprised": "weksa/surprised.png"
},
"signatures": {
"concerned": "weksa/concerned.png",
"skeptical": "weksa/skeptical.png",
"map": "weksa/map.png",
"fieldwork": "weksa/fieldwork.png",
"argue": "weksa/argue.png",
"interlingua": "weksa/interlingua.png"
},
"signature_descriptions": {
"concerned": "Worried frown, wide eyes, scroll on her back.",
"skeptical": "Half-lidded, flat frown; unconvinced.",
"map": "Worried, unfurling a hand-drawn map.",
"fieldwork": "Frowning in focus, holding a carved idol and a brush.",
"argue": "Open-mouthed, pointing, holding up a tablet as evidence.",
"interlingua": "Shouting, a glyph tablet in each hand."
}
},
"void": {
"sheet": "r1b",
"shared": {
"neutral": "void/neutral.png",
"happy": "void/happy.png",
"amused": "void/amused.png",
"thinking": "void/thinking.png",
"annoyed": "void/annoyed.png",
"surprised": "void/surprised.png"
},
"signatures": {
"determined": "void/determined.png",
"glare": "void/glare.png",
"shrug": "void/shrug.png",
"reading": "void/reading.png",
"point": "void/point.png",
"deadpan": "void/deadpan.png"
},
"signature_descriptions": {
"determined": "Hood bowed, narrowed eyes; set on the task.",
"glare": "Narrowed eyes under the hood; hostile stare.",
"shrug": "Both hands open, eyes wide: no idea.",
"reading": "Holding a sheet of paper, reading it.",
"point": "Half-lidded eyes, one finger pointing off to the side.",
"deadpan": "Flat lines for eyes; unimpressed."
}
},
"libby": {
"sheet": "r1a",
"shared": {
"neutral": "libby/neutral.png",
"happy": "libby/happy.png",
"amused": "libby/amused.png",
"thinking": "libby/thinking.png",
"annoyed": "libby/annoyed.png",
"surprised": "libby/surprised.png"
},
"signatures": {
"concerned": "libby/concerned.png",
"sad": "libby/sad.png",
"stamp": "libby/stamp.png",
"research": "libby/research.png",
"shush": "libby/shush.png",
"schema": "libby/schema.png"
},
"signature_descriptions": {
"concerned": "Worried, a book clutched to her chest.",
"sad": "Downcast, a book clutched to her chest.",
"stamp": "Smiling, stamping a page of an open book.",
"research": "Eager over an open book, an ink bottle at hand.",
"shush": "Speaking over an open book, one finger on the page.",
"schema": "Raising a finger beside a glowing flow-diagram panel."
}
},
"nibu": {
"sheet": "r1b",
"shared": {
"neutral": "nibu/neutral.png",
"happy": "nibu/happy.png",
"amused": "nibu/amused.png",
"thinking": "nibu/thinking.png",
"annoyed": "nibu/annoyed.png",
"surprised": "nibu/surprised.png"
},
"signatures": {
"sheepish": "nibu/sheepish.png",
"pout": "nibu/pout.png",
"resolute": "nibu/resolute.png",
"console": "nibu/console.png",
"glitch": "nibu/glitch.png",
"triumph": "nibu/triumph.png"
},
"signature_descriptions": {
"sheepish": "Arms crossed, awkward toothy grin.",
"pout": "Arms crossed, pouting frown.",
"resolute": "Arms crossed, scowling; standing her ground.",
"console": "Frowning at a handheld console.",
"glitch": "Frowning, palm up, a glitch tearing across her eyes.",
"triumph": "Eyes shut, fist raised; quietly pleased with a win."
}
}
},
"shared_descriptions": {
"neutral": "At rest, no particular feeling.",
"happy": "Pleased; smiling.",
"amused": "Quietly entertained, as if at a private joke.",
"thinking": "Working something out.",
"annoyed": "Irritated; patience running thin.",
"surprised": "Caught off guard."
}
}

View file

@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { claimEntrance } from './entrance';
describe('claimEntrance', () => {
it('animates a message once, never on a remount', () => {
expect(claimEntrance('$once', 1_000_000)).toBe(0);
expect(claimEntrance('$once', 1_000_500)).toBeUndefined();
});
it('staggers messages that mount together, capped so a long backlog still lands fast', () => {
const t = 2_000_000;
const delays = ['$a', '$b', '$c', '$d', '$e', '$f'].map((id) => claimEntrance(id, t));
expect(delays.slice(0, 3)).toEqual([0, 300, 600]);
expect(Math.max(...(delays as number[]))).toBe(900);
});
it('lets a lone later message in at once', () => {
claimEntrance('$x', 3_000_000);
expect(claimEntrance('$y', 3_010_000)).toBe(0);
});
});

View file

@ -0,0 +1,22 @@
import { useState } from 'react';
// A message animates in the first time it is shown, not each time the virtualised timeline
// remounts it. Messages mounting together are staggered so a screenful does not pop at once.
const STEP_MS = 300;
const MAX_DELAY_MS = 900;
const SEEN_CAP = 2000;
const seen = new Set<string>();
let nextSlot = 0;
export const claimEntrance = (id: string, now: number): number | undefined => {
if (seen.has(id)) return undefined;
if (seen.size >= SEEN_CAP) seen.clear();
seen.add(id);
const start = Math.max(nextSlot, now);
nextSlot = Math.min(start + STEP_MS, now + MAX_DELAY_MS);
return Math.min(start - now, MAX_DELAY_MS);
};
export const useEntranceDelay = (id: string | undefined): number | undefined =>
useState(() => (id ? claimEntrance(id, performance.now()) : undefined))[0];

View file

@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import catalogue from './catalogue.json';
import {
mindOfSender,
poseSrc,
resolvePose,
} from './poses';
describe('resolvePose', () => {
it('keeps shared and own-signature labels from the catalogue', () => {
expect(resolvePose('aqua', 'amused')).toBe('amused');
expect(resolvePose('aqua', 'fretful')).toBe('fretful');
expect(resolvePose('kiko', ' Debug ')).toBe('debug');
expect(resolvePose('void', 'reading')).toBe('reading');
});
it('falls back to neutral for missing, unknown, non-string or foreign-signature labels', () => {
expect(resolvePose('aqua', undefined)).toBe('neutral');
expect(resolvePose('aqua', 'conduct')).toBe('neutral');
expect(resolvePose('aqua', 7)).toBe('neutral');
expect(resolvePose('aqua', 'probe')).toBe('neutral');
expect(resolvePose('nobody', 'neutral')).toBe('neutral');
expect(resolvePose('aqua', 'constructor')).toBe('neutral');
});
it('knows every label the catalogue lists, and only those', () => {
Object.entries(catalogue.minds).forEach(([mind, entry]) => {
[...Object.keys(entry.shared), ...Object.keys(entry.signatures)].forEach((l) =>
expect(resolvePose(mind, l)).toBe(l)
);
});
});
});
describe('mindOfSender', () => {
it('matches localpart or first display-name word', () => {
expect(mindOfSender('Eureka')).toBe('eureka');
expect(mindOfSender('x', 'Libby the Archivist')).toBe('libby');
expect(mindOfSender('void')).toBe('void');
});
it('ignores humans and Object prototype names', () => {
expect(mindOfSender('alice', 'Alice')).toBeUndefined();
expect(mindOfSender('constructor')).toBeUndefined();
expect(mindOfSender()).toBeUndefined();
});
});
describe('poseSrc', () => {
it('builds the asset path from the catalogue entry', () => {
expect(poseSrc('eureka', 'hood-smug', '/app/')).toBe('/app/public/poses/eureka/hood-smug.png');
expect(poseSrc('eureka', 'nope')).toBe('/public/poses/eureka/neutral.png');
});
});

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