From b6a97046b0cd0c3aa5afdb40dc1874129613068e Mon Sep 17 00:00:00 2001 From: Void Date: Sun, 8 Nov 2026 07:55:54 +0000 Subject: [PATCH 1/4] docs: file zone design sketch Albums as threads, marks as custom reference events (or local), export, mobile layout, existing media code, and a four-slice first plan. Design only. Co-Authored-By: Claude Sonnet 5.5 --- docs/file-zone.md | 88 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 docs/file-zone.md diff --git a/docs/file-zone.md b/docs/file-zone.md new file mode 100644 index 00000000..eeca817a --- /dev/null +++ b/docs/file-zone.md @@ -0,0 +1,88 @@ +# File zone: design sketch + +Status: design only. No code exists. Everything below is a proposal; items marked **UNKNOWN** are not yet verified against the Body. + +Source: Meta ruling 2026-10-31, eureka mind `ratatoskr:ruling:file-zone-looks-like-file-sharing`. + +## What it is + +A file-sharing space beside the room timeline, not a view of it. A room's files appear as **albums**: a browsable grid or list with title, item count and date, opening into the files, with inline players and previews. The Matrix room (and its threads) is the storage and the fallback: other clients see ordinary messages and threads, and never need to know the zone exists. No homeserver change. + +First use (Aqua): listen to audio in place, mark each clip keep / fix / drop with a one-line reason, export the marks as a file. See `/opt/commons/handoff/aqua-listen-kits-20261108/` (static `listen.html` kits that emit `verdict.json` + `verdict.md`); the zone replaces emailing that page around. + +## Matrix event shapes (stock events only) + +- **File** = an ordinary `m.room.message` with `msgtype` `m.audio` / `m.image` / `m.video` / `m.file`, uploaded to the media repo. The existing builders in `src/app/features/room/msgContent.ts` already produce these (`getAudioMsgContent`, `getImageMsgContent`, `getVideoMsgContent`, `getFileMsgContent`). Unchanged. +- **Album** = a thread. The root is a text `m.room.message` whose `body` is the title (`m.text`), and files are posted with `m.relates_to: { rel_type: "m.thread", event_id: }`. Other clients see a normal thread; its count and last-event time come from the SDK's thread summary (`unsigned.m.relations.m.thread`: `count`, `latest_event`). Album date = root `origin_server_ts`. Cover = first image child, if any. + - Why a thread and not a custom state event or a space: needs no power-level or server change, degrades to something readable, and the SDK already tracks it. Cost: any member can add to it; an album is only as structured as its root. +- **Loose files** (no thread) fall into an implicit "Unsorted" album, newest first. +- **Custom marker on the root** so the zone can tell albums from chat threads: an extra content key, e.g. `org.gamecult.album: { v: 1 }`. Unknown keys are preserved by servers and ignored by other clients. **UNKNOWN:** whether to show every thread as an album instead and skip the marker; the marker is cleaner but is the one bit of non-stock shape. +- **Edits/redaction**: renaming = `m.replace` on the root; deleting a file = redact the child. Stock. + +## Marks (keep / fix / drop + one line) + +Two options; recommend a hybrid. + +1. **Local only**: marks live in the client (IndexedDB), never sent. Zero room noise, private, lost with the browser. Fine for a lone listener, wrong for "Aqua hears it, others see the verdict". +2. **Room events**: one small event per mark, related to the file event. Recommended shape: `m.room.message` `m.notice` in the same thread is visible to everyone but is chat spam. Better is an event type of our own, `org.gamecult.file_mark`, content `{ "m.relates_to": { "rel_type": "m.reference", "event_id": }, "verdict": "keep"|"fix"|"drop", "reason": "" }`. Latest mark per (sender, file) wins; changing one means sending a new event. Other clients ignore unknown event types, so no spam, and the room history stays the store. + - Alternative: `m.annotation` reactions with a key of `keep`/`fix`/`drop`, which stock clients already render; but a reaction key has no room for the reason line. Rejected for that. + - **UNKNOWN:** do the room's power levels allow custom event types for ordinary members (default `events_default` covers them); do synapse/tuwunel/conduwuit servers send unknown types through sync to us; encrypted rooms carry them as `m.room.encrypted` like any other (fine, expected). +3. **Hybrid (proposed)**: write marks locally first (instant, offline), publish to the room only on explicit "share marks". Keeps the player responsive and the room quiet. + +## Export + +Client-side only. "Export marks" builds a file from the marks the viewer can see for one album and either downloads it or uploads it into the album as another file: + +- `verdict.json`: `{ album, exported_at, marks: [{ file, sha256?, name, verdict, reason, by }] }` +- `verdict.md`: human table of the same. + +Matches the shape the `aqua-listen` kits already emit (`verdict.json` + `verdict.md`), so downstream tooling does not change. **UNKNOWN:** exact field names of the kits' `verdict.json`; read `tools/aqua-listen` in GameCult/AquaSynth (PR #16) before fixing the schema. Per ruling hygiene, JSON here is a xenos-boundary export, not state. + +## Mobile-first layout + +- Phone width is the design width; desktop adds columns. +- Level 1, album list: one column, each row = cover/icon, title, "N files", date. Tap opens. +- Level 2, album: list by default (title, duration/size), grid toggle for images. Audio rows play in place, one at a time (starting a clip stops the last), a persistent mini-player at the bottom with seek. +- Mark controls on each audio row: three large tap targets (keep / fix / drop) and a one-line text field that appears after a choice. Thumb-reachable, no modal. +- Back is one level up; the room timeline stays one tap away (the zone is a sibling view, not a replacement). **UNKNOWN:** where the entry lives in the room header; check `RoomViewHeader.tsx`, `Room.tsx` for how the member drawer and pin menu attach. + +## Upload + +Reuse the composer's pipeline, pointed at an album instead of the main timeline: + +- `createUploadAtom` / `useBindUploadAtom` (`src/app/state/upload.ts`), `UploadCard`, `upload-board` for progress. +- On success, the msgContent builder produces the event; the zone adds `m.relates_to` thread to the album root before sending (the composer already does this for thread replies, `RoomInput.tsx`). +- "New album" = send the root text event first (with the marker), then upload into it. Multi-file drop = N events, sent in order. +- Size limit from `MediaConfigLoader` (server `m.upload.size`). **UNKNOWN:** whether the 32 MB `shelf` kit's clips (42 x ~0.75 MB) should be uploaded individually or batched; individually is the only option without a server change. + +## Existing code to reuse (verified present in the repo) + +| need | where | +|---|---| +| audio player (play/seek/volume, encrypted download) | `src/app/components/message/content/AudioContent.tsx` (210 lines) | +| video, image, generic file | `.../content/VideoContent.tsx`, `ImageContent.tsx`, `FileContent.tsx` | +| full-screen image viewer | `src/app/components/image-viewer/ImageViewer.tsx` | +| media dispatch by msgtype | `src/app/components/RenderMessageContent.tsx`, `message/MsgTypeRenderers.tsx` | +| upload + progress | `state/upload.ts`, `components/upload-card`, `upload-board` | +| event content builders | `features/room/msgContent.ts` | +| thread plumbing | `features/room/message/Message.tsx`, `RoomTimeline.tsx`, `RoomInput.tsx` (thread relation on send) | +| room-scoped file/pin precedent | `features/room/room-pin-menu/RoomPinMenu.tsx` | + +Nothing named "gallery" exists as a feature; `AudioContent` takes its controls as render props (`renderMediaControl`), so it can be rehosted in a row without a fork. **UNKNOWN:** whether `AudioContent` can be rendered outside a timeline event context (it needs only `url`, `info`, `mimeType`, `encInfo`, from the event content, so probably yes). + +## First slice (what to build first) + +Goal: Aqua can open a room, see an album of clips, play them, mark them, export. Estimates are rough lines of TypeScript, tests excluded. + +1. **Read-only album list + album view** (~250 lines): derive albums from threads (marker or all-threads, see UNKNOWN), list/grid, rehost `AudioContent` and `ImageContent`. No upload, no marks. One new route/panel plus entry in the room header. This alone replaces "scroll the timeline". +2. **Local marks + export** (~200 lines): IndexedDB store keyed (room, file event id), three buttons and a reason field, `verdict.json`/`verdict.md` download. This is Aqua's actual workflow and needs no room writes. +3. **Upload into an album** (~150 lines): thread-related send through the existing upload atoms, "new album". +4. **Publish marks as room events** (~120 lines): `org.gamecult.file_mark`, read-back merge of other people's marks. + +Each is its own PR. Slices 1+2 serve the first use case; 3 and 4 follow once someone other than the listener needs to see the result. + +## Open questions for the Meta + +- Album = thread with a marker, or any thread? (above) +- Marks private-by-default with explicit share, or shared by default? (above: private) +- Is one flat album level enough, or do albums nest under a room's space structure? Proposed: flat, one level, per room. -- 2.47.3 From 089dcc86e11ad99aa635fe125c8c8ed9a0759ffd Mon Sep 17 00:00:00 2001 From: Void Date: Sun, 8 Nov 2026 07:56:36 +0000 Subject: [PATCH 2/4] docs: file zone is one room, one thread per Mind; marks are thread replies Co-Authored-By: Claude Sonnet 5.5 --- docs/file-zone.md | 48 +++++++++++++++++++++++------------------------ 1 file changed, 23 insertions(+), 25 deletions(-) diff --git a/docs/file-zone.md b/docs/file-zone.md index eeca817a..ad0d3474 100644 --- a/docs/file-zone.md +++ b/docs/file-zone.md @@ -6,32 +6,31 @@ Source: Meta ruling 2026-10-31, eureka mind `ratatoskr:ruling:file-zone-looks-li ## What it is -A file-sharing space beside the room timeline, not a view of it. A room's files appear as **albums**: a browsable grid or list with title, item count and date, opening into the files, with inline players and previews. The Matrix room (and its threads) is the storage and the fallback: other clients see ordinary messages and threads, and never need to know the zone exists. No homeserver change. +A file-sharing space beside the room timeline, not a view of it. The zone is **one ordinary Matrix room with threaded discussions, one thread per Mind**. The UI presents each thread as an **album** (title, item count, date) and each post in it as a **file** with an inline player or preview. Matrix is the storage and the fallback: another client sees a normal room with threads and never needs the zone. No homeserver change. -First use (Aqua): listen to audio in place, mark each clip keep / fix / drop with a one-line reason, export the marks as a file. See `/opt/commons/handoff/aqua-listen-kits-20261108/` (static `listen.html` kits that emit `verdict.json` + `verdict.md`); the zone replaces emailing that page around. +First use (Aqua): listen to audio in place, mark each clip keep / fix / drop with a one-line reason, export the marks as a file. See `/opt/commons/handoff/aqua-listen-kits-20261108/` (static `listen.html` kits that emit `verdict.json` + `verdict.md`); the zone replaces sending that page around. ## Matrix event shapes (stock events only) -- **File** = an ordinary `m.room.message` with `msgtype` `m.audio` / `m.image` / `m.video` / `m.file`, uploaded to the media repo. The existing builders in `src/app/features/room/msgContent.ts` already produce these (`getAudioMsgContent`, `getImageMsgContent`, `getVideoMsgContent`, `getFileMsgContent`). Unchanged. -- **Album** = a thread. The root is a text `m.room.message` whose `body` is the title (`m.text`), and files are posted with `m.relates_to: { rel_type: "m.thread", event_id: }`. Other clients see a normal thread; its count and last-event time come from the SDK's thread summary (`unsigned.m.relations.m.thread`: `count`, `latest_event`). Album date = root `origin_server_ts`. Cover = first image child, if any. - - Why a thread and not a custom state event or a space: needs no power-level or server change, degrades to something readable, and the SDK already tracks it. Cost: any member can add to it; an album is only as structured as its root. -- **Loose files** (no thread) fall into an implicit "Unsorted" album, newest first. -- **Custom marker on the root** so the zone can tell albums from chat threads: an extra content key, e.g. `org.gamecult.album: { v: 1 }`. Unknown keys are preserved by servers and ignored by other clients. **UNKNOWN:** whether to show every thread as an album instead and skip the marker; the marker is cleaner but is the one bit of non-stock shape. -- **Edits/redaction**: renaming = `m.replace` on the root; deleting a file = redact the child. Stock. +- **Zone** = one room, chosen by the client (**UNKNOWN:** how the client knows which room is the zone: a room-account-data flag, a `m.room.create` type, or a configured room id in `config.json`; config is simplest). +- **Album** = a thread root: a text `m.room.message` posted by a Mind, `body` = the album title (the Mind's name, e.g. `void`). One thread per Mind, owned by authorship (the root's sender). **UNKNOWN:** whether a Mind may open more than one (e.g. per kit); the shape allows it with no change, the zone would then list several albums per author. Count and last-activity come from the SDK's thread summary (`unsigned.m.relations.m.thread`); album date = root `origin_server_ts`. Rename = `m.replace` on the root. +- **File** = a post in the thread: an `m.room.message` with `msgtype` `m.audio` / `m.image` / `m.video` / `m.file` and `m.relates_to: { rel_type: "m.thread", event_id: , is_falling_back: true, "m.in_reply_to": { event_id: } }`. The builders in `src/app/features/room/msgContent.ts` already make the content. Delete = redact. +- **Album vs chat thread**: with a single-purpose room, every thread is an album. No marker key is needed. A non-media reply in a thread (chat, a mark) is not a file and is not listed as one. +- **Cover** = first image file in the thread, if any. Loose room messages outside any thread are not shown in the zone; they remain in the timeline. ## Marks (keep / fix / drop + one line) -Two options; recommend a hybrid. +A mark is a **thread reply to the file it judges**, so it lives in the same thread, is readable in any client, and needs no custom event type: -1. **Local only**: marks live in the client (IndexedDB), never sent. Zero room noise, private, lost with the browser. Fine for a lone listener, wrong for "Aqua hears it, others see the verdict". -2. **Room events**: one small event per mark, related to the file event. Recommended shape: `m.room.message` `m.notice` in the same thread is visible to everyone but is chat spam. Better is an event type of our own, `org.gamecult.file_mark`, content `{ "m.relates_to": { "rel_type": "m.reference", "event_id": }, "verdict": "keep"|"fix"|"drop", "reason": "" }`. Latest mark per (sender, file) wins; changing one means sending a new event. Other clients ignore unknown event types, so no spam, and the room history stays the store. - - Alternative: `m.annotation` reactions with a key of `keep`/`fix`/`drop`, which stock clients already render; but a reaction key has no room for the reason line. Rejected for that. - - **UNKNOWN:** do the room's power levels allow custom event types for ordinary members (default `events_default` covers them); do synapse/tuwunel/conduwuit servers send unknown types through sync to us; encrypted rooms carry them as `m.room.encrypted` like any other (fine, expected). -3. **Hybrid (proposed)**: write marks locally first (instant, offline), publish to the room only on explicit "share marks". Keeps the player responsive and the room quiet. +- `m.room.message`, `msgtype` `m.text`, `body` = `keep: ` (or `fix:` / `drop:`), `m.relates_to` = `{ rel_type: "m.thread", event_id: , "m.in_reply_to": { event_id: } }`. +- Plus one structured key for the zone to read without parsing prose: `org.gamecult.mark: { verdict: "keep"|"fix"|"drop", reason: "", target: }`. Unknown keys survive servers and are ignored by other clients; the `body` is the fallback if the key is stripped or absent. **UNKNOWN:** whether to also parse a bare `keep: ...` reply from a client that cannot send the key; recommend yes, as a tolerant reader of `body`. +- Latest mark by a given sender on a given file wins; changing a mark is a new reply (or an `m.replace` of the old one; prefer replace so the thread does not grow). The zone shows the latest per sender, and everyone's marks are visible on the file. +- **Why replies and not `m.annotation`**: an annotation key is a short string with no room for the reason line, and stock clients show reactions as chips with no text. Annotations stay available for a lightweight unreasoned ack, if wanted (**UNKNOWN**, not recommended in v1). +- **Local marks**: the client keeps an unsent mark draft in IndexedDB so playback and marking stay instant and survive a dropped connection; "send" publishes the reply. Marks are visible to the room once sent (the room is the shared store). **UNKNOWN:** whether Aqua wants a "private until I export" mode; if so it is local-only and the draft never publishes. ## Export -Client-side only. "Export marks" builds a file from the marks the viewer can see for one album and either downloads it or uploads it into the album as another file: +Client-side only. "Export marks" builds a file from the mark replies in one album (the viewer's own, or everyone's) and either downloads it or uploads it into the album as another file: - `verdict.json`: `{ album, exported_at, marks: [{ file, sha256?, name, verdict, reason, by }] }` - `verdict.md`: human table of the same. @@ -51,8 +50,8 @@ Matches the shape the `aqua-listen` kits already emit (`verdict.json` + `verdict Reuse the composer's pipeline, pointed at an album instead of the main timeline: - `createUploadAtom` / `useBindUploadAtom` (`src/app/state/upload.ts`), `UploadCard`, `upload-board` for progress. -- On success, the msgContent builder produces the event; the zone adds `m.relates_to` thread to the album root before sending (the composer already does this for thread replies, `RoomInput.tsx`). -- "New album" = send the root text event first (with the marker), then upload into it. Multi-file drop = N events, sent in order. +- On success, the msgContent builder produces the event; the zone adds the thread relation to the album root before sending (the composer already does this for thread replies, `RoomInput.tsx`). +- "New album" = send the root text event, then upload into its thread. Multi-file drop = N events, sent in order. - Size limit from `MediaConfigLoader` (server `m.upload.size`). **UNKNOWN:** whether the 32 MB `shelf` kit's clips (42 x ~0.75 MB) should be uploaded individually or batched; individually is the only option without a server change. ## Existing code to reuse (verified present in the repo) @@ -72,17 +71,16 @@ Nothing named "gallery" exists as a feature; `AudioContent` takes its controls a ## First slice (what to build first) -Goal: Aqua can open a room, see an album of clips, play them, mark them, export. Estimates are rough lines of TypeScript, tests excluded. +Rough lines of TypeScript, tests excluded. -1. **Read-only album list + album view** (~250 lines): derive albums from threads (marker or all-threads, see UNKNOWN), list/grid, rehost `AudioContent` and `ImageContent`. No upload, no marks. One new route/panel plus entry in the room header. This alone replaces "scroll the timeline". -2. **Local marks + export** (~200 lines): IndexedDB store keyed (room, file event id), three buttons and a reason field, `verdict.json`/`verdict.md` download. This is Aqua's actual workflow and needs no room writes. +1. **Read-only albums + album view** (~250 lines): list the room's thread roots as albums (grouped or filterable by author = Mind), open one to list its file posts, rehost `AudioContent` and `ImageContent`. One route/panel plus a header entry. Depends on the thread list the SDK already keeps. +2. **Marks + export** (~220 lines): three buttons and a reason field per audio row, sent as the thread reply above (with an IndexedDB draft), latest-per-sender merge on read, `verdict.json`/`verdict.md` download. This is Aqua's workflow end to end. 3. **Upload into an album** (~150 lines): thread-related send through the existing upload atoms, "new album". -4. **Publish marks as room events** (~120 lines): `org.gamecult.file_mark`, read-back merge of other people's marks. -Each is its own PR. Slices 1+2 serve the first use case; 3 and 4 follow once someone other than the listener needs to see the result. +Slices 1+2 serve the first use case. Each is its own PR. Because marks are ordinary thread replies, there is no separate "publish" slice. ## Open questions for the Meta -- Album = thread with a marker, or any thread? (above) -- Marks private-by-default with explicit share, or shared by default? (above: private) -- Is one flat album level enough, or do albums nest under a room's space structure? Proposed: flat, one level, per room. +- How the client identifies the zone room (config id recommended). +- Edit-in-place (`m.replace`) versus new reply for changing a mark (replace recommended). +- Whether a private, never-published marking mode is wanted. -- 2.47.3 From 002e0d608bd6e7f26c43f8cabbb2adf6e7103518 Mon Sep 17 00:00:00 2001 From: Void Date: Sun, 8 Nov 2026 07:57:30 +0000 Subject: [PATCH 3/4] docs: file zone marks and export follow aqua.listen.verdict.v1 Co-Authored-By: Claude Sonnet 5.5 --- docs/file-zone.md | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/docs/file-zone.md b/docs/file-zone.md index ad0d3474..3679eb13 100644 --- a/docs/file-zone.md +++ b/docs/file-zone.md @@ -23,19 +23,37 @@ First use (Aqua): listen to audio in place, mark each clip keep / fix / drop wit A mark is a **thread reply to the file it judges**, so it lives in the same thread, is readable in any client, and needs no custom event type: - `m.room.message`, `msgtype` `m.text`, `body` = `keep: ` (or `fix:` / `drop:`), `m.relates_to` = `{ rel_type: "m.thread", event_id: , "m.in_reply_to": { event_id: } }`. -- Plus one structured key for the zone to read without parsing prose: `org.gamecult.mark: { verdict: "keep"|"fix"|"drop", reason: "", target: }`. Unknown keys survive servers and are ignored by other clients; the `body` is the fallback if the key is stripped or absent. **UNKNOWN:** whether to also parse a bare `keep: ...` reply from a client that cannot send the key; recommend yes, as a tolerant reader of `body`. +- Plus one structured key for the zone to read without parsing prose: `org.gamecult.mark: { clip: "", verdict: "keep"|"fix"|"drop", reason: "", landed: 1-5|null, device: "headphones"|"speakers"|null, target: }`. `clip` is the kit's clip id (see Export); `landed` (how well the sound met its intended feel) and `device` are optional and null when unanswered. The `body` stays `verdict: reason` for other clients. Unknown keys survive servers and are ignored by other clients; the `body` is the fallback if the key is stripped or absent. **UNKNOWN:** whether to also parse a bare `keep: ...` reply from a client that cannot send the key; recommend yes, as a tolerant reader of `body`. - Latest mark by a given sender on a given file wins; changing a mark is a new reply (or an `m.replace` of the old one; prefer replace so the thread does not grow). The zone shows the latest per sender, and everyone's marks are visible on the file. - **Why replies and not `m.annotation`**: an annotation key is a short string with no room for the reason line, and stock clients show reactions as chips with no text. Annotations stay available for a lightweight unreasoned ack, if wanted (**UNKNOWN**, not recommended in v1). - **Local marks**: the client keeps an unsent mark draft in IndexedDB so playback and marking stay instant and survive a dropped connection; "send" publishes the reply. Marks are visible to the room once sent (the room is the shared store). **UNKNOWN:** whether Aqua wants a "private until I export" mode; if so it is local-only and the draft never publishes. ## Export -Client-side only. "Export marks" builds a file from the mark replies in one album (the viewer's own, or everyone's) and either downloads it or uploads it into the album as another file: +Client-side only. "Export" builds Aqua's verdict file from the mark replies in one album (one listener's marks per export; the sender is the `listener`) and either downloads it or uploads it into the album. Two files, as the kits write them: `verdict.json` and `verdict.md`. -- `verdict.json`: `{ album, exported_at, marks: [{ file, sha256?, name, verdict, reason, by }] }` -- `verdict.md`: human table of the same. +`verdict.json` is schema **`aqua.listen.verdict.v1`**, field for field what `buildVerdict` in the kit (`dings/listen.html`, section `buildVerdict`) emits, so Aqua's tooling reads it unchanged: -Matches the shape the `aqua-listen` kits already emit (`verdict.json` + `verdict.md`), so downstream tooling does not change. **UNKNOWN:** exact field names of the kits' `verdict.json`; read `tools/aqua-listen` in GameCult/AquaSynth (PR #16) before fixing the schema. Per ruling hygiene, JSON here is a xenos-boundary export, not state. +``` +{ schema: "aqua.listen.verdict.v1", + kit: { id, title }, // album root: id = root event id (or the kit id if the upload carried one), title = root body + exportedAt, // ISO time of export + seed, consent, // kit-only: zone writes seed: 0 (or omits), consent: true for the sender's own export (see UNKNOWN) + listener, // sender display name + device, // headphones|speakers|null, the sender's most-used mark device, or null + notes, // free text; zone leaves "" in v1 + summary: { total, keep, fix, drop, unanswered }, + clips: [{ position, id, title, group, feel, file, pair, slot, verdict, reason, landed, device }], + pairs: [{ a, b, preferred }] } // empty array when the album has no pairs +``` + +- `clips` has one row per file post in the album, in thread order (`position` = 1-based). `verdict` null and `reason` "" where the sender left no mark; those count as `unanswered`. `landed` is 1-5 or null; `device` is `headphones`, `speakers` or null (a mark's own value; per-clip, not the file-level `device`). +- `pairs`, `seed`, `consent` are optional for the zone: `pairs: []`, no `slot`/`pair` (null) unless the clip metadata supplies them. +- **Where clip metadata comes from.** A mark gets `id`, `verdict`, `reason`, `landed`, `device` from the reply. `title` = file post `body`; `file` = the post's `filename`/`body`. `group`, `feel`, `pair`, `slot` are kit data that Matrix has no slot for: carry them on the file post as one extra key `org.gamecult.clip: { id, group, feel, pair, slot }` written by whoever uploads the kit (a small uploader script, **UNKNOWN**, not in the first slice; without it those columns export as ""/null and the id falls back to the filename stem). +- `verdict.md` follows `verdictMarkdown` from the same file: header line, count line, the clip table (`# | clip | group | intended feel | verdict | landed (1-5) | device | reason`), pairs table when `pairs` is nonempty, notes. +- To avoid drift, the exporter should be a port of `buildVerdict`/`verdictMarkdown` with the schema string and field order pinned by a test that compares against a fixture produced by the kit. **UNKNOWN:** whether to share code (the kit's `logic.cjs`) rather than port; recommend port, ~80 lines, because the kit is a standalone page. +- **UNKNOWN:** `consent` is a kit-side checkbox before listening. The zone has no such step; recommend writing `true` only if the sender ticks a one-time "include my name in exports" toggle, else `false` and `listener: ""`. +- JSON here is a xenos-boundary export, not state; the room events are the state. ## Mobile-first layout @@ -74,7 +92,7 @@ Nothing named "gallery" exists as a feature; `AudioContent` takes its controls a Rough lines of TypeScript, tests excluded. 1. **Read-only albums + album view** (~250 lines): list the room's thread roots as albums (grouped or filterable by author = Mind), open one to list its file posts, rehost `AudioContent` and `ImageContent`. One route/panel plus a header entry. Depends on the thread list the SDK already keeps. -2. **Marks + export** (~220 lines): three buttons and a reason field per audio row, sent as the thread reply above (with an IndexedDB draft), latest-per-sender merge on read, `verdict.json`/`verdict.md` download. This is Aqua's workflow end to end. +2. **Marks + export** (~260 lines, with the schema port): three buttons, a landed 1-5 chip row, a headphones/speakers toggle and a reason field per audio row, sent as the thread reply above (with an IndexedDB draft), latest-per-sender merge on read, `verdict.json`/`verdict.md` download. This is Aqua's workflow end to end. 3. **Upload into an album** (~150 lines): thread-related send through the existing upload atoms, "new album". Slices 1+2 serve the first use case. Each is its own PR. Because marks are ordinary thread replies, there is no separate "publish" slice. -- 2.47.3 From ba592523d8e119813c6b01c21e2401fb58e92c47 Mon Sep 17 00:00:00 2001 From: Void Date: Sun, 8 Nov 2026 08:05:29 +0000 Subject: [PATCH 4/4] docs: file zone marks are verdict and landed reactions plus optional reply Co-Authored-By: Claude Sonnet 5.5 --- docs/file-zone.md | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/docs/file-zone.md b/docs/file-zone.md index 3679eb13..01b5a4c5 100644 --- a/docs/file-zone.md +++ b/docs/file-zone.md @@ -18,19 +18,21 @@ First use (Aqua): listen to audio in place, mark each clip keep / fix / drop wit - **Album vs chat thread**: with a single-purpose room, every thread is an album. No marker key is needed. A non-media reply in a thread (chat, a mark) is not a file and is not listed as one. - **Cover** = first image file in the thread, if any. Loose room messages outside any thread are not shown in the zone; they remain in the timeline. -## Marks (keep / fix / drop + one line) +## Marks (reactions, plus an optional reply) -A mark is a **thread reply to the file it judges**, so it lives in the same thread, is readable in any client, and needs no custom event type: +A mark is stock Matrix, so every client shows it. Two reaction sets on the **file post**, and one optional reply: -- `m.room.message`, `msgtype` `m.text`, `body` = `keep: ` (or `fix:` / `drop:`), `m.relates_to` = `{ rel_type: "m.thread", event_id: , "m.in_reply_to": { event_id: } }`. -- Plus one structured key for the zone to read without parsing prose: `org.gamecult.mark: { clip: "", verdict: "keep"|"fix"|"drop", reason: "", landed: 1-5|null, device: "headphones"|"speakers"|null, target: }`. `clip` is the kit's clip id (see Export); `landed` (how well the sound met its intended feel) and `device` are optional and null when unanswered. The `body` stays `verdict: reason` for other clients. Unknown keys survive servers and are ignored by other clients; the `body` is the fallback if the key is stripped or absent. **UNKNOWN:** whether to also parse a bare `keep: ...` reply from a client that cannot send the key; recommend yes, as a tolerant reader of `body`. -- Latest mark by a given sender on a given file wins; changing a mark is a new reply (or an `m.replace` of the old one; prefer replace so the thread does not grow). The zone shows the latest per sender, and everyone's marks are visible on the file. -- **Why replies and not `m.annotation`**: an annotation key is a short string with no room for the reason line, and stock clients show reactions as chips with no text. Annotations stay available for a lightweight unreasoned ack, if wanted (**UNKNOWN**, not recommended in v1). -- **Local marks**: the client keeps an unsent mark draft in IndexedDB so playback and marking stay instant and survive a dropped connection; "send" publishes the reply. Marks are visible to the room once sent (the room is the shared store). **UNKNOWN:** whether Aqua wants a "private until I export" mode; if so it is local-only and the draft never publishes. +- **Verdict** = an `m.annotation` reaction (`m.reaction` event, `m.relates_to: { rel_type: "m.annotation", event_id: , key }`) with key **👍 keep**, **🔧 fix**, **🗑 drop**. One per person per file: choosing another replaces the old one (the client redacts the sender's previous verdict reaction, then sends the new one); tapping the active one clears it. +- **Landed** = a second reaction set, keys **1️⃣ 2️⃣ 3️⃣ 4️⃣ 5️⃣** (keycap digits, 1-5). Also one per person per file, same replace rule. The two sets are independent: a file can have a verdict, a landed score, both or neither. +- **Reason** = an ordinary thread reply under the file: `m.room.message` `m.text`, `m.relates_to: { rel_type: "m.thread", event_id: , "m.in_reply_to": { event_id: } }`. Optional. **Keep needs none**; the UI prompts for one mainly after fix or drop. The latest such reply by the sender under that file is the reason (edits via `m.replace` count). No special key: the reply text is the reason, so nothing is lost if another client wrote it. +- **No private mode.** Marks are room events the moment they are tapped; there is no local-only draft and no "Share" step. Offline taps queue in the SDK's normal send queue. +- Who marked what is read from the reactions' senders, so each listener is distinguishable and the album can show tallies per file. +- Why reactions: one tap, readable in any client, no custom event type, and they can be changed with no history growth beyond a redaction. Reaction keys carry no text, hence the separate reply for the reason. +- **UNKNOWN:** emoji variation selectors. 🗑 and 🔧 and the keycaps may arrive with or without U+FE0F from different clients; match by normalizing (strip U+FE0F) before comparing keys. Also **UNKNOWN:** servers' per-event limits on distinct reaction keys; a handful per file is far below any default. ## Export -Client-side only. "Export" builds Aqua's verdict file from the mark replies in one album (one listener's marks per export; the sender is the `listener`) and either downloads it or uploads it into the album. Two files, as the kits write them: `verdict.json` and `verdict.md`. +Client-side only. "Export" builds Aqua's verdict file from the verdict and landed reactions and the reply under each file in one album (one listener's marks per export; that sender is the `listener`) and either downloads it or uploads it into the album. Two files, as the kits write them: `verdict.json` and `verdict.md`. `verdict.json` is schema **`aqua.listen.verdict.v1`**, field for field what `buildVerdict` in the kit (`dings/listen.html`, section `buildVerdict`) emits, so Aqua's tooling reads it unchanged: @@ -47,9 +49,9 @@ Client-side only. "Export" builds Aqua's verdict file from the mark replies in o pairs: [{ a, b, preferred }] } // empty array when the album has no pairs ``` -- `clips` has one row per file post in the album, in thread order (`position` = 1-based). `verdict` null and `reason` "" where the sender left no mark; those count as `unanswered`. `landed` is 1-5 or null; `device` is `headphones`, `speakers` or null (a mark's own value; per-clip, not the file-level `device`). +- `clips` has one row per file post in the album, in thread order (`position` = 1-based). The join per file, for the exporting listener: `verdict` = their 👍/🔧/🗑 reaction (keep/fix/drop) or null; `landed` = their keycap reaction (1-5) or null; `reason` = text of their latest reply under the file, or "". A file with no verdict reaction counts as `unanswered` even if it has a reply. `device` per clip is null (reactions cannot carry it); the top-level `device` (headphones|speakers|null) is asked once in the export dialog. - `pairs`, `seed`, `consent` are optional for the zone: `pairs: []`, no `slot`/`pair` (null) unless the clip metadata supplies them. -- **Where clip metadata comes from.** A mark gets `id`, `verdict`, `reason`, `landed`, `device` from the reply. `title` = file post `body`; `file` = the post's `filename`/`body`. `group`, `feel`, `pair`, `slot` are kit data that Matrix has no slot for: carry them on the file post as one extra key `org.gamecult.clip: { id, group, feel, pair, slot }` written by whoever uploads the kit (a small uploader script, **UNKNOWN**, not in the first slice; without it those columns export as ""/null and the id falls back to the filename stem). +- **Where clip metadata comes from.** `id` is the clip id from `org.gamecult.clip`, else the filename stem. `title` = file post `body`; `file` = the post's `filename`/`body`. `group`, `feel`, `pair`, `slot` are kit data that Matrix has no slot for: carry them on the file post as one extra key `org.gamecult.clip: { id, group, feel, pair, slot }` written by whoever uploads the kit (a small uploader script, **UNKNOWN**, not in the first slice; without it those columns export as ""/null and the id falls back to the filename stem). - `verdict.md` follows `verdictMarkdown` from the same file: header line, count line, the clip table (`# | clip | group | intended feel | verdict | landed (1-5) | device | reason`), pairs table when `pairs` is nonempty, notes. - To avoid drift, the exporter should be a port of `buildVerdict`/`verdictMarkdown` with the schema string and field order pinned by a test that compares against a fixture produced by the kit. **UNKNOWN:** whether to share code (the kit's `logic.cjs`) rather than port; recommend port, ~80 lines, because the kit is a standalone page. - **UNKNOWN:** `consent` is a kit-side checkbox before listening. The zone has no such step; recommend writing `true` only if the sender ticks a one-time "include my name in exports" toggle, else `false` and `listener: ""`. @@ -60,7 +62,7 @@ Client-side only. "Export" builds Aqua's verdict file from the mark replies in o - Phone width is the design width; desktop adds columns. - Level 1, album list: one column, each row = cover/icon, title, "N files", date. Tap opens. - Level 2, album: list by default (title, duration/size), grid toggle for images. Audio rows play in place, one at a time (starting a clip stops the last), a persistent mini-player at the bottom with seek. -- Mark controls on each audio row: three large tap targets (keep / fix / drop) and a one-line text field that appears after a choice. Thumb-reachable, no modal. +- Mark controls on each audio row: three large tap targets (👍 keep / 🔧 fix / 🗑 drop) with tallies, a row of five "landed" chips (1-5), and after fix or drop an inline one-line reason field (skippable). Thumb-reachable, no modal. - Back is one level up; the room timeline stays one tap away (the zone is a sibling view, not a replacement). **UNKNOWN:** where the entry lives in the room header; check `RoomViewHeader.tsx`, `Room.tsx` for how the member drawer and pin menu attach. ## Upload @@ -92,7 +94,7 @@ Nothing named "gallery" exists as a feature; `AudioContent` takes its controls a Rough lines of TypeScript, tests excluded. 1. **Read-only albums + album view** (~250 lines): list the room's thread roots as albums (grouped or filterable by author = Mind), open one to list its file posts, rehost `AudioContent` and `ImageContent`. One route/panel plus a header entry. Depends on the thread list the SDK already keeps. -2. **Marks + export** (~260 lines, with the schema port): three buttons, a landed 1-5 chip row, a headphones/speakers toggle and a reason field per audio row, sent as the thread reply above (with an IndexedDB draft), latest-per-sender merge on read, `verdict.json`/`verdict.md` download. This is Aqua's workflow end to end. +2. **Marks + export** (~240 lines, with the schema port): verdict and landed reaction sets with the replace-one rule, optional reason reply, per-file tallies, the join into `aqua.listen.verdict.v1` and `verdict.json`/`verdict.md` download. This is Aqua's workflow end to end. 3. **Upload into an album** (~150 lines): thread-related send through the existing upload atoms, "new album". Slices 1+2 serve the first use case. Each is its own PR. Because marks are ordinary thread replies, there is no separate "publish" slice. @@ -100,5 +102,5 @@ Slices 1+2 serve the first use case. Each is its own PR. Because marks are ordin ## Open questions for the Meta - How the client identifies the zone room (config id recommended). -- Edit-in-place (`m.replace`) versus new reply for changing a mark (replace recommended). -- Whether a private, never-published marking mode is wanted. +- Whether exports are per listener (proposed) or a merged all-listeners file. +- Where `device` comes from, now that reactions cannot carry it (export dialog, proposed). -- 2.47.3