Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 08bf515997 | |||
| f8115609b2 | |||
| 450a7e91ff | |||
| 94c976632e | |||
| b6da92a081 | |||
| 7045532187 | |||
| ac7a958f5c | |||
| 3def499b0f | |||
| 8e0e3b8986 | |||
| c286c4adc4 | |||
| 1fe3e2c92f | |||
|
|
d172c4eda3 | ||
|
|
9391e4753f | ||
| 313d070dd4 | |||
| 51d5fb0909 | |||
|
|
0f82576d9f | ||
|
|
8dea8717ea | ||
|
|
0351f1f6e8 | ||
|
|
de6f1849ea | ||
|
|
06d530d061 | ||
| 84770a2d2c | |||
|
|
5bb78b21cf | ||
| 7bac860016 | |||
|
|
d16842a70e | ||
|
|
08b23e0cc4 | ||
| 61e7539f01 | |||
|
|
9440921181 | ||
|
|
73754a0912 | ||
|
|
3e173d2fed | ||
|
|
84b2a93b8e | ||
|
|
20acd3d384 | ||
|
|
5def1bdfb8 | ||
| 66c4c6373b | |||
|
|
14ea0dbab0 | ||
| 0fe9ab2eee | |||
|
|
de9955a24a | ||
| 1499e9a810 | |||
|
|
aa0dfc0ee9 | ||
| 683d3b92f8 | |||
| bf8b889a0e | |||
|
|
f1287a6f3a | ||
| 3203a32b73 | |||
|
|
1dea4b51e5 | ||
| 64edacac4d | |||
|
|
9877154033 | ||
|
|
91e93e6887 |
151 changed files with 6335 additions and 476 deletions
|
|
@ -1,3 +1,8 @@
|
|||
// Airbnb's own bans, kept: a `no-restricted-syntax` entry below replaces the whole list.
|
||||
const airbnbRestrictedSyntax = require('eslint-config-airbnb-base/rules/style').rules[
|
||||
'no-restricted-syntax'
|
||||
];
|
||||
|
||||
module.exports = {
|
||||
env: {
|
||||
browser: true,
|
||||
|
|
@ -62,6 +67,21 @@ module.exports = {
|
|||
"@typescript-eslint/no-shadow": "error"
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
// The one join path: a join is `joinEstateRoom`, which files the DM before it sends the join.
|
||||
files: ['src/**/*.ts', 'src/**/*.tsx'],
|
||||
excludedFiles: ['src/app/features/estate/joinEstateRoom.ts', 'src/**/*.test.*'],
|
||||
rules: {
|
||||
'no-restricted-syntax': [
|
||||
...airbnbRestrictedSyntax,
|
||||
{
|
||||
selector: "CallExpression[callee.property.name='joinRoom']",
|
||||
message:
|
||||
'Join a room through joinEstateRoom (features/estate/joinEstateRoom.ts), the one join path.',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['*.ts'],
|
||||
rules: {
|
||||
|
|
|
|||
20
config.json
20
config.json
|
|
@ -1,6 +1,13 @@
|
|||
{
|
||||
"defaultHomeserver": 1,
|
||||
"homeserverList": ["converser.eu", "matrix.org", "mozilla.org", "unredacted.org", "xmr.se"],
|
||||
"defaultHomeserver": 0,
|
||||
"homeserverList": [
|
||||
"gamecult.org",
|
||||
"converser.eu",
|
||||
"matrix.org",
|
||||
"mozilla.org",
|
||||
"unredacted.org",
|
||||
"xmr.se"
|
||||
],
|
||||
"allowCustomHomeservers": true,
|
||||
"featuredCommunities": {
|
||||
"openAsDefault": false,
|
||||
|
|
@ -20,5 +27,14 @@
|
|||
|
||||
"backgroundScene": {
|
||||
"tier": "off"
|
||||
},
|
||||
|
||||
"dings": {
|
||||
"enabled": false
|
||||
},
|
||||
|
||||
"registration": {
|
||||
"enabled": false,
|
||||
"url": ""
|
||||
}
|
||||
}
|
||||
|
|
|
|||
242
docs/mind-feedback-pages.md
Normal file
242
docs/mind-feedback-pages.md
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
# Mind feedback pages
|
||||
|
||||
> The desk reads `gamecult.fork.*` now, not the `org.gamecult.feedback.*` projections named below; see CultLib `contracts/cultnet/governance/fork-card.md` (src/app/features/desk/forkContract.ts).
|
||||
|
||||
Design only. No code exists for this; nothing here is built. Drafted by Kiko Self (2026-11-07) with Heimdall's draft rulings
|
||||
on guests and hiding; placement ruled for this repo by Void. Follow-up `eureka-body:follow_up:ratatoskr-mind-feedback-pages`.
|
||||
Every number below is provisional.
|
||||
|
||||
**Schema owner.** The Huginn kinds, their fields, keys and refusals are specified in `huginn-feedback-kinds-proposal.md` (Eureka Self, 2026-11-07, in
|
||||
`/opt/commons/handoff`), adopted for this page by Void's Persona. Where this page names a field of those kinds it mirrors that proposal; if they differ, the proposal wins.
|
||||
This repo owns only the client: a typed interface mirroring those shapes, and fixtures.
|
||||
|
||||
Each Mind's section in the estate navigator opens on that Mind's **feedback page**: the Mind's open questions with options, its
|
||||
recommendation and what each waits on, answerable by the operator and anyone with an account. It replaces the old Fork Desk
|
||||
(taken from the brief; no trace of it was found in this repo or in the Minds' state).
|
||||
|
||||
## 1. Placement
|
||||
|
||||
Each Mind's navigator section **opens on its feedback page**, rooms below it. A cross-Mind list of everything waiting on a person
|
||||
is wanted later and is not part of this spec. On phones the page folds each question's options and the answer form behind its title,
|
||||
so the section root stays a list of questions, not a wall of forms.
|
||||
|
||||
## 2. Authority
|
||||
|
||||
| Concern | Owner | The page's part |
|
||||
|---|---|---|
|
||||
| The question | a typed `question` document in the **owning Mind's own mind**, key `<campaign>:question:<label>` | a view of it; the page stores and edits nothing |
|
||||
| Which questions appear | the owning Mind, by a `feedback_listing` (the opt-in act) carrying `audience` and `close_grants`; un-listing is a `Withdrawn` resolution on it | shows only what has a listing in force |
|
||||
| An answer | its author. The **receiver (Bifrost) holds the text**; the Mind holds a **text-free `feedback_answer` record** | submits the answer to the receiver |
|
||||
| The ruling | the owning Mind, recorded by her | **never writes one**; links to it once she has |
|
||||
| Status (open, answered, closed) | derived in the Mind from its documents (`closed` is a `Withdrawn` resolution on the question), never written | shows it |
|
||||
| Who answered, and as what | the Bifrost account behind the Heimdall session, plus its role at the moment of the answer | shows it; never takes it from the client |
|
||||
| Close | a `feedback_close_request` the owning Mind honours or declines; it never changes the question's status by itself | offers Close only to a grant holder; **the page writes no ruling** |
|
||||
| Hiding | the owning Mind alone, as a `feedback_hide`; unhide is a `Withdrawn` resolution on it | shows the labelled stub; hides nothing itself |
|
||||
| Erase | Bifrost's erase of an account; the receiver drops the text | shows "erased account" |
|
||||
|
||||
An answer is input to a Mind, not a vote and not a decision. **The page never tallies**: no counts, no totals, no "leading" option, no
|
||||
ordering by popularity, no count of reports or close requests. Only the Mind's ruling picks anything.
|
||||
|
||||
## 3. Receiver and carriers
|
||||
|
||||
- **Schema owner: `huginn-feedback-kinds-proposal.md`.** Four new Huginn kinds, all Eureka's: `feedback_listing` (audience, `close_grants` with no Guest
|
||||
grant, the opt-in act), `feedback_answer` (a text-free record), `feedback_close_request` and `feedback_hide`. Unhide, un-list and "request declined"
|
||||
are `resolution`s on those kinds. This page proposes no kinds.
|
||||
- **Bifrost is the receiver** (Heimdall's side). It verifies the Heimdall session, reads the account, stamps `answerer`, `kind`, weight and `time`,
|
||||
**stores the answer text**, delivers it, serves the projection the page reads, and **does the erase**. A Matrix sender is a different
|
||||
identity authority and never becomes `answerer`.
|
||||
- **Huginn is RUDP-only.** The client never talks to Huginn, and the receiver never writes to it: an admission declared for another instance is refused
|
||||
(`ForeignInstance`), so a receiver "admitting as Kiko" would be unauthenticated by construction.
|
||||
- **Delivery is by mouth.** The receiver delivers each answer or close request to the owning Mind's Persona through the mouth's pending/ack path (a new
|
||||
delivery document, `gamecult.persona.feedback_delivery.v1`, owned by Bifrost). **The Persona admits the text-free record into her own mind** and acks only
|
||||
after it is admitted; a restart redelivers the same record id, which is `AlreadyAdmitted`. The text reaches her turn once, in the delivery, and is never
|
||||
admitted.
|
||||
- Dependencies that must ship first, in the proposal's order: Huginn readers (daemon, CLI, clients), then the Persona organ, then Bifrost's receiver.
|
||||
|
||||
## 4. Mock
|
||||
|
||||
```
|
||||
Kiko > Feedback [synced 2 min ago]
|
||||
--------------------------------------------------------------------------------------
|
||||
Open questions [ Open ] [ Settled ]
|
||||
|
||||
OPEN | asked 6 Nov 2026 09:12 | waiting 1 day
|
||||
Which source feeds audio_spectrum? [ Details v ]
|
||||
The visualizer contract assumes a host-side spectrum ... (3 lines, then "Show more")
|
||||
Waits on: "Overlay-wide effect budget" (open, links to that question)
|
||||
|
||||
( ) A Overlay's own audio only
|
||||
( ) B OBS captured stream Kiko recommends this
|
||||
( ) C Loopback of the mix, microphone excluded
|
||||
( ) Something else: [ up to 2000 characters ]
|
||||
Answers are visible to everyone who can see this question.
|
||||
[ Send answer ]
|
||||
[ Ask Kiko to close this question ] <- only if you hold the close grant on it
|
||||
|
||||
Answers (every one, oldest first)
|
||||
Meta operator B OBS captured stream 6 Nov 21:04
|
||||
Aqua Mind B OBS captured stream 6 Nov 21:40
|
||||
Nibu member C Loopback of the mix 7 Nov 08:12
|
||||
Heimdall Mind "Either, if latency is measured first." 7 Nov 09:02
|
||||
a guest guest A Overlay's own audio only 7 Nov 09:15
|
||||
(answer hidden) guest Hidden by Kiko, 7 Nov, spam
|
||||
Earlier answers by the same person fold under their latest.
|
||||
--------------------------------------------------------------------------------------
|
||||
SETTLED | Kiko ruled: "..." (link to the ruling) | 5 Nov
|
||||
```
|
||||
|
||||
- Each row is the author's name and **kind**, the choice or text, and the time. Name and kind are what the account was **at the moment of the answer**
|
||||
and stay so if it later changes. The operator is a member holding the grant that makes her the operator, shown as such.
|
||||
- The recommendation is stated in words, not colour, and is never preselected.
|
||||
- Answering a settled question is not offered.
|
||||
|
||||
## 5. Data contract
|
||||
|
||||
The **Huginn shapes belong to the proposal** (section 2 there): `feedback_listing`, `feedback_answer`, `feedback_close_request`, `feedback_hide`. The client
|
||||
declares a typed interface that mirrors them as the page's projection and imports nothing it cannot decode from them.
|
||||
|
||||
**Question projection** (`org.gamecult.feedback.question.v0`, read-only, served by Bifrost):
|
||||
|
||||
| Field | Meaning | Source |
|
||||
|---|---|---|
|
||||
| `id` | `<campaign>:question:<label>`; the key carries the campaign, not the instance | the document key |
|
||||
| `owner` | the asking Mind's instance | the instance |
|
||||
| `title`, `body` | one line; prose up to 4000 bytes | `title`, `question` |
|
||||
| `options[]` | `{ id, text }`, 2 to 8; **the option id is the option's label** | `options` (`label`, `text`) |
|
||||
| `recommendation` | an option label | `recommended` |
|
||||
| `dependsOn[]` | `{ text, questionId? }`; an id only where the line cites one verbatim | `depends` (free text) |
|
||||
| `askedAt` | the question's receipt `committed_at` | the receipt; `asked_on` is only a date |
|
||||
| `audience`, `closeGrants[]` | `AccountHolders` or `MembersOnly`, no default; grantees are accounts or the kinds `Member` and `Mind`, **never `Guest`** | the in-force `feedback_listing` |
|
||||
| `status` | `open`, `answered` (with a `rulingId`) or `closed`, derived | the question's resolutions |
|
||||
|
||||
**Answer row** (`org.gamecult.feedback.answer.v0`, assembled by the receiver from its stored record and the Mind's text-free `feedback_answer`):
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `recordId` | `<question-label>.a<N>`, N the receiver's per-question sequence |
|
||||
| `answerer` | the **Bifrost account id** (stable; the authenticator handle is cleared on erase); never a display name, never client-supplied |
|
||||
| `kind` | `Guest`, `User`, `Patron`, `Member` or `Mind`, read from Bifrost's account state **at answer time**; the session carries identity, never role |
|
||||
| `weightPermille` | stamped 0 for a guest, 1000 otherwise; **the page sums nothing** |
|
||||
| `choice` / `text` | an option label, free text up to 2000 characters, or both; at least one. Text exists only in the receiver's store and the projection, never in a mind |
|
||||
| `time` | when the receiver accepted it |
|
||||
|
||||
Invariants: the receiver takes `answerer`, `kind` and weight from the verified session and account state, never from the request body; an unknown option
|
||||
label is refused; the latest record per account is its standing answer, derived at read time; display names are looked up for display and are never keys. An
|
||||
account may answer only while its status is `active`, read at answer time. A guest cannot request close, and a guest grant cannot be written. Answers to a
|
||||
settled question are kept. Nothing is edited or deleted, except the receiver's redaction on erase (section 7).
|
||||
|
||||
## 6. Flow
|
||||
|
||||
```
|
||||
owning Mind's mind --(Bifrost projection)--> page page --(answer, Heimdall session)--> receiver (Bifrost; holds the text)
|
||||
receiver --(mouth delivery)--> owning Mind's Persona --(admits text-free feedback_answer)--> her mind
|
||||
the Mind decides and records the ruling herself --> status derives --> projection updates --> page moves it to Settled
|
||||
```
|
||||
|
||||
1. **View.** The page shows the projection of the questions with a `feedback_listing` in force, filtered by the viewer's kind against `audience`. The mind
|
||||
stays the truth; the projection is rebuilt, never hand-edited, and republished on a heartbeat so freshness is visible.
|
||||
2. **Answer.** The page submits with the person's Heimdall session; the receiver stamps, stores the text and appends the record.
|
||||
3. **Return.** The receiver delivers by mouth; the Persona admits the text-free record and reads the text once. Each arrives as **quoted, attributed data**
|
||||
(record id, author, kind, time), never in an instruction position: text from a guest, user or patron is untrusted, and she records a ruling from it, never
|
||||
an action. The receiver does no keyword screening. She does not quote answer text into memory or a ruling; she cites the record id.
|
||||
4. **Decide.** She records the ruling in her mind. The next projection carries `answered` and the ruling link.
|
||||
5. **Close.** A grant holder's request is delivered as a `feedback_close_request`. The Mind honours it by resolving the question, or declines with a
|
||||
`Recorded` resolution on the request; the request never changes the status itself. The page shows who asked and never how many.
|
||||
|
||||
## 7. Guests, erasure and hiding
|
||||
|
||||
- **Guests may answer** and see what the Mind opted in for their audience; they may not close (refused at the receiver and again by Huginn, and no listing can
|
||||
name a Guest grantee). A guest answer carries weight 0 by construction and is tallied nowhere. Guests see display name and kind only, never a Matrix id,
|
||||
provider handle or GitHub login.
|
||||
- **Erase redacts.** Records are append-only and are **never edited, but may be redacted**. A mind cannot redact: every admission's receipt keeps its exact
|
||||
payload bytes, which is why the text never enters one. When Bifrost erases an account, **the receiver drops that account's answer text and name** and keeps record
|
||||
id, kind and time only. **A content hash is not kept**: an unsalted hash would confirm a guessed text. The author shows as "erased account". The receiver delivers an erase notice and the Persona admits a `feedback_hide` with
|
||||
reason `ErasedAuthor`, the Mind's acknowledgement rather than a moderation call. The honest residue: a Mind's own memory or ruling may already quote an
|
||||
answer, and Huginn cannot redact it; the rule that she cites record ids, never text, is a prompt, not an invariant.
|
||||
- **Only the owning Mind hides**, as a `feedback_hide`: target (an answer or a question), reason from the closed set (`PrivateMatter`, `Abuse`, `Spam`,
|
||||
`OffTopic`, `AuthorRequest`, `ErasedAuthor`) and a line of at most 200 bytes, with its admission receipt as the audit trail. Unhide is a `Withdrawn`
|
||||
resolution on the hide. No count of reports ever hides anything and there is no auto-hide threshold; the operator orders a hide in conversation and the
|
||||
Mind records it.
|
||||
- **Anyone with an account may report** an answer; the receiver delivers it to the Mind as input. The page shows nothing of it and never a count. An author
|
||||
reporting their own answer uses `AuthorRequest` and the Mind honours it.
|
||||
- **What a hidden item shows.** A hidden answer keeps its row (author, kind, time, "Hidden by <Mind>, <date>, <reason>") with its text withheld for every
|
||||
reason. A hidden question shows a stub with its title, body, options and answers withheld; taking a question off the page is a `Withdrawn` resolution on its
|
||||
listing. The answer box says answers are visible to everyone who can see the question.
|
||||
- **Private matter** is never listed. An answer carrying someone's private matter is hidden with `PrivateMatter`.
|
||||
- Guest answers are collected for nothing but delivery to the owning Mind.
|
||||
- **No content hash anywhere.** No answer, close-request or redaction shape in this page carries one. The proposal's `feedback_answer` lists a `content_hash`
|
||||
field; per Heimdall it must go, and Eureka owns that change.
|
||||
|
||||
## 7a. Receiver: credential, store, names
|
||||
|
||||
Source: Bifrost PR #10, `docs/feedback-receiver.md` (design, nothing implemented).
|
||||
|
||||
- **Credential: route 2.** Ratatoskr presents the **OIDC access token Bifrost already issues to chat**, and Bifrost verifies it itself. The client holds no new
|
||||
secret and needs no Heimdall token. The account is the token's subject, `UserAccount.Id`. **The claims check is still open**: which claims the token carries
|
||||
and which Bifrost must verify for this endpoint have not been read.
|
||||
- **Store: a CultCache file** (Eureka's pick), owned and written only by the receiver. **Backups need a stated purge or expiry before any deploy**, so that
|
||||
erased text does not survive in them; the number is left to Eureka and Heimdall.
|
||||
- **Display names.** The receiver must not reuse `CurrentBifrostActor.DisplayName`, which falls back to the GitHub login and then to "Guest". It stores the
|
||||
name at answer time from the member nickname or account display name only, and "an account" when both are empty. Never a provider handle, GitHub login,
|
||||
Heimdall id or Matrix localpart.
|
||||
- **No guest exists until Bifrost PR #9 lands.** On main an active membership is a member, an authenticated or pending one a user, and nobody is a guest, so the
|
||||
guest rules in this page have no account to apply to until then. An answer needs an active account, read at answer time.
|
||||
|
||||
## 8. States
|
||||
|
||||
- **Empty:** "Nothing is waiting on you from Kiko", when it last synced, a link to Settled. Never blank.
|
||||
- **No answers:** "No answers yet", with the question's age.
|
||||
- **Stale projection:** past two heartbeats (provisionally 30 minutes) the header reads "may be out of date"; answering stays possible. A projection that
|
||||
never arrived says "Kiko's questions have not been published yet", not "no questions".
|
||||
- **Long open:** past 14 days (provisional) shows its age; it is not demoted or hidden.
|
||||
- **Blocked:** shows the dependency chip with that question's status; answering stays allowed.
|
||||
- **Settled while answering:** the answer is kept for the Mind and the page says it arrived after the question settled.
|
||||
- **Signed out, expired, or not active:** answers are shown, the form asks to sign in or says why it cannot; nothing typed is lost.
|
||||
- **Send failed:** the text stays in the field with the reason and a retry.
|
||||
|
||||
## 9. Build order
|
||||
|
||||
1. The client's typed interface, mirroring the proposal's shapes and the projections in section 5, and **fixtures** for every state in section 8 plus the
|
||||
guest, erased and hidden rows. The page renders from fixtures; no network.
|
||||
2. The proposal's kinds and migration (Eureka: readers first, then the Persona organ), and the Bifrost account publisher, receiver and mouth delivery
|
||||
(Heimdall). The page switches from fixtures to the projection.
|
||||
|
||||
## 10. Accessibility
|
||||
|
||||
- Semantic structure: `main` with a heading; each question an `article` with its title as a heading; options a `fieldset`/`legend` radio group; the
|
||||
free-text field labelled, with a character counter announced politely. The phone fold is a button with `aria-expanded`.
|
||||
- No meaning by colour alone: status, recommendation, kind, hidden and close-grant are text.
|
||||
- Keyboard: everything reachable in a sensible order; after Send, focus goes to a confirmation naming the question and the answer.
|
||||
- New answers and status changes arrive in an `aria-live="polite"` region, never as a focus steal.
|
||||
- Targets at least 44 px; reduced motion respected; contrast per theme.
|
||||
- Names and answers are user text: `dir="auto"`, control and bidi characters stripped, long words wrap.
|
||||
|
||||
## 11. Still open
|
||||
|
||||
1. **Emergency withhold.** If the owning Mind is away and an answer shows something illegal or dangerous, may the host withhold the text at the receiver?
|
||||
Heimdall leans no: it is a second owner. Meta's fork.
|
||||
2. **How a Mind authenticates to answer** (kind `Mind`): Bifrost has no `Mind` role and the account publisher `gamecult.bifrost.account.v1` does not exist yet.
|
||||
3. **Erase is bounded.** Text a Mind has already quoted into her own documents cannot be redacted; absolute erase would mean the text never enters her turn.
|
||||
4. **`AccountId` grammar**, pinned to Bifrost's account id shape when that publisher lands.
|
||||
5. **Receiver claims.** The OIDC access token's claims for this endpoint (section 7a) are unread.
|
||||
6. **Reader ordering.** Whether an old Huginn reader skips or fails on a mind holding a kind it does not know is unverified (proposal section 6.4); it decides
|
||||
when the first Mind may list a question.
|
||||
|
||||
## 12. The desk: the page as the Fork Desk's descendant
|
||||
|
||||
Added 2026-11-10 by Void from Meta's brief and the original Fork Desk (`/opt/commons/handoff/fork-desk-original/`, Nibu's newest copy; working note `/opt/commons/handoff/ratatoskr-desk-spec-20261110.md`, section 11). The feedback page **is** the desk: one surface, not two. Design only.
|
||||
|
||||
**Keep from the original.**
|
||||
- Questions stream as rows (not one at a time): title, body as context, options with the recommended one marked **in words**, the `depends` line, a free-text note carrying the person's words verbatim, and a "recorded as rulings" list of settled questions with their ruling id. A pick is optional; the note can answer alone.
|
||||
- A **booth Persona**: the owning Mind's pose beside the rows on wide screens, a small fixed corner image on phones, reacting by mood: no forks (neutral), all answered (triumph), none answered (thinking), partway (determined, "N to go"), a pick equal to the recommendation (happy), a different pick (surprised, "a line on why helps"), a cleared pick (note), a failed send (concerned), a new fork arriving (surprised). The rules are a pure function of the forks and the last event (`deskMood`), tested exactly. Poses come from the shared cast art; a Mind without art shows no booth rather than another's face.
|
||||
- Answers save as the person acts, and she can close the page and come back.
|
||||
|
||||
**What changes.**
|
||||
- The row is the section 5 question projection. The page changes nothing about who owns a question, a ruling or a status (section 2).
|
||||
- Phones: each row's options and note fold behind its title (section 1); the entry point is a **"Desk · N open"** line on the Mind's group header in the navigator, hidden at zero. Chromeless: hairlines are allowed, panels, cards and pills are not.
|
||||
- **Answer rule and wire.** The answer rule (the body the client may send, `options_hash`, the counting order, retraction, the stored `desk_answer`) is owned by Heimdall's rule file `/opt/commons/handoff/fork-desk-answer-binding-20261110.md`; the wire is owned by CultLib #72. This section restates neither. The client's part: it sends only what the rule's body allows (never `answerer` or authority), recomputes `options_hash` from what it rendered and refuses to send on a mismatch with the projection's.
|
||||
- **A pick sends at once** (one tap, one record; the latest per account stands, section 5). **The note autosaves as a local draft** and is sent on blur or an explicit Send, never per keystroke. "Saved" says which: sent, or kept on this device.
|
||||
- **Clear pick is a retraction**, a revision of the answer under the retraction rule in Heimdall's rule file (section 1.3a there); the one exception to section 5's "at least one of choice or text" is that rule's, not this page's. Until the receiver implements it, Clear pick clears only an unsent draft.
|
||||
- "Self leans" and a provisional flag exist in the original; the question has no field for either, so the desk shows neither until one exists.
|
||||
|
||||
**Order.** Section 9 step 1 stands: typed interface and fixtures first. The desk's first slice is read-only (note field disabled with a plain sentence that answering opens with sign-in), and answering waits on Bifrost sign-in reaching chat, the wire (CultLib #72) and the receiver.
|
||||
|
|
@ -2,12 +2,17 @@
|
|||
// non-zero when the page or the fake homeserver's log says otherwise.
|
||||
const fs = require('fs');
|
||||
const fails = [];
|
||||
let asserted = 0;
|
||||
exports.expect = (label, ok, detail = '') => {
|
||||
asserted += 1;
|
||||
console.log(`[ASSERT] ${ok ? 'PASS' : 'FAIL'} ${label}${ok ? '' : ` :: ${detail}`}`);
|
||||
if (!ok) fails.push(label);
|
||||
};
|
||||
/** Exit code for the drive: 4 when any assertion failed. */
|
||||
exports.done = () => (fails.length ? 4 : 0);
|
||||
/** Exit code for the drive: 4 when any assertion failed, 5 when none ran (a drive that asserts nothing proves nothing). */
|
||||
exports.done = () => {
|
||||
if (fails.length) return 4;
|
||||
return asserted === 0 ? 5 : 0;
|
||||
};
|
||||
/** The fake homeserver's request log (HSLOG is shared with the server process). */
|
||||
exports.hsLog = () => fs.readFileSync(process.env.HSLOG, 'utf8').split('\n');
|
||||
/** Bodies of the account-data PUTs for m.direct, in order. */
|
||||
|
|
|
|||
35
harness/estate/check.test.ts
Normal file
35
harness/estate/check.test.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
// @vitest-environment node
|
||||
import { createRequire } from 'module';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// A drive that asserts nothing proves nothing: its exit code must say so.
|
||||
const fresh = () => {
|
||||
const require = createRequire(import.meta.url);
|
||||
const path = require.resolve('./check.cjs');
|
||||
delete require.cache[path];
|
||||
return require('./check.cjs') as {
|
||||
expect: (label: string, ok: boolean, detail?: string) => void;
|
||||
done: () => number;
|
||||
};
|
||||
};
|
||||
|
||||
describe('estate drive exit code', () => {
|
||||
it('is 5 when no assertion ran', () => {
|
||||
expect(fresh().done()).toBe(5);
|
||||
});
|
||||
|
||||
it('is 0 when assertions ran and all held', () => {
|
||||
vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
const c = fresh();
|
||||
c.expect('holds', true);
|
||||
expect(c.done()).toBe(0);
|
||||
});
|
||||
|
||||
it('is 4 when any assertion failed', () => {
|
||||
vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
const c = fresh();
|
||||
c.expect('holds', true);
|
||||
c.expect('breaks', false, 'detail');
|
||||
expect(c.done()).toBe(4);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,12 +1,29 @@
|
|||
// Standalone page for the ridge scene: full window, no host app. `?tier=still|low|full` picks the
|
||||
// tier, `?probe=1` shows the rAF median/p95 readout. Not shipped.
|
||||
// tier, `?probe=1` shows the rAF median/p95 readout. The bake record (each chunk's cost, from
|
||||
// the scene's `onBake`) and the long tasks the page saw are on `window.bakes` and `window.longTasks`
|
||||
// for a driver to read. Not shipped.
|
||||
import { startBackgroundScene } from '../../src/app/features/scene';
|
||||
import type { SceneTier } from '../../src/app/features/scene';
|
||||
|
||||
const q = new URLSearchParams(location.search);
|
||||
const tier = (q.get('tier') ?? 'full') as SceneTier;
|
||||
const canvas = document.getElementById('c') as HTMLCanvasElement;
|
||||
startBackgroundScene({ tier }, {}, { canvas }).then((scene) => {
|
||||
(window as unknown as { scene: unknown }).scene = scene;
|
||||
const w = window as unknown as { scene: unknown; bakes: unknown[]; longTasks: number[]; firstViewMs: number };
|
||||
w.bakes = [];
|
||||
w.longTasks = [];
|
||||
try {
|
||||
new PerformanceObserver((l) => l.getEntries().forEach((e) => w.longTasks.push(e.duration))).observe({
|
||||
entryTypes: ['longtask'],
|
||||
});
|
||||
} catch {
|
||||
// No long-task entries in this browser.
|
||||
}
|
||||
const t0 = performance.now();
|
||||
startBackgroundScene({ tier }, {}, {
|
||||
canvas,
|
||||
onBake: (b: unknown) => w.bakes.push(b),
|
||||
} as never).then((scene) => {
|
||||
w.firstViewMs = performance.now() - t0;
|
||||
w.scene = scene;
|
||||
document.title = scene ? 'ridge: running' : 'ridge: unavailable (CSS ground)';
|
||||
});
|
||||
|
|
|
|||
17
public/dings/README.md
Normal file
17
public/dings/README.md
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# Dings
|
||||
|
||||
Pre-rendered earcons, served at `public/dings/` and read at runtime by `src/app/features/dings`.
|
||||
Aqua renders the clips; nothing here is synthesised in the browser.
|
||||
|
||||
dings/manifest.json
|
||||
dings/senders/<mxid-localpart>/<motif>.ogg
|
||||
dings/neutral/<motif>.ogg
|
||||
|
||||
`manifest.json` is `{ "neutral": { <motif>: "<path>" }, "senders": { <localpart>: { <motif>: "<path>" } } }`
|
||||
with motif one of `mention`, `dm`, `message`, `invite`; paths are relative to this folder, `.ogg` or `.mp3`.
|
||||
A sender's own clip wins, else the neutral clip, else silence. Without a `manifest.json` the client keeps
|
||||
its stock notification sounds; once one exists, a motif with no clip is silent.
|
||||
|
||||
The set here is Aqua's v4 (AquaSynth merge 21e83ec, staged at minds.gamecult.org/aqua/dings-voices-dd9be66),
|
||||
Nibu on proposal 3 (8 kHz 5-bit smoothed sine) for all four events. Nobody has heard it. The client plays none of it unless the
|
||||
client config sets `"dings": { "enabled": true }` (default off, as in `config.json`).
|
||||
58
public/dings/manifest.json
Normal file
58
public/dings/manifest.json
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
{
|
||||
"neutral": {
|
||||
"mention": "neutral/mention.ogg",
|
||||
"dm": "neutral/dm.ogg",
|
||||
"message": "neutral/message.ogg",
|
||||
"invite": "neutral/invite.ogg"
|
||||
},
|
||||
"senders": {
|
||||
"aqua": {
|
||||
"mention": "senders/aqua/mention.ogg",
|
||||
"dm": "senders/aqua/dm.ogg",
|
||||
"message": "senders/aqua/message.ogg",
|
||||
"invite": "senders/aqua/invite.ogg"
|
||||
},
|
||||
"eureka": {
|
||||
"mention": "senders/eureka/mention.ogg",
|
||||
"dm": "senders/eureka/dm.ogg",
|
||||
"message": "senders/eureka/message.ogg",
|
||||
"invite": "senders/eureka/invite.ogg"
|
||||
},
|
||||
"heimdall": {
|
||||
"mention": "senders/heimdall/mention.ogg",
|
||||
"dm": "senders/heimdall/dm.ogg",
|
||||
"message": "senders/heimdall/message.ogg",
|
||||
"invite": "senders/heimdall/invite.ogg"
|
||||
},
|
||||
"kiko": {
|
||||
"mention": "senders/kiko/mention.ogg",
|
||||
"dm": "senders/kiko/dm.ogg",
|
||||
"message": "senders/kiko/message.ogg",
|
||||
"invite": "senders/kiko/invite.ogg"
|
||||
},
|
||||
"libby": {
|
||||
"mention": "senders/libby/mention.ogg",
|
||||
"dm": "senders/libby/dm.ogg",
|
||||
"message": "senders/libby/message.ogg",
|
||||
"invite": "senders/libby/invite.ogg"
|
||||
},
|
||||
"nibu": {
|
||||
"mention": "senders/nibu/mention.ogg",
|
||||
"dm": "senders/nibu/dm.ogg",
|
||||
"message": "senders/nibu/message.ogg",
|
||||
"invite": "senders/nibu/invite.ogg"
|
||||
},
|
||||
"void": {
|
||||
"mention": "senders/void/mention.ogg",
|
||||
"dm": "senders/void/dm.ogg",
|
||||
"message": "senders/void/message.ogg",
|
||||
"invite": "senders/void/invite.ogg"
|
||||
},
|
||||
"weksa": {
|
||||
"mention": "senders/weksa/mention.ogg",
|
||||
"dm": "senders/weksa/dm.ogg",
|
||||
"message": "senders/weksa/message.ogg",
|
||||
"invite": "senders/weksa/invite.ogg"
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
public/dings/neutral/dm.ogg
Normal file
BIN
public/dings/neutral/dm.ogg
Normal file
Binary file not shown.
BIN
public/dings/neutral/invite.ogg
Normal file
BIN
public/dings/neutral/invite.ogg
Normal file
Binary file not shown.
BIN
public/dings/neutral/mention.ogg
Normal file
BIN
public/dings/neutral/mention.ogg
Normal file
Binary file not shown.
BIN
public/dings/neutral/message.ogg
Normal file
BIN
public/dings/neutral/message.ogg
Normal file
Binary file not shown.
BIN
public/dings/senders/aqua/dm.ogg
Normal file
BIN
public/dings/senders/aqua/dm.ogg
Normal file
Binary file not shown.
BIN
public/dings/senders/aqua/invite.ogg
Normal file
BIN
public/dings/senders/aqua/invite.ogg
Normal file
Binary file not shown.
BIN
public/dings/senders/aqua/mention.ogg
Normal file
BIN
public/dings/senders/aqua/mention.ogg
Normal file
Binary file not shown.
BIN
public/dings/senders/aqua/message.ogg
Normal file
BIN
public/dings/senders/aqua/message.ogg
Normal file
Binary file not shown.
BIN
public/dings/senders/eureka/dm.ogg
Normal file
BIN
public/dings/senders/eureka/dm.ogg
Normal file
Binary file not shown.
BIN
public/dings/senders/eureka/invite.ogg
Normal file
BIN
public/dings/senders/eureka/invite.ogg
Normal file
Binary file not shown.
BIN
public/dings/senders/eureka/mention.ogg
Normal file
BIN
public/dings/senders/eureka/mention.ogg
Normal file
Binary file not shown.
BIN
public/dings/senders/eureka/message.ogg
Normal file
BIN
public/dings/senders/eureka/message.ogg
Normal file
Binary file not shown.
BIN
public/dings/senders/heimdall/dm.ogg
Normal file
BIN
public/dings/senders/heimdall/dm.ogg
Normal file
Binary file not shown.
BIN
public/dings/senders/heimdall/invite.ogg
Normal file
BIN
public/dings/senders/heimdall/invite.ogg
Normal file
Binary file not shown.
BIN
public/dings/senders/heimdall/mention.ogg
Normal file
BIN
public/dings/senders/heimdall/mention.ogg
Normal file
Binary file not shown.
BIN
public/dings/senders/heimdall/message.ogg
Normal file
BIN
public/dings/senders/heimdall/message.ogg
Normal file
Binary file not shown.
BIN
public/dings/senders/kiko/dm.ogg
Normal file
BIN
public/dings/senders/kiko/dm.ogg
Normal file
Binary file not shown.
BIN
public/dings/senders/kiko/invite.ogg
Normal file
BIN
public/dings/senders/kiko/invite.ogg
Normal file
Binary file not shown.
BIN
public/dings/senders/kiko/mention.ogg
Normal file
BIN
public/dings/senders/kiko/mention.ogg
Normal file
Binary file not shown.
BIN
public/dings/senders/kiko/message.ogg
Normal file
BIN
public/dings/senders/kiko/message.ogg
Normal file
Binary file not shown.
BIN
public/dings/senders/libby/dm.ogg
Normal file
BIN
public/dings/senders/libby/dm.ogg
Normal file
Binary file not shown.
BIN
public/dings/senders/libby/invite.ogg
Normal file
BIN
public/dings/senders/libby/invite.ogg
Normal file
Binary file not shown.
BIN
public/dings/senders/libby/mention.ogg
Normal file
BIN
public/dings/senders/libby/mention.ogg
Normal file
Binary file not shown.
BIN
public/dings/senders/libby/message.ogg
Normal file
BIN
public/dings/senders/libby/message.ogg
Normal file
Binary file not shown.
BIN
public/dings/senders/nibu/dm.ogg
Normal file
BIN
public/dings/senders/nibu/dm.ogg
Normal file
Binary file not shown.
BIN
public/dings/senders/nibu/invite.ogg
Normal file
BIN
public/dings/senders/nibu/invite.ogg
Normal file
Binary file not shown.
BIN
public/dings/senders/nibu/mention.ogg
Normal file
BIN
public/dings/senders/nibu/mention.ogg
Normal file
Binary file not shown.
BIN
public/dings/senders/nibu/message.ogg
Normal file
BIN
public/dings/senders/nibu/message.ogg
Normal file
Binary file not shown.
BIN
public/dings/senders/void/dm.ogg
Normal file
BIN
public/dings/senders/void/dm.ogg
Normal file
Binary file not shown.
BIN
public/dings/senders/void/invite.ogg
Normal file
BIN
public/dings/senders/void/invite.ogg
Normal file
Binary file not shown.
BIN
public/dings/senders/void/mention.ogg
Normal file
BIN
public/dings/senders/void/mention.ogg
Normal file
Binary file not shown.
BIN
public/dings/senders/void/message.ogg
Normal file
BIN
public/dings/senders/void/message.ogg
Normal file
Binary file not shown.
BIN
public/dings/senders/weksa/dm.ogg
Normal file
BIN
public/dings/senders/weksa/dm.ogg
Normal file
Binary file not shown.
BIN
public/dings/senders/weksa/invite.ogg
Normal file
BIN
public/dings/senders/weksa/invite.ogg
Normal file
Binary file not shown.
BIN
public/dings/senders/weksa/mention.ogg
Normal file
BIN
public/dings/senders/weksa/mention.ogg
Normal file
Binary file not shown.
BIN
public/dings/senders/weksa/message.ogg
Normal file
BIN
public/dings/senders/weksa/message.ogg
Normal file
Binary file not shown.
9
scripts/build-dev-preview.sh
Executable file
9
scripts/build-dev-preview.sh
Executable file
|
|
@ -0,0 +1,9 @@
|
|||
#!/usr/bin/env bash
|
||||
# Build this checkout as the offline demo for the standing dev preview: base /void/rat-dev/, the pretend
|
||||
# homeserver, scene tier $TIER (default full). Output: ./dist, ready for the shared publisher
|
||||
# (gamecult-ops scripts/publish-mind-preview.sh dist rat-dev), which owns publishing. Publishes nothing.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
node scripts/sync-poses.mjs >/dev/null
|
||||
VITE_PREVIEW_DEMO=1 CINNY_BASE=/void/rat-dev/ NODE_OPTIONS=--max_old_space_size=4096 nice -n 10 npx vite build
|
||||
node scripts/preview-config.js "${TIER:-full}" --demo
|
||||
32
src/app/components/ServerConfigsLoader.test.ts
Normal file
32
src/app/components/ServerConfigsLoader.test.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { loadServerConfigs } from './ServerConfigsLoader';
|
||||
|
||||
const server = (authMetadata: () => Promise<unknown>) =>
|
||||
({
|
||||
getCapabilities: async () => ({}),
|
||||
getMediaConfig: async () => ({ 'm.upload.size': 1 }),
|
||||
getAuthMetadata: authMetadata,
|
||||
} as unknown as Parameters<typeof loadServerConfigs>[0]);
|
||||
|
||||
describe('loadServerConfigs', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('logs nothing when the homeserver has no OIDC (no auth metadata came back)', async () => {
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const configs = await loadServerConfigs(
|
||||
server(async () => {
|
||||
throw new Error('M_UNRECOGNIZED');
|
||||
})
|
||||
);
|
||||
expect(error).not.toHaveBeenCalled();
|
||||
expect(configs.authMetadata).toBeUndefined();
|
||||
expect(configs.mediaConfig).toEqual({ 'm.upload.size': 1 });
|
||||
});
|
||||
|
||||
it('still logs and drops auth metadata that came back malformed', async () => {
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const configs = await loadServerConfigs(server(async () => ({ issuer: 'https://x' })));
|
||||
expect(error).toHaveBeenCalled();
|
||||
expect(configs.authMetadata).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,10 @@
|
|||
import { ReactNode, useCallback, useMemo } from 'react';
|
||||
import { Capabilities, validateAuthMetadata, ValidatedAuthMetadata } from 'matrix-js-sdk';
|
||||
import {
|
||||
Capabilities,
|
||||
MatrixClient,
|
||||
validateAuthMetadata,
|
||||
ValidatedAuthMetadata,
|
||||
} from 'matrix-js-sdk';
|
||||
import { AsyncStatus, useAsyncCallbackValue } from '../hooks/useAsyncCallback';
|
||||
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||
import { MediaConfig } from '../hooks/useMediaConfig';
|
||||
|
|
@ -14,35 +19,42 @@ export type ServerConfigs = {
|
|||
type ServerConfigsLoaderProps = {
|
||||
children: (configs: ServerConfigs) => ReactNode;
|
||||
};
|
||||
/**
|
||||
* The homeserver's capabilities, media config and OIDC metadata. A homeserver with no OIDC (and the demo's pretend
|
||||
* server) returns no auth metadata: there is nothing to validate, so nothing is logged. Metadata that did come back
|
||||
* and fails validation is logged and dropped.
|
||||
*/
|
||||
export async function loadServerConfigs(
|
||||
mx: Pick<MatrixClient, 'getCapabilities' | 'getMediaConfig' | 'getAuthMetadata'>
|
||||
): Promise<ServerConfigs> {
|
||||
const result = await Promise.allSettled([
|
||||
mx.getCapabilities(),
|
||||
mx.getMediaConfig(),
|
||||
mx.getAuthMetadata(),
|
||||
]);
|
||||
|
||||
const capabilities = promiseFulfilledResult(result[0]);
|
||||
const mediaConfig = promiseFulfilledResult(result[1]);
|
||||
const authMetadata = promiseFulfilledResult(result[2]);
|
||||
let validatedAuthMetadata: ValidatedAuthMetadata | undefined;
|
||||
|
||||
if (authMetadata) {
|
||||
try {
|
||||
validatedAuthMetadata = validateAuthMetadata(authMetadata);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
return { capabilities, mediaConfig, authMetadata: validatedAuthMetadata };
|
||||
}
|
||||
|
||||
export function ServerConfigsLoader({ children }: ServerConfigsLoaderProps) {
|
||||
const mx = useMatrixClient();
|
||||
const fallbackConfigs = useMemo(() => ({}), []);
|
||||
|
||||
const [configsState] = useAsyncCallbackValue<ServerConfigs, unknown>(
|
||||
useCallback(async () => {
|
||||
const result = await Promise.allSettled([
|
||||
mx.getCapabilities(),
|
||||
mx.getMediaConfig(),
|
||||
mx.getAuthMetadata(),
|
||||
]);
|
||||
|
||||
const capabilities = promiseFulfilledResult(result[0]);
|
||||
const mediaConfig = promiseFulfilledResult(result[1]);
|
||||
const authMetadata = promiseFulfilledResult(result[2]);
|
||||
let validatedAuthMetadata: ValidatedAuthMetadata | undefined;
|
||||
|
||||
try {
|
||||
validatedAuthMetadata = validateAuthMetadata(authMetadata);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
return {
|
||||
capabilities,
|
||||
mediaConfig,
|
||||
authMetadata: validatedAuthMetadata,
|
||||
};
|
||||
}, [mx])
|
||||
useCallback(() => loadServerConfigs(mx), [mx])
|
||||
);
|
||||
|
||||
const configs: ServerConfigs =
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ export const Reply = style({
|
|||
});
|
||||
|
||||
export const ReplyContent = style({
|
||||
minWidth: 0,
|
||||
opacity: config.opacity.P300,
|
||||
|
||||
selectors: {
|
||||
|
|
@ -39,3 +40,19 @@ export const ReplyContent = style({
|
|||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const ReplyText = style({
|
||||
display: '-webkit-box',
|
||||
WebkitBoxOrient: 'vertical',
|
||||
WebkitLineClamp: 2,
|
||||
overflow: 'hidden',
|
||||
minWidth: 0,
|
||||
maxWidth: '100%',
|
||||
overflowWrap: 'anywhere',
|
||||
wordBreak: 'break-word',
|
||||
});
|
||||
|
||||
export const ReplyWrap = style({
|
||||
minWidth: 0,
|
||||
maxWidth: '100%',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -101,7 +101,14 @@ export const Reply = as<'div', ReplyProps>(
|
|||
const bodyJSX = body ? scaleSystemEmoji(trimReplyFromBody(body)) : fallbackBody;
|
||||
|
||||
return (
|
||||
<Box direction="Row" gap="200" alignItems="Center" {...props} ref={ref}>
|
||||
<Box
|
||||
className={css.ReplyWrap}
|
||||
direction="Row"
|
||||
gap="200"
|
||||
alignItems="Center"
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
{threadRootId && (
|
||||
<ThreadIndicator as="button" data-event-id={threadRootId} onClick={onClick} />
|
||||
)}
|
||||
|
|
@ -119,7 +126,7 @@ export const Reply = as<'div', ReplyProps>(
|
|||
onClick={onClick}
|
||||
>
|
||||
{replyEvent !== undefined ? (
|
||||
<Text size="T300" truncate>
|
||||
<Text size="T300" className={css.ReplyText}>
|
||||
{badEncryption ? <MessageBadEncryptedContent /> : bodyJSX}
|
||||
</Text>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { createVar, globalStyle, keyframes, style, styleVariants } from '@vanilla-extract/css';
|
||||
import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
|
||||
import { DefaultReset, color, config, toRem } from 'folds';
|
||||
import { MOBILE_BREAKPOINT } from '../../../hooks/useScreenSize';
|
||||
|
||||
export const StickySection = style({
|
||||
position: 'sticky',
|
||||
|
|
@ -283,6 +284,9 @@ export const BubbleLeftArrow = style({
|
|||
zIndex: 1,
|
||||
});
|
||||
|
||||
// Phone tap target: a sender name is 23 px tall, so the button grows 11 px above and below (44+ px to tap) and the
|
||||
// negative margin takes the growth back out of the layout. Hit area only: no background, border or outline moves.
|
||||
const SCREEN_PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`;
|
||||
export const Username = style({
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
|
|
@ -290,6 +294,9 @@ export const Username = style({
|
|||
selectors: {
|
||||
'button&': {
|
||||
cursor: 'pointer',
|
||||
'@media': {
|
||||
[SCREEN_PHONE]: { paddingBlock: 11, marginBlock: -11 },
|
||||
},
|
||||
},
|
||||
'button&:hover, button&:focus-visible': {
|
||||
textDecoration: 'underline',
|
||||
|
|
|
|||
|
|
@ -13,7 +13,8 @@ type PoseAvatarProps = {
|
|||
alt?: boolean;
|
||||
/** Play the first-appearance pop. */
|
||||
enter?: boolean;
|
||||
onClick: React.MouseEventHandler<HTMLButtonElement>;
|
||||
/** Without it the portrait is a plain image: no button, no tab stop. */
|
||||
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
||||
};
|
||||
|
||||
// Safe boxes derived from art's alpha, by source; the art is traced once.
|
||||
|
|
@ -114,9 +115,10 @@ export function PoseAvatar({ mind, pose, userId, alt, enter, onClick }: PoseAvat
|
|||
} as React.CSSProperties)
|
||||
: undefined;
|
||||
|
||||
const Tag = onClick ? 'button' : 'span';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
<Tag
|
||||
type={onClick ? 'button' : undefined}
|
||||
className={classNames(css.PoseButton, alt && css.PoseButtonAlt, enter && css.EnterPortrait)}
|
||||
style={{ ...cropStyle, shapeOutside: shape }}
|
||||
data-user-id={userId}
|
||||
|
|
@ -130,6 +132,6 @@ export function PoseAvatar({ mind, pose, userId, alt, enter, onClick }: PoseAvat
|
|||
alt={`${mind}, ${pose}`}
|
||||
draggable={false}
|
||||
/>
|
||||
</button>
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { ComplexStyleRule, StyleRule, createVar, globalStyle, style } from '@van
|
|||
import { RecipeVariants, recipe } from '@vanilla-extract/recipes';
|
||||
import { ContainerColor, DefaultReset, Disabled, RadiiVariant, color, config, toRem } from 'folds';
|
||||
import { surfaceInk } from '../../features/scene/sceneShell.css';
|
||||
import { skyLabelInk } from '../../features/scene/skyLabelInk';
|
||||
|
||||
// Wash strengths over the scene: rows and labels are faint, and hover, selected and pressed rows
|
||||
// are three distinct steps above them.
|
||||
|
|
@ -222,6 +223,7 @@ phoneLabel(`.${NavItemBase}:has(.${NavLink}:active) ${label}`, {
|
|||
textDecorationLine: 'underline',
|
||||
});
|
||||
phoneLabel(`:root[data-scene] ${label}`, {
|
||||
color: skyLabelInk('inherit'),
|
||||
textShadow: `0 0 3px ${surfaceInk}, 0 0 8px ${surfaceInk}`,
|
||||
});
|
||||
|
||||
|
|
|
|||
51
src/app/features/albums/AlbumView.css.ts
Normal file
51
src/app/features/albums/AlbumView.css.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { color, config, toRem } from 'folds';
|
||||
import { MOBILE_BREAKPOINT } from '../../hooks/useScreenSize';
|
||||
|
||||
const WIDE = `screen and (min-width: ${MOBILE_BREAKPOINT + 1}px)`;
|
||||
|
||||
/** One column, bare ink on the scene. */
|
||||
export const Column = style({
|
||||
maxWidth: toRem(720),
|
||||
width: '100%',
|
||||
margin: '0 auto',
|
||||
padding: config.space.S400,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: config.space.S200,
|
||||
});
|
||||
|
||||
/** A tappable text row: no border, no fill, at least 44 px tall. */
|
||||
export const Row = style({
|
||||
all: 'unset',
|
||||
boxSizing: 'border-box',
|
||||
cursor: 'pointer',
|
||||
minHeight: 44,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
gap: toRem(2),
|
||||
color: color.Background.OnContainer,
|
||||
':focus-visible': { textDecoration: 'underline' },
|
||||
});
|
||||
|
||||
export const Back = style([Row, { minHeight: 44 }]);
|
||||
|
||||
export const Files = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: config.space.S400,
|
||||
});
|
||||
|
||||
/** Images may tile into a grid on wide screens only. */
|
||||
export const FilesWide = style({
|
||||
'@media': {
|
||||
[WIDE]: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
|
||||
alignItems: 'start',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const Dim = style({ opacity: config.opacity.P300 });
|
||||
141
src/app/features/albums/AlbumView.tsx
Normal file
141
src/app/features/albums/AlbumView.tsx
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Box, Scroll, Text } from 'folds';
|
||||
import { Room, RoomEvent } from 'matrix-js-sdk';
|
||||
import { MediaControl } from '../../components/media';
|
||||
import { AudioContent } from '../../components/message';
|
||||
import { RenderMessageContent } from '../../components/RenderMessageContent';
|
||||
import { IAudioContent } from '../../../types/matrix/common';
|
||||
import { LINKIFY_OPTS, getReactCustomHtmlParser } from '../../plugins/react-custom-html-parser';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { markAsRead } from '../../utils/notifications';
|
||||
import { getBlobSafeMimeType } from '../../utils/mimeTypes';
|
||||
import { timeDayMonYear } from '../../utils/time';
|
||||
import { getMemberDisplayName } from '../../utils/room';
|
||||
import { Album, AlbumFile } from './buildAlbums';
|
||||
import { useAlbums } from './useAlbums';
|
||||
import * as css from './AlbumView.css';
|
||||
|
||||
/**
|
||||
* Audio goes straight to AudioContent, skipping the chat Attachment frame and file-type badge: the
|
||||
* title above it is the only label and the transport controls sit on the scene. Anything that is
|
||||
* not playable audio falls through to the stock renderer (a file download).
|
||||
*/
|
||||
const playableAudio = (content: Record<string, unknown>) => {
|
||||
const c = content as IAudioContent;
|
||||
const url = c.file?.url ?? c.url;
|
||||
const mimeType = getBlobSafeMimeType(c.info?.mimetype ?? '');
|
||||
if (!c.info || typeof url !== 'string' || !mimeType.startsWith('audio')) return undefined;
|
||||
return { info: c.info, url, mimeType, encInfo: c.file };
|
||||
};
|
||||
|
||||
type FileRowProps = { room: Room; file: AlbumFile; author: string };
|
||||
function FileRow({ room, file, author }: FileRowProps) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
||||
const htmlReactParserOptions = useMemo(
|
||||
() =>
|
||||
getReactCustomHtmlParser(mx, room.roomId, { linkifyOpts: LINKIFY_OPTS, useAuthentication }),
|
||||
[mx, room.roomId, useAuthentication]
|
||||
);
|
||||
const audio = file.kind === 'audio' ? playableAudio(file.content) : undefined;
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="T300" truncate>
|
||||
{file.name}
|
||||
</Text>
|
||||
{audio ? (
|
||||
<AudioContent {...audio} renderMediaControl={(p) => <MediaControl {...p} />} />
|
||||
) : (
|
||||
<RenderMessageContent
|
||||
displayName={author}
|
||||
msgType={file.content.msgtype as string}
|
||||
ts={file.ts}
|
||||
getContent={<T,>() => file.content as T}
|
||||
mediaAutoLoad={mediaAutoLoad}
|
||||
htmlReactParserOptions={htmlReactParserOptions}
|
||||
linkifyOpts={LINKIFY_OPTS}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const countLabel = (n: number) => `${n} ${n === 1 ? 'file' : 'files'}`;
|
||||
|
||||
/** The zone never mounts RoomTimeline, so it sends the read receipt the timeline would. */
|
||||
function useMarkZoneRead(room: Room) {
|
||||
const mx = useMatrixClient();
|
||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||
useEffect(() => {
|
||||
const mark = () => {
|
||||
markAsRead(mx, room.roomId, hideActivity).catch(() => undefined);
|
||||
};
|
||||
mark();
|
||||
room.on(RoomEvent.Timeline, mark);
|
||||
return () => {
|
||||
room.off(RoomEvent.Timeline, mark);
|
||||
};
|
||||
}, [mx, room, hideActivity]);
|
||||
}
|
||||
|
||||
type AlbumViewProps = { room: Room; onTimeline: () => void };
|
||||
export function AlbumView({ room, onTimeline }: AlbumViewProps) {
|
||||
useMarkZoneRead(room);
|
||||
const { loading, error, albums } = useAlbums(room);
|
||||
const [dateFormat] = useSetting(settingsAtom, 'dateFormatString');
|
||||
const [openId, setOpenId] = useState<string>();
|
||||
const open: Album | undefined = albums.find((a) => a.id === openId);
|
||||
const authorOf = (a: Album) => getMemberDisplayName(room, a.author) ?? a.author;
|
||||
const encrypted = room.hasEncryptionStateEvent();
|
||||
|
||||
let body: React.ReactNode;
|
||||
if (loading) body = <Text>Loading albums…</Text>;
|
||||
else if (error) body = <Text>Could not load albums: {error.message}</Text>;
|
||||
else if (open) {
|
||||
const images = open.files.length > 0 && open.files.every((f) => f.kind === 'image');
|
||||
body = (
|
||||
<>
|
||||
<button type="button" className={css.Back} onClick={() => setOpenId(undefined)}>
|
||||
<Text size="B400">‹ Albums</Text>
|
||||
</button>
|
||||
<Text size="H4">{open.title}</Text>
|
||||
<Text size="T200" className={css.Dim}>
|
||||
{authorOf(open)} · {countLabel(open.count)} · {timeDayMonYear(open.ts, dateFormat)}
|
||||
</Text>
|
||||
{open.count === 0 && <Text>This album has no files.</Text>}
|
||||
<div className={images ? `${css.Files} ${css.FilesWide}` : css.Files}>
|
||||
{open.files.map((f) => (
|
||||
<FileRow key={f.id} room={room} file={f} author={authorOf(open)} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
} else if (encrypted)
|
||||
body = <Text>This zone is encrypted, so its albums cannot be listed here.</Text>;
|
||||
else if (albums.length === 0) body = <Text>No albums yet.</Text>;
|
||||
else {
|
||||
body = albums.map((a) => (
|
||||
<button key={a.id} type="button" className={css.Row} onClick={() => setOpenId(a.id)}>
|
||||
<Text size="T400">{a.title}</Text>
|
||||
<Text size="T200" className={css.Dim}>
|
||||
{authorOf(a)} · {countLabel(a.count)} · {timeDayMonYear(a.ts, dateFormat)}
|
||||
</Text>
|
||||
</button>
|
||||
));
|
||||
}
|
||||
|
||||
return (
|
||||
<Scroll hideTrack visibility="Hover">
|
||||
<div className={css.Column}>
|
||||
{body}
|
||||
<button type="button" className={css.Row} onClick={onTimeline}>
|
||||
<Text size="B400">Timeline ›</Text>
|
||||
</button>
|
||||
</div>
|
||||
</Scroll>
|
||||
);
|
||||
}
|
||||
216
src/app/features/albums/buildAlbums.test.ts
Normal file
216
src/app/features/albums/buildAlbums.test.ts
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { AlbumInput, ZoneEvent, buildAlbums } from './buildAlbums';
|
||||
|
||||
const root = (id: string, ts: number, over: Partial<ZoneEvent> = {}): ZoneEvent => ({
|
||||
event_id: id,
|
||||
type: 'm.room.message',
|
||||
sender: '@void:x',
|
||||
origin_server_ts: ts,
|
||||
content: { msgtype: 'm.text', body: `album ${id}` },
|
||||
...over,
|
||||
});
|
||||
const reply = (id: string, ts: number, msgtype: string, body = id, extra = {}): ZoneEvent => ({
|
||||
event_id: id,
|
||||
type: 'm.room.message',
|
||||
sender: '@void:x',
|
||||
origin_server_ts: ts,
|
||||
content: { msgtype, body, ...extra },
|
||||
});
|
||||
const one = (r: ZoneEvent, replies: ZoneEvent[]) => buildAlbums([{ root: r, replies }])[0];
|
||||
|
||||
describe('buildAlbums', () => {
|
||||
it('lists the newest album first', () => {
|
||||
const inputs: AlbumInput[] = [
|
||||
{ root: root('$old', 100), replies: [] },
|
||||
{ root: root('$new', 300), replies: [] },
|
||||
{ root: root('$mid', 200), replies: [] },
|
||||
];
|
||||
expect(buildAlbums(inputs).map((a) => a.id)).toEqual(['$new', '$mid', '$old']);
|
||||
});
|
||||
|
||||
it('takes title, author and date from the root', () => {
|
||||
const a = one(root('$r', 555, { sender: '@eureka:x' }), []);
|
||||
expect([a.title, a.author, a.ts]).toEqual(['album $r', '@eureka:x', 555]);
|
||||
});
|
||||
|
||||
it('lists files oldest first whatever order the server returned them in', () => {
|
||||
const a = one(root('$r', 1), [
|
||||
reply('$c', 30, 'm.audio'),
|
||||
reply('$a', 10, 'm.audio'),
|
||||
reply('$b', 20, 'm.audio'),
|
||||
]);
|
||||
expect(a.files.map((f) => f.id)).toEqual(['$a', '$b', '$c']);
|
||||
});
|
||||
|
||||
it('counts media replies only; a text reply is not a file', () => {
|
||||
const a = one(root('$r', 1), [
|
||||
reply('$t', 5, 'm.text', 'nice'),
|
||||
reply('$n', 6, 'm.notice', 'bot'),
|
||||
reply('$a', 10, 'm.audio'),
|
||||
reply('$f', 11, 'm.file'),
|
||||
]);
|
||||
expect(a.count).toBe(2);
|
||||
expect(a.files.map((f) => f.id)).toEqual(['$a', '$f']);
|
||||
});
|
||||
|
||||
it('keeps a root with no media as an album of zero files', () => {
|
||||
const a = one(root('$r', 1), [reply('$t', 5, 'm.text')]);
|
||||
expect(a).toMatchObject({ id: '$r', count: 0, files: [], cover: undefined });
|
||||
});
|
||||
|
||||
it('uses the first image as the cover', () => {
|
||||
const a = one(root('$r', 1), [
|
||||
reply('$a', 5, 'm.audio'),
|
||||
reply('$i2', 20, 'm.image'),
|
||||
reply('$i1', 10, 'm.image'),
|
||||
]);
|
||||
expect(a.cover?.id).toBe('$i1');
|
||||
expect(one(root('$q', 1), [reply('$a', 5, 'm.audio')]).cover).toBeUndefined();
|
||||
});
|
||||
|
||||
it('derives the kind from the msgtype', () => {
|
||||
const a = one(root('$r', 1), [
|
||||
reply('$1', 1, 'm.audio'),
|
||||
reply('$2', 2, 'm.image'),
|
||||
reply('$3', 3, 'm.video'),
|
||||
reply('$4', 4, 'm.file'),
|
||||
]);
|
||||
expect(a.files.map((f) => f.kind)).toEqual(['audio', 'image', 'video', 'file']);
|
||||
});
|
||||
|
||||
it('names a file by filename, else body', () => {
|
||||
const a = one(root('$r', 1), [
|
||||
reply('$1', 1, 'm.audio', 'caption', { filename: 'clip.wav' }),
|
||||
reply('$2', 2, 'm.audio', 'plain.wav'),
|
||||
]);
|
||||
expect(a.files.map((f) => f.name)).toEqual(['clip.wav', 'plain.wav']);
|
||||
});
|
||||
|
||||
it('does not duplicate a file the relations endpoint returned twice', () => {
|
||||
const a = one(root('$r', 1), [reply('$a', 5, 'm.audio'), reply('$a', 5, 'm.audio')]);
|
||||
expect(a.count).toBe(1);
|
||||
});
|
||||
|
||||
it('does not duplicate an album listed twice', () => {
|
||||
expect(
|
||||
buildAlbums([
|
||||
{ root: root('$r', 1), replies: [] },
|
||||
{ root: root('$r', 1), replies: [] },
|
||||
])
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('skips redacted and empty events', () => {
|
||||
const redacted = {
|
||||
...reply('$x', 5, 'm.audio'),
|
||||
content: {},
|
||||
unsigned: { redacted_because: {} },
|
||||
};
|
||||
const stillTyped = { ...reply('$y', 6, 'm.audio'), unsigned: { redacted_because: {} } };
|
||||
const empty: ZoneEvent = { event_id: '$z' };
|
||||
const a = one(root('$r', 1), [redacted, stillTyped, empty, reply('$ok', 7, 'm.audio')]);
|
||||
expect(a.files.map((f) => f.id)).toEqual(['$ok']);
|
||||
expect(buildAlbums([{ root: root('$g', 1, { content: {} }), replies: [] }])).toEqual([]);
|
||||
expect(
|
||||
buildAlbums([{ root: root('$h', 1, { unsigned: { redacted_because: {} } }), replies: [] }])
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('tolerates events with missing fields', () => {
|
||||
const a = one({ event_id: '$r', content: { msgtype: 'm.text' } }, [
|
||||
{ event_id: '$a', content: { msgtype: 'm.audio' } },
|
||||
]);
|
||||
expect(a).toMatchObject({ title: 'Untitled album', author: '', ts: 0, count: 1 });
|
||||
});
|
||||
|
||||
it('does not read inherited object keys as file kinds', () => {
|
||||
const a = one(root('$r', 1), [
|
||||
reply('$c', 1, 'constructor'),
|
||||
reply('$p', 2, '__proto__'),
|
||||
reply('$t', 3, 'toString'),
|
||||
]);
|
||||
expect(a.count).toBe(0);
|
||||
});
|
||||
|
||||
it('skips events that are not room messages even if their content looks like media', () => {
|
||||
const reaction = { ...reply('$x', 5, 'm.audio'), type: 'm.reaction' };
|
||||
const a = one(root('$r', 1), [reaction, reply('$ok', 6, 'm.audio')]);
|
||||
expect(a.files.map((f) => f.id)).toEqual(['$ok']);
|
||||
expect(buildAlbums([{ root: root('$g', 1, { type: 'm.reaction' }), replies: [] }])).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips events without an event id', () => {
|
||||
const anon: ZoneEvent = { type: 'm.room.message', content: { msgtype: 'm.audio', body: 'a' } };
|
||||
expect(one(root('$r', 1), [anon]).count).toBe(0);
|
||||
expect(
|
||||
buildAlbums([{ root: { content: { msgtype: 'm.text', body: 'x' } }, replies: [] }])
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('titles an edited album by its latest edit', () => {
|
||||
const edited = root('$r', 1, {
|
||||
unsigned: {
|
||||
'm.relations': {
|
||||
'm.replace': { content: { 'm.new_content': { msgtype: 'm.text', body: 'renamed' } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(one(edited, []).title).toBe('renamed');
|
||||
const emptyEdit = root('$q', 1, { unsigned: { 'm.relations': { 'm.replace': {} } } });
|
||||
expect(one(emptyEdit, []).title).toBe('album $q');
|
||||
});
|
||||
|
||||
it('survives null or non-object entries in inputs and replies', () => {
|
||||
const bad = [null, undefined, 'x', 7] as unknown as ZoneEvent[];
|
||||
const a = buildAlbums([
|
||||
null as unknown as AlbumInput,
|
||||
{ root: null as unknown as ZoneEvent, replies: [] },
|
||||
{ root: root('$r', 1), replies: [...bad, reply('$ok', 2, 'm.audio')] },
|
||||
{ root: root('$s', 2), replies: null as unknown as ZoneEvent[] },
|
||||
]);
|
||||
expect(a.map((x) => x.id)).toEqual(['$s', '$r']);
|
||||
expect(a[1].files.map((f) => f.id)).toEqual(['$ok']);
|
||||
});
|
||||
|
||||
it('treats a non-numeric timestamp as 0 so it sorts first, not by string order', () => {
|
||||
const a = one(root('$r', 1), [
|
||||
reply('$b', 20, 'm.audio'),
|
||||
{ ...reply('$bad', 0, 'm.audio'), origin_server_ts: '5' as unknown as number },
|
||||
{ ...reply('$nan', 0, 'm.audio'), origin_server_ts: NaN },
|
||||
reply('$a', 10, 'm.audio'),
|
||||
]);
|
||||
expect(a.files.map((f) => f.id)).toEqual(['$bad', '$nan', '$a', '$b']);
|
||||
const albums = buildAlbums([
|
||||
{ root: root('$x', 100), replies: [] },
|
||||
{ root: root('$y', 0, { origin_server_ts: '999' as unknown as number }), replies: [] },
|
||||
]);
|
||||
expect(albums.map((x) => [x.id, x.ts])).toEqual([
|
||||
['$x', 100],
|
||||
['$y', 0],
|
||||
]);
|
||||
});
|
||||
|
||||
it('gives an album with an empty body a fallback title', () => {
|
||||
expect(one(root('$r', 1, { content: { msgtype: 'm.text', body: '' } }), []).title).toBe(
|
||||
'Untitled album'
|
||||
);
|
||||
expect(one(root('$q', 1, { content: { msgtype: 'm.text' } }), []).title).toBe('Untitled album');
|
||||
});
|
||||
|
||||
it('never counts an edit event as a file or an album', () => {
|
||||
const edit = reply('$e', 9, 'm.audio', '* x', {
|
||||
'm.relates_to': { rel_type: 'm.replace', event_id: '$a' },
|
||||
'm.new_content': { msgtype: 'm.audio', body: 'x' },
|
||||
});
|
||||
const a = one(root('$r', 1), [reply('$a', 5, 'm.audio'), edit]);
|
||||
expect(a.files.map((f) => f.id)).toEqual(['$a']);
|
||||
const rootEdit = root('$re', 1, {
|
||||
content: { msgtype: 'm.text', body: '* t', 'm.relates_to': { rel_type: 'm.replace' } },
|
||||
});
|
||||
expect(buildAlbums([{ root: rootEdit, replies: [] }])).toEqual([]);
|
||||
const threaded = reply('$t', 6, 'm.audio', 't', {
|
||||
'm.relates_to': { rel_type: 'm.thread', event_id: '$r' },
|
||||
});
|
||||
expect(one(root('$q', 1), [threaded]).count).toBe(1);
|
||||
});
|
||||
});
|
||||
121
src/app/features/albums/buildAlbums.ts
Normal file
121
src/app/features/albums/buildAlbums.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
// Pure: turns the thread roots of a file-zone room and each root's thread replies, in the JSON
|
||||
// Synapse returns from GET /rooms/{roomId}/threads and GET /rooms/{roomId}/relations/{root}/m.thread,
|
||||
// into albums. See docs/file-zone.md for the rulings this encodes.
|
||||
|
||||
/** The slice of a Matrix event this module reads. Every field may be missing. */
|
||||
export type ZoneEvent = {
|
||||
event_id?: string;
|
||||
type?: string;
|
||||
sender?: string;
|
||||
origin_server_ts?: number;
|
||||
content?: Record<string, unknown>;
|
||||
unsigned?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type AlbumInput = { root: ZoneEvent; replies: ZoneEvent[] };
|
||||
|
||||
export type FileKind = 'audio' | 'image' | 'video' | 'file';
|
||||
|
||||
export type AlbumFile = {
|
||||
id: string;
|
||||
kind: FileKind;
|
||||
name: string;
|
||||
ts: number;
|
||||
/** The event content, as RenderMessageContent reads it. */
|
||||
content: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type Album = {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string;
|
||||
ts: number;
|
||||
cover?: AlbumFile;
|
||||
files: AlbumFile[];
|
||||
count: number;
|
||||
};
|
||||
|
||||
// A Map, not an object: msgtype is server-supplied and "constructor" must not name a kind.
|
||||
const KIND_OF_MSGTYPE = new Map<string, FileKind>([
|
||||
['m.audio', 'audio'],
|
||||
['m.image', 'image'],
|
||||
['m.video', 'video'],
|
||||
['m.file', 'file'],
|
||||
]);
|
||||
|
||||
// A redacted event keeps its id but loses its content and gains unsigned.redacted_because.
|
||||
const live = (
|
||||
e: ZoneEvent
|
||||
): e is ZoneEvent & { event_id: string; content: Record<string, unknown> } =>
|
||||
!!e &&
|
||||
typeof e === 'object' &&
|
||||
!!e.event_id &&
|
||||
// An edit is an m.room.message too, but it revises an event; it is never a file or an album.
|
||||
(e.content?.['m.relates_to'] as { rel_type?: unknown } | undefined)?.rel_type !== 'm.replace' &&
|
||||
!e.unsigned?.redacted_because &&
|
||||
(e.type === undefined || e.type === 'm.room.message') &&
|
||||
typeof e.content?.msgtype === 'string';
|
||||
|
||||
const tsOf = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
|
||||
|
||||
const asString = (v: unknown): string => (typeof v === 'string' ? v : '');
|
||||
|
||||
const toFile = (e: ZoneEvent): AlbumFile | undefined => {
|
||||
if (!live(e)) return undefined;
|
||||
const kind = KIND_OF_MSGTYPE.get(e.content.msgtype as string);
|
||||
if (!kind) return undefined;
|
||||
return {
|
||||
id: e.event_id,
|
||||
kind,
|
||||
name: asString(e.content.filename) || asString(e.content.body),
|
||||
ts: tsOf(e.origin_server_ts),
|
||||
content: e.content,
|
||||
};
|
||||
};
|
||||
|
||||
export const UNTITLED = 'Untitled album';
|
||||
|
||||
// An edited root carries its latest edit under unsigned["m.relations"]["m.replace"]; the title is
|
||||
// that edit's m.new_content.body, else the original body.
|
||||
const titleOf = (root: ZoneEvent & { content: Record<string, unknown> }): string => {
|
||||
const relations = root.unsigned?.['m.relations'] as Record<string, unknown> | undefined;
|
||||
const edit = relations?.['m.replace'] as ZoneEvent | undefined;
|
||||
const edited = (edit?.content?.['m.new_content'] as Record<string, unknown> | undefined)?.body;
|
||||
return asString(edited) || asString(root.content.body) || UNTITLED;
|
||||
};
|
||||
|
||||
const toAlbum = (input: AlbumInput): Album | undefined => {
|
||||
const { root, replies } = input ?? {};
|
||||
if (!live(root)) return undefined;
|
||||
const seen = new Set<string>();
|
||||
const files = (Array.isArray(replies) ? replies : [])
|
||||
.map(toFile)
|
||||
.filter((f): f is AlbumFile => {
|
||||
if (!f || seen.has(f.id)) return false;
|
||||
seen.add(f.id);
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => a.ts - b.ts);
|
||||
return {
|
||||
id: root.event_id,
|
||||
title: titleOf(root),
|
||||
author: root.sender ?? '',
|
||||
ts: tsOf(root.origin_server_ts),
|
||||
cover: files.find((f) => f.kind === 'image'),
|
||||
files,
|
||||
count: files.length,
|
||||
};
|
||||
};
|
||||
|
||||
/** Newest album first; files oldest first. Loose room messages never reach this function. */
|
||||
export const buildAlbums = (inputs: AlbumInput[]): Album[] => {
|
||||
const seen = new Set<string>();
|
||||
return inputs
|
||||
.map(toAlbum)
|
||||
.filter((a): a is Album => {
|
||||
if (!a || seen.has(a.id)) return false;
|
||||
seen.add(a.id);
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => b.ts - a.ts);
|
||||
};
|
||||
24
src/app/features/albums/isFileZone.test.ts
Normal file
24
src/app/features/albums/isFileZone.test.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { isFileZone } from './isFileZone';
|
||||
|
||||
const room = { roomId: '!f:x', aliases: ['#files:x'] };
|
||||
|
||||
describe('isFileZone', () => {
|
||||
it('is true for the file-zone create type', () => {
|
||||
expect(isFileZone({ ...room, createType: 'org.gamecult.file_zone' }, {})).toBe(true);
|
||||
});
|
||||
it('is false for other create types and for plain rooms', () => {
|
||||
expect(isFileZone({ ...room, createType: 'm.space' }, {})).toBe(false);
|
||||
expect(isFileZone({ ...room, createType: 'org.gamecult.other' }, {})).toBe(false);
|
||||
expect(isFileZone({ ...room, createType: 'org.gamecult.file_zone.x' }, {})).toBe(false);
|
||||
expect(isFileZone(room, {})).toBe(false);
|
||||
expect(isFileZone(room, { fileZoneRooms: [] })).toBe(false);
|
||||
});
|
||||
it('is true when the room id or an alias is configured', () => {
|
||||
expect(isFileZone(room, { fileZoneRooms: ['!f:x'] })).toBe(true);
|
||||
expect(isFileZone(room, { fileZoneRooms: ['#other:x', '#files:x'] })).toBe(true);
|
||||
});
|
||||
it('is false when only another room is configured', () => {
|
||||
expect(isFileZone(room, { fileZoneRooms: ['!g:x', '#g:x'] })).toBe(false);
|
||||
});
|
||||
});
|
||||
14
src/app/features/albums/isFileZone.ts
Normal file
14
src/app/features/albums/isFileZone.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
export const FILE_ZONE_TYPE = 'org.gamecult.file_zone';
|
||||
|
||||
export type FileZoneRoom = {
|
||||
roomId: string;
|
||||
/** Canonical and alternative aliases. */
|
||||
aliases?: string[];
|
||||
/** The `type` of the room's m.room.create content. */
|
||||
createType?: string;
|
||||
};
|
||||
|
||||
/** A room is the file zone when its create type says so or the client config lists it. */
|
||||
export const isFileZone = (room: FileZoneRoom, config: { fileZoneRooms?: string[] }): boolean =>
|
||||
room.createType === FILE_ZONE_TYPE ||
|
||||
!!config.fileZoneRooms?.some((r) => r === room.roomId || room.aliases?.includes(r));
|
||||
29
src/app/features/albums/mapLimit.test.ts
Normal file
29
src/app/features/albums/mapLimit.test.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { mapLimit } from './mapLimit';
|
||||
|
||||
describe('mapLimit', () => {
|
||||
it('keeps result order and never exceeds the limit', async () => {
|
||||
let inFlight = 0;
|
||||
let peak = 0;
|
||||
const out = await mapLimit([5, 1, 4, 2, 3, 0], 2, async (n) => {
|
||||
inFlight += 1;
|
||||
peak = Math.max(peak, inFlight);
|
||||
await new Promise((r) => {
|
||||
setTimeout(r, n);
|
||||
});
|
||||
inFlight -= 1;
|
||||
return n * 10;
|
||||
});
|
||||
expect(out).toEqual([50, 10, 40, 20, 30, 0]);
|
||||
expect(peak).toBe(2);
|
||||
});
|
||||
it('handles an empty list and rejects when a call rejects', async () => {
|
||||
expect(await mapLimit([], 3, async (n) => n)).toEqual([]);
|
||||
await expect(
|
||||
mapLimit([1, 2], 2, async (n) => {
|
||||
if (n === 2) throw new Error('boom');
|
||||
return n;
|
||||
})
|
||||
).rejects.toThrow('boom');
|
||||
});
|
||||
});
|
||||
19
src/app/features/albums/mapLimit.ts
Normal file
19
src/app/features/albums/mapLimit.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
/** Like Promise.all(items.map(fn)) but with at most `limit` calls in flight; result order kept. */
|
||||
export const mapLimit = async <T, R>(
|
||||
items: T[],
|
||||
limit: number,
|
||||
fn: (item: T) => Promise<R>
|
||||
): Promise<R[]> => {
|
||||
const out = new Array<R>(items.length);
|
||||
let next = 0;
|
||||
const worker = async () => {
|
||||
while (next < items.length) {
|
||||
const i = next;
|
||||
next += 1;
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
out[i] = await fn(items[i]);
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
||||
return out;
|
||||
};
|
||||
73
src/app/features/albums/useAlbums.ts
Normal file
73
src/app/features/albums/useAlbums.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ClientPrefix, Method, Room } from 'matrix-js-sdk';
|
||||
import { StateEvent } from '../../../types/matrix/room';
|
||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useClientConfig } from '../../hooks/useClientConfig';
|
||||
import { Album, ZoneEvent, buildAlbums } from './buildAlbums';
|
||||
import { isFileZone } from './isFileZone';
|
||||
import { mapLimit } from './mapLimit';
|
||||
|
||||
const REPLY_FETCHES_IN_FLIGHT = 6;
|
||||
const REFRESH_MS = 30_000;
|
||||
|
||||
type Chunk = { chunk?: ZoneEvent[] };
|
||||
|
||||
/** True when the room is the file zone: by its create type, or listed in the client config. */
|
||||
export const useIsFileZone = (room: Room): boolean => {
|
||||
const config = useClientConfig();
|
||||
// Subscribes to alias changes (alt aliases live in the same event).
|
||||
useStateEvent(room, StateEvent.RoomCanonicalAlias);
|
||||
const createType = room.currentState.getStateEvents('m.room.create', '')?.getContent().type;
|
||||
return isFileZone(
|
||||
{
|
||||
roomId: room.roomId,
|
||||
aliases: [room.getCanonicalAlias(), ...room.getAltAliases()].filter((a): a is string => !!a),
|
||||
createType,
|
||||
},
|
||||
config
|
||||
);
|
||||
};
|
||||
|
||||
export type AlbumsState = { loading: boolean; error: Error | null; albums: Album[] };
|
||||
|
||||
/**
|
||||
* Slice 1 reads ONE page of each endpoint (threads: 100 roots; replies per root: 100). A zone with
|
||||
* more albums or files than that is truncated; pagination is a later slice. Reads are made with
|
||||
* the logged-in client, so the room must be unencrypted: encrypted events carry no msgtype and
|
||||
* are skipped.
|
||||
*/
|
||||
export const useAlbums = (room: Room): AlbumsState => {
|
||||
const mx = useMatrixClient();
|
||||
const { roomId } = room;
|
||||
const query = useQuery({
|
||||
queryKey: ['albums', roomId],
|
||||
queryFn: async (): Promise<Album[]> => {
|
||||
const get = (path: string, params: Record<string, string>) =>
|
||||
mx.http.authedRequest<Chunk>(Method.Get, path, params, undefined, {
|
||||
prefix: ClientPrefix.V1,
|
||||
});
|
||||
const room_ = encodeURIComponent(roomId);
|
||||
const roots = (await get(`/rooms/${room_}/threads`, { limit: '100' })).chunk ?? [];
|
||||
const inputs = await mapLimit(roots, REPLY_FETCHES_IN_FLIGHT, async (root: ZoneEvent) => ({
|
||||
root,
|
||||
replies:
|
||||
(
|
||||
await get(
|
||||
`/rooms/${room_}/relations/${encodeURIComponent(root.event_id ?? '')}/m.thread`,
|
||||
{ limit: '100', dir: 'f' }
|
||||
)
|
||||
).chunk ?? [],
|
||||
}));
|
||||
return buildAlbums(inputs);
|
||||
},
|
||||
retry: 1,
|
||||
refetchInterval: REFRESH_MS,
|
||||
});
|
||||
return {
|
||||
loading: query.isLoading,
|
||||
// A failed refetch keeps the albums already loaded; the error shows only when there are none.
|
||||
error: query.data ? null : (query.error as Error | null),
|
||||
albums: query.data ?? [],
|
||||
};
|
||||
};
|
||||
16
src/app/features/albums/zoneView.test.ts
Normal file
16
src/app/features/albums/zoneView.test.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { zoneView } from './zoneView';
|
||||
|
||||
describe('zoneView', () => {
|
||||
it('opens albums by default and the timeline for an event deep link', () => {
|
||||
expect(zoneView(undefined, undefined)).toBe('albums');
|
||||
expect(zoneView(undefined, '$e')).toBe('timeline');
|
||||
});
|
||||
it('lets an event link win over an earlier choice of albums', () => {
|
||||
expect(zoneView('albums', '$e')).toBe('timeline');
|
||||
});
|
||||
it('honours the choice when there is no event link', () => {
|
||||
expect(zoneView('albums', undefined)).toBe('albums');
|
||||
expect(zoneView('timeline', undefined)).toBe('timeline');
|
||||
});
|
||||
});
|
||||
5
src/app/features/albums/zoneView.ts
Normal file
5
src/app/features/albums/zoneView.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export type ZoneView = 'albums' | 'timeline';
|
||||
|
||||
/** The view a file-zone room shows. A link to an event always wins; else the user's choice; else albums. */
|
||||
export const zoneView = (choice: ZoneView | undefined, eventId: string | undefined): ZoneView =>
|
||||
eventId ? 'timeline' : choice ?? 'albums';
|
||||
56
src/app/features/desk/Desk.tsx
Normal file
56
src/app/features/desk/Desk.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds';
|
||||
import { Page, PageContent, PageContentCenter, PageHeader } from '../../components/page';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
import { getHomePath } from '../../pages/pathUtils';
|
||||
import { DeskView } from './DeskView';
|
||||
import { unverifiedForks } from './optionsHash';
|
||||
import { deskFor } from './source';
|
||||
|
||||
const labelOf = (mind: string) => mind.charAt(0).toUpperCase() + mind.slice(1);
|
||||
|
||||
/** The route's page: one Mind's desk under the navigator. */
|
||||
export function Desk() {
|
||||
const { mind = '' } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const phone = useScreenSizeContext() === ScreenSize.Mobile;
|
||||
const label = labelOf(mind);
|
||||
const list = useMemo(() => deskFor(mind), [mind]);
|
||||
// Until the recompute finishes nothing is flagged; a card that fails is withdrawn when it lands.
|
||||
const [unverified, setUnverified] = useState<ReadonlySet<string>>(new Set());
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
unverifiedForks(list?.forks ?? []).then((bad) => live && setUnverified(bad));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [list]);
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader balance>
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<Box grow="Yes" basis="No">
|
||||
{phone && (
|
||||
<IconButton aria-label="Back to rooms" onClick={() => navigate(getHomePath())}>
|
||||
<Icon src={Icons.ArrowLeft} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
<Text size="H3" truncate>{`${label}'s desk`}</Text>
|
||||
<Box grow="Yes" basis="No" />
|
||||
</Box>
|
||||
</PageHeader>
|
||||
<Box style={{ position: 'relative' }} grow="Yes">
|
||||
<Scroll hideTrack visibility="Hover">
|
||||
<PageContent>
|
||||
<PageContentCenter>
|
||||
<DeskView mind={mind} label={label} list={list} unverified={unverified} />
|
||||
</PageContentCenter>
|
||||
</PageContent>
|
||||
</Scroll>
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
16
src/app/features/desk/DeskEntry.tsx
Normal file
16
src/app/features/desk/DeskEntry.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Text } from 'folds';
|
||||
import { getDeskPath } from '../../pages/pathUtils';
|
||||
import { deskFor } from './source';
|
||||
|
||||
/** The way into a Mind's desk, set in its navigator group header; nothing at all when nothing is open. */
|
||||
export function DeskEntry({ mind }: { mind: string }) {
|
||||
const open = deskFor(mind)?.open_count ?? 0;
|
||||
if (open === 0) return null;
|
||||
return (
|
||||
<Text as={Link} to={getDeskPath(mind)} size="T200" priority="300">
|
||||
{`Desk · ${open} open`}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
99
src/app/features/desk/DeskView.css.ts
Normal file
99
src/app/features/desk/DeskView.css.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
import { color, config, toRem } from 'folds';
|
||||
import { MOBILE_BREAKPOINT } from '../../hooks/useScreenSize';
|
||||
|
||||
const PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`;
|
||||
|
||||
/** Rows and booth side by side on wide screens. */
|
||||
export const Layout = style({
|
||||
display: 'flex',
|
||||
gap: config.space.S500,
|
||||
alignItems: 'flex-start',
|
||||
'@media': { [PHONE]: { display: 'block', paddingBottom: toRem(120) } },
|
||||
});
|
||||
|
||||
export const Rows = style({ flex: 1, minWidth: 0, margin: 0, padding: 0, listStyle: 'none' });
|
||||
|
||||
/** A hairline splits rows; it is the only chrome. */
|
||||
export const Row = style({
|
||||
padding: `${config.space.S300} 0`,
|
||||
borderTop: `1px solid ${color.Background.ContainerLine}`,
|
||||
});
|
||||
|
||||
export const Title = style({
|
||||
margin: 0,
|
||||
font: 'inherit',
|
||||
fontWeight: 600,
|
||||
});
|
||||
|
||||
/** The fold button reads as the title itself, not as a control. */
|
||||
export const Fold = style({
|
||||
all: 'unset',
|
||||
cursor: 'pointer',
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
minHeight: 44,
|
||||
boxSizing: 'border-box',
|
||||
});
|
||||
|
||||
export const Quiet = style({ opacity: config.opacity.P300, margin: `${config.space.S100} 0` });
|
||||
export const Body = style({ whiteSpace: 'pre-wrap', margin: `${config.space.S200} 0` });
|
||||
export const Options = style({ margin: 0, padding: 0, listStyle: 'none' });
|
||||
export const Option = style({ padding: `${config.space.S100} 0` });
|
||||
|
||||
export const Note = style({
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
minHeight: 44,
|
||||
margin: `${config.space.S200} 0`,
|
||||
font: 'inherit',
|
||||
color: 'inherit',
|
||||
background: 'none',
|
||||
border: `1px solid ${color.Background.ContainerLine}`,
|
||||
opacity: config.opacity.Disabled,
|
||||
});
|
||||
|
||||
/** Sticky beside the rows on wide screens; a small fixed corner image on phones. */
|
||||
export const Booth = style({
|
||||
position: 'sticky',
|
||||
top: config.space.S400,
|
||||
width: toRem(168),
|
||||
flexShrink: 0,
|
||||
'@media': {
|
||||
[PHONE]: {
|
||||
position: 'fixed',
|
||||
top: 'auto',
|
||||
right: config.space.S200,
|
||||
bottom: config.space.S200,
|
||||
width: toRem(88),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// PoseAvatar's button is laid out for message gutters; the booth takes it back into flow.
|
||||
globalStyle(`${Booth} > [data-pose]`, {
|
||||
position: 'static',
|
||||
float: 'none',
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
margin: 0,
|
||||
maskImage: 'none',
|
||||
WebkitMaskImage: 'none',
|
||||
cursor: 'default',
|
||||
});
|
||||
|
||||
export const BoothLine = style({
|
||||
'@media': {
|
||||
// Still read out by the live region; only the picture shows in the corner.
|
||||
[PHONE]: {
|
||||
position: 'absolute',
|
||||
width: 1,
|
||||
height: 1,
|
||||
overflow: 'hidden',
|
||||
clip: 'rect(0 0 0 0)',
|
||||
whiteSpace: 'nowrap',
|
||||
},
|
||||
},
|
||||
});
|
||||
150
src/app/features/desk/DeskView.test.tsx
Normal file
150
src/app/features/desk/DeskView.test.tsx
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { ScreenSize, ScreenSizeProvider } from '../../hooks/useScreenSize';
|
||||
import { DeskEntry } from './DeskEntry';
|
||||
import { DeskView, SIGN_IN_NOTE } from './DeskView';
|
||||
import { STATES } from './fixtures';
|
||||
import { setDeskSource } from './source';
|
||||
|
||||
const view = (
|
||||
state: keyof typeof STATES | 'unpublished',
|
||||
size: ScreenSize,
|
||||
mind = 'void',
|
||||
unverified = new Set<string>()
|
||||
) =>
|
||||
renderToStaticMarkup(
|
||||
<ScreenSizeProvider value={size}>
|
||||
<DeskView
|
||||
mind={mind}
|
||||
label="Void"
|
||||
list={state === 'unpublished' ? undefined : STATES[state]}
|
||||
unverified={unverified}
|
||||
/>
|
||||
</ScreenSizeProvider>
|
||||
);
|
||||
|
||||
describe('DeskView', () => {
|
||||
it('folds a row behind its title on a phone, with the fold button saying so', () => {
|
||||
const html = view('noAnswers', ScreenSize.Mobile);
|
||||
expect(html).toContain('aria-expanded="false"');
|
||||
expect(html).toContain('Question plain');
|
||||
expect(html).not.toContain('Context for plain');
|
||||
expect(html).not.toContain('First way');
|
||||
});
|
||||
it('shows the row open, with no fold button, on a wide screen', () => {
|
||||
const html = view('noAnswers', ScreenSize.Desktop);
|
||||
expect(html).not.toContain('aria-expanded');
|
||||
expect(html).toContain('Context for plain');
|
||||
expect(html).toContain('First way');
|
||||
});
|
||||
it('states the recommendation in words, on that option only', () => {
|
||||
const html = view('noAnswers', ScreenSize.Desktop);
|
||||
expect(html.match(/\(recommended\)/g)).toHaveLength(1);
|
||||
expect(html).toContain('B Second way (recommended)');
|
||||
});
|
||||
it('keeps the note disabled and says why in a sentence the field points at', () => {
|
||||
const html = view('noAnswers', ScreenSize.Desktop);
|
||||
expect(html).toMatch(/<textarea[^>]* disabled=""/);
|
||||
expect(html).toContain(SIGN_IN_NOTE);
|
||||
expect(SIGN_IN_NOTE).toMatch(/sign-in/);
|
||||
expect(html).toMatch(/aria-describedby="note-[^"]+-why"/);
|
||||
});
|
||||
it('shows the depends line as the text it is', () => {
|
||||
expect(view('blocked', ScreenSize.Desktop)).toContain(
|
||||
'Waits on: "Needs the first one settled"'
|
||||
);
|
||||
});
|
||||
it('says No answers yet on a row without answers', () => {
|
||||
expect(view('noAnswers', ScreenSize.Desktop)).toContain('No answers yet.');
|
||||
});
|
||||
it('lists settled questions as recorded rulings with the ruling id', () => {
|
||||
const html = view('settled', ScreenSize.Desktop);
|
||||
expect(html).toContain('Recorded as rulings');
|
||||
expect(html).toContain('Question done (ruling:done-1)');
|
||||
expect(html).not.toContain('Question shut');
|
||||
});
|
||||
it('shows each outcome quietly, marks the counted answer, and renders a note only when sent', () => {
|
||||
const html = view('answered', ScreenSize.Desktop);
|
||||
expect(html).toContain(
|
||||
'Answer First way "Because it is first." (2026-11-09) (counted)'
|
||||
);
|
||||
expect(html).toContain('Revision of n1 Third way (2026-11-09)');
|
||||
expect(html).toContain('Retraction of n2 (2026-11-09)');
|
||||
expect(html).toContain('Refused (not_a_decider) Second way "Let me in."');
|
||||
expect(html).toContain('Answer "Either, if measured first."');
|
||||
expect(html.match(/\(counted\)/g)).toHaveLength(1);
|
||||
expect(html).not.toContain('"null"');
|
||||
});
|
||||
it('says nothing is waiting when empty, and that nothing is published when it never arrived', () => {
|
||||
expect(view('empty', ScreenSize.Desktop)).toContain('Nothing is waiting on you from Void.');
|
||||
expect(view('unpublished', ScreenSize.Desktop)).toContain(
|
||||
'Void's questions have not been published yet.'
|
||||
);
|
||||
});
|
||||
it('seats the Mind in the booth with the pose for the desk mood', () => {
|
||||
const html = view('noAnswers', ScreenSize.Desktop);
|
||||
expect(html).toContain('data-pose="thinking"');
|
||||
expect(html).toContain('Just one fork on the desk.');
|
||||
});
|
||||
it('makes the booth portrait a plain image: no button, no tab stop', () => {
|
||||
const html = view('noAnswers', ScreenSize.Desktop);
|
||||
const booth = html.slice(html.indexOf('data-pose'));
|
||||
expect(booth).toContain('<img');
|
||||
expect(html).not.toMatch(/<button[^>]*data-pose/);
|
||||
expect(html).not.toMatch(/<[a-z]+[^>]*data-pose[^>]*tabindex/);
|
||||
expect(html).toMatch(/<span[^>]*data-pose/);
|
||||
});
|
||||
it('withdraws a card whose hash does not match and does not count it open', () => {
|
||||
const html = view('noAnswers', ScreenSize.Desktop, 'void', new Set(['c:question:plain']));
|
||||
expect(html).toContain('does not match the hash it was published with');
|
||||
expect(html).not.toContain('First way');
|
||||
expect(html).toContain('1 open.');
|
||||
expect(html).not.toContain('Nothing is waiting');
|
||||
});
|
||||
it('never claims an empty desk while the list says forks are open, whatever rows it could show', () => {
|
||||
setDeskSource(() => undefined);
|
||||
const html = renderToStaticMarkup(
|
||||
<ScreenSizeProvider value={ScreenSize.Desktop}>
|
||||
<DeskView mind="void" label="Void" list={{ ...STATES.empty, open_count: 3 }} />
|
||||
</ScreenSizeProvider>
|
||||
);
|
||||
expect(html).toContain('3 open.');
|
||||
expect(html).not.toContain('Nothing is waiting');
|
||||
});
|
||||
it('shows no booth for a Mind without art rather than another face', () => {
|
||||
expect(view('noAnswers', ScreenSize.Desktop, 'nobody')).not.toContain('data-pose');
|
||||
});
|
||||
it('shows no booth before anything is published', () => {
|
||||
expect(view('unpublished', ScreenSize.Desktop)).not.toContain('data-pose');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DeskEntry', () => {
|
||||
afterEach(() => setDeskSource(() => undefined));
|
||||
const entry = (mind: string) =>
|
||||
renderToStaticMarkup(
|
||||
<MemoryRouter>
|
||||
<DeskEntry mind={mind} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
it('reads "Desk · N open" and links to that Mind\'s desk', () => {
|
||||
setDeskSource(() => STATES.blocked);
|
||||
const html = entry('void');
|
||||
expect(html).toContain('Desk · 2 open');
|
||||
expect(html).toContain('href="/desk/void"');
|
||||
});
|
||||
it('reads the count the list states, not a count of the forks it carries', () => {
|
||||
setDeskSource(() => ({ ...STATES.empty, open_count: 5 }));
|
||||
expect(entry('void')).toContain('Desk · 5 open');
|
||||
});
|
||||
it('is absent at zero open, when nothing is published, and when only settled forks exist', () => {
|
||||
setDeskSource(() => STATES.empty);
|
||||
expect(entry('void')).toBe('');
|
||||
setDeskSource(() => undefined);
|
||||
expect(entry('void')).toBe('');
|
||||
setDeskSource(() => ({ ...STATES.settled, open_count: 0 }));
|
||||
expect(entry('void')).toBe('');
|
||||
});
|
||||
});
|
||||
169
src/app/features/desk/DeskView.tsx
Normal file
169
src/app/features/desk/DeskView.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Text } from 'folds';
|
||||
import { PoseAvatar } from '../../components/message/pose/PoseAvatar';
|
||||
import { MINDS } from '../../components/message/pose/poses';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
import { AnswerRow, Desk, buildDesk, DeskRow } from './buildDesk';
|
||||
import { ForkList } from './forkContract';
|
||||
import { deskMood } from './deskMood';
|
||||
import * as css from './DeskView.css';
|
||||
|
||||
/** Slice 1 has no session: every viewer is signed out, and the note says why it cannot be used. */
|
||||
export const SIGN_IN_NOTE = 'Answering opens with sign-in. Until then you can read the questions.';
|
||||
|
||||
const date = (iso: string) => iso.slice(0, 10);
|
||||
|
||||
const OUTCOME: Record<AnswerRow['outcome'], string> = {
|
||||
Counted: 'Answer',
|
||||
Revision: 'Revision',
|
||||
Retraction: 'Retraction',
|
||||
Refused: 'Refused',
|
||||
};
|
||||
|
||||
/** One answer as served: its outcome, what it chose, its note when the server sent one, and whether it counts. */
|
||||
const answerLine = (a: AnswerRow) =>
|
||||
[
|
||||
OUTCOME[a.outcome] + (a.code ? ` (${a.code})` : ''),
|
||||
a.target && `of ${a.target}`,
|
||||
a.choice,
|
||||
a.note !== undefined && `"${a.note}"`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
function Answers({ answers }: { answers: AnswerRow[] }) {
|
||||
if (answers.length === 0) return <p className={css.Quiet}>No answers yet.</p>;
|
||||
return (
|
||||
<ul className={css.Options}>
|
||||
{answers.map((a) => (
|
||||
<li key={a.answerId} className={css.Option}>
|
||||
{`${answerLine(a)} (${date(a.at)})`}
|
||||
{a.counted && ' (counted)'}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ row }: { row: DeskRow }) {
|
||||
const phone = useScreenSizeContext() === ScreenSize.Mobile;
|
||||
// Phones list titles; a tap opens one. Wide screens show every row open.
|
||||
const [open, setOpen] = useState(!phone);
|
||||
if (row.kind === 'unverified') {
|
||||
return (
|
||||
<li className={css.Row}>
|
||||
<p className={css.Quiet}>
|
||||
A question here does not match the hash it was published with, so it is not shown.
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
const noteId = `note-${row.forkId}`;
|
||||
return (
|
||||
<li className={css.Row}>
|
||||
<h3 className={css.Title}>
|
||||
{phone ? (
|
||||
<button
|
||||
type="button"
|
||||
className={css.Fold}
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
{row.title}
|
||||
</button>
|
||||
) : (
|
||||
row.title
|
||||
)}
|
||||
</h3>
|
||||
{open && (
|
||||
<>
|
||||
<p className={css.Body}>{row.context}</p>
|
||||
<ul className={css.Options}>
|
||||
{row.options.map((o) => (
|
||||
<li key={o.id} className={css.Option}>
|
||||
{`${o.id} ${o.text}`}
|
||||
{o.recommended && ' (recommended)'}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{row.depends.map((d) => (
|
||||
<p key={d} className={css.Quiet}>
|
||||
{`Waits on: "${d}"`}
|
||||
</p>
|
||||
))}
|
||||
<span id={`${noteId}-label`}>Your note</span>
|
||||
<textarea
|
||||
className={css.Note}
|
||||
disabled
|
||||
aria-labelledby={`${noteId}-label`}
|
||||
aria-describedby={`${noteId}-why`}
|
||||
/>
|
||||
<p id={`${noteId}-why`} className={css.Quiet}>
|
||||
{SIGN_IN_NOTE}
|
||||
</p>
|
||||
<Answers answers={row.answers} />
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function Status({ desk, label }: { desk: Desk; label: string }) {
|
||||
if (!desk.published) return <p>{`${label}'s questions have not been published yet.`}</p>;
|
||||
return (
|
||||
<p role="status">
|
||||
{desk.counts.open === 0
|
||||
? `Nothing is waiting on you from ${label}. `
|
||||
: `${desk.counts.open} open.`}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
type DeskViewProps = {
|
||||
/** The Mind's key, as the navigator groups it. */
|
||||
mind: string;
|
||||
label: string;
|
||||
list?: ForkList;
|
||||
/** Fork ids whose options_hash failed the recompute (optionsHash.ts). */
|
||||
unverified?: ReadonlySet<string>;
|
||||
};
|
||||
|
||||
/** One Mind's desk, read-only: a stream of rows, the recorded rulings, and the booth Persona. */
|
||||
export function DeskView({ mind, label, list, unverified }: DeskViewProps) {
|
||||
const desk = buildDesk(list, unverified);
|
||||
// Slice 1 has no session, so nothing on the desk is the viewer's answer yet.
|
||||
const mood = deskMood(desk.counts.open, 0);
|
||||
return (
|
||||
<section aria-label={`${label}'s desk`}>
|
||||
<Status desk={desk} label={label} />
|
||||
<div className={css.Layout}>
|
||||
<ol className={css.Rows}>
|
||||
{desk.open.map((row) => (
|
||||
<Row key={row.forkId} row={row} />
|
||||
))}
|
||||
</ol>
|
||||
{/* Only Minds with art in the catalogue get a booth; none gets another Mind's face. */}
|
||||
{desk.published && MINDS.includes(mind) && (
|
||||
<div className={css.Booth}>
|
||||
<PoseAvatar mind={mind} pose={mood.pose} userId={mind} />
|
||||
<Text className={css.BoothLine} size="T200" aria-live="polite">
|
||||
{mood.line}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{desk.recorded.length > 0 && (
|
||||
<>
|
||||
<h3 className={css.Title}>Recorded as rulings</h3>
|
||||
<ul className={css.Options}>
|
||||
{desk.recorded.map((r) => (
|
||||
<li key={r.forkId} className={css.Option}>
|
||||
{`${r.title} (${r.rulingId})`}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
105
src/app/features/desk/buildDesk.test.ts
Normal file
105
src/app/features/desk/buildDesk.test.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { buildDesk } from './buildDesk';
|
||||
import { STATES, entry, list } from './fixtures';
|
||||
|
||||
const questions = (d: ReturnType<typeof buildDesk>) =>
|
||||
d.open.flatMap((r) => (r.kind === 'question' ? [r] : []));
|
||||
|
||||
describe('buildDesk', () => {
|
||||
it('says "not published" for a list that never arrived, never "no questions"', () => {
|
||||
expect(buildDesk(undefined)).toMatchObject({ published: false, open: [], counts: { open: 0 } });
|
||||
});
|
||||
it('is published and empty when the list holds nothing', () => {
|
||||
expect(buildDesk(STATES.empty)).toMatchObject({
|
||||
published: true,
|
||||
open: [],
|
||||
counts: { open: 0 },
|
||||
});
|
||||
});
|
||||
it('shows no answers as an empty list on an open row', () => {
|
||||
expect(questions(buildDesk(STATES.noAnswers))[0].answers).toEqual([]);
|
||||
});
|
||||
it('resolves the recommendation to its option, marks only that option, and never preselects', () => {
|
||||
const [row] = questions(buildDesk(STATES.noAnswers));
|
||||
expect(row.recommended).toBe('Second way');
|
||||
expect(row.options.filter((o) => o.recommended).map((o) => o.id)).toEqual(['B']);
|
||||
});
|
||||
it('leaves the recommendation unresolved when it names no option', () => {
|
||||
const [row] = questions(buildDesk(list([entry('x', { recommendation: 'Z' })])));
|
||||
expect(row.recommended).toBeUndefined();
|
||||
expect(row.options.some((o) => o.recommended)).toBe(false);
|
||||
});
|
||||
it('carries the depends lines as the text they are', () => {
|
||||
const rows = questions(buildDesk(STATES.blocked));
|
||||
expect(rows.find((r) => r.forkId.endsWith('second'))?.depends).toEqual([
|
||||
'Needs the first one settled',
|
||||
]);
|
||||
expect(rows.find((r) => r.forkId.endsWith('first'))?.depends).toEqual([]);
|
||||
});
|
||||
it('lists a closed card with a ruling as recorded, not open; omits one closed with no ruling', () => {
|
||||
const d = buildDesk(STATES.settled);
|
||||
expect(d.recorded).toEqual([
|
||||
{ forkId: 'c:question:done', title: 'Question done', rulingId: 'ruling:done-1' },
|
||||
]);
|
||||
expect(d.open.map((r) => r.forkId)).toEqual(['c:question:live']);
|
||||
});
|
||||
it('keeps the served order (newest first by card.order) and sorts nothing itself', () => {
|
||||
const served = list([entry('late', { order: 9 }), entry('early', { order: 2 })]);
|
||||
expect(buildDesk(served).open.map((r) => r.forkId)).toEqual([
|
||||
'c:question:late',
|
||||
'c:question:early',
|
||||
]);
|
||||
const odd = list([entry('a', { order: 2 }), entry('b', { order: 9 })]);
|
||||
expect(buildDesk(odd).open.map((r) => r.forkId)).toEqual(['c:question:a', 'c:question:b']);
|
||||
});
|
||||
it("takes the open count from the list's open_count, not from the rows it could verify or carries", () => {
|
||||
const d = buildDesk({ ...list([entry('only')]), open_count: 4 }, new Set(['c:question:only']));
|
||||
expect(d.counts.open).toBe(4);
|
||||
});
|
||||
it('marks the answer the record names as counted, and no other', () => {
|
||||
const [row] = questions(buildDesk(STATES.answered));
|
||||
expect(row.answers.filter((a) => a.counted).map((a) => a.answerId)).toEqual(['n1']);
|
||||
});
|
||||
it('follows the record when a revision is the counted answer: nothing is re-derived', () => {
|
||||
const e = STATES.answered.forks[0];
|
||||
const moved = list([{ ...e, record: { ...e.record, counted_answer_id: 'n2' } }]);
|
||||
expect(
|
||||
questions(buildDesk(moved))[0]
|
||||
.answers.filter((a) => a.counted)
|
||||
.map((a) => a.answerId)
|
||||
).toEqual(['n2']);
|
||||
});
|
||||
it('keeps answers in the served order and carries each outcome, target and refusal code', () => {
|
||||
const [row] = questions(buildDesk(STATES.answered));
|
||||
expect(row.answers.map((a) => [a.answerId, a.outcome, a.target, a.code])).toEqual([
|
||||
['n1', 'Counted', undefined, undefined],
|
||||
['n2', 'Revision', 'n1', undefined],
|
||||
['n3', 'Retraction', 'n2', undefined],
|
||||
['n4', 'Refused', undefined, 'not_a_decider'],
|
||||
['n5', 'Counted', undefined, undefined],
|
||||
]);
|
||||
});
|
||||
it('shows a choice as option text, passes an unknown id through, and leaves a null choice absent', () => {
|
||||
const [row] = questions(buildDesk(STATES.answered));
|
||||
expect(row.answers.map((a) => a.choice)).toEqual([
|
||||
'First way',
|
||||
'Third way',
|
||||
undefined,
|
||||
'Second way',
|
||||
undefined,
|
||||
]);
|
||||
const odd = list([
|
||||
entry('odd', {}, [{ ...STATES.answered.forks[0].record.answers[0], choice: 'Q' }]),
|
||||
]);
|
||||
expect(questions(buildDesk(odd))[0].answers[0].choice).toBe('Q');
|
||||
});
|
||||
it('carries a note verbatim when present and leaves it absent when null', () => {
|
||||
const [row] = questions(buildDesk(STATES.answered));
|
||||
expect(row.answers[0].note).toBe('Because it is first.');
|
||||
expect(row.answers[1].note).toBeUndefined();
|
||||
});
|
||||
it('withdraws an unverified open card and does not count it', () => {
|
||||
const d = buildDesk(STATES.noAnswers, new Set(['c:question:plain']));
|
||||
expect(d.open).toEqual([{ kind: 'unverified', forkId: 'c:question:plain' }]);
|
||||
});
|
||||
});
|
||||
95
src/app/features/desk/buildDesk.ts
Normal file
95
src/app/features/desk/buildDesk.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { DeskAnswer, ForkEntry, ForkList } from './forkContract';
|
||||
|
||||
export type AnswerRow = {
|
||||
answerId: string;
|
||||
outcome: DeskAnswer['outcome'];
|
||||
/** The option's text for a choice. */
|
||||
choice?: string;
|
||||
/** Verbatim, and only when the server sent one. */
|
||||
note?: string;
|
||||
/** The answer a Revision or Retraction points at. */
|
||||
target?: string;
|
||||
/** A refusal's code. */
|
||||
code?: string;
|
||||
at: string;
|
||||
/** The record's counted answer: marked as served, never derived here. */
|
||||
counted: boolean;
|
||||
};
|
||||
|
||||
export type DeskRow =
|
||||
/** Its published options_hash is not the hash of what it shows: neither shown nor counted. */
|
||||
| { kind: 'unverified'; forkId: string }
|
||||
| {
|
||||
kind: 'question';
|
||||
forkId: string;
|
||||
title: string;
|
||||
context: string;
|
||||
options: { id: string; text: string; recommended: boolean }[];
|
||||
/** The recommendation resolved to its option's text; absent if it names no option. */
|
||||
recommended?: string;
|
||||
depends: string[];
|
||||
/** In admission order, as served. */
|
||||
answers: AnswerRow[];
|
||||
};
|
||||
|
||||
export type Desk = {
|
||||
/** False until the list first arrives: "not published yet", never "no questions". */
|
||||
published: boolean;
|
||||
/** Oldest first, by the card's admission ordinal. */
|
||||
open: DeskRow[];
|
||||
/** Closed with a ruling id. A card closed with no ruling (withdrawn) is not listed. */
|
||||
recorded: { forkId: string; title: string; rulingId: string }[];
|
||||
/** The list's own open_count: the one count the navigator, the status line and the booth share. */
|
||||
counts: { open: number };
|
||||
};
|
||||
|
||||
const answerRow = (a: DeskAnswer, entry: ForkEntry): AnswerRow => ({
|
||||
answerId: a.answer_id,
|
||||
outcome: a.outcome,
|
||||
choice:
|
||||
a.choice === null
|
||||
? undefined
|
||||
: entry.card.options.find((o) => o.id === a.choice)?.text ?? a.choice,
|
||||
note: a.note ?? undefined,
|
||||
target: a.of ?? a.retracts ?? undefined,
|
||||
code: a.code ?? undefined,
|
||||
at: a.answered_at,
|
||||
counted: entry.record.counted_answer_id === a.answer_id,
|
||||
});
|
||||
|
||||
export const buildDesk = (
|
||||
list: ForkList | undefined,
|
||||
unverified: ReadonlySet<string> = new Set()
|
||||
): Desk => {
|
||||
if (!list) return { published: false, open: [], recorded: [], counts: { open: 0 } };
|
||||
const open: DeskRow[] = [];
|
||||
const recorded: Desk['recorded'] = [];
|
||||
list.forks.forEach((entry) => {
|
||||
const { card, record } = entry;
|
||||
if (card.status === 'closed') {
|
||||
if (card.ruling !== null)
|
||||
recorded.push({ forkId: card.fork_id, title: card.title, rulingId: card.ruling });
|
||||
return;
|
||||
}
|
||||
if (unverified.has(card.fork_id)) {
|
||||
open.push({ kind: 'unverified', forkId: card.fork_id });
|
||||
return;
|
||||
}
|
||||
open.push({
|
||||
kind: 'question',
|
||||
forkId: card.fork_id,
|
||||
title: card.title,
|
||||
context: card.context,
|
||||
options: card.options.map((o) => ({ ...o, recommended: o.id === card.recommendation })),
|
||||
recommended: card.options.find((o) => o.id === card.recommendation)?.text,
|
||||
depends: card.depends,
|
||||
answers: record.answers.map((a) => answerRow(a, entry)),
|
||||
});
|
||||
});
|
||||
return {
|
||||
published: true,
|
||||
open,
|
||||
recorded,
|
||||
counts: { open: list.open_count },
|
||||
};
|
||||
};
|
||||
59
src/app/features/desk/deskMood.test.ts
Normal file
59
src/app/features/desk/deskMood.test.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { MINDS, resolvePose } from '../../components/message/pose/poses';
|
||||
import { MOOD_POSE, deskMood } from './deskMood';
|
||||
|
||||
describe('deskMood', () => {
|
||||
it('is neutral with the desk clear when nothing is open', () => {
|
||||
expect(deskMood(0, 0)).toEqual({
|
||||
mood: 'neutral',
|
||||
pose: 'neutral',
|
||||
line: "Desk's clear. I'll wave when something lands.",
|
||||
});
|
||||
});
|
||||
it('is triumph when every open fork is answered', () => {
|
||||
expect(deskMood(3, 3).mood).toBe('triumph');
|
||||
expect(deskMood(1, 1).mood).toBe('triumph');
|
||||
});
|
||||
it('is thinking when none is answered, and says whether it is one fork or several', () => {
|
||||
expect(deskMood(1, 0)).toMatchObject({ mood: 'thinking', line: 'Just one fork on the desk.' });
|
||||
expect(deskMood(4, 0)).toMatchObject({
|
||||
mood: 'thinking',
|
||||
line: '4 forks on the desk. No rush.',
|
||||
});
|
||||
});
|
||||
it('is determined partway, counting what is left', () => {
|
||||
expect(deskMood(5, 2)).toMatchObject({ mood: 'determined', line: '3 to go.' });
|
||||
});
|
||||
it('is happy when the pick is the recommendation', () => {
|
||||
expect(deskMood(2, 0, { type: 'picked', choice: 'B', recommended: 'B' }).mood).toBe('happy');
|
||||
});
|
||||
it('is surprised, and asks for a line on why, when the pick differs', () => {
|
||||
expect(deskMood(2, 0, { type: 'picked', choice: 'A', recommended: 'B' })).toMatchObject({
|
||||
mood: 'surprised',
|
||||
line: expect.stringContaining('A line in the note on why helps'),
|
||||
});
|
||||
});
|
||||
it('is surprised, not happy, when there is no recommendation to match', () => {
|
||||
expect(deskMood(2, 0, { type: 'picked', choice: 'A' }).mood).toBe('surprised');
|
||||
});
|
||||
it('is note when a pick is cleared, saying the note can carry the answer', () => {
|
||||
expect(deskMood(2, 1, { type: 'cleared' })).toMatchObject({
|
||||
mood: 'note',
|
||||
line: expect.stringContaining('note can carry the answer on its own'),
|
||||
});
|
||||
});
|
||||
it('is concerned when a send failed, even with everything answered', () => {
|
||||
expect(deskMood(2, 2, { type: 'sendFailed' }).mood).toBe('concerned');
|
||||
});
|
||||
it('is surprised when a new fork arrives, even on a clear desk', () => {
|
||||
expect(deskMood(0, 0, { type: 'newFork' })).toMatchObject({
|
||||
mood: 'surprised',
|
||||
line: 'New fork on the desk!',
|
||||
});
|
||||
});
|
||||
it('maps every mood to a label every Mind in the catalogue can show', () => {
|
||||
Object.values(MOOD_POSE).forEach((pose) =>
|
||||
MINDS.forEach((mind) => expect(resolvePose(mind, pose)).toBe(pose))
|
||||
);
|
||||
});
|
||||
});
|
||||
75
src/app/features/desk/deskMood.ts
Normal file
75
src/app/features/desk/deskMood.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
/**
|
||||
* The booth Persona's reactions, from the original Fork Desk's mood() and choose() rules. A pure function of
|
||||
* what is on the desk and the last thing that happened; it is the desk's character, so each rule is tested.
|
||||
*/
|
||||
|
||||
/** The original's nine moods. */
|
||||
export type Mood =
|
||||
| 'idle'
|
||||
| 'neutral'
|
||||
| 'thinking'
|
||||
| 'happy'
|
||||
| 'surprised'
|
||||
| 'note'
|
||||
| 'determined'
|
||||
| 'triumph'
|
||||
| 'concerned';
|
||||
|
||||
/** What last happened; `none` means nothing since the page opened or the last event wore off. */
|
||||
export type DeskEvent =
|
||||
| { type: 'none' }
|
||||
| { type: 'picked'; choice: string; recommended?: string }
|
||||
| { type: 'cleared' }
|
||||
| { type: 'sendFailed' }
|
||||
| { type: 'newFork' };
|
||||
|
||||
/**
|
||||
* Each mood on the shared cast-art labels (neutral, happy, amused, thinking, annoyed, surprised) that every
|
||||
* Mind has, so the booth never asks a Mind for art it lacks.
|
||||
*/
|
||||
export const MOOD_POSE: Record<Mood, string> = {
|
||||
idle: 'neutral',
|
||||
neutral: 'neutral',
|
||||
thinking: 'thinking',
|
||||
happy: 'happy',
|
||||
surprised: 'surprised',
|
||||
note: 'thinking',
|
||||
determined: 'thinking',
|
||||
triumph: 'happy',
|
||||
concerned: 'surprised',
|
||||
};
|
||||
|
||||
export type DeskMood = { mood: Mood; pose: string; line: string };
|
||||
|
||||
const say = (mood: Mood, line: string): DeskMood => ({ mood, pose: MOOD_POSE[mood], line });
|
||||
|
||||
export const deskMood = (
|
||||
open: number,
|
||||
answered: number,
|
||||
event: DeskEvent = { type: 'none' }
|
||||
): DeskMood => {
|
||||
switch (event.type) {
|
||||
case 'sendFailed':
|
||||
return say('concerned', "That one didn't save. Give it another try in a moment?");
|
||||
case 'newFork':
|
||||
return say('surprised', 'New fork on the desk!');
|
||||
case 'cleared':
|
||||
return say('note', 'Pick cleared. Your note can carry the answer on its own.');
|
||||
case 'picked':
|
||||
return event.recommended !== undefined && event.choice === event.recommended
|
||||
? say('happy', "That's the one I'd have picked too.")
|
||||
: say('surprised', 'Off-script. A line in the note on why helps the next brief.');
|
||||
default:
|
||||
break;
|
||||
}
|
||||
if (open === 0) return say('neutral', "Desk's clear. I'll wave when something lands.");
|
||||
if (answered >= open)
|
||||
return say('triumph', 'All answered! I will carry these back and admit them.');
|
||||
if (answered === 0) {
|
||||
return say(
|
||||
'thinking',
|
||||
open === 1 ? 'Just one fork on the desk.' : `${open} forks on the desk. No rush.`
|
||||
);
|
||||
}
|
||||
return say('determined', `${open - answered} to go.`);
|
||||
};
|
||||
116
src/app/features/desk/fixtures.ts
Normal file
116
src/app/features/desk/fixtures.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { DeskAnswer, ForkCard, ForkEntry, ForkList } from './forkContract';
|
||||
|
||||
/** A card as the server writes it: every key present. The hash is a stand-in; tests pass `unverified` directly. */
|
||||
export const card = (label: string, over: Partial<ForkCard> = {}): ForkCard => ({
|
||||
schemaVersion: 'gamecult.fork.card.v0',
|
||||
fork_id: `c:question:${label}`,
|
||||
title: `Question ${label}`,
|
||||
context: `Context for ${label}.`,
|
||||
options: [
|
||||
{ id: 'A', text: 'First way' },
|
||||
{ id: 'B', text: 'Second way' },
|
||||
{ id: 'C', text: 'Third way' },
|
||||
],
|
||||
recommendation: 'B',
|
||||
depends: [],
|
||||
leans: null,
|
||||
flag: null,
|
||||
raised_in: null,
|
||||
deciders: ['d1'],
|
||||
status: 'open',
|
||||
ruling: null,
|
||||
ruled_from: [],
|
||||
order: 1,
|
||||
options_hash: `hash-${label}`,
|
||||
...over,
|
||||
});
|
||||
|
||||
export const answer = (label: string, n: number, over: Partial<DeskAnswer> = {}): DeskAnswer => ({
|
||||
schemaVersion: 'gamecult.desk_answer.v0',
|
||||
answer_id: `n${n}`,
|
||||
fork_id: `c:question:${label}`,
|
||||
outcome: 'Counted',
|
||||
of: null,
|
||||
retracts: null,
|
||||
code: null,
|
||||
options_hash: `hash-${label}`,
|
||||
choice: 'A',
|
||||
note: null,
|
||||
answered_at: '2026-11-09T10:00:00Z',
|
||||
received_at: '2026-11-09T10:00:01Z',
|
||||
claim_in_future: false,
|
||||
answerer: 'acct-1',
|
||||
authority: 'Member',
|
||||
request_id: `r${n}`,
|
||||
...over,
|
||||
});
|
||||
|
||||
export const entry = (
|
||||
label: string,
|
||||
over: Partial<ForkCard> = {},
|
||||
answers: DeskAnswer[] = [],
|
||||
counted: string | null = null
|
||||
): ForkEntry => {
|
||||
const c = card(label, over);
|
||||
return {
|
||||
schemaVersion: 'gamecult.fork.entry.v0',
|
||||
card: c,
|
||||
record: {
|
||||
schemaVersion: 'gamecult.fork.record.v0',
|
||||
fork_id: c.fork_id,
|
||||
options_hash: c.options_hash,
|
||||
counted_answer_id: counted,
|
||||
answers,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const list = (forks: ForkEntry[], openCount?: number): ForkList => ({
|
||||
schemaVersion: 'gamecult.fork.list.v0',
|
||||
mind: 'mind',
|
||||
stamp: 's12',
|
||||
open_count: openCount ?? forks.filter((f) => f.card.status === 'open').length,
|
||||
recorded_count: forks.filter((f) => f.card.status === 'closed').length,
|
||||
forks,
|
||||
next_cursor: null,
|
||||
});
|
||||
|
||||
/** One list per state the desk shows. */
|
||||
export const STATES = {
|
||||
empty: list([]),
|
||||
noAnswers: list([entry('plain')]),
|
||||
blocked: list([
|
||||
entry('first', { order: 1 }),
|
||||
entry('second', { order: 2, depends: ['Needs the first one settled'] }),
|
||||
]),
|
||||
settled: list([
|
||||
entry('done', { status: 'closed', ruling: 'ruling:done-1', ruled_from: ['n1'] }),
|
||||
entry('shut', { status: 'closed' }),
|
||||
entry('live'),
|
||||
]),
|
||||
answered: list([
|
||||
entry(
|
||||
'plain',
|
||||
{},
|
||||
[
|
||||
answer('plain', 1, { note: 'Because it is first.' }),
|
||||
answer('plain', 2, {
|
||||
outcome: 'Revision',
|
||||
of: 'n1',
|
||||
choice: 'C',
|
||||
note: null,
|
||||
answerer: 'acct-2',
|
||||
}),
|
||||
answer('plain', 3, { outcome: 'Retraction', retracts: 'n2', choice: null }),
|
||||
answer('plain', 4, {
|
||||
outcome: 'Refused',
|
||||
code: 'not_a_decider',
|
||||
choice: 'B',
|
||||
note: 'Let me in.',
|
||||
}),
|
||||
answer('plain', 5, { choice: null, note: 'Either, if measured first.' }),
|
||||
],
|
||||
'n1'
|
||||
),
|
||||
]),
|
||||
} satisfies Record<string, ForkList>;
|
||||
92
src/app/features/desk/forkContract.ts
Normal file
92
src/app/features/desk/forkContract.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
/**
|
||||
* The desk's typed view of the documents Bifrost serves for a Mind's desk: CultLib's
|
||||
* contracts/cultnet/governance/fork-card.md (gamecult.fork.list.v0 / entry / card / record, and the stored
|
||||
* gamecult.desk_answer.v0). Read-only: the page decodes them as served and derives nothing from them (the counted
|
||||
* answer is the record's `counted_answer_id`, not the page's opinion), so nothing here has a write shape.
|
||||
* Every key is written by the server, nil where absent.
|
||||
*/
|
||||
|
||||
export type ForkOption = { id: string; text: string };
|
||||
|
||||
export type ForkCard = {
|
||||
schemaVersion: 'gamecult.fork.card.v0';
|
||||
/** `<campaign>:question:<label>`. */
|
||||
fork_id: string;
|
||||
title: string;
|
||||
context: string;
|
||||
options: ForkOption[];
|
||||
/** An option id; never nil. Never preselected. */
|
||||
recommendation: string;
|
||||
/** Prose lines, each verbatim; a line may read like a reference but is only text. */
|
||||
depends: string[];
|
||||
/** An option id, or nil. Not shown by the desk. */
|
||||
leans: string | null;
|
||||
/** Not shown by the desk. */
|
||||
flag: string | null;
|
||||
raised_in: { kind: string; id: string } | null;
|
||||
deciders: string[];
|
||||
status: 'open' | 'closed';
|
||||
/** The ruling id when a ruling closed the fork; nil when open, and nil on a closed card withdrawn with no ruling. */
|
||||
ruling: string | null;
|
||||
ruled_from: string[];
|
||||
/** The admission ordinal. */
|
||||
order: number;
|
||||
/**
|
||||
* Publisher's hash of the five shown members. The desk recomputes it (optionsHash.ts, pinned to the CultLib
|
||||
* vectors) and withdraws a card whose hash differs.
|
||||
*/
|
||||
options_hash: string;
|
||||
};
|
||||
|
||||
/** One stored answer, told apart by `outcome` (and `code` for a refusal). */
|
||||
export type DeskAnswer = {
|
||||
schemaVersion: 'gamecult.desk_answer.v0';
|
||||
/** `n<seq>`. */
|
||||
answer_id: string;
|
||||
fork_id: string;
|
||||
outcome: 'Counted' | 'Revision' | 'Retraction' | 'Refused';
|
||||
/** A Revision's target. */
|
||||
of: string | null;
|
||||
/** A Retraction's target (or a refused retraction's). */
|
||||
retracts: string | null;
|
||||
/** A refusal's code. */
|
||||
code: string | null;
|
||||
options_hash: string;
|
||||
choice: string | null;
|
||||
/** The decider's own words, verbatim; nil for everyone Bifrost does not show notes to. */
|
||||
note: string | null;
|
||||
answered_at: string;
|
||||
received_at: string;
|
||||
claim_in_future: boolean;
|
||||
answerer: string;
|
||||
authority: string;
|
||||
request_id: string;
|
||||
};
|
||||
|
||||
export type ForkRecord = {
|
||||
schemaVersion: 'gamecult.fork.record.v0';
|
||||
fork_id: string;
|
||||
options_hash: string;
|
||||
/** Derived by the receiver; the page renders it and never recomputes it. */
|
||||
counted_answer_id: string | null;
|
||||
/** In admission order. */
|
||||
answers: DeskAnswer[];
|
||||
};
|
||||
|
||||
export type ForkEntry = {
|
||||
schemaVersion: 'gamecult.fork.entry.v0';
|
||||
card: ForkCard;
|
||||
record: ForkRecord;
|
||||
};
|
||||
|
||||
/** What the page holds for one Mind's desk. */
|
||||
export type ForkList = {
|
||||
schemaVersion: 'gamecult.fork.list.v0';
|
||||
mind: string;
|
||||
/** `s<N>`: the same stamp is the same state. */
|
||||
stamp: string;
|
||||
open_count: number;
|
||||
recorded_count: number;
|
||||
forks: ForkEntry[];
|
||||
next_cursor: string | null;
|
||||
};
|
||||
3
src/app/features/desk/index.ts
Normal file
3
src/app/features/desk/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { Desk } from './Desk';
|
||||
export { DeskEntry } from './DeskEntry';
|
||||
export { setDeskSource } from './source';
|
||||
50
src/app/features/desk/optionsHash.test.ts
Normal file
50
src/app/features/desk/optionsHash.test.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import vectors from './optionsHashVectors.json';
|
||||
import { entry } from './fixtures';
|
||||
import { ForkCard } from './forkContract';
|
||||
import { canonicalShown, optionsHash, shownOf, unverifiedForks } from './optionsHash';
|
||||
|
||||
describe('options_hash against the CultLib vectors', () => {
|
||||
it('has all fifteen vectors', () => {
|
||||
expect(vectors.optionsHash).toHaveLength(15);
|
||||
});
|
||||
vectors.optionsHash.forEach((v) => {
|
||||
it(`${v.name}: canonical bytes and sha256 match`, async () => {
|
||||
expect(canonicalShown(v)).toBe(v.canonical);
|
||||
expect(await optionsHash(v)).toBe(v.sha256);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('unverifiedForks', () => {
|
||||
const sealed = async (over: Partial<ForkCard> = {}) => {
|
||||
const e = entry('plain', over);
|
||||
return { ...e, card: { ...e.card, options_hash: await optionsHash(shownOf(e.card)) } };
|
||||
};
|
||||
it('passes a card whose hash is the hash of what it shows', async () => {
|
||||
expect(await unverifiedForks([await sealed()])).toEqual(new Set());
|
||||
});
|
||||
it('flags a card whose shown title, context, option, recommendation or depends changed after hashing', async () => {
|
||||
const e = await sealed();
|
||||
const edits: Partial<ForkCard>[] = [
|
||||
{ title: 'Other' },
|
||||
{ context: 'Other context.' },
|
||||
{ options: [...e.card.options].reverse() },
|
||||
{ recommendation: 'A' },
|
||||
{ depends: ['new line'] },
|
||||
];
|
||||
await Promise.all(
|
||||
edits.map(async (x) =>
|
||||
expect(
|
||||
await unverifiedForks([{ ...e, card: { ...e.card, ...x } }]),
|
||||
JSON.stringify(Object.keys(x))
|
||||
).toEqual(new Set([e.card.fork_id]))
|
||||
)
|
||||
);
|
||||
});
|
||||
it('does not read fork_id, status, order, leans or flag, which are not shown members', async () => {
|
||||
const e = await sealed();
|
||||
const card = { ...e.card, fork_id: 'x:question:y', order: 7, leans: 'A', flag: 'f' };
|
||||
expect(await unverifiedForks([{ ...e, card }])).toEqual(new Set());
|
||||
});
|
||||
});
|
||||
51
src/app/features/desk/optionsHash.ts
Normal file
51
src/app/features/desk/optionsHash.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { ForkCard, ForkEntry } from './forkContract';
|
||||
|
||||
// The hash and its canonical bytes are specified in CultLib contracts/cultnet/governance/fork-card.md
|
||||
// ("Options hash", rule owner: Heimdall's binding file 1.2); the vectors in optionsHashVectors.json pin this code to it.
|
||||
|
||||
const jstr = (s: string): string => {
|
||||
let out = '"';
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const ch of s.normalize('NFC')) {
|
||||
const o = ch.codePointAt(0) as number;
|
||||
const named: Record<number, string> = { 8: '\\b', 9: '\\t', 10: '\\n', 12: '\\f', 13: '\\r' };
|
||||
if (ch === '"') out += '\\"';
|
||||
else if (ch === '\\') out += '\\\\';
|
||||
else if (named[o]) out += named[o];
|
||||
else out += o < 0x20 ? `\\u${o.toString(16).padStart(4, '0')}` : ch;
|
||||
}
|
||||
return `${out}"`;
|
||||
};
|
||||
|
||||
export type Shown = Pick<ForkCard, 'title' | 'context' | 'options' | 'recommendation' | 'depends'>;
|
||||
|
||||
/** The five shown members as RFC 8785 JSON, keys already in sorted order. */
|
||||
export const canonicalShown = (c: Shown): string =>
|
||||
`{"context":${jstr(c.context)},"depends":[${c.depends.map(jstr).join(',')}],"options":[${c.options
|
||||
.map((o) => `{"id":${jstr(o.id)},"text":${jstr(o.text)}}`)
|
||||
.join(',')}],"recommendation":${jstr(c.recommendation)},"title":${jstr(c.title)}}`;
|
||||
|
||||
export const shownOf = (c: ForkCard): Shown => ({
|
||||
title: c.title,
|
||||
context: c.context,
|
||||
options: c.options,
|
||||
recommendation: c.recommendation,
|
||||
depends: c.depends,
|
||||
});
|
||||
|
||||
export const optionsHash = async (c: Shown): Promise<string> => {
|
||||
const bytes = new TextEncoder().encode(canonicalShown(c));
|
||||
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
||||
return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('');
|
||||
};
|
||||
|
||||
/** Fork ids whose published `options_hash` is not the hash of what the card shows. */
|
||||
export const unverifiedForks = async (forks: ForkEntry[]): Promise<Set<string>> => {
|
||||
const bad = new Set<string>();
|
||||
await Promise.all(
|
||||
forks.map(async ({ card }) => {
|
||||
if ((await optionsHash(shownOf(card))) !== card.options_hash) bad.add(card.fork_id);
|
||||
})
|
||||
);
|
||||
return bad;
|
||||
};
|
||||
320
src/app/features/desk/optionsHashVectors.json
Normal file
320
src/app/features/desk/optionsHashVectors.json
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
{
|
||||
"source": "GameCult/CultLib contracts/cultnet/governance/fork-card-vectors.json, `optionsHash` member only, values unchanged (re-escaped to ASCII so TypeScript parses it), checked equal at CultLib 0c19eddb. Rule: contracts/cultnet/governance/fork-card.md, 'Options hash'.",
|
||||
"optionsHash": [
|
||||
{
|
||||
"name": "heimdall_A_five_members",
|
||||
"heimdall": "A",
|
||||
"title": "Slot mark consent",
|
||||
"context": "Should a slot mark be shared?",
|
||||
"options": [
|
||||
{
|
||||
"id": "yes",
|
||||
"text": "Share it."
|
||||
},
|
||||
{
|
||||
"id": "no",
|
||||
"text": "Keep it local."
|
||||
}
|
||||
],
|
||||
"recommendation": "no",
|
||||
"depends": ["gamecult-chat:question:file-zone-audience"],
|
||||
"canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}",
|
||||
"canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D",
|
||||
"sha256": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48"
|
||||
},
|
||||
{
|
||||
"name": "heimdall_B_recommendation_changed",
|
||||
"heimdall": "B",
|
||||
"title": "Slot mark consent",
|
||||
"context": "Should a slot mark be shared?",
|
||||
"options": [
|
||||
{
|
||||
"id": "yes",
|
||||
"text": "Share it."
|
||||
},
|
||||
{
|
||||
"id": "no",
|
||||
"text": "Keep it local."
|
||||
}
|
||||
],
|
||||
"recommendation": "yes",
|
||||
"depends": ["gamecult-chat:question:file-zone-audience"],
|
||||
"canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"yes\",\"title\":\"Slot mark consent\"}",
|
||||
"canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A22796573222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D",
|
||||
"sha256": "14b8ffbdb61106dbc06f69f3fc8e2072af7a205d766e4cc040e89733013d54dc"
|
||||
},
|
||||
{
|
||||
"name": "heimdall_C_option_order_changed",
|
||||
"heimdall": "C",
|
||||
"title": "Slot mark consent",
|
||||
"context": "Should a slot mark be shared?",
|
||||
"options": [
|
||||
{
|
||||
"id": "no",
|
||||
"text": "Keep it local."
|
||||
},
|
||||
{
|
||||
"id": "yes",
|
||||
"text": "Share it."
|
||||
}
|
||||
],
|
||||
"recommendation": "no",
|
||||
"depends": ["gamecult-chat:question:file-zone-audience"],
|
||||
"canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"no\",\"text\":\"Keep it local.\"},{\"id\":\"yes\",\"text\":\"Share it.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}",
|
||||
"canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D2C7B226964223A22796573222C2274657874223A2253686172652069742E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D",
|
||||
"sha256": "ac67b41539f869f6203ac0908819df046e4328b27795743a919bd4ab390596b5"
|
||||
},
|
||||
{
|
||||
"name": "heimdall_D_nfc_escapes_astral",
|
||||
"heimdall": "D",
|
||||
"title": "Caf\u00e9 \u201cdesk\u201d",
|
||||
"context": "Line one\nLine two \u2014 \ud83d\ude00",
|
||||
"options": [
|
||||
{
|
||||
"id": "a",
|
||||
"text": "x"
|
||||
},
|
||||
{
|
||||
"id": "b",
|
||||
"text": "y"
|
||||
}
|
||||
],
|
||||
"recommendation": "a",
|
||||
"depends": [],
|
||||
"canonical": "{\"context\":\"Line one\\nLine two \u2014 \ud83d\ude00\",\"depends\":[],\"options\":[{\"id\":\"a\",\"text\":\"x\"},{\"id\":\"b\",\"text\":\"y\"}],\"recommendation\":\"a\",\"title\":\"Caf\u00e9 \u201cdesk\u201d\"}",
|
||||
"canonicalHex": "7B22636F6E74657874223A224C696E65206F6E655C6E4C696E652074776F20E2809420F09F9880222C22646570656E6473223A5B5D2C226F7074696F6E73223A5B7B226964223A2261222C2274657874223A2278227D2C7B226964223A2262222C2274657874223A2279227D5D2C227265636F6D6D656E646174696F6E223A2261222C227469746C65223A22436166C3A920E2809C6465736BE2809D227D",
|
||||
"sha256": "71d5c413478ad06aaa1cbdd9e91c53f0cb4cef1dbd4c51bf3d473bccc6a5a272"
|
||||
},
|
||||
{
|
||||
"name": "heimdall_E_decomposed_equals_D",
|
||||
"heimdall": "E",
|
||||
"title": "Cafe\u0301 \u201cdesk\u201d",
|
||||
"context": "Line one\nLine two \u2014 \ud83d\ude00",
|
||||
"options": [
|
||||
{
|
||||
"id": "a",
|
||||
"text": "x"
|
||||
},
|
||||
{
|
||||
"id": "b",
|
||||
"text": "y"
|
||||
}
|
||||
],
|
||||
"recommendation": "a",
|
||||
"depends": [],
|
||||
"canonical": "{\"context\":\"Line one\\nLine two \u2014 \ud83d\ude00\",\"depends\":[],\"options\":[{\"id\":\"a\",\"text\":\"x\"},{\"id\":\"b\",\"text\":\"y\"}],\"recommendation\":\"a\",\"title\":\"Caf\u00e9 \u201cdesk\u201d\"}",
|
||||
"canonicalHex": "7B22636F6E74657874223A224C696E65206F6E655C6E4C696E652074776F20E2809420F09F9880222C22646570656E6473223A5B5D2C226F7074696F6E73223A5B7B226964223A2261222C2274657874223A2278227D2C7B226964223A2262222C2274657874223A2279227D5D2C227265636F6D6D656E646174696F6E223A2261222C227469746C65223A22436166C3A920E2809C6465736BE2809D227D",
|
||||
"sha256": "71d5c413478ad06aaa1cbdd9e91c53f0cb4cef1dbd4c51bf3d473bccc6a5a272"
|
||||
},
|
||||
{
|
||||
"name": "heimdall_F_option_id_changed",
|
||||
"heimdall": "F",
|
||||
"title": "Slot mark consent",
|
||||
"context": "Should a slot mark be shared?",
|
||||
"options": [
|
||||
{
|
||||
"id": "share",
|
||||
"text": "Share it."
|
||||
},
|
||||
{
|
||||
"id": "no",
|
||||
"text": "Keep it local."
|
||||
}
|
||||
],
|
||||
"recommendation": "no",
|
||||
"depends": ["gamecult-chat:question:file-zone-audience"],
|
||||
"canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"share\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}",
|
||||
"canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A227368617265222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D",
|
||||
"sha256": "fb53aa3a9f7e74029f755d774064eedd240ab90d8fc64723f97c31b3d2846d40"
|
||||
},
|
||||
{
|
||||
"name": "heimdall_G_depends_emptied",
|
||||
"heimdall": "G",
|
||||
"title": "Slot mark consent",
|
||||
"context": "Should a slot mark be shared?",
|
||||
"options": [
|
||||
{
|
||||
"id": "yes",
|
||||
"text": "Share it."
|
||||
},
|
||||
{
|
||||
"id": "no",
|
||||
"text": "Keep it local."
|
||||
}
|
||||
],
|
||||
"recommendation": "no",
|
||||
"depends": [],
|
||||
"canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}",
|
||||
"canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B5D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D",
|
||||
"sha256": "f856edce121db4121b5f1af400b1c471ebbfa6579a042aca9a34ae31c15f5c86"
|
||||
},
|
||||
{
|
||||
"name": "option_text_changed",
|
||||
"heimdall": null,
|
||||
"title": "Slot mark consent",
|
||||
"context": "Should a slot mark be shared?",
|
||||
"options": [
|
||||
{
|
||||
"id": "yes",
|
||||
"text": "Share it!"
|
||||
},
|
||||
{
|
||||
"id": "no",
|
||||
"text": "Keep it local."
|
||||
}
|
||||
],
|
||||
"recommendation": "no",
|
||||
"depends": ["gamecult-chat:question:file-zone-audience"],
|
||||
"canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it!\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}",
|
||||
"canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A22536861726520697421227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D",
|
||||
"sha256": "9ce0d2770497fdd2d5338e66f9f1ae1741d87301db4c793407c25762929392b6"
|
||||
},
|
||||
{
|
||||
"name": "title_changed",
|
||||
"heimdall": null,
|
||||
"title": "Slot mark consent?",
|
||||
"context": "Should a slot mark be shared?",
|
||||
"options": [
|
||||
{
|
||||
"id": "yes",
|
||||
"text": "Share it."
|
||||
},
|
||||
{
|
||||
"id": "no",
|
||||
"text": "Keep it local."
|
||||
}
|
||||
],
|
||||
"recommendation": "no",
|
||||
"depends": ["gamecult-chat:question:file-zone-audience"],
|
||||
"canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent?\"}",
|
||||
"canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E743F227D",
|
||||
"sha256": "30fe0c5bc88bd21d64592a495316c30e80fcee6cacf5c63fd9a818ca5cf87d0f"
|
||||
},
|
||||
{
|
||||
"name": "context_changed",
|
||||
"heimdall": null,
|
||||
"title": "Slot mark consent",
|
||||
"context": "Should slot marks be shared?",
|
||||
"options": [
|
||||
{
|
||||
"id": "yes",
|
||||
"text": "Share it."
|
||||
},
|
||||
{
|
||||
"id": "no",
|
||||
"text": "Keep it local."
|
||||
}
|
||||
],
|
||||
"recommendation": "no",
|
||||
"depends": ["gamecult-chat:question:file-zone-audience"],
|
||||
"canonical": "{\"context\":\"Should slot marks be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}",
|
||||
"canonicalHex": "7B22636F6E74657874223A2253686F756C6420736C6F74206D61726B73206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D",
|
||||
"sha256": "716de65096e34fd89cc92848b667d90f5c27eeb66929da3d340cabb80155e173"
|
||||
},
|
||||
{
|
||||
"name": "depends_order_changed",
|
||||
"heimdall": null,
|
||||
"title": "Slot mark consent",
|
||||
"context": "Should a slot mark be shared?",
|
||||
"options": [
|
||||
{
|
||||
"id": "yes",
|
||||
"text": "Share it."
|
||||
},
|
||||
{
|
||||
"id": "no",
|
||||
"text": "Keep it local."
|
||||
}
|
||||
],
|
||||
"recommendation": "no",
|
||||
"depends": ["gamecult-chat:question:file-zone-audience", "x:question:y"],
|
||||
"canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\",\"x:question:y\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}",
|
||||
"canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365222C22783A7175657374696F6E3A79225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D",
|
||||
"sha256": "fc3f651c6f498385256fc1232d5db31ece9dcc542e391f97245d27bc9e385b3d"
|
||||
},
|
||||
{
|
||||
"name": "depends_order_changed_reversed",
|
||||
"heimdall": null,
|
||||
"title": "Slot mark consent",
|
||||
"context": "Should a slot mark be shared?",
|
||||
"options": [
|
||||
{
|
||||
"id": "yes",
|
||||
"text": "Share it."
|
||||
},
|
||||
{
|
||||
"id": "no",
|
||||
"text": "Keep it local."
|
||||
}
|
||||
],
|
||||
"recommendation": "no",
|
||||
"depends": ["x:question:y", "gamecult-chat:question:file-zone-audience"],
|
||||
"canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"x:question:y\",\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}",
|
||||
"canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B22783A7175657374696F6E3A79222C2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D",
|
||||
"sha256": "b22642e30f8d68839e3a0df5bc23b83ff50a839fcecf0366a65fa6e3cf873796"
|
||||
},
|
||||
{
|
||||
"name": "hidden_members_do_not_move_it",
|
||||
"heimdall": null,
|
||||
"title": "Slot mark consent",
|
||||
"context": "Should a slot mark be shared?",
|
||||
"options": [
|
||||
{
|
||||
"id": "yes",
|
||||
"text": "Share it."
|
||||
},
|
||||
{
|
||||
"id": "no",
|
||||
"text": "Keep it local."
|
||||
}
|
||||
],
|
||||
"recommendation": "no",
|
||||
"depends": ["gamecult-chat:question:file-zone-audience"],
|
||||
"canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}",
|
||||
"canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D",
|
||||
"sha256": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48"
|
||||
},
|
||||
{
|
||||
"name": "control_characters_and_markup_are_escaped_minimally",
|
||||
"heimdall": null,
|
||||
"title": "q \" b \\ t \t n \n bell \u0007 del \u007f",
|
||||
"context": "</script> \u2028 &",
|
||||
"options": [
|
||||
{
|
||||
"id": "yes",
|
||||
"text": "Share it."
|
||||
},
|
||||
{
|
||||
"id": "no",
|
||||
"text": "Keep it local."
|
||||
}
|
||||
],
|
||||
"recommendation": "no",
|
||||
"depends": ["gamecult-chat:question:file-zone-audience"],
|
||||
"canonical": "{\"context\":\"</script> \u2028 &\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"q \\\" b \\\\ t \\t n \\n bell \\u0007 del \u007f\"}",
|
||||
"canonicalHex": "7B22636F6E74657874223A223C2F7363726970743E20E280A82026222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A2271205C222062205C5C2074205C74206E205C6E2062656C6C205C75303030372064656C207F227D",
|
||||
"sha256": "f2d5a09513ce728129f6ca13f7123aa75b2bb77541b1de8946f39239634b40fe"
|
||||
},
|
||||
{
|
||||
"name": "astral_option_id_and_text",
|
||||
"heimdall": null,
|
||||
"title": "Slot mark consent",
|
||||
"context": "Should a slot mark be shared?",
|
||||
"options": [
|
||||
{
|
||||
"id": "\ud83d\ude00",
|
||||
"text": "\ud834\udd1e"
|
||||
},
|
||||
{
|
||||
"id": "no",
|
||||
"text": "Keep it local."
|
||||
}
|
||||
],
|
||||
"recommendation": "no",
|
||||
"depends": ["gamecult-chat:question:file-zone-audience"],
|
||||
"canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"\ud83d\ude00\",\"text\":\"\ud834\udd1e\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}",
|
||||
"canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22F09F9880222C2274657874223A22F09D849E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D",
|
||||
"sha256": "836fbfce16127132bae100aa773d328a85ad08d241ae9bf47ea7f84766762cac"
|
||||
}
|
||||
]
|
||||
}
|
||||
13
src/app/features/desk/source.ts
Normal file
13
src/app/features/desk/source.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { ForkList } from './forkContract';
|
||||
|
||||
type DeskSource = (mind: string) => ForkList | undefined;
|
||||
|
||||
// Slice 1 reads no network. The preview demo installs seeded forks here; slice 2 replaces this with
|
||||
// Bifrost's list reader, which is why the page asks a function and not a fixture.
|
||||
let source: DeskSource = () => undefined;
|
||||
|
||||
export const setDeskSource = (next: DeskSource) => {
|
||||
source = next;
|
||||
};
|
||||
|
||||
export const deskFor = (mind: string): ForkList | undefined => source(mind);
|
||||
144
src/app/features/dings/dings.test.ts
Normal file
144
src/app/features/dings/dings.test.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { DING_VOLUME, createDingPlayer, motifFor, parseManifest, pickDing } from './dings';
|
||||
|
||||
const manifest = parseManifest({
|
||||
neutral: { mention: 'neutral/mention.ogg', message: 'neutral/message.ogg' },
|
||||
senders: {
|
||||
eureka: { mention: 'eureka/mention.ogg', message: 'eureka/message.ogg', dm: 'eureka/dm.ogg' },
|
||||
nibu: { message: 'nibu/message.ogg' },
|
||||
},
|
||||
})!;
|
||||
|
||||
describe('dings manifest', () => {
|
||||
it('is read defensively: junk is no manifest, bad paths are dropped', () => {
|
||||
expect(parseManifest(undefined)).toBeUndefined();
|
||||
expect(parseManifest('x')).toBeUndefined();
|
||||
expect(parseManifest({ neutral: 3, senders: [] })).toBeUndefined();
|
||||
const m = parseManifest({
|
||||
neutral: { message: '../../etc/passwd.ogg', mention: 'https://evil.example/a.ogg' },
|
||||
senders: { void: { message: 'void/message.wav', dm: 'void/dm.ogg', mention: '/abs.ogg' } },
|
||||
})!;
|
||||
expect(m.neutral).toEqual({});
|
||||
expect(m.senders.void).toEqual({ dm: 'void/dm.ogg' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('manifest guards', () => {
|
||||
it('rejects a dot segment in the middle of a path', () => {
|
||||
const m = parseManifest({
|
||||
neutral: { message: 'a/../b.ogg', mention: 'a/b.ogg' },
|
||||
senders: {},
|
||||
})!;
|
||||
expect(m.neutral).toEqual({ mention: 'a/b.ogg' });
|
||||
});
|
||||
|
||||
it('is not fooled by an array of senders', () => {
|
||||
expect(parseManifest({ neutral: { message: 'a.ogg' }, senders: [] })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores motifs a clip set only inherits', () => {
|
||||
const inherited = Object.create({ message: 'x/message.ogg' });
|
||||
const m = parseManifest({ neutral: inherited, senders: {} })!;
|
||||
expect(m.neutral).toEqual({});
|
||||
});
|
||||
|
||||
it('gives object-prototype names no clip of their own', () => {
|
||||
const m = parseManifest({ neutral: { message: 'n/message.ogg' }, senders: {} })!;
|
||||
['__proto__', 'constructor', 'toString', 'hasOwnProperty'].forEach((who) => {
|
||||
expect(pickDing(m, who, 'message')?.path).toBe('n/message.ogg');
|
||||
});
|
||||
});
|
||||
|
||||
it('reports the motif volume in the pick itself', () => {
|
||||
expect(pickDing(manifest, 'eureka', 'message')?.volume).toBe(DING_VOLUME.message);
|
||||
expect(pickDing(manifest, 'stranger', 'mention')?.volume).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('which ding plays', () => {
|
||||
it('uses the sender instrument for the motif', () => {
|
||||
expect(pickDing(manifest, 'eureka', 'mention')).toEqual({
|
||||
path: 'eureka/mention.ogg',
|
||||
volume: DING_VOLUME.mention,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the neutral ding for an unknown sender', () => {
|
||||
expect(pickDing(manifest, 'stranger', 'message')?.path).toBe('neutral/message.ogg');
|
||||
});
|
||||
|
||||
it('falls back to neutral when the sender has no clip for that motif', () => {
|
||||
expect(pickDing(manifest, 'nibu', 'mention')?.path).toBe('neutral/mention.ogg');
|
||||
});
|
||||
|
||||
it('is silent when neither the sender nor neutral has a clip', () => {
|
||||
expect(pickDing(manifest, 'eureka', 'invite')).toBeUndefined();
|
||||
expect(pickDing(manifest, 'stranger', 'dm')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('is loud for mentions and DMs and quiet for ordinary messages', () => {
|
||||
expect(DING_VOLUME.mention).toBe(1);
|
||||
expect(DING_VOLUME.dm).toBe(1);
|
||||
expect(DING_VOLUME.message).toBeLessThan(DING_VOLUME.invite);
|
||||
expect(DING_VOLUME.invite).toBeLessThan(DING_VOLUME.mention);
|
||||
});
|
||||
|
||||
it('names the motif: invite over mention over DM over message', () => {
|
||||
expect(motifFor({ invite: true, highlight: true, direct: true })).toBe('invite');
|
||||
expect(motifFor({ highlight: true, direct: true })).toBe('mention');
|
||||
expect(motifFor({ highlight: false, direct: true })).toBe('dm');
|
||||
expect(motifFor({ highlight: false, direct: false })).toBe('message');
|
||||
});
|
||||
});
|
||||
|
||||
describe('audio unlock', () => {
|
||||
const make = () => {
|
||||
const listeners = new Map<string, () => void>();
|
||||
const target = {
|
||||
addEventListener: (t: string, f: () => void) => listeners.set(t, f),
|
||||
removeEventListener: (t: string) => listeners.delete(t),
|
||||
};
|
||||
const played: Array<{ url: string; volume: number }> = [];
|
||||
const player = createDingPlayer((url) => {
|
||||
const clip = { volume: 1, play: vi.fn(async () => undefined) };
|
||||
played.push({
|
||||
url,
|
||||
get volume() {
|
||||
return clip.volume;
|
||||
},
|
||||
} as never);
|
||||
return clip;
|
||||
}, target);
|
||||
return { listeners, player, played };
|
||||
};
|
||||
|
||||
it('does not play before the first user gesture, and plays after it', () => {
|
||||
const { listeners, player, played } = make();
|
||||
expect(player.play('a.ogg', 1)).toBe(false);
|
||||
expect(played).toHaveLength(0);
|
||||
listeners.get('pointerdown')!();
|
||||
expect(player.play('a.ogg', 0.3)).toBe(true);
|
||||
expect(played).toHaveLength(1);
|
||||
expect(played[0].volume).toBe(0.3);
|
||||
});
|
||||
|
||||
it('stops listening once unlocked', () => {
|
||||
const { listeners, player } = make();
|
||||
listeners.get('keydown')!();
|
||||
expect(listeners.size).toBe(0);
|
||||
expect(player.unlocked()).toBe(true);
|
||||
});
|
||||
|
||||
it('a clip the browser refuses does not throw', async () => {
|
||||
const target = {
|
||||
addEventListener: (_t: string, f: () => void) => f(),
|
||||
removeEventListener: () => undefined,
|
||||
};
|
||||
const player = createDingPlayer(
|
||||
() => ({ volume: 1, play: () => Promise.reject(new Error('NotAllowedError')) }),
|
||||
target
|
||||
);
|
||||
expect(() => player.play('a.ogg', 1)).not.toThrow();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
100
src/app/features/dings/dings.ts
Normal file
100
src/app/features/dings/dings.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
// Dings: pre-rendered earcons shipped as static assets, `dings/<mxid-localpart>/<motif>.ogg` plus
|
||||
// `dings/manifest.json` (docs/gamecult/ratatoskr-target.md, Agreements). The sender picks the
|
||||
// instrument, the event picks the motif. This file is the whole decision; the client only asks it.
|
||||
|
||||
export type Motif = 'mention' | 'dm' | 'message' | 'invite';
|
||||
type Clips = Partial<Record<Motif, string>>;
|
||||
export type DingManifest = { neutral: Clips; senders: Record<string, Clips> };
|
||||
|
||||
/** Client config `dings`. Off unless `enabled` is exactly true; nothing about dings loads otherwise. */
|
||||
export type DingsConfig = { enabled?: boolean };
|
||||
export const dingsEnabled = (config: DingsConfig | undefined): boolean => config?.enabled === true;
|
||||
|
||||
const MOTIFS: Motif[] = ['mention', 'dm', 'message', 'invite'];
|
||||
|
||||
// Mentions and DMs loud, an ordinary message quiet.
|
||||
export const DING_VOLUME: Record<Motif, number> = {
|
||||
mention: 1,
|
||||
dm: 1,
|
||||
invite: 0.7,
|
||||
message: 0.3,
|
||||
};
|
||||
|
||||
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
||||
typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
|
||||
// A clip path stays inside dings/: relative, ogg or mp3 (not wav), no dot segments, no scheme.
|
||||
const isClipPath = (v: unknown): v is string =>
|
||||
typeof v === 'string' &&
|
||||
/^[A-Za-z0-9_-]+(\/[A-Za-z0-9_.-]+)*\.(ogg|mp3)$/.test(v) &&
|
||||
!v.split('/').includes('..');
|
||||
|
||||
const readClips = (v: unknown): Clips => {
|
||||
const clips: Clips = {};
|
||||
if (!isRecord(v)) return clips;
|
||||
MOTIFS.forEach((m) => {
|
||||
if (Object.prototype.hasOwnProperty.call(v, m) && isClipPath(v[m])) clips[m] = v[m] as string;
|
||||
});
|
||||
return clips;
|
||||
};
|
||||
|
||||
/** The manifest is JSON from outside the typed world: read it defensively, undefined if unusable. */
|
||||
export const parseManifest = (json: unknown): DingManifest | undefined => {
|
||||
if (!isRecord(json) || !isRecord(json.neutral) || !isRecord(json.senders)) return undefined;
|
||||
const senders: Record<string, Clips> = Object.create(null);
|
||||
Object.keys(json.senders).forEach((localpart) => {
|
||||
senders[localpart] = readClips(
|
||||
json.senders && (json.senders as Record<string, unknown>)[localpart]
|
||||
);
|
||||
});
|
||||
return { neutral: readClips(json.neutral), senders };
|
||||
};
|
||||
|
||||
/** The sender's own clip, else the neutral one, else nothing: silence. */
|
||||
export const pickDing = (
|
||||
manifest: DingManifest,
|
||||
localpart: string,
|
||||
motif: Motif
|
||||
): { path: string; volume: number } | undefined => {
|
||||
const own = Object.prototype.hasOwnProperty.call(manifest.senders, localpart)
|
||||
? manifest.senders[localpart][motif]
|
||||
: undefined;
|
||||
const path = own ?? manifest.neutral[motif];
|
||||
return path === undefined ? undefined : { path, volume: DING_VOLUME[motif] };
|
||||
};
|
||||
|
||||
export const motifFor = (e: { invite?: boolean; highlight: boolean; direct: boolean }): Motif => {
|
||||
if (e.invite) return 'invite';
|
||||
if (e.highlight) return 'mention';
|
||||
return e.direct ? 'dm' : 'message';
|
||||
};
|
||||
|
||||
type Clip = { volume: number; play: () => Promise<void> };
|
||||
type GestureTarget = {
|
||||
addEventListener: (type: string, listener: () => void) => void;
|
||||
removeEventListener: (type: string, listener: () => void) => void;
|
||||
};
|
||||
const GESTURES = ['pointerdown', 'keydown', 'touchend'];
|
||||
|
||||
/**
|
||||
* Browsers refuse audio until the first user gesture. The player stays silent until one has
|
||||
* happened, then plays; a clip the browser still refuses is dropped, never thrown.
|
||||
*/
|
||||
export const createDingPlayer = (make: (url: string) => Clip, target: GestureTarget) => {
|
||||
let unlocked = false;
|
||||
const unlock = () => {
|
||||
unlocked = true;
|
||||
GESTURES.forEach((g) => target.removeEventListener(g, unlock));
|
||||
};
|
||||
GESTURES.forEach((g) => target.addEventListener(g, unlock));
|
||||
return {
|
||||
unlocked: () => unlocked,
|
||||
play(url: string, volume: number): boolean {
|
||||
if (!unlocked) return false;
|
||||
const clip = make(url);
|
||||
clip.volume = volume;
|
||||
clip.play().catch(() => undefined);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
};
|
||||
123
src/app/features/dings/manifest.test.ts
Normal file
123
src/app/features/dings/manifest.test.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { DingManifest, Motif, dingsEnabled, parseManifest, pickDing } from './dings';
|
||||
|
||||
// The staged v4 set (Aqua, AquaSynth merge 21e83ec, page minds.gamecult.org/aqua/dings-voices-dd9be66). Nobody has heard it; these tests check the manifest
|
||||
// against this code, not the sound.
|
||||
const DINGS = join(__dirname, '../../../../public/dings');
|
||||
const raw = JSON.parse(readFileSync(join(DINGS, 'manifest.json'), 'utf8'));
|
||||
const manifest = parseManifest(raw) as DingManifest;
|
||||
// sha256 prefixes of the 32 clips as staged by Aqua; Nibu's four are proposals/nibu3 (same samples, own Ogg serial).
|
||||
const V4_SHA: Record<string, string> = {
|
||||
'aqua/mention': 'cedb63552008',
|
||||
'aqua/dm': '5b996d01c770',
|
||||
'aqua/message': '8be19ccd505a',
|
||||
'aqua/invite': '462e63687f0b',
|
||||
'eureka/mention': '7afe0bcc90d0',
|
||||
'eureka/dm': '3909d70b0d45',
|
||||
'eureka/message': '0b466cd4b437',
|
||||
'eureka/invite': '837e1bbbdacf',
|
||||
'heimdall/mention': '36051569eb2c',
|
||||
'heimdall/dm': '26ebc4158d51',
|
||||
'heimdall/message': 'c383d6ce3da0',
|
||||
'heimdall/invite': 'fd47e9a97bb2',
|
||||
'kiko/mention': 'e73d2d586f4e',
|
||||
'kiko/dm': 'b3e39946ab70',
|
||||
'kiko/message': '6730b9df5bfa',
|
||||
'kiko/invite': '1a3c1d118b9e',
|
||||
'libby/mention': 'eb9f3ac60b02',
|
||||
'libby/dm': '77dfd01cc3ca',
|
||||
'libby/message': '257b84a114a2',
|
||||
'libby/invite': '8ef7e91100b8',
|
||||
'nibu/mention': '56484d731df3',
|
||||
'nibu/dm': '573f5db4de4b',
|
||||
'nibu/message': '30f6a5c9ce54',
|
||||
'nibu/invite': 'bb36c0975f35',
|
||||
'void/mention': 'dee69f4af562',
|
||||
'void/dm': '92e3ebbc0b29',
|
||||
'void/message': 'fadc5556b681',
|
||||
'void/invite': 'd602121389d8',
|
||||
'weksa/mention': 'db46a36ab3c5',
|
||||
'weksa/dm': 'efe993382a46',
|
||||
'weksa/message': '00e323635949',
|
||||
'weksa/invite': '9bf03a36758b',
|
||||
};
|
||||
const MOTIFS: Motif[] = ['mention', 'dm', 'message', 'invite'];
|
||||
const VOICES = ['aqua', 'eureka', 'heimdall', 'kiko', 'libby', 'nibu', 'void', 'weksa'];
|
||||
|
||||
// Vorbis-in-Ogg duration: last page's granule position over the sample rate in the id header.
|
||||
const oggMs = (file: string): number => {
|
||||
const b = readFileSync(join(DINGS, file));
|
||||
const id = b.indexOf('\x01vorbis', 0, 'latin1');
|
||||
const rate = b.readUInt32LE(id + 12);
|
||||
const last = b.lastIndexOf('OggS', b.length, 'latin1');
|
||||
return (Number(b.readBigUInt64LE(last + 6)) / rate) * 1000;
|
||||
};
|
||||
|
||||
describe('dings switch', () => {
|
||||
it('is off unless the config says exactly true', () => {
|
||||
expect(dingsEnabled(undefined)).toBe(false);
|
||||
expect(dingsEnabled({})).toBe(false);
|
||||
expect(dingsEnabled({ enabled: false })).toBe(false);
|
||||
expect(dingsEnabled({ enabled: 'true' as unknown as boolean })).toBe(false);
|
||||
expect(dingsEnabled({ enabled: true })).toBe(true);
|
||||
});
|
||||
|
||||
it('ships off in config.json', () => {
|
||||
const config = JSON.parse(readFileSync(join(__dirname, '../../../../config.json'), 'utf8'));
|
||||
expect(config.dings).toEqual({ enabled: false });
|
||||
expect(dingsEnabled(config.dings)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('v4 manifest against the parser', () => {
|
||||
it('survives parseManifest whole: no path or motif is dropped', () => {
|
||||
expect(manifest.neutral).toEqual(raw.neutral);
|
||||
expect(Object.keys(manifest.senders).sort()).toEqual(VOICES);
|
||||
VOICES.forEach((v) => expect(manifest.senders[v]).toEqual(raw.senders[v]));
|
||||
MOTIFS.forEach((m) => expect(manifest.neutral[m]).toBeDefined());
|
||||
});
|
||||
|
||||
it('names a file that exists for every clip, and no file the manifest omits', () => {
|
||||
const paths = [
|
||||
...Object.values(manifest.neutral),
|
||||
...Object.values(manifest.senders).flatMap((c) => Object.values(c)),
|
||||
];
|
||||
expect(paths).toHaveLength(4 + VOICES.length * 4);
|
||||
paths.forEach((p) => expect(existsSync(join(DINGS, p as string)), p).toBe(true));
|
||||
});
|
||||
|
||||
it('maps a sender localpart to that sender folder, per motif', () => {
|
||||
VOICES.forEach((v) =>
|
||||
MOTIFS.forEach((m) => expect(pickDing(manifest, v, m)?.path).toBe(`senders/${v}/${m}.ogg`))
|
||||
);
|
||||
});
|
||||
|
||||
it('an unknown sender and an invite get the neutral clip', () => {
|
||||
MOTIFS.forEach((m) => expect(pickDing(manifest, 'someone', m)?.path).toBe(`neutral/${m}.ogg`));
|
||||
});
|
||||
|
||||
it('Nibu carries pick 3 (8 kHz 5-bit smoothed sine) for every event: senders/nibu, never the proposals folder', () => {
|
||||
MOTIFS.forEach((m) => {
|
||||
const path = pickDing(manifest, 'nibu', m)?.path as string;
|
||||
expect(path).toBe(`senders/nibu/${m}.ogg`);
|
||||
expect(path).not.toContain('proposals');
|
||||
});
|
||||
});
|
||||
|
||||
it("every voice file is Aqua's v4 render (sha256 prefix)", () => {
|
||||
const hash = (file: string) =>
|
||||
createHash('sha256').update(readFileSync(join(DINGS, file))).digest('hex').slice(0, 12);
|
||||
expect(Object.keys(V4_SHA).length).toBe(VOICES.length * MOTIFS.length);
|
||||
Object.entries(V4_SHA).forEach(([k, want]) => expect(hash(`senders/${k}.ogg`)).toBe(want));
|
||||
});
|
||||
|
||||
it('keeps the clip lengths the design assumes: message under 400 ms, nothing over 600 ms', () => {
|
||||
VOICES.forEach((v) => {
|
||||
expect(oggMs(`senders/${v}/message.ogg`)).toBeLessThan(400);
|
||||
MOTIFS.forEach((m) => expect(oggMs(`senders/${v}/${m}.ogg`)).toBeLessThan(601));
|
||||
});
|
||||
});
|
||||
});
|
||||
55
src/app/features/dings/useDings.ts
Normal file
55
src/app/features/dings/useDings.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useClientConfig } from '../../hooks/useClientConfig';
|
||||
import { trimTrailingSlash } from '../../utils/common';
|
||||
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||
import {
|
||||
DingManifest,
|
||||
Motif,
|
||||
createDingPlayer,
|
||||
dingsEnabled,
|
||||
parseManifest,
|
||||
pickDing,
|
||||
} from './dings';
|
||||
|
||||
const DINGS_ROOT = `${trimTrailingSlash(import.meta.env.BASE_URL)}/public/dings`;
|
||||
|
||||
/**
|
||||
* Plays a ding for a sender and motif. The manifest is read once at runtime. Returns false when
|
||||
* there is no manifest (nothing rendered yet), so the caller keeps the stock sound; once there is
|
||||
* one, a sender or motif with no clip is silence, as the design says, and the return is true.
|
||||
* The client config's `dings.enabled` is the switch (default off): off, nothing is fetched, no
|
||||
* gesture listener is added, and the return is always false, so the stock sound plays.
|
||||
*/
|
||||
export const useDings = () => {
|
||||
const enabled = dingsEnabled(useClientConfig().dings);
|
||||
const manifestRef = useRef<DingManifest>();
|
||||
const player = useMemo(
|
||||
() => (enabled ? createDingPlayer((url) => new Audio(url), window) : undefined),
|
||||
[enabled]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return undefined;
|
||||
let live = true;
|
||||
fetch(`${DINGS_ROOT}/manifest.json`)
|
||||
.then((r) => (r.ok ? r.json() : undefined))
|
||||
.then((json) => {
|
||||
if (live) manifestRef.current = parseManifest(json);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
return useCallback(
|
||||
(sender: string, motif: Motif): boolean => {
|
||||
const manifest = manifestRef.current;
|
||||
if (!player || !manifest) return false;
|
||||
const ding = pickDing(manifest, getMxIdLocalPart(sender) ?? sender, motif);
|
||||
if (ding) player.play(`${DINGS_ROOT}/${ding.path}`, ding.volume);
|
||||
return true;
|
||||
},
|
||||
[player]
|
||||
);
|
||||
};
|
||||
|
|
@ -42,6 +42,7 @@ import { EstateGroup, EstateRoom, EstateTier, splitGroups } from './buildEstate'
|
|||
import { activateEstateRoom, awaitMyJoin } from './joinEstateRoom';
|
||||
import { useEstate, useEstateRootId } from './useEstate';
|
||||
import * as css from './EstateNavigator.css';
|
||||
import { DeskEntry } from '../desk';
|
||||
import { SkyStateControl } from '../scene/SkyStateControl';
|
||||
|
||||
/** How long a tap waits for the client store to hold the room it just joined. */
|
||||
|
|
@ -135,9 +136,11 @@ type CollapsibleProps = {
|
|||
categoryId: string;
|
||||
label: string;
|
||||
rooms: EstateRoom[];
|
||||
/** Sits in the header beside the label, so a collapsed section still shows it. */
|
||||
trailing?: ReactNode;
|
||||
children: ReactNode;
|
||||
};
|
||||
function Collapsible({ categoryId, label, rooms, children }: CollapsibleProps) {
|
||||
function Collapsible({ categoryId, label, rooms, trailing, children }: CollapsibleProps) {
|
||||
const mobile = useScreenSizeContext() === ScreenSize.Mobile;
|
||||
const selectedRoomId = useSelectedRoom();
|
||||
const roomToUnread = useAtomValue(roomToUnreadAtom);
|
||||
|
|
@ -161,6 +164,7 @@ function Collapsible({ categoryId, label, rooms, children }: CollapsibleProps) {
|
|||
<RoomNavCategoryButton closed={closed} onClick={handleClick}>
|
||||
{label}
|
||||
</RoomNavCategoryButton>
|
||||
{trailing}
|
||||
</NavCategoryHeader>
|
||||
{!closed && children}
|
||||
</NavCategory>
|
||||
|
|
@ -169,7 +173,12 @@ function Collapsible({ categoryId, label, rooms, children }: CollapsibleProps) {
|
|||
|
||||
function GroupSection({ tierId, group }: { tierId: string; group: EstateGroup }) {
|
||||
return (
|
||||
<Collapsible categoryId={`${tierId}|${group.mind}`} label={group.label} rooms={group.rooms}>
|
||||
<Collapsible
|
||||
categoryId={`${tierId}|${group.mind}`}
|
||||
label={group.label}
|
||||
rooms={group.rooms}
|
||||
trailing={<DeskEntry mind={group.mind} />}
|
||||
>
|
||||
{group.rooms.map((r) => (
|
||||
<EstateRow key={r.roomId} room={r} />
|
||||
))}
|
||||
|
|
|
|||
32
src/app/features/estate/joinPath.lint.test.ts
Normal file
32
src/app/features/estate/joinPath.lint.test.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// @vitest-environment node
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ESLint } from 'eslint';
|
||||
|
||||
// The product rule is one join path: a join is joinEstateRoom, which files the DM before it sends the join.
|
||||
// This pins that the lint config enforces it, not just that the code happens to comply today.
|
||||
const lint = async (filePath: string, code: string) => {
|
||||
const eslint = new ESLint({ cwd: process.cwd(), useEslintrc: true });
|
||||
const [result] = await eslint.lintText(code, { filePath });
|
||||
return result.messages.filter((m) => m.ruleId === 'no-restricted-syntax');
|
||||
};
|
||||
const JOIN = 'export const f = (mx: { joinRoom: (id: string) => void }) => mx.joinRoom("!a:b");\n';
|
||||
|
||||
describe('the one join path is enforced by lint', () => {
|
||||
it('rejects a joinRoom call anywhere but joinEstateRoom.ts', async () => {
|
||||
const found = await lint('src/app/features/estate/EstateNavigator.tsx', JOIN);
|
||||
expect(found).toHaveLength(1);
|
||||
expect(found[0].message).toContain('joinEstateRoom');
|
||||
});
|
||||
|
||||
it('allows the call inside joinEstateRoom.ts itself', async () => {
|
||||
expect(await lint('src/app/features/estate/joinEstateRoom.ts', JOIN)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('still applies the Airbnb syntax bans in the same files', async () => {
|
||||
const found = await lint(
|
||||
'src/app/foo.ts',
|
||||
'export const g = (o: object) => { for (const k in o) { return k; } return 0; };\n'
|
||||
);
|
||||
expect(found.some((m) => m.message.includes('for..in'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { config } from 'folds';
|
||||
import { surfaceInk } from '../scene/sceneShell.css';
|
||||
import { skyLabelInk } from '../scene/skyLabelInk';
|
||||
|
||||
export const CategoryButton = style({
|
||||
flexGrow: 1,
|
||||
|
|
@ -15,6 +16,7 @@ export const CategoryButton = style({
|
|||
borderRadius: 0,
|
||||
},
|
||||
':root[data-scene] &&': {
|
||||
color: skyLabelInk('inherit'),
|
||||
textShadow: `0 0 3px ${surfaceInk}, 0 0 8px ${surfaceInk}`,
|
||||
filter: `drop-shadow(0 0 3px ${surfaceInk})`,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -123,7 +123,8 @@ import { useSpaceOptionally } from '../../hooks/useSpace';
|
|||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||
import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
|
||||
import { useTheme } from '../../hooks/useTheme';
|
||||
import { useSkyInk, useTheme } from '../../hooks/useTheme';
|
||||
import { skyLabelInk } from '../scene/skyLabelInk';
|
||||
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
|
||||
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
|
||||
|
||||
|
|
@ -462,10 +463,12 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
const getMemberPowerTag = useGetMemberPowerTag(room, creators, powerLevels);
|
||||
|
||||
const theme = useTheme();
|
||||
const skyInk = useSkyInk();
|
||||
const accessiblePowerTagColors = useAccessiblePowerTagColors(
|
||||
theme.kind,
|
||||
creatorsTag,
|
||||
powerLevelTags
|
||||
powerLevelTags,
|
||||
skyInk
|
||||
);
|
||||
|
||||
const permissions = useRoomPermissions(creators, powerLevels);
|
||||
|
|
@ -1674,9 +1677,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
newDivider && eventJSX && eventSender !== mx.getUserId() ? (
|
||||
<MessageBase space={messageSpacing}>
|
||||
<TimelineDivider style={{ color: color.Success.Main }} variant="Inherit">
|
||||
<Badge as="span" size="500" variant="Success" fill="Solid" radii="300">
|
||||
<Text size="L400">New Messages</Text>
|
||||
</Badge>
|
||||
<Text as="span" size="L400" style={{ color: skyLabelInk(color.Surface.OnContainer) }}>
|
||||
New Messages
|
||||
</Text>
|
||||
</TimelineDivider>
|
||||
</MessageBase>
|
||||
) : null;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { Room } from 'matrix-js-sdk';
|
|||
import React from 'react';
|
||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||
import { useClientConfig } from '../../hooks/useClientConfig';
|
||||
import { useSkyInk } from '../../hooks/useTheme';
|
||||
import { noticeInk } from '../scene/skyInk';
|
||||
import {
|
||||
ROOM_NOTICE_EVENT_TYPE,
|
||||
parseRoomNotice,
|
||||
|
|
@ -14,12 +16,16 @@ import {
|
|||
export function RoomTrainingLine({ room }: { room: Room }) {
|
||||
const event = useStateEvent(room, ROOM_NOTICE_EVENT_TYPE);
|
||||
const { previewDemo } = useClientConfig();
|
||||
const skyInk = useSkyInk();
|
||||
const line = roomTrainingLine(parseRoomNotice(event?.getContent()), previewDemo);
|
||||
if (!line) return null;
|
||||
return (
|
||||
<Text
|
||||
size="T200"
|
||||
style={{ padding: `${config.space.S100} 0`, color: color.SurfaceVariant.OnContainer }}
|
||||
style={{
|
||||
padding: `${config.space.S100} 0`,
|
||||
color: (skyInk && noticeInk(skyInk)) ?? color.SurfaceVariant.OnContainer,
|
||||
}}
|
||||
>
|
||||
{line}
|
||||
</Text>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useCallback, useRef } from 'react';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { Box, Text, config } from 'folds';
|
||||
import { EventType } from 'matrix-js-sdk';
|
||||
import { ReactEditor } from 'slate-react';
|
||||
|
|
@ -23,6 +23,10 @@ import { useSetting } from '../../state/hooks/settings';
|
|||
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
import { useRoom } from '../../hooks/useRoom';
|
||||
import { AlbumView } from '../albums/AlbumView';
|
||||
import * as albumCss from '../albums/AlbumView.css';
|
||||
import { useIsFileZone } from '../albums/useAlbums';
|
||||
import { ZoneView, zoneView } from '../albums/zoneView';
|
||||
|
||||
const FN_KEYS_REGEX = /^F\d+$/;
|
||||
const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
|
||||
|
|
@ -64,6 +68,12 @@ export function RoomView({ eventId }: { eventId?: string }) {
|
|||
const room = useRoom();
|
||||
const { roomId } = room;
|
||||
const editor = useEditor();
|
||||
const isZone = useIsFileZone(room);
|
||||
// A zone shows its albums; the timeline stays one tap away, and an event deep link opens it.
|
||||
const [choice, setChoice] = useState<{ roomId: string; view: ZoneView }>();
|
||||
const fileZone =
|
||||
isZone &&
|
||||
zoneView(choice && choice.roomId === roomId ? choice.view : undefined, eventId) === 'albums';
|
||||
|
||||
const mx = useMatrixClient();
|
||||
|
||||
|
|
@ -78,7 +88,7 @@ export function RoomView({ eventId }: { eventId?: string }) {
|
|||
window,
|
||||
useCallback(
|
||||
(evt) => {
|
||||
if (editableActiveElement()) return;
|
||||
if (fileZone || editableActiveElement()) return;
|
||||
const portalContainer = document.getElementById('portalContainer');
|
||||
if (portalContainer && portalContainer.children.length > 0) {
|
||||
return;
|
||||
|
|
@ -87,13 +97,35 @@ export function RoomView({ eventId }: { eventId?: string }) {
|
|||
ReactEditor.focus(editor);
|
||||
}
|
||||
},
|
||||
[editor]
|
||||
[editor, fileZone]
|
||||
)
|
||||
);
|
||||
|
||||
// The file zone is its own view: no timeline, no composer.
|
||||
if (fileZone) {
|
||||
return (
|
||||
<Page ref={roomViewRef}>
|
||||
<AlbumView
|
||||
key={roomId}
|
||||
room={room}
|
||||
onTimeline={() => setChoice({ roomId, view: 'timeline' })}
|
||||
/>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Page ref={roomViewRef}>
|
||||
<Box grow="Yes" direction="Column">
|
||||
{isZone && !eventId && (
|
||||
<button
|
||||
type="button"
|
||||
className={albumCss.Back}
|
||||
onClick={() => setChoice({ roomId, view: 'albums' })}
|
||||
>
|
||||
<Text size="B400">‹ Albums</Text>
|
||||
</button>
|
||||
)}
|
||||
<RoomTimeline
|
||||
key={roomId}
|
||||
room={room}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,18 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { config } from 'folds';
|
||||
import { MOBILE_BREAKPOINT } from '../../hooks/useScreenSize';
|
||||
|
||||
// Phone tap target: the topic is an 18 px line under the room name; its button grows 9 px up and 17 px down
|
||||
// (44 px to tap; more below because the name is above) and the negative margin keeps the layout where it was.
|
||||
export const HeaderTopic = style({
|
||||
'@media': {
|
||||
[`screen and (max-width: ${MOBILE_BREAKPOINT}px)`]: {
|
||||
paddingTop: 9,
|
||||
paddingBottom: 17,
|
||||
marginTop: -9,
|
||||
marginBottom: -17,
|
||||
},
|
||||
},
|
||||
':hover': {
|
||||
cursor: 'pointer',
|
||||
opacity: config.opacity.P500,
|
||||
|
|
|
|||
|
|
@ -82,6 +82,8 @@ import { useRoomPinnedEvents } from '../../../hooks/useRoomPinnedEvents';
|
|||
import { MemberPowerTag, StateEvent } from '../../../../types/matrix/room';
|
||||
import { PowerIcon } from '../../../components/power';
|
||||
import colorMXID from '../../../../util/colorMXID';
|
||||
import { useSkyInk } from '../../../hooks/useTheme';
|
||||
import { userColorInk } from '../../scene/skyInk';
|
||||
import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
|
||||
|
||||
export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void;
|
||||
|
|
@ -723,6 +725,7 @@ export const Message = as<'div', MessageProps>(
|
|||
ref
|
||||
) => {
|
||||
const mx = useMatrixClient();
|
||||
const skyInk = useSkyInk();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const senderId = mEvent.getSender() ?? '';
|
||||
|
||||
|
|
@ -743,7 +746,8 @@ export const Message = as<'div', MessageProps>(
|
|||
? getPowerTagIconSrc(mx, useAuthentication, memberPowerTag.icon)
|
||||
: undefined;
|
||||
|
||||
const usernameColor = legacyUsernameColor ? colorMXID(senderId) : tagColor;
|
||||
const hashColor = skyInk ? userColorInk(senderId, skyInk) : colorMXID(senderId);
|
||||
const usernameColor = legacyUsernameColor ? hashColor : tagColor;
|
||||
|
||||
const headerJSX = !collapse && (
|
||||
<Box
|
||||
|
|
|
|||
|
|
@ -4,13 +4,22 @@
|
|||
config says `backgroundScene.style: 'raymarch'`; the tier flag still defaults to `off`, and null still
|
||||
means "keep the CSS ground". Everything below this section describes the opt-in ray-march scene.
|
||||
|
||||
- `ridge/Ridge.ts` `createRidgeScene({ canvas, tier, probe?, onProbe?, sky? })` returns `{ login, dispose }` or
|
||||
null (tier `off`, no 2D context). Four ridge layers are generated once on the CPU from CultMath's
|
||||
value-noise fbm (`ridge/noise.ts`, a clean-room port, periodic so the drift loops), each painted once to a plain
|
||||
2D canvas of integer size (two periods wide; DPR 2 or more paints at 2x, otherwise 1x; no resampling between
|
||||
layers) over a baked sky gradient on the host canvas. Per frame the only motion is the browser sliding each layer with a
|
||||
CSS transform (a Web Animation of `translate3d`, 150-420 s per period, farther is slower); no script runs per frame.
|
||||
The host canvas and the layers' parent must be positioned and the canvas should fill the parent.
|
||||
- `ridge/Ridge.ts` `createRidgeScene({ canvas, tier, probe?, onProbe?, onBake?, sky? })` returns `{ login, dispose }` or
|
||||
null (tier `off`, no 2D context). Each of four ridge layers is a function of world-x (`ridge/ridgeWorld.ts` `profile`:
|
||||
ridged multifractal value noise, each octave folded at its crest and weighted by the previous one, domain-warped; one
|
||||
function, a parameter row per layer; nothing repeats). World-x is baked in chunks (1.2 viewport widths, integer pixels)
|
||||
onto plain 2D canvases over a baked sky gradient on the host canvas. Per layer a track holds three chunk canvases: two
|
||||
the window can reach and one ahead. The first view bakes only the first chunk of each layer; the others are baked in
|
||||
tasks of about 6 ms (`setTimeout`; a chunk is baked, put on a canvas with its in-scatter, and blurred in column bands, each sized from the last band's time). Per frame the only motion is the
|
||||
compositor sliding the track with a CSS transform (a Web Animation of `translate3d`, one chunk long, `fill: forwards`,
|
||||
150-420 s per viewport width, farther is slower); when it finishes the chunks move one slot left and the track restarts
|
||||
at 0 in the same task (no jump), and the canvas that left is baked with the next stretch of world. A second animation
|
||||
sways the whole rig (up to 5% of the width sideways, 3% of the height down, 150 s loop): the slow yaw and pitch.
|
||||
No script runs per frame. The host canvas and the layers' parent must be positioned and the canvas should fill the parent.
|
||||
- `ridge/ridgeBake.ts` is the pixel function (pure, no DOM): per column the crest's slope gives a normal, lit against the
|
||||
sun (`sunDirection`: x = 0.9 sin(2 pi \* `sunAzimuth`), so the sun is toward +x for azimuth 0 to 0.5, strongest at 0.25, toward -x for 0.5 to 1; with no `sunAzimuth` the face lighting falls back to 0.3 and the in-scatter is off); the face under it has a light-to-haze gradient, erosion
|
||||
streaks (a folded noise at gully width, darker with steepness) and a lit rim on the crest. Contrast scales with
|
||||
`transmittance`; the base colour, haze and blur are the aerial perspective below, not redone.
|
||||
- Paused when the tab is hidden; under `prefers-reduced-motion` the layers are painted but never played.
|
||||
`tier: 'still'` flattens sky and layers into the host canvas at once and leaves no extra elements.
|
||||
- Cadence guard: for the first 2 s after start (after 5 warm-up frames) rAF intervals are sampled; if their p95 is over
|
||||
|
|
@ -23,8 +32,9 @@ means "keep the CSS ground". Everything below this section describes the opt-in
|
|||
in-scatter). Layer distance d = 1 - depth; transmittance T = exp(-1.6 d) (0.24 / 0.37 / 0.58 / 1.00 far to near); colour is
|
||||
`T * ground + (1 - T) * haze`, then chroma drops by `0.5 (1 - T)`; the edge is blurred up to 2 px (canvas pixels at DPR 1,
|
||||
via the 2D `filter`, skipped where unsupported, e.g. Safari); a faint warm in-scatter (at most 0.16 alpha) peaks at the
|
||||
sun's azimuth. Nothing runs per frame. The in-scatter lobe is painted into each layer, so it drifts with the layer
|
||||
rather than staying fixed under the sun; that is a known approximation until the sun is a real, fixed light.
|
||||
sun's azimuth in world-x (two incommensurate cosines, so not periodic). Nothing runs per frame. The in-scatter lobe is
|
||||
painted into each chunk, so it drifts with the layer rather than staying fixed under the sun; that is a known
|
||||
approximation until the sun is a real, fixed light.
|
||||
- `harness/scene/bundle.cjs <out> ridge` builds a standalone page (`?tier=`, `?probe=1`).
|
||||
- Not yet measured on a phone: see the PR description.
|
||||
|
||||
|
|
|
|||
18
src/app/features/scene/cssModules.test.ts
Normal file
18
src/app/features/scene/cssModules.test.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { compile, processVanillaFile } from '@vanilla-extract/integration';
|
||||
|
||||
// vitest imports a .css.ts through the vanilla plugin, which is lenient; `vite build` compiles it with
|
||||
// vanilla-extract's integration, which refuses a function export. Compile the scene's .css.ts files the build's way.
|
||||
const FILES = [
|
||||
'src/app/features/scene/sceneShell.css.ts',
|
||||
'src/app/features/room-nav/styles.css.ts',
|
||||
'src/app/components/nav/styles.css.ts',
|
||||
];
|
||||
|
||||
describe('scene .css.ts files, compiled as the production build compiles them', () => {
|
||||
it.each(FILES)('%s compiles with exports vanilla-extract accepts', async (file) => {
|
||||
const filePath = `${process.cwd()}/${file}`;
|
||||
const { source } = await compile({ filePath, cwd: process.cwd(), identOption: 'debug' });
|
||||
await expect(processVanillaFile({ source, filePath, identOption: 'debug' })).resolves.toBeTypeOf('string');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createRidgeScene } from './Ridge';
|
||||
import { fbmPeriodic, valueNoisePeriodic, hash2 } from './noise';
|
||||
import { bakeSlice, BakeParams, layerPaint } from './ridgeBake';
|
||||
import { valueNoise, hash2 } from './noise';
|
||||
import { FrameSampler, frameStats, SLOW_P95_MS, WARMUP_FRAMES, WINDOW_MS } from './probe';
|
||||
import {
|
||||
AERIAL,
|
||||
FBM,
|
||||
LAYERS,
|
||||
edgeBlur,
|
||||
hazeColour,
|
||||
|
|
@ -23,58 +23,37 @@ describe('noise', () => {
|
|||
it('is deterministic and in [0, 1)', () => {
|
||||
expect(hash2(3, 5)).toBe(hash2(3, 5));
|
||||
for (let i = 0; i < 200; i += 1) {
|
||||
const v = valueNoisePeriodic(i * 0.37, 2, 7);
|
||||
const v = valueNoise(i * 0.37 - 20, 2);
|
||||
expect(v).toBeGreaterThanOrEqual(0);
|
||||
expect(v).toBeLessThan(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('wraps: the lattice repeats every `period` cells', () => {
|
||||
for (const x of [0, 0.4, 3.9, 6.99]) {
|
||||
expect(valueNoisePeriodic(x + 7, 4, 7)).toBeCloseTo(valueNoisePeriodic(x, 4, 7), 12);
|
||||
expect(valueNoise(i * 0.37 - 20, 2)).toBe(v);
|
||||
}
|
||||
});
|
||||
|
||||
it('different rows are different profiles', () => {
|
||||
expect(valueNoisePeriodic(1.5, 0, 9)).not.toBeCloseTo(valueNoisePeriodic(1.5, 1, 9), 3);
|
||||
expect(valueNoise(1.5, 0)).not.toBeCloseTo(valueNoise(1.5, 1), 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ridge profile', () => {
|
||||
it('every layer is periodic in t, so a drift of one period loops seamlessly', () => {
|
||||
it('stays in [0, 1] for any world-x, negative and far included, and layers differ', () => {
|
||||
for (const spec of LAYERS) {
|
||||
for (const t of [0, 0.13, 0.5, 0.97]) {
|
||||
expect(Number.isFinite(profile(spec, t))).toBe(true);
|
||||
}
|
||||
// the end of one period meets the start of the next
|
||||
expect(profile(spec, 1 - 1e-9)).toBeCloseTo(profile(spec, 0), 3);
|
||||
}
|
||||
});
|
||||
|
||||
it('every octave count is an integer lattice, which periodicity needs', () => {
|
||||
for (const spec of LAYERS) {
|
||||
for (let k = 0; k < FBM.octaves; k += 1) {
|
||||
expect(Number.isInteger(spec.baseCells * FBM.lacunarity ** k)).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('stays in [0, 1] and layers differ', () => {
|
||||
for (const spec of LAYERS) {
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
const p = profile(spec, i / 100);
|
||||
expect(p).toBeGreaterThanOrEqual(0);
|
||||
expect(p).toBeLessThanOrEqual(1);
|
||||
for (let i = -300; i < 300; i += 7) {
|
||||
for (const off of [0, 1234.5, 98765.25]) {
|
||||
const p = profile(spec, i / 50 + off);
|
||||
expect(p).toBeGreaterThanOrEqual(0);
|
||||
expect(p).toBeLessThanOrEqual(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(profile(LAYERS[0], 0.3)).not.toBeCloseTo(profile(LAYERS[1], 0.3), 3);
|
||||
});
|
||||
|
||||
it('ridge tops lie inside the layer band and rise with farther base', () => {
|
||||
it('ridge tops lie inside the layer band', () => {
|
||||
const size = layerSize(400, 800, 1);
|
||||
for (const spec of LAYERS) {
|
||||
for (let x = 0; x < size.period; x += 17) {
|
||||
const y = ridgeY(spec, x, size.period, size.height, 800);
|
||||
for (let x = -500; x < 5 * size.period; x += 17) {
|
||||
const y = ridgeY(spec, x / size.period, size.height, 800);
|
||||
expect(y).toBeGreaterThanOrEqual(0);
|
||||
expect(y).toBeLessThanOrEqual(size.height);
|
||||
}
|
||||
|
|
@ -95,9 +74,9 @@ describe('ridge profile', () => {
|
|||
] as const) {
|
||||
const z = layerSize(w, h, s);
|
||||
expect(
|
||||
Number.isInteger(z.period) && Number.isInteger(z.width) && Number.isInteger(z.height)
|
||||
Number.isInteger(z.period) && Number.isInteger(z.chunk) && Number.isInteger(z.height)
|
||||
).toBe(true);
|
||||
expect(z.width).toBe(2 * z.period);
|
||||
expect(z.chunk).toBeGreaterThan(z.period);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -147,14 +126,16 @@ describe('aerial perspective', () => {
|
|||
expect(edgeBlur(far)).toBeLessThanOrEqual(AERIAL.blurPx);
|
||||
});
|
||||
|
||||
it('in-scatter peaks at the sun azimuth, is periodic, faint, grows with distance, and is off without a sun', () => {
|
||||
it('in-scatter is faint, grows with distance, is off without a sun, and never repeats', () => {
|
||||
const az = 0.3;
|
||||
const s = { ...sky, sunAzimuth: az };
|
||||
expect(scatterAlpha(far, s, az)).toBeGreaterThan(scatterAlpha(far, s, az + 0.5));
|
||||
expect(scatterAlpha(far, s, 0)).toBeCloseTo(scatterAlpha(far, s, 1), 12);
|
||||
expect(scatterAlpha(far, s, az)).toBeLessThanOrEqual(AERIAL.scatter);
|
||||
const lobe = (t: number) => scatterAlpha(far, s, t);
|
||||
expect(lobe(az * 1.9)).toBeGreaterThan(lobe(az * 1.9 + 0.95));
|
||||
for (const period of [1, 1.9, 3.8, 5.7, 19])
|
||||
expect(Math.abs(lobe(0.7 + period) - lobe(0.7))).toBeGreaterThan(1e-4);
|
||||
expect(lobe(az * 1.9)).toBeLessThanOrEqual(AERIAL.scatter);
|
||||
expect(scatterAlpha(near, s, az)).toBe(0);
|
||||
expect(scatterAlpha(LAYERS[1], s, az)).toBeLessThan(scatterAlpha(far, s, az));
|
||||
expect(scatterAlpha(LAYERS[1], s, az * 1.9)).toBeLessThan(scatterAlpha(far, s, az * 1.9));
|
||||
expect(scatterAlpha(far, { ...sky, sunAzimuth: undefined }, az)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -237,8 +218,21 @@ function fakeEl(tag = 'div') {
|
|||
this.children.push(c);
|
||||
return c;
|
||||
},
|
||||
animate: vi.fn(() => {
|
||||
const a = { currentTime: 0, play: vi.fn(), pause: vi.fn(), cancel: vi.fn() };
|
||||
animate: vi.fn((keyframes: any[], options: any) => {
|
||||
const a: any = {
|
||||
currentTime: 0,
|
||||
keyframes,
|
||||
options,
|
||||
onfinish: null,
|
||||
play: vi.fn(),
|
||||
pause: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
/** The animation reaches its end: what the compositor's finish event does. */
|
||||
finish() {
|
||||
a.currentTime = options.duration;
|
||||
a.onfinish?.();
|
||||
},
|
||||
};
|
||||
el.anims.push(a);
|
||||
return a;
|
||||
}),
|
||||
|
|
@ -256,6 +250,10 @@ const DAY: SkyColours = {
|
|||
sunAzimuth: 0.3,
|
||||
};
|
||||
const draws: string[] = [];
|
||||
/** Every ImageData put on any canvas, in order. */
|
||||
const puts: any[] = [];
|
||||
/** For every drawImage: the source canvas's width as it was at the moment of the draw. */
|
||||
const drawnWidths: number[] = [];
|
||||
function ctx(withFilter = false) {
|
||||
const c: any = {
|
||||
globalCompositeOperation: 'source-over',
|
||||
|
|
@ -263,6 +261,16 @@ function ctx(withFilter = false) {
|
|||
fillRect() {
|
||||
draws.push(c.globalCompositeOperation === 'source-atop' ? 'scatter' : 'sky');
|
||||
},
|
||||
save() {},
|
||||
restore() {},
|
||||
rect() {},
|
||||
clip() {},
|
||||
createImageData: (w: number, h: number) => ({
|
||||
data: new Uint8ClampedArray(w * h * 4),
|
||||
width: w,
|
||||
height: h,
|
||||
}),
|
||||
putImageData: (img: any) => puts.push(img),
|
||||
beginPath() {},
|
||||
moveTo() {},
|
||||
lineTo() {},
|
||||
|
|
@ -280,7 +288,10 @@ function ctx(withFilter = false) {
|
|||
if (c.arcing) c.arcing = false;
|
||||
else draws.push('fill');
|
||||
},
|
||||
drawImage: () => draws.push('flatten'),
|
||||
drawImage: (src: any) => {
|
||||
drawnWidths.push(src.width);
|
||||
draws.push('flatten');
|
||||
},
|
||||
set fillStyle(_v: unknown) {},
|
||||
};
|
||||
if (withFilter) c.filter = 'none';
|
||||
|
|
@ -295,6 +306,8 @@ let search: string;
|
|||
beforeEach(() => {
|
||||
rafQueue = [];
|
||||
draws.length = 0;
|
||||
puts.length = 0;
|
||||
drawnWidths.length = 0;
|
||||
search = '';
|
||||
motion = { matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() };
|
||||
doc = Object.assign(new EventTarget(), {
|
||||
|
|
@ -330,6 +343,11 @@ function host() {
|
|||
parent.appendChild(canvas);
|
||||
return { parent, canvas };
|
||||
}
|
||||
/** The rig holds one track per layer, farthest first; each track holds three chunk canvases. */
|
||||
const rigOf = (parent: any) => parent.children.find((c: any) => c.tag === 'div');
|
||||
const tracksOf = (parent: any): any[] => rigOf(parent).children;
|
||||
const animsOf = (parent: any): any[] =>
|
||||
[rigOf(parent), ...tracksOf(parent)].flatMap((e: any) => e.anims);
|
||||
const runFrames = (dt: number, n: number, from = 0) => {
|
||||
let t = from;
|
||||
for (let i = 0; i < n && rafQueue.length; i += 1) {
|
||||
|
|
@ -353,19 +371,25 @@ describe('createRidgeScene', () => {
|
|||
expect(createRidgeScene({ canvas, tier: 'full', sky: DAY })).toBeNull();
|
||||
});
|
||||
|
||||
it('bakes the sky once and paints four layers at integer sizes, farthest first', () => {
|
||||
it('bakes the sky once, and per layer only the first chunk before the first frame, at integer sizes, farthest first', () => {
|
||||
const { parent, canvas } = host();
|
||||
const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!;
|
||||
const baked: any[] = [];
|
||||
const s = createRidgeScene({ canvas, tier: 'full', sky: DAY, onBake: (b) => baked.push(b) })!;
|
||||
expect(s).not.toBeNull();
|
||||
const layers = parent.children.slice(1);
|
||||
expect(layers).toHaveLength(LAYERS.length);
|
||||
layers.forEach((l: any) => {
|
||||
expect(Number.isInteger(l.width) && Number.isInteger(l.height)).toBe(true);
|
||||
expect(l.width).toBe(canvas.width * 2); // DPR 2: layer is two periods wide
|
||||
expect(l.animate).toHaveBeenCalledTimes(1);
|
||||
const tracks = tracksOf(parent);
|
||||
expect(tracks).toHaveLength(LAYERS.length);
|
||||
tracks.forEach((t: any) => {
|
||||
expect(t.children).toHaveLength(3);
|
||||
expect(t.animate).toHaveBeenCalledTimes(1);
|
||||
t.children.forEach((c: any) => {
|
||||
expect(Number.isInteger(c.width) && Number.isInteger(c.height)).toBe(true);
|
||||
expect(c.width).toBe(layerSize(400, 800, 2).chunk);
|
||||
});
|
||||
});
|
||||
expect(baked.map((b) => [b.layer, b.chunk, b.first])).toEqual(
|
||||
LAYERS.map((l) => [l.index, 0, true])
|
||||
);
|
||||
expect(draws.filter((d) => d === 'sky')).toHaveLength(1);
|
||||
expect(draws.filter((d) => d === 'fill')).toHaveLength(LAYERS.length);
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
|
|
@ -403,8 +427,9 @@ describe('createRidgeScene', () => {
|
|||
motion.matches = true;
|
||||
const { canvas } = host();
|
||||
const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!;
|
||||
const anims = canvas.anims.concat(...canvas.parentElement.children.map((c: any) => c.anims));
|
||||
expect(anims.length).toBe(LAYERS.length);
|
||||
const anims = animsOf(canvas.parentElement);
|
||||
expect(anims.length).toBe(LAYERS.length + 1); // one drift per layer and the sway
|
||||
expect(anims.every((a: any) => a.pause.mock.calls.length > 0)).toBe(true);
|
||||
anims.forEach((a: any) => expect(a.play).not.toHaveBeenCalled());
|
||||
expect(rafQueue).toHaveLength(0);
|
||||
s.dispose();
|
||||
|
|
@ -413,7 +438,7 @@ describe('createRidgeScene', () => {
|
|||
it('pauses the drift when the tab is hidden and resumes when it is visible', () => {
|
||||
const { parent, canvas } = host();
|
||||
const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!;
|
||||
const anims = parent.children.slice(1).flatMap((l: any) => l.anims);
|
||||
const anims = animsOf(parent);
|
||||
anims.forEach((a: any) => a.pause.mockClear());
|
||||
doc.visibilityState = 'hidden';
|
||||
doc.dispatchEvent(new Event('visibilitychange'));
|
||||
|
|
@ -442,7 +467,7 @@ describe('createRidgeScene', () => {
|
|||
const { parent, canvas } = host();
|
||||
const before = parent.children.length;
|
||||
const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!;
|
||||
expect(parent.children.length).toBe(before + LAYERS.length);
|
||||
expect(parent.children.length).toBe(before + 1); // the rig
|
||||
runFrames(16.7, 300);
|
||||
expect(rafQueue.length).toBe(0);
|
||||
s.dispose();
|
||||
|
|
@ -466,7 +491,7 @@ describe('createRidgeScene', () => {
|
|||
(window as any).innerHeight = 800;
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
expect(parent.children.slice(1)).toHaveLength(LAYERS.length);
|
||||
expect(tracksOf(parent)).toHaveLength(LAYERS.length);
|
||||
expect(draws.filter((d) => d === 'sky')).toHaveLength(1);
|
||||
s!.dispose();
|
||||
expect(parent.children).toEqual([canvas]);
|
||||
|
|
@ -475,7 +500,7 @@ describe('createRidgeScene', () => {
|
|||
it('dispose cancels the loop, the animations and the layers', () => {
|
||||
const { parent, canvas } = host();
|
||||
const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!;
|
||||
const anims = parent.children.slice(1).flatMap((l: any) => l.anims);
|
||||
const anims = animsOf(parent);
|
||||
s.dispose();
|
||||
anims.forEach((a: any) => expect(a.cancel).toHaveBeenCalled());
|
||||
expect(parent.children).toEqual([canvas]);
|
||||
|
|
@ -501,16 +526,6 @@ describe('the door', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('fbm', () => {
|
||||
it('is normalised into [0, 1]', () => {
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
const v = fbmPeriodic(i / 100, 0, 3, FBM);
|
||||
expect(v).toBeGreaterThanOrEqual(0);
|
||||
expect(v).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('aerial perspective in the scene', () => {
|
||||
it('is baked once: one scatter fill per hazy layer, none per frame, the nearest layer has none', () => {
|
||||
const { canvas } = host();
|
||||
|
|
@ -533,7 +548,7 @@ describe('aerial perspective in the scene', () => {
|
|||
s.dispose();
|
||||
});
|
||||
|
||||
it('softens with the canvas filter where it exists, blur scaled by DPR, tiled so the wrap does not fade', () => {
|
||||
it('softens with the canvas filter where it exists, blur scaled by DPR, from pixels baked past the chunk edges', () => {
|
||||
const { parent, canvas } = host();
|
||||
const filters: string[] = [];
|
||||
const orig = (document as any).createElement;
|
||||
|
|
@ -557,7 +572,7 @@ describe('aerial perspective in the scene', () => {
|
|||
const blurs = filters.filter((f) => f !== 'none');
|
||||
expect(blurs).toHaveLength(LAYERS.length - 1); // nearest is sharp
|
||||
expect(blurs[0]).toBe(`blur(${edgeBlur(LAYERS[0]) * 2}px)`); // DPR 2
|
||||
expect(parent.children).toHaveLength(1 + LAYERS.length);
|
||||
expect(parent.children).toHaveLength(2); // sky canvas and the rig
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
|
|
@ -591,9 +606,8 @@ describe('the starfield in the scene', () => {
|
|||
const s = createRidgeScene({ canvas, tier: 'full', sky: night })!;
|
||||
expect(count('star')).toBe(N);
|
||||
expect(count('sky')).toBe(1);
|
||||
expect(parent.children.indexOf(parent.children[1])).toBe(1);
|
||||
expect(parent.children[1].anims).toHaveLength(0); // the overlay: above the sky, under the ridges
|
||||
expect(parent.children).toHaveLength(2 + LAYERS.length);
|
||||
expect(parent.children).toHaveLength(3);
|
||||
const before = baked();
|
||||
runFrames(16.7, 300); // 5 s at 60 fps; the overlay repaints at 20 fps
|
||||
expect(baked()).toBe(before);
|
||||
|
|
@ -609,7 +623,7 @@ describe('the starfield in the scene', () => {
|
|||
const { parent, canvas } = host();
|
||||
const s = createRidgeScene({ canvas, tier: 'full', sky: DAY })!;
|
||||
expect(count('star')).toBe(0);
|
||||
expect(parent.children).toHaveLength(1 + LAYERS.length);
|
||||
expect(parent.children).toHaveLength(2);
|
||||
runFrames(16.7, 60);
|
||||
expect(count('clear')).toBe(0);
|
||||
s.dispose();
|
||||
|
|
@ -688,3 +702,113 @@ describe('the starfield in the scene', () => {
|
|||
expect(rafQueue).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('endless drift: chunks baked on demand and recycled', () => {
|
||||
const size = layerSize(400, 800, 2); // the fake host: 400 x 800 CSS px at DPR 2
|
||||
const until = async (cond: () => boolean) => {
|
||||
const t0 = Date.now();
|
||||
while (!cond()) {
|
||||
if (Date.now() - t0 > 10000) throw new Error('timed out waiting for the background bakes');
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
}
|
||||
};
|
||||
/** The pixels chunk `m` of layer `li` must hold: the pure bake of its world columns, no blur. */
|
||||
const expected = (li: number, m: number) => {
|
||||
const p: BakeParams = {
|
||||
spec: LAYERS[li],
|
||||
sky: DAY,
|
||||
period: size.period,
|
||||
scale: 2,
|
||||
viewH: 1600,
|
||||
rows: size.height,
|
||||
bandH: size.height,
|
||||
px0: m * size.chunk,
|
||||
cols: size.chunk,
|
||||
};
|
||||
const out = new Uint8ClampedArray(p.cols * p.rows * 4);
|
||||
bakeSlice(out, p, layerPaint(p.spec, DAY), 0, p.cols);
|
||||
return Buffer.from(out.buffer);
|
||||
};
|
||||
/** Each distinct ImageData put, in order of first appearance: one per baked chunk. */
|
||||
const baked = () => [...new Set(puts)].map((img) => Buffer.from(img.data.buffer));
|
||||
const start = () => {
|
||||
const { parent, canvas } = host();
|
||||
const records: any[] = [];
|
||||
const s = createRidgeScene({ canvas, tier: 'full', sky: DAY, onBake: (b) => records.push(b) })!;
|
||||
return { parent, s, records };
|
||||
};
|
||||
|
||||
it('bakes every chunk from its own world columns: no offset, the next chunk continues the last', async () => {
|
||||
const { s, records } = start();
|
||||
await until(() => records.length === 3 * LAYERS.length);
|
||||
// First view: chunk 0 of each layer; then, per layer in queue order, chunks 1 and 2.
|
||||
const order = [
|
||||
...LAYERS.map((l) => [l.index, 0]),
|
||||
...LAYERS.flatMap((l) => [
|
||||
[l.index, 1],
|
||||
[l.index, 2],
|
||||
]),
|
||||
];
|
||||
expect(records.map((r) => [r.layer, r.chunk])).toEqual(order);
|
||||
const imgs = baked();
|
||||
expect(imgs).toHaveLength(order.length);
|
||||
order.forEach(([li, m], i) => expect(imgs[i].equals(expected(li, m))).toBe(true));
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
it('when the drift animation ends, the chunks move one slot left and the track restarts', async () => {
|
||||
const { parent, s, records } = start();
|
||||
await until(() => records.length === 3 * LAYERS.length);
|
||||
const track = tracksOf(parent)[0];
|
||||
const [e0, e1, e2] = track.children;
|
||||
const first = track.anims[0];
|
||||
// One chunk of drift, not one viewport: the track slides by the width of a chunk.
|
||||
expect(first.keyframes[1].transform).toContain(`${-size.chunk / 2}px`);
|
||||
expect(track.animate).toHaveBeenCalledTimes(1);
|
||||
first.finish();
|
||||
expect(first.cancel).toHaveBeenCalled();
|
||||
expect(track.animate).toHaveBeenCalledTimes(2);
|
||||
const w = size.chunk / 2;
|
||||
expect([e1.style.left, e2.style.left, e0.style.left]).toEqual(['0px', `${w}px`, `${2 * w}px`]);
|
||||
expect(track.anims[1].keyframes[1].transform).toContain(`${-w}px`);
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
it('the canvas that left is baked with the next stretch of world, and each rotation after it, without end', async () => {
|
||||
const { parent, s, records } = start();
|
||||
await until(() => records.length === 3 * LAYERS.length);
|
||||
const track = tracksOf(parent)[0];
|
||||
const done = () => records.filter((r) => r.layer === 0).map((r) => r.chunk);
|
||||
for (let m = 3; m <= 5; m += 1) {
|
||||
track.anims[track.anims.length - 1].finish();
|
||||
await until(() => done().includes(m));
|
||||
}
|
||||
expect(done()).toEqual([0, 1, 2, 3, 4, 5]);
|
||||
// The pixels of chunks 3 to 5 are the world's, not a repeat of an earlier one.
|
||||
const imgs = baked();
|
||||
const layer0 = imgs.filter((_, i) => records[i].layer === 0);
|
||||
[3, 4, 5].forEach((m, k) => expect(layer0[3 + k].equals(expected(0, m))).toBe(true));
|
||||
expect(layer0[3].equals(layer0[0])).toBe(false);
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
it('with the blur, each chunk is blurred from a scratch canvas of its own width: seams stay opaque and exact', async () => {
|
||||
const orig = (document as any).createElement;
|
||||
(document as any).createElement = (tag: string) => {
|
||||
const el = orig(tag);
|
||||
el.getContext = vi.fn(() => ctx(true));
|
||||
return el;
|
||||
};
|
||||
const { s, records } = start();
|
||||
await until(() => records.length === 3 * LAYERS.length);
|
||||
const want = new Set(
|
||||
LAYERS.filter((l) => edgeBlur(l) > 0).map(
|
||||
(l) => size.chunk + 2 * Math.ceil(3 * edgeBlur(l) * 2)
|
||||
)
|
||||
);
|
||||
expect(want.size).toBeGreaterThan(1); // the layers' pads differ, which is what a shared canvas gets wrong
|
||||
// Every blur reads a source exactly as wide as that chunk's padded bake, at the moment of the draw.
|
||||
expect(new Set(drawnWidths)).toEqual(want);
|
||||
s.dispose();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,18 +1,20 @@
|
|||
/* eslint-disable no-param-reassign -- the bake writes into the canvases and layer records it is handed */
|
||||
import type { Scene, SceneOptions } from '../Scene';
|
||||
import { overlayRequested } from '../debugOverlays';
|
||||
import { FrameSampler, FrameStats } from './probe';
|
||||
import { skyColours, SkyColours, Srgb } from './skyColour';
|
||||
import { activeAt, drawStars, drawTwinkle as paintTwinkle, makeStars, Star } from './stars';
|
||||
import {
|
||||
LAYERS,
|
||||
LayerSpec,
|
||||
edgeBlur,
|
||||
hazeColour,
|
||||
layerColour,
|
||||
layerSize,
|
||||
ridgeY,
|
||||
scatterAlpha,
|
||||
} from './ridgeWorld';
|
||||
import { bakeSlice, BakeParams, layerPaint } from './ridgeBake';
|
||||
import { LAYERS, LayerSpec, edgeBlur, layerSize, scatterAlpha, transmittance } from './ridgeWorld';
|
||||
|
||||
/** One chunk's bake: `ms` is the summed task time, `maxTaskMs` the longest single task; `first` marks the bake of the first view. */
|
||||
export interface BakeRecord {
|
||||
layer: number;
|
||||
chunk: number;
|
||||
ms: number;
|
||||
maxTaskMs: number;
|
||||
first: boolean;
|
||||
}
|
||||
|
||||
export type RidgeOptions = Pick<SceneOptions, 'canvas' | 'tier'> & {
|
||||
/** Show the rAF median/p95 readout. Default: the page URL has `?probe=1`, where `debug` allows it. */
|
||||
|
|
@ -23,15 +25,11 @@ export type RidgeOptions = Pick<SceneOptions, 'canvas' | 'tier'> & {
|
|||
onProbe?: (s: FrameStats & { still: boolean }) => void;
|
||||
/** The sky's colours; default `skyColours()`, the one function to replace with Void's sun.js. */
|
||||
sky?: SkyColours;
|
||||
/** Called after each chunk is baked (a chunk is one layer's stretch of world-x): the cost record. */
|
||||
onBake?: (b: BakeRecord) => void;
|
||||
};
|
||||
|
||||
const css = (c: Srgb): string => `rgb(${c[0]},${c[1]},${c[2]})`;
|
||||
const mix = (a: Srgb, b: Srgb, t: number): Srgb => [
|
||||
Math.round(a[0] + (b[0] - a[0]) * t),
|
||||
Math.round(a[1] + (b[1] - a[1]) * t),
|
||||
Math.round(a[2] + (b[2] - a[2]) * t),
|
||||
];
|
||||
|
||||
function context2d(c: HTMLCanvasElement): CanvasRenderingContext2D | null {
|
||||
return c.getContext('2d');
|
||||
}
|
||||
|
|
@ -49,95 +47,202 @@ function paintSky(canvas: HTMLCanvasElement, sky: SkyColours): boolean {
|
|||
return true;
|
||||
}
|
||||
|
||||
const clock = (): number => (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
||||
|
||||
type Size = ReturnType<typeof layerSize>;
|
||||
|
||||
const SCATTER_STOPS = 16;
|
||||
|
||||
/** Warm in-scatter over the silhouette only (source-atop), as a horizontal gradient that repeats each period. */
|
||||
/** Warm in-scatter over the silhouette only (source-atop): a horizontal gradient over the chunk's world-x. */
|
||||
function scatter(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
spec: LayerSpec,
|
||||
sky: SkyColours,
|
||||
size: { period: number; width: number; height: number }
|
||||
widthPx: number,
|
||||
heightPx: number,
|
||||
t0: number,
|
||||
tPerPx: number,
|
||||
bandX = 0,
|
||||
bandW = widthPx
|
||||
) {
|
||||
if (sky.sunAzimuth === undefined || scatterAlpha(spec, sky, sky.sunAzimuth) <= 0) return;
|
||||
const g = ctx.createLinearGradient(0, 0, size.width, 0);
|
||||
if (sky.sunAzimuth === undefined || transmittance(spec) >= 1) return; // the nearest layer takes none
|
||||
const g = ctx.createLinearGradient(0, 0, widthPx, 0);
|
||||
const [r, gr, b] = sky.horizon;
|
||||
for (let k = 0; k <= 2 * SCATTER_STOPS; k += 1) {
|
||||
const t = (k % SCATTER_STOPS) / SCATTER_STOPS;
|
||||
g.addColorStop(k / (2 * SCATTER_STOPS), `rgba(${r},${gr},${b},${scatterAlpha(spec, sky, t)})`);
|
||||
for (let k = 0; k <= SCATTER_STOPS; k += 1) {
|
||||
const a = scatterAlpha(spec, sky, t0 + (tPerPx * widthPx * k) / SCATTER_STOPS);
|
||||
g.addColorStop(k / SCATTER_STOPS, `rgba(${r},${gr},${b},${a})`);
|
||||
}
|
||||
ctx.globalCompositeOperation = 'source-atop';
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(0, 0, size.width, size.height);
|
||||
ctx.fillRect(bandX, 0, bandW, heightPx);
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
}
|
||||
|
||||
/**
|
||||
* Soften the silhouette's edge by `px` once, where the browser's 2D canvas supports `filter` (not
|
||||
* Safari, which keeps the sharp edge). One period is tiled past both ends and the bottom row extended below,
|
||||
* then blurred in a single draw, so the blur sees the wrap and the ground instead of fading at borders.
|
||||
*/
|
||||
function soften(src: HTMLCanvasElement, px: number, period: number): HTMLCanvasElement {
|
||||
const probe = context2d(src);
|
||||
if (!probe || px <= 0 || !('filter' in probe)) return src;
|
||||
const pad = Math.ceil(3 * px);
|
||||
// The unfiltered tiling: blurring tile by tile would fade each tile's own border.
|
||||
const tiled = document.createElement('canvas');
|
||||
tiled.width = src.width + 2 * period;
|
||||
tiled.height = src.height + pad;
|
||||
const tctx = context2d(tiled);
|
||||
const out = document.createElement('canvas');
|
||||
out.width = src.width;
|
||||
out.height = src.height;
|
||||
const ctx = context2d(out);
|
||||
if (!tctx || !ctx) return src;
|
||||
for (let k = 0; k < 4; k += 1) {
|
||||
tctx.drawImage(src, 0, 0, period, src.height, k * period, 0, period, src.height);
|
||||
tctx.drawImage(src, 0, src.height - 1, period, 1, k * period, src.height, period, pad);
|
||||
}
|
||||
ctx.filter = `blur(${px}px)`;
|
||||
ctx.drawImage(tiled, -period, 0);
|
||||
ctx.filter = 'none';
|
||||
return out;
|
||||
/** A chunk's bake in slices: `step(budgetMs)` returns true once the chunk is on its canvas. */
|
||||
interface Job {
|
||||
step(budgetMs: number): boolean;
|
||||
}
|
||||
|
||||
/** One ridge layer, painted once: a filled silhouette two periods wide on a plain 2D canvas. */
|
||||
function paintLayer(
|
||||
/**
|
||||
* One layer's stretch of world-x `[m * chunk, (m + 1) * chunk)` painted onto `el`, in three phases of
|
||||
* column bands, each band sized to the task budget by measuring the last one: bake the pixels
|
||||
* (`bakeSlice`), put them on a canvas with the in-scatter, and (where the 2D `filter` exists) blur them
|
||||
* into `el`. The blur needs neighbours, so the pixels are baked `pad` columns past both ends and
|
||||
* the bottom into a scratch canvas this job owns, and each blurred band reads `pad` columns either side of
|
||||
* itself: seams show no fade. The scratch is made when the job first runs and dropped when it ends.
|
||||
*/
|
||||
function chunkJob(
|
||||
el: HTMLCanvasElement,
|
||||
makeCanvas: () => HTMLCanvasElement,
|
||||
spec: LayerSpec,
|
||||
sky: SkyColours,
|
||||
size: { period: number; width: number; height: number },
|
||||
size: Size,
|
||||
viewH: number,
|
||||
scale: number
|
||||
): HTMLCanvasElement | null {
|
||||
const el = document.createElement('canvas');
|
||||
el.width = size.width;
|
||||
el.height = size.height;
|
||||
scale: number,
|
||||
m: number,
|
||||
record: (ms: number, maxTaskMs: number) => void
|
||||
): Job | null {
|
||||
const ctx = context2d(el);
|
||||
if (!ctx) return null;
|
||||
const top: number[] = [];
|
||||
for (let x = 0; x < size.period; x += 1)
|
||||
top.push(ridgeY(spec, x, size.period, size.height, viewH));
|
||||
const c = layerColour(spec, sky);
|
||||
const g = ctx.createLinearGradient(0, 0, 0, size.height);
|
||||
g.addColorStop(0, css(c));
|
||||
g.addColorStop(1, css(mix(c, hazeColour(sky), 0.5 * (1 - spec.depth))));
|
||||
ctx.fillStyle = g;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, size.height);
|
||||
for (let x = 0; x <= size.width; x += 1) ctx.lineTo(x, top[x % size.period]);
|
||||
ctx.lineTo(size.width, size.height);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
scatter(ctx, spec, sky, size);
|
||||
return soften(el, edgeBlur(spec) * scale, size.period);
|
||||
const blur = edgeBlur(spec) * scale;
|
||||
const pad = blur > 0 && 'filter' in ctx ? Math.ceil(3 * blur) : 0;
|
||||
const cols = size.chunk + 2 * pad;
|
||||
const rows = size.height + pad;
|
||||
const params: BakeParams = {
|
||||
spec,
|
||||
sky,
|
||||
period: size.period,
|
||||
scale,
|
||||
viewH,
|
||||
rows,
|
||||
bandH: size.height,
|
||||
px0: m * size.chunk - pad,
|
||||
cols,
|
||||
};
|
||||
const paint = layerPaint(spec, sky);
|
||||
const t0World = params.px0 / size.period;
|
||||
let scratch: HTMLCanvasElement | undefined;
|
||||
let sctx: CanvasRenderingContext2D | null = ctx;
|
||||
let img: ImageData | undefined;
|
||||
/** 0 bake, 1 put and in-scatter, 2 blur into el; a chunk with no blur ends after 1. */
|
||||
let phase = 0;
|
||||
let pos = 0;
|
||||
let sliceCols = 32;
|
||||
let total = 0;
|
||||
let longest = 0;
|
||||
const last = pad > 0 ? 2 : 1;
|
||||
const start = (): boolean => {
|
||||
if (pad > 0) {
|
||||
scratch = makeCanvas();
|
||||
scratch.width = cols;
|
||||
scratch.height = rows;
|
||||
sctx = context2d(scratch);
|
||||
} else {
|
||||
el.width = size.chunk;
|
||||
el.height = size.height;
|
||||
}
|
||||
if (!sctx) return false;
|
||||
img = sctx.createImageData(cols, rows);
|
||||
return true;
|
||||
};
|
||||
const work = (x: number, n: number) => {
|
||||
if (!img || !sctx) return;
|
||||
if (phase === 0) bakeSlice(img.data, params, paint, x, x + n);
|
||||
else if (phase === 1) {
|
||||
sctx.putImageData(img, 0, 0, x, 0, n, rows);
|
||||
scatter(sctx, spec, sky, cols, rows, t0World, 1 / size.period, x, n);
|
||||
} else {
|
||||
// One draw: a blurred draw cut into bands costs more in the margins than it saves in task length.
|
||||
ctx.drawImage(scratch as HTMLCanvasElement, -pad, 0);
|
||||
}
|
||||
};
|
||||
return {
|
||||
step(budgetMs) {
|
||||
const t0 = clock();
|
||||
// Allocating the canvases and the pixel buffer is a task of its own.
|
||||
if (!img) {
|
||||
if (!start()) return true;
|
||||
if (Number.isFinite(budgetMs)) {
|
||||
total += clock() - t0;
|
||||
longest = Math.max(longest, clock() - t0);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
while (phase <= last && clock() - t0 < budgetMs) {
|
||||
const end = phase === 2 ? size.chunk : cols;
|
||||
if (pos === 0 && phase === 2) {
|
||||
el.width = size.chunk;
|
||||
el.height = size.height;
|
||||
ctx.filter = `blur(${blur}px)`;
|
||||
}
|
||||
const n = phase === 2 ? end : Math.min(end - pos, sliceCols);
|
||||
const s0 = clock();
|
||||
work(pos, n);
|
||||
pos += n;
|
||||
const dt = Math.max(clock() - s0, 0.02);
|
||||
sliceCols = Number.isFinite(budgetMs)
|
||||
? Math.max(8, Math.min(512, Math.round((n * budgetMs * 0.4) / dt)))
|
||||
: cols;
|
||||
if (pos >= end) {
|
||||
if (phase === 2) ctx.filter = 'none';
|
||||
phase += 1;
|
||||
pos = 0;
|
||||
if (phase === 1 || phase === 2) sliceCols = Number.isFinite(budgetMs) ? 64 : cols;
|
||||
}
|
||||
}
|
||||
const dt = clock() - t0;
|
||||
total += dt;
|
||||
longest = Math.max(longest, dt);
|
||||
const done = phase > last;
|
||||
if (done) {
|
||||
img = undefined;
|
||||
if (scratch) {
|
||||
scratch.width = 0;
|
||||
scratch.height = 0;
|
||||
}
|
||||
scratch = undefined;
|
||||
record(total, longest);
|
||||
}
|
||||
return done;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface Chunk {
|
||||
el: HTMLCanvasElement;
|
||||
/** World chunk index it holds or is being baked to hold. */
|
||||
id: number;
|
||||
job?: Job;
|
||||
ready: boolean;
|
||||
}
|
||||
|
||||
/** One layer: a track that drifts by one chunk per animation, holding three chunk canvases. */
|
||||
interface LayerState {
|
||||
spec: LayerSpec;
|
||||
track: HTMLElement;
|
||||
/** Slot 0 and 1 are what the window can see; slot 2 is the one ahead, baked in the background. */
|
||||
slots: Chunk[];
|
||||
anim?: Animation;
|
||||
}
|
||||
|
||||
interface Built {
|
||||
layers: HTMLCanvasElement[];
|
||||
anims: Animation[];
|
||||
rig: HTMLElement;
|
||||
layers: LayerState[];
|
||||
sway?: Animation;
|
||||
size: Size;
|
||||
/** CSS px to canvas px, and the viewport's height in canvas px, as baked. */
|
||||
scale: number;
|
||||
viewH: number;
|
||||
/** Makes a canvas, bound to the document the scene was built in. */
|
||||
makeCanvas: () => HTMLCanvasElement;
|
||||
/** The twinkling stars' own canvas, between the sky and the ridges; absent in a day sky and in 'still'. */
|
||||
twinkle?: HTMLCanvasElement;
|
||||
}
|
||||
|
||||
/** Yaw and pitch: the camera's slow sway, a loop of this many seconds over the whole rig. */
|
||||
const SWAY_SECONDS = 150;
|
||||
const SLICE_BUDGET_MS = 6;
|
||||
|
||||
/** The twinkle overlay redraws at most this often: the swings are 2 to 7 s, no need for 60 fps. */
|
||||
const TWINKLE_FRAME_MS = 50;
|
||||
|
||||
|
|
@ -162,6 +267,9 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null {
|
|||
let twRaf = 0;
|
||||
let twLast = 0;
|
||||
let still = tier === 'still';
|
||||
let jobTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let queue: Chunk[] = [];
|
||||
let generation = 0;
|
||||
let disposed = false;
|
||||
let raf = 0;
|
||||
let readout: HTMLElement | undefined;
|
||||
|
|
@ -175,8 +283,13 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null {
|
|||
};
|
||||
|
||||
const teardownLayers = () => {
|
||||
built?.anims.forEach((a) => a.cancel());
|
||||
built?.layers.forEach((l) => l.remove());
|
||||
generation += 1;
|
||||
clearTimeout(jobTimer);
|
||||
jobTimer = undefined;
|
||||
queue = [];
|
||||
built?.sway?.cancel();
|
||||
built?.layers.forEach((l) => l.anim?.cancel());
|
||||
built?.rig.remove();
|
||||
built?.twinkle?.remove();
|
||||
cancelAnimationFrame(twRaf);
|
||||
twRaf = 0;
|
||||
|
|
@ -190,6 +303,109 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null {
|
|||
return size.period < 1 || size.height < 1 || Math.round(h * scale) < 1;
|
||||
};
|
||||
|
||||
/**
|
||||
* Start baking chunk `c` of layer `l`. `first` runs it to the end in this task (the first view);
|
||||
* otherwise it joins the queue, which `pump` works through in short tasks. False when the canvas has no 2D context.
|
||||
*/
|
||||
const bake = (l: LayerState, c: Chunk, first: boolean): boolean => {
|
||||
if (!built) return false;
|
||||
const { scale, viewH, makeCanvas } = built;
|
||||
const job = chunkJob(
|
||||
c.el,
|
||||
makeCanvas,
|
||||
l.spec,
|
||||
sky,
|
||||
built.size,
|
||||
viewH,
|
||||
scale,
|
||||
c.id,
|
||||
(ms, maxTaskMs) => {
|
||||
c.ready = true;
|
||||
c.job = undefined;
|
||||
opts.onBake?.({ layer: l.spec.index, chunk: c.id, ms, maxTaskMs, first });
|
||||
}
|
||||
);
|
||||
if (!job) return false;
|
||||
c.ready = false;
|
||||
c.job = job;
|
||||
if (first) job.step(Infinity);
|
||||
else queue.push(c);
|
||||
return true;
|
||||
};
|
||||
|
||||
const pump = () => {
|
||||
if (jobTimer !== undefined || !queue.length) return;
|
||||
const gen = generation;
|
||||
jobTimer = setTimeout(() => {
|
||||
jobTimer = undefined;
|
||||
if (gen !== generation) return;
|
||||
const c = queue[0];
|
||||
if (!c.job || c.job.step(SLICE_BUDGET_MS)) queue.shift();
|
||||
pump();
|
||||
}, 0);
|
||||
};
|
||||
|
||||
/** Bake a still-queued chunk to the end now: only when the drift has caught up with the background work. */
|
||||
const flush = (c: Chunk) => {
|
||||
while (c.job && !c.job.step(Infinity));
|
||||
queue = queue.filter((q) => q !== c);
|
||||
};
|
||||
|
||||
const chunkSeconds = (spec: LayerSpec, size: Size) => (spec.seconds * size.chunk) / size.period;
|
||||
|
||||
/** One chunk of drift as one animation, holding its end until `rotate` hands the track the next chunk. */
|
||||
const startDrift = (l: LayerState, onEnd: (l: LayerState) => void) => {
|
||||
if (!built) return;
|
||||
const { size, scale } = built;
|
||||
l.anim = l.track.animate?.(
|
||||
[
|
||||
{ transform: 'translate3d(0,0,0)' },
|
||||
{ transform: `translate3d(${-size.chunk / scale}px,0,0)` },
|
||||
],
|
||||
{ duration: chunkSeconds(l.spec, size) * 1000, easing: 'linear', fill: 'forwards' }
|
||||
);
|
||||
if (l.anim) l.anim.onfinish = () => onEnd(l);
|
||||
};
|
||||
|
||||
/**
|
||||
* The window has drifted one chunk. Slot 1 is now slot 0 and slot 2 slot 1, at the same screen place
|
||||
* (the track restarts at 0 and the chunks move one slot left in the same task, so nothing jumps); the
|
||||
* canvas that left becomes slot 2 and is baked with the world one chunk further on, off the critical path.
|
||||
*/
|
||||
const rotate = (l: LayerState) => {
|
||||
if (!built || !built.layers.includes(l)) return;
|
||||
const [s0, s1, s2] = l.slots;
|
||||
flush(s1);
|
||||
flush(s2);
|
||||
l.slots = [s1, s2, s0];
|
||||
s0.id = s2.id + 1;
|
||||
const { size, scale } = built;
|
||||
l.slots.forEach((c, i) => {
|
||||
c.el.style.left = `${(i * size.chunk) / scale}px`;
|
||||
});
|
||||
l.anim?.cancel();
|
||||
startDrift(l, rotate);
|
||||
bake(l, s0, false);
|
||||
pump();
|
||||
syncPlayState();
|
||||
};
|
||||
|
||||
/** The camera's slow yaw and pitch: the rig eases sideways (up to 5% of the width) and down (up to 3% of the height) and back, in a loop. */
|
||||
const startSway = (rig: HTMLElement, w: number, h: number) => {
|
||||
const x = 0.05 * w;
|
||||
const y = 0.03 * h;
|
||||
const at = (fx: number, fy: number) => `translate3d(${-x * fx}px,${y * fy}px,0)`;
|
||||
return rig.animate?.(
|
||||
[
|
||||
{ transform: at(0, 0), offset: 0 },
|
||||
{ transform: at(0.55, 1), offset: 0.35 },
|
||||
{ transform: at(1, 0.3), offset: 0.7 },
|
||||
{ transform: at(0, 0), offset: 1 },
|
||||
],
|
||||
{ duration: SWAY_SECONDS * 1000, iterations: Infinity, easing: 'ease-in-out' }
|
||||
);
|
||||
};
|
||||
|
||||
/** Bake the sky, then the layers. Returns false when a 2D context is missing or there is no area yet. */
|
||||
const build = (): boolean => {
|
||||
teardownLayers();
|
||||
|
|
@ -200,7 +416,6 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null {
|
|||
canvas.width = size.period;
|
||||
canvas.height = Math.round(h * scale);
|
||||
if (!paintSky(canvas, sky)) return false;
|
||||
const layers: HTMLCanvasElement[] = [];
|
||||
// Every star is baked into the sky; the twinkling ones get their own canvas, which a per-frame
|
||||
// pass clears and paints over. 'still' has no overlay.
|
||||
const stars = makeStars(sky, w, h);
|
||||
|
|
@ -223,39 +438,73 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null {
|
|||
parent.appendChild(twinkle);
|
||||
}
|
||||
field = stars;
|
||||
for (const spec of LAYERS) {
|
||||
const el = paintLayer(spec, sky, size, canvas.height, scale);
|
||||
if (!el) {
|
||||
layers.forEach((l) => l.remove());
|
||||
twinkle?.remove();
|
||||
return false;
|
||||
}
|
||||
el.setAttribute('aria-hidden', 'true');
|
||||
Object.assign(el.style, {
|
||||
// The rig carries the camera's sway; each layer's track carries its drift; chunks sit on the track.
|
||||
const rig = document.createElement('div');
|
||||
rig.setAttribute('aria-hidden', 'true');
|
||||
Object.assign(rig.style, {
|
||||
position: 'absolute',
|
||||
left: '0',
|
||||
bottom: '0',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
willChange: 'transform',
|
||||
});
|
||||
const layers: LayerState[] = LAYERS.map((spec) => {
|
||||
const track = document.createElement('div');
|
||||
Object.assign(track.style, {
|
||||
position: 'absolute',
|
||||
left: '0',
|
||||
bottom: '0',
|
||||
width: `${size.width / scale}px`,
|
||||
width: `${size.period / scale}px`,
|
||||
height: `${size.height / scale}px`,
|
||||
pointerEvents: 'none',
|
||||
willChange: 'transform',
|
||||
});
|
||||
layers.push(el);
|
||||
const slots: Chunk[] = [0, 1, 2].map((i) => {
|
||||
const el = document.createElement('canvas');
|
||||
el.width = size.chunk;
|
||||
el.height = size.height;
|
||||
Object.assign(el.style, {
|
||||
position: 'absolute',
|
||||
bottom: '0',
|
||||
width: `${size.chunk / scale}px`,
|
||||
height: `${size.height / scale}px`,
|
||||
});
|
||||
return { el, id: i, ready: false };
|
||||
});
|
||||
return { spec, track, slots };
|
||||
});
|
||||
const doc = document;
|
||||
built = {
|
||||
rig,
|
||||
layers,
|
||||
size,
|
||||
scale,
|
||||
viewH: canvas.height,
|
||||
makeCanvas: () => doc.createElement('canvas'),
|
||||
twinkle,
|
||||
};
|
||||
// First view: only slot 0 of each layer, in this task. The window sees nothing else for the first
|
||||
// fifth of a chunk's drift; slots 1 and 2 are baked in short tasks behind it.
|
||||
if (!layers.every((l) => bake(l, l.slots[0], true))) {
|
||||
teardownLayers();
|
||||
return false;
|
||||
}
|
||||
layers.forEach((l) => {
|
||||
l.slots.forEach((c, i) => {
|
||||
c.el.setAttribute('aria-hidden', 'true');
|
||||
c.el.style.left = `${(i * size.chunk) / scale}px`;
|
||||
l.track.appendChild(c.el);
|
||||
});
|
||||
rig.appendChild(l.track);
|
||||
bake(l, l.slots[1], false);
|
||||
bake(l, l.slots[2], false);
|
||||
});
|
||||
// DOM order is paint order: farthest first, so the nearest ridge ends up on top.
|
||||
layers.forEach((l) => parent.appendChild(l));
|
||||
const anims = layers
|
||||
.map((el, i) =>
|
||||
el.animate?.(
|
||||
[
|
||||
{ transform: 'translate3d(0,0,0)' },
|
||||
{ transform: `translate3d(${-size.period / scale}px,0,0)` },
|
||||
],
|
||||
{ duration: LAYERS[i].seconds * 1000, iterations: Infinity, easing: 'linear' }
|
||||
)
|
||||
)
|
||||
.filter((a): a is Animation => !!a);
|
||||
built = { layers, anims, twinkle };
|
||||
parent.appendChild(rig);
|
||||
layers.forEach((l) => startDrift(l, rotate));
|
||||
built.sway = startSway(rig, w, h);
|
||||
pump();
|
||||
syncPlayState();
|
||||
return true;
|
||||
};
|
||||
|
|
@ -288,7 +537,8 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null {
|
|||
};
|
||||
|
||||
function syncPlayState() {
|
||||
built?.anims.forEach((a) => (moving() ? a.play() : a.pause()));
|
||||
const anims = built ? [built.sway, ...built.layers.map((l) => l.anim)] : [];
|
||||
anims.forEach((a) => (moving() ? a?.play() : a?.pause()));
|
||||
if (!built?.twinkle) return;
|
||||
if (moving()) {
|
||||
if (!twRaf) twRaf = requestAnimationFrame(twTick);
|
||||
|
|
@ -305,13 +555,15 @@ export function createRidgeScene(opts: RidgeOptions): Scene | null {
|
|||
still = true;
|
||||
const ctx = context2d(canvas);
|
||||
if (ctx && built) {
|
||||
const { anims } = built;
|
||||
built.layers.forEach((l, i) => {
|
||||
// Freeze where the drift is, so the swap to a still does not jump.
|
||||
const a = anims[i];
|
||||
const t = Number(a?.currentTime ?? 0);
|
||||
const turns = (t % (LAYERS[i].seconds * 1000)) / (LAYERS[i].seconds * 1000);
|
||||
ctx.drawImage(l, -Math.round(turns * canvas.width), canvas.height - l.height);
|
||||
const { size } = built;
|
||||
built.layers.forEach((l) => {
|
||||
// Freeze where the drift is, so the swap to a still does not jump; whatever is baked and in view.
|
||||
const dur = chunkSeconds(l.spec, size) * 1000;
|
||||
const p = Math.min(1, Math.max(0, Number(l.anim?.currentTime ?? 0) / dur));
|
||||
const x = -Math.round(p * size.chunk);
|
||||
l.slots.slice(0, 2).forEach((c, i) => {
|
||||
if (c.ready) ctx.drawImage(c.el, x + i * size.chunk, canvas.height - size.height);
|
||||
});
|
||||
});
|
||||
}
|
||||
teardownLayers();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// CultMath's value noise and fbm on the CPU, in one dimension and periodic. Clean-room: the
|
||||
// algorithm is read from shaders/CultMath.glsl (`cultmath_hash`, `cultmath_value_noise`), not copied
|
||||
// from any other terrain. Doubles instead of floats, so values differ from the GLSL in the last
|
||||
// digits; nothing here has to match a shader.
|
||||
// CultMath's value noise on the CPU, in one dimension, over the whole line (no period). Clean-room:
|
||||
// the algorithm is read from shaders/CultMath.glsl (`cultmath_hash`, `cultmath_value_noise`), not
|
||||
// copied from any other terrain. Doubles instead of floats, so values differ from the GLSL in the
|
||||
// last digits; nothing here has to match a shader.
|
||||
|
||||
const frac = (v: number): number => v - Math.floor(v);
|
||||
|
||||
|
|
@ -12,40 +12,14 @@ export const hash2 = (x: number, y: number): number =>
|
|||
const smooth = (t: number): number => t * t * (3 - 2 * t);
|
||||
|
||||
/**
|
||||
* `cultmath_value_noise` along row `row` of the lattice, with the lattice wrapping every `period`
|
||||
* cells: noise(x + period) === noise(x). `row` is an integer, so the vertical blend of the 2D
|
||||
* original is the identity.
|
||||
* `cultmath_value_noise` along row `row` of the lattice, in [0, 1). `row` is an integer, so the
|
||||
* vertical blend of the 2D original is the identity. The lattice is hashed per cell, never wrapped:
|
||||
* a ridge is a function of world-x, with no period for the eye to find.
|
||||
*/
|
||||
export function valueNoisePeriodic(x: number, row: number, period: number): number {
|
||||
export function valueNoise(x: number, row: number): number {
|
||||
const cell = Math.floor(x);
|
||||
const u = smooth(x - cell);
|
||||
const wrap = (c: number) => ((c % period) + period) % period;
|
||||
const a = hash2(wrap(cell), row);
|
||||
const b = hash2(wrap(cell + 1), row);
|
||||
const a = hash2(cell, row);
|
||||
const b = hash2(cell + 1, row);
|
||||
return a + (b - a) * u;
|
||||
}
|
||||
|
||||
export interface FbmShape {
|
||||
octaves: number;
|
||||
lacunarity: number;
|
||||
gain: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fractal sum in [0, 1]. `t` runs 0..1 across one period; the first octave has `baseCells` cells
|
||||
* per period and each next one `lacunarity` times more, so `baseCells * lacunarity^k` must be an
|
||||
* integer for the sum to be periodic (checked by the caller's config, not here).
|
||||
*/
|
||||
export function fbmPeriodic(t: number, row: number, baseCells: number, s: FbmShape): number {
|
||||
let sum = 0;
|
||||
let norm = 0;
|
||||
let amp = 1;
|
||||
let cells = baseCells;
|
||||
for (let k = 0; k < s.octaves; k += 1) {
|
||||
sum += amp * valueNoisePeriodic(t * cells, row + k * 13, cells);
|
||||
norm += amp;
|
||||
amp *= s.gain;
|
||||
cells *= s.lacunarity;
|
||||
}
|
||||
return sum / norm;
|
||||
}
|
||||
|
|
|
|||
303
src/app/features/scene/ridge/ridgeBake.test.ts
Normal file
303
src/app/features/scene/ridge/ridgeBake.test.ts
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { BakeParams, bakeSlice, faceLight, layerPaint, sunDirection } from './ridgeBake';
|
||||
import { LACUNARITY, LAYERS, profile } from './ridgeWorld';
|
||||
import { skyColours, SkyColours } from './skyColour';
|
||||
|
||||
const SKY: SkyColours = { ...skyColours(), sunAzimuth: 0.3 }; // sun toward +x
|
||||
const PERIOD = 400;
|
||||
const VIEW_H = 800;
|
||||
const BAND = 480;
|
||||
|
||||
const params = (
|
||||
li: number,
|
||||
px0: number,
|
||||
cols: number,
|
||||
over: Partial<BakeParams> = {}
|
||||
): BakeParams => ({
|
||||
spec: LAYERS[li],
|
||||
sky: SKY,
|
||||
period: PERIOD,
|
||||
scale: 1,
|
||||
viewH: VIEW_H,
|
||||
rows: BAND,
|
||||
bandH: BAND,
|
||||
px0,
|
||||
cols,
|
||||
...over,
|
||||
});
|
||||
|
||||
function bake(p: BakeParams, slices: number[] = [p.cols]): Uint8ClampedArray {
|
||||
const out = new Uint8ClampedArray(p.cols * p.rows * 4);
|
||||
const paint = layerPaint(p.spec, p.sky);
|
||||
let c = 0;
|
||||
for (const n of slices) {
|
||||
bakeSlice(out, p, paint, c, Math.min(p.cols, c + n));
|
||||
c += n;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const lum = (d: Uint8ClampedArray, cols: number, x: number, y: number) => {
|
||||
const o = (y * cols + x) * 4;
|
||||
return 0.2126 * d[o] + 0.7152 * d[o + 1] + 0.0722 * d[o + 2];
|
||||
};
|
||||
/** First row of column x whose alpha is full. */
|
||||
const solidRow = (d: Uint8ClampedArray, cols: number, x: number) => {
|
||||
let y = 0;
|
||||
while (y < BAND && d[(y * cols + x) * 4 + 3] < 255) y += 1;
|
||||
return y;
|
||||
};
|
||||
|
||||
describe('the field is a function of world-x', () => {
|
||||
it('is deterministic, and defined and bounded at any world-x', () => {
|
||||
for (const spec of LAYERS) {
|
||||
for (const t of [-40.3, 0, 0.5, 17.77, 5000.123]) {
|
||||
expect(profile(spec, t)).toBe(profile(spec, t));
|
||||
expect(profile(spec, t)).toBeGreaterThanOrEqual(0);
|
||||
expect(profile(spec, t)).toBeLessThanOrEqual(1);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('has no repeat at the old period (1 viewport) or its multiples: the profile differs from itself shifted', () => {
|
||||
for (const spec of LAYERS) {
|
||||
for (const lag of [1, 2, 3, 0.5]) {
|
||||
let diff = 0;
|
||||
for (let i = 0; i < 400; i += 1)
|
||||
diff += Math.abs(profile(spec, i / 100) - profile(spec, i / 100 + lag));
|
||||
expect(diff / 400).toBeGreaterThan(0.04);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('the same world column bakes to the same pixels whichever chunk or slice paints it', () => {
|
||||
for (const li of [0, 3]) {
|
||||
const whole = bake(params(li, 1000, 200));
|
||||
const sliced = bake(params(li, 1000, 200), [37, 64, 1000]);
|
||||
expect(Array.from(sliced)).toEqual(Array.from(whole));
|
||||
// A chunk starting 120 columns later sees the same pixels for the columns it shares.
|
||||
const shifted = bake(params(li, 1120, 80));
|
||||
for (const x of [0, 17, 79]) {
|
||||
for (const y of [0, 100, 300, BAND - 1]) {
|
||||
const a = (y * 200 + 120 + x) * 4;
|
||||
const b = (y * 80 + x) * 4;
|
||||
expect(Array.from(shifted.slice(b, b + 4))).toEqual(Array.from(whole.slice(a, a + 4)));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('is continuous across a chunk seam: the crest steps no more at the seam than elsewhere', () => {
|
||||
const chunk = Math.round(PERIOD * 1.2);
|
||||
for (const li of [0, 1, 2, 3]) {
|
||||
const p = params(li, chunk - 40, 80);
|
||||
const d = bake(p);
|
||||
let worst = 0;
|
||||
for (let x = 1; x < 80; x += 1)
|
||||
worst = Math.max(worst, Math.abs(solidRow(d, 80, x) - solidRow(d, 80, x - 1)));
|
||||
const seam = Math.abs(solidRow(d, 80, 40) - solidRow(d, 80, 39));
|
||||
expect(seam).toBeLessThanOrEqual(Math.max(worst, 1));
|
||||
// and the seam columns equal a chunk baked from the other side
|
||||
const other = bake(params(li, chunk - 40, 80, { cols: 80 }));
|
||||
expect(Array.from(other)).toEqual(Array.from(d));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('shading from the slope normal', () => {
|
||||
const sun = sunDirection(SKY);
|
||||
|
||||
it('the sun is toward +x for azimuth 0.3', () => {
|
||||
expect(sun[0]).toBeGreaterThan(0.5);
|
||||
});
|
||||
|
||||
it('a face looking at the sun is lit more than one looking away', () => {
|
||||
// slope < 0: the crest falls toward +x, so the face looks at +x, at the sun.
|
||||
for (const s of [0.2, 0.6, 1.5]) expect(faceLight(-s, sun)).toBeGreaterThan(faceLight(s, sun));
|
||||
expect(faceLight(1.5, sun)).toBeLessThan(0.2);
|
||||
expect(faceLight(-1.5, sun)).toBeGreaterThan(0.6);
|
||||
});
|
||||
|
||||
it('in the baked pixels the lit side is brighter than the shadow side', () => {
|
||||
for (const li of [0, 1, 2]) {
|
||||
const cols = 900;
|
||||
const p = params(li, 0, cols);
|
||||
const d = bake(p);
|
||||
const slope = (x: number) => (solidRow(d, cols, x - 1) - solidRow(d, cols, x + 1)) / 2; // rows up per column
|
||||
const litX: number[] = [];
|
||||
const shadeX: number[] = [];
|
||||
for (let x = 2; x < cols - 2; x += 1) {
|
||||
if (slope(x) < -0.8) litX.push(x);
|
||||
if (slope(x) > 0.8) shadeX.push(x);
|
||||
}
|
||||
expect(litX.length).toBeGreaterThan(5);
|
||||
expect(shadeX.length).toBeGreaterThan(5);
|
||||
const mean = (xs: number[]) =>
|
||||
xs.reduce((a, x) => a + lum(d, cols, x, solidRow(d, cols, x) + 12), 0) / xs.length;
|
||||
expect(mean(litX)).toBeGreaterThan(mean(shadeX));
|
||||
}
|
||||
});
|
||||
|
||||
it('a lit crest rim: the crest pixels are brighter than the face a little below, on both sides', () => {
|
||||
for (const li of [0, 1, 2, 3]) {
|
||||
const cols = 600;
|
||||
const d = bake(params(li, 300, cols));
|
||||
let rimmed = 0;
|
||||
let n = 0;
|
||||
for (let x = 3; x < cols - 3; x += 1) {
|
||||
const y = solidRow(d, cols, x);
|
||||
if (y + 40 >= BAND) continue;
|
||||
n += 1;
|
||||
if (lum(d, cols, x, y + 1) > lum(d, cols, x, y + 30)) rimmed += 1;
|
||||
}
|
||||
expect(n).toBeGreaterThan(100);
|
||||
expect(rimmed / n).toBeGreaterThan(0.8);
|
||||
}
|
||||
});
|
||||
|
||||
it('the foot of the face is hazier than its top: it moves toward the haze colour', () => {
|
||||
const cols = 300;
|
||||
const p = params(0, 0, cols);
|
||||
const d = bake(p);
|
||||
const haze = layerPaint(p.spec, p.sky).haze;
|
||||
const dist = (x: number, y: number) => {
|
||||
const o = (y * cols + x) * 4;
|
||||
return Math.hypot(d[o] - haze[0], d[o + 1] - haze[1], d[o + 2] - haze[2]);
|
||||
};
|
||||
let nearer = 0;
|
||||
let n = 0;
|
||||
for (let x = 0; x < cols; x += 1) {
|
||||
const y = solidRow(d, cols, x);
|
||||
if (y + 200 >= BAND) continue;
|
||||
n += 1;
|
||||
if (dist(x, y + 200) < dist(x, y + 20)) nearer += 1;
|
||||
}
|
||||
expect(n).toBeGreaterThan(50);
|
||||
expect(nearer / n).toBeGreaterThan(0.9);
|
||||
});
|
||||
|
||||
it('erosion streaks run down the face: brightness varies across columns at one depth, and not without them', () => {
|
||||
const cols = 500;
|
||||
const spread = (gully: number) => {
|
||||
const d = bake(params(2, 0, cols, { spec: { ...LAYERS[2], gully } }));
|
||||
// The same depth below the crest in every column, in the lit-independent part (gullies darken, lit is column-wise).
|
||||
const v: number[] = [];
|
||||
for (let x = 0; x < cols; x += 1) {
|
||||
const y = solidRow(d, cols, x);
|
||||
if (y + 90 < BAND) v.push(lum(d, cols, x, y + 80) / Math.max(1, lum(d, cols, x, y + 8)));
|
||||
}
|
||||
const m = v.reduce((a, b) => a + b, 0) / v.length;
|
||||
return v.reduce((a, b) => a + (b - m) ** 2, 0) / v.length;
|
||||
};
|
||||
expect(spread(LAYERS[2].gully)).toBeGreaterThan(spread(0) * 1.2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bake cost', () => {
|
||||
it('is recorded, not asserted: one chunk of every layer at 1280 x 720', () => {
|
||||
const period = 1280;
|
||||
const rows = Math.round(720 * 0.6);
|
||||
const ms = LAYERS.map((spec) => {
|
||||
const p: BakeParams = {
|
||||
spec,
|
||||
sky: SKY,
|
||||
period,
|
||||
scale: 1,
|
||||
viewH: 720,
|
||||
rows,
|
||||
bandH: rows,
|
||||
px0: 0,
|
||||
cols: Math.round(period * 1.2),
|
||||
};
|
||||
const t0 = performance.now();
|
||||
bake(p);
|
||||
return performance.now() - t0;
|
||||
});
|
||||
expect(ms.every((v) => Number.isFinite(v) && v >= 0)).toBe(true);
|
||||
console.info(
|
||||
`ridge bake ms per layer (node, 1 chunk, 1280x720): ${ms.map((v) => v.toFixed(1)).join(' ')}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the ridged multifractal core, each rule on its own', () => {
|
||||
const one = { ...LAYERS[0], octaves: 1, warp: 0, crag: 1 };
|
||||
|
||||
it('folds each octave at its crest: a creased, sharp ridge line, not a smooth swell', () => {
|
||||
// Largest second difference over a fine scan. A smooth curve of this size stays in the hundreds;
|
||||
// a fold leaves a crease where the noise crosses its midpoint.
|
||||
const h = 1e-3;
|
||||
let worst = 0;
|
||||
for (let i = 1; i < 20000; i += 1) {
|
||||
const t = i * h;
|
||||
worst = Math.max(
|
||||
worst,
|
||||
Math.abs(profile(one, t + h) - 2 * profile(one, t) + profile(one, t - h)) / (h * h)
|
||||
);
|
||||
}
|
||||
expect(worst).toBeGreaterThan(2000);
|
||||
});
|
||||
|
||||
it('domain-warps: the same layer without its warp is a measurably different line', () => {
|
||||
const spec = LAYERS[1];
|
||||
let diff = 0;
|
||||
for (let i = 0; i < 2000; i += 1)
|
||||
diff += Math.abs(profile(spec, i / 100) - profile({ ...spec, warp: 0 }, i / 100));
|
||||
expect(diff / 2000).toBeGreaterThan(0.02);
|
||||
});
|
||||
|
||||
it('weights each octave by the previous one: where the first octave is low, the later ones add almost nothing', () => {
|
||||
const lo = { ...LAYERS[1], octaves: 1, warp: 0 };
|
||||
const hi = { ...LAYERS[1], octaves: 5, warp: 0 };
|
||||
let valley = 0;
|
||||
let nv = 0;
|
||||
let peak = 0;
|
||||
let np = 0;
|
||||
for (let i = 0; i < 10000; i += 1) {
|
||||
const a = profile(lo, i / 500);
|
||||
const d = Math.abs(profile(hi, i / 500) - a);
|
||||
if (a < 0.12) {
|
||||
valley += d;
|
||||
nv += 1;
|
||||
} else if (a > 0.35) {
|
||||
peak += d;
|
||||
np += 1;
|
||||
}
|
||||
}
|
||||
expect(nv).toBeGreaterThan(200);
|
||||
expect(np).toBeGreaterThan(200);
|
||||
expect(valley / nv).toBeLessThan(0.25 * (peak / np));
|
||||
});
|
||||
|
||||
it('octave lattices do not line up: the lacunarity is not a power of two', () => {
|
||||
expect(Number.isInteger(Math.log2(LACUNARITY))).toBe(false);
|
||||
expect(Number.isInteger(LACUNARITY)).toBe(false);
|
||||
});
|
||||
|
||||
it('distance flattens relief: contrast falls from the nearest layer to the farthest', () => {
|
||||
const c = LAYERS.map((spec) => layerPaint(spec, SKY).contrast);
|
||||
expect(c[LAYERS.length - 1]).toBe(1);
|
||||
for (let i = 1; i < c.length; i += 1) expect(c[i]).toBeGreaterThan(c[i - 1]);
|
||||
// and it shows in the pixels: the lit/shadow difference is smaller on the farthest layer than the nearest
|
||||
const gap = (li: number) => {
|
||||
const cols = 700;
|
||||
const d = bake(params(li, 0, cols));
|
||||
const slope = (x: number) => (solidRow(d, cols, x - 1) - solidRow(d, cols, x + 1)) / 2;
|
||||
const avg = (pick: (s: number) => boolean) => {
|
||||
const xs = Array.from({ length: cols - 4 }, (_, k) => k + 2).filter((x) => pick(slope(x)));
|
||||
return (
|
||||
xs.reduce((a, x) => a + lum(d, cols, x, solidRow(d, cols, x) + 12), 0) /
|
||||
Math.max(1, xs.length)
|
||||
);
|
||||
};
|
||||
return (
|
||||
Math.abs(avg((s) => s < -0.8) - avg((s) => s > 0.8)) /
|
||||
Math.max(
|
||||
1,
|
||||
avg(() => true)
|
||||
)
|
||||
);
|
||||
};
|
||||
expect(gap(0)).toBeLessThan(gap(3));
|
||||
});
|
||||
});
|
||||
139
src/app/features/scene/ridge/ridgeBake.ts
Normal file
139
src/app/features/scene/ridge/ridgeBake.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
/* eslint-disable no-param-reassign -- `bakeSlice` fills the caller's pixel buffer */
|
||||
// The pixels of a ridge chunk, as a pure function: no DOM, no canvas. Each column is lit from the
|
||||
// slope of the heightfield's crest line against the sun; the face under it takes a light-to-haze
|
||||
// gradient, erosion streaks and a lit rim on the crest. The depth treatment (colour, haze, contrast
|
||||
// by distance) is ridgeWorld's `layerColour`, `hazeColour` and `transmittance`, used here and not redone.
|
||||
import { gullyAt, LayerSpec, profile, hazeColour, layerColour, transmittance } from './ridgeWorld';
|
||||
import type { SkyColours, Srgb } from './skyColour';
|
||||
|
||||
export type Vec3 = readonly [number, number, number];
|
||||
|
||||
/** Unit vector toward the sun: x across the screen (from the azimuth), y up, z toward the viewer. */
|
||||
export function sunDirection(sky: SkyColours): Vec3 {
|
||||
const az = sky.sunAzimuth ?? 0.3;
|
||||
const x = 0.9 * Math.sin(2 * Math.PI * az);
|
||||
const l = Math.hypot(x, 0.35, 0.45);
|
||||
return [x / l, 0.35 / l, 0.45 / l];
|
||||
}
|
||||
|
||||
/** How steeply the crest's slope tilts the face's normal sideways. */
|
||||
const TILT = 1.2;
|
||||
|
||||
/**
|
||||
* Sun light on the face under a crest whose silhouette has `slope` (rows per column, positive when the
|
||||
* crest rises toward +x, so the face looks toward -x). 0 in full shadow .. 1 head-on to the sun.
|
||||
*/
|
||||
export function faceLight(slope: number, sun: Vec3): number {
|
||||
const nx = -slope * TILT;
|
||||
const inv = 1 / Math.hypot(nx, 0.55, 0.85);
|
||||
const d = (nx * sun[0] + 0.55 * sun[1] + 0.85 * sun[2]) * inv;
|
||||
return Math.min(1, Math.max(0, (d - 0.2) / 0.7));
|
||||
}
|
||||
|
||||
export interface BakeParams {
|
||||
spec: LayerSpec;
|
||||
sky: SkyColours;
|
||||
/** Pixels in one viewport width: the unit of world-x. */
|
||||
period: number;
|
||||
/** CSS px to canvas px. */
|
||||
scale: number;
|
||||
/** Viewport height in canvas px. */
|
||||
viewH: number;
|
||||
/** Rows of the pixel buffer (the band plus any padding below it). */
|
||||
rows: number;
|
||||
/** Row count of the visible band; the crest is measured from the top of it. */
|
||||
bandH: number;
|
||||
/** World pixel of the buffer's column 0 (may be fractional-free negative: a chunk's left minus its padding). */
|
||||
px0: number;
|
||||
/** Columns of the buffer. */
|
||||
cols: number;
|
||||
}
|
||||
|
||||
const clamp01 = (v: number): number => Math.min(1, Math.max(0, v));
|
||||
const smoothstep = (a: number, b: number, v: number): number => {
|
||||
const t = clamp01((v - a) / (b - a));
|
||||
return t * t * (3 - 2 * t);
|
||||
};
|
||||
const SUN_WHITE: Srgb = [255, 238, 205];
|
||||
|
||||
/** Everything `bakeSlice` needs per layer, computed once. */
|
||||
export interface LayerPaint {
|
||||
base: Srgb;
|
||||
haze: Srgb;
|
||||
warm: Srgb;
|
||||
contrast: number;
|
||||
sun: Vec3;
|
||||
}
|
||||
|
||||
export function layerPaint(spec: LayerSpec, sky: SkyColours): LayerPaint {
|
||||
const warm: Srgb = [
|
||||
(sky.horizon[0] + SUN_WHITE[0]) / 2,
|
||||
(sky.horizon[1] + SUN_WHITE[1]) / 2,
|
||||
(sky.horizon[2] + SUN_WHITE[2]) / 2,
|
||||
];
|
||||
return {
|
||||
base: layerColour(spec, sky),
|
||||
haze: hazeColour(sky),
|
||||
warm,
|
||||
// Distance flattens relief: the same transmittance that thins the colour.
|
||||
contrast: 0.35 + 0.65 * transmittance(spec),
|
||||
sun: sunDirection(sky),
|
||||
};
|
||||
}
|
||||
|
||||
/** Crest row (float, from the top of the band) of every buffer column from c0 - 1 to c1 (inclusive). */
|
||||
function crestRows(p: BakeParams, c0: number, c1: number): Float64Array {
|
||||
const out = new Float64Array(c1 - c0 + 2);
|
||||
const amp = p.spec.relief * p.viewH;
|
||||
const lift = p.spec.base * p.viewH;
|
||||
for (let c = c0 - 1; c <= c1; c += 1) {
|
||||
const top = Math.min(p.bandH, lift + amp * profile(p.spec, (p.px0 + c) / p.period));
|
||||
out[c - c0 + 1] = p.bandH - top;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paint columns [c0, c1) of the chunk into `out` (RGBA, `p.cols` wide, `p.rows` tall, zeroed). One
|
||||
* call is a slice of a chunk, so a caller can keep each task short. Seams are exact: a column's pixels
|
||||
* depend only on its world pixel, never on where the slice or chunk begins.
|
||||
*/
|
||||
export function bakeSlice(
|
||||
out: Uint8ClampedArray,
|
||||
p: BakeParams,
|
||||
paint: LayerPaint,
|
||||
c0: number,
|
||||
c1: number
|
||||
): void {
|
||||
const { spec } = p;
|
||||
const crest = crestRows(p, c0, c1);
|
||||
const faceH = 0.45 * p.viewH;
|
||||
const rim = 2.2 * p.scale;
|
||||
const { base, haze, warm, contrast, sun } = paint;
|
||||
for (let c = c0; c < c1; c += 1) {
|
||||
const cy = crest[c - c0 + 1];
|
||||
const slope = (crest[c - c0 + 2] - crest[c - c0]) * -0.5; // rows up per column
|
||||
const lit = faceLight(slope, sun);
|
||||
const steep = 0.45 + 0.55 * clamp01(Math.abs(slope) * 0.8);
|
||||
const gl = gullyAt(spec, (p.px0 + c) / p.scale) * steep;
|
||||
const y0 = Math.max(0, Math.floor(cy));
|
||||
for (let y = y0; y < p.rows; y += 1) {
|
||||
const d = y + 0.5 - cy;
|
||||
const dn = clamp01(d / faceH);
|
||||
const cover = clamp01(y + 1 - cy);
|
||||
const gully = spec.gully * gl * smoothstep(0, 0.2, dn);
|
||||
// Shade the ground colour by the sun; the lit warm light fades down the face and in the gullies.
|
||||
const shade = (0.7 + 0.3 * lit * contrast + 0.3 * (1 - contrast)) * (1 - 0.5 * gully);
|
||||
const warmK = 0.22 * contrast * lit * lit * (1 - 0.7 * dn) * (1 - 0.6 * gully);
|
||||
const rimK = clamp01(1 - d / rim) * contrast * (0.3 + 0.7 * lit) * 0.6;
|
||||
const mist = spec.mist * smoothstep(0, 1, dn);
|
||||
const o = (y * p.cols + c) * 4;
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
let v = base[i] * shade + warm[i] * (warmK + rimK);
|
||||
v += (haze[i] - v) * mist;
|
||||
out[o + i] = v;
|
||||
}
|
||||
out[o + 3] = cover * 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,39 +1,137 @@
|
|||
// Every number of the ridge scene, and the pure functions that turn them into silhouettes. No DOM.
|
||||
import { fbmPeriodic, FbmShape } from './noise';
|
||||
// A ridge is a function of world-x (t, in viewport widths): ridged multifractal noise, domain-warped,
|
||||
// one function for every layer with the parameters below. Nothing in it repeats.
|
||||
import { valueNoise } from './noise';
|
||||
import type { Srgb, SkyColours } from './skyColour';
|
||||
|
||||
export interface LayerSpec {
|
||||
/** 0 farthest. Also the noise row, so layers never share a profile. */
|
||||
index: number;
|
||||
/** Lattice cells across one period for the first octave; the fbm octaves scale it by `lacunarity`. */
|
||||
/** Lattice cells per viewport width for the first octave; each octave multiplies it by LACUNARITY. */
|
||||
baseCells: number;
|
||||
/** Peak-to-valley height of the silhouette, as a fraction of the layer band. */
|
||||
/** Peak-to-valley height of the silhouette, as a fraction of the viewport height. */
|
||||
relief: number;
|
||||
/** 0 hills (plain fbm) .. 1 crags (ridged: 1 - |2n - 1|). */
|
||||
/** 0 rounded hills .. 1 sharp crags: how tightly each octave is folded at its crest. */
|
||||
crag: number;
|
||||
/** Ridge top at its lowest point, fraction of the viewport height up from the bottom. */
|
||||
base: number;
|
||||
/** 0 = the layer is the horizon haze, 1 = the nearest ground colour. */
|
||||
depth: number;
|
||||
/** Seconds for the layer to drift one period. Farther layers are slower. */
|
||||
/** Seconds for the layer to drift one viewport width. Farther layers are slower. */
|
||||
seconds: number;
|
||||
/** Octaves of the ridged multifractal; the bake cost is linear in it. */
|
||||
octaves: number;
|
||||
/** Domain warp, in cells of the first octave. */
|
||||
warp: number;
|
||||
/** 0..1 depth of the erosion streaks down the face. */
|
||||
gully: number;
|
||||
/** Width of one gully, CSS px. */
|
||||
gullyPx: number;
|
||||
/** 0..1 how far the foot of the face dissolves into the valley mist. */
|
||||
mist: number;
|
||||
}
|
||||
|
||||
/** Four layers. `lacunarity` 2 with these base cell counts keeps every octave an integer. */
|
||||
/** Four layers: one function, four parameter rows. */
|
||||
export const LAYERS: readonly LayerSpec[] = [
|
||||
{ index: 0, baseCells: 3, relief: 0.24, crag: 0.8, base: 0.34, depth: 0.12, seconds: 420 },
|
||||
{ index: 1, baseCells: 4, relief: 0.2, crag: 0.55, base: 0.26, depth: 0.38, seconds: 300 },
|
||||
{ index: 2, baseCells: 5, relief: 0.16, crag: 0.3, base: 0.17, depth: 0.66, seconds: 210 },
|
||||
{ index: 3, baseCells: 6, relief: 0.12, crag: 0.1, base: 0.08, depth: 1, seconds: 150 },
|
||||
{
|
||||
index: 0,
|
||||
baseCells: 2.2,
|
||||
relief: 0.24,
|
||||
crag: 0.85,
|
||||
base: 0.34,
|
||||
depth: 0.12,
|
||||
seconds: 420,
|
||||
octaves: 5,
|
||||
warp: 0.6,
|
||||
gully: 0.3,
|
||||
gullyPx: 16,
|
||||
mist: 0.35,
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
baseCells: 3.0,
|
||||
relief: 0.2,
|
||||
crag: 0.7,
|
||||
base: 0.26,
|
||||
depth: 0.38,
|
||||
seconds: 300,
|
||||
octaves: 5,
|
||||
warp: 0.55,
|
||||
gully: 0.5,
|
||||
gullyPx: 20,
|
||||
mist: 0.3,
|
||||
},
|
||||
{
|
||||
index: 2,
|
||||
baseCells: 3.8,
|
||||
relief: 0.16,
|
||||
crag: 0.5,
|
||||
base: 0.17,
|
||||
depth: 0.66,
|
||||
seconds: 210,
|
||||
octaves: 5,
|
||||
warp: 0.5,
|
||||
gully: 0.6,
|
||||
gullyPx: 24,
|
||||
mist: 0.22,
|
||||
},
|
||||
{
|
||||
index: 3,
|
||||
baseCells: 4.6,
|
||||
relief: 0.12,
|
||||
crag: 0.3,
|
||||
base: 0.08,
|
||||
depth: 1,
|
||||
seconds: 150,
|
||||
octaves: 4,
|
||||
warp: 0.45,
|
||||
gully: 0.7,
|
||||
gullyPx: 30,
|
||||
mist: 0.16,
|
||||
},
|
||||
];
|
||||
|
||||
export const FBM: FbmShape = { octaves: 5, lacunarity: 2, gain: 0.5 };
|
||||
/** Not an integer power of two, so no octave's lattice lines up with another's. */
|
||||
export const LACUNARITY = 2.07;
|
||||
/** Each octave is weighted by the previous octave's folded signal, times this, clamped to 1. */
|
||||
export const WEIGHT_GAIN = 2;
|
||||
const AMP_GAIN = 0.5;
|
||||
/** Calibration: the weighted sum's typical top is about 1 / this. */
|
||||
const PEAK_NORM = 1.25;
|
||||
|
||||
/** Silhouette height in [0, 1] at `t` (0..1 across one period); periodic in t. */
|
||||
/** Silhouette height in [0, 1] at world-x `t` (viewport widths); defined for every real t. */
|
||||
export function profile(spec: LayerSpec, t: number): number {
|
||||
const n = fbmPeriodic(t, spec.index * 101, spec.baseCells, FBM);
|
||||
const crag = 1 - Math.abs(2 * n - 1);
|
||||
return n + (crag - n) * spec.crag;
|
||||
const row = spec.index * 101;
|
||||
const wt =
|
||||
t + (spec.warp * (2 * valueNoise(t * spec.baseCells * 0.7, row + 97) - 1)) / spec.baseCells;
|
||||
const e = 0.03 + (1 - spec.crag) * 0.4;
|
||||
const top = Math.sqrt(1 + e * e);
|
||||
let sum = 0;
|
||||
let norm = 0;
|
||||
let amp = 1;
|
||||
let f = spec.baseCells;
|
||||
let w = 1;
|
||||
for (let k = 0; k < spec.octaves; k += 1) {
|
||||
const v = 2 * valueNoise(wt * f, row + k * 13) - 1;
|
||||
// Fold: 1 at the zero crossing of the noise, 0 at its extremes; `e` rounds the crease.
|
||||
let sig = 1 - Math.sqrt(v * v + e * e) / top;
|
||||
sig *= sig;
|
||||
sig *= w;
|
||||
w = Math.min(1, sig * WEIGHT_GAIN);
|
||||
sum += sig * amp;
|
||||
norm += amp;
|
||||
amp *= AMP_GAIN;
|
||||
f *= LACUNARITY;
|
||||
}
|
||||
// A soft swell under the folds, so the valleys between sharp crests are not flat floors.
|
||||
const swell = valueNoise(wt * spec.baseCells * 0.6, row + 211);
|
||||
return Math.min(1, 0.85 * (sum / norm) * PEAK_NORM + 0.15 * swell);
|
||||
}
|
||||
|
||||
/** Erosion streak strength in [0, 1] at world-x `wxCss` (CSS px): a folded noise at gully width. */
|
||||
export function gullyAt(spec: LayerSpec, wxCss: number): number {
|
||||
const g = 1 - Math.abs(2 * valueNoise(wxCss / spec.gullyPx, spec.index * 131 + 5) - 1);
|
||||
return g * g;
|
||||
}
|
||||
|
||||
/** The distance haze the far layers dissolve into: between the mid sky and the horizon, not the horizon glow itself. */
|
||||
|
|
@ -75,32 +173,32 @@ export function layerColour(spec: LayerSpec, sky: SkyColours): Srgb {
|
|||
export const edgeBlur = (spec: LayerSpec): number => AERIAL.blurPx * (1 - transmittance(spec));
|
||||
|
||||
/**
|
||||
* Alpha of the warm sun-side in-scatter at `t` (0..1 across one period; periodic). Zero when the sky has
|
||||
* no sun; grows with distance, peaks at the sun's azimuth.
|
||||
* Alpha of the warm sun-side in-scatter at world-x `t` (viewport widths). Zero when the sky has no
|
||||
* sun; grows with distance. The lobe is the product of two cosines with incommensurate wavelengths
|
||||
* (1.9 and 1.9 * golden ratio), so it is not periodic; its peaks are where the sun's azimuth puts them.
|
||||
*/
|
||||
export function scatterAlpha(spec: LayerSpec, sky: SkyColours, t: number): number {
|
||||
if (sky.sunAzimuth === undefined) return 0;
|
||||
const lobe = 0.5 + 0.5 * Math.cos(2 * Math.PI * (t - sky.sunAzimuth));
|
||||
return AERIAL.scatter * (1 - transmittance(spec)) * lobe * lobe;
|
||||
const a = 0.5 + 0.5 * Math.cos(2 * Math.PI * (t / 1.9 - sky.sunAzimuth));
|
||||
const b = 0.75 + 0.25 * Math.cos((2 * Math.PI * t) / (1.9 * 1.618034));
|
||||
return AERIAL.scatter * (1 - transmittance(spec)) * a * a * b;
|
||||
}
|
||||
|
||||
/** Integer pixel sizes of a layer for a viewport: one period wide, twice that painted, so a drift of one period loops. */
|
||||
/** Chunk width as a multiple of the viewport width: room for the yaw sway on top of the viewport. */
|
||||
export const CHUNK_OVER = 1.2;
|
||||
|
||||
/**
|
||||
* Integer pixel sizes for a viewport. `period` is one viewport width, the unit of world-x; a chunk
|
||||
* is `chunk` pixels of world, `height` the band a layer is painted in.
|
||||
*/
|
||||
export function layerSize(cssW: number, cssH: number, scale: number) {
|
||||
const period = Math.round(cssW * scale);
|
||||
const band = Math.round(cssH * scale * 0.6);
|
||||
return { period, width: period * 2, height: band };
|
||||
return { period, chunk: Math.round(period * CHUNK_OVER), height: band };
|
||||
}
|
||||
|
||||
/** Ridge-top y (canvas pixels, from the top of the layer's band) for pixel column x. */
|
||||
export function ridgeY(
|
||||
spec: LayerSpec,
|
||||
x: number,
|
||||
period: number,
|
||||
bandH: number,
|
||||
viewH: number
|
||||
): number {
|
||||
const amp = spec.relief * viewH;
|
||||
const bottomOffset = spec.base * viewH; // lowest the top sits above the viewport bottom
|
||||
const topFromBottom = bottomOffset + amp * profile(spec, (x % period) / period);
|
||||
/** Ridge-top y (canvas pixels, from the top of the layer's band) at world-x `t` (viewport widths). */
|
||||
export function ridgeY(spec: LayerSpec, t: number, bandH: number, viewH: number): number {
|
||||
const topFromBottom = spec.base * viewH + spec.relief * viewH * profile(spec, t);
|
||||
return bandH - Math.min(bandH, topFromBottom);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
// The one place the sky's colours come from. Void's `src/sky/sun.js` (`light({ time, lat, lon,
|
||||
// cloud_cover, precipitation })`, PR #20) is the intended source; until it lands this returns a
|
||||
// fixed twilight: lavender zenith, rose mid-sky, orange horizon. Its darkest blend stays above the dusk
|
||||
// floor of ../skyBand.ts (the dark ink of Noonday Sky needs a backdrop of at least 0.2983 luminance), which
|
||||
// skyForState.test.ts sweeps. Replace the body, keep the signature: the scene calls it once, at startup,
|
||||
// fixed twilight: lavender zenith, rose mid-sky, orange horizon. These are the raw stops: the scene draws them
|
||||
// shaped into the dusk band of ../skyBand.ts (`ridgeSkyFor`, skyForState.ts), whose floor the link of Noonday Sky needs. Replace the body, keep the signature: the scene calls it once, at startup,
|
||||
// and bakes the result.
|
||||
|
||||
export type Srgb = readonly [number, number, number];
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue