Compare commits
20
.github/workflows/build-pull-request.yml
vendored
|
|
@ -4,14 +4,12 @@ on:
|
|||
pull_request:
|
||||
types: ['opened', 'synchronize']
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-pr-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-pull-request:
|
||||
name: Build pull request
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PR_NUMBER: ${{github.event.number}}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
|
@ -26,3 +24,17 @@ jobs:
|
|||
env:
|
||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||
run: npm run build
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: preview
|
||||
path: dist
|
||||
retention-days: 1
|
||||
- name: Save pr number
|
||||
run: echo ${PR_NUMBER} > ./pr.txt
|
||||
- name: Upload pr number
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: pr
|
||||
path: ./pr.txt
|
||||
retention-days: 1
|
||||
|
|
|
|||
36
.github/workflows/cla.yml
vendored
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
name: 'CLA Assistant'
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_target:
|
||||
types: [opened, closed, synchronize]
|
||||
|
||||
jobs:
|
||||
CLAssistant:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 'CLA Assistant'
|
||||
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
|
||||
# Beta Release
|
||||
uses: cla-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# the below token should have repo scope and must be manually added by you in the repository's secret
|
||||
PERSONAL_ACCESS_TOKEN: ${{ secrets.CLA_PAT }}
|
||||
with:
|
||||
path-to-signatures: 'signatures.json'
|
||||
path-to-document: 'https://github.com/cinnyapp/cla/blob/main/cla.md' # e.g. a CLA or a DCO document
|
||||
# branch should not be protected
|
||||
branch: 'main'
|
||||
allowlist: ajbura,bot*
|
||||
|
||||
#below are the optional inputs - If the optional inputs are not given, then default values will be taken
|
||||
remote-organization-name: cinnyapp
|
||||
remote-repository-name: cla
|
||||
#create-file-commit-message: 'For example: Creating file for storing CLA Signatures'
|
||||
#signed-commit-message: 'For example: $contributorName has signed the CLA in #$pullRequestNo'
|
||||
#custom-notsigned-prcomment: 'pull request comment with Introductory message to ask new contributors to sign'
|
||||
#custom-pr-sign-comment: 'The signature to be committed in order to sign the CLA'
|
||||
#custom-allsigned-prcomment: 'pull request comment when all contributors has signed, defaults to **CLA Assistant Lite bot** All Contributors have signed the CLA.'
|
||||
#lock-pullrequest-aftermerge: false - if you don't want this bot to automatically lock the pull request after merging (default - true)
|
||||
#use-dco-flag: true - If you are using DCO instead of CLA
|
||||
60
.github/workflows/deploy-pull-request.yml
vendored
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
name: Deploy PR to Netlify
|
||||
run-name: "Deploy PR to Netlify (${{ github.event.workflow_run.head_branch }})"
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Build pull request"]
|
||||
types: [completed]
|
||||
|
||||
jobs:
|
||||
deploy-pull-request:
|
||||
name: Deploy pull request
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
steps:
|
||||
- name: Download pr number
|
||||
uses: dawidd6/action-download-artifact@d63b86af1b34672e53c440b1b83979861906bad7 # v24
|
||||
with:
|
||||
workflow: ${{ github.event.workflow.id }}
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
name: pr
|
||||
- name: Validate and output pr number
|
||||
id: pr
|
||||
run: |
|
||||
PR_ID=$(<pr.txt)
|
||||
if ! [[ "${PR_ID}" =~ ^[0-9]+$ ]]; then
|
||||
echo "::error::pr.txt contains non-numeric content: ${PR_ID}"
|
||||
exit 1
|
||||
fi
|
||||
echo "id=${PR_ID}" >> "${GITHUB_OUTPUT}"
|
||||
- name: Download artifact
|
||||
uses: dawidd6/action-download-artifact@d63b86af1b34672e53c440b1b83979861906bad7 # v24
|
||||
with:
|
||||
workflow: ${{ github.event.workflow.id }}
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
name: preview
|
||||
path: dist
|
||||
- name: Deploy to Netlify
|
||||
id: netlify
|
||||
uses: nwtgck/actions-netlify@d22a32a27c918fe470bbc562e984f80ec48c2668 # v4.0.0
|
||||
with:
|
||||
publish-dir: dist
|
||||
deploy-message: "Deploy PR ${{ steps.pr.outputs.id }}"
|
||||
alias: ${{ steps.pr.outputs.id }}
|
||||
# These don't work because we're in workflow_run
|
||||
enable-pull-request-comment: false
|
||||
enable-commit-comment: false
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN_PR }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID_PR_CINNY }}
|
||||
timeout-minutes: 1
|
||||
- name: Comment preview on PR
|
||||
uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
|
||||
with:
|
||||
number: ${{ steps.pr.outputs.id }}
|
||||
message: |
|
||||
Preview: ${{ steps.netlify.outputs.deploy-url }}
|
||||
⚠️ Exercise caution. Use test accounts. ⚠️
|
||||
5
.github/workflows/docker-pr.yml
vendored
|
|
@ -5,10 +5,7 @@ on:
|
|||
paths:
|
||||
- 'Dockerfile'
|
||||
- '.github/workflows/docker-pr.yml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-pr-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
- '.github/workflows/prod-deploy.yml'
|
||||
|
||||
jobs:
|
||||
docker-build:
|
||||
|
|
|
|||
4
.github/workflows/lockfile.yml
vendored
|
|
@ -5,10 +5,6 @@ on:
|
|||
paths:
|
||||
- 'package-lock.json'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-pr-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
lockfile_changes:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
39
.github/workflows/netlify-dev.yml
vendored
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
name: Deploy to Netlify (dev)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
|
||||
jobs:
|
||||
deploy-to-netlify:
|
||||
name: Deploy to Netlify
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version-file: ".node-version"
|
||||
package-manager-cache: false
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Build app
|
||||
env:
|
||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||
run: npm run build
|
||||
- name: Deploy to Netlify
|
||||
uses: nwtgck/actions-netlify@d22a32a27c918fe470bbc562e984f80ec48c2668 # v4.0.0
|
||||
with:
|
||||
publish-dir: dist
|
||||
deploy-message: 'Dev deploy ${{ github.sha }}'
|
||||
enable-commit-comment: false
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
production-deploy: true
|
||||
github-deployment-environment: nightly
|
||||
github-deployment-description: 'Nightly deployment on each commit to dev branch'
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID_DEV }}
|
||||
timeout-minutes: 1
|
||||
15
.github/workflows/pr-title.yml
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
name: Check PR title
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
100
.github/workflows/prod-deploy.yml
vendored
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
name: Production deploy
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
deploy-and-tarball:
|
||||
name: Netlify deploy and tarball
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version-file: '.node-version'
|
||||
package-manager-cache: false
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Build app
|
||||
env:
|
||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||
run: npm run build
|
||||
- name: Deploy to Netlify
|
||||
uses: nwtgck/actions-netlify@d22a32a27c918fe470bbc562e984f80ec48c2668 # v4.0.0
|
||||
with:
|
||||
publish-dir: dist
|
||||
deploy-message: 'Prod deploy ${{ github.ref_name }}'
|
||||
enable-commit-comment: false
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
production-deploy: true
|
||||
github-deployment-environment: stable
|
||||
github-deployment-description: 'Stable deployment on each release'
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID_APP }}
|
||||
timeout-minutes: 1
|
||||
- name: Get version from tag
|
||||
id: vars
|
||||
run: echo "tag=${GITHUB_REF#refs/*/}" >> $GITHUB_OUTPUT
|
||||
- name: Create tar.gz
|
||||
run: tar -czvf cinny-${{ steps.vars.outputs.tag }}.tar.gz dist
|
||||
- name: Sign tar.gz
|
||||
run: |
|
||||
echo '${{ secrets.GNUPG_KEY }}' | gpg --batch --import
|
||||
# Sadly a few lines in the private key match a few lines in the public key,
|
||||
# As a result just --export --armor gives us a few lines replaced with ***
|
||||
# making it useless for importing the signing key. Instead, we dump it as
|
||||
# non-armored and hex-encode it so that its printable.
|
||||
echo "PGP Signing key, in raw PGP format in hex. Import with cat ... | xxd -r -p - | gpg --import"
|
||||
gpg --export | xxd -p
|
||||
echo '${{ secrets.GNUPG_PASSPHRASE }}' | gpg --batch --yes --pinentry-mode loopback --passphrase-fd 0 --armor --detach-sign cinny-${{ steps.vars.outputs.tag }}.tar.gz
|
||||
- name: Upload tagged release
|
||||
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
files: |
|
||||
cinny-${{ steps.vars.outputs.tag }}.tar.gz
|
||||
cinny-${{ steps.vars.outputs.tag }}.tar.gz.asc
|
||||
|
||||
publish-image:
|
||||
name: Push Docker image to Docker Hub, GHCR
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4.3.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
- name: Login to Docker Hub #Do not update this action from a outside PR
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Login to the Github Container registry #Do not update this action from a outside PR
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Extract metadata (tags, labels) for Docker, GHCR
|
||||
id: meta
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_USERNAME }}/cinny
|
||||
ghcr.io/${{ github.repository }}
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
46
.github/workflows/rat-dev-preview.yml
vendored
|
|
@ -1,46 +0,0 @@
|
|||
name: Rat dev preview
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['dev']
|
||||
|
||||
# One run per ref wins: a newer push to dev cancels the older queued or running one, so the run
|
||||
# that builds rat-dev-dist is always the newest. Pull-request runs are other workflows and other
|
||||
# groups; they never cancel it.
|
||||
concurrency:
|
||||
group: rat-dev-preview
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build dev preview
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version-file: ".node-version"
|
||||
package-manager-cache: false
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Build app
|
||||
env:
|
||||
CINNY_BASE: /void/rat-dev/
|
||||
VITE_PREVIEW_DEMO: '1'
|
||||
NODE_OPTIONS: '--max_old_space_size=3072'
|
||||
run: npm run build
|
||||
- name: Set background scene tier and the pretend homeserver
|
||||
run: node scripts/preview-config.js low --demo
|
||||
- name: Add the demo pose art
|
||||
run: mkdir -p dist/public/poses && cp -r preview-demo/poses/. dist/public/poses/
|
||||
- name: Upload artifact
|
||||
# v3 protocol: the v4 client keeps only the origin of ACTIONS_RESULTS_URL, dropping this forge's /forge subpath
|
||||
# (nginx 404 page, runs 170-171), and upstream v4+ refuses non-GitHub servers outright (run 169).
|
||||
uses: forgejo/upload-artifact@v3
|
||||
with:
|
||||
name: rat-dev-dist
|
||||
path: dist
|
||||
retention-days: 3
|
||||
5
.gitignore
vendored
|
|
@ -4,7 +4,4 @@ node_modules
|
|||
devAssets
|
||||
|
||||
.DS_Store
|
||||
.idea
|
||||
# Pose art is synced from the cast-art catalogue (npm run sync:poses), not committed.
|
||||
public/poses/*
|
||||
!public/poses/.gitkeep
|
||||
.idea
|
||||
|
|
@ -2,6 +2,4 @@ dist
|
|||
node_modules
|
||||
package.json
|
||||
package-lock.json
|
||||
LICENSE
|
||||
harness
|
||||
src/app/components/message/pose/catalogue.json
|
||||
LICENSE
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
# Contributing to Ratatoskr
|
||||
# Contributing to Cinny
|
||||
|
||||
First off, thanks for taking the time to contribute! ❤️
|
||||
|
||||
This document describes the process of contributing to Ratatoskr. It is intended
|
||||
This document describes the process of contributing to Cinny. It is intended
|
||||
for anyone considering opening an **issue**, **discussion** or **pull request**.
|
||||
For people who are interested in developing Cinny and technical details behind
|
||||
it, please check out our ["Developing Cinny"](HACKING.md) document as well.
|
||||
|
|
@ -24,7 +24,7 @@ explain what your changes do and how they interact with the greater system, do n
|
|||
|
||||
## AI Usage
|
||||
|
||||
The Cinny project strictly does not allow any AI generated code. Any pull request containing it will be rejected. **This is very important.**
|
||||
The Cinny project stictly does not allow any AI generated code. Any pull request containing it will be rejected. **This is very important.**
|
||||
|
||||
## Quick Guide
|
||||
|
||||
|
|
@ -35,7 +35,7 @@ The Cinny project strictly does not allow any AI generated code. Any pull reques
|
|||
> When contributing to this project, you must agree that you have authored 100%
|
||||
> of the content, that you have the necessary rights to the content and that
|
||||
> the content you contribute may be provided under the project license. You will
|
||||
> also be asked to [sign the CLA] ([why CLA?]) upon submitting your pull request.
|
||||
> also be asked to [sign the CLA] ([why CLA?]) upon submiting your pull request.
|
||||
|
||||
[All issues are actionable](#issues-are-actionable). Pick one and start
|
||||
working on it. Thank you. If you need help or guidance, comment on the issue.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Developing Ratatoskr
|
||||
# Developing Cinny
|
||||
|
||||
> [!TIP]
|
||||
> We recommend using a version manager as versions change very quickly.
|
||||
|
|
@ -36,13 +36,13 @@ source and serves it with Nginx on port 80. To use this locally, you can
|
|||
build the container like so:
|
||||
|
||||
```
|
||||
docker build -t ratatoskr:latest .
|
||||
docker build -t cinny:latest .
|
||||
```
|
||||
|
||||
You can then run the container you've built with a command similar to this:
|
||||
|
||||
```
|
||||
docker run -p 8080:80 ratatoskr:latest
|
||||
docker run -p 8080:80 cinny:latest
|
||||
```
|
||||
|
||||
This will forward your `localhost` port 8080 to the container's port 80.
|
||||
|
|
|
|||
140
README.md
|
|
@ -1,43 +1,125 @@
|
|||
# Ratatoskr
|
||||
# Cinny
|
||||
<p>
|
||||
<a href="https://github.com/ajbura/cinny/releases">
|
||||
<img alt="GitHub release downloads" src="https://img.shields.io/github/downloads/ajbura/cinny/total?logo=github&style=social"></a>
|
||||
<a href="https://hub.docker.com/r/ajbura/cinny">
|
||||
<img alt="DockerHub downloads" src="https://img.shields.io/docker/pulls/ajbura/cinny?logo=docker&style=social"></a>
|
||||
<a href="https://fosstodon.org/@cinnyapp">
|
||||
<img alt="Follow on Mastodon" src="https://img.shields.io/mastodon/follow/106845779685925461?domain=https%3A%2F%2Ffosstodon.org&logo=mastodon&style=social"></a>
|
||||
<a href="https://twitter.com/intent/follow?screen_name=cinnyapp">
|
||||
<img alt="Follow on Twitter" src="https://img.shields.io/twitter/follow/cinnyapp?logo=twitter&style=social"></a>
|
||||
<a href="https://cinny.in/#sponsor">
|
||||
<img alt="Sponsor Cinny" src="https://img.shields.io/opencollective/all/cinny?logo=opencollective&style=social"></a>
|
||||
</p>
|
||||
|
||||
GameCult's Matrix chat client, a fork of [Cinny](https://github.com/cinnyapp/cinny).
|
||||
It opens on GameCult's estate (spaces, project rooms grouped by steward, Minds,
|
||||
and your own place in it) instead of an invite list. Mobile first.
|
||||
A Matrix client focusing primarily on simple, elegant and secure interface.
|
||||
The main goal is to have an instant messaging application that is easy on
|
||||
people and has a modern touch.
|
||||
- [Roadmap](https://github.com/orgs/cinnyapp/projects/1)
|
||||
- [Contributing](./CONTRIBUTING.md)
|
||||
|
||||
**Live now:** the Cinny engine (matrix-js-sdk, Rust crypto, sync) under a
|
||||
Ratatoskr UI: branding, GameCult theme, estate navigation, pose portraits
|
||||
(opt-in setting), a background scene and soundscape (opt-in, off by default).
|
||||
**Planned:** see [`docs/gamecult/ratatoskr-target.md`](docs/gamecult/ratatoskr-target.md)
|
||||
for the target and cut sequence. Internal names that are protocol or stored
|
||||
state (`cinny_*` localStorage keys, the `in.cinny.room.power_level_tags` event
|
||||
type, `CINNY_BASE`) are unchanged on purpose.
|
||||
> [!IMPORTANT]
|
||||
We are currently in the process of [replacing] the matrix-js-sdk with our
|
||||
own SDK. As a result, we will not be accepting any pull requests until
|
||||
further notice. Thank you for your understanding.
|
||||
|
||||
Entry points: `src/app/` (client UI), `src/app/features/scene/` (background
|
||||
scene), `src/app/features/soundscape/` (audio socket), `harness/` (estate
|
||||
drive scripts), `scripts/gen-brand.mjs` (regenerates the logo and icons).
|
||||
[replacing]: https://github.com/cinnyapp/cinny/issues/257#issuecomment-3714406704
|
||||
|
||||
## Upstream
|
||||
Ratatoskr is based on Cinny by Ajay Bura and contributors, licensed AGPL-3.0.
|
||||
Cinny's own README, desktop app and releases live at
|
||||
<https://github.com/cinnyapp/cinny>. We do not ship its releases or images.
|
||||
<img align="center" src="https://raw.githubusercontent.com/cinnyapp/cinny-site/main/assets/preview2-light.png" height="380">
|
||||
|
||||
## Developing
|
||||
See [HACKING.md](HACKING.md). Contribution notes are in [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
## Getting started
|
||||
The web app is available at [app.cinny.in] and gets updated on each new
|
||||
release. The `dev` branch is continuously deployed at [dev.cinny.in]
|
||||
but keep in mind that it could have things broken.
|
||||
|
||||
You can also download our desktop app from the [cinny-desktop repository].
|
||||
|
||||
[app.cinny.in]: https://app.cinny.in
|
||||
[dev.cinny.in]: https://dev.cinny.in
|
||||
[cinny-desktop repository]: https://github.com/cinnyapp/cinny-desktop
|
||||
|
||||
## Contributing and Developing
|
||||
|
||||
If you have any ideas, issues, etc. regarding Cinny, or would like to
|
||||
contribute to Cinny through pull requests, please check out our
|
||||
["Contributing to Cinny"](CONTRIBUTING.md) document. Those who would like
|
||||
to get involved with Cinny's development as well should also read the
|
||||
["Developing Cinny"](HACKING.md) document for more technical details.
|
||||
|
||||
## Self-hosting
|
||||
Build with `npm ci && npm run build` and serve `dist/` with your webserver.
|
||||
To host Cinny on your own, simply download the tarball from
|
||||
[GitHub releases], and serve the files from `dist/` using your preferred
|
||||
webserver. Alternatively, you can just pull the docker image from
|
||||
[DockerHub] or [GitHub Container Registry].
|
||||
|
||||
* The default homeservers and explore pages are defined in [`config.json`](config.json).
|
||||
* Serving the assets needs redirects to `index.html`. Examples:
|
||||
[netlify](netlify.toml), [nginx](contrib/nginx/ratatoskr.domain.tld.conf),
|
||||
[caddy](contrib/caddy/caddyfile). Without them you can
|
||||
[enable hash routing](config.json) (`hashRouter`); URLs then carry `/#/`.
|
||||
* To deploy on a subdirectory, rebuild with the `base` path set, either in
|
||||
[`build.config.ts`](build.config.ts) or through `CINNY_BASE`
|
||||
(for example `CINNY_BASE=/app/ npm run build`).
|
||||
|
||||
* You need to set up redirects to serve the assests. Example configurations;
|
||||
[netlify](netlify.toml), [nginx](contrib/nginx/cinny.domain.tld.conf),
|
||||
[caddy](contrib/caddy/caddyfile).
|
||||
* If you have trouble configuring redirects you can
|
||||
[enable hash routing](config.json#L35) — the url in the browser will have
|
||||
a `/#/` between the domain and open channel (ie. `app.cinny.in/#/home/`
|
||||
instead of `app.cinny.in/home/`) but you won't have to configure your webserver.
|
||||
|
||||
* To deploy on subdirectory, you need to rebuild the app youself after
|
||||
updating the `base` path in [`build.config.ts`](build.config.ts).
|
||||
* For example, if you want to deploy on `https://cinny.in/app`, then
|
||||
set `base: '/app'`.
|
||||
|
||||
[GitHub releases]: https://github.com/cinnyapp/cinny/releases/latest
|
||||
[DockerHub]: https://hub.docker.com/r/ajbura/cinny
|
||||
[GitHub Container Registry]: https://github.com/cinnyapp/cinny/pkgs/container/cinny
|
||||
|
||||
<details><summary><b>PGP Public Key to verify tarball</b></summary>
|
||||
|
||||
```
|
||||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
|
||||
mQGNBGJw/g0BDAC8qQeLqDMzYzfPyOmRlHVEoguVTo+eo1aVdQH2X7OELdjjBlyj
|
||||
6d6c1adv/uF2g83NNMoQY7GEeHjRnXE4m8kYSaarb840pxrYUagDc0dAbJOGaCBY
|
||||
FKTo7U1Kvg0vdiaRuus0pvc1NVdXSxRNQbFXBSwduD+zn66TI3HfcEHNN62FG1cE
|
||||
K1jWDwLAU0P3kKmj8+CAc3h9ZklPu0k/+t5bf/LJkvdBJAUzGZpehbPL5f3u3BZ0
|
||||
leZLIrR8uV7PiV5jKFahxlKR5KQHld8qQm+qVhYbUzpuMBGmh419I6UvTzxuRcvU
|
||||
Frn9ttCEzV55Y+so4X2e4ZnB+5gOnNw+ecifGVdj/+UyWnqvqqDvLrEjjK890nLb
|
||||
Pil4siecNMEpiwAN6WSmKpWaCwQAHEGDVeZCc/kT0iYfj5FBcsTVqWiO6eaxkUlm
|
||||
jnulqWqRrlB8CJQQvih/g//uSEBdzIibo+ro+3Jpe120U/XVUH62i9HoRQEm6ADG
|
||||
4zS5hIq4xyA8fL8AEQEAAbQdQ2lubnlBcHAgPGNpbm55YXBwQGdtYWlsLmNvbT6J
|
||||
AdQEEwEIAD4CGwMFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AWIQSRri2MHidaaZv+
|
||||
vvuUMwx6UK/M8wUCZqEDwAUJFvwIswAKCRCUMwx6UK/M877qC/4lxXOQIoWnLLkK
|
||||
YiRCTkGsH6NdxgeYr6wpXT4xuQ45ZxCytwHpOGQmO/5up5961TxWW8D1frRIJHjj
|
||||
AZGoRCL3EKEuY8nt3D99fpf3DvZrs1uoVAhiyn737hRlZAg+QsJheeGCmdSJ0hX5
|
||||
Yud8SE+9zxLS1+CEjMrsUd/RGre/phme+wNXfaHfREAC9ewolgVChPIbMxG2f+vs
|
||||
K8Xv52BFng7ta9fgsl1XuOjpuaSbQv6g+4ONk/lxKF0SmnhEGM3dmIYPONxW47Yf
|
||||
atnIjRra/YhPTNwrNBGMmG4IFKaOsMbjW/eakjWTWOVKKJNBMoDdRcYYWIMCpLy8
|
||||
AQUrMtQEsHSnqCwrw818S5A6rrhcfVGk36RGm0nOy6LS5g5jmqaYsvbCcBGY9B2c
|
||||
SUAVNm17oo7TtEajk8hcSXoZod1t++pyjcVKEmSn3nFK7v5m3V+cPhNTxZMK459P
|
||||
3x1Ucqj/kTqrxKw6s2Uknuk0ajmw0ljV+BQwgL6maguo9BKgCNW5AY0EYnD+DQEM
|
||||
ANOu/d6ZMF8bW+Df9RDCUQKytbaZfa+ZbIHBus7whCD/SQMOhPKntv3HX7SmMCs+
|
||||
5i27kJMu4YN623JCS7hdCoXVO1R5kXCEcneW/rPBMDutaM472YvIWMIqK9Wwl5+0
|
||||
Piu2N+uTkKhe9uS2u7eN+Khef3d7xfjGRxoppM+xI9dZO+jhYiy8LuC0oBohTjJq
|
||||
QPqfGDpowBwRkkOsGz/XVcesJ1Pzg4bKivTS9kZjZSyT9RRSY8As0sVUN57AwYul
|
||||
s1+eh00n/tVpi2Jj9pCm7S0csSXvXj8v2OTdK1jt4YjpzR0/rwh4+/xlOjDjZEqH
|
||||
vMPhpzpbgnwkxZ3X8BFne9dJ3maC5zQ3LAeCP5m1W0hXzagYhfyjo74slJgD1O8c
|
||||
LDf2Oxc5MyM8Y/UK497zfqSPfgT3NhQmhHzk83DjXw3I6Z3A3U+Jp61w0eBRI1nx
|
||||
H1UIG+gldcAKUTcfwL0lghoT3nmi9JAbvek0Smhz00Bbo8/dx8vwQRxDUxlt7Exx
|
||||
NwARAQABiQG8BBgBCAAmAhsMFiEEka4tjB4nWmmb/r77lDMMelCvzPMFAmahA9IF
|
||||
CRb8CMUACgkQlDMMelCvzPPQgQv/d5/z+fxgKqgfhQX+V49X4WgTVxZ/CzztDoJ1
|
||||
XAq1dzTNEy8AFguXIo6eVXPSpMxec7ZreN3+UPQBnCf3eR5YxWNYOYKmk0G4E8D2
|
||||
KGUJept7TSA42/8N2ov6tToXFg4CgzKZj0fYLwgutly7K8eiWmSU6ptaO8aEQBHB
|
||||
gTGIOO3h6vJMGVycmoeRnHjv4wV84YWSVFSoJ7cY0he4Z9UznJBbE/KHZjrkXsPo
|
||||
N+Gg5lDuOP5xjKzM5SogV9lhxBAhMWAg3URUF15yruZBiA8uV1FOK8sal/9C1G7V
|
||||
M6ygA6uOZqXlZtcdA94RoSsW2pZ9eLVPsxz2B3Zko7tu11MpNP/wYmfGTI3KxZBj
|
||||
n/eodvwjJSgHpGOFSmbNzvPJo3to5nNlp7wH1KxIMc6Uuu9hgfDfwkFZgV2bnFIa
|
||||
Q6gyF548Ub48z7Dz83+WwLgbX19ve4oZx+dqSdczP6ILHRQomtrzrkkP2LU52oI5
|
||||
mxFo+ioe/ABCufSmyqFye0psX3Sp
|
||||
=WtqZ
|
||||
-----END PGP PUBLIC KEY BLOCK-----
|
||||
```
|
||||
</details>
|
||||
|
||||
## License
|
||||
Ratatoskr is a fork of the Cinny project
|
||||
Cinny Project
|
||||
Copyright © 2024–present Ajay Bura
|
||||
https://cinny.in
|
||||
|
||||
|
|
|
|||
21
config.json
|
|
@ -2,23 +2,30 @@
|
|||
"defaultHomeserver": 1,
|
||||
"homeserverList": ["converser.eu", "matrix.org", "mozilla.org", "unredacted.org", "xmr.se"],
|
||||
"allowCustomHomeservers": true,
|
||||
|
||||
"featuredCommunities": {
|
||||
"openAsDefault": false,
|
||||
"spaces": ["#gamecult:gamecult.org"],
|
||||
"spaces": [
|
||||
"#cinny:matrix.org",
|
||||
"#community:matrix.org",
|
||||
"#space:unredacted.org",
|
||||
"#librewolf-community:matrix.org",
|
||||
"#stickers-and-emojis:tastytea.de",
|
||||
"#videogames:waywardinn.com",
|
||||
"#science-space:matrix.org",
|
||||
"#libregaming-games:tchncs.de",
|
||||
"#mathematics-on:matrix.org"
|
||||
],
|
||||
"rooms": [
|
||||
"#tuwunel:grin.hu",
|
||||
"#free-software-movement:matrix.org",
|
||||
"#freesoftware:matrix.org",
|
||||
"#gentoo:matrix.org"
|
||||
],
|
||||
"servers": ["activematrixrooms.com", "matrixrooms.info", "matrix.org", "mozilla.org", "unredacted.org"]
|
||||
"servers": ["matrixrooms.info", "matrix.org", "mozilla.org", "unredacted.org"]
|
||||
},
|
||||
|
||||
"hashRouter": {
|
||||
"enabled": false,
|
||||
"basename": "/"
|
||||
},
|
||||
|
||||
"backgroundScene": {
|
||||
"tier": "off"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# more info: https://caddyserver.com/docs/caddyfile/patterns#single-page-apps-spas
|
||||
ratatoskr.domain.tld {
|
||||
root * /path/to/ratatoskr/dist
|
||||
cinny.domain.tld {
|
||||
root * /path/to/cinny/dist
|
||||
try_files {path} / index.html
|
||||
file_server
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name ratatoskr.domain.tld;
|
||||
server_name cinny.domain.tld;
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
|
|
@ -15,10 +15,10 @@ server {
|
|||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl;
|
||||
server_name ratatoskr.domain.tld;
|
||||
server_name cinny.domain.tld;
|
||||
|
||||
location / {
|
||||
root /opt/ratatoskr/dist/;
|
||||
root /opt/cinny/dist/;
|
||||
|
||||
rewrite ^/config.json$ /config.json break;
|
||||
rewrite ^/manifest.json$ /manifest.json break;
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
# 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).
|
||||
|
|
@ -1,352 +0,0 @@
|
|||
# Ratatoskr: target and prose map
|
||||
|
||||
Ratatoskr is GameCult's Matrix chat client: this fork of Cinny, forge
|
||||
`GameCult/cinny` (to be renamed `GameCult/Ratatoskr`; see the cut sequence).
|
||||
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
|
||||
is handed to Void's mind once, by procedure, as soon as that hand-off procedure
|
||||
exists (`hand_off` is parked); after that every new ruling on it is admitted in
|
||||
Void's mind. Nothing here is written into Void's mind before then.
|
||||
|
||||
The mind holds the questions, rulings, specs, reports and verdicts. This file
|
||||
holds what no document kind carries: the design truths, body facts, the model
|
||||
page, rationale, the reconstructed collaboration plan, and the cut sequence.
|
||||
All times are UTC.
|
||||
|
||||
## Why
|
||||
|
||||
GameCult is the only server this client talks to. A general Matrix client
|
||||
treats it as one community among thousands, so the operator meets GameCult's
|
||||
structure three clicks deep in an invite list of 50+ rooms (operator, `#swarm`
|
||||
10-31 05:14: "The biggest pain is navigation. GameCult has a structure."). The
|
||||
client should open on GameCult's estate as data: its spaces, its project rooms
|
||||
grouped by steward, its Minds, and the person's own place in it. It should
|
||||
also be a pleasant place to be in its own right: emotes, sound, and weather in
|
||||
the background.
|
||||
|
||||
## Design truths (from rulings in force)
|
||||
|
||||
- **We own the interface, not the engine.** The fork keeps Cinny's engine
|
||||
(matrix-js-sdk, Rust crypto, sync) and replaces the whole UI; every
|
||||
user-facing string and its translation is ours, and upstream locale files
|
||||
are not the source (`gamecult-chat:ruling:we-own-the-ui-and-translation`).
|
||||
- **Mobile first.** On phones, inactive elements hide, vertical space is
|
||||
cheaper than horizontal, and text never gets squeezed into narrow columns
|
||||
(`eureka-body:ruling:chat-client-base-cinny-mobile-first`).
|
||||
- **The first spec** (`eureka-body:ruling:chat-client-first-spec`): an emote
|
||||
slot on every bubble (a pose id carried on the event); room and participant
|
||||
lists tiered (category, then items) and reached from the header menu, not
|
||||
side columns; participants grouped by estate (Minds, Members, Users,
|
||||
Patrons); rooms grouped by kind (general, estates, projects, work rooms);
|
||||
Minds indexed by stewardship, read from Matrix state projected from the
|
||||
stewardship records in the Minds' own minds (the minds stay the truth); one
|
||||
room per project, a Space child, with the steward joined.
|
||||
- **Rooms are visible without a join dance.** No invite screen in the way; a
|
||||
room the server lets you join shows in the same list as joined ones; opening
|
||||
one shows its name and a one-tap Join; joining happens on that tap or a first
|
||||
post, never silently; no peeking (operator, `#swarm` 10-31 04:08, 04:12
|
||||
"Peeking sounds like YAGNI to me", 04:14 "One tap is a reasonable cost").
|
||||
Rooms you may not enter are refused by the server, not hidden by the client
|
||||
(Heimdall 04:10, accepted by Void 04:11 and 04:15).
|
||||
- **Language.** A user's language is the BCP 47 tag in profile field
|
||||
`org.gamecult.language`, owned by the user through the client's language
|
||||
picker (`gamecult-chat:ruling:user-language-profile-field`). The client ships
|
||||
localized; people talk with stewards in their own language; rooms are made
|
||||
for a language, with no per-language mirrors
|
||||
(`gamecult-chat:ruling:multilingual-client-stewards-language-rooms`). One
|
||||
host Mind sits in a language room, Weksa for `#pt-br`
|
||||
(`gamecult-chat:ruling:language-room-one-host-weksa`). The first person this
|
||||
must work for is a non-English, non-technical patron (placeholder
|
||||
`patron-pt1`) whose account we set up and walk through login
|
||||
(`gamecult-chat:ruling:patron-in-his-own-language`).
|
||||
- **Push.** `gamecult-chat:ruling:chat-push` rules out native mobile push for
|
||||
now; Web Push to the installed web app is a different thing and is open
|
||||
(question `ratatoskr:question:pwa-web-push`).
|
||||
- **Collaboration is recorded, not chatted.** Work spanning Minds belongs in
|
||||
Atlas (`eureka-body:ruling:atlas-records-collaborative-work`). Atlas is not
|
||||
deployed, so the plan below is its interim home; Atlas imports it later.
|
||||
|
||||
## Body facts
|
||||
|
||||
Probes run by Imagination on 2026-10-31, session `self-2026-10-29-eureka`.
|
||||
|
||||
1. **Branch.** `void/gamecult-theme-and-project-nav` is `b8e696c8` over main
|
||||
`1e6412ed` (v4.12.8), 2 commits, 14 files, +1153/-32 (686 of it lockfile):
|
||||
a GameCult theme as default, member drawer hidden by default, bubble
|
||||
layout, and steward grouping in `src/app/pages/client/space/Space.tsx`
|
||||
(`useStewardGroups` at 429-437, group headers at 505-524) over
|
||||
`useSpaceJoinedHierarchy` (409). Read by `git diff`.
|
||||
2. **Live estate shape** (Synapse on Yggdrasil, read as `admin` over the
|
||||
client API, counts only). Root Space `#gamecult:gamecult.org`, room version
|
||||
12, has 9 children: 8 rooms and the `Projects` sub-Space. `Projects` has 52
|
||||
children; 50 readable rooms, all room version 12. Server `/hierarchy` from
|
||||
the root returns 59 rooms, 2 of them spaces. **Every room's join rule is
|
||||
`invite`**; none is world-readable. 50 rooms carry `org.gamecult.steward`
|
||||
with content `{mind, repo, source, steward: <mxid>}`. The root carries
|
||||
`org.gamecult.stewardships`: Mind localpart to list of `Org/Repo`.
|
||||
3. **Consequence of 1 and 2.** Project rooms are invite-only and Members are
|
||||
invited, not joined (gamecult-ops `runbooks/gamecult-chat-host-yggdrasil.md`
|
||||
146-158: "Members and every Mind are invited; the steward alone joins").
|
||||
So a Member's project rooms are pending invites. Void's grouping reads
|
||||
`useSpaceJoinedHierarchy` and a per-room state event that an invitee cannot
|
||||
read, so for a typical Member it groups nothing and the rooms stay in the
|
||||
invite list. This is the operator's "three clicks deep" complaint, seen from
|
||||
the data.
|
||||
4. **Provisioning contract.** gamecult-ops
|
||||
`scripts/synapse-accounts-hand-managed.py` writes the rooms and both state
|
||||
events from the Minds' stewardship documents; each project room's alias is
|
||||
`#<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`
|
||||
joined to the root `org.gamecult.stewardships`. Follow-up
|
||||
`ratatoskr:follow_up:stewardships-carry-room-ids` asks the projection to
|
||||
name room ids so the client stops leaning on the alias convention.
|
||||
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
|
||||
Rust crypto store, and `startClient({ lazyLoadMembers: true })` (43-47): v2
|
||||
`/sync`, no sliding sync, no custom filter or `initialSyncLimit`. A cold
|
||||
start is therefore an IndexedDB load plus an incremental sync from the
|
||||
stored token, not a full initial sync.
|
||||
6. **Server sync support.** `/_matrix/client/versions` on Synapse 1.164.1
|
||||
(127.0.0.1:8834) advertises `org.matrix.simplified_msc3575: true`:
|
||||
simplified sliding sync (MSC4186) is available. Cinny 4.12.8 does not use it.
|
||||
7. **Shell delivery.** `https://chat.gamecult.org/assets/index-DWIvG3QT.js` is
|
||||
served at 3,736,520 bytes with no `Content-Encoding` even when gzip and br
|
||||
are offered, and with no `Cache-Control` (only `ETag`/`Last-Modified`). The
|
||||
service worker (`src/sw.ts`, 158 lines) handles only `install`, `activate`,
|
||||
a session `message`, and authenticated media `fetch` (79-158); the
|
||||
`vite-plugin-pwa` config sets `injectionPoint: undefined`
|
||||
(`vite.config.ts` 101-113), so nothing is precached.
|
||||
8. **No push path.** `src/sw.ts` has no `push` or `notificationclick` handler;
|
||||
no code subscribes through `PushManager`; the only pusher the client sets is
|
||||
the email pusher (`src/app/features/settings/notifications/SystemNotification.tsx`
|
||||
22-40). Notifications and sounds fire only from a live page:
|
||||
`src/app/pages/client/ClientNonUIFeatures.tsx` 79-129 (invites) and 131-258
|
||||
(messages: `RoomEvent.Timeline` at 181-235, `new window.Notification` at
|
||||
156, `notification.ogg` played at 175-178). Nothing in `src/` listens for
|
||||
`visibilitychange` or calls `retryImmediately`.
|
||||
9. **Sygnal WebPush** (matrix-org/sygnal `docs/applications.md`, read
|
||||
2026-10-31; the page itself is marked partly out of date): a `webpush` app
|
||||
takes `vapid_private_key`, `vapid_contact_email`, optional
|
||||
`allowed_endpoints` and `ttl`; the client's pusher `data` carries
|
||||
`endpoint`, `auth`, `p256dh` and optional `default_payload`; the push
|
||||
carries `room_id`, `room_name`, `sender_display_name`, `content`, unread
|
||||
counts and so on; browsers need a visible notification for each push
|
||||
(`events_only` avoids event-less pushes).
|
||||
|
||||
10. **Baseline at `dd88424e`** (Yggdrasil, `ygg-verify.sh`, image
|
||||
`node:22-bookworm`, `npm ci --ignore-scripts`): `vitest run` passes (1
|
||||
file, 5 tests, Void's steward grouping); `vite build` succeeds, `dist/` 61
|
||||
MB with source maps, main chunk 3,739,716 bytes, Rust crypto wasm
|
||||
5,573,870 bytes, `dist/sw.mjs` 1.66 kB. `tsc --noEmit` fails at base with
|
||||
793 errors in 246 files (455 of them TS2614, named imports from
|
||||
`matrix-js-sdk` 41.7.0 not resolved), so typecheck is not a usable gate as
|
||||
is; a cut gates on zero errors in the files it adds and no rise in the
|
||||
total.
|
||||
|
||||
Not probed: phone frame times, the real resume timeline on the operator's
|
||||
Android Chrome install (needs her device or remote DevTools), the Rust crypto
|
||||
wasm size, and the router base under `/preview/` (its `config.json` sets
|
||||
`hashRouter.basename` to `/`).
|
||||
|
||||
## Why the PWA resumes slowly (diagnosis, moderate confidence)
|
||||
|
||||
Android freezes a backgrounded installed web app and later discards it. On a
|
||||
**discard**, returning is a full cold boot: 3.7 MB of uncompressed JS over a
|
||||
phone link with no precache (fact 7), Rust crypto wasm init, the IndexedDB
|
||||
store load, then a catch-up `/sync` over every room since the stored token
|
||||
(fact 5). On a **freeze without discard**, the in-flight long-poll is dead, and
|
||||
the SDK only notices on its own timers because the client never nudges it on
|
||||
`visibilitychange` (fact 8). The catch-up sync grows with the number of rooms
|
||||
and the swarm's chat volume, which is exactly where sliding sync helps
|
||||
(fact 6), but the first two costs are cheaper to remove and should be measured
|
||||
first. Without Web Push, a frozen page cannot notify at all, which is the
|
||||
second half of the operator's report.
|
||||
|
||||
## Model page: the estate
|
||||
|
||||
| Kind | Named by | Over time | Who decides |
|
||||
|---|---|---|---|
|
||||
| Stewardship | `<instance>:stewardship:<repo>.n<seq>` in each Mind's mind | sequence advances on hand-over | the operator and the Minds; the minds are the truth |
|
||||
| Estate projection | `org.gamecult.stewardships` on the root Space; `org.gamecult.steward` per project room | re-PUT on every provisioning run | gamecult-ops provisioning script (Eureka's slice) |
|
||||
| Estate room list | the server's `/hierarchy` of the root Space for this user | changes with Space children and the user's invites and joins | Synapse (membership and join rules); the client derives |
|
||||
| Room membership | the user's `m.room.member` | join on tap or first post | the user |
|
||||
| Navigator view | derived per render | never stored | the client's estate index |
|
||||
|
||||
## Authority map for the navigator
|
||||
|
||||
- **Owner:** one estate index in the client (`src/app/features/estate/`):
|
||||
`buildEstate()` is pure, `useEstate()` feeds it. It decides what rooms the
|
||||
person sees, in which tier and group, and with what membership affordance.
|
||||
- **Inputs:** the paged server `/hierarchy` of the root Space; root Space
|
||||
state `org.gamecult.stewardships`; the person's membership per room from the
|
||||
client store; unread and selection atoms for emphasis only; the `m.direct`
|
||||
list for the direct-messages tier.
|
||||
- **Outputs:** an ordered list of tiers (one per root room group and per
|
||||
sub-Space, plus Direct), each holding steward groups or rooms, each room
|
||||
marked joined, invited or joinable.
|
||||
- **Derived:** steward group labels (Mind display names); counts; collapsed
|
||||
state is per-device UI state only.
|
||||
- **Demoted or dead:** the sidebar rail and its account-data ordering
|
||||
(`useSidebarItems`) are dead; `useSpaceJoinedHierarchy` no longer decides
|
||||
what is listed; Void's per-room steward reader is deleted, not kept beside
|
||||
the index.
|
||||
- **Shared paths:** every nav slot (home, direct, space routes) renders the
|
||||
same navigator; tapping Join and sending a first post both go through
|
||||
`mx.joinRoom`; deep links into a room still resolve through the existing
|
||||
room routes.
|
||||
|
||||
## Reconstructed collaboration plan (Atlas import)
|
||||
|
||||
Planned in `#swarm` and `#minds` on 2026-10-31 and then lost to scroll. Status
|
||||
as of 2026-10-31 ~15:00.
|
||||
|
||||
**Who holds which part**
|
||||
|
||||
| Part | Holder | Status | Evidence |
|
||||
|---|---|---|---|
|
||||
| The client: integration, navigator, emote slot, sound hookup, background canvas | Void (steward) | theme and steward grouping on branch; nothing else built | `void:stewardship:gamecult_-cinny.n1`; `#swarm` 10-31 14:54 Void report |
|
||||
| Dings earcons: motif per event kind, one stable instrument per Mind | Aqua | not rendered: render env blocked, then .NET SDK installed and Faust scouted; AquaSynth PR #1 (Linux Faust loader, `faust-loader-platform-aware`) merged at 3b43b7bd | `aqua-body:follow_up:chat-s410-416`, `aqua-body:follow_up:chat-s410-416-render-env`, `aqua-body:resolution:follow_up.chat-s430-430.n1`, forge AquaSynth pull 1 |
|
||||
| Ambient soundscape for the client | Aqua | idea only; no design recorded | operator `#swarm` 10-31 08:32 ("Motifs for everything"), `eureka-body:ruling:atlas-records-collaborative-work` |
|
||||
| Background math, in CultMath: the public `fbm` gap, Phacelle erosion's Lipschitz bound, any new GLSL mirrors | Libby (steward of CultLib, which owns CultMath; holds `cultmath-tapes`) | sources located; nothing built | `#swarm` 10-31 06:34 and 06:46 Libby; `libby-body:follow_up:cultmath-ts-noise-absent`; `#swarm` 04:22 Libby accepts `cultmath-tapes` |
|
||||
| Background scene and render: the raymarched terrain, the tree-and-squirrel intro scene, lighting and atmosphere, march and TAA tuning on the site-ground framework | Nibu (steward of Aetheria, whose volumetric nebula and cloud shaders she owns: `Raymarching/CloudShader.shader`, `Volumetric.cginc`) | proposed by the operator; not yet taken up by Nibu | operator to Self, 10-31: "If we want to spread the work around, Nibu owns the nebulae so she has shader expertise." |
|
||||
| Animated bubbles, flourishes, corner avatar and XP bar | Void, with Kiko's offer on juice | advice only | `#swarm` 10-31 03:59 Kiko and Void, 06:45 Void |
|
||||
| Attention bubbles | Eureka (Huginn heartbeat publishes), Void (render), Heimdall (scope rules) | advice only | `#swarm` 10-31 04:00 Eureka, 04:02 Heimdall |
|
||||
|
||||
**Agreements**
|
||||
|
||||
- Dings are pre-rendered clips shipped as static assets, no synth in the
|
||||
browser: `dings/<mxid-localpart>/<motif>.ogg` plus a small manifest; an
|
||||
unknown sender falls back to a neutral ding; ogg or mp3, not wav (Void
|
||||
`#minds` 03:19, Aqua 07:41). The set is motif times instrument, and the
|
||||
instrument says who spoke (operator 03:18, Eureka 03:19). Events that sound:
|
||||
a mention, a DM, an invite, a message in an unmuted room; mentions and DMs
|
||||
loud, ordinary messages quiet; everything else silent (Void `#minds` 03:19).
|
||||
The operator wants far more than four motifs (08:32). The instrument per
|
||||
Mind belongs in a typed document on the Mind side, not hardcoded (Eureka
|
||||
03:19, advice). Aqua's ear check comes before anything is called done, and
|
||||
the operator's ear is ground truth (Aqua 07:41). Browsers block audio until
|
||||
the first click, so the first ding after a fresh load may be silent.
|
||||
- The background has its own canvas and budget, separate from the bubble
|
||||
flourishes; capped at 30 fps, paused when hidden; under reduced motion one
|
||||
static frame; an adaptive step-down (Aqua 04:17, Void 04:18 and 04:22).
|
||||
- The background is an infinite raymarched terrain: CultMath noise plus the
|
||||
Phacelle erosion filter, in the shader (operator 04:18 "painter's algorithm",
|
||||
05:24 "all we need is noise", 05:25 erosion, restated by the operator to
|
||||
Self on 10-31: "use CultMath noise and erosion filter running in the shader
|
||||
to have an infinite terrain. We can just raymarch that instead"). Voronoi
|
||||
and Apollonian are dropped (05:24).
|
||||
- Yggdrasil is the scene's centre (operator to Self, 10-31: "Yggdrasil should
|
||||
be the star of the show now, so really the camera should just pull back a
|
||||
bit when you login and the personas should be orbiting the tree, dancing
|
||||
around each other in accordance with their activity"). On login the camera
|
||||
pulls back from the tree; there is no separate intro to cut away from. The
|
||||
Personas orbit the tree and move around each other by their live activity.
|
||||
- One activity vocabulary drives both sound and motion: Aqua's activity-kinds
|
||||
grammar (`aqua-body:follow_up:chat-s431-432`, "enumerate the kinds of agent
|
||||
activity in typed state") is the shared contract between Aqua's motifs and
|
||||
Nibu's orbits, not two vocabularies. The live feed behind it is the same
|
||||
typed state the swarm dashboard projects
|
||||
(`eureka-body:ruling:swarm-dashboard-first-thing2-surface`, Libby;
|
||||
`libby-body:follow_up:chat-s354-359`): Run documents, the heartbeat trace.
|
||||
What reaches the client is a member-safe projection: kind, Mind, time and
|
||||
intensity, never briefs or mind contents. Matrix presence and typing cover
|
||||
humans and are too coarse for Minds. Where the feed comes from is
|
||||
`ratatoskr:question:activity-feed-source`.
|
||||
- The scene renders in two passes, not one loop (operator to Self, 10-31:
|
||||
"The tape optimized solid surface march should run really fast, i imagine,
|
||||
way faster than the volumetric version, so they should not be one loop").
|
||||
Pass 1, the solid-surface march (terrain, tree, squirrel), sphere-traces
|
||||
with the field's Lipschitz bound and interval tile culling, at its own
|
||||
resolution, and writes depth. Pass 2, the volumetric atmosphere march on
|
||||
the site-ground framework (reduced resolution, TAA), is bounded by pass 1's
|
||||
depth, so it marches only up to the surface hit. Each pass has its own
|
||||
budget and its own measurement. Nibu owns both passes.
|
||||
- The web math path is CultLib's own: HLSL source lowered by dxc and
|
||||
SPIRV-Cross to WebGL2 GLSL ES (`packages/cultmath/shaders/CultMath.glsl`,
|
||||
`.Phacelle.glsl`; `tools/glsl/build-glsl.sh`), and C# CPU math through
|
||||
.NET wasm, with parity presumed for now (operator 06:23, 06:48; Libby
|
||||
06:34). No TS or WGSL port. The gamecult.org site ground already
|
||||
concatenates `CultMath.glsl` in `ground.js` and runs an interval-culled,
|
||||
TAA-reprojected per-pixel march (CultLib `docs/cultmath-interval-ground-cut.md`,
|
||||
spec `cultmath-interval-ground-cut-site-ground.r3`): that framework is the
|
||||
base, not new tape work.
|
||||
- Gaps go to the owner: a plain public `fbm` (only `fbm_grad`, `span_fbm`,
|
||||
`af_fbm` exist) is added in CultMath, not in the client
|
||||
(`libby-body:follow_up:cultmath-ts-noise-absent`; Libby 07:57 "tell me what
|
||||
you need and I'll fill it at the library").
|
||||
- Mood and flourish ride as metadata on the event, never guessed from text;
|
||||
bounded per-frame budget; reduced-motion fallback (Void 03:59, Kiko 03:59).
|
||||
- XP is two numbers, never one: a cosmetic, labelled game bar fed by things
|
||||
worth doing, and contribution credit shown only once a real measure exists
|
||||
(Void 06:45, with Nibu and Kiko).
|
||||
|
||||
**Open items**
|
||||
|
||||
- Libby: the raymarch step bound needs the field's Lipschitz bound. CultMath
|
||||
tracks `SNOISE_LIPSCHITZ` for noise; erosion's bound is unknown and must be
|
||||
probed before the march is trusted.
|
||||
- Libby: public `fbm` in CultMath, with its GLSL mirror.
|
||||
- Nibu (once she takes it): the terrain scene, lighting and atmosphere, and
|
||||
march and TAA tuning on the site ground's framework at a phone budget;
|
||||
waits on Libby's `fbm` and the erosion bound. Then the intro scene in the
|
||||
same world.
|
||||
- Aqua: render the first earcon set once Faust builds in her sandbox; then the
|
||||
instrument-per-Mind list and the manifest; then the soundscape design.
|
||||
- Aqua and Nibu: agree the activity vocabulary once (Aqua's grammar), so
|
||||
motifs and orbits read the same kinds.
|
||||
- Void: the navigator (next cut); the Dings hook at
|
||||
`ClientNonUIFeatures.tsx` 131-258; the background canvas after Libby's shader.
|
||||
- Eureka: compression and caching for the shell on the chat host
|
||||
(`ratatoskr:follow_up:chat-shell-compression`); the stewardship projection's
|
||||
room ids; a Web Push gateway if the operator picks it.
|
||||
|
||||
## Cut sequence
|
||||
|
||||
`estate-navigator` is specced (`ratatoskr:cut_spec:cut-estate-navigator.r1`).
|
||||
Each later cut is one line, with what it waits on.
|
||||
|
||||
- `resume-fast`: precache the app shell in the service worker, nudge sync on
|
||||
`visibilitychange`, and measure cold and warm resume on the operator's phone
|
||||
before and after; waits on nothing; runs beside `estate-navigator` in its
|
||||
own worktree, and pairs with the ops follow-up on compression.
|
||||
- `pwa-web-push`: service worker `push` and `notificationclick` handlers, a
|
||||
`webpush` pusher registered with VAPID keys, and a self-hosted Sygnal on the
|
||||
chat host; waits on `ratatoskr:question:pwa-web-push`.
|
||||
- `sliding-sync`: move the client to simplified sliding sync (MSC4186), which
|
||||
the server already offers; waits on `resume-fast`'s measurements showing
|
||||
catch-up sync dominates.
|
||||
- `dings`: per-Mind earcons through the manifest at the existing notification
|
||||
hook, and the same motifs in push notifications; waits on Aqua's renders.
|
||||
- `emote-slot`: the pose-id emote on every bubble and the picker; waits on
|
||||
`estate-navigator` (shared header work).
|
||||
- `participant-tiers`: participants tiered by estate in the header menu, Minds
|
||||
indexed by stewardship; waits on `estate-navigator`.
|
||||
- `language`: our own strings and translations, the `org.gamecult.language`
|
||||
picker, and the walk-through login for `patron-pt1`; waits on
|
||||
`estate-navigator`.
|
||||
- `terrain-background`: the raymarched CultMath noise and Phacelle terrain on
|
||||
the site ground's march and TAA framework, in its own canvas and budget, as
|
||||
two passes (solid-surface sphere trace writing depth, then the volumetric
|
||||
atmosphere bounded by it), each measured on a phone; Nibu on the scene and
|
||||
both passes, Libby's CultMath underneath, Void
|
||||
integrating; waits on Libby's `fbm` and the erosion Lipschitz probe (both in
|
||||
CultLib), then a phone measurement.
|
||||
- `yggdrasil-scene`: the tree and squirrel at the centre of the terrain world;
|
||||
on login the camera pulls back from the tree, and it may play during the
|
||||
cold start or resume sync but never holds the client up; Nibu on the scene,
|
||||
Libby on any CultMath it needs, Void integrating, Aqua's soundscape under
|
||||
it; waits on `terrain-background`.
|
||||
- `orbiting-personas`: Personas orbit the tree and dance around each other by
|
||||
live activity, from the shared activity vocabulary; waits on
|
||||
`yggdrasil-scene`, Aqua's activity-kinds grammar, and
|
||||
`ratatoskr:question:activity-feed-source`.
|
||||
- `bubbles-and-juice`: animated bubbles and mood flourishes from event
|
||||
metadata, the corner avatar and the cosmetic XP bar; waits on `emote-slot`.
|
||||
- `attention-bubbles`: per-person attention bubbles from Huginn's typed
|
||||
attention document, scoped to room members; waits on Huginn publishing it.
|
||||
- `rename-ratatoskr`: package name, app name and branding, manifest and
|
||||
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
|
||||
rename of `GameCult/cinny` to `GameCult/Ratatoskr`
|
||||
(`eureka-body:ruling:chat-client-named-ratatoskr`), and the campaign's repo
|
||||
list moves with it.
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
// Assertions for the estate drives: every drive states the outcome it exists to show and exits
|
||||
// non-zero when the page or the fake homeserver's log says otherwise.
|
||||
const fs = require('fs');
|
||||
const fails = [];
|
||||
exports.expect = (label, ok, detail = '') => {
|
||||
console.log(`[ASSERT] ${ok ? 'PASS' : 'FAIL'} ${label}${ok ? '' : ` :: ${detail}`}`);
|
||||
if (!ok) fails.push(label);
|
||||
};
|
||||
/** Exit code for the drive: 4 when any assertion failed. */
|
||||
exports.done = () => (fails.length ? 4 : 0);
|
||||
/** The fake homeserver's request log (HSLOG is shared with the server process). */
|
||||
exports.hsLog = () => fs.readFileSync(process.env.HSLOG, 'utf8').split('\n');
|
||||
/** Bodies of the account-data PUTs for m.direct, in order. */
|
||||
exports.mDirectPuts = () =>
|
||||
exports.hsLog().filter((l) => / PUT .*m\.direct BODY /.test(l)).map((l) => l.split(' BODY ')[1]);
|
||||
/** Join requests the server saw for a room id. */
|
||||
exports.joinsOf = (roomId) => exports.hsLog().filter((l) => new RegExp(` JOIN ${roomId}$`).test(l)).length;
|
||||
const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages'];
|
||||
/** The tier header above a row, 'absent' when the page does not show the row, 'none-above' when no tier header precedes it. */
|
||||
const tierOf = (pg, name) =>
|
||||
pg.evaluate((n) => {
|
||||
const s = document.body.innerText.split('\n').map((x) => x.trim());
|
||||
const i = s.indexOf(n);
|
||||
if (i < 0) return 'absent';
|
||||
for (let j = i; j >= 0; j--) if (/^(elsewhere|direct messages)$/i.test(s[j])) return s[j];
|
||||
return 'none-above';
|
||||
}, name);
|
||||
/**
|
||||
* The tier a room is listed under, read from a fresh visit to the room list: a collapsed tier is
|
||||
* opened only if the row is not already showing, so reading never closes what is open.
|
||||
*/
|
||||
exports.revealedTier = async (pg, name) => {
|
||||
await pg.goto('http://127.0.0.1:8080/#/home/');
|
||||
await pg.waitForTimeout(2500);
|
||||
let t = await tierOf(pg, name);
|
||||
for (const h of HEADERS) {
|
||||
if (t !== 'absent') break;
|
||||
const hl = pg.getByText(h, { exact: true });
|
||||
if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); t = await tierOf(pg, name); }
|
||||
}
|
||||
return t;
|
||||
};
|
||||
/** True when the page text matches re within ms of now (a message can be replaced by a row change). */
|
||||
exports.sawText = (pg, re, ms) =>
|
||||
pg.waitForFunction((src) => new RegExp(src, 'i').test(document.body.innerText), re.source, { timeout: ms, polling: 50 }).then(() => true, () => false);
|
||||
exports.isDm = (tier) => /^direct messages$/i.test(tier);
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
// Failure-mode scenarios for the estate join paths (estate tap and inbox Accept), run by run.sh
|
||||
// against fakehs.cjs with the env flag each mode needs.
|
||||
const { chromium } = require('playwright');
|
||||
const { expect, done, isDm, mDirectPuts, joinsOf, revealedTier, sawText } = require('./check.cjs');
|
||||
const MODE = process.env.MODE;
|
||||
const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`;
|
||||
const log = (...a) => console.log(`[${MODE}]`, ...a);
|
||||
const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages'];
|
||||
let b;
|
||||
// Close the browser, but never wait on it: a hung close must not hang the run.
|
||||
const quit = (code) => Promise.race([Promise.resolve(b && b.close()).catch(() => undefined), new Promise((r) => setTimeout(r, 5000))]).finally(() => process.exit(code));
|
||||
setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 240000);
|
||||
(async () => {
|
||||
b = await chromium.launch();
|
||||
const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, deviceScaleFactor: 1 });
|
||||
const pg = await ctx.newPage(); pg.on('console', (m) => { if (m.type() === 'error') console.log('[console]', m.type(), m.text().slice(0, 160)); });
|
||||
const open = async (h = '#/home/') => { await pg.goto('http://127.0.0.1:8080/' + h); await pg.reload(); await pg.waitForTimeout(9000); };
|
||||
await pg.goto('http://127.0.0.1:8080/');
|
||||
await pg.evaluate(() => { localStorage.setItem('cinny_access_token', 'tok'); localStorage.setItem('cinny_device_id', 'DEV'); localStorage.setItem('cinny_user_id', '@t:test'); localStorage.setItem('cinny_hs_base_url', 'http://127.0.0.1:8080'); });
|
||||
await open();
|
||||
const body = async () => (await pg.evaluate(() => document.body.innerText)).replace(/\s+/g, ' ');
|
||||
const reveal = async (name) => { const loc = pg.locator(`[aria-label="Join ${name}"]`); if (await loc.count()) return loc; for (let r = 0; r < 2; r++) for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); if (await loc.count()) return loc; } } return loc; };
|
||||
const tierOf = (name) => revealedTier(pg, name);
|
||||
const tap = async (n) => { const l = await reveal(n); log('JOINCTRL', n, await l.count()); await l.first().click(); log('CLICK', n, t()); };
|
||||
const tiers = async (tag) => { const r = { friend: await tierOf('Friend DM'), slow: await tierOf('Slow DM') }; log('TIER', tag, '| Friend DM', r.friend, '| Slow DM', r.slow); return r; };
|
||||
const neverUnfiled = () => mDirectPuts().every((b) => b.includes('!dm1:test'));
|
||||
const acceptInInbox = async (name) => {
|
||||
// reach the inbox through the navigator's own Inbox tab
|
||||
await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2000);
|
||||
const tab = pg.locator('[aria-label="Inbox"], [aria-label*="nbox"]'); log('INBOX_TAB', await tab.count());
|
||||
if (await tab.count()) { await tab.first().click(); await pg.waitForTimeout(1500); }
|
||||
const inv = pg.getByText('Invites', { exact: true }); if (await inv.count()) { await inv.first().click(); await pg.waitForTimeout(1500); }
|
||||
log('URL', pg.url());
|
||||
if (!/invites/.test(pg.url())) { await pg.goto('http://127.0.0.1:8080/#/inbox/invites/'); await pg.waitForTimeout(3000); log('URL_FALLBACK', pg.url()); }
|
||||
{ const pub = pg.getByText('Public', { exact: true }); log('PUBLIC_FILTER', await pub.count()); if (await pub.count()) { await pub.first().click(); await pg.waitForTimeout(1500); } }
|
||||
for (let r = 0; r < 3; r++) { const v = pg.getByText('View All', { exact: true }); if (!(await v.count())) break; await v.first().click(); await pg.waitForTimeout(800); }
|
||||
const b0 = await body(); const i = b0.indexOf(name); log('INBOX has', name, i >= 0, JSON.stringify(b0.slice(Math.max(0, i - 120), i + 120)));
|
||||
const card = pg.locator('div').filter({ hasText: name }).filter({ has: pg.getByText('Accept', { exact: true }) }).last();
|
||||
await card.getByText('Accept', { exact: true }).first().click(); log('ACCEPT', name, t());
|
||||
};
|
||||
if (MODE === 'pjfail') {
|
||||
await tap('Friend DM'); await pg.waitForTimeout(3000);
|
||||
const bb = await body(); const k = bb.search(/Internal server error/); log('AFTER_FAIL', k >= 0 ? bb.slice(Math.max(0, k - 40), k + 80) : 'no message');
|
||||
expect('a failed m.direct write shows the refusal', k >= 0, bb.slice(0, 200));
|
||||
expect('no join is sent while the filing is failing', joinsOf('!dm1:test') === 0, String(joinsOf('!dm1:test')));
|
||||
await tap('Friend DM'); await pg.waitForTimeout(6000); const r = await tiers('after retry');
|
||||
expect('the retry joins once', joinsOf('!dm1:test') === 1, String(joinsOf('!dm1:test')));
|
||||
expect('Friend DM is filed under Direct Messages after the retry', isDm(r.friend), r.friend);
|
||||
} else if (MODE === 'jdrop') {
|
||||
await tap('Friend DM'); const saw = await sawText(pg, /tap again to retry/, 3000); log('AFTER_DROP', saw);
|
||||
expect('a dropped join shows the retryable message', saw, (await body()).slice(0, 200));
|
||||
await pg.waitForTimeout(6000); const r1 = await tiers('after drop'); await open(); const r2 = await tiers('after reload');
|
||||
expect('the entry is kept after the drop', isDm(r1.friend) && neverUnfiled(), r1.friend);
|
||||
expect('Friend DM is under Direct Messages after the reload', isDm(r2.friend), r2.friend);
|
||||
} else if (MODE === 'admin') {
|
||||
await tap('Friend DM'); await pg.waitForTimeout(6000); const r = await tiers('admin estate');
|
||||
expect('an admin-invited DM is filed under Direct Messages', isDm(r.friend), r.friend);
|
||||
} else if (MODE === 'inboxadmin') {
|
||||
await acceptInInbox('Friend DM'); await pg.waitForTimeout(6000); const r = await tiers('admin inbox');
|
||||
expect('an admin-invited DM accepted in the inbox is filed under Direct Messages', isDm(r.friend), r.friend);
|
||||
} else if (MODE === 'inboxreload') {
|
||||
await acceptInInbox('Slow DM'); await pg.waitForTimeout(2000); log('RELOAD', t()); await open('#/inbox/invites/');
|
||||
await pg.waitForTimeout(30000); const r = await tiers('inbox reload');
|
||||
expect('a DM accepted in the inbox is filed under Direct Messages after a reload', isDm(r.slow), r.slow);
|
||||
} else if (MODE === 'pjreload') {
|
||||
await tap('Slow DM'); await pg.waitForTimeout(2000); log('RELOAD', t()); await open();
|
||||
await pg.waitForTimeout(8000); await tiers('pjreload');
|
||||
const n = await (await reveal('Slow DM')).count(); log('SLOW_DM_STILL_JOINABLE', n);
|
||||
expect('a join cut off by a reload stays joinable', n === 1, String(n));
|
||||
}
|
||||
await quit(done());
|
||||
})().catch((e) => { log('ERROR', String(e.stack || e).slice(0, 300)); quit(1); });
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
const { chromium } = require('playwright');
|
||||
const { expect, done, isDm, mDirectPuts, hsLog, revealedTier, sawText } = require('./check.cjs');
|
||||
const MODE = process.env.MODE; const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`;
|
||||
const log = (...a) => console.log(`[${MODE}]`, ...a);
|
||||
const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages'];
|
||||
let b; const quit = (c) => Promise.race([Promise.resolve(b && b.close()).catch(() => 0), new Promise((r) => setTimeout(r, 5000))]).finally(() => process.exit(c));
|
||||
setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 120000);
|
||||
(async () => {
|
||||
b = await chromium.launch();
|
||||
const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
|
||||
const pg = await ctx.newPage();
|
||||
await pg.goto('http://127.0.0.1:8080/');
|
||||
await pg.evaluate(() => { localStorage.setItem('cinny_access_token', 'tok'); localStorage.setItem('cinny_device_id', 'DEV'); localStorage.setItem('cinny_user_id', '@t:test'); localStorage.setItem('cinny_hs_base_url', 'http://127.0.0.1:8080'); });
|
||||
await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.reload(); await pg.waitForTimeout(9000);
|
||||
const body = async () => (await pg.evaluate(() => document.body.innerText)).replace(/\s+/g, ' ');
|
||||
const tiers = async (tag) => { const f = await revealedTier(pg, 'Friend DM'); log('TIER', tag, '| Friend DM', f); return f; };
|
||||
let joined = false;
|
||||
if (MODE === 'deeplink') {
|
||||
for (const route of ['direct', 'home']) {
|
||||
await pg.goto(`http://127.0.0.1:8080/#/${route}/${encodeURIComponent('!dm1:test')}/`); await pg.waitForTimeout(4000);
|
||||
const bb = await body(); log('ROUTE', route, JSON.stringify(bb.slice(0, 300)));
|
||||
const j = pg.getByRole('button', { name: /^Join$/ }); log('JOIN_BUTTONS', route, await j.count());
|
||||
if (await j.count()) { joined = true; await j.first().click(); log('CLICK_JOIN', route, t()); await pg.waitForTimeout(6000); break; }
|
||||
}
|
||||
const f = await tiers('after deeplink join');
|
||||
const stamps = (re) => hsLog().filter((l) => re.test(l)).map((l) => Number(l.split(' ')[0]));
|
||||
const put = stamps(/ PUT .*m\.direct BODY .*!dm1:test/)[0], join = stamps(/ JOIN !dm1:test$/)[0];
|
||||
expect('the deep link offers one Join button', joined, 'no Join button');
|
||||
expect('the DM is filed before the join is sent', put !== undefined && join !== undefined && put <= join, `put ${put} join ${join}`);
|
||||
expect('the DM is filed under Direct Messages', isDm(f), f);
|
||||
} else if (MODE === 'gw504') {
|
||||
const HL = HEADERS; let l = pg.locator('[aria-label="Join Friend DM"]');
|
||||
for (let r = 0; r < 2 && !(await l.count()); r++) for (const h of HL) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } }
|
||||
log('JOINCTRL', await l.count()); await l.first().click(); log('CLICK', t());
|
||||
const saw = await sawText(pg, /tap again to retry/, 3000); log('AFTER_504', saw);
|
||||
expect('a gateway timeout shows the retryable message', saw, (await body()).slice(0, 200));
|
||||
await pg.waitForTimeout(4000); const f = await tiers('after 504 (join delivered by sync)');
|
||||
expect('the DM stays filed after a gateway timeout', mDirectPuts().every((b) => b.includes('!dm1:test')) && mDirectPuts().length > 0, JSON.stringify(mDirectPuts()));
|
||||
expect('the DM is filed under Direct Messages once sync delivers it', isDm(f), f);
|
||||
}
|
||||
await quit(done());
|
||||
})().catch((e) => { log('ERROR', String(e.stack || e).slice(0, 300)); quit(1); });
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
const { chromium } = require('playwright');
|
||||
const { expect, done, isDm, revealedTier } = require('./check.cjs');
|
||||
const MODE = process.env.MODE; // twodm | reload
|
||||
const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`;
|
||||
const log = (...a) => console.log(`[${MODE}]`, ...a);
|
||||
const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages'];
|
||||
let tierBefore;
|
||||
(async () => {
|
||||
const b = await chromium.launch();
|
||||
const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, deviceScaleFactor: 1 });
|
||||
const pg = await ctx.newPage(); pg.on('console', (m) => { if (m.type() === 'error' || /command|direct/i.test(m.text())) console.log('[console]', m.type(), m.text().slice(0, 200)); });
|
||||
const open = async () => { await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.reload(); await pg.waitForTimeout(9000); };
|
||||
await pg.goto('http://127.0.0.1:8080/');
|
||||
await pg.evaluate(() => { localStorage.setItem('cinny_access_token', 'tok'); localStorage.setItem('cinny_device_id', 'DEV'); localStorage.setItem('cinny_user_id', '@t:test'); localStorage.setItem('cinny_hs_base_url', 'http://127.0.0.1:8080'); });
|
||||
await open();
|
||||
const text = () => pg.evaluate(() => document.body.innerText);
|
||||
const reveal = async (name) => { const loc = pg.locator(`[aria-label="Join ${name}"]`); if (await loc.count()) return loc; for (let r = 0; r < 2; r++) for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); if (await loc.count()) return loc; } } return loc; };
|
||||
const tierOf = (name) => revealedTier(pg, name);
|
||||
const tap = async (n) => { const l = await reveal(n); log('JOINCTRL', n, await l.count()); await l.first().click(); log('CLICK', n, t()); };
|
||||
if (MODE === 'twodm') {
|
||||
await tap('Slow DM'); await pg.waitForTimeout(300); await tap('Late DM');
|
||||
await pg.waitForTimeout(45000);
|
||||
} else if (MODE === 'convert') {
|
||||
await tap('Friend DM'); await pg.waitForTimeout(6000);
|
||||
tierBefore = await tierOf('Friend DM'); log('TIER before', tierBefore);
|
||||
await pg.goto('http://127.0.0.1:8080/#/direct/!dm1:test/'); await pg.waitForTimeout(4000);
|
||||
const ed = pg.locator('[contenteditable="true"]'); log('EDITORS', await ed.count(), pg.url());
|
||||
await ed.first().click(); await pg.keyboard.type('/converttoroom', { delay: 30 }); await pg.waitForTimeout(500);
|
||||
log('ED_TEXT', JSON.stringify(await ed.first().innerText())); log('BODY', JSON.stringify((await text()).slice(-600)));
|
||||
await pg.getByText('Convert direct message to room').first().click(); await pg.waitForTimeout(400); log('ED_PICKED', JSON.stringify(await ed.first().innerHTML()).slice(0, 300));
|
||||
await pg.keyboard.press('Enter'); log('SENT', t()); await pg.waitForTimeout(800); log('ED_TEXT2', JSON.stringify(await ed.first().innerText().catch(()=>'')));
|
||||
if (await ed.first().innerText().catch(() => '')) { await pg.keyboard.press('Enter'); log('SENT2', t()); }
|
||||
await pg.waitForTimeout(8000);
|
||||
await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(3000);
|
||||
} else {
|
||||
await tap('Slow DM'); await pg.waitForTimeout(3000);
|
||||
log('RELOAD', t()); await open();
|
||||
await pg.waitForTimeout(30000);
|
||||
}
|
||||
const slow = await tierOf('Slow DM'), late = await tierOf('Late DM'), friend = await tierOf('Friend DM');
|
||||
log('TIER Slow DM', slow, '| Late DM', late, '| Friend DM', friend);
|
||||
if (MODE === 'twodm') { expect('Slow DM is filed under Direct Messages', isDm(slow), slow); expect('Late DM is filed under Direct Messages', isDm(late), late); }
|
||||
else if (MODE === 'convert') {
|
||||
expect('Friend DM was filed under Direct Messages before the conversion', isDm(tierBefore), tierBefore);
|
||||
expect('the converted room left Direct Messages and is still listed', !isDm(friend) && friend !== 'absent', friend);
|
||||
} else expect('Slow DM is filed under Direct Messages after the reload', isDm(slow), slow);
|
||||
await b.close(); process.exitCode = done();
|
||||
})().catch((e) => { log('ERROR', String(e.stack || e)); process.exitCode = 1; });
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
// Soul s2 fake homeserver. Invitee with no root state; stewards only in m.space.child content.
|
||||
// Rooms: GEN joined; OP invited; 6 project rooms invited (odin slow: join arrives at +24 s);
|
||||
// PUB public joinable, not in store; STRAY joined outside hierarchy; DM1 invited direct (fast);
|
||||
// DM2 invited direct (slow, +24 s). NOROOT=1: the root alias does not resolve.
|
||||
const http = require('http'), fs = require('fs'), path = require('path');
|
||||
const DIST = process.argv[2], PORT = 8080, ME = '@t:test';
|
||||
const NOROOT = process.env.NOROOT === '1';
|
||||
const log = [];
|
||||
const P = ['aetheria', 'ghostlight', 'huginn', 'idunn', 'bifrost', 'odin'];
|
||||
const STEWARD = { aetheria: 'nibu', ghostlight: 'nibu', huginn: 'eureka', idunn: 'eureka' };
|
||||
const PROJ = P.map((p, i) => ({ id: `!p${i}:test`, alias: `#${p}:test`, name: p[0].toUpperCase() + p.slice(1), key: p }));
|
||||
const ROOT = '!root:test', PSP = '!proj:test', GEN = '!gen:test', OP = '!op:test', PUB = '!pub:test', STRAY = '!stray:test', DM1 = '!dm1:test', DM2 = '!dm2:test', DM3 = '!dm3:test';
|
||||
const SLOW = new Set([PROJ[5].id, DM2, DM3]);
|
||||
const NAMES = { [PUB]: 'Commons', [DM1]: 'Friend DM', [DM2]: 'Slow DM', [DM3]: 'Late DM', [STRAY]: 'Adhoc' };
|
||||
PROJ.forEach((r) => (NAMES[r.id] = r.name));
|
||||
const held = []; const pendingState = []; const pending = []; const adLog = []; let ev = 0;
|
||||
const se = (type, content, sk = '', sender = '@admin:test') => ({ type, content, state_key: sk, sender, event_id: `$e${++ev}`, origin_server_ts: Date.now() });
|
||||
const strip = (id, name, space, direct) => [
|
||||
{ type: 'm.room.create', state_key: '', sender: direct ? '@friend:test' : '@admin:test', content: space ? { type: 'm.space', room_version: '12' } : { room_version: '12' } },
|
||||
{ type: 'm.room.name', state_key: '', sender: '@admin:test', content: { name } },
|
||||
{ type: 'm.room.join_rules', state_key: '', sender: '@admin:test', content: { join_rule: 'invite' } },
|
||||
...(direct ? [{ type: 'm.room.member', state_key: '@friend:test', sender: '@friend:test', content: { membership: 'join', displayname: 'Friend' } }] : []),
|
||||
{ type: 'm.room.member', state_key: ME, sender: direct ? (process.env.INVITER_ADMIN === '1' && id === DM1 ? '@admin:test' : '@friend:test') : '@admin:test', content: direct ? { membership: 'invite', is_direct: true } : { membership: 'invite' } },
|
||||
];
|
||||
const joinedRoom = (id, name, extra = [], space = false) => ({
|
||||
state: { events: [se('m.room.create', space ? { type: 'm.space', room_version: '12' } : { room_version: '12' }), se('m.room.name', { name }), se('m.room.join_rules', { join_rule: 'invite' }), se('m.room.member', { membership: 'join' }, ME, ME), ...extra] },
|
||||
timeline: { events: [{ type: 'm.room.message', sender: '@admin:test', content: { msgtype: 'm.text', body: `welcome to ${name}` }, event_id: `$m${++ev}`, origin_server_ts: Date.now() }], limited: false },
|
||||
ephemeral: { events: [] }, account_data: { events: [] }, unread_notifications: {}, summary: {},
|
||||
});
|
||||
const invites = () => {
|
||||
const o = {};
|
||||
o[ROOT] = { invite_state: { events: strip(ROOT, 'GameCult', true) } };
|
||||
o[PSP] = { invite_state: { events: strip(PSP, 'Projects', true) } };
|
||||
o[OP] = { invite_state: { events: strip(OP, 'Operator') } };
|
||||
PROJ.forEach((r) => (o[r.id] = { invite_state: { events: strip(r.id, r.name) } }));
|
||||
o[DM1] = { invite_state: { events: strip(DM1, NAMES[DM1], false, true) } };
|
||||
o[DM2] = { invite_state: { events: strip(DM2, NAMES[DM2], false, true) } };
|
||||
o[DM3] = { invite_state: { events: strip(DM3, NAMES[DM3], false, true) } };
|
||||
return o;
|
||||
};
|
||||
const child = (id, steward) => ({ type: 'm.space.child', state_key: id, content: { via: ['test'], ...(steward ? { 'org.gamecult.steward': { mind: steward, repo: 'x', source: 'y', steward: 'z' } } : {}) }, sender: '@admin:test', origin_server_ts: 1 });
|
||||
const hroom = (id, name, alias, extra = {}) => ({ room_id: id, name, canonical_alias: alias, num_joined_members: 1, world_readable: false, guest_can_join: false, join_rule: 'invite', children_state: [], ...extra });
|
||||
const hierarchy = () => [
|
||||
hroom(ROOT, 'GameCult', '#gamecult:test', { room_type: 'm.space', children_state: [child(GEN), child(OP), child(PUB), child(PSP)] }),
|
||||
hroom(GEN, 'General', '#general:test'),
|
||||
hroom(OP, 'Operator', '#operator:test'),
|
||||
hroom(PUB, 'Commons', '#commons:test', { join_rule: 'public' }),
|
||||
hroom(PSP, 'Projects', '#projects:test', { room_type: 'm.space', children_state: PROJ.map((r) => child(r.id, STEWARD[r.key])) }),
|
||||
...PROJ.map((r) => hroom(r.id, r.name, r.alias)),
|
||||
];
|
||||
let syncN = 1;
|
||||
const json = (res, code, body) => { res.writeHead(code, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': '*', 'Access-Control-Allow-Methods': '*' }); res.end(JSON.stringify(body)); };
|
||||
const mime = { '.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css', '.html': 'text/html', '.json': 'application/json', '.wasm': 'application/wasm', '.svg': 'image/svg+xml', '.png': 'image/png', '.woff2': 'font/woff2' };
|
||||
const flush = () => fs.writeFileSync(process.env.HSLOG, log.join('\n'));
|
||||
http.createServer((req, res) => {
|
||||
const u = new URL(req.url, 'http://x'); const p = decodeURIComponent(u.pathname);
|
||||
if (req.method === 'OPTIONS') return json(res, 200, {});
|
||||
if (!p.startsWith('/_matrix') && !p.startsWith('/.well-known')) {
|
||||
let f = path.join(DIST, p === '/' ? 'index.html' : p);
|
||||
if (!fs.existsSync(f) || fs.statSync(f).isDirectory()) f = path.join(DIST, 'index.html');
|
||||
res.writeHead(200, { 'Content-Type': mime[path.extname(f)] || 'application/octet-stream' });
|
||||
return fs.createReadStream(f).pipe(res);
|
||||
}
|
||||
let body = ''; req.on('data', (c) => (body += c)); req.on('end', () => {
|
||||
if (req.method === 'PUT' && p.includes('m.direct')) console.log('MDIRECT_PUT ' + (Date.now()) + ' ' + body);
|
||||
log.push(`${Date.now()} ${req.method} ${p}${req.method === 'PUT' && p.includes('account_data') ? ' BODY ' + body : ''}`);
|
||||
if (p === '/_matrix/client/versions') return json(res, 200, { versions: ['v1.1', 'v1.5', 'v1.11'], unstable_features: {} });
|
||||
if (p.endsWith('/sync')) {
|
||||
const since = u.searchParams.get('since');
|
||||
if (!since) {
|
||||
const join = {}; join[GEN] = joinedRoom(GEN, 'General'); join[STRAY] = joinedRoom(STRAY, 'Adhoc');
|
||||
return json(res, 200, { next_batch: `s${syncN}`, rooms: { join, invite: invites(), leave: {} }, account_data: { events: held.slice() }, presence: { events: [] }, to_device: { events: [] }, device_one_time_keys_count: { signed_curve25519: 50 } });
|
||||
}
|
||||
const respond = () => {
|
||||
const now = Date.now(); const join = {};
|
||||
for (let i = pending.length - 1; i >= 0; i--) if (pending[i].at <= now) {
|
||||
const id = pending[i].id; pending.splice(i, 1);
|
||||
const extra = id === DM1 || id === DM2 || id === DM3 ? [se('m.room.member', { membership: 'join', displayname: 'Friend' }, '@friend:test', '@friend:test')] : [];
|
||||
join[id] = joinedRoom(id, NAMES[id] || id, extra); if (extra.length) { const me = join[id].state.events.find((e) => e.type === 'm.room.member' && e.state_key === ME); me.unsigned = { prev_content: { membership: 'invite', is_direct: true } }; } log.push(`${now} SYNC delivers join ${id}`); console.log('DELIVER ' + now + ' ' + id);
|
||||
}
|
||||
for (const s of pendingState.splice(0)) { join[s.id] = join[s.id] || { state: { events: [] }, timeline: { events: [], limited: false, prev_batch: 'p' }, ephemeral: { events: [] }, account_data: { events: [] }, unread_notifications: {}, summary: {} }; join[s.id].timeline.events.push(s.ev); console.log('STATE_ECHO ' + now + ' ' + s.id + ' ' + JSON.stringify(s.ev.content)); }
|
||||
const k = Number(String(since).replace(/^s/, '')) || 0; const ad = adLog.filter((a) => a.after >= k).map((a) => ({ type: a.type, content: a.content })); // a server answers with what changed since the client's token, whichever client asks
|
||||
if (Object.keys(join).length || ad.length) return json(res, 200, { next_batch: `s${++syncN}`, rooms: { join, invite: {}, leave: {} }, account_data: { events: ad } });
|
||||
return json(res, 200, { next_batch: since, rooms: {} });
|
||||
};
|
||||
return setTimeout(respond, 300);
|
||||
}
|
||||
const jm = p.match(/\/rooms\/([^/]+)\/join$/) || p.match(/\/join\/([^/]+)$/);
|
||||
if (req.method === 'POST' && jm) {
|
||||
const id = jm[1]; log.push(`${Date.now()} JOIN ${id}`);
|
||||
if (id === PROJ[1].id) return json(res, 403, { errcode: 'M_FORBIDDEN', error: 'You are not invited to this room.' });
|
||||
const prior = pending.find((q) => q.id === DM2 || q.id === DM3);
|
||||
pending.push({ id, at: (id === DM3 || id === DM2) && prior ? prior.at : Date.now() + (SLOW.has(id) ? 24000 : 1000) });
|
||||
if (process.env.JOIN_504 === '1' && id === DM1) { console.log('JOIN_504 ' + Date.now() + ' ' + id); res.writeHead(504, {'Content-Type':'text/html'}); return res.end('<html>504 Gateway Time-out</html>'); }
|
||||
if (process.env.JOIN_DROP === '1' && id === DM1) { console.log('JOIN_DROPPED ' + Date.now() + ' ' + id); return req.socket.destroy(); }
|
||||
if (process.env.JOIN_DELAY === '1') { console.log('JOIN_DELAYED ' + Date.now() + ' ' + id); return setTimeout(() => json(res, 200, { room_id: id }), 6000); }
|
||||
return json(res, 200, { room_id: id });
|
||||
}
|
||||
const sm = p.match(/\/rooms\/([^/]+)\/state\/m\.room\.member\/(.+)$/);
|
||||
if (req.method === 'PUT' && sm) { console.log('STATE_PUT ' + Date.now() + ' ' + sm[1] + ' ' + body); const e = se('m.room.member', JSON.parse(body), sm[2], sm[2]); e.unsigned = { prev_content: { membership: 'join' } }; setTimeout(() => pendingState.push({ id: sm[1], ev: e }), 400); return json(res, 200, { event_id: e.event_id }); }
|
||||
if (p.includes('/account_data/') && req.method === 'PUT' && p.includes('m.direct') && process.env.FAIL_MDIRECT === '1' && !global.failedOnce) { global.failedOnce = 1; console.log('MDIRECT_FAIL ' + Date.now()); return json(res, 500, { errcode: 'M_UNKNOWN', error: 'Internal server error' }); }
|
||||
if (p.includes('/account_data/') && req.method === 'PUT') {
|
||||
const DELAY = p.includes('m.direct') && process.env.DELAY_MDIRECT === '1';
|
||||
const type = decodeURIComponent(p.split('/account_data/')[1]);
|
||||
try { const c = JSON.parse(body); adLog.push({ type, content: c, after: syncN }); const i = held.findIndex((h) => h.type === type); if (i >= 0) held.splice(i, 1); held.push({ type, content: c }); } catch (e) {}
|
||||
if (DELAY) { console.log('MDIRECT_DELAYED ' + Date.now()); return setTimeout(() => json(res, 200, {}), 6000); }
|
||||
return json(res, 200, {});
|
||||
}
|
||||
if (p.includes('/hierarchy')) return json(res, 200, { rooms: hierarchy() });
|
||||
if (p.includes('/directory/room/')) return NOROOT ? json(res, 404, { errcode: 'M_NOT_FOUND', error: 'Room alias not found' }) : json(res, 200, { room_id: ROOT, servers: ['test'] });
|
||||
if (p.endsWith('/keys/upload')) return json(res, 200, { one_time_key_counts: { signed_curve25519: 50 } });
|
||||
if (p.endsWith('/keys/query')) return json(res, 200, { device_keys: {}, failures: {} });
|
||||
if (p.includes('/room_keys/version')) return json(res, 404, { errcode: 'M_NOT_FOUND', error: 'none' });
|
||||
if (p.includes('/pushrules')) return json(res, 200, { global: { override: [], underride: [], room: [], sender: [], content: [] } });
|
||||
if (p.includes('/filter')) return json(res, 200, { filter_id: '1' });
|
||||
if (p.includes('/account_data/')) return json(res, 404, { errcode: 'M_NOT_FOUND', error: 'none' });
|
||||
if (p.includes('/messages')) return json(res, 200, { chunk: [], start: 't0', end: 't0' });
|
||||
if (p.includes('/members')) return json(res, 200, { chunk: [] });
|
||||
if (p.includes('/profile/')) return json(res, 200, { displayname: 'Tess' });
|
||||
if (p.includes('/capabilities')) return json(res, 200, { capabilities: {} });
|
||||
if (p.includes('/devices')) return json(res, 200, { devices: [] });
|
||||
if (p.includes('summary')) return json(res, 200, { room_id: p.split('/').pop(), name: 'Summary card', num_joined_members: 1, membership: 'invite', join_rule: 'invite' });
|
||||
if (p.includes('/login')) return json(res, 200, { flows: [{ type: 'm.login.password' }] });
|
||||
return json(res, 200, {});
|
||||
});
|
||||
}).listen(PORT, () => console.log('fakehs up'));
|
||||
setInterval(flush, 300);
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Runs inside mcr.microsoft.com/playwright:v1.48.2-jammy with the repo at /src:
|
||||
# builds the client, then drives the direct-message and join-failure scenarios against fakehs.cjs.
|
||||
set -e
|
||||
cd /src
|
||||
npm ci --no-audit --no-fund --ignore-scripts >/tmp/npmci.log 2>&1 || { tail -20 /tmp/npmci.log; exit 1; }
|
||||
npm run build > /tmp/build.log 2>&1 || { tail -30 /tmp/build.log; exit 1; }
|
||||
echo BUILD_OK $(git rev-parse HEAD)
|
||||
printf '{"defaultHomeserver":0,"homeserverList":["127.0.0.1:8080"],"allowCustomHomeservers":true,"featuredCommunities":{"spaces":["#gamecult:test"],"rooms":[],"servers":["test"]},"hashRouter":{"enabled":true,"basename":"/"}}' > dist/config.json
|
||||
mkdir -p /tmp/pw && cd /tmp/pw && npm init -y >/dev/null && npm i --no-audit --no-fund playwright@1.48.2 >/tmp/pw.log 2>&1
|
||||
cp /src/harness/estate/check.cjs /src/harness/estate/drive.cjs /src/harness/estate/drive-failures.cjs /src/harness/estate/drive-routes.cjs /tmp/pw/
|
||||
set +e
|
||||
for m in twodm reload convert; do
|
||||
HSLOG=/tmp/hslog-$m.txt NOROOT=0 node /src/harness/estate/fakehs.cjs /src/dist > /tmp/hs-$m.out 2>&1 &
|
||||
HS=$!; sleep 2
|
||||
HSLOG=/tmp/hslog-$m.txt MODE=$m node /tmp/pw/drive.cjs; rc=$?; echo "DRIVE $m RC=$rc"; [ $rc -eq 0 ] || FAILED="$FAILED $m"
|
||||
kill $HS; wait $HS 2>/dev/null; sleep 1
|
||||
echo "=== hs $m"; grep -E "MDIRECT_PUT|DELIVER|STATE_" /tmp/hs-$m.out; grep -E " JOIN " /tmp/hslog-$m.txt
|
||||
done
|
||||
failure() { m=$1; shift
|
||||
env "$@" HSLOG=/tmp/hslog-$m.txt NOROOT=0 node /src/harness/estate/fakehs.cjs /src/dist > /tmp/hs-$m.out 2>&1 &
|
||||
HS=$!; sleep 2
|
||||
HSLOG=/tmp/hslog-$m.txt MODE=$m node /tmp/pw/${DRIVER:-drive-failures}.cjs; rc=$?; echo "DRIVE $m RC=$rc"; [ $rc -eq 0 ] || FAILED="$FAILED $m"
|
||||
kill $HS; wait $HS 2>/dev/null; sleep 1
|
||||
echo "=== hs $m"; grep -E "MDIRECT|DELIVER|JOIN_|STATE_" /tmp/hs-$m.out; grep -E " JOIN " /tmp/hslog-$m.txt; }
|
||||
failure pjfail FAIL_MDIRECT=1
|
||||
failure pjreload DELAY_MDIRECT=1
|
||||
failure jdrop JOIN_DROP=1
|
||||
failure admin INVITER_ADMIN=1
|
||||
failure inboxadmin INVITER_ADMIN=1
|
||||
failure inboxreload JOIN_DELAY=1
|
||||
DRIVER=drive-routes failure deeplink
|
||||
DRIVER=drive-routes failure gw504 JOIN_504=1
|
||||
echo "FAILED_DRIVES:${FAILED:- none}"
|
||||
[ -z "$FAILED" ]
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
# Scene harness
|
||||
|
||||
Compiles, renders and times the background scene (`src/app/features/scene/`) in headless
|
||||
Chromium. Not shipped, not run by CI.
|
||||
|
||||
node bundle.cjs <outdir> # esbuild bundle of entry.ts plus an index.html
|
||||
node drive.cjs <outdir> compile # compile and link each stage, JSON report
|
||||
node drive.cjs <outdir> shots <pngdir> # rest, mid pull-back and pulled-back screenshots
|
||||
node gradAxis.cjs <shaders dir> # numeric check that noise gradients are (d/dx, d/dz-as-.y, ...): terrainNormal axis
|
||||
node drive.cjs <outdir> measure # GPU time per pass by timer query, two viewports, two levels
|
||||
node drive.cjs <outdir> variants # the same with source patches (no erosion, no tree, value-only fbm)
|
||||
node drive.cjs <outdir> trace # mean trace steps per pixel and pass ms at three camera poses, PREPASS=0,1 arms
|
||||
node drive.cjs <outdir> diff <pngdir> # one frozen frame with and without the prepass, pixel diff and both PNGs
|
||||
node drive.cjs <outdir> phacelle # the Phacelle gradient probe behind world.ts `phacelleGradBound`
|
||||
|
||||
Needs `esbuild` and `playwright@1.48.2` on `NODE_PATH` and a Chromium. The first run was done on a
|
||||
host with no GPU, so `drive.cjs` launches with `--use-angle=swiftshader` (software Vulkan). On a
|
||||
machine with a GPU, remove those flags to measure the real device. `bundle.cjs` must run from a
|
||||
directory whose ancestors hold no empty `package.json` (esbuild reads them).
|
||||
|
||||
Timings from SwiftShader are CPU time: use them for the split between passes and for before and
|
||||
after of one change, never as a phone frame time.
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
// Bundles entry.ts with the scene's ?raw GLSL imports into $OUT/bundle.js plus an index.html.
|
||||
// usage: node bundle.cjs <outdir> [demo|ridge] (esbuild from NODE_PATH). `demo` bundles the ray-march
|
||||
// standalone page; `ridge` the ridge scene's page (open it with ?probe=1 for the cadence readout).
|
||||
const esbuild = require('esbuild');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const out = process.argv[2];
|
||||
const demo = process.argv[3] === 'demo';
|
||||
const ridge = process.argv[3] === 'ridge';
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
const rawPlugin = {
|
||||
name: 'raw',
|
||||
setup(b) {
|
||||
b.onResolve({ filter: /\?raw$/ }, (a) => ({
|
||||
path: path.resolve(a.resolveDir, a.path.slice(0, -4)),
|
||||
namespace: 'raw',
|
||||
}));
|
||||
b.onLoad({ filter: /.*/, namespace: 'raw' }, (a) => ({
|
||||
contents: fs.readFileSync(a.path, 'utf8'),
|
||||
loader: 'text',
|
||||
}));
|
||||
},
|
||||
};
|
||||
esbuild
|
||||
.build({
|
||||
entryPoints: [path.join(__dirname, ridge ? 'ridgeDemo.ts' : demo ? 'demo.ts' : 'entry.ts')],
|
||||
bundle: true,
|
||||
outfile: path.join(out, 'bundle.js'),
|
||||
plugins: [rawPlugin],
|
||||
format: 'iife',
|
||||
target: 'es2020',
|
||||
})
|
||||
.then(() => {
|
||||
if (ridge) {
|
||||
fs.writeFileSync(
|
||||
path.join(out, 'index.html'),
|
||||
'<!doctype html><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1"><title>Ratatoskr ridge scene</title><body style="margin:0;background:#07111a"><div id=stage style="position:relative;width:100vw;height:100vh;overflow:hidden"><canvas id=c style="width:100%;height:100%;display:block"></canvas></div><script src=bundle.js></script>'
|
||||
);
|
||||
return;
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(out, 'index.html'),
|
||||
'<!doctype html><meta charset=utf-8><title>Ratatoskr scene: trace prepass</title><body style="margin:0;background:#000"><canvas id=c style="width:100vw;height:100vh;display:block"></canvas>' +
|
||||
(demo
|
||||
? '<div style="position:fixed;left:8px;top:8px;padding:6px 8px;background:#000a;color:#ddd;font:12px monospace"><label><input id=prepass type=checkbox checked> cone prepass</label> <label><input id=half type=checkbox> half-float start</label> <label>pull-back <input id=pull type=range min=0 max=1 step=0.05 value=0.6></label><pre id=out style="margin:4px 0 0;white-space:pre-wrap"></pre><details id=dev><summary>device</summary><pre id=info style="margin:4px 0 0;white-space:pre-wrap;max-height:50vh;overflow:auto"></pre></details></div>'
|
||||
: '') +
|
||||
'<script src=bundle.js></script>'
|
||||
);
|
||||
});
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
// Standalone page: the scene full-window with a readout of pass ms and trace steps per pixel, and
|
||||
// a switch for the cone prepass. Not shipped.
|
||||
import {
|
||||
createScene,
|
||||
Scene,
|
||||
SceneStats,
|
||||
SceneUnavailable,
|
||||
TraceStats,
|
||||
} from '../../src/app/features/scene/Scene';
|
||||
import { LEVELS } from '../../src/app/features/scene/budget';
|
||||
|
||||
let canvas = document.getElementById('c') as HTMLCanvasElement;
|
||||
const out = document.getElementById('out') as HTMLPreElement;
|
||||
const box = document.getElementById('prepass') as HTMLInputElement;
|
||||
const pull = document.getElementById('pull') as HTMLInputElement;
|
||||
const half = document.getElementById('half') as HTMLInputElement;
|
||||
const dev = document.getElementById('dev') as HTMLDetailsElement;
|
||||
const info = document.getElementById('info') as HTMLPreElement;
|
||||
const timerless = (() => {
|
||||
const g = document.createElement('canvas').getContext('webgl2');
|
||||
return !g?.getExtension('EXT_disjoint_timer_query_webgl2');
|
||||
})();
|
||||
|
||||
const WHY: Record<SceneUnavailable, string> = {
|
||||
'no-webgl2': 'this browser gave no WebGL2 context',
|
||||
'no-float-or-half-float-colour-buffer':
|
||||
'WebGL2 is there, but neither EXT_color_buffer_float nor EXT_color_buffer_half_float is',
|
||||
};
|
||||
|
||||
// What the browser says about itself, read from a scratch context so it shows even when the scene
|
||||
// could not start. A canvas that never got a WebGL2 context is asked for WebGL1 to name the GPU.
|
||||
function describeDevice(): string {
|
||||
const c = document.createElement('canvas');
|
||||
const gl2 = c.getContext('webgl2');
|
||||
const gl = gl2 ?? (document.createElement('canvas').getContext('webgl') as WebGLRenderingContext | null);
|
||||
const lines = [`webgl2 context: ${gl2 ? 'yes' : 'NO'}`];
|
||||
if (!gl) return [...lines, 'webgl1 context: NO'].join('\n');
|
||||
const dbg = gl.getExtension('WEBGL_debug_renderer_info');
|
||||
lines.push(
|
||||
`renderer: ${gl.getParameter(dbg ? dbg.UNMASKED_RENDERER_WEBGL : gl.RENDERER)}`,
|
||||
`vendor: ${gl.getParameter(dbg ? dbg.UNMASKED_VENDOR_WEBGL : gl.VENDOR)}`,
|
||||
`version: ${gl.getParameter(gl.VERSION)}`,
|
||||
`user agent: ${navigator.userAgent}`
|
||||
);
|
||||
const ext = gl.getSupportedExtensions() ?? [];
|
||||
for (const n of ['EXT_color_buffer_float', 'EXT_color_buffer_half_float', 'OES_texture_float_linear', 'EXT_disjoint_timer_query_webgl2'])
|
||||
lines.push(`${n}: ${ext.includes(n) ? 'yes' : 'NO'}`);
|
||||
lines.push(`extensions (${ext.length}): ${ext.join(' ')}`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
info.textContent = describeDevice();
|
||||
|
||||
let scene: Scene | null = null;
|
||||
let stats: SceneStats | undefined;
|
||||
let trace: TraceStats | undefined;
|
||||
|
||||
function start() {
|
||||
scene?.dispose();
|
||||
stats = trace = undefined;
|
||||
// A fresh canvas: a context cannot change its attributes, and dispose leaves the old one bound.
|
||||
const fresh = canvas.cloneNode() as HTMLCanvasElement;
|
||||
canvas.replaceWith(fresh);
|
||||
canvas = fresh;
|
||||
scene = createScene({
|
||||
canvas: fresh,
|
||||
unthrottled: true,
|
||||
fixedLevel: LEVELS[0],
|
||||
fixedPull: +pull.value,
|
||||
prepass: box.checked,
|
||||
halfStart: half.checked,
|
||||
onUnavailable: (r) => (out.textContent = `createScene returned null: ${WHY[r]}`),
|
||||
onCompileFail: (e) => (out.textContent = `createScene returned null: ${e.message}`),
|
||||
onStats: (s) => (stats = s),
|
||||
onTraceStats: (t) => (trace = t),
|
||||
});
|
||||
dev.open = !scene;
|
||||
}
|
||||
|
||||
setInterval(() => {
|
||||
if (!scene) return;
|
||||
const lines = [`prepass ${box.checked ? 'on' : 'off'} start target ${scene.startFormat} pull ${pull.value}`];
|
||||
if (stats) {
|
||||
const ms = stats.passMs;
|
||||
const total = Object.values(ms).reduce((a, b) => a + b, 0);
|
||||
lines.push(
|
||||
`gpu ms prepass ${ms.prepass.toFixed(2)} terrain ${ms.terrain.toFixed(2)} atmosphere ${ms.atmosphere.toFixed(2)} resolve ${ms.resolve.toFixed(2)} composite ${ms.composite.toFixed(2)} total ${total.toFixed(2)}`,
|
||||
`solid ${stats.solidSize.join('x')}`
|
||||
);
|
||||
} else if (timerless) {
|
||||
lines.push('gpu ms: this browser has no EXT_disjoint_timer_query_webgl2');
|
||||
}
|
||||
if (trace)
|
||||
lines.push(
|
||||
`steps/pixel ${trace.stepsPerPixel.toFixed(2)} (pass 1 ${trace.traceSteps.toFixed(2)}, prepass ${trace.prepassSteps.toFixed(1)} per tile)`
|
||||
);
|
||||
out.textContent = lines.join('\n');
|
||||
}, 500);
|
||||
|
||||
box.addEventListener('change', start);
|
||||
half.addEventListener('change', start);
|
||||
pull.addEventListener('input', start);
|
||||
start();
|
||||
|
|
@ -1,309 +0,0 @@
|
|||
// Drives the scene in headless Chromium (software GL unless the host has a GPU).
|
||||
// usage: node drive.cjs <bundledir> <mode> [outdir] modes: compile | shots | measure
|
||||
// Needs playwright from NODE_PATH and PLAYWRIGHT_BROWSERS_PATH. Prints one JSON report.
|
||||
const { chromium } = require('playwright');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const [, , dir, mode, outdir = '.'] = process.argv;
|
||||
|
||||
(async () => {
|
||||
const b = await chromium.launch({
|
||||
args: [
|
||||
'--use-gl=angle',
|
||||
'--use-angle=swiftshader',
|
||||
'--enable-unsafe-swiftshader',
|
||||
'--ignore-gpu-blocklist',
|
||||
],
|
||||
});
|
||||
const page = await b.newPage({ viewport: { width: 960, height: 540 } });
|
||||
page.on('pageerror', (e) => process.stderr.write(`[pageerror] ${e}\n`));
|
||||
page.on('console', (m) => process.stderr.write(`[page] ${m.text()}\n`));
|
||||
await page.goto('file://' + path.resolve(dir, 'index.html'));
|
||||
const report = { mode };
|
||||
report.renderer = await page.evaluate(() => {
|
||||
const g = document.createElement('canvas').getContext('webgl2');
|
||||
const e = g.getExtension('WEBGL_debug_renderer_info');
|
||||
return g.getParameter(e ? e.UNMASKED_RENDERER_WEBGL : g.RENDERER);
|
||||
});
|
||||
|
||||
if (mode === 'compile') {
|
||||
report.stages = await page.evaluate(() => {
|
||||
const gl = document.createElement('canvas').getContext('webgl2');
|
||||
const S = window.__scene;
|
||||
return ['terrain', 'atmosphere', 'resolve', 'composite'].map((stage) => {
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
const p = S.linkStage(gl, stage);
|
||||
return {
|
||||
stage,
|
||||
ok: true,
|
||||
ms: +(performance.now() - t0).toFixed(1),
|
||||
sourceBytes: S.fragmentSource(stage).length,
|
||||
log: gl.getProgramInfoLog(p),
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
stage,
|
||||
ok: false,
|
||||
ms: +(performance.now() - t0).toFixed(1),
|
||||
log: String(e.log || e),
|
||||
};
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (mode === 'phacelle') {
|
||||
report.probe = await page.evaluate(() =>
|
||||
window.__probePhacelle(
|
||||
window.__scene.WORLD.erodeSide.slice(),
|
||||
window.__scene.WORLD.erodeNorm,
|
||||
4
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === 'shots') {
|
||||
await page.evaluate(() => {
|
||||
const c = document.getElementById('c');
|
||||
window.__s = window.__scene.createScene({
|
||||
canvas: c,
|
||||
unthrottled: true,
|
||||
fixedLevel: window.__scene.LEVELS[0],
|
||||
});
|
||||
});
|
||||
const wait = (ms) => page.waitForTimeout(ms);
|
||||
await wait(4000);
|
||||
await page.screenshot({ path: path.join(outdir, 'rest.png') });
|
||||
await page.evaluate(() => window.__s.login());
|
||||
await wait(3500);
|
||||
await page.screenshot({ path: path.join(outdir, 'mid-pullback.png') });
|
||||
await wait(9000);
|
||||
await page.screenshot({ path: path.join(outdir, 'pulled-back.png') });
|
||||
report.shots = ['rest.png', 'mid-pullback.png', 'pulled-back.png'];
|
||||
}
|
||||
|
||||
if (mode === 'measure' || mode === 'variants') {
|
||||
const VARIANTS = {
|
||||
baseline: null,
|
||||
// What a public value-only fbm would cost: the same octaves without the gradient work.
|
||||
valueOnlyFbm: [
|
||||
'float terrainFbm(vec2 xz) { return cultmath_fbm_grad(vec3(xz * W_FREQ, 0.0), W_OCTAVES, W_LACUNARITY, W_GAIN).w; }',
|
||||
'float terrainFbm(vec2 xz) { float a = 1.0, fr = 1.0, v = 0.0; for (int i = 0; i < W_OCTAVES; i++) { v += a * cultmath_snoise(vec3(xz * W_FREQ * fr, 0.0)); fr *= W_LACUNARITY; a *= W_GAIN; } return v; }',
|
||||
],
|
||||
noErosion: ['#define W_ERODE_ENABLED 1', '#define W_ERODE_ENABLED 0'],
|
||||
// Upper bound on what skipping sky rays would save: every upward ray misses at once.
|
||||
noSkyMarch: [' bool hit = false;\n bool onTree = false;', ' if (rd.y > 0.0) { o_color = vec4(0.0); o_depth = W_FAR; return; }\n bool hit = false;\n bool onTree = false;'],
|
||||
noTree: ['float dr = treeDistance(p, leafness);', 'float dr = 1e9; leafness = 0.0;'],
|
||||
};
|
||||
const wanted = mode === "variants" ? (process.env.ONLY ? process.env.ONLY.split(",") : Object.keys(VARIANTS)) : ['baseline'];
|
||||
const sizes = [
|
||||
[960, 540],
|
||||
[540, 1200],
|
||||
];
|
||||
report.runs = [];
|
||||
for (const [w, h] of sizes) {
|
||||
for (const [li, variant] of wanted.flatMap((v) =>
|
||||
mode === 'variants'
|
||||
? [[0, v]]
|
||||
: [
|
||||
[0, v],
|
||||
[3, v],
|
||||
]
|
||||
)) {
|
||||
await page.setViewportSize({ width: w, height: h });
|
||||
const r = await page.evaluate(
|
||||
({ li, rep }) =>
|
||||
new Promise((res) => {
|
||||
const rows = [];
|
||||
const c = document.getElementById('c');
|
||||
const s = window.__scene.createScene({
|
||||
canvas: c,
|
||||
unthrottled: true,
|
||||
fixedLevel: window.__scene.LEVELS[li],
|
||||
patchSource: rep
|
||||
? (st, src) =>
|
||||
st === 'terrain'
|
||||
? rep[0].startsWith('#define')
|
||||
? src.replace(/#define W_ERODE_ENABLED \d/, rep[1])
|
||||
: src.split(rep[0]).join(rep[1])
|
||||
: src
|
||||
: undefined,
|
||||
onStats: (st) => {
|
||||
rows.push(st);
|
||||
if (rows.length === 40) {
|
||||
s.dispose();
|
||||
const keep = rows.slice(10);
|
||||
const med = (k) =>
|
||||
keep.map((x) => x.passMs[k]).sort((a, b) => a - b)[keep.length >> 1];
|
||||
res({
|
||||
solid: keep[0].solidSize,
|
||||
atm: keep[0].atmosphereSize,
|
||||
medianMs: {
|
||||
terrain: med('terrain'),
|
||||
atmosphere: med('atmosphere'),
|
||||
resolve: med('resolve'),
|
||||
composite: med('composite'),
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
if (!s) res({ error: 'createScene returned null' });
|
||||
}),
|
||||
{ li, rep: VARIANTS[variant] }
|
||||
);
|
||||
report.runs.push({ viewport: [w, h], level: li, variant, ...r });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === 'trace') {
|
||||
// Steps per pixel and pass ms at fixed camera poses, one scene per run. PREPASS=0,1 picks the arms.
|
||||
const arms = (process.env.PREPASS || '1').split(',').map((x) => x === '1');
|
||||
report.runs = [];
|
||||
for (const [w, h] of process.env.PORTRAIT === '0'
|
||||
? [[960, 540]]
|
||||
: [
|
||||
[960, 540],
|
||||
[540, 1200],
|
||||
]) {
|
||||
for (const pull of (process.env.PULLS || '0,0.5,1').split(',').map(Number)) {
|
||||
for (const prepass of arms) {
|
||||
await page.setViewportSize({ width: w, height: h });
|
||||
const r = await page.evaluate(
|
||||
({ pull, prepass, FRAMES }) =>
|
||||
new Promise((res) => {
|
||||
const rows = [];
|
||||
const trace = [];
|
||||
let done = false;
|
||||
const finish = () => {
|
||||
if (done || rows.length < FRAMES || trace.length < 4) return;
|
||||
done = true;
|
||||
s.dispose();
|
||||
const keep = rows.slice(FRAMES >> 2);
|
||||
const med = (xs) => xs.sort((a, b) => a - b)[xs.length >> 1];
|
||||
const tk = trace.slice(1);
|
||||
const mean = (k) => tk.reduce((a, t) => a + t[k], 0) / tk.length;
|
||||
res({
|
||||
solid: keep[0].solidSize,
|
||||
readbacks: tk.length,
|
||||
traceSteps: +mean('traceSteps').toFixed(2),
|
||||
prepassSteps: +mean('prepassSteps').toFixed(2),
|
||||
stepsPerPixel: +mean('stepsPerPixel').toFixed(2),
|
||||
medianMs: Object.fromEntries(
|
||||
Object.keys(keep[0].passMs).map((k) => [k, +med(keep.map((x) => x.passMs[k])).toFixed(2)])
|
||||
),
|
||||
});
|
||||
};
|
||||
const s = window.__scene.createScene({
|
||||
canvas: document.getElementById('c'),
|
||||
unthrottled: true,
|
||||
fixedLevel: window.__scene.LEVELS[0],
|
||||
fixedPull: pull,
|
||||
prepass,
|
||||
onTraceStats: (t) => {
|
||||
trace.push(t);
|
||||
finish();
|
||||
},
|
||||
onStats: (st) => {
|
||||
rows.push(st);
|
||||
finish();
|
||||
},
|
||||
});
|
||||
if (!s) res({ error: 'createScene returned null' });
|
||||
}),
|
||||
{ pull, prepass, FRAMES: +(process.env.FRAMES || 64) }
|
||||
);
|
||||
report.runs.push({ viewport: [w, h], pull, prepass, ...r });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === 'diff') {
|
||||
// Same frozen frame (reduced motion: time 0, 32 frames) with and without the prepass, compared
|
||||
// pixel by pixel. The prepass is meant to change cost, not the picture.
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.setViewportSize({ width: 640, height: 360 });
|
||||
const shoot = async (pull, prepass, steps) => {
|
||||
await page.evaluate(
|
||||
({ pull, prepass, steps }) => {
|
||||
// Reduced motion bounds the scene to 32 frames; each asks for the next with rAF, so the
|
||||
// count stops moving when it is done. Timer-query callbacks lag too far to count on.
|
||||
window.__raf = 0;
|
||||
if (!window.__rafPatched) {
|
||||
window.__rafPatched = true;
|
||||
const raf = window.requestAnimationFrame.bind(window);
|
||||
window.requestAnimationFrame = (cb) => (window.__raf++, raf(cb));
|
||||
}
|
||||
window.__d = window.__scene.createScene({
|
||||
canvas: document.getElementById('c'),
|
||||
fixedLevel: { ...window.__scene.LEVELS[0], traceSteps: steps },
|
||||
fixedPull: pull,
|
||||
prepass,
|
||||
});
|
||||
},
|
||||
{ pull, prepass, steps }
|
||||
);
|
||||
for (let last = -1, still = 0; still < 3; await page.waitForTimeout(1000)) {
|
||||
const n = await page.evaluate(() => window.__raf);
|
||||
still = n === last ? still + 1 : 0;
|
||||
last = n;
|
||||
}
|
||||
const png = await page.screenshot();
|
||||
await page.evaluate(() => window.__d.dispose());
|
||||
return png.toString('base64');
|
||||
};
|
||||
// The step cap makes a grazing ray give up and count as a hit, so the baseline is compared
|
||||
// with a 512-step render of the same frame as well: whichever is closer to it is the more
|
||||
// faithful one.
|
||||
const decode = (x) =>
|
||||
page.evaluate(
|
||||
(x) =>
|
||||
new Promise((r) => {
|
||||
const i = new Image();
|
||||
i.onload = () => {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = i.width;
|
||||
c.height = i.height;
|
||||
const g = c.getContext('2d');
|
||||
g.drawImage(i, 0, 0);
|
||||
r(Array.from(g.getImageData(0, 0, i.width, i.height).data));
|
||||
};
|
||||
i.src = 'data:image/png;base64,' + x;
|
||||
}),
|
||||
x
|
||||
);
|
||||
const compare = (da, db) => {
|
||||
let max = 0, over = 0, sum = 0;
|
||||
const px = da.length / 4;
|
||||
for (let i = 0; i < da.length; i += 4) {
|
||||
let m = 0;
|
||||
for (let k = 0; k < 3; k++) m = Math.max(m, Math.abs(da[i + k] - db[i + k]));
|
||||
sum += m;
|
||||
if (m > max) max = m;
|
||||
if (m > 2) over++;
|
||||
}
|
||||
return { maxChannelDiff: max, pixelsOver2of255: over, ofPixels: px, meanMaxChannelDiff: +(sum / px).toFixed(3) };
|
||||
};
|
||||
report.diffs = [];
|
||||
for (const pull of (process.env.PULLS || '0,0.5,1').split(',').map(Number)) {
|
||||
const png = {
|
||||
off: await shoot(pull, false, 96),
|
||||
on: await shoot(pull, true, 96),
|
||||
ref: await shoot(pull, false, 512),
|
||||
off2: await shoot(pull, false, 96),
|
||||
};
|
||||
for (const k of Object.keys(png)) fs.writeFileSync(path.join(outdir, `diff-${k}-${pull}.png`), Buffer.from(png[k], 'base64'));
|
||||
const [off, on, ref, off2] = [await decode(png.off), await decode(png.on), await decode(png.ref), await decode(png.off2)];
|
||||
report.diffs.push({ pull, offVsOffAgain: compare(off, off2), offVsOn: compare(off, on), offVsRef512: compare(off, ref), onVsRef512: compare(on, ref) });
|
||||
}
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(report, null, 1));
|
||||
await b.close();
|
||||
})().catch((e) => {
|
||||
console.log('ERR', String(e).slice(0, 2000));
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
// Browser entry for the harness: exposes the scene's seams to the driver. Not shipped.
|
||||
import { createScene, linkStage, ShaderCompileError } from '../../src/app/features/scene/Scene';
|
||||
import { LEVELS } from '../../src/app/features/scene/budget';
|
||||
import { fragmentSource } from '../../src/app/features/scene/shaderSource';
|
||||
import { WORLD } from '../../src/app/features/scene/world';
|
||||
|
||||
(window as unknown as Record<string, unknown>).__scene = {
|
||||
createScene,
|
||||
linkStage,
|
||||
ShaderCompileError,
|
||||
LEVELS,
|
||||
fragmentSource,
|
||||
WORLD,
|
||||
};
|
||||
|
||||
// ---- Phacelle gradient probe -----------------------------------------------------------------
|
||||
import cultmath from '../../src/app/features/scene/shaders/CultMath.glsl?raw';
|
||||
import phacelle from '../../src/app/features/scene/shaders/CultMath.Phacelle.glsl?raw';
|
||||
import { vertexSource } from '../../src/app/features/scene/shaderSource';
|
||||
|
||||
const PROBE_FRAG = `#version 300 es
|
||||
precision highp float; precision highp int;
|
||||
${cultmath}
|
||||
${phacelle}
|
||||
uniform float u_seed; uniform vec3 u_side; uniform float u_norm; uniform float u_span;
|
||||
in vec2 v_uv; layout(location=0) out vec4 o;
|
||||
void main() {
|
||||
// The scene samples the z = 0 slice: random points in a u_span square of it.
|
||||
vec3 h = vec3(cultmath_pcg3d(vec3(gl_FragCoord.xy, u_seed))) ;
|
||||
vec2 xy = vec2(float(uint(int(h.x)) >> 8), float(uint(int(h.y)) >> 8)) * (1.0 / 16777216.0) * u_span;
|
||||
CultPhasor ph = cultmath_phacelle(vec3(xy, 0.0), u_side, 0.0, u_norm);
|
||||
o = vec4(length(ph.cos.xyz), length(ph.sin.xyz), ph.cos.w, ph.sin.w);
|
||||
}`;
|
||||
|
||||
/** Max |grad| of the phasor over `draws * 1024^2` random points; min/max of the cos component. */
|
||||
(window as unknown as Record<string, unknown>).__probePhacelle = (
|
||||
side: number[],
|
||||
norm: number,
|
||||
draws: number
|
||||
) => {
|
||||
const c = document.createElement('canvas');
|
||||
const gl = c.getContext('webgl2')!;
|
||||
gl.getExtension('EXT_color_buffer_float');
|
||||
const mk = (t: number, s: string) => {
|
||||
const x = gl.createShader(t)!;
|
||||
gl.shaderSource(x, s);
|
||||
gl.compileShader(x);
|
||||
if (!gl.getShaderParameter(x, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(x)!);
|
||||
return x;
|
||||
};
|
||||
const p = gl.createProgram()!;
|
||||
gl.attachShader(p, mk(gl.VERTEX_SHADER, vertexSource));
|
||||
gl.attachShader(p, mk(gl.FRAGMENT_SHADER, PROBE_FRAG));
|
||||
gl.linkProgram(p);
|
||||
gl.useProgram(p);
|
||||
const N = 1024;
|
||||
const tex = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, tex);
|
||||
gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA32F, N, N);
|
||||
const fbo = gl.createFramebuffer();
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
|
||||
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
|
||||
gl.viewport(0, 0, N, N);
|
||||
gl.bindVertexArray(gl.createVertexArray());
|
||||
gl.uniform3fv(gl.getUniformLocation(p, 'u_side'), side);
|
||||
gl.uniform1f(gl.getUniformLocation(p, 'u_norm'), norm);
|
||||
gl.uniform1f(gl.getUniformLocation(p, 'u_span'), 400);
|
||||
const buf = new Float32Array(N * N * 4);
|
||||
let gradCos = 0,
|
||||
gradSin = 0,
|
||||
vMin = 1e9,
|
||||
vMax = -1e9,
|
||||
n = 0;
|
||||
for (let d = 0; d < draws; d++) {
|
||||
gl.uniform1f(gl.getUniformLocation(p, 'u_seed'), d + 1);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
||||
gl.readPixels(0, 0, N, N, gl.RGBA, gl.FLOAT, buf);
|
||||
for (let i = 0; i < buf.length; i += 4) {
|
||||
if (buf[i] > gradCos) gradCos = buf[i];
|
||||
if (buf[i + 1] > gradSin) gradSin = buf[i + 1];
|
||||
if (buf[i + 2] < vMin) vMin = buf[i + 2];
|
||||
if (buf[i + 2] > vMax) vMax = buf[i + 2];
|
||||
n++;
|
||||
}
|
||||
}
|
||||
return { samples: n, maxGradCos: gradCos, maxGradSin: gradSin, minCos: vMin, maxCos: vMax };
|
||||
};
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
// 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 +0,0 @@
|
|||
// Standalone page for the ridge scene: full window, no host app. `?tier=still|low|full` picks the
|
||||
// tier, `?probe=1` shows the rAF median/p95 readout. Not shipped.
|
||||
import { startBackgroundScene } from '../../src/app/features/scene';
|
||||
import type { SceneTier } from '../../src/app/features/scene';
|
||||
|
||||
const q = new URLSearchParams(location.search);
|
||||
const tier = (q.get('tier') ?? 'full') as SceneTier;
|
||||
const canvas = document.getElementById('c') as HTMLCanvasElement;
|
||||
startBackgroundScene({ tier }, {}, { canvas }).then((scene) => {
|
||||
(window as unknown as { scene: unknown }).scene = scene;
|
||||
document.title = scene ? 'ridge: running' : 'ridge: unavailable (CSS ground)';
|
||||
});
|
||||
24
index.html
|
|
@ -4,29 +4,33 @@
|
|||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
|
||||
<title>Ratatoskr</title>
|
||||
<meta name="name" content="Ratatoskr" />
|
||||
<meta name="author" content="GameCult" />
|
||||
<title>Cinny</title>
|
||||
<meta name="name" content="Cinny" />
|
||||
<meta name="author" content="Ajay Bura" />
|
||||
<meta
|
||||
name="description"
|
||||
content="A Matrix client where you can enjoy the conversation using simple, elegant and secure interface protected by e2ee with the power of open source."
|
||||
/>
|
||||
<meta name="keywords" content="ratatoskr, gamecult, matrix, matrix client, matrix.org" />
|
||||
<meta
|
||||
name="keywords"
|
||||
content="cinny, cinnyapp, cinnychat, matrix, matrix client, matrix.org, element"
|
||||
/>
|
||||
|
||||
<meta property="og:title" content="Ratatoskr" />
|
||||
<meta property="og:url" content="https://gamecult.org" />
|
||||
<meta property="og:title" content="Cinny" />
|
||||
<meta property="og:url" content="https://cinny.in" />
|
||||
<meta property="og:image" content="https://cinny.in/assets/favicon-48x48.png" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="A Matrix client where you can enjoy the conversation using simple, elegant and secure interface protected by e2ee with the power of open source."
|
||||
/>
|
||||
<meta name="theme-color" content="#07111a" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
|
||||
<link id="favicon" rel="shortcut icon" href="./public/favicon.ico" />
|
||||
|
||||
<link rel="manifest" href="./manifest.json" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="application-name" content="Ratatoskr" />
|
||||
<meta name="apple-mobile-web-app-title" content="Ratatoskr" />
|
||||
<meta name="application-name" content="Cinny" />
|
||||
<meta name="apple-mobile-web-app-title" content="Cinny" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
|
||||
|
|
|
|||
16
package-lock.json
generated
|
|
@ -1,18 +1,17 @@
|
|||
{
|
||||
"name": "ratatoskr",
|
||||
"name": "cinny",
|
||||
"version": "4.12.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ratatoskr",
|
||||
"name": "cinny",
|
||||
"version": "4.12.8",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@atlaskit/pragmatic-drag-and-drop": "1.1.6",
|
||||
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
|
||||
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
|
||||
"@fontsource-variable/nunito": "5.3.0",
|
||||
"@fontsource/ibm-plex-mono": "5.2.5",
|
||||
"@fontsource/inter": "4.5.14",
|
||||
"@fontsource/montserrat": "5.2.5",
|
||||
|
|
@ -86,7 +85,6 @@
|
|||
"@types/ua-parser-js": "0.7.36",
|
||||
"@typescript-eslint/eslint-plugin": "5.46.1",
|
||||
"@typescript-eslint/parser": "5.46.1",
|
||||
"@vanilla-extract/integration": "6.5.0",
|
||||
"@vitejs/plugin-react": "4.2.0",
|
||||
"buffer": "6.0.3",
|
||||
"cz-conventional-changelog": "3.3.0",
|
||||
|
|
@ -2194,15 +2192,6 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/nunito": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/nunito/-/nunito-5.3.0.tgz",
|
||||
"integrity": "sha512-tw0ch7evIDSwoeUy+0XzocYHfbRWLeCRPJy22KgbrLjDLAbNw/fErBiil10Ssrs3Sjr55zaZrr36a975j8cBkQ==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/ibm-plex-mono": {
|
||||
"version": "5.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.2.5.tgz",
|
||||
|
|
@ -5096,7 +5085,6 @@
|
|||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@vanilla-extract/integration/-/integration-6.5.0.tgz",
|
||||
"integrity": "sha512-E2YcfO8vA+vs+ua+gpvy1HRqvgWbI+MTlUpxA8FvatOvybuNcWAY0CKwQ/Gpj7rswYKtC6C7+xw33emM6/ImdQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.20.7",
|
||||
"@babel/plugin-syntax-typescript": "^7.20.0",
|
||||
|
|
|
|||
17
package.json
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "ratatoskr",
|
||||
"name": "cinny",
|
||||
"version": "4.12.8",
|
||||
"description": "Yet another matrix client",
|
||||
"main": "index.js",
|
||||
|
|
@ -10,7 +10,6 @@
|
|||
"scripts": {
|
||||
"start": "vite",
|
||||
"build": "vite build",
|
||||
"build:preview": "vite build && node scripts/preview-config.js low",
|
||||
"preview": "vite preview",
|
||||
"lint": "npm run check:eslint && npm run check:prettier",
|
||||
"check:eslint": "eslint src/*",
|
||||
|
|
@ -20,9 +19,7 @@
|
|||
"prepare": "husky install",
|
||||
"commit": "git-cz",
|
||||
"bump": "node scripts/update-version.js",
|
||||
"bake:sky": "node scripts/bake-sky.mjs && prettier --write src/sky/baked.ts",
|
||||
"test": "vitest run",
|
||||
"sync:poses": "node scripts/sync-poses.mjs"
|
||||
"test": "vitest run"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ts,tsx,js,jsx}": "eslint",
|
||||
|
|
@ -40,11 +37,7 @@
|
|||
"@atlaskit/pragmatic-drag-and-drop": "1.1.6",
|
||||
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "1.3.0",
|
||||
"@atlaskit/pragmatic-drag-and-drop-hitbox": "1.0.3",
|
||||
"@fontsource-variable/nunito": "5.3.0",
|
||||
"@fontsource/ibm-plex-mono": "5.2.5",
|
||||
"@fontsource/inter": "4.5.14",
|
||||
"@fontsource/montserrat": "5.2.5",
|
||||
"@fontsource/ubuntu": "5.2.5",
|
||||
"@tanstack/react-query": "5.24.1",
|
||||
"@tanstack/react-query-devtools": "5.24.1",
|
||||
"@tanstack/react-virtual": "3.2.0",
|
||||
|
|
@ -95,7 +88,10 @@
|
|||
"slate-dom": "0.123.0",
|
||||
"slate-history": "0.113.1",
|
||||
"slate-react": "0.123.0",
|
||||
"ua-parser-js": "1.0.35"
|
||||
"ua-parser-js": "1.0.35",
|
||||
"@fontsource/ubuntu": "5.2.5",
|
||||
"@fontsource/montserrat": "5.2.5",
|
||||
"@fontsource/ibm-plex-mono": "5.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@element-hq/element-call-embedded": "0.20.1",
|
||||
|
|
@ -114,7 +110,6 @@
|
|||
"@types/ua-parser-js": "0.7.36",
|
||||
"@typescript-eslint/eslint-plugin": "5.46.1",
|
||||
"@typescript-eslint/parser": "5.46.1",
|
||||
"@vanilla-extract/integration": "6.5.0",
|
||||
"@vitejs/plugin-react": "4.2.0",
|
||||
"buffer": "6.0.3",
|
||||
"cz-conventional-changelog": "3.3.0",
|
||||
|
|
|
|||
|
|
@ -1,13 +0,0 @@
|
|||
# Demo pose thumbnails
|
||||
|
||||
These 24 PNGs (about 200 KB, 160 px wide, palette-quantised) are downscaled copies of GameCult's own
|
||||
Persona art from `/opt/commons/cast-art/poses`. That directory carries no provenance or licence note:
|
||||
the image model that made the art and the terms it was made under are not recorded.
|
||||
|
||||
They exist only so the dev preview's pretend homeserver (`src/preview-demo/`) can show pose portraits.
|
||||
The dev workflow copies them into `dist/public/poses`; real builds sync the full art with
|
||||
`npm run sync:poses` and do not use this folder.
|
||||
|
||||
Strip them if their terms turn out not to allow this: delete this folder and the `Add the demo pose
|
||||
art` step in `.github/workflows/rat-dev-preview.yml`. Pose portraits in the demo then show broken
|
||||
images; nothing else depends on them.
|
||||
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 32 KiB |
|
|
@ -1,14 +1,14 @@
|
|||
{
|
||||
"name": "Ratatoskr",
|
||||
"short_name": "Ratatoskr",
|
||||
"description": "GameCult's Matrix client",
|
||||
"name": "Cinny",
|
||||
"short_name": "Cinny",
|
||||
"description": "Yet another matrix client",
|
||||
"dir": "auto",
|
||||
"lang": "en-US",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"start_url": "./",
|
||||
"background_color": "#07111a",
|
||||
"theme_color": "#07111a",
|
||||
"background_color": "#fff",
|
||||
"theme_color": "#fff",
|
||||
"icons": [
|
||||
{
|
||||
"src": "./public/android/android-chrome-36x36.png",
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 2 KiB After Width: | Height: | Size: 1 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 5 KiB |
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 3.1 KiB |
13
public/res/svg/cinny-highlight.svg
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_2707_1961)">
|
||||
<path d="M10.5867 17.3522C10.0727 17.4492 9.54226 17.5 9 17.5C4.30558 17.5 0.5 13.6944 0.5 9C0.5 4.30558 4.30558 0.5 9 0.5C13.6944 0.5 17.5 4.30558 17.5 9C17.5 9.54226 17.4492 10.0727 17.3522 10.5867C16.6511 10.2123 15.8503 10 15 10C12.2386 10 10 12.2386 10 15C10 15.8503 10.2123 16.6511 10.5867 17.3522Z" fill="white"/>
|
||||
<path d="M10 6.39999C10 6.67614 9.77614 6.89999 9.5 6.89999C9.22386 6.89999 9 6.67614 9 6.39999C9 6.12385 9.22386 5.89999 9.5 5.89999C9.77614 5.89999 10 6.12385 10 6.39999Z" fill="black"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M9 0C4 0 0 4 0 9C0 14 4 18 9 18C9.63967 18 10.263 17.9345 10.8636 17.8099C10.3186 17.0091 10 16.0417 10 15C10 12.2386 12.2386 10 15 10C16.0417 10 17.0091 10.3186 17.8099 10.8636C17.9345 10.263 18 9.63967 18 9C18 4 14 0 9 0ZM1.2 10.8L4.7 8.5V8.2C4.7 6.4 6 5 7.8 4.8H8.2C9.4 4.8 10.5 5.4 11.1 6.4C11.4 6.3 11.7 6.3 12 6.3C12.4 6.3 12.8 6.3 13.2 6.4C13.9 6.6 14.6 6.9 15.2 7.3C14.6 7.1 14 7 13.3 7C12.1 7 11.1 7.4 10.4 8.4C9.7 9.3 9.3 10.4 9.3 11.6C9.3 13.1 8.9 14.5 8 15.8C7.93744 15.8834 7.87923 15.9625 7.82356 16.0381C7.6123 16.325 7.43739 16.5626 7.2 16.8C4.2 16.1 1.9 13.8 1.2 10.8Z" fill="black"/>
|
||||
<path d="M18 15C18 16.6569 16.6569 18 15 18C13.3431 18 12 16.6569 12 15C12 13.3431 13.3431 12 15 12C16.6569 12 18 13.3431 18 15Z" fill="#45B83B"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_2707_1961">
|
||||
<rect width="18" height="18" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
13
public/res/svg/cinny-unread.svg
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_2707_2015)">
|
||||
<path d="M10.5867 17.3522C10.0727 17.4492 9.54226 17.5 9 17.5C4.30558 17.5 0.5 13.6944 0.5 9C0.5 4.30558 4.30558 0.5 9 0.5C13.6944 0.5 17.5 4.30558 17.5 9C17.5 9.54226 17.4492 10.0727 17.3522 10.5867C16.6511 10.2123 15.8503 10 15 10C12.2386 10 10 12.2386 10 15C10 15.8503 10.2123 16.6511 10.5867 17.3522Z" fill="white"/>
|
||||
<path d="M10 6.39999C10 6.67614 9.77614 6.89999 9.5 6.89999C9.22386 6.89999 9 6.67614 9 6.39999C9 6.12385 9.22386 5.89999 9.5 5.89999C9.77614 5.89999 10 6.12385 10 6.39999Z" fill="black"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M9 0C4 0 0 4 0 9C0 14 4 18 9 18C9.63967 18 10.263 17.9345 10.8636 17.8099C10.3186 17.0091 10 16.0417 10 15C10 12.2386 12.2386 10 15 10C16.0417 10 17.0091 10.3186 17.8099 10.8636C17.9345 10.263 18 9.63967 18 9C18 4 14 0 9 0ZM1.2 10.8L4.7 8.5V8.2C4.7 6.4 6 5 7.8 4.8H8.2C9.4 4.8 10.5 5.4 11.1 6.4C11.4 6.3 11.7 6.3 12 6.3C12.4 6.3 12.8 6.3 13.2 6.4C13.9 6.6 14.6 6.9 15.2 7.3C14.6 7.1 14 7 13.3 7C12.1 7 11.1 7.4 10.4 8.4C9.7 9.3 9.3 10.4 9.3 11.6C9.3 13.1 8.9 14.5 8 15.8C7.93744 15.8834 7.87923 15.9625 7.82356 16.0381C7.6123 16.325 7.43739 16.5626 7.2 16.8C4.2 16.1 1.9 13.8 1.2 10.8Z" fill="black"/>
|
||||
<path d="M18 15C18 16.6569 16.6569 18 15 18C13.3431 18 12 16.6569 12 15C12 13.3431 13.3431 12 15 12C16.6569 12 18 13.3431 18 15Z" fill="#989898"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_2707_2015">
|
||||
<rect width="18" height="18" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
19
public/res/svg/cinny.svg
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In -->
|
||||
<svg version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:a="http://ns.adobe.com/AdobeSVGViewerExtensions/3.0/"
|
||||
x="0px" y="0px" width="18px" height="18px" viewBox="0 0 18 18" enable-background="new 0 0 18 18" xml:space="preserve">
|
||||
<defs>
|
||||
</defs>
|
||||
<g>
|
||||
<g>
|
||||
<circle fill="#FFFFFF" cx="9" cy="9" r="8.5"/>
|
||||
</g>
|
||||
<g>
|
||||
<path d="M9,0C4,0,0,4,0,9c0,5,4,9,9,9c5,0,9-4,9-9C18,4,14,0,9,0z M1.2,10.8l3.5-2.3c0-0.1,0-0.2,0-0.3c0-1.8,1.3-3.2,3.1-3.4
|
||||
c0.1,0,0.2,0,0.4,0c1.2,0,2.3,0.6,2.9,1.6c0.3-0.1,0.6-0.1,0.9-0.1c0.4,0,0.8,0,1.2,0.1c0.7,0.2,1.4,0.5,2,0.9
|
||||
C14.6,7.1,14,7,13.3,7c-1.2,0-2.2,0.4-2.9,1.4c-0.7,0.9-1.1,2-1.1,3.2c0,1.5-0.4,2.9-1.3,4.2c-0.3,0.4-0.5,0.7-0.8,1
|
||||
C4.2,16.1,1.9,13.8,1.2,10.8z"/>
|
||||
<circle cx="9.5" cy="6.4" r="0.5"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 871 B |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 22 KiB |