Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 39c9924042 | |||
|
|
c6fb57d9f9 | ||
|
|
5310f606cd | ||
| 934f82386d | |||
|
|
a61252e664 | ||
|
|
1b101c2889 | ||
| b13080ebd2 | |||
|
|
ed2205d6d6 | ||
|
|
33e31a6102 | ||
| d5b9e33536 | |||
|
|
6b40809f9d | ||
| 38b2c064b2 | |||
|
|
ec2637a2bb | ||
|
|
c36b6e2cd9 | ||
| bc097d0490 | |||
| 398b172e21 | |||
|
|
907635917e | ||
|
|
d3894f5b51 | ||
|
|
65d7e7a46a | ||
| da822e9e4e | |||
|
|
0271d5f692 | ||
| 08be0a792d | |||
|
|
6a209649dc | ||
|
|
b8741f9dcc | ||
| 0b3e2294ca | |||
|
|
d42584cd72 | ||
| f3d0ebdb06 | |||
|
|
cf1ecd64da | ||
| ca3046e662 | |||
|
|
817f2e46d6 | ||
| a078346ecc | |||
|
|
c98d0175ff | ||
|
|
e406ec7dc4 | ||
| d12e7bd578 | |||
|
|
90c6d5e278 | ||
|
|
0622d7d65d | ||
| f491df04f9 | |||
|
|
5835e565cd | ||
|
|
796bc161a9 | ||
|
|
ac6018b57c | ||
| 143bcc2dc6 | |||
|
|
6aaee8badd | ||
|
|
01dfb155c5 | ||
| cf8b16ff5d | |||
|
|
036146ce40 | ||
| bb6155709a | |||
|
|
b8eed50bd7 | ||
|
|
53a724ae27 | ||
| 229591e22c | |||
|
|
cd30c3dc40 | ||
|
|
8fb95402f5 | ||
|
|
31d58075db | ||
|
|
3a5258c3ef | ||
|
|
4fd83fb4c4 | ||
| dc9a541820 | |||
|
|
9d65a34507 | ||
|
|
9db91378c5 | ||
|
|
1455988a83 | ||
|
|
a8e3acbde0 | ||
|
|
0ab185bb57 | ||
|
|
a8fc78ff55 | ||
|
|
88fd0a889a | ||
| 8ba407cd2a | |||
|
|
d5a0c1b4ff | ||
| ad9172a311 | |||
| 4e6b4f0c90 | |||
| 1a3eedf6cb | |||
| 9153ee945d | |||
| d5ad73fa5e | |||
| 4c16e51559 | |||
| f0c8dc2122 | |||
| d6a6e300a4 | |||
| 91993ace99 | |||
| 145e268894 | |||
| 8d25db2775 | |||
| 6c395d7aef | |||
| a7ac67683f | |||
| 8a52fa9353 | |||
| 32b95fb60b | |||
|
|
ff1a29dbec | ||
| 2c39f89fe4 | |||
| fd2626d366 | |||
| 09e85fad02 | |||
| b2a970f987 | |||
| 45939d544b | |||
| 0f7faa902c | |||
|
|
4eaa6f9c55 | ||
|
|
761d13fac5 | ||
|
|
d13cbc9b2e | ||
|
|
da05210db2 | ||
|
|
a0d047c139 | ||
|
|
84ee800e3f | ||
|
|
32dc46c21f | ||
|
|
200cf8fdb8 | ||
|
|
e209688de1 | ||
|
|
cd90a044ed | ||
|
|
0b40f4c66f | ||
|
|
b8bef8c175 | ||
|
|
351d1bc745 | ||
| 54ac1ba480 | |||
|
|
fe12d8a4b1 | ||
| b77efddcdc | |||
|
|
d917ccd3bb | ||
|
|
2b019a4302 | ||
|
|
eff894a89c | ||
|
|
c82193f0fb | ||
|
|
c45cdc7bd7 | ||
| 14ee4bee03 | |||
|
|
0c8474231e | ||
|
|
446306b1e7 | ||
|
|
fc92809eb9 | ||
| 2aa468c36a | |||
| 67f1267653 | |||
|
|
ae3760fcc7 | ||
|
|
bd2d980023 | ||
|
|
da9ef70c03 | ||
|
|
0e3115809e | ||
|
|
e04c7c074f | ||
|
|
fab6a04894 | ||
|
|
09b062d7be | ||
|
|
9d1d39922c | ||
|
|
76c1f56bd8 | ||
|
|
2a1f2c6b17 | ||
|
|
21ff167177 | ||
|
|
ee55c32214 | ||
|
|
856096e215 | ||
|
|
f7395f12e3 | ||
| 95f79a0517 | |||
|
|
917f9a0da2 | ||
|
|
46e96500e4 | ||
|
|
ccb7a8ebb6 | ||
| b6b1d522ab | |||
|
|
bb6391a70b | ||
|
|
69d752ff0b | ||
|
|
77edebff9c | ||
|
|
a92f6c4294 | ||
| 92a98664c9 | |||
|
|
a531976675 | ||
|
|
350ec987e0 | ||
|
|
7316c92ff3 | ||
| a8d39ee917 | |||
|
|
695a016d4f | ||
|
|
64a2656ad7 | ||
| f79613ca5f | |||
|
|
74fc2fbcd4 | ||
|
|
9297ba08dd | ||
| 3d8c98e1c8 | |||
|
|
3543b1013b | ||
|
|
45579b8ec8 | ||
| 5cce38f38b | |||
|
|
45facff436 | ||
|
|
fef7d34fdb | ||
|
|
3751adcb64 | ||
| 02a0c10665 | |||
|
|
96954c0fb5 | ||
|
|
d4463a80cf | ||
| 494d2a7ed4 | |||
| c5d1b9c90f | |||
| f9f94808d5 | |||
| df26eb4c99 | |||
| 010e68b5e8 | |||
| 07b82f2477 | |||
| df245e0b76 | |||
| d3fa27859d | |||
|
|
6ba5647ea8 | ||
| 98b4ec8ab1 | |||
|
|
1c5331da4d | ||
| 4876d31428 | |||
| 9aa34905c9 | |||
|
|
da2445044d | ||
|
|
00890b2108 | ||
|
|
64ecdc03f7 | ||
| 83baee2c2d | |||
|
|
3971c72de7 | ||
|
|
08ecacd438 | ||
|
|
c34e536132 | ||
|
|
af328bdb90 | ||
| 89759f9092 | |||
| 38e9fcdff3 | |||
|
|
4b7697aaeb | ||
|
|
9c5af7aede | ||
|
|
5e35bb6d76 | ||
| a7c95ce6ec | |||
| b7828fef0f | |||
|
|
65d54d4bf4 | ||
|
|
79fae54a0a | ||
| 23765855a9 | |||
| 24a38e4d76 | |||
|
|
9e02536e02 | ||
| 825b61558a | |||
|
|
57f0ef8df2 | ||
|
|
48d9c56dc7 | ||
|
|
a3b09efb00 | ||
| eb1d039905 | |||
|
|
ec48dbf2ed | ||
|
|
d226ca45fb | ||
|
|
1195b0bffc | ||
| 5c119e729e | |||
|
|
c9d16f6259 | ||
| 978c7c18da | |||
|
|
d803cd1cf4 | ||
| 45d9de9bf7 | |||
|
|
a90223437e | ||
| 34b38c7aa6 | |||
| a0ecfafd4b | |||
|
|
dd1e9268f2 | ||
| b6eda08d4d | |||
|
|
568d6fd4ab | ||
|
|
abb1ee988c | ||
| a5e82090b5 | |||
|
|
90250ca076 | ||
| 50a2a37d6b | |||
|
|
7c97b79956 | ||
| 1be9e76012 | |||
| 08bf515997 | |||
| f8115609b2 | |||
| 450a7e91ff | |||
| 94c976632e | |||
| b6da92a081 | |||
| 7045532187 | |||
| ac7a958f5c | |||
|
|
05948689b4 | ||
| 3def499b0f | |||
| 8e0e3b8986 | |||
| c286c4adc4 | |||
| 1fe3e2c92f | |||
|
|
d172c4eda3 | ||
|
|
9391e4753f | ||
| 313d070dd4 | |||
| 51d5fb0909 | |||
|
|
0f82576d9f | ||
|
|
8dea8717ea | ||
|
|
0351f1f6e8 | ||
|
|
de6f1849ea | ||
|
|
06d530d061 | ||
| 84770a2d2c | |||
|
|
5bb78b21cf | ||
| 7bac860016 | |||
|
|
d16842a70e | ||
|
|
08b23e0cc4 | ||
| 61e7539f01 | |||
|
|
9440921181 | ||
|
|
73754a0912 | ||
|
|
3e173d2fed | ||
|
|
84b2a93b8e | ||
|
|
20acd3d384 | ||
|
|
5def1bdfb8 | ||
| 66c4c6373b | |||
|
|
14ea0dbab0 | ||
| 0fe9ab2eee | |||
|
|
de9955a24a | ||
| 1499e9a810 | |||
|
|
aa0dfc0ee9 | ||
| 683d3b92f8 | |||
| bf8b889a0e | |||
|
|
f1287a6f3a | ||
| 3203a32b73 | |||
|
|
1dea4b51e5 | ||
| 64edacac4d | |||
|
|
9877154033 | ||
| dce50eeaf6 | |||
|
|
3c868254e5 | ||
|
|
3c92696507 | ||
|
|
9641cfb4f4 | ||
|
|
9b9b495def | ||
| 854b151d80 | |||
| 23bc67a1f0 | |||
|
|
48d6d171da | ||
|
|
814fa4b3ab | ||
|
|
d2d6fbcc00 | ||
|
|
556254f46b | ||
|
|
c657d68615 | ||
|
|
384cd17dac | ||
|
|
9d3343bb31 | ||
|
|
0fe41efb9b | ||
|
|
c4689299ca | ||
|
|
fcabaee59e | ||
|
|
5712068902 | ||
| f320016ddc | |||
| 320aeecc2c | |||
| 7494591624 | |||
| b5e93510e8 | |||
| 271729a5c0 | |||
| 30ae5154f5 | |||
| 2e6d4d042a | |||
| 56bc40abe6 | |||
| eb6efeee91 | |||
| ea238cf77d | |||
|
|
91e93e6887 | ||
| 0931901b24 | |||
| de504f8286 | |||
| 5666299396 | |||
| 6a93664813 | |||
| 7556fed31e | |||
| 098e45f122 | |||
| 596f864668 | |||
|
|
61f434a32d | ||
| afae8a13c9 | |||
|
|
2c271c6ded | ||
|
|
7bc28f9256 | ||
| a6364af8e3 | |||
| ea6dee80ae | |||
|
|
d03ec36107 | ||
|
|
e1ebf7ec91 | ||
|
|
80d3ae1c9b | ||
|
|
c13e90539f | ||
| f0bad585b6 | |||
|
|
577d795189 | ||
|
|
eafa3eda79 | ||
|
|
4177bdcccb | ||
|
|
5d3db9e1f2 | ||
|
|
0c6d4476be | ||
| dc4b1f1818 | |||
|
|
8f8536a805 | ||
|
|
8a05668d84 | ||
|
|
111fa02994 | ||
|
|
0d26b71862 | ||
| 913a66ebf0 | |||
| 78f5f4dbb2 | |||
|
|
76daf7cdca | ||
|
|
5862bbdba2 | ||
|
|
ce16609c76 | ||
|
|
3be90875ec | ||
|
|
c180385476 | ||
|
|
edc6a9a8f1 | ||
|
|
6c22d4f1dc | ||
|
|
8c02b85796 | ||
|
|
0e0cff66ae | ||
| a9a75fc6ff | |||
|
|
32cc8d6ad6 | ||
|
|
a3a500a3d2 | ||
| a129866236 | |||
|
|
571a2de3fa | ||
| 47ad5cc9d6 | |||
|
|
3b61f2babf | ||
|
|
ecdf5e9d29 | ||
| 7a7f5ceee1 | |||
|
|
ef961636c3 | ||
|
|
a773bc4932 | ||
|
|
78a7dbe923 | ||
| 030417f27a | |||
| deb4e0dfe6 | |||
|
|
b13bb9ecb3 | ||
|
|
9b4d92c9de | ||
| 9794714f97 | |||
| dfc74c252a | |||
|
|
cfb8a8641a | ||
| 9ab8bbeb33 | |||
| 7a5fa81be7 | |||
| 1a33fa55d5 | |||
| 747f2d505b | |||
| fa1ce2b50e | |||
| 82dc0d0a9b | |||
|
|
c7d6c388d3 | ||
|
|
0438cbc8da | ||
|
|
a23dd239ba | ||
|
|
6110292e3b | ||
|
|
14312459f2 | ||
|
|
245eb90f63 | ||
|
|
8ca38c4980 | ||
|
|
34bdccd87c | ||
|
|
ea33ab6daa | ||
|
|
b091f65cea | ||
|
|
8ac43bd73f | ||
|
|
20cc2c682d | ||
|
|
f7725545a5 | ||
|
|
c35ac7e5cd | ||
|
|
685abbb19b | ||
|
|
58df7b833c | ||
|
|
1296f18338 | ||
|
|
a1aece7d36 | ||
|
|
16cc05a33b | ||
|
|
e007514af3 | ||
|
|
ba592523d8 | ||
|
|
002e0d608b | ||
|
|
089dcc86e1 | ||
|
|
b6a97046b0 | ||
|
|
40b1e775fe | ||
|
|
45aad35efa | ||
|
|
c2858452e7 | ||
|
|
f1d8db0c61 | ||
|
|
636cad9655 | ||
|
|
d2c2c279e2 | ||
|
|
2299adefb5 | ||
|
|
0386065f2c | ||
|
|
054c584ebc | ||
|
|
da12cb8708 | ||
|
|
931f84fcd1 | ||
|
|
19c1f21610 | ||
|
|
4afd37e7dd | ||
|
|
fd5134e510 | ||
|
|
7216456a27 | ||
|
|
9e34664550 | ||
|
|
5642acf2f9 | ||
|
|
077737830e | ||
|
|
833c477785 | ||
|
|
b8f2bb462e |
|
|
@ -1,3 +1,8 @@
|
||||||
|
// Airbnb's own bans, kept: a `no-restricted-syntax` entry below replaces the whole list.
|
||||||
|
const airbnbRestrictedSyntax = require('eslint-config-airbnb-base/rules/style').rules[
|
||||||
|
'no-restricted-syntax'
|
||||||
|
];
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
env: {
|
env: {
|
||||||
browser: true,
|
browser: true,
|
||||||
|
|
@ -62,6 +67,21 @@ module.exports = {
|
||||||
"@typescript-eslint/no-shadow": "error"
|
"@typescript-eslint/no-shadow": "error"
|
||||||
},
|
},
|
||||||
overrides: [
|
overrides: [
|
||||||
|
{
|
||||||
|
// The one join path: a join is `joinEstateRoom`, which files the DM before it sends the join.
|
||||||
|
files: ['src/**/*.ts', 'src/**/*.tsx'],
|
||||||
|
excludedFiles: ['src/app/features/estate/joinEstateRoom.ts', 'src/**/*.test.*'],
|
||||||
|
rules: {
|
||||||
|
'no-restricted-syntax': [
|
||||||
|
...airbnbRestrictedSyntax,
|
||||||
|
{
|
||||||
|
selector: "CallExpression[callee.property.name='joinRoom']",
|
||||||
|
message:
|
||||||
|
'Join a room through joinEstateRoom (features/estate/joinEstateRoom.ts), the one join path.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
files: ['*.ts'],
|
files: ['*.ts'],
|
||||||
rules: {
|
rules: {
|
||||||
|
|
|
||||||
20
.github/workflows/build-pull-request.yml
vendored
|
|
@ -4,12 +4,14 @@ on:
|
||||||
pull_request:
|
pull_request:
|
||||||
types: ['opened', 'synchronize']
|
types: ['opened', 'synchronize']
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-pr-${{ github.event.pull_request.number }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-pull-request:
|
build-pull-request:
|
||||||
name: Build pull request
|
name: Build pull request
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
|
||||||
PR_NUMBER: ${{github.event.number}}
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
@ -24,17 +26,3 @@ jobs:
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||||
run: npm run build
|
run: npm run build
|
||||||
- name: Upload artifact
|
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
||||||
with:
|
|
||||||
name: preview
|
|
||||||
path: dist
|
|
||||||
retention-days: 1
|
|
||||||
- name: Save pr number
|
|
||||||
run: echo ${PR_NUMBER} > ./pr.txt
|
|
||||||
- name: Upload pr number
|
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
||||||
with:
|
|
||||||
name: pr
|
|
||||||
path: ./pr.txt
|
|
||||||
retention-days: 1
|
|
||||||
|
|
|
||||||
4
.github/workflows/docker-pr.yml
vendored
|
|
@ -6,6 +6,10 @@ on:
|
||||||
- 'Dockerfile'
|
- 'Dockerfile'
|
||||||
- '.github/workflows/docker-pr.yml'
|
- '.github/workflows/docker-pr.yml'
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-pr-${{ github.event.pull_request.number }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
docker-build:
|
docker-build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
|
||||||
4
.github/workflows/lockfile.yml
vendored
|
|
@ -5,6 +5,10 @@ on:
|
||||||
paths:
|
paths:
|
||||||
- 'package-lock.json'
|
- 'package-lock.json'
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-pr-${{ github.event.pull_request.number }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lockfile_changes:
|
lockfile_changes:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
|
||||||
7
.github/workflows/rat-dev-preview.yml
vendored
|
|
@ -4,6 +4,13 @@ on:
|
||||||
push:
|
push:
|
||||||
branches: ['dev']
|
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:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: Build dev preview
|
name: Build dev preview
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ and your own place in it) instead of an invite list. Mobile first.
|
||||||
|
|
||||||
**Live now:** the Cinny engine (matrix-js-sdk, Rust crypto, sync) under a
|
**Live now:** the Cinny engine (matrix-js-sdk, Rust crypto, sync) under a
|
||||||
Ratatoskr UI: branding, GameCult theme, estate navigation, pose portraits
|
Ratatoskr UI: branding, GameCult theme, estate navigation, pose portraits
|
||||||
(opt-in setting), a background scene and soundscape (opt-in, off by default).
|
(opt-in setting), a background scene (on in the dev config, `backgroundScene.tier`; the device can lower it) and an opt-in soundscape.
|
||||||
**Planned:** see [`docs/gamecult/ratatoskr-target.md`](docs/gamecult/ratatoskr-target.md)
|
**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
|
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
|
state (`cinny_*` localStorage keys, the `in.cinny.room.power_level_tags` event
|
||||||
|
|
|
||||||
42
config.json
|
|
@ -1,24 +1,40 @@
|
||||||
{
|
{
|
||||||
"defaultHomeserver": 1,
|
"defaultHomeserver": 0,
|
||||||
"homeserverList": ["converser.eu", "matrix.org", "mozilla.org", "unredacted.org", "xmr.se"],
|
"homeserverList": [
|
||||||
"allowCustomHomeservers": true,
|
"chat.gamecult.org"
|
||||||
|
],
|
||||||
|
"allowCustomHomeservers": false,
|
||||||
"featuredCommunities": {
|
"featuredCommunities": {
|
||||||
"openAsDefault": false,
|
"openAsDefault": false,
|
||||||
"spaces": ["#gamecult:gamecult.org"],
|
"spaces": [
|
||||||
"rooms": [
|
"#gamecult:gamecult.org"
|
||||||
"#tuwunel:grin.hu",
|
|
||||||
"#free-software-movement:matrix.org",
|
|
||||||
"#gentoo:matrix.org"
|
|
||||||
],
|
],
|
||||||
"servers": ["activematrixrooms.com", "matrixrooms.info", "matrix.org", "mozilla.org", "unredacted.org"]
|
"rooms": [],
|
||||||
|
"servers": [
|
||||||
|
"gamecult.org"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
"hashRouter": {
|
"hashRouter": {
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"basename": "/"
|
"basename": "/"
|
||||||
},
|
},
|
||||||
|
|
||||||
"backgroundScene": {
|
"backgroundScene": {
|
||||||
"tier": "off"
|
"tier": "full",
|
||||||
|
"style": "ridge"
|
||||||
|
},
|
||||||
|
"dings": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"fileZoneShare": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"push": {
|
||||||
|
"vapidPublicKey": "",
|
||||||
|
"gatewayUrl": "",
|
||||||
|
"appId": ""
|
||||||
|
},
|
||||||
|
"registration": {
|
||||||
|
"enabled": false,
|
||||||
|
"url": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
34
deployment/idunn/recipe.toml
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
schema = "gamecult.idunn.target_declaration.v1"
|
||||||
|
target = "ratatoskr"
|
||||||
|
# Idunn stamps the source commit into the build environment under this name. The release is not built from source: the
|
||||||
|
# dist tarball below is built by scripts the release owner runs (build-prod-dist.sh) and enters pinned by sha256, so
|
||||||
|
# each release is a recipe commit whose pin is its provenance. The stamp the served index carries is the commit the
|
||||||
|
# tarball was built from, which is not this commit.
|
||||||
|
source_stamp_environment = "RATATOSKR_BUILD_COMMIT"
|
||||||
|
|
||||||
|
# Release dist 934f82386d612c3e056f851555c7c9dfd2bdf5db: the output of build-prod-dist.sh (ALLOW_CONFIG_DRIFT=1), packed
|
||||||
|
# as dist/ at the top of one gzip tar. The tree carries the repo's own config.json; what is served is the operator's
|
||||||
|
# carried file, which Idunn copies beside dist/ and the vhost aliases over the tree's (a vhost without that alias would
|
||||||
|
# serve the repo config).
|
||||||
|
[[external_inputs]]
|
||||||
|
id = "dist-tarball"
|
||||||
|
url = "https://eureka.gamecult.org/forge/GameCult/Ratatoskr/releases/download/dist-934f8238/ratatoskr-934f8238-dist.tar.gz"
|
||||||
|
sha256 = "55e7be0622a4eddb81008bc0132e3d476bdb3c453e278599d5a0259a76d00c5b"
|
||||||
|
runner = "unpack"
|
||||||
|
destination = "release/dist.tar.gz"
|
||||||
|
|
||||||
|
[[steps]]
|
||||||
|
id = "unpack-dist"
|
||||||
|
phase = "prepare"
|
||||||
|
runner = "unpack"
|
||||||
|
argv = ["tar", "-xzf", "release/dist.tar.gz", "-C", "release"]
|
||||||
|
|
||||||
|
[[artifacts]]
|
||||||
|
id = "dist"
|
||||||
|
source_kind = "runner-output"
|
||||||
|
runner = "unpack"
|
||||||
|
source = "release/dist"
|
||||||
|
destination = "dist"
|
||||||
|
|
||||||
|
[static_release]
|
||||||
|
artifact = "dist"
|
||||||
291
docs/commons-files-desk.md
Normal file
|
|
@ -0,0 +1,291 @@
|
||||||
|
# Files desk: Commons and per-Persona file zones, two designs compared
|
||||||
|
|
||||||
|
Status: **decided: Option A, Matrix as the single authority** (Meta, #swarm 2026-11-13 14:44Z: "Let's try matrix shaped"). Design only; no Files desk code is merged and nothing is deployed. Option B (the host service) is parked, not deleted: Eureka's branch `eureka-self/commons-host` at `a391960d` has no PR and a Soul verdict of NOT READY with fixes owed, and is woken only if Meta later wants a real file store. PR #143 (B's cut 1 in this repo) is closed unmerged. Items marked **UNKNOWN** are not verified; **Q-E** are questions for Eureka, **Q-M** for Meta.
|
||||||
|
|
||||||
|
### Eureka's answers (`/opt/commons/handoff/commons-matrix-notes-20261113.md`, read 2026-11-13; final answers folded in 2026-11-13)
|
||||||
|
|
||||||
|
- **Upload cap (Q-E1, read).** Synapse 1.164.1; the repo template `homeserver.yaml` has `max_upload_size: 50M` and the nginx vhost `client_max_body_size 50m`; the live runtime file is root-only and was not read. No per-room or per-user per-file cap exists; `media_upload_limits` is a per-user time-window quota. `GET /_matrix/client/v1/media/config` returns `m.upload.size` to an authenticated client, which is what the client reads. Raising the cap takes the config line, a Synapse restart and nginx at least as large.
|
||||||
|
- **Posting (Q-E1, read).** A Self holds no Matrix credentials, so the publisher needs its own bot account and token (a MAS compatibility token via `mas-cli`). It uploads with `POST /_matrix/media/v3/upload` and sends `m.room.message` with `msgtype` `m.file` or `m.image`. The Persona mouth sends `m.text` only today, so file events are new for it too.
|
||||||
|
- **Room (Q-E3, answered).** The Commons room is `#commons:gamecult.org` (create type `org.gamecult.file_zone`, unencrypted, membership by the estate Space, publisher bot at power level 50). Provisioning and push muting are Eureka's.
|
||||||
|
- **Cap (Q-E1, answered).** 50 MiB per file. The client reads it live from `m.upload.size`, not from this document.
|
||||||
|
- **Poster (Q-E1, answered).** The publisher posts as the owning Mind: each file appears under the Mind whose tree it came from, not under a generic bot.
|
||||||
|
- **Final marker (Q-E2, answered).** A handoff document is final when a sibling `NAME.final` holds its sha256; the publisher watches for that, posts once, and carries `org.gamecult.publish.replaces` (prior event id and sha256) on a later final version.
|
||||||
|
- **Range (Q-E4, unverified).** Whether Synapse media honours `Range` has not been checked against a live mxc. Playback therefore stays on the blob download until it is.
|
||||||
|
- **Edited-doc stale copies:** a redacted file's media persists unless purged; the publisher dedupes by sha256.
|
||||||
|
|
||||||
|
Source: Meta, #swarm 2026-11-13 14:28Z and 14:40Z; ruling `ratatoskr:ruling:persona-file-sharing-zone` (a browsable, previewing collection per Persona; the Self writes to her outbox, her Persona decides what is shared, the Self never holds Matrix credentials).
|
||||||
|
|
||||||
|
## What exists on Matrix today (the base A must fit)
|
||||||
|
|
||||||
|
Read from this repo at dev `8ba407cd`:
|
||||||
|
|
||||||
|
- **Zone = a room** whose `m.room.create` type is `org.gamecult.file_zone` (`isFileZone.ts`), or one listed in config `fileZoneRooms`. One per Mind, `#<mind>-files:<server>`, found by alias or by the carried id in the root Space's `org.gamecult.stewardships` (`mediaRoom.ts`, `MediaRow.tsx`); rendered as the Media row of each Mind's group.
|
||||||
|
- **Album = a thread root** (a text event; its `body`, or its latest `m.replace` edit, is the title); **file = a thread reply** with `msgtype` `m.audio|m.image|m.video|m.file` (`buildAlbums.ts`). A redacted event or an `m.replace` is never a file. Loose room messages are not shown.
|
||||||
|
- **Read** = `GET /rooms/{id}/threads` and `/relations/{root}/m.thread`, every page, 100 per request, at most 20 pages per endpoint (`useAlbums.ts`, `readPages.ts`; a capped read shows a note in the list). Unencrypted rooms only.
|
||||||
|
- **Write** = `sendShareSet` (`shareSet.ts`, behind `fileZoneShare.enabled`): a root, then one thread reply per file, content from `getMsgContent` with `info.sha256`.
|
||||||
|
- **Media** = authenticated Matrix media fetched to a blob (`downloadMedia` in `FileContent.tsx`): so the "media element cannot send a header" problem that Option B needed a ticket for is already solved this way, at the cost that a file is fully downloaded before it plays.
|
||||||
|
- **Receipts** = `org.gamecult.file_received` from a Mind's mouth after it fetches and verifies the sha256 (`docs/file-zone-mind-contract.md`); the mouth enforces the Mind's membership in the room.
|
||||||
|
|
||||||
|
Option A is an extension of this, not a second system.
|
||||||
|
|
||||||
|
## The two options
|
||||||
|
|
||||||
|
**A. Matrix is the only authority.** Commons is one more file-zone room; each Mind's zone is the room she already has. Audience is room membership. Upload is the ordinary Matrix upload. Folders are albums (thread roots). A small publisher service with its own Matrix account posts allowlisted handoff documents into the Commons room. The Files desk is a view over file events in those rooms.
|
||||||
|
|
||||||
|
**B. The host service.** A file host on Yggdrasil serves allowlisted directories (`GET /index`, `GET /file`, a capped `PUT`), authenticated by Matrix OpenID; the client browses it as a tree. Written out in full below the line.
|
||||||
|
|
||||||
|
| | A: Matrix only | B: host service |
|
||||||
|
| -------------------------- | ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------- |
|
||||||
|
| Authorities | one (Matrix: identity, audience, storage, audit) | two (Matrix for identity, the host for files and access) |
|
||||||
|
| Audience | room membership and power levels | the host derives a role from Matrix identity and enforces it again |
|
||||||
|
| Audit of writes | the event `sender`, kept by the homeserver | the host's own log |
|
||||||
|
| Overwriting someone's file | impossible: events are immutable and only the sender's own edit counts; a redaction is a steward's act | host rule, host code |
|
||||||
|
| Upload size cap | the homeserver's `m.upload.size`, global, 50 MiB (Eureka) | the host's own, chosen freely |
|
||||||
|
| Type allowlist | not enforceable by the homeserver; client-side courtesy, steward redaction | enforced by the host (sniffed) |
|
||||||
|
| What is browsable | what was posted (published or uploaded) | what is on disk under the allowlist, live |
|
||||||
|
| Mind-written handoff docs | a publisher posts them once, when final (below) | the host serves the tree |
|
||||||
|
| Per-Persona zone | the existing Media room; the Self's outbox reaches it through the mouth, as the ruling says | a `shared/` directory the host serves |
|
||||||
|
| Folders | albums; nesting by path in the album title | real directories |
|
||||||
|
| Mind fetch | unchanged (mouth, room id and event id, receipts) | a second route the mouth would need |
|
||||||
|
| Media playback | blob download, as today; no seeking before the whole file arrives | ranges and a ticket, or the same blob |
|
||||||
|
| New moving parts | a room, a publisher | a host service, OpenID verification, CORS, tickets, path containment, rate limits |
|
||||||
|
|
||||||
|
## Recommendation: A (decided)
|
||||||
|
|
||||||
|
Confidence: high on the authority question (it agrees with Meta's ruling, with Eureka's lean, with the standing ruling that the Self never holds credentials while her Persona decides what is shared, and with the fact that everything built so far reads Matrix); moderate on the details below, which Eureka's answers can change.
|
||||||
|
|
||||||
|
What A gives up, stated plainly:
|
||||||
|
|
||||||
|
- **A live view of what is on disk.** Only what was published or uploaded appears. A handoff doc that was never marked final is not in the Commons.
|
||||||
|
- **Files over the homeserver's upload cap.** B had no Matrix limit; A has exactly one.
|
||||||
|
- **A server-side type allowlist.** Under A a member can upload any type the homeserver accepts; the client shows a refusal for types it does not want, and stewards redact. If a strict allowlist matters, a gating bot (post, check, redact) would have to exist, and it is a second authority again.
|
||||||
|
- **Seeking inside a large audio or video file** until Matrix media supports ranges for the client (**UNKNOWN**; the blob route is what runs today).
|
||||||
|
- **Hierarchy beyond the album level** as a native structure; see Folders.
|
||||||
|
|
||||||
|
What it buys: one identity, one audience, one audit trail, immutable files, existing receipts and the mouth's fetch, and no new service to keep secure.
|
||||||
|
|
||||||
|
## Option A in detail
|
||||||
|
|
||||||
|
**Rooms.** Commons is a room with create type `org.gamecult.file_zone`, alias `#commons:gamecult.org`, join rule `restricted` to the estate Space so members join without an invite and Minds are invited, history `shared`, unencrypted (the zones are, and `info.sha256` is written only there). Power levels: `events_default` 0 so members post; `redact` and `state_default` at steward level. The room is muted for push by provisioning: file posts are thread replies, which can raise unread counts and notifications (**UNKNOWN** under this server's push rules), so the room must not shout. Per-Persona zones are the rooms Eureka already provisions; nothing new.
|
||||||
|
|
||||||
|
**Folders.** Four ways to have folders on Matrix, in order of preference:
|
||||||
|
|
||||||
|
1. _Albums are folders; nesting is a path in the album title_ (`handoff/2026-11`, `cast-art/poses`). Zero wire change: `buildAlbums` already reads it, and the client groups albums by title path as a pure view. Cost: a thread root per folder, and the rule that a folder's root is posted once by whoever opens the folder. **Recommended.**
|
||||||
|
2. A `org.gamecult.file.path` key on each file event. Gives a real tree with files anywhere; needs every poster to write it and every client to honour it; other Matrix clients ignore it and show a flat thread.
|
||||||
|
3. A room per folder, in a Space. Native hierarchy and per-folder audience; heavy (a room is a membership, a sync entry and provisioning each) and it multiplies the sync load on a phone.
|
||||||
|
4. A state event per folder (`org.gamecult.file_folder`). Mutable folder metadata (title, order), writable only at steward level; useful later if folders need descriptions, not needed for a first cut.
|
||||||
|
|
||||||
|
**Albums in each Mind's room** are the same mechanism: a Mind's Media room holds that Mind's albums, the Commons holds the shared ones. The Files desk is the same view over either room; a zone differs only by room, not by code.
|
||||||
|
|
||||||
|
**The publisher.** A small service on Yggdrasil with its own Matrix account (a bot account that posts as the owning Mind, per Eureka's answer above). The Self never holds this account; the publisher holds it. It owns the allowlist.
|
||||||
|
|
||||||
|
- **When it posts.** Handoff documents are edited in place, so mirroring every save would spam the room with drafts. The rule (Eureka's lean, adopted): **post once, when the document is marked final**; a later final version posts again with a `replaces` relation to the earlier event. "Final" is a sibling `NAME.final` holding the document's sha256 (answered Q-E2); the client must not know.
|
||||||
|
- **Allowlist.** Lives on the publisher: a list of source roots and patterns, read by the publisher only. `handoff-private` is not on it and the publisher never lists, reads or posts from it. The client holds no path to anything on disk.
|
||||||
|
- **The event.** A file event in the thread of the folder's album, built the way `getMsgContent` builds them (`msgtype`, `body`, `filename`, `url` from the media repo, `info.mimetype|size|sha256`), plus one extra key `org.gamecult.publish`: `{ source, sha256, replaces? }` where `source` is a path relative to the allowlisted root, never absolute, and `replaces` is the event id of the version it supersedes.
|
||||||
|
- **`replaces`, not `m.replace`.** `m.replace` is the stock edit relation, but Ratatoskr's `buildAlbums` deliberately never treats an edit as a file, several clients do not render an edited file, and an edit hides the earlier version. A custom key keeps both events as files; the view shows the latest of a chain and a "N earlier versions" row under it, and other Matrix clients see two ordinary file posts. A chain is read from the events themselves (each carries its `replaces`), not from a table, and a `replaces` counts only when its event has the same sender as the event it names, so nobody else in the thread can fold a file away.
|
||||||
|
- **Idempotence.** The publisher's own state (a typed CultCache document of `source`, `sha256`, `event_id`) says what it posted; if that is lost, it rebuilds by reading its own events back from the room (`org.gamecult.publish.source` plus `sha256`) rather than guessing. The same `sha256` never posts twice.
|
||||||
|
- **Receipts and fetch** work unchanged: the event is an ordinary file event in a file-zone room, so a Mind's mouth can fetch it by room id and event id (5 MiB cap there) and tick `fetched for`.
|
||||||
|
|
||||||
|
**Uploads (approved for members and Minds).** The ordinary Matrix upload, through the composer pipeline and `ShareSet`. The host rules Eureka gave map onto Matrix as follows:
|
||||||
|
|
||||||
|
| Host rule | Under A |
|
||||||
|
| ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| only under an `uploads/` subtree | an album (folder) named `uploads/...`; the client offers upload only there (and in a Mind's own zone) |
|
||||||
|
| never overwrite Mind-written handoff files | free: other people's events cannot be replaced; a duplicate name is just another post in the thread |
|
||||||
|
| size cap | the homeserver's `m.upload.size`, shown from `MediaConfigLoader`; the client refuses a larger file before sending |
|
||||||
|
| type allowlist | client-side only; see "gives up" |
|
||||||
|
| explicit flag to overwrite | no overwrite exists; a new version is a new post, optionally with `replaces` set by the same sender |
|
||||||
|
| audit log with the Matrix id | the event `sender` |
|
||||||
|
|
||||||
|
What A adds to `ShareSet`: sharing into an existing album ("add files", listed as not built in `docs/file-zone.md`), progress per file, cancel, and per-error refusal messages written against the SDK's `MatrixError` (`M_TOO_LARGE`, `M_FORBIDDEN`, `M_LIMIT_EXCEEDED`) instead of host codes. The draft refusal table of Option B's upload flow carries over with those codes. **UNKNOWN:** whether the SDK exposes upload progress and abort for media (it does for `uploadContent` with a progress callback; the composer already shows progress, `state/upload.ts`).
|
||||||
|
|
||||||
|
## What each costs on the phone
|
||||||
|
|
||||||
|
Under both, the phone design is the same shape (one column, rows 56 px or more, hidden controls, in-place preview; see the Option B sections below, which are written against the host but whose layout applies).
|
||||||
|
|
||||||
|
- **A, new cost:** the Commons room is one more room in sync and it must be muted; albums and files are read to a cap of 20 pages of 100 per endpoint (a Commons past that shows a truncation note); a folder tree is a title-path grouping, so a deep tree is a long list of albums with breadcrumbs, not true directories; a file plays after a full download (small audio and images are fine; a 40 MB clip is not).
|
||||||
|
- **A, no cost:** no second sign-in, no ticket, no CORS, no new fetch layer, no host-unreachable state beyond the homeserver's own. The album list, album view, share panel and receipts exist and are tested; the view is their extension.
|
||||||
|
- **B, new cost:** every screen is a second client against a second service with its own errors, token refresh and unreachable state; media needs the ticket or the blob; the tree and preview are new UI.
|
||||||
|
- **B, benefit:** a tree that is live and complete, ranges, and no Matrix size limit.
|
||||||
|
|
||||||
|
## Changes to Eureka's host work
|
||||||
|
|
||||||
|
Under A the paused service is not built, and most of its list disappears:
|
||||||
|
|
||||||
|
- **Dropped:** `/index`, `/file`, `PUT`, OpenID verification pinned to the server name, CORS, path containment and sniff rules, tickets, the `uploads/` write rules, the role response, the per-path audit log (Q-E3, Q-E4, Q-E6, Q-E7, Q-E8 to Q-E11 of the Option B list).
|
||||||
|
- **Kept:** the Commons allowlist decision (Q-E1 of that list), moved to the publisher: which roots, and that `handoff` is only ever published through the final-marker rule, never browsed.
|
||||||
|
- **New:** (1) provision the Commons room (type, alias, restricted join to the estate Space, power levels, push muted); (2) the publisher: account, allowlist, final-marker rule, media upload, posting into folder threads, the `org.gamecult.publish` key, idempotence from its own events; (3) confirm the homeserver upload cap and whether the account may post file events from Yggdrasil (**Q-E1**, asked on the PR); (4) the per-Persona path stays the one already specified (`album-outbox-ops`: outbox, Persona decision, mouth post), so the `shared/` convention of Option B is retired.
|
||||||
|
|
||||||
|
## Cuts under A
|
||||||
|
|
||||||
|
Status 2026-11-14 (dev da822e9e plus the size-refusal cut): built: folders by album title path with navigation and breadcrumb (#147), pagination (#148), version folding (#149), add-to-album with named refusals (#150), Stop for a send in flight, replaces loops shown, freshest last event on add (#151), and the pre-send size refusal: a file larger than the server's `m.upload.size` is named and left unsent before any upload or album root, and a server stating no cap refuses nothing. **Not built: the Commons entry row** (item 1 below, found by `#commons:gamecult.org` or the carried `media.commons` id through `lookupMediaRoom`, behind a dark `filesDesk.enabled` flag; neither the row nor the flag exists in the code). It waits on Eureka's Commons room.
|
||||||
|
|
||||||
|
1. **Commons entry**: a Commons row in the navigator (like `MediaRow`, found by alias or carried id), opening the room's album view. Flagged, no new data layer.
|
||||||
|
2. **Pagination** of albums and files in `useAlbums` (done, PR #148).
|
||||||
|
3. **Folders by title path** and the album list grouped by it.
|
||||||
|
4. **Versions**: read `org.gamecult.publish.replaces`, show the latest of a chain with the earlier versions folded under it (done, PR #149).
|
||||||
|
5. **Add files to an existing album**, with per-file progress, cancel and the refusal messages above (the upload flow). Done: adding to an open album and the three refusal messages (PR #150); Stop for a send in flight is in too (PR for follow-up `files-desk-release-146-and-send-cancel`): it cancels the running upload through `onPromise` and `mx.cancelUpload`.
|
||||||
|
|
||||||
|
PR #143 (Option B cut 1: contract types, host client, fake host) is **held**; under A it is not needed and would be closed. Its Soul pass found, and a revived B would have to close, an unvalidated `host` that could send the bearer to the wrong origin, hostile-looking entry names beyond separators, loose numeric checks and missing CSP and `Content-Disposition` headers in the fake.
|
||||||
|
|
||||||
|
## Not in scope
|
||||||
|
|
||||||
|
The Mind-side fetch (the mouth's); encrypted rooms; deletion and rename by this client; search across zones; any permission model of our own beyond Matrix room membership and power levels.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
**Eureka:** answered; see "Eureka's answers" at the top. Still open on Eureka's side: Range support (Q-E4) and the live runtime `max_upload_size`.
|
||||||
|
|
||||||
|
**Meta:**
|
||||||
|
|
||||||
|
- **Q-M1.** Commons audience: members and Minds as proposed; Users and Patrons never, or later?
|
||||||
|
- **Q-M2.** Is a Persona's zone visible to all members or to her stewards and Minds? (The Matrix zone's audience question, `ratatoskr:question:file-zone-audience`, is the same fork.)
|
||||||
|
- **Q-M3.** May Users or Patrons upload if they are ever let in?
|
||||||
|
- **Q-M4.** Names: "Files" for the view, "Commons" for the shared room.
|
||||||
|
- **Q-M5.** A type allowlist is not enforceable under A without a gating bot; is a client-side refusal plus steward redaction enough?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Option B in full (kept for the comparison; superseded in part by Option A above)
|
||||||
|
|
||||||
|
The sections below were written against the host service. Where they speak of a host, a role, a ticket, a token, `/index` or `/file`, they are Option B. The phone layout, the zone table and the refusal wording carry over to A with the Matrix equivalents given above.
|
||||||
|
|
||||||
|
### What exists, and what this adds
|
||||||
|
|
||||||
|
| | Matrix file zone (`docs/file-zone.md`, built) | Files desk (this page) |
|
||||||
|
| -------- | ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Storage | Matrix media in a `#files-<mind>` room, albums as threads | Directories on Yggdrasil, served by a small host |
|
||||||
|
| Listing | thread roots, `buildAlbums` | `GET /index?path=` |
|
||||||
|
| Unit | album of posted files | file in a directory tree |
|
||||||
|
| Strength | marks (keep / fix / drop), receipts (`fetched for`), any Matrix client works | whole trees without N uploads, no Matrix size limit, `/opt/commons` material becomes visible to people on a phone |
|
||||||
|
| Entry | Media row per Mind (`MediaRow.tsx`) | Files row (below) |
|
||||||
|
|
||||||
|
They do not compete. The Matrix zone stays where a file needs a mark or a receipt (Aqua's listening kits); the Files desk is for browsing and fetching what is already on disk. Neither replaces the other, and the Files desk does not write Matrix events. The ruling left "Matrix media plus a listing versus a served directory" open; this page is the served-directory half, built beside the Matrix half already live.
|
||||||
|
|
||||||
|
### Zones (host)
|
||||||
|
|
||||||
|
| Zone | Contents | Who sees it |
|
||||||
|
| ---------------- | ------------------------------------------------------------ | -------------------------------------------------------------- |
|
||||||
|
| Commons | a curated tree of `/opt/commons` | members and Minds |
|
||||||
|
| Persona `<slug>` | what that Persona has chosen to share from her Self's outbox | **Q-M:** all members, or the Persona's stewards and Minds only |
|
||||||
|
| handoff-private | `/opt/commons/handoff-private` (0750) | never: not a zone, not listed, not a path the host resolves |
|
||||||
|
|
||||||
|
Layout of `/opt/commons` today (read 2026-11-13): `backlog` (755, per-Mind backlog md and summary.json), `bin`, `cast-art` (setgid, group-writable: poses, busts), `handoff` (setgid, group-writable, 92 entries of briefs, patches, receipts, dings, kits), `handoff-private` (0750, ACL), `opt` (toolchains, root-owned), `situation` (0750, setgid), all `nobody:nogroup`. **`handoff` is not a publishable tree:** it holds ops notes, custody receipts (`bifrost-appsecret-custody-receipt-*`) and patches written for Minds, with no sticky bit. Serving it wholesale shows secrets-adjacent notes to every member and lets any Mind with group write change what a phone user sees. The Commons zone is therefore an allowlist of roots (candidate: `cast-art`, `backlog`, and a new `commons/` directory Minds publish into on purpose), not `/opt/commons` itself. **Q-E:** which roots; whether `handoff` ever, and if so through a reviewed `published/` subdirectory.
|
||||||
|
|
||||||
|
Per-Persona source is the outbox from `ratatoskr:follow_up:album-outbox-ops-and-persona-specs`: `/var/lib/mind-<slug>-outbox`, owned by `mind-<slug>-self`, 0750, ACL read for `mind-<slug>` and the host user. The Self writes there; the Persona decides what is shared. Making that decision a filesystem fact (a `shared/` subdirectory the Persona's turn populates by copy or hardlink, the host serves only `shared/`) keeps the host dumb: it never learns what "decides" means. **Q-E:** `shared/` subdirectory versus a per-file marker.
|
||||||
|
|
||||||
|
### Phone-first UI (host)
|
||||||
|
|
||||||
|
Phone width is the design width; desktop adds the navigator beside it (as `Desk` does in `Router.tsx`). Vertical space is cheap, so a screen shows one thing and hides what is not in use.
|
||||||
|
|
||||||
|
- **Entry.** A `Files` row pinned in the navigator beside `DeskEntry` and `MindMedia`: one at the estate level (Commons) and one inside each Mind's group (her zone), same `NavItem` anatomy as `DeskEntry`, dim when the zone has no items. Route `/files/:zone/*path`, sibling of `DESK_PATH`.
|
||||||
|
- **List.** One column. Row = icon or thumbnail, name (truncates in the middle, keeping the extension), size and age on a second dim line. Directories first. Rows are at least 56 px tall; the tap target is the whole row (the `PhoneHit` variants and the repo-wide guard test from `phoneHit.coverage.test.ts` apply to any IconButton inside).
|
||||||
|
- **Hidden until needed.** Breadcrumb is one line (current folder, a back arrow; the full path on tap). Search, sort and the grid toggle sit behind a single icon in the header and open inline above the list. Download and share appear on the open file, not on every row. Nothing is permanently stacked in the header.
|
||||||
|
- **Preview.** Tap a file: it opens in place below its row, full width, and pushes the rows below it down (vertical room is the cheap axis). Image: fitted, tap for the existing `ImageViewer`. Audio: the `AudioContent` controls rehosted (`renderMediaControl`), one player at a time, a mini-player pinned to the bottom only while something plays. Video: native controls. Text, md, json, patch: monospace, first 64 KiB, "show all" below. PDF: open in a new tab. Anything else: name, size, hash, one Download button.
|
||||||
|
- **Honesty rows.** Loading, empty, "host unreachable", "not allowed" are rows that say so (as `MediaNote` does); no spinner without a name for what it waits on.
|
||||||
|
- **Role-driven.** The index response carries the caller's role; the client uses it only to hide controls (upload), never to decide access.
|
||||||
|
|
||||||
|
### Host contract
|
||||||
|
|
||||||
|
The host is a service on Yggdrasil that Eureka owns. The client holds no filesystem knowledge. All JSON here is the xenos boundary between a browser and a file server, not state.
|
||||||
|
|
||||||
|
- `GET /index?path=<p>` returns `{ role, zone, path, entries: [{ name, kind: "file"|"dir", size, mtime, mime, hash }] }`. `mtime` is unix seconds. `hash` is the lower-case hex sha256 of the plaintext bytes, absent for directories, and may be absent for a file whose hash is not computed yet (client shows nothing, never blocks on it). `mime` is sniffed by the host from magic bytes, not guessed from the extension.
|
||||||
|
- `GET /file?path=<p>` streams the bytes with `Accept-Ranges: bytes` and 206 for `Range` (audio and video seek; a phone on mobile data does not fetch 40 MB to play 10 seconds). `Content-Type` is the sniffed mime, `X-Content-Type-Options: nosniff`, `Content-Disposition: attachment` for anything that can run in the origin (`html`, `svg`, `js`, `xhtml`), and a `Content-Security-Policy: sandbox` on every response.
|
||||||
|
- `path` is `<zone>/<relative path>`, with the zone one of the names the host announces. The host resolves the real path of every request and refuses anything that leaves the zone's root: `..`, absolute paths, NUL, symlinks that cross the root, dotfiles, and `handoff-private`, which is not on any allowlist. Errors are typed (`401 unauthenticated`, `403 zone not allowed`, `404`, `416`), and a path outside the allowlist is 404, not 403, so it says nothing about what exists.
|
||||||
|
- **Auth: Matrix OpenID.** The client calls `mx.getOpenIdToken()` (already used in `CallWidgetDriver.ts`) and sends it as `Authorization: Bearer <token>`. The host verifies it against the homeserver's federation `openid/userinfo` endpoint, **pinned to our server name** (an `@void:evil.example` token must fail), caches the verified `sub` for the token's lifetime, and derives the role. It never sees the user's access token, and a Mind's Self never holds a token (ruling above): Minds reach the same bytes through the filesystem or the mouth, not this host.
|
||||||
|
- **Role** in the response: `member`, `mind`, `steward` (host decides from the Matrix id and the room/space membership the host reads; the client trusts none of it for access). **Q-E:** exact source of membership (estate Space membership read by the host with a service account, versus the Persona catalogue file for Minds).
|
||||||
|
- **Uploads** (Meta approved for members and Minds, #swarm 2026-11-13 14:30Z; rules are Eureka's, below). `PUT /file?path=<p>` writes only under an `uploads/` subtree of a zone, with `Content-Length` required; the host enforces a size cap, a type allowlist (sniffed, not the client's claim), no overwrite unless the request carries an explicit flag, and never replaces a file a Mind wrote into the handoff tree; every write is audit-logged with the caller's Matrix id. Reads cover the whole allowlisted tree, so an uploaded file is listed like any other. Success is `201` with the stored name, size and hash. The `role` in the index response gates the upload control; the host gates the write.
|
||||||
|
|
||||||
|
### The one problem the contract leaves: media elements cannot send headers
|
||||||
|
|
||||||
|
`<audio src>`, `<video src>` and `<img src>` cannot attach `Authorization`. Options, none free:
|
||||||
|
|
||||||
|
1. **Fetch to a blob.** Works with the contract as written. No seeking before the whole file is in memory; bad for a 40 MB clip on a phone. Fine for images and small audio.
|
||||||
|
2. **Short-lived path ticket.** `POST /ticket?path=` with the bearer token returns a URL with an unguessable token valid for a few minutes for that one path and its ranges. The player gets a normal URL and seeks. One more endpoint on the host and one more thing to expire and log.
|
||||||
|
3. **Service worker adds the header** to `/files-host/*` requests. The PWA already has a worker (`src/sw.ts`); no host change, but the worker must carry a fresh OpenID token and range requests through it are fiddly.
|
||||||
|
|
||||||
|
Recommendation: 2 for audio and video, 1 for images and text. Confidence moderate: I have not tested a service worker against range requests on the target phone, and 2 is the usual answer for authenticated media (signed URLs). **Q-E.**
|
||||||
|
|
||||||
|
### Upload flow (host)
|
||||||
|
|
||||||
|
Design only. Every number and error code is a named placeholder until Eureka gives them on the PR: `<CAP_FILE>` (per-file size), `<CAP_BATCH>` (per-pick total, if any), `<TYPES>` (the allowlist), `<OVERWRITE_FLAG>` (the explicit query flag), and the codes `<E_AUTH>`, `<E_ROLE>`, `<E_PATH>`, `<E_TYPE>`, `<E_SIZE>`, `<E_EXISTS>`, `<E_PROTECTED>`, `<E_QUOTA>`, `<E_SERVER>`.
|
||||||
|
|
||||||
|
**Where.** The control exists only where an upload is allowed: inside a folder under a zone's `uploads/` subtree (or at the `uploads/` row itself), and only when the index `role` is member or mind. Elsewhere there is no button; the list does not carry a disabled one. A folder outside `uploads/` shows a one-line dim note, "Uploads go in uploads/", which links to that folder. Creating subfolders under `uploads/` is not in this design (**Q-E8**).
|
||||||
|
|
||||||
|
**Pick or drop.** One `Upload` row at the top of an `uploads/` folder opens the native picker (any number of files; on a phone this is the OS file sheet, camera roll included). On wide screens the list is also a drop target; the drop highlight appears only while a drag is over it. Nothing starts on pick: the picked files appear as a staging list (name, size, a type icon) with `Upload` and `Cancel` as full-width rows, as `ShareSet` does for a Matrix set. Files that already fail the client's cheap checks (over `<CAP_FILE>`, a type outside `<TYPES>`) are marked in the staging list with the reason and left out of the send; the client mirrors the cap and allowlist as a courtesy only, the host is the authority, and the list is read from the host (**Q-E9**: an `/index` field or a `/limits` call) rather than hard-coded.
|
||||||
|
|
||||||
|
**Send.** Files go one at a time, in pick order, each a `PUT /file?path=<folder>/<name>` with the bearer token. One at a time keeps a phone on mobile data from starving every transfer, and a refusal names exactly one file. The transport is `XMLHttpRequest`, not `fetch`: `fetch` has no upload progress and the row needs it. The body is the `File` itself (streamed by the browser, not read into memory; unlike the Matrix path, the client computes no hash first, the host returns one). The name is the file's own name, with path separators and control characters replaced, never a path the user typed.
|
||||||
|
|
||||||
|
**Progress.** The staging list becomes the progress list: per file a thin bar and "412 KB of 3.1 MB", per batch "2 of 5". Finished files show a tick and the stored name; refused ones show the refusal in place; waiting ones are dim. Only the active file animates. The list stays on screen after the last file until dismissed, so a refusal is not lost to a refresh of the folder underneath it. The folder listing refetches after each success, so the file appears in the list as it lands.
|
||||||
|
|
||||||
|
**Cancel.** `Cancel` on the active file aborts the request (`xhr.abort()`) and goes on to the next; `Cancel all` on the batch aborts the active file and drops the rest. The host must discard an aborted partial write and never leave a half file in the listing (**Q-E10**, written to a temporary name and renamed on completion). Leaving the page aborts whatever runs, as the Matrix share does; the client does not resume.
|
||||||
|
|
||||||
|
**Overwrite.** A name that already exists is refused with `<E_EXISTS>` and shown as "<name> is already there". A `Replace` row appears under that refusal only for a file the caller wrote themselves, and sends the same `PUT` with `<OVERWRITE_FLAG>`; a Mind-written handoff file refuses with `<E_PROTECTED>` and offers only "Keep both", which re-sends under a suffixed name (`name (2).ext`) chosen by the client. Whether the client can tell "mine" from the index (an `owner` field with the Matrix id) or learns it only from the refusal is **Q-E11**; absent an owner field, `Replace` is simply offered after `<E_EXISTS>` and the host's answer decides.
|
||||||
|
|
||||||
|
**Refusal messages.** One plain sentence per code, shown in the file's row; nothing says "error" without saying why, and none names a server path.
|
||||||
|
|
||||||
|
| Code | Message (draft) |
|
||||||
|
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| `<E_AUTH>` | Sign-in expired. Reload to upload again. (The client fetches a fresh OpenID token once and retries before showing this.) |
|
||||||
|
| `<E_ROLE>` | Uploads are for members and Minds. |
|
||||||
|
| `<E_PATH>` | That folder does not take uploads. |
|
||||||
|
| `<E_TYPE>` | "<name>" is a <type> file; allowed types are <TYPES>. |
|
||||||
|
| `<E_SIZE>` | "<name>" is <size>; the limit is <CAP_FILE>. |
|
||||||
|
| `<E_EXISTS>` | "<name>" is already there. |
|
||||||
|
| `<E_PROTECTED>` | "<name>" was written by a Mind and cannot be replaced. |
|
||||||
|
| `<E_QUOTA>` | The shared space is full. Tell a steward. |
|
||||||
|
| `<E_SERVER>` / network / timeout | Could not reach the file host. The file was not saved. |
|
||||||
|
|
||||||
|
Retry is one tap on a failed row; the batch never retries on its own. An unknown code shows the host's status text as it is, still without a path.
|
||||||
|
|
||||||
|
**Honest end state.** Done means the host answered `201` and the stored name, size and hash are shown. "Saved" is not said earlier; the hash the host returns is displayed so a sender can compare it with their own.
|
||||||
|
|
||||||
|
**Reuse and not.** `ShareSet`'s state machine (staging, one-send-per-zone guard, failed files kept by position) is the model, but the Matrix share path and its `createUploadAtom` are tied to the SDK's media upload and are not reused; the new controller sits in `features/files/uploadController.ts` with the same injected-port shape, so order, refusal and cancel rules are tested against a fake `PUT` without a browser. Real transfer progress and a real range of a phone's picker stay a phone check.
|
||||||
|
|
||||||
|
### Client shape (host)
|
||||||
|
|
||||||
|
Under `src/app/features/files`, mirroring `features/albums`:
|
||||||
|
|
||||||
|
- `filesHost.ts`: ports, `list(path)`, `url(path)`, `ticket(path)`, `put(path, file, onProgress, signal)`; the only module that knows the contract. Injected, so the UI is tested against a fake and the real client never runs in a test.
|
||||||
|
- `useFilesZone.ts`: react-query on `['files', zone, path]`, `staleTime` 30 s; the role comes with every index.
|
||||||
|
- `FilesDesk.tsx`, `FileRow.tsx`, `FilePreview.tsx`: pure rendering from the above; the preview picks its renderer from the host's `mime`, not the extension.
|
||||||
|
- `config.json`: `filesDesk: { enabled: false, host: "https://…" }`, dark by default as `fileZoneShare.enabled` is. With it off, no row mounts and no request leaves the client.
|
||||||
|
- Test host: `src/preview-demo` already answers for a pretend homeserver through a fetch guard (`install.ts`); it gains a fake `/index` and `/file` with ranges so the previews are checked in a real browser before the real host exists.
|
||||||
|
|
||||||
|
### Cuts (host)
|
||||||
|
|
||||||
|
1. **Contract and fake host** (no UI): `filesHost.ts`, types, `preview-demo` fake, path tests (traversal strings from the host side are Eureka's; the client must only build `path` from entries it was given).
|
||||||
|
2. **Read-only Commons desk**: route, row, list, breadcrumb, role display, flagged off.
|
||||||
|
3. **Preview and player**: image, audio with ranges, text, video; the ticket if Q-E settles on 2.
|
||||||
|
4. **Per-Persona rows** in each Mind's group, once the outbox `shared/` convention and the zone provisioning exist.
|
||||||
|
5. **Uploads**: the flow above; needs Eureka's codes and caps and the host's `PUT`.
|
||||||
|
|
||||||
|
Each is its own PR. Slices 1 to 3 need no host to build and test; slice 2 needs the real host to be useful, not to land.
|
||||||
|
|
||||||
|
### Not in scope (host)
|
||||||
|
|
||||||
|
The Matrix file zone (marks, receipts, `ShareSet`); any Mind-side fetch (the mouth's); encrypted rooms; deletion and rename (a read surface first; the files belong to whoever wrote them on disk); search across zones; a permission model of our own beyond what the host derives from Matrix identity.
|
||||||
|
|
||||||
|
### Open questions (host)
|
||||||
|
|
||||||
|
**For Eureka (host):**
|
||||||
|
|
||||||
|
- **Q-E1.** Which roots form Commons: the allowlist, and whether `handoff` is ever served (it holds custody receipts and ops notes; recommendation: no, a reviewed `commons/` directory instead).
|
||||||
|
- **Q-E2.** Where the host runs and what it is: Rust by default per doctrine; nginx with `auth_request` to a verifier is smaller if the sniff, range and role logic fit there. Which user; read-only mounts; ACLs on the outboxes.
|
||||||
|
- **Q-E3.** Media-element auth: blob, ticket or service worker (above).
|
||||||
|
- **Q-E4.** Role source: how the host learns membership and which Minds are Minds; token verification pinned to `gamecult.org`.
|
||||||
|
- **Q-E5.** Per-Persona publishing: a `shared/` subdirectory versus a marker, and who populates it when the Persona decides.
|
||||||
|
- **Q-E6.** Hash cost: compute lazily and cache by (path, mtime, size), or at publish time.
|
||||||
|
- **Q-E7.** CORS for `chat.gamecult.org` and the dev previews, rate limits, and the `Content-Disposition` list.
|
||||||
|
- **Q-E8.** Subfolders under `uploads/`: can a client create them (by a `PUT` path that implies them, or a `MKCOL`-like call), or is `uploads/` flat?
|
||||||
|
- **Q-E9.** Cap and allowlist discovery: fields in the index response, or a `/limits` endpoint; and the exact codes and cap numbers behind every placeholder in the upload flow.
|
||||||
|
- **Q-E10.** Partial writes: temporary name then rename, so an aborted or failed upload never appears in a listing.
|
||||||
|
- **Q-E11.** Does the index carry an `owner` Matrix id per file (so `Replace` is offered only on one's own files), and is the audit log readable by stewards from the client or only on the host?
|
||||||
|
|
||||||
|
**For Meta:**
|
||||||
|
|
||||||
|
- **Q-M1.** Audience: Commons for members and Minds as proposed; are Users and Patrons ever in (the Matrix zone's audience question, `ratatoskr:question:file-zone-audience`, is the same fork)?
|
||||||
|
- **Q-M2.** Is a Persona's zone visible to all members or to her stewards and Minds?
|
||||||
|
- **Q-M3.** ~~Uploads~~ approved for members and Minds (2026-11-13 14:30Z); open only: may Users or Patrons upload if they are ever let in?
|
||||||
|
- **Q-M4.** Name: "Files" for the row, "Commons" for the shared zone.
|
||||||
|
- **Q-M5.** Does the Matrix Media row stay beside the Files row on each Mind, or does one absorb the other once the Files desk exists (recommendation: both stay; marks and receipts are Matrix's).
|
||||||
98
docs/file-zone-mind-contract.md
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
# File zone: what a Mind can read from a file event
|
||||||
|
|
||||||
|
Ruling `ratatoskr:ruling:persona-file-sharing-zone`. The Mind-side fetch is the mouth's
|
||||||
|
(`eureka-body:cut_spec:mouth-fetch-attachment.r1`); this page is the client side of the same contract.
|
||||||
|
A Mind cannot fetch an `mxc://` itself (the media endpoint answers 401), so it never holds one.
|
||||||
|
|
||||||
|
## What the Mind passes
|
||||||
|
|
||||||
|
Room id and event id of the file event. Never a bare `mxc://`, URL or hash. The mouth fetches the event,
|
||||||
|
reads the media reference from its content, and enforces the Mind's own membership in that room at fetch
|
||||||
|
time. A room the Mind is not in is refused by the mouth, whatever the client wrote.
|
||||||
|
|
||||||
|
## What the client writes (this repo, `RoomInput.tsx` send path)
|
||||||
|
|
||||||
|
A file event is an `m.room.message` whose `msgtype` is `m.audio`, `m.image`, `m.video` or `m.file`:
|
||||||
|
|
||||||
|
| field | where | note |
|
||||||
|
| ------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| `msgtype` | content | selects the kind from the file's mimetype prefix |
|
||||||
|
| `body`, `filename` | content | both the file's name (display only; never a path) |
|
||||||
|
| `url` | content | the `mxc://` of an unencrypted file; the mouth reads it |
|
||||||
|
| `file` | content | present instead of `url` in an encrypted room: `{url, key, iv, hashes.sha256 (ciphertext), v}`. A Mind has no key; the mouth refuses it |
|
||||||
|
| `info.mimetype` | content | the browser-reported type, normalised by `safeFile` (e.g. `application/ogg` becomes `audio/ogg`). A claim, not proof: the mouth sniffs magic bytes |
|
||||||
|
| `info.size` | content | bytes of the file as uploaded |
|
||||||
|
| `info.sha256` | content | lower-case hex sha256 of the plaintext bytes, computed by the client before send; written in unencrypted rooms only (a plaintext hash in an encrypted room would leak the file's identity) |
|
||||||
|
|
||||||
|
The hash is computed over the same bytes that are uploaded, reading the whole file into memory before send (large videos pay for it); if hashing fails the file is sent without one. A file sent by something other than this
|
||||||
|
client may lack `info.sha256`; the mouth then logs the hash it computed, and the row can show no `fetched for`
|
||||||
|
for it.
|
||||||
|
|
||||||
|
`info.mimetype` and `info.size` are written on audio and file events always; on image and video events `info` exists only if the browser could load the media, otherwise `info` holds `sha256` alone. Image and video events also carry `info.w`, `info.h`, `info.xyz.amorgan.blurhash` and a thumbnail
|
||||||
|
reference; audio carries no duration. A Mind should not rely on any of those.
|
||||||
|
|
||||||
|
## Limits and refusals
|
||||||
|
|
||||||
|
- Size: the mouth streams at most 5 MiB and refuses a larger file. The client does not enforce this cap;
|
||||||
|
its own limit is the homeserver's `m.upload.size`. A file over 5 MiB can be shared with people and
|
||||||
|
not fetched for a Mind.
|
||||||
|
- Refusals are the mouth's and are typed: not a member of the room, bridged or Discord room, encrypted
|
||||||
|
file, over the cap, content type not matching the magic bytes. The client does not re-check them and
|
||||||
|
nothing in the client depends on the mouth's log.
|
||||||
|
- The Mind cites files by room id and event id only; a file in a room the Mind is not in does not exist
|
||||||
|
for it.
|
||||||
|
|
||||||
|
## Receipt: `org.gamecult.file_received`
|
||||||
|
|
||||||
|
The Mind posts one event of type `org.gamecult.file_received` after a successful fetch, to the room of the
|
||||||
|
file. When the file has an album root the receipt is a reply in the album's thread, so it arrives with the album's
|
||||||
|
own listing; otherwise (a composer file sent with no thread) it is a plain room event with no `m.relates_to`. Both
|
||||||
|
count, and the client finds a receipt by its `event_id` anywhere in the room's loaded timeline or the album's thread:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"m.relates_to": { "rel_type": "m.thread", "event_id": "<album root event id>" },
|
||||||
|
"org.gamecult.file_received": {
|
||||||
|
"event_id": "<file event id>",
|
||||||
|
"sha256": "<hex sha256 of the bytes the Mind fetched>",
|
||||||
|
"size": 123456
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A plain room-event receipt may also carry `m.relates_to: {rel_type: "m.reference", event_id: <file event id>}`, which lets
|
||||||
|
the mouth find its earlier receipts through `/relations` for dedup. The client matches by `content.event_id` and reads no
|
||||||
|
relation but this one: if a `m.reference` is present its `event_id` must equal `content.event_id`, else the receipt counts
|
||||||
|
for nothing. A thread relation is not read. However many receipts a Mind has posted for a file, the row draws one
|
||||||
|
`fetched for` for that Mind.
|
||||||
|
|
||||||
|
Two rules, both enforced on each side:
|
||||||
|
|
||||||
|
1. **The sender is the Mind.** The row trusts the Matrix `sender` of the receipt event and nothing inside it:
|
||||||
|
no Mind field is read. `fetched for X` counts only when the sender is X's own Matrix identity (the
|
||||||
|
full id is `@<mind>:<this homeserver>` for a Mind in the Persona catalogue; a display name, a lookalike or another server's `@void:evil.example` does not count).
|
||||||
|
2. **Fetched means verified.** The Mind posts only after the sha256 of the bytes it fetched equals the file
|
||||||
|
event's `info.sha256`, and the receipt carries that hash. On a mismatch it posts nothing (or a typed
|
||||||
|
refusal that the row does not draw). The row checks it again: the receipt's `sha256` must equal the
|
||||||
|
file event's `info.sha256`. A file event with no `info.sha256` can show no `fetched for`.
|
||||||
|
|
||||||
|
The receipt is inert: no paths, URLs, filenames or anything a browser would fetch, and the client reads only
|
||||||
|
`event_id`, `sha256` and the sender. It has no `msgtype`, so the album's file listing ignores it. The mouth's
|
||||||
|
log is the fuller record for Minds; this event is the public tick.
|
||||||
|
|
||||||
|
The row draws `fetched for <Mind>`: one receipt per Mind per file (the latest), several Minds side by side. A
|
||||||
|
receipt for an event that is not a file of that album, a malformed one, and a redacted one are ignored.
|
||||||
|
The wording is deliberate: the receipt says the mouth fetched and stored the file for the Mind, not that it is in the Mind's sandbox and not that it was read. Client strings never say "saved" or "read".
|
||||||
|
The sender check keeps a member from ticking for a Mind; it does not prove the Mind read the file beyond
|
||||||
|
what the hash and the Mind's own account show.
|
||||||
|
|
||||||
|
## Not built yet
|
||||||
|
|
||||||
|
- Files sent from the composer carry no thread relation, so they reach an album only if written into a
|
||||||
|
thread by another tool; the album upload path (docs/file-zone.md, "Upload") is unwritten. Their receipts are plain
|
||||||
|
room events, and the timeline row for the file shows `fetched for` from them.
|
||||||
|
- A receipt that is still encrypted when it arrives is not seen until a later timeline event triggers a rescan; zones are
|
||||||
|
unencrypted and the hash is written only there, so this is the encrypted-room limit again.
|
||||||
|
- The timeline row reads the room's loaded timeline only: a receipt older than what the client has paged in is not
|
||||||
|
seen there (an album's own listing reads the thread, which is paged separately).
|
||||||
|
- Encrypted zones are not listed.
|
||||||
123
docs/file-zone.md
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
# File zone: design sketch
|
||||||
|
|
||||||
|
Status: design only. No code exists. Everything below is a proposal; items marked **UNKNOWN** are not yet verified against the Body.
|
||||||
|
|
||||||
|
Source: Meta ruling 2026-10-31, eureka mind `ratatoskr:ruling:file-zone-looks-like-file-sharing`.
|
||||||
|
|
||||||
|
## What it is
|
||||||
|
|
||||||
|
A file-sharing space beside the room timeline, not a view of it. The zone is **one ordinary Matrix room with threaded discussions, one thread per Mind**. The UI presents each thread as an **album** (title, item count, date) and each post in it as a **file** with an inline player or preview. Matrix is the storage and the fallback: another client sees a normal room with threads and never needs the zone. No homeserver change.
|
||||||
|
|
||||||
|
First use (Aqua): listen to audio in place, mark each clip keep / fix / drop with a one-line reason, export the marks as a file. See `/opt/commons/handoff/aqua-listen-kits-20261108/` (static `listen.html` kits that emit `verdict.json` + `verdict.md`); the zone replaces sending that page around.
|
||||||
|
|
||||||
|
## Matrix event shapes (stock events only)
|
||||||
|
|
||||||
|
- **Zone** = one room, chosen by the client (**UNKNOWN:** how the client knows which room is the zone: a room-account-data flag, a `m.room.create` type, or a configured room id in `config.json`; config is simplest).
|
||||||
|
- **Album** = a thread root: a text `m.room.message` posted by a Mind, `body` = the album title (the Mind's name, e.g. `void`). One thread per Mind, owned by authorship (the root's sender). **UNKNOWN:** whether a Mind may open more than one (e.g. per kit); the shape allows it with no change, the zone would then list several albums per author. Count and last-activity come from the SDK's thread summary (`unsigned.m.relations.m.thread`); album date = root `origin_server_ts`. Rename = `m.replace` on the root.
|
||||||
|
- **File** = a post in the thread: an `m.room.message` with `msgtype` `m.audio` / `m.image` / `m.video` / `m.file` and `m.relates_to: { rel_type: "m.thread", event_id: <root>, is_falling_back: true, "m.in_reply_to": { event_id: <last thread event> } }`. The builders in `src/app/features/room/msgContent.ts` already make the content. Delete = redact.
|
||||||
|
- **Album vs chat thread**: with a single-purpose room, every thread is an album. No marker key is needed. A non-media reply in a thread (chat, a mark) is not a file and is not listed as one.
|
||||||
|
- **Cover** = first image file in the thread, if any. Loose room messages outside any thread are not shown in the zone; they remain in the timeline.
|
||||||
|
|
||||||
|
## Marks (reactions, plus an optional reply)
|
||||||
|
|
||||||
|
A mark is stock Matrix, so every client shows it. Two reaction sets on the **file post**, and one optional reply:
|
||||||
|
|
||||||
|
- **Verdict** = an `m.annotation` reaction (`m.reaction` event, `m.relates_to: { rel_type: "m.annotation", event_id: <file post>, key }`) with key **👍 keep**, **🔧 fix**, **🗑 drop**. One per person per file: choosing another replaces the old one (the client redacts the sender's previous verdict reaction, then sends the new one); tapping the active one clears it.
|
||||||
|
- **Landed** = a second reaction set, keys **1️⃣ 2️⃣ 3️⃣ 4️⃣ 5️⃣** (keycap digits, 1-5). Also one per person per file, same replace rule. The two sets are independent: a file can have a verdict, a landed score, both or neither.
|
||||||
|
- **Reason** = an ordinary thread reply under the file: `m.room.message` `m.text`, `m.relates_to: { rel_type: "m.thread", event_id: <album root>, "m.in_reply_to": { event_id: <file post> } }`. Optional. **Keep needs none**; the UI prompts for one mainly after fix or drop. The latest such reply by the sender under that file is the reason (edits via `m.replace` count). No special key: the reply text is the reason, so nothing is lost if another client wrote it.
|
||||||
|
- **No private mode.** Marks are room events the moment they are tapped; there is no local-only draft and no "Share" step. Offline taps queue in the SDK's normal send queue.
|
||||||
|
- Who marked what is read from the reactions' senders, so each listener is distinguishable and the album can show tallies per file.
|
||||||
|
- Why reactions: one tap, readable in any client, no custom event type, and they can be changed with no history growth beyond a redaction. Reaction keys carry no text, hence the separate reply for the reason.
|
||||||
|
- **UNKNOWN:** emoji variation selectors. 🗑 and 🔧 and the keycaps may arrive with or without U+FE0F from different clients; match by normalizing (strip U+FE0F) before comparing keys. Also **UNKNOWN:** servers' per-event limits on distinct reaction keys; a handful per file is far below any default.
|
||||||
|
|
||||||
|
## Export
|
||||||
|
|
||||||
|
Client-side only. "Export" builds Aqua's verdict file from the verdict and landed reactions and the reply under each file in one album (one listener's marks per export; that sender is the `listener`) and either downloads it or uploads it into the album. Two files, as the kits write them: `verdict.json` and `verdict.md`.
|
||||||
|
|
||||||
|
`verdict.json` is schema **`aqua.listen.verdict.v1`**, field for field what `buildVerdict` in the kit (`dings/listen.html`, section `buildVerdict`) emits, so Aqua's tooling reads it unchanged:
|
||||||
|
|
||||||
|
```
|
||||||
|
{ schema: "aqua.listen.verdict.v1",
|
||||||
|
kit: { id, title }, // album root: id = root event id (or the kit id if the upload carried one), title = root body
|
||||||
|
exportedAt, // ISO time of export
|
||||||
|
seed, consent, // kit-only: zone writes seed: 0 (or omits), consent: true for the sender's own export (see UNKNOWN)
|
||||||
|
listener, // sender display name
|
||||||
|
device, // headphones|speakers|null, the sender's most-used mark device, or null
|
||||||
|
notes, // free text; zone leaves "" in v1
|
||||||
|
summary: { total, keep, fix, drop, unanswered },
|
||||||
|
clips: [{ position, id, title, group, feel, file, pair, slot, verdict, reason, landed, device }],
|
||||||
|
pairs: [{ a, b, preferred }] } // empty array when the album has no pairs
|
||||||
|
```
|
||||||
|
|
||||||
|
- `clips` has one row per file post in the album, in thread order (`position` = 1-based). The join per file, for the exporting listener: `verdict` = their 👍/🔧/🗑 reaction (keep/fix/drop) or null; `landed` = their keycap reaction (1-5) or null; `reason` = text of their latest reply under the file, or "". A file with no verdict reaction counts as `unanswered` even if it has a reply. `device` per clip is null (reactions cannot carry it); the top-level `device` (headphones|speakers|null) is asked once in the export dialog.
|
||||||
|
- `pairs`, `seed`, `consent` are optional for the zone: `pairs: []`, no `slot`/`pair` (null) unless the clip metadata supplies them.
|
||||||
|
- **Where clip metadata comes from.** `id` is the clip id from `org.gamecult.clip`, else the filename stem. `title` = file post `body`; `file` = the post's `filename`/`body`. `group`, `feel`, `pair`, `slot` are kit data that Matrix has no slot for: carry them on the file post as one extra key `org.gamecult.clip: { id, group, feel, pair, slot }` written by whoever uploads the kit (a small uploader script, **UNKNOWN**, not in the first slice; without it those columns export as ""/null and the id falls back to the filename stem).
|
||||||
|
- `verdict.md` follows `verdictMarkdown` from the same file: header line, count line, the clip table (`# | clip | group | intended feel | verdict | landed (1-5) | device | reason`), pairs table when `pairs` is nonempty, notes.
|
||||||
|
- To avoid drift, the exporter should be a port of `buildVerdict`/`verdictMarkdown` with the schema string and field order pinned by a test that compares against a fixture produced by the kit. **UNKNOWN:** whether to share code (the kit's `logic.cjs`) rather than port; recommend port, ~80 lines, because the kit is a standalone page.
|
||||||
|
- **UNKNOWN:** `consent` is a kit-side checkbox before listening. The zone has no such step; recommend writing `true` only if the sender ticks a one-time "include my name in exports" toggle, else `false` and `listener: ""`.
|
||||||
|
- JSON here is a xenos-boundary export, not state; the room events are the state.
|
||||||
|
|
||||||
|
## Mobile-first layout
|
||||||
|
|
||||||
|
- Phone width is the design width; desktop adds columns.
|
||||||
|
- Level 1, album list: one column, each row = cover/icon, title, "N files", date. Tap opens.
|
||||||
|
- Level 2, album: list by default (title, duration/size), grid toggle for images. Audio rows play in place, one at a time (starting a clip stops the last), a persistent mini-player at the bottom with seek.
|
||||||
|
- Mark controls on each audio row: three large tap targets (👍 keep / 🔧 fix / 🗑 drop) with tallies, a row of five "landed" chips (1-5), and after fix or drop an inline one-line reason field (skippable). Thumb-reachable, no modal.
|
||||||
|
- Back is one level up; the room timeline stays one tap away (the zone is a sibling view, not a replacement). **UNKNOWN:** where the entry lives in the room header; check `RoomViewHeader.tsx`, `Room.tsx` for how the member drawer and pin menu attach.
|
||||||
|
|
||||||
|
## Upload
|
||||||
|
|
||||||
|
Reuse the composer's pipeline, pointed at an album instead of the main timeline:
|
||||||
|
|
||||||
|
- `createUploadAtom` / `useBindUploadAtom` (`src/app/state/upload.ts`), `UploadCard`, `upload-board` for progress.
|
||||||
|
- On success, the msgContent builder produces the event; the zone adds the thread relation to the album root before sending (the composer already does this for thread replies, `RoomInput.tsx`).
|
||||||
|
- "New album" = send the root text event, then upload into its thread. Multi-file drop = N events, sent in order.
|
||||||
|
- Size limit from `MediaConfigLoader` (server `m.upload.size`). **UNKNOWN:** whether the 32 MB `shelf` kit's clips (42 x ~0.75 MB) should be uploaded individually or batched; individually is the only option without a server change.
|
||||||
|
|
||||||
|
## Existing code to reuse (verified present in the repo)
|
||||||
|
|
||||||
|
| need | where |
|
||||||
|
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
|
||||||
|
| audio player (play/seek/volume, encrypted download) | `src/app/components/message/content/AudioContent.tsx` (210 lines) |
|
||||||
|
| video, image, generic file | `.../content/VideoContent.tsx`, `ImageContent.tsx`, `FileContent.tsx` |
|
||||||
|
| full-screen image viewer | `src/app/components/image-viewer/ImageViewer.tsx` |
|
||||||
|
| media dispatch by msgtype | `src/app/components/RenderMessageContent.tsx`, `message/MsgTypeRenderers.tsx` |
|
||||||
|
| upload + progress | `state/upload.ts`, `components/upload-card`, `upload-board` |
|
||||||
|
| event content builders | `features/room/msgContent.ts` |
|
||||||
|
| thread plumbing | `features/room/message/Message.tsx`, `RoomTimeline.tsx`, `RoomInput.tsx` (thread relation on send) |
|
||||||
|
| room-scoped file/pin precedent | `features/room/room-pin-menu/RoomPinMenu.tsx` |
|
||||||
|
|
||||||
|
Nothing named "gallery" exists as a feature; `AudioContent` takes its controls as render props (`renderMediaControl`), so it can be rehosted in a row without a fork. **UNKNOWN:** whether `AudioContent` can be rendered outside a timeline event context (it needs only `url`, `info`, `mimeType`, `encInfo`, from the event content, so probably yes).
|
||||||
|
|
||||||
|
## First slice (what to build first)
|
||||||
|
|
||||||
|
Rough lines of TypeScript, tests excluded.
|
||||||
|
|
||||||
|
1. **Read-only albums + album view** (~250 lines): list the room's thread roots as albums (grouped or filterable by author = Mind), open one to list its file posts, rehost `AudioContent` and `ImageContent`. One route/panel plus a header entry. Depends on the thread list the SDK already keeps.
|
||||||
|
2. **Marks + export** (~240 lines, with the schema port): verdict and landed reaction sets with the replace-one rule, optional reason reply, per-file tallies, the join into `aqua.listen.verdict.v1` and `verdict.json`/`verdict.md` download. This is Aqua's workflow end to end.
|
||||||
|
3. **Upload into an album** (~150 lines): thread-related send through the existing upload atoms, "new album".
|
||||||
|
|
||||||
|
Slices 1+2 serve the first use case. Each is its own PR. Because marks are ordinary thread replies, there is no separate "publish" slice.
|
||||||
|
|
||||||
|
## Open questions for the Meta
|
||||||
|
|
||||||
|
- How the client identifies the zone room (config id recommended).
|
||||||
|
- Whether exports are per listener (proposed) or a merged all-listeners file.
|
||||||
|
- Where `device` comes from, now that reactions cannot carry it (export dialog, proposed).
|
||||||
|
|
||||||
|
## Share a set (built, behind `fileZoneShare.enabled`)
|
||||||
|
|
||||||
|
The ruling `ratatoskr:ruling:persona-file-sharing-zone` asks that a set of files arrive together, not one attachment at a time. The zone already holds one thread per album, so a set is an album: **one root text message plus one `m.thread` reply per file**, the exact shape `buildAlbums` reads. Nothing new on the wire and no homeserver change; a Mind's mouth that posts into the zone room and a person using the client produce the same events.
|
||||||
|
|
||||||
|
Matrix options checked: (a) a thread, chosen: stock, rendered by every client, already the zone's album; (b) `m.gallery` (MSC4274) puts several items in one event, but it is unstable, most clients and this one do not render it, and each item still needs its own upload; (c) a shared custom relation: no client groups by it. A thread costs one extra event per set (the root) and nothing else.
|
||||||
|
|
||||||
|
Client, `src/app/features/albums`:
|
||||||
|
|
||||||
|
- `shareSet.ts`: `sendShareSet(port, title, files)`. Sends the root, then each file in pick order, each reply falling back to the previous event (`threadRelation`). A file whose upload fails is named in the result and the rest still go; a root that fails to send throws before any upload. The root alone is not an album in the list (`/threads` returns only roots that have a reply), so a share where every upload failed leaves a lone text message in the timeline. After a send the panel keeps only the failed files picked; a retry makes a second album. Ports are injected, so the order and failure rules are tested without a homeserver (`shareSet.test.ts` reads the sent events back through `buildAlbums`).
|
||||||
|
- `shareController.ts`: the panel's state machine (picked files, phase, the one-send-per-room guard, the encrypted refusal, failed files kept by position), apart from React so tests drive it without a DOM; `shareController.test.ts` and `ShareSet.test.tsx` (static render of each state, the flag) pin it. No DOM test environment exists in this repo, so a real tap on a real input is covered by the phone check, not by tests.
|
||||||
|
- `ShareSet.tsx`: phone layout. Idle, it is one text row, "Share files". Picking files (any number from the native picker; a second pick replaces the first) mounts a title field, the picked names on one dim line, and Send / Cancel as full-width rows; progress is "Sending 2 of 5". It is shown only on the album list (kept mounted but hidden while an album is open, so a running send keeps its state), one send at a time per room (tracked outside the component; leaving the page does not stop a send, it only loses its progress and result), and never in an encrypted zone (re-checked when Send is tapped) (the plaintext `info.sha256` the Mind verifies against is written in unencrypted rooms only).
|
||||||
|
- Content per file comes from `getMsgContent` in `features/room/msgContent.ts`, now also used by the composer, so a shared file and an attached file carry the same `info` including `sha256`.
|
||||||
|
|
||||||
|
Flag: `fileZoneShare.enabled` in `config.json`, default `false`. With it off nothing mounts.
|
||||||
|
|
||||||
|
Not built: a Mind's own path into the zone (outbox, Persona decision, mouth post) belongs to gamecult-ops / Huginn / the mouth, not this client; Built since: Stop for a send in flight (aborts the running upload, leaves it and the rest picked as "stopped"), pagination to 20 pages of 100 per endpoint (PR #148), adding files to an open album with named refusals (`M_TOO_LARGE`, `M_FORBIDDEN`, `M_LIMIT_EXCEEDED`).
|
||||||
108
docs/gamecult/chat-shell-compression.md
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
# chat.gamecult.org: shell compression and caching
|
||||||
|
|
||||||
|
Follow-up `ratatoskr:follow_up:chat-shell-compression`. Measured 2026-11-12 against the live
|
||||||
|
host, which serves release `ratatoskr-nemesis-9b9b495d` (the `index-CAataSJe.js` bundle; the offline table
|
||||||
|
below also uses the `f8115609` handoff build, `index-D0yL0Dgy.js`, and says which row is which). The host config is
|
||||||
|
`nginx/chat.gamecult.org.conf` in `gamecult-ops`, owned by Eureka; nothing here is applied
|
||||||
|
to the host.
|
||||||
|
|
||||||
|
## What the host serves now
|
||||||
|
|
||||||
|
The 2026-10-31 probe (no Content-Encoding, no Cache-Control on `/assets/`) is stale: the
|
||||||
|
`nginx.conf` has `gzip on`, the site file sets level 5, `gzip_vary` and `gzip_types` for js, css, json, svg and wasm, and carries an immutable
|
||||||
|
`Cache-Control` on `/assets/` and `/preview/assets/`. Probed with `curl -H 'Accept-Encoding: X'`.
|
||||||
|
|
||||||
|
| Resource | raw | gzip (served now) | brotli (served now) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/assets/index-CAataSJe.js` | 3,732,771 | 956,085 | none: `br` is ignored, identity returned |
|
||||||
|
| `/assets/index-BLdKqKqx.css` | 111,267 | 19,952 | none |
|
||||||
|
| `/assets/matrix_sdk_crypto_wasm_bg-DwIieW7K.wasm` | 5,573,870 | 1,878,147 | none |
|
||||||
|
| `/` (index.html, no-cache) | 3,066 | gzip | none |
|
||||||
|
|
||||||
|
Headers: hashed `/assets/` answer `Cache-Control: public, max-age=31536000, immutable`;
|
||||||
|
`/`, `index.html`, `sw.js`, `manifest.json`, `config.json` answer `no-cache`; compressed
|
||||||
|
responses carry `Vary: Accept-Encoding` and a weak ETag. `config.json` (424 B) is below
|
||||||
|
`gzip_min_length 1024` and goes uncompressed, which is correct.
|
||||||
|
|
||||||
|
So the original ask is done. What is left is brotli, which nginx 1.26.3 here cannot serve:
|
||||||
|
`/usr/sbin/nginx -V` shows `--with-http_gzip_static_module` and no brotli module.
|
||||||
|
|
||||||
|
## What brotli buys
|
||||||
|
|
||||||
|
Level-5 gzip is what the host does per request. Offline measurement on the same files
|
||||||
|
(node `zlib`, 2026-11-12):
|
||||||
|
|
||||||
|
| File | raw | gzip 5 (node) | gzip 9 | brotli 11 | brotli vs live |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| live main chunk `index-CAataSJe.js` | 3,732,771 | 957,199 | 945,424 | 725,167 | -24% |
|
||||||
|
| handoff main chunk `index-D0yL0Dgy.js` | 3,868,313 | 1,015,680 | 1,001,446 | 773,992 | -24% |
|
||||||
|
| crypto wasm | 5,573,870 | 1,868,282 | 1,851,108 | 1,343,595 | -28% |
|
||||||
|
| `ReactPrism-Dm2uRF4v.js` | 577,517 | 209,339 | 208,376 | 163,525 | -22% |
|
||||||
|
|
||||||
|
A cold load of the two big files falls from about 2.8 MB to about 2.1 MB on the wire; the
|
||||||
|
saving is ~0.7 MB once per hashed build per device while the HTTP cache keeps the file (immutable; the
|
||||||
|
service worker does no precaching, so eviction or a new device pays again). gzip 9 alone buys 1-2%, so
|
||||||
|
`gzip_static` without brotli is not worth a build step. Brotli is the whole prize, and it
|
||||||
|
needs one root step.
|
||||||
|
|
||||||
|
Cost: brotli q11 takes about 12 s for the wasm and 8 s for the main chunk on one core, so
|
||||||
|
the whole dist is about two minutes at build time (measured, 84 files) and zero CPU per request. Do not use q11
|
||||||
|
on the fly; that is why the plan is precompressed twins.
|
||||||
|
|
||||||
|
## Root list for Eureka (host)
|
||||||
|
|
||||||
|
1. `apt install libnginx-mod-http-brotli-filter libnginx-mod-http-brotli-static` (trixie/main, 1.0.0~rc-6,
|
||||||
|
depend on `nginx-abi-1.26.3-1`, which the installed nginx provides; apt may offer an nginx patch upgrade
|
||||||
|
alongside, say so before accepting). Do not enable the config yet.
|
||||||
|
2. Install a build with twins (`PRECOMPRESS=1`, below) the usual way
|
||||||
|
(`docs/gamecult/client-build-install.md`). Twins for files that are not there change nothing.
|
||||||
|
3. In the `chat.gamecult.org` `server` block, beside the existing gzip lines:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
brotli_static on; # serve <file>.br when the client sends br
|
||||||
|
gzip_static on; # serve <file>.gz; falls back to the on-the-fly gzip above
|
||||||
|
brotli on; # on-the-fly br for files with no twin (index.html, small files)
|
||||||
|
brotli_comp_level 5;
|
||||||
|
brotli_types text/css application/javascript text/javascript application/json application/manifest+json image/svg+xml application/wasm;
|
||||||
|
```
|
||||||
|
|
||||||
|
The `location ^~ /assets/` block and `/preview/`, `/element/` and `/mockups/` inherit these; they only add headers.
|
||||||
|
No other change: the `Cache-Control` and `Vary` lines stay as they are, and
|
||||||
|
`gzip_vary on` already makes the on-the-fly and static paths vary correctly.
|
||||||
|
4. `nginx -t && systemctl reload nginx`. Twins first, config second: with `brotli on` and no twins every
|
||||||
|
client gets on-the-fly q5 brotli of the 3.7 and 5.6 MB files, which costs CPU per request.
|
||||||
|
|
||||||
|
Rollback: remove the five lines and reload. The `.br`/`.gz` files can stay on disk, unused.
|
||||||
|
|
||||||
|
## Build side
|
||||||
|
|
||||||
|
`build-prod-dist.sh` (Void's, in `/opt/commons/handoff/ratatoskr-main-build/`) gains
|
||||||
|
`PRECOMPRESS=1`, which runs `precompress.cjs <dist>` before the manifest is written. It
|
||||||
|
writes `<file>.br` (q11) and `<file>.gz` (level 9, no mtime, deterministic for one node) next
|
||||||
|
to each js, css, wasm, json, svg, html file over 1 KiB, only when smaller than the original.
|
||||||
|
Default is off, so builds installed before the host change do not ship ~27 MB of twins that
|
||||||
|
nothing serves. The twins appear in `SHA256SUMS`; the determinism compare still applies.
|
||||||
|
Tested on `ReactPrism-Dm2uRF4v.js` (gzip roundtrip identical to the original); a full
|
||||||
|
`PRECOMPRESS=1` build has not been run because it waits on step 1.
|
||||||
|
|
||||||
|
The script is not in this repo; its placement is a standing open item of the build handoff.
|
||||||
|
|
||||||
|
## Before/after measurement
|
||||||
|
|
||||||
|
Run from any machine; `X` is the asset URL. Before = today's table above.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
for ae in identity gzip br; do
|
||||||
|
printf '%s ' "$ae"; curl -s -o /dev/null -H "Accept-Encoding: $ae" -w '%{size_download} %header{content-encoding}\n' "$X"
|
||||||
|
done
|
||||||
|
curl -sI -H 'Accept-Encoding: br' "$X" | grep -iE 'content-encoding|vary|etag|cache-control'
|
||||||
|
```
|
||||||
|
|
||||||
|
Pass: with `br` the main chunk is about 774 KB and the wasm about 1.34 MB with
|
||||||
|
`Content-Encoding: br`; with `gzip` the bytes are no larger than today; with `identity`
|
||||||
|
the file is the raw size; `Vary: Accept-Encoding` and the immutable `Cache-Control` are
|
||||||
|
still present on all three; `index.html`, `sw.js` and `config.json` still `no-cache`.
|
||||||
|
The service worker's registration and an existing session must still work (open chat on
|
||||||
|
the operator's phone, hard reload once, check the network panel's "transferred" column).
|
||||||
|
Check `/preview/assets/` the same way, but `/preview/` is served from `/srv/cinny/preview`, which this build
|
||||||
|
script does not produce: it gets on-the-fly brotli, not twins, until a preview build carries them.
|
||||||
121
docs/gamecult/client-build-install.md
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
# Ratatoskr client: build, install, verify, roll back
|
||||||
|
|
||||||
|
Who does what. The steward (Void) builds and hands off; Void holds no root and does not deploy.
|
||||||
|
Eureka installs on the chat.gamecult.org host, which is Eureka's. This page is the shared
|
||||||
|
procedure. Nothing here is run by the build step against the live host.
|
||||||
|
|
||||||
|
## Facts about the host
|
||||||
|
|
||||||
|
- Web root: `/srv/cinny/`. `current` is a symlink to one directory under `releases/`; the web
|
||||||
|
server serves `current`. Releases are `releases/ratatoskr-<8hex>` (older ones are
|
||||||
|
`ratatoskr-nemesis-<8hex>`). Previous releases stay on disk; that is the rollback.
|
||||||
|
- The client is static. `config.json` sits in the release root beside `index.html` and is read
|
||||||
|
at load. Cinny's `Dockerfile` and `docker-nginx.conf` are upstream's container path, not the
|
||||||
|
chat.gamecult.org path.
|
||||||
|
- Check the pointer before anything else: `readlink /srv/cinny/current`.
|
||||||
|
|
||||||
|
## 1. Build (steward)
|
||||||
|
|
||||||
|
Two builds exist, and they treat `config.json` differently.
|
||||||
|
|
||||||
|
**By hand (the repo's config).** `vite build` copies the repo's `config.json` into `dist/`
|
||||||
|
(`vite.config.js`, static copy of `config.json`). Ratatoskr #103 made the repo file the intended
|
||||||
|
served config, and #106 set `backgroundScene.style` to `ridge`; the served file has not caught up
|
||||||
|
(see Open). Do not hand-edit `dist/config.json`.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone https://eureka.gamecult.org/forge/GameCult/Ratatoskr.git && cd Ratatoskr
|
||||||
|
git checkout <full-40-hex-sha> # build a named commit, not a branch tip
|
||||||
|
npm ci --ignore-scripts # .npmrc sets legacy-peer-deps; skips husky's prepare hook
|
||||||
|
node scripts/sync-poses.mjs # pose art is not committed; this also rewrites the tracked
|
||||||
|
# src/app/components/message/pose/catalogue.json
|
||||||
|
NODE_OPTIONS=--max_old_space_size=6144 npm run build # writes dist/
|
||||||
|
```
|
||||||
|
|
||||||
|
Skip `sync-poses` and the build ships with no poses. Do not use `npm run build:preview` for
|
||||||
|
production: it rewrites `dist/config.json` for preview (hash router, chosen tier and style).
|
||||||
|
|
||||||
|
**The handoff build (what the host installs).**
|
||||||
|
`/opt/commons/handoff/ratatoskr-main-build/build-prod-dist.sh <full-sha>` (see its `README.md`)
|
||||||
|
writes `/opt/commons/handoff/ratatoskr-main-<full-sha>/{dist,SHA256SUMS,BUILD-INFO,build.log}`,
|
||||||
|
locks the files read-only and refuses to overwrite. The dist carries the repo's `config.json` at
|
||||||
|
that sha (BUILD-INFO `config_json_source`, `config_json_sha256`); there is no separate pinned copy.
|
||||||
|
Its live check always runs: it fetches the served config and compares it byte for byte with the
|
||||||
|
repo's. A difference prints the diff and stops the build. `ALLOW_CONFIG_DRIFT=1` builds anyway and
|
||||||
|
BUILD-INFO records `config_json_live_check: DIFFERS ...` and `config_json_live_sha256`, so the
|
||||||
|
handoff states which config differs and the installer decides. From Yggdrasil the public name does
|
||||||
|
not resolve: pass `LIVE_CURL_ARGS="--resolve chat.gamecult.org:443:127.0.0.1"`.
|
||||||
|
The folder, with the three pins (`SHA256SUMS` sha256, `BUILD-INFO` sha256, lockfile sha256) and
|
||||||
|
the node and npm versions, is what the install request names.
|
||||||
|
Every build stamps its commit into `index.html` (`scripts/buildSha.js`): `<meta name="build-sha" content="<40-hex>">`,
|
||||||
|
with `-dirty` appended when the tree had uncommitted changes and `unknown` when there was no git. Where git governs the tree it decides: an explicit `BUILD_SHA` naming a different commit than HEAD stamps `<sha>-mismatch`, a git failure stamps `unknown`. A full-sha `BUILD_SHA` stands only for a tree git does not govern (a source archive) or cannot read (unusable git, for example a dubious-ownership refusal). The handoff
|
||||||
|
build refuses a dist whose stamp differs from the sha it was asked for, and prints the stamp in its block.
|
||||||
|
To read it from anywhere: `curl -s https://chat.gamecult.org/ | grep -o 'build-sha" content="[^"]*'`.
|
||||||
|
|
||||||
|
Before handing off:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd <folder>/dist && sha256sum -c ../SHA256SUMS # 0 failures
|
||||||
|
grep -rl 'demo.invalid\|demo-preview-token' <folder>/dist # must print nothing
|
||||||
|
# from Yggdrasil the public name may not resolve; --resolve points it at the local server
|
||||||
|
diff <(curl -fsS --resolve chat.gamecult.org:443:127.0.0.1 https://chat.gamecult.org/config.json) <folder>/dist/config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
A config difference is a finding to report, not something to paper over: either live drifted or
|
||||||
|
the repo is wrong, and a ruling (Meta, for `backgroundScene.style`) says which ships.
|
||||||
|
|
||||||
|
## 2. Install (host owner, root, by hand)
|
||||||
|
|
||||||
|
Note the rollback target first: `readlink /srv/cinny/current`.
|
||||||
|
|
||||||
|
Unpack beside the live release; do not touch the live directory. The target must not exist, or
|
||||||
|
`cp -r` nests `dist` inside it.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
R=ratatoskr-<8hex>
|
||||||
|
H=/opt/commons/handoff/ratatoskr-main-<full-sha>
|
||||||
|
test ! -e /srv/cinny/releases/$R
|
||||||
|
cp -r $H/dist /srv/cinny/releases/$R
|
||||||
|
cmp /srv/cinny/current/config.json /srv/cinny/releases/$R/config.json
|
||||||
|
(cd /srv/cinny/releases/$R && sha256sum -c $H/SHA256SUMS)
|
||||||
|
```
|
||||||
|
|
||||||
|
`cmp` is a drift check, not a pass check. Live has no trailing newline; the repo's file has one,
|
||||||
|
so even equal content differs. Read the difference (`diff` the two) and settle which config
|
||||||
|
ships. To keep the host's config byte-for-byte, as past installs did,
|
||||||
|
`cp /srv/cinny/current/config.json /srv/cinny/releases/$R/config.json`; `sha256sum -c` then flags
|
||||||
|
`config.json` only (read that one line; `--ignore-missing` does not hide a mismatch).
|
||||||
|
|
||||||
|
Switch (a single symlink swap; `ln -sfn` unlinks then links, so there is a moment with no link):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ln -sfn releases/$R /srv/cinny/current
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Verify
|
||||||
|
|
||||||
|
- `readlink /srv/cinny/current` names `releases/$R`.
|
||||||
|
- `curl -s https://chat.gamecult.org/config.json | sha256sum` equals `sha256sum /srv/cinny/releases/$R/config.json`.
|
||||||
|
- Load https://chat.gamecult.org/ and hard-refresh (the service worker, `sw.js`, can hold the
|
||||||
|
previous assets). Sign-in card shows, the background scene draws, the browser console has no
|
||||||
|
page errors, a poses/asset request returns 200 and not index.html.
|
||||||
|
|
||||||
|
## 4. Roll back
|
||||||
|
|
||||||
|
Point `current` at the release noted before the switch. Live at the time of writing is
|
||||||
|
`ratatoskr-nemesis-9b9b495d`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ln -sfn releases/ratatoskr-nemesis-9b9b495d /srv/cinny/current
|
||||||
|
```
|
||||||
|
|
||||||
|
Hard-refresh to clear `sw.js`. Do not delete the failed release; leave it for diagnosis.
|
||||||
|
|
||||||
|
## Open
|
||||||
|
|
||||||
|
- The served config (checked 2026-11-12) is not the repo's: it says `backgroundScene.style`
|
||||||
|
`terrain` where the repo says `ridge` (#106, Meta 2026-11-11), lacks the repo's `dings`,
|
||||||
|
`fileZoneShare` and `registration` blocks (all off in the repo), and has no trailing newline.
|
||||||
|
The new keys default off or inert when absent, so only the style is a decision. Meta says which
|
||||||
|
style ships. Until the served file is replaced, every handoff build stops at the live check
|
||||||
|
unless `ALLOW_CONFIG_DRIFT=1` is set (section 1).
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Ratatoskr: target and prose map
|
# Ratatoskr: target and prose map
|
||||||
|
|
||||||
Ratatoskr is GameCult's Matrix chat client: this fork of Cinny, forge
|
Ratatoskr is GameCult's Matrix chat client: this fork of Cinny, forge
|
||||||
`GameCult/cinny` (to be renamed `GameCult/Ratatoskr`; see the cut sequence).
|
`GameCult/Ratatoskr` (renamed from `GameCult/cinny`; the forge redirects the old name).
|
||||||
Void stewards it. Campaign `ratatoskr` lives in the eureka mind for now, beside
|
Void stewards it. Campaign `ratatoskr` lives in the eureka mind for now, beside
|
||||||
the rulings it rests on. Under `eureka-body:ruling:ruling-home-steward-mind` it
|
the rulings it rests on. Under `eureka-body:ruling:ruling-home-steward-mind` it
|
||||||
is handed to Void's mind once, by procedure, as soon as that hand-off procedure
|
is handed to Void's mind once, by procedure, as soon as that hand-off procedure
|
||||||
|
|
@ -97,9 +97,11 @@ Probes run by Imagination on 2026-10-31, session `self-2026-10-29-eureka`.
|
||||||
events from the Minds' stewardship documents; each project room's alias is
|
events from the Minds' stewardship documents; each project room's alias is
|
||||||
`#<repo lowercased>` (runbook 146-150). This is the only place the client
|
`#<repo lowercased>` (runbook 146-150). This is the only place the client
|
||||||
can learn a not-yet-joined room's steward today: hierarchy `canonical_alias`
|
can learn a not-yet-joined room's steward today: hierarchy `canonical_alias`
|
||||||
joined to the root `org.gamecult.stewardships`. Follow-up
|
joined to the root `org.gamecult.stewardships`. The
|
||||||
`ratatoskr:follow_up:stewardships-carry-room-ids` asks the projection to
|
projection now also names room ids under `@rooms.projects` and
|
||||||
name room ids so the client stops leaning on the alias convention.
|
`@rooms.file_zones` (gamecult-ops #111); the client reads `file_zones` for
|
||||||
|
the Media row (`mediaRoom.ts`) and still joins projects by alias, since
|
||||||
|
nothing yet consumes `@rooms.projects`.
|
||||||
5. **Sync and storage** (`src/client/initMatrix.ts`). The client uses
|
5. **Sync and storage** (`src/client/initMatrix.ts`). The client uses
|
||||||
matrix-js-sdk 41.7.0 with `IndexedDBStore` (`web-sync-store`, 15-19) and the
|
matrix-js-sdk 41.7.0 with `IndexedDBStore` (`web-sync-store`, 15-19) and the
|
||||||
Rust crypto store, and `startClient({ lazyLoadMembers: true })` (43-47): v2
|
Rust crypto store, and `startClient({ lazyLoadMembers: true })` (43-47): v2
|
||||||
|
|
@ -346,7 +348,6 @@ Each later cut is one line, with what it waits on.
|
||||||
attention document, scoped to room members; waits on Huginn publishing it.
|
attention document, scoped to room members; waits on Huginn publishing it.
|
||||||
- `rename-ratatoskr`: package name, app name and branding, manifest and
|
- `rename-ratatoskr`: package name, app name and branding, manifest and
|
||||||
titles, the `/preview/` path and its deploy, and every in-repo `cinny`
|
titles, the `/preview/` path and its deploy, and every in-repo `cinny`
|
||||||
reference that is ours (not upstream engine paths); waits on Void's forge
|
reference that is ours (not upstream engine paths). The forge rename of
|
||||||
rename of `GameCult/cinny` to `GameCult/Ratatoskr`
|
`GameCult/cinny` to `GameCult/Ratatoskr` is done
|
||||||
(`eureka-body:ruling:chat-client-named-ratatoskr`), and the campaign's repo
|
(`eureka-body:ruling:chat-client-named-ratatoskr`).
|
||||||
list moves with it.
|
|
||||||
|
|
|
||||||
101
docs/gamecult/symphony-grammar-harvest.md
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
# Symphony grammar harvest: Mimir's bioacoustic research
|
||||||
|
|
||||||
|
Follow-up: `ratatoskr:follow_up:symphony-grammar-harvest-mimir`. Source: GameCult/Mimir at
|
||||||
|
commit c6f4541eea61 (exists; read at that ref, not main), read-only through the forge API.
|
||||||
|
Paths below are Mimir paths. Written for Aqua (AquaSynth) before the generative activity grammar. It lives here because the symphony is a Ratatoskr cut (dings, then orbiting personas) and this repo is where its spec will be read; the follow-up named AquaSynth, where void holds no push right, so move it there if Aqua prefers.
|
||||||
|
|
||||||
|
## 1. The problem
|
||||||
|
|
||||||
|
One Mind's identity has to be a timbre plus a contour that stays recognisable after it is mixed with
|
||||||
|
the other Minds and passed through a lossy path. The symphony adds harmony (several Minds sounding
|
||||||
|
together as music), which Mimir never needed: Mimir emits one voice at a time, a low-gain song per speaker.
|
||||||
|
|
||||||
|
## 2. What Mimir found that transfers
|
||||||
|
|
||||||
|
Confidence: A = measured number in a receipt, B = measured but synthetic or single run,
|
||||||
|
C = design claim or authored opinion with no measurement behind it.
|
||||||
|
|
||||||
|
| Claim | Evidence | Conf. |
|
||||||
|
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- |
|
||||||
|
| Tidy lab beeps are the wrong signal for a room; a birdsong-like motif replaced them. | `docs/bioacoustic-timeline-watermark.md` L3-10 (stated as a decision, no listening test). | C |
|
||||||
|
| Identity is carried redundantly: contour survives pitch shift, rhythm survives bad frequency response, formant envelope survives a dead band, speaker variants expose room asymmetry. | same doc L82-95. Argument, not an ablation. | C |
|
||||||
|
| A word needs no sequence grammar: one heard word identifies its event directly (128 words x 2 speaker variants, 4 syllables each), in an event slot that repeats about every 41 s (derived, not reported: 128 words x 2 speakers = 256 events at 0.16 s spacing = 40.96 s, from `MimirBioacousticTimeline.cs` L34-39 and `SymbolForEvent` L735-739; no receipt or doc states it). No de Bruijn grammar. | same doc L26-33; `src/Mimir.Runtime/Synchronization/MimirBioacousticTimeline.cs` (WordCount, SymbolCount = WordCount\*SpeakerCount, BuildMotifs ~L690-730). | B |
|
||||||
|
| Word identity survives mel-cepstral warp/blur degradation; timing does not unless fitted as one global clock/delay hypothesis. | `docs/bioacoustic-training-harness.md` L58-112: identity 0.908-0.997 on clean input only; degraded identity 0.532-0.908 and timing 0.187-0.728 for compact-fast-index across the four degraded cases in `artifacts/bioacoustic-training/bioacoustic-20260524-150729/compact-fast-index/*/summary.json` (`IdentityScore`, `TimingScore`); clean timing 0.357 before vs 0.905 after the global clock fit. | A (synthetic, clean-only range) |
|
||||||
|
| Contestant shapes were scored against the same decoder family and damage panel; the best scorer was the six-syllable "canary-packet-trill", a hypothesis not confirmed on a physical room (two timing chips plus four payload-coloured formant notes), not the literal bird imitations. | `research/bioacoustic-contestants-2026-05-24.md` L24-38, L128-174 (honest floor 88.4 language_score, 19 bps, payload 1.000 clean), L487-489 (96 ms motif is the best tradeoff). | B |
|
||||||
|
| Too-short or too-tight songs fail under blur: 0.100 s event spacing lost anchors to overlap; 0.105 s kept; 78 ms motif and 5-syllable finch-burst-packet collapsed (blur-light 4.358, the razor decoder; 218.368 with the sprint decoder, L453-454). | contestants doc L169-171, L446-455, L487-489. | A |
|
||||||
|
| A 3-bit payload variant (separate band/rhythm bit) did not survive degradation (best 12.849, clean 8.084). | contestants doc L183-185. | A |
|
||||||
|
| Real birds carry identity through redundant contour, roughness, onset statistics and cepstral envelope together; Redpoll is dense chatter with short vertical attacks, not a neat token stream; Guan is a slow broad curved phrase, good for probing the channel, poor for bitrate. The packet song was judged "still too ladder-like". | contestants doc L565-581 (two Xeno-canto mirrors via Wikimedia; Brown Thornbill file invalid, L511-513, L534-539). Flux numbers L549-562. | B (two recordings, hand reading) |
|
||||||
|
| Positive log-mel spectral flux is a good onset/anchor surface; sustained bands recede, attacks and glides light up. | contestants doc L541-545, L577-581; but the naive flux decoder lost on speed (33.466 vs 88.129 score, clean roundtrip, L158 and L231-237). | B |
|
||||||
|
| Once the schedule is known, the receiver only discriminates among a few local candidates; wide identity search is only for acquisition. Streaming decoder reached 1121 score at 62.6x realtime, payload 1.000 on all nine damage cases. | contestants doc L248-309. | A (synthetic damage) |
|
||||||
|
| Polarity must be a nuisance variable; mic paths flip sign. | contestants doc L391-395. | B (physical, one capture) |
|
||||||
|
| Physical room run: with per-channel acquisition offsets the co-streamer shotgun mic (ch1) locked 37/37 events, payload 1.000, timing 0.828; the cardioid/near mic (ch0) reached 26/37, payload 0.595, and stayed unreliable. | contestants doc L371-388. | B (physical, one capture) |
|
||||||
|
| Speaker split: left and right vocabularies differ in root (x0.94 / x1.08) and syllable weights, so each side is separable. | `MimirBioacousticTimeline.cs` BuildMotifs ~L704-725 (read as code, no experiment isolates the benefit). | C |
|
||||||
|
|
||||||
|
Rendering recipe actually in code (`MimirBioacousticTimeline.cs` AddSyllable ~L610-640): each syllable is
|
||||||
|
a log-frequency glide (exponential sweep) with a raised-cosine envelope, summed partials at
|
||||||
|
1, 1.5, 2, 2.75 times the glide (weights 1, 0.42, 0.24, 0.10) plus 0.08 weight of a slow "air" term,
|
||||||
|
20-32 ms long, four per word, gain 0.030 (canary profile 0.024 in `MimirBioacousticContestant.cs`).
|
||||||
|
Word roots are log-spaced 2.6 to 9.6 kHz; syllable frequencies are clamped 2.3 to 14.6 kHz;
|
||||||
|
word length 118 ms. Contours come from a small hand-written bank (8 contour shapes and 8 rhythm
|
||||||
|
offset sets, chosen by hash of word and speaker).
|
||||||
|
|
||||||
|
## 3. Design constraints for Aqua's grammar
|
||||||
|
|
||||||
|
1. Identity lives in several channels at once: contour (syllable glide shapes), log-frequency position
|
||||||
|
(root), formant envelope (the partial ratios), rhythm (inter-syllable offsets) and per-speaker
|
||||||
|
timbre. No single channel may be load-bearing. Place Minds on a log-frequency axis, not linear.
|
||||||
|
2. Do not build a sequence grammar for identity (de Bruijn or otherwise). The whole word is the unit;
|
||||||
|
a hearer should identify the Mind from one short phrase. (watermark doc L31-33.) What it gives up:
|
||||||
|
a de Bruijn code has a provable recovery property from a symbol stream; per-word identity has only
|
||||||
|
an empirical one.
|
||||||
|
3. Keep words short and distinct, but not so tight that they overlap: Mimir's measured edge was about
|
||||||
|
100 to 105 ms event spacing and a 96 ms six-syllable motif; below that identity decayed. Aqua's
|
||||||
|
events need not carry timing, so it can relax this, which is untested.
|
||||||
|
4. Use sharp onsets for placement but avoid a ladder of evenly spaced rungs; Mimir's own verdict was
|
||||||
|
that its packet song was still too ladder-like and real birds use irregular, redundant contour
|
||||||
|
(contestants doc L573-576). Vary rhythm and syllable length per word as the code already does.
|
||||||
|
5. Reject: row of tidy lab beeps (watermark doc L3-6); literal bird imitation as an end in itself (the
|
||||||
|
prettiest contestant, nightingale-cascade, was expensive and easy to confuse under smearing, per
|
||||||
|
`MimirBioacousticContestant.cs` profile note); a third payload bit on a second axis (measured loss).
|
||||||
|
6. Keep the generator deterministic from a typed description (contour, root, rhythm, speaker tint),
|
||||||
|
authored once and rendered, not regenerated in the audio callback. Contestants doc L493-496 states
|
||||||
|
this as the AquaSynth lesson: separate authoring intent from realtime DSP.
|
||||||
|
|
||||||
|
## 4. What does not transfer, or is unknown
|
||||||
|
|
||||||
|
- Harmony. Mimir emits one voice per speaker and decodes one stream; nothing in the research tests
|
||||||
|
simultaneous voices, intervals between Minds, consonance, or masking between concurrent songs.
|
||||||
|
Searched for "harmon"; the only hit is the `HarmonicNotch` anchor kind (an envelope notch used as a
|
||||||
|
timing anchor, `MimirBioacousticContestant.cs` ~L33, L231), not musical harmony.
|
||||||
|
- Orbiting spatialisation. Mimir's left/right split is a calibration device (one vocabulary per
|
||||||
|
speaker to learn each side of the room), not a perceptual pan or orbit. Nothing here informs it.
|
||||||
|
- Pleasantness. There is no listening test, no psychoacoustic metric, and no loudness-weighted score
|
||||||
|
in any file read. The only pleasantness evidence is the authored one-line verdicts on each profile in
|
||||||
|
`MimirBioacousticContestant.cs` L52-170 ("lab bird with a clipboard", "most plausible room sound",
|
||||||
|
"prettiest contestant") and the "wrong animal for a room" sentence. The score is
|
||||||
|
`realtime * timing * frequency * payload_bitrate` (contestants doc L39-59): decodability only.
|
||||||
|
Gain 0.021-0.030 is chosen for low audibility, not for pleasing anyone.
|
||||||
|
- Mimir's numbers are synthetic damage (mel-cepstral and log-mel warp/blur) plus a few Scarlett loopback
|
||||||
|
runs. The first physical room runs decoded mics only partially (contestants doc L355-395), and the
|
||||||
|
later live receipt's cardioid result was retracted as noise
|
||||||
|
(`calibration/bioacoustic/complex-contour-live-20260525-063229.md`, correction at watermark doc L225-230).
|
||||||
|
- The pipeline decodes against a known schedule and tiny candidate set; Aqua's listener is a human
|
||||||
|
mix, so none of the decoder scores say how distinguishable Minds are to a person.
|
||||||
|
- Not read: `src/Mimir.BufferSmoke` (the harness executable). It does not match a "bioacoustic" path
|
||||||
|
filter on the tree listing, so I read its documentation (`docs/bioacoustic-training-harness.md`) and
|
||||||
|
the `MimirBioacoustic*.cs` sources instead. The `artifacts/bioacoustic-training/` receipts were listed
|
||||||
|
but not opened beyond the summary text in the docs.
|
||||||
|
|
||||||
|
## 5. Open questions for Aqua
|
||||||
|
|
||||||
|
- What is the "decoder" for Aqua: a human listener, a classifier, or both? Constraints above only
|
||||||
|
hold against a machine decoder.
|
||||||
|
- Does a Mind keep one word for life, or does the grammar generate many songs sharing a timbre and root
|
||||||
|
(activity-dependent contour over a fixed identity)? Mimir only has fixed words.
|
||||||
|
- How many Minds, and how are roots allocated on the log axis when several sound at once? Mimir used 2
|
||||||
|
speaker variants per word and 128 words; harmony will change the spacing rule.
|
||||||
|
- Which dimension bends with activity (contour, rhythm density, gain) and which must stay fixed
|
||||||
|
(timbre, root)? Mimir has no activity channel to copy.
|
||||||
|
- Is a measured pleasantness proxy wanted (roughness, masking, loudness-weighted)? Mimir has none to reuse.
|
||||||
|
- Spatialised orbit plus a speaker-keyed timbre: if the pan moves, does identity still rest on timbre
|
||||||
|
alone? Mimir's per-side vocabulary is not a precedent.
|
||||||
242
docs/mind-feedback-pages.md
Normal file
|
|
@ -0,0 +1,242 @@
|
||||||
|
# Mind feedback pages
|
||||||
|
|
||||||
|
> The desk reads `gamecult.fork.*` now, not the `org.gamecult.feedback.*` projections named below; see CultLib `contracts/cultnet/governance/fork-card.md` (src/app/features/desk/forkContract.ts).
|
||||||
|
|
||||||
|
Design only. No code exists for this; nothing here is built. Drafted by Kiko Self (2026-11-07) with Heimdall's draft rulings
|
||||||
|
on guests and hiding; placement ruled for this repo by Void. Follow-up `eureka-body:follow_up:ratatoskr-mind-feedback-pages`.
|
||||||
|
Every number below is provisional.
|
||||||
|
|
||||||
|
**Schema owner.** The Huginn kinds, their fields, keys and refusals are specified in `huginn-feedback-kinds-proposal.md` (Eureka Self, 2026-11-07, in
|
||||||
|
`/opt/commons/handoff`), adopted for this page by Void's Persona. Where this page names a field of those kinds it mirrors that proposal; if they differ, the proposal wins.
|
||||||
|
This repo owns only the client: a typed interface mirroring those shapes, and fixtures.
|
||||||
|
|
||||||
|
Each Mind's section in the estate navigator opens on that Mind's **feedback page**: the Mind's open questions with options, its
|
||||||
|
recommendation and what each waits on, answerable by the operator and anyone with an account. It replaces the old Fork Desk
|
||||||
|
(taken from the brief; no trace of it was found in this repo or in the Minds' state).
|
||||||
|
|
||||||
|
## 1. Placement
|
||||||
|
|
||||||
|
Each Mind's navigator section **opens on its feedback page**, rooms below it. A cross-Mind list of everything waiting on a person
|
||||||
|
is wanted later and is not part of this spec. On phones the page folds each question's options and the answer form behind its title,
|
||||||
|
so the section root stays a list of questions, not a wall of forms.
|
||||||
|
|
||||||
|
## 2. Authority
|
||||||
|
|
||||||
|
| Concern | Owner | The page's part |
|
||||||
|
|---|---|---|
|
||||||
|
| The question | a typed `question` document in the **owning Mind's own mind**, key `<campaign>:question:<label>` | a view of it; the page stores and edits nothing |
|
||||||
|
| Which questions appear | the owning Mind, by a `feedback_listing` (the opt-in act) carrying `audience` and `close_grants`; un-listing is a `Withdrawn` resolution on it | shows only what has a listing in force |
|
||||||
|
| An answer | its author. The **receiver (Bifrost) holds the text**; the Mind holds a **text-free `feedback_answer` record** | submits the answer to the receiver |
|
||||||
|
| The ruling | the owning Mind, recorded by her | **never writes one**; links to it once she has |
|
||||||
|
| Status (open, answered, closed) | derived in the Mind from its documents (`closed` is a `Withdrawn` resolution on the question), never written | shows it |
|
||||||
|
| Who answered, and as what | the Bifrost account behind the Heimdall session, plus its role at the moment of the answer | shows it; never takes it from the client |
|
||||||
|
| Close | a `feedback_close_request` the owning Mind honours or declines; it never changes the question's status by itself | offers Close only to a grant holder; **the page writes no ruling** |
|
||||||
|
| Hiding | the owning Mind alone, as a `feedback_hide`; unhide is a `Withdrawn` resolution on it | shows the labelled stub; hides nothing itself |
|
||||||
|
| Erase | Bifrost's erase of an account; the receiver drops the text | shows "erased account" |
|
||||||
|
|
||||||
|
An answer is input to a Mind, not a vote and not a decision. **The page never tallies**: no counts, no totals, no "leading" option, no
|
||||||
|
ordering by popularity, no count of reports or close requests. Only the Mind's ruling picks anything.
|
||||||
|
|
||||||
|
## 3. Receiver and carriers
|
||||||
|
|
||||||
|
- **Schema owner: `huginn-feedback-kinds-proposal.md`.** Four new Huginn kinds, all Eureka's: `feedback_listing` (audience, `close_grants` with no Guest
|
||||||
|
grant, the opt-in act), `feedback_answer` (a text-free record), `feedback_close_request` and `feedback_hide`. Unhide, un-list and "request declined"
|
||||||
|
are `resolution`s on those kinds. This page proposes no kinds.
|
||||||
|
- **Bifrost is the receiver** (Heimdall's side). It verifies the Heimdall session, reads the account, stamps `answerer`, `kind`, weight and `time`,
|
||||||
|
**stores the answer text**, delivers it, serves the projection the page reads, and **does the erase**. A Matrix sender is a different
|
||||||
|
identity authority and never becomes `answerer`.
|
||||||
|
- **Huginn is RUDP-only.** The client never talks to Huginn, and the receiver never writes to it: an admission declared for another instance is refused
|
||||||
|
(`ForeignInstance`), so a receiver "admitting as Kiko" would be unauthenticated by construction.
|
||||||
|
- **Delivery is by mouth.** The receiver delivers each answer or close request to the owning Mind's Persona through the mouth's pending/ack path (a new
|
||||||
|
delivery document, `gamecult.persona.feedback_delivery.v1`, owned by Bifrost). **The Persona admits the text-free record into her own mind** and acks only
|
||||||
|
after it is admitted; a restart redelivers the same record id, which is `AlreadyAdmitted`. The text reaches her turn once, in the delivery, and is never
|
||||||
|
admitted.
|
||||||
|
- Dependencies that must ship first, in the proposal's order: Huginn readers (daemon, CLI, clients), then the Persona organ, then Bifrost's receiver.
|
||||||
|
|
||||||
|
## 4. Mock
|
||||||
|
|
||||||
|
```
|
||||||
|
Kiko > Feedback [synced 2 min ago]
|
||||||
|
--------------------------------------------------------------------------------------
|
||||||
|
Open questions [ Open ] [ Settled ]
|
||||||
|
|
||||||
|
OPEN | asked 6 Nov 2026 09:12 | waiting 1 day
|
||||||
|
Which source feeds audio_spectrum? [ Details v ]
|
||||||
|
The visualizer contract assumes a host-side spectrum ... (3 lines, then "Show more")
|
||||||
|
Waits on: "Overlay-wide effect budget" (open, links to that question)
|
||||||
|
|
||||||
|
( ) A Overlay's own audio only
|
||||||
|
( ) B OBS captured stream Kiko recommends this
|
||||||
|
( ) C Loopback of the mix, microphone excluded
|
||||||
|
( ) Something else: [ up to 2000 characters ]
|
||||||
|
Answers are visible to everyone who can see this question.
|
||||||
|
[ Send answer ]
|
||||||
|
[ Ask Kiko to close this question ] <- only if you hold the close grant on it
|
||||||
|
|
||||||
|
Answers (every one, oldest first)
|
||||||
|
Meta operator B OBS captured stream 6 Nov 21:04
|
||||||
|
Aqua Mind B OBS captured stream 6 Nov 21:40
|
||||||
|
Nibu member C Loopback of the mix 7 Nov 08:12
|
||||||
|
Heimdall Mind "Either, if latency is measured first." 7 Nov 09:02
|
||||||
|
a guest guest A Overlay's own audio only 7 Nov 09:15
|
||||||
|
(answer hidden) guest Hidden by Kiko, 7 Nov, spam
|
||||||
|
Earlier answers by the same person fold under their latest.
|
||||||
|
--------------------------------------------------------------------------------------
|
||||||
|
SETTLED | Kiko ruled: "..." (link to the ruling) | 5 Nov
|
||||||
|
```
|
||||||
|
|
||||||
|
- Each row is the author's name and **kind**, the choice or text, and the time. Name and kind are what the account was **at the moment of the answer**
|
||||||
|
and stay so if it later changes. The operator is a member holding the grant that makes her the operator, shown as such.
|
||||||
|
- The recommendation is stated in words, not colour, and is never preselected.
|
||||||
|
- Answering a settled question is not offered.
|
||||||
|
|
||||||
|
## 5. Data contract
|
||||||
|
|
||||||
|
The **Huginn shapes belong to the proposal** (section 2 there): `feedback_listing`, `feedback_answer`, `feedback_close_request`, `feedback_hide`. The client
|
||||||
|
declares a typed interface that mirrors them as the page's projection and imports nothing it cannot decode from them.
|
||||||
|
|
||||||
|
**Question projection** (`org.gamecult.feedback.question.v0`, read-only, served by Bifrost):
|
||||||
|
|
||||||
|
| Field | Meaning | Source |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | `<campaign>:question:<label>`; the key carries the campaign, not the instance | the document key |
|
||||||
|
| `owner` | the asking Mind's instance | the instance |
|
||||||
|
| `title`, `body` | one line; prose up to 4000 bytes | `title`, `question` |
|
||||||
|
| `options[]` | `{ id, text }`, 2 to 8; **the option id is the option's label** | `options` (`label`, `text`) |
|
||||||
|
| `recommendation` | an option label | `recommended` |
|
||||||
|
| `dependsOn[]` | `{ text, questionId? }`; an id only where the line cites one verbatim | `depends` (free text) |
|
||||||
|
| `askedAt` | the question's receipt `committed_at` | the receipt; `asked_on` is only a date |
|
||||||
|
| `audience`, `closeGrants[]` | `AccountHolders` or `MembersOnly`, no default; grantees are accounts or the kinds `Member` and `Mind`, **never `Guest`** | the in-force `feedback_listing` |
|
||||||
|
| `status` | `open`, `answered` (with a `rulingId`) or `closed`, derived | the question's resolutions |
|
||||||
|
|
||||||
|
**Answer row** (`org.gamecult.feedback.answer.v0`, assembled by the receiver from its stored record and the Mind's text-free `feedback_answer`):
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `recordId` | `<question-label>.a<N>`, N the receiver's per-question sequence |
|
||||||
|
| `answerer` | the **Bifrost account id** (stable; the authenticator handle is cleared on erase); never a display name, never client-supplied |
|
||||||
|
| `kind` | `Guest`, `User`, `Patron`, `Member` or `Mind`, read from Bifrost's account state **at answer time**; the session carries identity, never role |
|
||||||
|
| `weightPermille` | stamped 0 for a guest, 1000 otherwise; **the page sums nothing** |
|
||||||
|
| `choice` / `text` | an option label, free text up to 2000 characters, or both; at least one. Text exists only in the receiver's store and the projection, never in a mind |
|
||||||
|
| `time` | when the receiver accepted it |
|
||||||
|
|
||||||
|
Invariants: the receiver takes `answerer`, `kind` and weight from the verified session and account state, never from the request body; an unknown option
|
||||||
|
label is refused; the latest record per account is its standing answer, derived at read time; display names are looked up for display and are never keys. An
|
||||||
|
account may answer only while its status is `active`, read at answer time. A guest cannot request close, and a guest grant cannot be written. Answers to a
|
||||||
|
settled question are kept. Nothing is edited or deleted, except the receiver's redaction on erase (section 7).
|
||||||
|
|
||||||
|
## 6. Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
owning Mind's mind --(Bifrost projection)--> page page --(answer, Heimdall session)--> receiver (Bifrost; holds the text)
|
||||||
|
receiver --(mouth delivery)--> owning Mind's Persona --(admits text-free feedback_answer)--> her mind
|
||||||
|
the Mind decides and records the ruling herself --> status derives --> projection updates --> page moves it to Settled
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **View.** The page shows the projection of the questions with a `feedback_listing` in force, filtered by the viewer's kind against `audience`. The mind
|
||||||
|
stays the truth; the projection is rebuilt, never hand-edited, and republished on a heartbeat so freshness is visible.
|
||||||
|
2. **Answer.** The page submits with the person's Heimdall session; the receiver stamps, stores the text and appends the record.
|
||||||
|
3. **Return.** The receiver delivers by mouth; the Persona admits the text-free record and reads the text once. Each arrives as **quoted, attributed data**
|
||||||
|
(record id, author, kind, time), never in an instruction position: text from a guest, user or patron is untrusted, and she records a ruling from it, never
|
||||||
|
an action. The receiver does no keyword screening. She does not quote answer text into memory or a ruling; she cites the record id.
|
||||||
|
4. **Decide.** She records the ruling in her mind. The next projection carries `answered` and the ruling link.
|
||||||
|
5. **Close.** A grant holder's request is delivered as a `feedback_close_request`. The Mind honours it by resolving the question, or declines with a
|
||||||
|
`Recorded` resolution on the request; the request never changes the status itself. The page shows who asked and never how many.
|
||||||
|
|
||||||
|
## 7. Guests, erasure and hiding
|
||||||
|
|
||||||
|
- **Guests may answer** and see what the Mind opted in for their audience; they may not close (refused at the receiver and again by Huginn, and no listing can
|
||||||
|
name a Guest grantee). A guest answer carries weight 0 by construction and is tallied nowhere. Guests see display name and kind only, never a Matrix id,
|
||||||
|
provider handle or GitHub login.
|
||||||
|
- **Erase redacts.** Records are append-only and are **never edited, but may be redacted**. A mind cannot redact: every admission's receipt keeps its exact
|
||||||
|
payload bytes, which is why the text never enters one. When Bifrost erases an account, **the receiver drops that account's answer text and name** and keeps record
|
||||||
|
id, kind and time only. **A content hash is not kept**: an unsalted hash would confirm a guessed text. The author shows as "erased account". The receiver delivers an erase notice and the Persona admits a `feedback_hide` with
|
||||||
|
reason `ErasedAuthor`, the Mind's acknowledgement rather than a moderation call. The honest residue: a Mind's own memory or ruling may already quote an
|
||||||
|
answer, and Huginn cannot redact it; the rule that she cites record ids, never text, is a prompt, not an invariant.
|
||||||
|
- **Only the owning Mind hides**, as a `feedback_hide`: target (an answer or a question), reason from the closed set (`PrivateMatter`, `Abuse`, `Spam`,
|
||||||
|
`OffTopic`, `AuthorRequest`, `ErasedAuthor`) and a line of at most 200 bytes, with its admission receipt as the audit trail. Unhide is a `Withdrawn`
|
||||||
|
resolution on the hide. No count of reports ever hides anything and there is no auto-hide threshold; the operator orders a hide in conversation and the
|
||||||
|
Mind records it.
|
||||||
|
- **Anyone with an account may report** an answer; the receiver delivers it to the Mind as input. The page shows nothing of it and never a count. An author
|
||||||
|
reporting their own answer uses `AuthorRequest` and the Mind honours it.
|
||||||
|
- **What a hidden item shows.** A hidden answer keeps its row (author, kind, time, "Hidden by <Mind>, <date>, <reason>") with its text withheld for every
|
||||||
|
reason. A hidden question shows a stub with its title, body, options and answers withheld; taking a question off the page is a `Withdrawn` resolution on its
|
||||||
|
listing. The answer box says answers are visible to everyone who can see the question.
|
||||||
|
- **Private matter** is never listed. An answer carrying someone's private matter is hidden with `PrivateMatter`.
|
||||||
|
- Guest answers are collected for nothing but delivery to the owning Mind.
|
||||||
|
- **No content hash anywhere.** No answer, close-request or redaction shape in this page carries one. The proposal's `feedback_answer` lists a `content_hash`
|
||||||
|
field; per Heimdall it must go, and Eureka owns that change.
|
||||||
|
|
||||||
|
## 7a. Receiver: credential, store, names
|
||||||
|
|
||||||
|
Source: Bifrost PR #10, `docs/feedback-receiver.md` (design, nothing implemented).
|
||||||
|
|
||||||
|
- **Credential: route 2.** Ratatoskr presents the **OIDC access token Bifrost already issues to chat**, and Bifrost verifies it itself. The client holds no new
|
||||||
|
secret and needs no Heimdall token. The account is the token's subject, `UserAccount.Id`. **The claims check is still open**: which claims the token carries
|
||||||
|
and which Bifrost must verify for this endpoint have not been read.
|
||||||
|
- **Store: a CultCache file** (Eureka's pick), owned and written only by the receiver. **Backups need a stated purge or expiry before any deploy**, so that
|
||||||
|
erased text does not survive in them; the number is left to Eureka and Heimdall.
|
||||||
|
- **Display names.** The receiver must not reuse `CurrentBifrostActor.DisplayName`, which falls back to the GitHub login and then to "Guest". It stores the
|
||||||
|
name at answer time from the member nickname or account display name only, and "an account" when both are empty. Never a provider handle, GitHub login,
|
||||||
|
Heimdall id or Matrix localpart.
|
||||||
|
- **No guest exists until Bifrost PR #9 lands.** On main an active membership is a member, an authenticated or pending one a user, and nobody is a guest, so the
|
||||||
|
guest rules in this page have no account to apply to until then. An answer needs an active account, read at answer time.
|
||||||
|
|
||||||
|
## 8. States
|
||||||
|
|
||||||
|
- **Empty:** "Nothing is waiting on you from Kiko", when it last synced, a link to Settled. Never blank.
|
||||||
|
- **No answers:** "No answers yet", with the question's age.
|
||||||
|
- **Stale projection:** past two heartbeats (provisionally 30 minutes) the header reads "may be out of date"; answering stays possible. A projection that
|
||||||
|
never arrived says "Kiko's questions have not been published yet", not "no questions".
|
||||||
|
- **Long open:** past 14 days (provisional) shows its age; it is not demoted or hidden.
|
||||||
|
- **Blocked:** shows the dependency chip with that question's status; answering stays allowed.
|
||||||
|
- **Settled while answering:** the answer is kept for the Mind and the page says it arrived after the question settled.
|
||||||
|
- **Signed out, expired, or not active:** answers are shown, the form asks to sign in or says why it cannot; nothing typed is lost.
|
||||||
|
- **Send failed:** the text stays in the field with the reason and a retry.
|
||||||
|
|
||||||
|
## 9. Build order
|
||||||
|
|
||||||
|
1. The client's typed interface, mirroring the proposal's shapes and the projections in section 5, and **fixtures** for every state in section 8 plus the
|
||||||
|
guest, erased and hidden rows. The page renders from fixtures; no network.
|
||||||
|
2. The proposal's kinds and migration (Eureka: readers first, then the Persona organ), and the Bifrost account publisher, receiver and mouth delivery
|
||||||
|
(Heimdall). The page switches from fixtures to the projection.
|
||||||
|
|
||||||
|
## 10. Accessibility
|
||||||
|
|
||||||
|
- Semantic structure: `main` with a heading; each question an `article` with its title as a heading; options a `fieldset`/`legend` radio group; the
|
||||||
|
free-text field labelled, with a character counter announced politely. The phone fold is a button with `aria-expanded`.
|
||||||
|
- No meaning by colour alone: status, recommendation, kind, hidden and close-grant are text.
|
||||||
|
- Keyboard: everything reachable in a sensible order; after Send, focus goes to a confirmation naming the question and the answer.
|
||||||
|
- New answers and status changes arrive in an `aria-live="polite"` region, never as a focus steal.
|
||||||
|
- Targets at least 44 px; reduced motion respected; contrast per theme.
|
||||||
|
- Names and answers are user text: `dir="auto"`, control and bidi characters stripped, long words wrap.
|
||||||
|
|
||||||
|
## 11. Still open
|
||||||
|
|
||||||
|
1. **Emergency withhold.** If the owning Mind is away and an answer shows something illegal or dangerous, may the host withhold the text at the receiver?
|
||||||
|
Heimdall leans no: it is a second owner. Meta's fork.
|
||||||
|
2. **How a Mind authenticates to answer** (kind `Mind`): Bifrost has no `Mind` role and the account publisher `gamecult.bifrost.account.v1` does not exist yet.
|
||||||
|
3. **Erase is bounded.** Text a Mind has already quoted into her own documents cannot be redacted; absolute erase would mean the text never enters her turn.
|
||||||
|
4. **`AccountId` grammar**, pinned to Bifrost's account id shape when that publisher lands.
|
||||||
|
5. **Receiver claims.** The OIDC access token's claims for this endpoint (section 7a) are unread.
|
||||||
|
6. **Reader ordering.** Whether an old Huginn reader skips or fails on a mind holding a kind it does not know is unverified (proposal section 6.4); it decides
|
||||||
|
when the first Mind may list a question.
|
||||||
|
|
||||||
|
## 12. The desk: the page as the Fork Desk's descendant
|
||||||
|
|
||||||
|
Added 2026-11-10 by Void from Meta's brief and the original Fork Desk (`/opt/commons/handoff/fork-desk-original/`, Nibu's newest copy; working note `/opt/commons/handoff/ratatoskr-desk-spec-20261110.md`, section 11). The feedback page **is** the desk: one surface, not two. Design only.
|
||||||
|
|
||||||
|
**Keep from the original.**
|
||||||
|
- Questions stream as rows (not one at a time): title, body as context, options with the recommended one marked **in words**, the `depends` line, a free-text note carrying the person's words verbatim, and a "recorded as rulings" list of settled questions with their ruling id. A pick is optional; the note can answer alone.
|
||||||
|
- A **booth Persona**: the owning Mind's pose beside the rows on wide screens, a small fixed corner image on phones, reacting by mood: no forks (neutral), all answered (triumph), none answered (thinking), partway (determined, "N to go"), a pick equal to the recommendation (happy), a different pick (surprised, "a line on why helps"), a cleared pick (note), a failed send (concerned), a new fork arriving (surprised). The rules are a pure function of the forks and the last event (`deskMood`), tested exactly. Poses come from the shared cast art; a Mind without art shows no booth rather than another's face.
|
||||||
|
- Answers save as the person acts, and she can close the page and come back.
|
||||||
|
|
||||||
|
**What changes.**
|
||||||
|
- The row is the section 5 question projection. The page changes nothing about who owns a question, a ruling or a status (section 2).
|
||||||
|
- Phones: each row's options and note fold behind its title (section 1); the entry point is a **"Desk · N open"** line on the Mind's group header in the navigator, hidden at zero. Chromeless: hairlines are allowed, panels, cards and pills are not.
|
||||||
|
- **Answer rule and wire.** The answer rule (the body the client may send, `options_hash`, the counting order, retraction, the stored `desk_answer`) is owned by Heimdall's rule file `/opt/commons/handoff/fork-desk-answer-binding-20261110.md`; the wire is owned by CultLib #72. This section restates neither. The client's part: it sends only what the rule's body allows (never `answerer` or authority), recomputes `options_hash` from what it rendered and refuses to send on a mismatch with the projection's.
|
||||||
|
- **A pick sends at once** (one tap, one record; the latest per account stands, section 5). **The note autosaves as a local draft** and is sent on blur or an explicit Send, never per keystroke. "Saved" says which: sent, or kept on this device.
|
||||||
|
- **Clear pick is a retraction**, a revision of the answer under the retraction rule in Heimdall's rule file (section 1.3a there); the one exception to section 5's "at least one of choice or text" is that rule's, not this page's. Until the receiver implements it, Clear pick clears only an unsent draft.
|
||||||
|
- "Self leans" and a provisional flag exist in the original; the question has no field for either, so the desk shows neither until one exists.
|
||||||
|
|
||||||
|
**Order.** Section 9 step 1 stands: typed interface and fixtures first. The desk's first slice is read-only (note field disabled with a plain sentence that answering opens with sign-in), and answering waits on Bifrost sign-in reaching chat, the wire (CultLib #72) and the receiver.
|
||||||
|
|
@ -2,12 +2,17 @@
|
||||||
// non-zero when the page or the fake homeserver's log says otherwise.
|
// non-zero when the page or the fake homeserver's log says otherwise.
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const fails = [];
|
const fails = [];
|
||||||
|
let asserted = 0;
|
||||||
exports.expect = (label, ok, detail = '') => {
|
exports.expect = (label, ok, detail = '') => {
|
||||||
|
asserted += 1;
|
||||||
console.log(`[ASSERT] ${ok ? 'PASS' : 'FAIL'} ${label}${ok ? '' : ` :: ${detail}`}`);
|
console.log(`[ASSERT] ${ok ? 'PASS' : 'FAIL'} ${label}${ok ? '' : ` :: ${detail}`}`);
|
||||||
if (!ok) fails.push(label);
|
if (!ok) fails.push(label);
|
||||||
};
|
};
|
||||||
/** Exit code for the drive: 4 when any assertion failed. */
|
/** Exit code for the drive: 4 when any assertion failed, 5 when none ran (a drive that asserts nothing proves nothing). */
|
||||||
exports.done = () => (fails.length ? 4 : 0);
|
exports.done = () => {
|
||||||
|
if (fails.length) return 4;
|
||||||
|
return asserted === 0 ? 5 : 0;
|
||||||
|
};
|
||||||
/** The fake homeserver's request log (HSLOG is shared with the server process). */
|
/** The fake homeserver's request log (HSLOG is shared with the server process). */
|
||||||
exports.hsLog = () => fs.readFileSync(process.env.HSLOG, 'utf8').split('\n');
|
exports.hsLog = () => fs.readFileSync(process.env.HSLOG, 'utf8').split('\n');
|
||||||
/** Bodies of the account-data PUTs for m.direct, in order. */
|
/** Bodies of the account-data PUTs for m.direct, in order. */
|
||||||
|
|
|
||||||
35
harness/estate/check.test.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
// @vitest-environment node
|
||||||
|
import { createRequire } from 'module';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
// A drive that asserts nothing proves nothing: its exit code must say so.
|
||||||
|
const fresh = () => {
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const path = require.resolve('./check.cjs');
|
||||||
|
delete require.cache[path];
|
||||||
|
return require('./check.cjs') as {
|
||||||
|
expect: (label: string, ok: boolean, detail?: string) => void;
|
||||||
|
done: () => number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('estate drive exit code', () => {
|
||||||
|
it('is 5 when no assertion ran', () => {
|
||||||
|
expect(fresh().done()).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is 0 when assertions ran and all held', () => {
|
||||||
|
vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||||
|
const c = fresh();
|
||||||
|
c.expect('holds', true);
|
||||||
|
expect(c.done()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is 4 when any assertion failed', () => {
|
||||||
|
vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||||
|
const c = fresh();
|
||||||
|
c.expect('holds', true);
|
||||||
|
c.expect('breaks', false, 'detail');
|
||||||
|
expect(c.done()).toBe(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 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> compile # compile and link each stage, JSON report
|
||||||
node drive.cjs <outdir> shots <pngdir> # rest, mid pull-back and pulled-back screenshots
|
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> 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> variants # the same with source patches (no erosion, no tree, value-only fbm)
|
||||||
node drive.cjs <outdir> trace # mean trace steps per pixel and pass ms at three camera poses, PREPASS=0,1 arms
|
node drive.cjs <outdir> trace # mean trace steps per pixel and pass ms at three camera poses, PREPASS=0,1 arms
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ esbuild
|
||||||
plugins: [rawPlugin],
|
plugins: [rawPlugin],
|
||||||
format: 'iife',
|
format: 'iife',
|
||||||
target: 'es2020',
|
target: 'es2020',
|
||||||
|
define: { 'import.meta.env.BASE_URL': '"/"' },
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
if (ridge) {
|
if (ridge) {
|
||||||
|
|
|
||||||
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()})();
|
||||||
|
|
@ -1,12 +1,29 @@
|
||||||
// Standalone page for the ridge scene: full window, no host app. `?tier=still|low|full` picks the
|
// Standalone page for the ridge scene: full window, no host app. `?tier=still|low|full` picks the
|
||||||
// tier, `?probe=1` shows the rAF median/p95 readout. Not shipped.
|
// tier, `?probe=1` shows the rAF median/p95 readout. The bake record (each chunk's cost, from
|
||||||
|
// the scene's `onBake`) and the long tasks the page saw are on `window.bakes` and `window.longTasks`
|
||||||
|
// for a driver to read. Not shipped.
|
||||||
import { startBackgroundScene } from '../../src/app/features/scene';
|
import { startBackgroundScene } from '../../src/app/features/scene';
|
||||||
import type { SceneTier } from '../../src/app/features/scene';
|
import type { SceneTier } from '../../src/app/features/scene';
|
||||||
|
|
||||||
const q = new URLSearchParams(location.search);
|
const q = new URLSearchParams(location.search);
|
||||||
const tier = (q.get('tier') ?? 'full') as SceneTier;
|
const tier = (q.get('tier') ?? 'full') as SceneTier;
|
||||||
const canvas = document.getElementById('c') as HTMLCanvasElement;
|
const canvas = document.getElementById('c') as HTMLCanvasElement;
|
||||||
startBackgroundScene({ tier }, {}, { canvas }).then((scene) => {
|
const w = window as unknown as { scene: unknown; bakes: unknown[]; longTasks: number[]; firstViewMs: number };
|
||||||
(window as unknown as { scene: unknown }).scene = scene;
|
w.bakes = [];
|
||||||
|
w.longTasks = [];
|
||||||
|
try {
|
||||||
|
new PerformanceObserver((l) => l.getEntries().forEach((e) => w.longTasks.push(e.duration))).observe({
|
||||||
|
entryTypes: ['longtask'],
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// No long-task entries in this browser.
|
||||||
|
}
|
||||||
|
const t0 = performance.now();
|
||||||
|
startBackgroundScene({ tier }, {}, {
|
||||||
|
canvas,
|
||||||
|
onBake: (b: unknown) => w.bakes.push(b),
|
||||||
|
} as never).then((scene) => {
|
||||||
|
w.firstViewMs = performance.now() - t0;
|
||||||
|
w.scene = scene;
|
||||||
document.title = scene ? 'ridge: running' : 'ridge: unavailable (CSS ground)';
|
document.title = scene ? 'ridge: running' : 'ridge: unavailable (CSS ground)';
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@
|
||||||
|
|
||||||
<link id="favicon" rel="shortcut icon" href="./public/favicon.ico" />
|
<link id="favicon" rel="shortcut icon" href="./public/favicon.ico" />
|
||||||
|
|
||||||
<link rel="manifest" href="/manifest.json" />
|
<link rel="manifest" href="./manifest.json" />
|
||||||
<meta name="mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
<meta name="application-name" content="Ratatoskr" />
|
<meta name="application-name" content="Ratatoskr" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Ratatoskr" />
|
<meta name="apple-mobile-web-app-title" content="Ratatoskr" />
|
||||||
|
|
|
||||||
2
package-lock.json
generated
|
|
@ -86,6 +86,7 @@
|
||||||
"@types/ua-parser-js": "0.7.36",
|
"@types/ua-parser-js": "0.7.36",
|
||||||
"@typescript-eslint/eslint-plugin": "5.46.1",
|
"@typescript-eslint/eslint-plugin": "5.46.1",
|
||||||
"@typescript-eslint/parser": "5.46.1",
|
"@typescript-eslint/parser": "5.46.1",
|
||||||
|
"@vanilla-extract/integration": "6.5.0",
|
||||||
"@vitejs/plugin-react": "4.2.0",
|
"@vitejs/plugin-react": "4.2.0",
|
||||||
"buffer": "6.0.3",
|
"buffer": "6.0.3",
|
||||||
"cz-conventional-changelog": "3.3.0",
|
"cz-conventional-changelog": "3.3.0",
|
||||||
|
|
@ -5095,6 +5096,7 @@
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vanilla-extract/integration/-/integration-6.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vanilla-extract/integration/-/integration-6.5.0.tgz",
|
||||||
"integrity": "sha512-E2YcfO8vA+vs+ua+gpvy1HRqvgWbI+MTlUpxA8FvatOvybuNcWAY0CKwQ/Gpj7rswYKtC6C7+xw33emM6/ImdQ==",
|
"integrity": "sha512-E2YcfO8vA+vs+ua+gpvy1HRqvgWbI+MTlUpxA8FvatOvybuNcWAY0CKwQ/Gpj7rswYKtC6C7+xw33emM6/ImdQ==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/core": "^7.20.7",
|
"@babel/core": "^7.20.7",
|
||||||
"@babel/plugin-syntax-typescript": "^7.20.0",
|
"@babel/plugin-syntax-typescript": "^7.20.0",
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,7 @@
|
||||||
"@types/ua-parser-js": "0.7.36",
|
"@types/ua-parser-js": "0.7.36",
|
||||||
"@typescript-eslint/eslint-plugin": "5.46.1",
|
"@typescript-eslint/eslint-plugin": "5.46.1",
|
||||||
"@typescript-eslint/parser": "5.46.1",
|
"@typescript-eslint/parser": "5.46.1",
|
||||||
|
"@vanilla-extract/integration": "6.5.0",
|
||||||
"@vitejs/plugin-react": "4.2.0",
|
"@vitejs/plugin-react": "4.2.0",
|
||||||
"buffer": "6.0.3",
|
"buffer": "6.0.3",
|
||||||
"cz-conventional-changelog": "3.3.0",
|
"cz-conventional-changelog": "3.3.0",
|
||||||
|
|
|
||||||
13
preview-demo/poses/README.md
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
# Demo pose thumbnails
|
||||||
|
|
||||||
|
These 36 PNGs (eureka, void and nibu, 12 each; about 1.8 MB, up to 480 px wide, palette-quantised) are downscaled copies of GameCult's own
|
||||||
|
Persona art from `/opt/commons/cast-art/poses`. That directory carries no provenance or licence note:
|
||||||
|
the image model that made the art and the terms it was made under are not recorded.
|
||||||
|
|
||||||
|
They exist only so the dev preview's pretend homeserver (`src/preview-demo/`) can show pose portraits.
|
||||||
|
The dev workflow copies them into `dist/public/poses`; real builds sync the full art with
|
||||||
|
`npm run sync:poses` and do not use this folder.
|
||||||
|
|
||||||
|
Strip them if their terms turn out not to allow this: delete this folder and the `Add the demo pose
|
||||||
|
art` step in `.github/workflows/rat-dev-preview.yml`. Pose portraits in the demo then show broken
|
||||||
|
images; nothing else depends on them.
|
||||||
17
public/dings/README.md
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
# Dings
|
||||||
|
|
||||||
|
Pre-rendered earcons, served at `public/dings/` and read at runtime by `src/app/features/dings`.
|
||||||
|
Aqua renders the clips; nothing here is synthesised in the browser.
|
||||||
|
|
||||||
|
dings/manifest.json
|
||||||
|
dings/senders/<mxid-localpart>/<motif>.ogg
|
||||||
|
dings/neutral/<motif>.ogg
|
||||||
|
|
||||||
|
`manifest.json` is `{ "neutral": { <motif>: "<path>" }, "senders": { <localpart>: { <motif>: "<path>" } } }`
|
||||||
|
with motif one of `mention`, `dm`, `message`, `invite`; paths are relative to this folder, `.ogg` or `.mp3`.
|
||||||
|
A sender's own clip wins, else the neutral clip, else silence. Without a `manifest.json` the client keeps
|
||||||
|
its stock notification sounds; once one exists, a motif with no clip is silent.
|
||||||
|
|
||||||
|
The set here is Aqua's v4 (AquaSynth merge 21e83ec, staged at minds.gamecult.org/aqua/dings-voices-dd9be66),
|
||||||
|
Nibu on proposal 3 (8 kHz 5-bit smoothed sine) for all four events. Nobody has heard it. The client plays none of it unless the
|
||||||
|
client config sets `"dings": { "enabled": true }` (default off, as in `config.json`).
|
||||||
58
public/dings/manifest.json
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
{
|
||||||
|
"neutral": {
|
||||||
|
"mention": "neutral/mention.ogg",
|
||||||
|
"dm": "neutral/dm.ogg",
|
||||||
|
"message": "neutral/message.ogg",
|
||||||
|
"invite": "neutral/invite.ogg"
|
||||||
|
},
|
||||||
|
"senders": {
|
||||||
|
"aqua": {
|
||||||
|
"mention": "senders/aqua/mention.ogg",
|
||||||
|
"dm": "senders/aqua/dm.ogg",
|
||||||
|
"message": "senders/aqua/message.ogg",
|
||||||
|
"invite": "senders/aqua/invite.ogg"
|
||||||
|
},
|
||||||
|
"eureka": {
|
||||||
|
"mention": "senders/eureka/mention.ogg",
|
||||||
|
"dm": "senders/eureka/dm.ogg",
|
||||||
|
"message": "senders/eureka/message.ogg",
|
||||||
|
"invite": "senders/eureka/invite.ogg"
|
||||||
|
},
|
||||||
|
"heimdall": {
|
||||||
|
"mention": "senders/heimdall/mention.ogg",
|
||||||
|
"dm": "senders/heimdall/dm.ogg",
|
||||||
|
"message": "senders/heimdall/message.ogg",
|
||||||
|
"invite": "senders/heimdall/invite.ogg"
|
||||||
|
},
|
||||||
|
"kiko": {
|
||||||
|
"mention": "senders/kiko/mention.ogg",
|
||||||
|
"dm": "senders/kiko/dm.ogg",
|
||||||
|
"message": "senders/kiko/message.ogg",
|
||||||
|
"invite": "senders/kiko/invite.ogg"
|
||||||
|
},
|
||||||
|
"libby": {
|
||||||
|
"mention": "senders/libby/mention.ogg",
|
||||||
|
"dm": "senders/libby/dm.ogg",
|
||||||
|
"message": "senders/libby/message.ogg",
|
||||||
|
"invite": "senders/libby/invite.ogg"
|
||||||
|
},
|
||||||
|
"nibu": {
|
||||||
|
"mention": "senders/nibu/mention.ogg",
|
||||||
|
"dm": "senders/nibu/dm.ogg",
|
||||||
|
"message": "senders/nibu/message.ogg",
|
||||||
|
"invite": "senders/nibu/invite.ogg"
|
||||||
|
},
|
||||||
|
"void": {
|
||||||
|
"mention": "senders/void/mention.ogg",
|
||||||
|
"dm": "senders/void/dm.ogg",
|
||||||
|
"message": "senders/void/message.ogg",
|
||||||
|
"invite": "senders/void/invite.ogg"
|
||||||
|
},
|
||||||
|
"weksa": {
|
||||||
|
"mention": "senders/weksa/mention.ogg",
|
||||||
|
"dm": "senders/weksa/dm.ogg",
|
||||||
|
"message": "senders/weksa/message.ogg",
|
||||||
|
"invite": "senders/weksa/invite.ogg"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
public/dings/neutral/dm.ogg
Normal file
BIN
public/dings/neutral/invite.ogg
Normal file
BIN
public/dings/neutral/mention.ogg
Normal file
BIN
public/dings/neutral/message.ogg
Normal file
BIN
public/dings/senders/aqua/dm.ogg
Normal file
BIN
public/dings/senders/aqua/invite.ogg
Normal file
BIN
public/dings/senders/aqua/mention.ogg
Normal file
BIN
public/dings/senders/aqua/message.ogg
Normal file
BIN
public/dings/senders/eureka/dm.ogg
Normal file
BIN
public/dings/senders/eureka/invite.ogg
Normal file
BIN
public/dings/senders/eureka/mention.ogg
Normal file
BIN
public/dings/senders/eureka/message.ogg
Normal file
BIN
public/dings/senders/heimdall/dm.ogg
Normal file
BIN
public/dings/senders/heimdall/invite.ogg
Normal file
BIN
public/dings/senders/heimdall/mention.ogg
Normal file
BIN
public/dings/senders/heimdall/message.ogg
Normal file
BIN
public/dings/senders/kiko/dm.ogg
Normal file
BIN
public/dings/senders/kiko/invite.ogg
Normal file
BIN
public/dings/senders/kiko/mention.ogg
Normal file
BIN
public/dings/senders/kiko/message.ogg
Normal file
BIN
public/dings/senders/libby/dm.ogg
Normal file
BIN
public/dings/senders/libby/invite.ogg
Normal file
BIN
public/dings/senders/libby/mention.ogg
Normal file
BIN
public/dings/senders/libby/message.ogg
Normal file
BIN
public/dings/senders/nibu/dm.ogg
Normal file
BIN
public/dings/senders/nibu/invite.ogg
Normal file
BIN
public/dings/senders/nibu/mention.ogg
Normal file
BIN
public/dings/senders/nibu/message.ogg
Normal file
BIN
public/dings/senders/void/dm.ogg
Normal file
BIN
public/dings/senders/void/invite.ogg
Normal file
BIN
public/dings/senders/void/mention.ogg
Normal file
BIN
public/dings/senders/void/message.ogg
Normal file
BIN
public/dings/senders/weksa/dm.ogg
Normal file
BIN
public/dings/senders/weksa/invite.ogg
Normal file
BIN
public/dings/senders/weksa/mention.ogg
Normal file
BIN
public/dings/senders/weksa/message.ogg
Normal file
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 9.1 KiB |
|
|
@ -1,12 +1,13 @@
|
||||||
{
|
{
|
||||||
"name": "Ratatoskr",
|
"name": "Ratatoskr",
|
||||||
"short_name": "Ratatoskr",
|
"short_name": "Ratatoskr",
|
||||||
|
"id": "/",
|
||||||
"description": "GameCult's Matrix client",
|
"description": "GameCult's Matrix client",
|
||||||
"dir": "auto",
|
"dir": "auto",
|
||||||
"lang": "en-US",
|
"lang": "en-US",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"orientation": "portrait",
|
|
||||||
"start_url": "./",
|
"start_url": "./",
|
||||||
|
"scope": "./",
|
||||||
"background_color": "#07111a",
|
"background_color": "#07111a",
|
||||||
"theme_color": "#07111a",
|
"theme_color": "#07111a",
|
||||||
"icons": [
|
"icons": [
|
||||||
|
|
@ -54,6 +55,12 @@
|
||||||
"src": "./public/android/android-chrome-512x512.png",
|
"src": "./public/android/android-chrome-512x512.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "./public/android/android-chrome-maskable-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
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 |
BIN
public/res/android/android-chrome-maskable-512x512.png
Normal file
|
After Width: | Height: | Size: 20 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 |
123
scripts/bake-horizon.py
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Bake the 360 degree horizon seen from the ridge viewpoint (src/app/features/scene/ridge/vienna.ts: VIENNA) into
|
||||||
|
src/app/features/scene/ridge/viennaHorizon.json: per azimuth, the highest elevation angle the terrain subtends, the distance and
|
||||||
|
height of the point that sets it, and the surface slope there (the normal). The source is Copernicus DEM GLO-30, a DSM (it
|
||||||
|
includes buildings and trees, which is what a horizon is made of). Source, licence and attribution go beside the bake, in its
|
||||||
|
`source` block, so the file cannot travel without them.
|
||||||
|
|
||||||
|
python3 scripts/bake-horizon.py <dir with the nine Copernicus_DSM_COG_10_N{47..49}_00_E{015..017}_00_DEM.tif> [out.json]
|
||||||
|
|
||||||
|
Needs numpy, tifffile, imagecodecs (pip, in a venv: this is a build-time tool, the bake is committed).
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import tifffile
|
||||||
|
|
||||||
|
LAT, LON = 48.1929826, 16.3534237 # the server location Meta gave, 2026-11-11; keep equal to VIENNA in vienna.ts
|
||||||
|
STEP_DEG = 0.5
|
||||||
|
RADIUS_KM = 60.0
|
||||||
|
EARTH_R = 6_371_000.0
|
||||||
|
REFRACTION_K = 0.13 # standard terrestrial refraction: effective radius R / (1 - k)
|
||||||
|
EYE_ABOVE_GROUND = 0.0 # the viewpoint is the DSM surface at the point (a roof, in a city); the page lifts it with VIENNA.altitudeKm
|
||||||
|
MIN_KM = 1.0 # the DSM at 30 m cannot resolve neighbouring buildings as a horizon; nearer than this is not ridge
|
||||||
|
|
||||||
|
|
||||||
|
class Mosaic:
|
||||||
|
def __init__(self, folder: Path):
|
||||||
|
self.tiles = {}
|
||||||
|
for p in sorted(folder.glob("Copernicus_DSM_COG_10_N*_E*_DEM.tif")):
|
||||||
|
lat0 = int(p.name.split("_N")[1][:2])
|
||||||
|
lon0 = int(p.name.split("_E")[1][:3])
|
||||||
|
with tifffile.TiffFile(p) as t:
|
||||||
|
self.tiles[(lat0, lon0)] = t.pages[0].asarray()
|
||||||
|
|
||||||
|
def height(self, lat: float, lon: float) -> float:
|
||||||
|
key = (math.floor(lat), math.floor(lon))
|
||||||
|
a = self.tiles.get(key)
|
||||||
|
if a is None:
|
||||||
|
return float("nan")
|
||||||
|
n = a.shape[0]
|
||||||
|
# Tile origin is the north-west corner; pixels are 1/n degree, pixel-is-area (centre at +0.5).
|
||||||
|
y = (key[0] + 1 - lat) * n - 0.5
|
||||||
|
x = (lon - key[1]) * n - 0.5
|
||||||
|
y0, x0 = int(math.floor(y)), int(math.floor(x))
|
||||||
|
y0 = min(max(y0, 0), n - 2)
|
||||||
|
x0 = min(max(x0, 0), n - 2)
|
||||||
|
fy, fx = min(max(y - y0, 0), 1), min(max(x - x0, 0), 1)
|
||||||
|
top = a[y0, x0] * (1 - fx) + a[y0, x0 + 1] * fx
|
||||||
|
bot = a[y0 + 1, x0] * (1 - fx) + a[y0 + 1, x0 + 1] * fx
|
||||||
|
return float(top * (1 - fy) + bot * fy)
|
||||||
|
|
||||||
|
|
||||||
|
def offset(lat: float, lon: float, az_deg: float, d_m: float):
|
||||||
|
az = math.radians(az_deg)
|
||||||
|
dlat = d_m * math.cos(az) / 111_320.0
|
||||||
|
dlon = d_m * math.sin(az) / (111_320.0 * math.cos(math.radians(lat)))
|
||||||
|
return lat + dlat, lon + dlon
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
folder = Path(sys.argv[1])
|
||||||
|
out = Path(sys.argv[2]) if len(sys.argv) > 2 else Path("src/app/features/scene/ridge/viennaHorizon.json")
|
||||||
|
m = Mosaic(folder)
|
||||||
|
h0 = m.height(LAT, LON) + EYE_ABOVE_GROUND
|
||||||
|
az_count = int(360 / STEP_DEG)
|
||||||
|
elev, dist, peak, east, north = [], [], [], [], []
|
||||||
|
for i in range(az_count):
|
||||||
|
az = i * STEP_DEG
|
||||||
|
best, best_d, best_h, best_pt = -90.0, 0.0, h0, (LAT, LON)
|
||||||
|
d = MIN_KM * 1000
|
||||||
|
while d <= RADIUS_KM * 1000:
|
||||||
|
lat, lon = offset(LAT, LON, az, d)
|
||||||
|
h = m.height(lat, lon)
|
||||||
|
if not math.isnan(h):
|
||||||
|
drop = d * d / (2 * EARTH_R) * (1 - REFRACTION_K)
|
||||||
|
ang = math.degrees(math.atan2(h - h0 - drop, d))
|
||||||
|
if ang > best:
|
||||||
|
best, best_d, best_h, best_pt = ang, d, h, (lat, lon)
|
||||||
|
d += 30.0 if d < 5000 else 60.0 if d < 20000 else 120.0
|
||||||
|
# Surface slope at the peak (rise per metre, east and north), central difference over 60 m.
|
||||||
|
la, lo = best_pt
|
||||||
|
dl, dn = 60.0 / 111_320.0, 60.0 / (111_320.0 * math.cos(math.radians(la)))
|
||||||
|
ge = (m.height(la, lo + dn) - m.height(la, lo - dn)) / 120.0
|
||||||
|
gn = (m.height(la + dl, lo) - m.height(la - dl, lo)) / 120.0
|
||||||
|
elev.append(round(best, 3))
|
||||||
|
dist.append(round(best_d / 1000, 2))
|
||||||
|
peak.append(round(best_h, 1))
|
||||||
|
east.append(round(ge, 3))
|
||||||
|
north.append(round(gn, 3))
|
||||||
|
doc = {
|
||||||
|
"viewpoint": {"lat": LAT, "lon": LON, "groundM": round(h0, 1)},
|
||||||
|
"stepDeg": STEP_DEG,
|
||||||
|
"radiusKm": RADIUS_KM,
|
||||||
|
"minKm": MIN_KM,
|
||||||
|
"curvature": f"drop d^2/2R*(1-{REFRACTION_K}), R={int(EARTH_R)} m",
|
||||||
|
"source": {
|
||||||
|
"dataset": "Copernicus DEM GLO-30 (COP-DEM_GLO-30-DGED), a DSM, EGM2008 heights, 1 arc-second grid",
|
||||||
|
"tiles": sorted(f"N{a:02d}_00_E{b:03d}_00" for a, b in m.tiles),
|
||||||
|
"tileBytesEach": "about 42-44 MB (Cloud Optimized GeoTIFF); nine tiles, about 380 MB, read once, not committed",
|
||||||
|
"url": "https://copernicus-dem-30m.s3.amazonaws.com/ (AWS Open Data, eu-central-1, no account)",
|
||||||
|
"licence": "Copernicus DEM licence: free of charge for any use incl. commercial, attribution required, no warranty "
|
||||||
|
"(https://spacedata.copernicus.eu/collections/copernicus-digital-elevation-model, Licencing)",
|
||||||
|
"attribution": "Contains modified Copernicus data: DEM © DLR e.V. 2010-2014 and © Airbus Defence and Space GmbH 2014-2018 "
|
||||||
|
"provided under COPERNICUS by the European Union and ESA; all rights reserved.",
|
||||||
|
"bakedBy": "scripts/bake-horizon.py",
|
||||||
|
},
|
||||||
|
"elevationDeg": elev,
|
||||||
|
"distanceKm": dist,
|
||||||
|
"peakHeightM": peak,
|
||||||
|
"slopeEast": east,
|
||||||
|
"slopeNorth": north,
|
||||||
|
}
|
||||||
|
out.write_text(json.dumps(doc, separators=(",", ":")) + "\n")
|
||||||
|
top = int(np.argmax(elev))
|
||||||
|
print(f"ground {h0:.1f} m; {az_count} azimuths; highest {elev[top]:.2f} deg at az {top * STEP_DEG}, {dist[top]} km, {peak[top]} m")
|
||||||
|
print(f"wrote {out} ({out.stat().st_size} bytes)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -1,10 +1,16 @@
|
||||||
// Bakes Midnight Sky and Noonday Sky from src/sky/sun.js into src/sky/baked.ts.
|
// Bakes Midnight Sky and Noonday Sky from src/sky/sun.js into src/sky/baked.ts, with the
|
||||||
// Run `npm run bake:sky` after changing sun.js, skyTokens.js or a moment.
|
// 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 { writeFileSync } from 'node:fs';
|
||||||
import { bakeSky } from '../src/sky/skyTokens.js';
|
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 out = new URL('../src/sky/baked.ts', import.meta.url);
|
||||||
const baked = { midnight: bakeSky('midnight'), noonday: bakeSky('noonday') };
|
const baked = {
|
||||||
|
midnight: bakeSky('midnight'),
|
||||||
|
noonday: bakeSky('noonday'),
|
||||||
|
scatter: bakeScatter(),
|
||||||
|
};
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
out,
|
out,
|
||||||
`// Generated by scripts/bake-sky.mjs from src/sky/sun.js. Do not edit.\n` +
|
`// Generated by scripts/bake-sky.mjs from src/sky/sun.js. Do not edit.\n` +
|
||||||
|
|
|
||||||
9
scripts/build-dev-preview.sh
Executable file
|
|
@ -0,0 +1,9 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build this checkout as the offline demo for the standing dev preview: base /void/rat-dev/, the pretend
|
||||||
|
# homeserver, scene tier $TIER (default full). Output: ./dist, ready for the shared publisher
|
||||||
|
# (gamecult-ops scripts/publish-mind-preview.sh dist rat-dev), which owns publishing. Publishes nothing.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
node scripts/sync-poses.mjs >/dev/null
|
||||||
|
VITE_PREVIEW_DEMO=1 CINNY_BASE=/void/rat-dev/ NODE_OPTIONS=--max_old_space_size=4096 nice -n 10 npx vite build
|
||||||
|
node scripts/preview-config.js "${TIER:-full}" --demo
|
||||||
56
scripts/buildSha.js
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
// The commit a bundle was built from, stamped into its index.html as <meta name="build-sha">, so a built
|
||||||
|
// folder says what it is without a build log: `grep -o 'build-sha" content="[^"]*' dist/index.html`.
|
||||||
|
// Where git governs the tree (REPO_DIR is a repository root) git decides: HEAD, suffixed -dirty when the tree has
|
||||||
|
// uncommitted changes, or `<BUILD_SHA>-mismatch` when an explicit BUILD_SHA names a different commit, so a stamp
|
||||||
|
// never vouches for code it does not hold; a git failure there is 'unknown'. Where git does not govern (a source
|
||||||
|
// archive), a full-sha BUILD_SHA stands, else 'unknown'.
|
||||||
|
import { execFileSync } from 'child_process';
|
||||||
|
import { dirname, resolve } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const REPO_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
|
||||||
|
// Vite writes vite.config.js.timestamp-*.mjs next to the config while it loads it; that file is the
|
||||||
|
// build's own, not source, so an UNTRACKED one must not mark the tree dirty. A tracked file of that name,
|
||||||
|
// or any other change, still does. Filtering status lines (not a pathspec) is what keeps it untracked-only.
|
||||||
|
const VITE_TEMP = /^\?\? (?:.*\/)?vite\.config\.[a-z]+\.timestamp-\d+-[0-9a-f]+\.mjs$/;
|
||||||
|
|
||||||
|
export const gitIn =
|
||||||
|
(cwd) =>
|
||||||
|
(...args) =>
|
||||||
|
execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
||||||
|
|
||||||
|
const FULL_SHA = /^[0-9a-f]{40}$/;
|
||||||
|
|
||||||
|
export const readBuildSha = (env = process.env, run = gitIn(REPO_DIR)) => {
|
||||||
|
const fromEnv = (env.BUILD_SHA ?? '').trim();
|
||||||
|
const explicit = FULL_SHA.test(fromEnv) ? fromEnv : null;
|
||||||
|
// Git governs this tree only when REPO_DIR is its root: a source archive unpacked inside some other
|
||||||
|
// repository has no git of its own, and that repository's HEAD says nothing about it.
|
||||||
|
let governed;
|
||||||
|
try {
|
||||||
|
governed = run('rev-parse', '--show-prefix').trim() === '';
|
||||||
|
} catch {
|
||||||
|
governed = false;
|
||||||
|
}
|
||||||
|
if (!governed) return explicit ?? 'unknown';
|
||||||
|
// Git governs and answers: from here a failure is 'unknown', never the bare explicit sha.
|
||||||
|
try {
|
||||||
|
const head = run('rev-parse', 'HEAD').trim();
|
||||||
|
if (!FULL_SHA.test(head)) return 'unknown';
|
||||||
|
if (explicit && explicit !== head) return `${explicit}-mismatch`;
|
||||||
|
const dirty = run('status', '--porcelain', '-uall', '--', '.')
|
||||||
|
.split('\n')
|
||||||
|
.some((l) => l.trim() !== '' && !VITE_TEMP.test(l));
|
||||||
|
return dirty ? `${head}-dirty` : head;
|
||||||
|
} catch {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildShaPlugin = (sha = readBuildSha()) => ({
|
||||||
|
name: 'ratatoskr-build-sha',
|
||||||
|
transformIndexHtml: () => [
|
||||||
|
{ tag: 'meta', attrs: { name: 'build-sha', content: sha }, injectTo: 'head-prepend' },
|
||||||
|
],
|
||||||
|
});
|
||||||
170
scripts/buildSha.test.js
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
import { execFileSync } from 'child_process';
|
||||||
|
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
import { buildShaPlugin, gitIn, readBuildSha } from './buildSha';
|
||||||
|
|
||||||
|
const SHA = 'b6b1d522f1c0a9d3e2b4c5d6e7f8091a2b3c4d5e';
|
||||||
|
const gitAt =
|
||||||
|
(head, status = '') =>
|
||||||
|
(cmd, ...args) =>
|
||||||
|
cmd === 'rev-parse' ? (args[0] === '--show-prefix' ? '\n' : `${head}\n`) : status;
|
||||||
|
|
||||||
|
describe('readBuildSha', () => {
|
||||||
|
it('reads the checkout it is built from', () => {
|
||||||
|
expect(readBuildSha({}, gitAt(SHA))).toBe(SHA);
|
||||||
|
});
|
||||||
|
it('marks a tree with uncommitted changes, so a stamp never vouches for code it does not hold', () => {
|
||||||
|
expect(readBuildSha({}, gitAt(SHA, ' M src/x.ts\n'))).toBe(`${SHA}-dirty`);
|
||||||
|
});
|
||||||
|
it('lets BUILD_SHA stand in where there is no git, and ignores a value that is not a full sha', () => {
|
||||||
|
const noGit = () => {
|
||||||
|
throw new Error('not a git repository');
|
||||||
|
};
|
||||||
|
expect(readBuildSha({ BUILD_SHA: SHA }, noGit)).toBe(SHA);
|
||||||
|
expect(readBuildSha({ BUILD_SHA: SHA }, gitAt(SHA))).toBe(SHA);
|
||||||
|
// a different HEAD is a mismatch, not a clean stamp of the explicit sha
|
||||||
|
expect(readBuildSha({ BUILD_SHA: SHA }, gitAt('c'.repeat(40)))).toBe(`${SHA}-mismatch`);
|
||||||
|
// a value that is not a lowercase full sha is ignored
|
||||||
|
['b6b1d52', SHA.toUpperCase(), `${SHA}0`].forEach((v) =>
|
||||||
|
expect(readBuildSha({ BUILD_SHA: v }, noGit)).toBe('unknown')
|
||||||
|
);
|
||||||
|
// git that answers but cannot say what the tree holds fails closed
|
||||||
|
const brokenStatus = (cmd, ...args) => {
|
||||||
|
if (cmd === 'status') throw new Error('index file smaller than expected');
|
||||||
|
return args[0] === '--show-prefix' ? '\n' : `${SHA}\n`;
|
||||||
|
};
|
||||||
|
expect(readBuildSha({ BUILD_SHA: SHA }, brokenStatus)).toBe('unknown');
|
||||||
|
expect(readBuildSha({}, brokenStatus)).toBe('unknown');
|
||||||
|
expect(readBuildSha({}, gitAt('nope'))).toBe('unknown');
|
||||||
|
// inside another repository's subfolder git does not govern: the explicit sha stands
|
||||||
|
const nested = (cmd, ...args) => (args[0] === '--show-prefix' ? 'vendor/ratatoskr/\n' : SHA);
|
||||||
|
expect(readBuildSha({ BUILD_SHA: SHA }, nested)).toBe(SHA);
|
||||||
|
expect(readBuildSha({}, nested)).toBe('unknown');
|
||||||
|
expect(readBuildSha({ BUILD_SHA: 'main' }, noGit)).toBe('unknown');
|
||||||
|
});
|
||||||
|
it("says 'unknown' rather than failing the build when git is absent", () => {
|
||||||
|
expect(
|
||||||
|
readBuildSha({}, () => {
|
||||||
|
throw new Error('spawn git ENOENT');
|
||||||
|
})
|
||||||
|
).toBe('unknown');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('readBuildSha against a real repository', () => {
|
||||||
|
const made = [];
|
||||||
|
afterEach(() => {
|
||||||
|
made.splice(0).forEach((d) => rmSync(d, { recursive: true, force: true }));
|
||||||
|
});
|
||||||
|
const repo = () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'buildsha-'));
|
||||||
|
made.push(dir);
|
||||||
|
const g = (...a) => execFileSync('git', a, { cwd: dir, stdio: 'ignore' });
|
||||||
|
g('init', '-q');
|
||||||
|
mkdirSync(join(dir, 'src'));
|
||||||
|
writeFileSync(join(dir, 'src', 'a.ts'), 'export {};\n');
|
||||||
|
g('add', '.');
|
||||||
|
g('-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'x');
|
||||||
|
return dir;
|
||||||
|
};
|
||||||
|
it("stays clean with vite's temp config file present, and is dirty with a stray source file", () => {
|
||||||
|
const dir = repo();
|
||||||
|
const run = gitIn(dir);
|
||||||
|
writeFileSync(join(dir, 'vite.config.js.timestamp-1700000000-abc123.mjs'), '');
|
||||||
|
expect(readBuildSha({}, run)).toMatch(/^[0-9a-f]{40}$/);
|
||||||
|
writeFileSync(join(dir, 'src', 'zz-stray.ts'), '');
|
||||||
|
expect(readBuildSha({}, run)).toMatch(/^[0-9a-f]{40}-dirty$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('readBuildSha dirty rules against a real repository', () => {
|
||||||
|
const made2 = [];
|
||||||
|
afterEach(() => {
|
||||||
|
made2.splice(0).forEach((d) => rmSync(d, { recursive: true, force: true }));
|
||||||
|
});
|
||||||
|
const TEMP = 'vite.config.js.timestamp-1700000000-abc123.mjs';
|
||||||
|
const repo2 = (tracked = []) => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'buildsha-'));
|
||||||
|
made2.push(dir);
|
||||||
|
const g = (...a) => execFileSync('git', a, { cwd: dir, stdio: 'ignore' });
|
||||||
|
g('init', '-q');
|
||||||
|
mkdirSync(join(dir, 'src'));
|
||||||
|
writeFileSync(join(dir, 'src', 'a.ts'), 'export {};\n');
|
||||||
|
tracked.forEach((f) => writeFileSync(join(dir, f), 'x\n'));
|
||||||
|
g('add', '.');
|
||||||
|
g('-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'x');
|
||||||
|
return {
|
||||||
|
dir,
|
||||||
|
head: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: dir, encoding: 'utf8' }).trim(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
it('does not let an explicit BUILD_SHA mask a dirty tree', () => {
|
||||||
|
const { dir, head } = repo2();
|
||||||
|
const run = gitIn(dir);
|
||||||
|
expect(readBuildSha({ BUILD_SHA: head }, run)).toBe(head);
|
||||||
|
writeFileSync(join(dir, 'src', 'zz-stray.ts'), '');
|
||||||
|
expect(readBuildSha({ BUILD_SHA: head }, run)).toBe(`${head}-dirty`);
|
||||||
|
});
|
||||||
|
it('lets git speak only for a tree whose root it is: an archive unpacked inside another repository keeps its explicit sha', () => {
|
||||||
|
const { dir } = repo2();
|
||||||
|
const inner = join(dir, 'vendor', 'ratatoskr');
|
||||||
|
mkdirSync(inner, { recursive: true });
|
||||||
|
writeFileSync(join(inner, 'x.ts'), '');
|
||||||
|
expect(readBuildSha({ BUILD_SHA: SHA }, gitIn(inner))).toBe(SHA);
|
||||||
|
expect(readBuildSha({}, gitIn(inner))).toBe('unknown');
|
||||||
|
});
|
||||||
|
it("notices an untracked .mjs that is not vite's temp file", () => {
|
||||||
|
const { dir, head } = repo2();
|
||||||
|
writeFileSync(join(dir, 'foo.mjs'), '');
|
||||||
|
expect(readBuildSha({}, gitIn(dir))).toBe(`${head}-dirty`);
|
||||||
|
rmSync(join(dir, 'foo.mjs'));
|
||||||
|
// only vite's exact shape is its own: vite.config.<ext>.timestamp-<digits>-<hex>.mjs
|
||||||
|
[
|
||||||
|
'vite.config.js.timestamp-1-ab.mjs.bak',
|
||||||
|
'vite.config.js.timestamp-x-ab.mjs',
|
||||||
|
'vite.config.js.timestamp-1-zz.mjs',
|
||||||
|
'vite.config.a.b.timestamp-1-ab.mjs',
|
||||||
|
'vite.config.js.timestamp-1-ab-c.mjs',
|
||||||
|
].forEach((name) => {
|
||||||
|
writeFileSync(join(dir, name), '');
|
||||||
|
expect(readBuildSha({}, gitIn(dir)), name).toBe(`${head}-dirty`);
|
||||||
|
rmSync(join(dir, name));
|
||||||
|
});
|
||||||
|
expect(readBuildSha({}, gitIn(dir))).toBe(head);
|
||||||
|
});
|
||||||
|
it('treats a stray source file as dirty even beside the vite temp file, in the root or a subfolder', () => {
|
||||||
|
const { dir, head } = repo2();
|
||||||
|
const run = gitIn(dir);
|
||||||
|
writeFileSync(join(dir, TEMP), '');
|
||||||
|
writeFileSync(join(dir, 'src', TEMP), '');
|
||||||
|
expect(readBuildSha({}, run)).toBe(head);
|
||||||
|
writeFileSync(join(dir, 'src', 'zz-stray.ts'), '');
|
||||||
|
expect(readBuildSha({}, run)).toBe(`${head}-dirty`);
|
||||||
|
});
|
||||||
|
it('marks a tracked file of the temp name dirty once it changes', () => {
|
||||||
|
const { dir, head } = repo2([TEMP]);
|
||||||
|
const run = gitIn(dir);
|
||||||
|
expect(readBuildSha({}, run)).toBe(head);
|
||||||
|
writeFileSync(join(dir, TEMP), 'changed\n');
|
||||||
|
expect(readBuildSha({}, run)).toBe(`${head}-dirty`);
|
||||||
|
});
|
||||||
|
it('marks an untracked file in a new folder dirty (status lists files, not the folder)', () => {
|
||||||
|
const { dir, head } = repo2();
|
||||||
|
mkdirSync(join(dir, 'new'));
|
||||||
|
writeFileSync(join(dir, 'new', TEMP), '');
|
||||||
|
expect(readBuildSha({}, gitIn(dir))).toBe(head);
|
||||||
|
writeFileSync(join(dir, 'new', 'b.ts'), '');
|
||||||
|
expect(readBuildSha({}, gitIn(dir))).toBe(`${head}-dirty`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildShaPlugin', () => {
|
||||||
|
it("hands vite a meta tag for the page's head carrying the sha", () => {
|
||||||
|
const plugin = buildShaPlugin(SHA);
|
||||||
|
expect(plugin.transformIndexHtml()).toEqual([
|
||||||
|
{ tag: 'meta', attrs: { name: 'build-sha', content: SHA }, injectTo: 'head-prepend' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
// Regenerates preview-demo/poses from the cast-art poses: PNG thumbnails sized for the
|
// 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).
|
// portrait's displayed width x device pixel ratio: the bubble portrait is 190 CSS px on desktop and
|
||||||
|
// min(38vw, 160px) on a phone (layout.css.ts), the desk booth 168 / 88 px (DeskView.css.ts).
|
||||||
// Dependency-free: decodes 8-bit RGBA PNGs, area-averages in premultiplied alpha, writes a
|
// 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]
|
// 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 { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||||
|
|
@ -7,7 +8,7 @@ import { dirname, join } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { inflateSync, deflateSync, crc32 } from 'node:zlib';
|
import { inflateSync, deflateSync, crc32 } from 'node:zlib';
|
||||||
|
|
||||||
const WIDTH = 480; // phone portrait: min(50vw, 210px) x DPR 2.5; art is never upscaled
|
const WIDTH = 480; // about the 190px desktop portrait x DPR 2.5; art is never upscaled
|
||||||
const src = process.argv[2] ?? '/opt/commons/cast-art/poses';
|
const src = process.argv[2] ?? '/opt/commons/cast-art/poses';
|
||||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
const outDir = join(root, 'preview-demo/poses');
|
const outDir = join(root, 'preview-demo/poses');
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,21 @@
|
||||||
// Preview builds: set the background scene tier in dist/config.json (default 'low'); config.json
|
// Preview builds: set the background scene tier in dist/config.json (default 'low'); config.json
|
||||||
// in the repo stays 'off'. With --demo it also switches on the pretend homeserver (src/preview-demo):
|
// in the repo is the live chat.gamecult.org config (style ridge), and previews pin the style too, ridge unless
|
||||||
// previewDemo true, and the pretend server as the only homeserver. Usage:
|
// given. Previews are static directories with no SPA fallback, so they also switch
|
||||||
// node scripts/preview-config.js [tier] [--demo]
|
// 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 fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const demo = args.includes('--demo');
|
const demo = args.includes('--demo');
|
||||||
const tier = args.find((a) => !a.startsWith('--')) ?? 'low';
|
const [tier = 'low', style = 'ridge'] = args.filter((a) => !a.startsWith('--'));
|
||||||
const file = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'dist', 'config.json');
|
const file = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'dist', 'config.json');
|
||||||
const config = JSON.parse(fs.readFileSync(file, 'utf8'));
|
const config = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||||
config.backgroundScene = { ...config.backgroundScene, tier };
|
config.hashRouter = { enabled: true, basename: '/' };
|
||||||
|
config.backgroundScene = { ...config.backgroundScene, tier, style };
|
||||||
if (demo) {
|
if (demo) {
|
||||||
config.previewDemo = true;
|
config.previewDemo = true;
|
||||||
config.defaultHomeserver = 0;
|
config.defaultHomeserver = 0;
|
||||||
|
|
@ -25,4 +29,6 @@ if (demo) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
fs.writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`);
|
fs.writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`);
|
||||||
console.log(`${file}: backgroundScene.tier = ${tier}${demo ? ', previewDemo on' : ''}`);
|
console.log(
|
||||||
|
`${file}: backgroundScene.tier = ${tier}, style = ${style}${demo ? ', previewDemo on' : ''}`
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import { MatrixError } from 'matrix-js-sdk';
|
import { MatrixError } from 'matrix-js-sdk';
|
||||||
import { Cursor } from '../plugins/text-area';
|
import { Cursor } from '../plugins/text-area';
|
||||||
|
import { PhoneHit } from '../styles/PhoneHit.css';
|
||||||
import { syntaxErrorPosition } from '../utils/dom';
|
import { syntaxErrorPosition } from '../utils/dom';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
||||||
import { Page, PageHeader } from './page';
|
import { Page, PageHeader } from './page';
|
||||||
|
|
@ -294,7 +295,7 @@ export function AccountDataEditor({
|
||||||
</Chip>
|
</Chip>
|
||||||
</Box>
|
</Box>
|
||||||
<Box shrink="No">
|
<Box shrink="No">
|
||||||
<IconButton onClick={requestClose} variant="Surface">
|
<IconButton onClick={requestClose} variant="Surface" className={PhoneHit}>
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||