Release candidate: dev 64edacac to main #71
20
.github/workflows/build-pull-request.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
4
.github/workflows/docker-pr.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
4
.github/workflows/lockfile.yml
vendored
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -4,3 +4,4 @@ package.json
|
|||
package-lock.json
|
||||
LICENSE
|
||||
harness
|
||||
src/app/components/message/pose/catalogue.json
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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).
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>' +
|
||||
|
|
|
|||
30
harness/scene/gradAxis.cjs
Normal 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()})();
|
||||
12
harness/scene/ridgeDemo.ts
Normal 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)';
|
||||
});
|
||||
|
|
@ -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
|
|
@ -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",
|
||||
|
|
|
|||
17
package.json
|
|
@ -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",
|
||||
|
|
|
|||
13
preview-demo/poses/README.md
Normal 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.
|
||||
BIN
preview-demo/poses/eureka/amused.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
preview-demo/poses/eureka/annoyed.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
preview-demo/poses/eureka/concerned.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
preview-demo/poses/eureka/determined.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
preview-demo/poses/eureka/happy.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
preview-demo/poses/eureka/hood-smug.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
preview-demo/poses/eureka/inspect.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
preview-demo/poses/eureka/neutral.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
preview-demo/poses/eureka/probe.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
preview-demo/poses/eureka/report.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
preview-demo/poses/eureka/surprised.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
preview-demo/poses/eureka/thinking.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
preview-demo/poses/nibu/amused.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
preview-demo/poses/nibu/annoyed.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
preview-demo/poses/nibu/console.png
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
preview-demo/poses/nibu/glitch.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
preview-demo/poses/nibu/happy.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
preview-demo/poses/nibu/neutral.png
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
preview-demo/poses/nibu/pout.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
preview-demo/poses/nibu/resolute.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
preview-demo/poses/nibu/sheepish.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
preview-demo/poses/nibu/surprised.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
preview-demo/poses/nibu/thinking.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
preview-demo/poses/nibu/triumph.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
preview-demo/poses/void/amused.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
preview-demo/poses/void/annoyed.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
preview-demo/poses/void/deadpan.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
preview-demo/poses/void/determined.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
preview-demo/poses/void/glare.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
preview-demo/poses/void/happy.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
preview-demo/poses/void/neutral.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
preview-demo/poses/void/point.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
preview-demo/poses/void/reading.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
preview-demo/poses/void/shrug.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
preview-demo/poses/void/surprised.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
preview-demo/poses/void/thinking.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 9.1 KiB |
|
|
@ -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
|
Before Width: | Height: | Size: 3 KiB After Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 4 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 817 B After Width: | Height: | Size: 2 KiB |
|
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 1 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 2 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 3 KiB After Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 4.6 KiB |
1
public/res/svg/ratatoskr-favicon-highlight.svg
Normal file
|
After Width: | Height: | Size: 22 KiB |
1
public/res/svg/ratatoskr-favicon-unread.svg
Normal file
|
After Width: | Height: | Size: 22 KiB |
1
public/res/svg/ratatoskr-favicon.svg
Normal file
|
After Width: | Height: | Size: 22 KiB |
|
|
@ -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 |
|
|
@ -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 |
|
Before Width: | Height: | Size: 1,001 B After Width: | Height: | Size: 59 KiB |
19
scripts/bake-sky.mjs
Normal 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`
|
||||
);
|
||||
203
scripts/make-pose-thumbs.mjs
Normal 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
|
|
@ -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
|
|
@ -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}`);
|
||||
|
|
@ -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 && (
|
||||
|
|
|
|||
27
src/app/components/editor/spellcheck.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
11
src/app/components/editor/spellcheck.ts
Normal 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);
|
||||
9
src/app/components/logo/logoMark.css.ts
Normal 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)' });
|
||||
|
|
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
|
|
|
|||
64
src/app/components/message/pose/PoseAvatar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
242
src/app/components/message/pose/catalogue.json
Normal 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."
|
||||
}
|
||||
}
|
||||
19
src/app/components/message/pose/entrance.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
22
src/app/components/message/pose/entrance.ts
Normal 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];
|
||||
54
src/app/components/message/pose/poses.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||