diff --git a/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md similarity index 94% rename from CODE_OF_CONDUCT.md rename to .github/CODE_OF_CONDUCT.md index b95315876..d07895ba7 100644 --- a/CODE_OF_CONDUCT.md +++ b/.github/CODE_OF_CONDUCT.md @@ -4,7 +4,7 @@ This applies everywhere Patternflow people meet: this repository (issues, pull r ## How we treat each other -The rule was written before there was anything to build, as a short poem, and it is the sixth section of the [manifesto](docs/manifesto.md#6-how-we-treat-each-other). In full: +The rule was written before there was anything to build, as a short poem, and it is the sixth section of the [manifesto](../docs/manifesto.md#6-how-we-treat-each-other). In full: > 아무것도 몰라도, 하고자 한다면 만들 수 있습니다. > 도와주는 게 당연합니다. 고마운 게 당연합니다. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index e67ad6201..cdcde0f89 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -13,7 +13,7 @@ contact_links: url: https://discord.gg/Vr9QtsxeTk about: New here? Join the server, then jump into the channels above. - name: Getting help — every venue in one table - url: https://github.com/engmung/Patternflow/blob/main/SUPPORT.md + url: https://github.com/engmung/Patternflow/blob/main/.github/SUPPORT.md about: Where to ask what, including the venue that works where Discord is blocked. - name: Contributing guide url: https://github.com/engmung/Patternflow/blob/main/CONTRIBUTING.md diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 776794203..24ab53dcb 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -9,5 +9,10 @@ ## Tested +## Which row + + --- - [ ] Touches `web/` → it builds (`npm run build`) +- [ ] Touches `firmware/` → every composition builds (`./firmware/bundles/build.sh all`), and the core still names no feature (`python firmware/toolchain/check_boundaries.py`) +- [ ] Touches a markdown file → its relative links resolve (`python3 .github/scripts/check_links.py`) diff --git a/SECURITY.md b/.github/SECURITY.md similarity index 57% rename from SECURITY.md rename to .github/SECURITY.md index 98915fe88..772748efa 100644 --- a/SECURITY.md +++ b/.github/SECURITY.md @@ -7,13 +7,13 @@ Please report privately through [GitHub's report form](https://github.com/engmun ## What is in scope - **The community site** ([community.patternflow.work](https://community.patternflow.work)): accounts and sessions, uploads, the sandboxed iframe that runs published patterns, moderation. -- **The browser flasher and update handoff** ([patternflow.work/flash](https://patternflow.work/flash), `/update`): the firmware images it serves and where they come from. +- **The browser flasher and update handoff** (the **Flash Patternflow** button on [patternflow.work/pattern](https://patternflow.work/pattern) and in the Play guide, which installs the images under `/flash/`; and [`/update`](https://patternflow.work/update)): the firmware images they serve and where they come from. - **The build worker** that turns a pattern into a `.pfm` module on the community host. - **Published firmware images.** They must not carry credentials. Three early releases did carry the maintainer's Wi-Fi credentials, which is why the release workflow now refuses an image built with `patternflow_secrets.h`. If you find one that slipped through, that is a report. ## What is a documented trust model, not a vulnerability -The device's HTTP server on the LAN has **no authentication**: anyone on the same network can call any `/api/*` route, including `POST /update`. This is deliberate and documented in [docs/rest-api.md](docs/rest-api.md#transport) — the panel is an instrument on a home or venue network, with the same posture as ArduinoOTA's default. `PF_WEBUPDATE_ALWAYS_ARMED 0` is the one lever that narrows it. Reports that amount to "the LAN API is open" will be closed with a pointer here; reports of a way to reach that API from *outside* the LAN, or from a web page on another origin, are in scope. +The device's HTTP server on the LAN has **no authentication**: anyone on the same network can call any `/api/*` route, including `POST /update`. The same holds on the panel's own hotspot (`patternflow-xxxx`, raised when it finds no network it knows), whose password is the documented default `patternflow` until it is changed on the Wi-Fi page. This is deliberate and documented in [docs/rest-api.md](../docs/rest-api.md#transport) — the panel is an instrument on a home or venue network, with the same posture as ArduinoOTA's default. `PF_WEBUPDATE_ALWAYS_ARMED 0` is the one lever that narrows `/update`; the hotspot takes its own password, or is switched off, on the Wi-Fi page. Reports that amount to "the LAN API is open" will be closed with a pointer here; reports of a way to reach that API from *outside* the LAN, or from a web page on another origin, are in scope. ## Supported versions diff --git a/SUPPORT.md b/.github/SUPPORT.md similarity index 51% rename from SUPPORT.md rename to .github/SUPPORT.md index b5f6e1121..e9dc5ab2b 100644 --- a/SUPPORT.md +++ b/.github/SUPPORT.md @@ -6,12 +6,22 @@ One table for "where do I ask". Issues are for things that change this repositor | :--- | :--- | | Learn to build, play, make patterns for one or give it sound and MIDI | [The guide](https://patternflow.work/guide) — each step on a 3D Patternflow in the browser. A step that didn't get you through has a **Stuck here?** link, which opens an issue with that step filled in | | Get unstuck building, soldering or flashing | [Discord → hardware-help](https://discord.com/channels/1497757947827327067/1499907910707187833) — photos and quick back-and-forth | +| Flash the DevKit for the first time, or get the panel onto Wi-Fi | [The Play guide's Flash chapter](https://patternflow.work/guide/play#flash), or [`BUILD_GUIDE.md` §8](../BUILD_GUIDE.md#8-firmware) in writing. The short version is below the table | | Show a pattern I made, or get feedback on one | Publish it from the [Pattern Lab](https://patternflow.work/pattern-lab) to the [Community](https://community.patternflow.work/community), then [Discord → patterns](https://discord.com/channels/1497757947827327067/1499908302962819236) | | Put my finished build on the [build map](https://patternflow.work/inside) | [Share your build](https://github.com/engmung/Patternflow/issues/new?template=share_build.yml) — or post it in Discord | | Talk about where the project should go, or find people working on the same direction | [Discord](https://discord.gg/Vr9QtsxeTk) — the dev-log and the project channels | | Report a bug, a wrong or missing doc, or propose a feature | [New issue](https://github.com/engmung/Patternflow/issues/new/choose) — pick the template | | Report a security problem | [SECURITY.md](SECURITY.md) — privately, not as an issue | -| Contribute code, hardware files or docs | [CONTRIBUTING.md](CONTRIBUTING.md) | +| Contribute code, hardware files or docs | [CONTRIBUTING.md](../CONTRIBUTING.md) | + +**The first flash, in short.** + +1. The DevKit out of the board, on its **left** USB-C port (labelled `USB`), with a cable that carries data — a charge-only cable is the most common reason nothing shows up — in desktop Chrome or Edge. That port is for flashing and data only; the panel is always powered through `J4`, the screw terminal. +2. **Download mode, every first flash:** hold **BOOT**, tap **RST**, let go of BOOT. Then press **Flash Patternflow** (on the [Pattern tab](https://patternflow.work/pattern) under *Got the hardware?*, or in the Play guide) and pick the port (`USB JTAG/serial debug unit`). +3. **The Wi-Fi step usually does not appear** after *Installation complete!* That is expected: press **RST** once, press **Flash Patternflow** again, pick the port, choose **Connect to Wi-Fi**, and after about ten seconds of scanning type your network's name exactly (capitals count; 2.4 GHz only) and its password. +4. **If that will not take,** put the DevKit back in the board and power it: about 15 seconds after it finds no network it knows, the panel raises its own, `patternflow-xxxx` (password `patternflow`; hold **K2** to see the exact name). Not in your phone's list yet? Wait, and refresh the list. Join it (the phone will say the network has no internet, which is true; stay on it), open `http://192.168.4.1/`, and pick your network on the Wi-Fi page. + +A board that already runs Patternflow is updated over Wi-Fi from its console's Update page instead — download mode erases the patterns and saved networks. **If Discord is blocked where you live**, the community site still works for patterns and decks without it, and a bug or a documentation issue here reaches the maintainer the same day. A discussion venue that works everywhere is planned; until it exists, an issue is the fallback for a question too. diff --git a/.github/workflows/drift.yml b/.github/workflows/drift.yml new file mode 100644 index 000000000..c41a676e9 --- /dev/null +++ b/.github/workflows/drift.yml @@ -0,0 +1,70 @@ +name: Drift checks + +# The tree keeps some facts in two places by hand: the BOM and the build +# guide's parts table, a card on /editions and a folder under +# firmware/bundles/. Each check in web/scripts/drift/ compares one such pair +# and prints both sides when they part (docs/rfc-cleanup.md §2.4). check:ci +# runs them too, but Web CI starts only on web/ and hardware/case/. A pull +# request that edits only the BOM, a bundle or the changelog runs no web job, +# and that is the pull request a drift check is for. So this one runs on every +# pull request. +# +# PF_DRIFT_STRICT=1 turns a skip into a failure. At a desk a check skips a side +# it cannot read: a tag the clone lacks, no pdftotext, no gh login. Here every +# side can be read, so a skip means this file is missing something. +# fetch-depth: 0 brings the tags the absolute links point at, and the base +# commit check:human-lock compares with. +on: + pull_request: + push: + branches: [main] + +concurrency: + group: drift-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + issues: read + +jobs: + check: + runs-on: ubuntu-latest + defaults: + run: + working-directory: web + env: + PF_DRIFT_STRICT: "1" + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: web/package-lock.json + + - name: Install dependencies + id: deps + run: npm ci + + # check:bom-schematic reads the schematic's text layer with pdftotext, + # which the runner image does not carry. + - name: Install pdftotext + run: command -v pdftotext || { sudo apt-get update -q && sudo apt-get install -y -q poppler-utils; } + + # check:roadmap asks gh for the state of each issue a roadmap node names. + - name: Drift checks + run: npm run check:drift + env: + GH_TOKEN: ${{ github.token }} + + # Not a word of the maintainer's own writing changes (§2.9). It runs + # after a red drift step too, so one push shows both. + - name: Locked text + if: ${{ !cancelled() && steps.deps.outcome == 'success' && github.event_name == 'pull_request' && hashFiles('web/scripts/human-lock.ts') != '' }} + run: npm run check:human-lock + env: + PF_LOCK_BASE: ${{ github.event.pull_request.base.sha }} diff --git a/.github/workflows/web-ci.yml b/.github/workflows/web-ci.yml index 87717e892..3253a76fc 100644 --- a/.github/workflows/web-ci.yml +++ b/.github/workflows/web-ci.yml @@ -33,16 +33,17 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: npm cache-dependency-path: web/package-lock.json - name: Install dependencies run: npm ci - # Lint is a hard gate: lint errors can't reach main (warnings still pass). + # Lint is a hard gate, and a warning fails it like an error. The cleanup's + # step 3 took the site from twelve warnings to none; one more fails here. - name: Lint - run: npm run lint + run: npm run lint -- --max-warnings=0 # `next build` type-checks only what it bundles; scripts/ and the smoke # suites are outside that, and a type error there used to surface as a diff --git a/AGENTS.md b/AGENTS.md index a66463721..cd03e8be7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,11 +8,12 @@ Patternflow is an open-source hardware instrument: four rotary encoders controll It is standalone (not Eurorack), powered from a 5 V power bank through a screw terminal, and it is not an analog video synthesizer. Sound arrives through the Audio edition (on-board microphone, OSC, MIDI), not the default firmware. ## Repository map -- `docs/` — contracts and long-form docs: `EDITIONS.md` (the firmware seam — read it before touching firmware), `rest-api.md` / `osc-spec.md` / `midi-spec.md` / `audio-ws-spec.md` (wire contracts integrations are built against), `pfst-v2-spec.md` (show files), `panel-compatibility.md`, `RELEASING.md`, `SERVICES.md` (production server ops), the assembly map (`docs/assembly/`), the manifesto, investigations, and media (build-guide images in `docs/build-guide/`). Root-level guides: `BUILD_GUIDE.md` (the **v3.0 board**), `BUILD_GUIDE_v2.md` (legacy v2.x board), `PATTERN_GUIDE.md` (community → deck → Pattern Lab → device), `FEATURE_GUIDE.md` (writing a firmware feature or edition), `AUDIO_GUIDE.md` (sound into the panel). The live roadmap is patternflow.work/journal and the `/roadmap` page. -- `firmware/` — PlatformIO/Arduino code for the ESP32-S3. Main sketch folder: `firmware/patternflow/` with `patternflow.ino`, `config.h` (pin mappings, brightness, limits, LED calibration), `net_config.h` (Wi-Fi / Improv / OTA / self-update defaults and the lane scale — core only; a feature's own tunables live in `features//_config.h`, and settings TUNE a feature, they never add one; per-device secrets in gitignored `patternflow_secrets.h`), and `pattern_registry.h` (function-pointer pattern table; compiles in Origin alone — the rest of the curated set ships as the Basics pack). Shared engine code lives in `firmware/patternflow/src/` (`core_display.h` HUB75 driver, `core_encoders.h`, canvas/color/math/noise helpers, Wi-Fi/OTA/web-console modules, the hotspot in `core_hotspot.h`) — core only: OSC, audio, MQTT, weather, shows, MIDI and the clock all live in `features/`. `firmware/patternflow/abi/` is the frozen host ⇄ module contract. Curated patterns live in `firmware/patternflow/presets/` (`preset_origin.h`, `preset_wave_saw.h`, dated presets), each using its own pattern namespace; user patterns are `.pfm` modules uploaded to the device over Wi-Fi, built from `firmware/modules/` via `firmware/toolchain/build_module.py` (the old `custom1.h`–`custom3.h` root slots are gone). Features attach through `firmware/patternflow/features/` (named `addons/` until 2026-08-30; legacy spellings are shimmed for out-of-tree bundles — see docs/EDITIONS.md) — a directory and a descriptor of function pointers, with the core naming none of them (see its README). `firmware/bundles/` names firmwares built from the same tree (the shelf editions `audio` and `performance`, plus `clock` and `midi`, compositions the tree keeps buildable but does not publish; the default build has no bundle): two files each, saying which features compile in and what the build calls itself; `firmware/bundles/build.sh` builds the default or a named one, `shelf.sh` stages a publishable image. Device console pages are plain HTML in `firmware/patternflow/console/`, spliced into `*_index.h` by `firmware/toolchain/console_pages.py`. Tooling lives in `firmware/toolchain/` (repo-level scripts) and `firmware/patternflow/toolchain/` (PlatformIO `extra_scripts`, which must stay sketch-relative). Also: `firmware/encoder_test/` (standalone encoder diagnostic) and `firmware/CUSTOM_PATTERNS.md` (pattern authoring guide). -- `hardware/` — Hardware designs. `case/` (Blender source in `source/`, STLs by printer bed size, `legacy_v2/` for the v2.x board), `pcb/` (KiCad 10.0 source, Gerbers per revision, schematic PDF), `bom/` (`bom_v3.9.csv` — **the BOM source of truth**; the guide's table is derived from it. `bom_v3.0.csv` is kept for the previous board). -- `web/` — Next.js site at patternflow.work: landing page (`/`, `/pattern`, `/build`, `/inside` are tabs of one view), the interactive guide (`/guide` is a hub over `/guide/build`, `/guide/play`, `/guide/make` and `/guide/audio`, each with a `/ko` twin; one 3D stage in `components/guide/`, the firmware simulator in `lib/guide/`), Pattern Lab (`/pattern-lab`), community (`/community/**`, SQLite + Drizzle + Better Auth, only on the Pi deployment), browser flasher (`/flash`), the edition shelf (`/editions`; `/variants` redirects there), the feature catalogue (`/features`, reels per feature), device update handoff (`/update`), journal, roadmap. `lib/pattern/` is the pattern runtime and annotations, `lib/lab/` the Pattern Lab, `lib/community/` the community, `lib/ai/` the Gemini client. Architecture doc: `web/ARCHITECTURE.md`. The JS presets in `web/src/lib/presets/` are the source of truth for firmware preset headers. -- `tools/` — desktop-side helpers: `patternflow-audio-extension` (the browser audio-react extension — also the authoring source the device's `/audio-in` console page is assembled from), `patternflow-audio-android` (phone capture app), `rtpmidi-probe`, and `case-models` (builds the /build preview's per-case 3D models, `web/public/cases//model.glb`, from the case files in the repo; run by hand, not by the site build). +- `docs/` — contracts and long-form docs: `EDITIONS.md` (the firmware seam — read it before touching firmware), `rest-api.md` / `osc-spec.md` / `midi-spec.md` / `mqtt-spec.md` / `audio-ws-spec.md` (wire contracts integrations are built against, each with a folder of vectors beside it), `contract-vectors.md` (the vectors' format), `pfst-v2-spec.md` (show files, and `pfst-v2-vectors/`, the first vectors), `panel-compatibility.md`, `RELEASING.md`, `SERVICES.md` (production server ops), the assembly map (`docs/assembly/`), the legacy v2.x build guide (`docs/legacy/BUILD_GUIDE_v2.md`), `AUDIO_GUIDE.md` (sound into the panel), the manifesto, investigations, and media (build-guide images in `docs/build-guide/`). The vector folders are `rest-api-vectors/`, `osc-vectors/`, `midi-vectors/`, `mqtt-vectors/` and `audio-ws-vectors/`. Each vector is a JSON file that quotes one sentence of its spec in `says` and gives the steps that show a panel doing it. It follows the spec, not the firmware: where the firmware disagrees, the vector keeps the spec and the disagreement goes to an issue. An edit that rewords a quoted sentence fails its vector until the vector is brought along. Root-level guides: `BUILD_GUIDE.md` (the **v3 board**: v3.9, and v3.0 the same way), `PATTERN_GUIDE.md` (community → deck → Pattern Lab → device), `FEATURE_GUIDE.md` (writing a firmware feature or edition). The live roadmap is patternflow.work/journal and the `/roadmap` page. +- `firmware/` — PlatformIO/Arduino code for the ESP32-S3. Main sketch folder: `firmware/patternflow/` with `patternflow.ino`, `config.h` (pin mappings, brightness, limits, LED calibration), `net_config.h` (Wi-Fi / Improv / OTA / self-update defaults and the lane scale — core only; a feature's own tunables live in `features//_config.h`, and settings TUNE a feature, they never add one; per-device secrets in gitignored `patternflow_secrets.h`), and `pattern_registry.h` (function-pointer pattern table; compiles in Origin alone — the rest of the curated set ships as the Basics pack). Shared engine code lives in `firmware/patternflow/src/` (`core_display.h` HUB75 driver, `core_encoders.h`, canvas/color/math/noise helpers, Wi-Fi/OTA/web-console modules, the hotspot in `core_hotspot.h`) — core only: OSC, audio, MQTT, weather, shows, MIDI and the clock all live in `features/`. `core_display.h` drives the panel through `src/hub75/`, ESP32-HUB75-MatrixPanel-DMA vendored at tag 3.0.13. Its `UPSTREAM.md` names the upstream commit and each commit taken from upstream since, and `patternflow.patch` holds every line Patternflow changed (`VENDORED.md` says why). An edit under `src/hub75/` rebuilds the patch with the command in `UPSTREAM.md`, never by hand: upstream ends some lines in spaces, and an editor that trims them breaks the hunks. `firmware/patternflow/abi/` is the frozen host ⇄ module contract. Curated patterns live in `firmware/patternflow/presets/` (`preset_origin.h`, `preset_wave_saw.h`, dated presets), each using its own pattern namespace; user patterns are `.pfm` modules uploaded to the device over Wi-Fi, built from `firmware/modules/` via `firmware/toolchain/build_module.py` (the old `custom1.h`–`custom3.h` root slots are gone). Features attach through `firmware/patternflow/features/` (named `addons/` until 2026-08-30; legacy spellings are shimmed for out-of-tree bundles — see docs/EDITIONS.md) — a directory and a descriptor of function pointers, with the core naming none of them (see its README). `firmware/bundles/` names firmwares built from the same tree (the shelf editions `audio` and `performance`, plus `clock` and `midi`, compositions the tree keeps buildable but does not publish; the default build has no bundle): two files each, saying which features compile in and what the build calls itself; `firmware/bundles/build.sh` builds the default or a named one, `shelf.sh` stages a publishable image. Device console pages are plain HTML in `firmware/patternflow/console/`, spliced into `*_index.h` by `firmware/toolchain/console_pages.py`. Tooling lives in `firmware/toolchain/` (repo-level scripts) and `firmware/patternflow/toolchain/` (PlatformIO `extra_scripts`, which must stay sketch-relative). Also: `firmware/encoder_test/` (standalone encoder diagnostic) and `firmware/CUSTOM_PATTERNS.md` (pattern authoring guide). +- `hardware/` — Hardware designs. `case/` (Blender source in `source/`, STLs by printer bed size, `knobs/`, `legacy_v2/` for the v2.x board, `legacy_lasercut/` for the v1/v2 acrylic drawings, and `remixes/` — community enclosures, one folder each, whose README is its license header), `pcb/` (KiCad 10.0 source, Gerbers per revision, schematic PDF), `bom/` (`bom_v3.9.csv` — **the BOM source of truth**; the guide's table is derived from it. `bom_v3.0.csv` is kept for the previous board). +- `web/` — Next.js site at patternflow.work: landing page (`/`, `/pattern`, `/build`, `/inside` are tabs of one view), the interactive guide (`/guide` is a hub over `/guide/build`, `/guide/play`, `/guide/make` and `/guide/audio`, each with a `/ko` twin; one 3D stage in `components/guide/`, the firmware simulator in `lib/guide/`), Pattern Lab (`/pattern-lab`), community (`/community/**`, SQLite + Drizzle + Better Auth, only on the Pi deployment), browser flasher (no page of its own: the **Flash Patternflow** button on `/pattern` and in the Play guide, installing `public/flash/manifest.json` and the images in `public/flash/bin/`; the bare `/flash` redirects to `/guide/play#flash`), the edition shelf (`/editions`; `/variants` redirects there), the feature catalogue (`/features`, reels per feature), device update handoff (`/update`), journal, roadmap. `lib/pattern/` is the pattern runtime and annotations, `lib/lab/` the Pattern Lab, `lib/community/` the community, `lib/ai/` the Gemini client. Architecture doc: `web/ARCHITECTURE.md`. The JS presets in `web/src/lib/presets/` are the source of truth for firmware preset headers. +- `shared/` — `constants.json`, the values the firmware and the site both carry: the panel geometries `PANEL_GEOMETRY` picks from, the knob count, the encoder's 24 detents a turn, the bus maximum and the button timings. Two files are generated from it, `firmware/patternflow/src/core_shared.h` (each key with `PF_SHARED_` in front) and `web/src/lib/shared/constants.ts`, and neither is edited by hand. It exists because the lab counted 20 detents a turn, against the encoder's 24, until 31 July 2026. +- `tools/` — desktop-side helpers: `patternflow-audio-extension` (the browser audio-react extension — also the authoring source the device's `/audio-in` console page is assembled from), `patternflow-audio-android` (phone capture app), `rtpmidi-probe`, `case-models` (builds the /build preview's per-case 3D models, `web/public/cases//model.glb`, from the case files in the repo; run by hand, not by the site build), and `virtual-panel`. `virtual-panel` is the one that is not a client: a Patternflow with no board, the guide's `DeviceSim` (`web/src/lib/guide/deviceSim.ts`) behind the ports the firmware listens on, with the five contracts in `transports/`. It runs each pattern's JS twin, never a `.pfm` module. The vector suites replay against it, so a change to `DeviceSim` is a change to the panel they test. It is a test fixture, and Wi-Fi and firmware updates are not part of it. - `integrations/` — host-software integrations, each with its own README. `integrations/ableton/` is the Max for Live bridge (knobs → Live parameters over OSC). The Home Assistant integration left this repository on 2026-09-03; its author maintains it separately. Integrations are built against the contracts in `docs/` (`rest-api.md`, `osc-spec.md`, `midi-spec.md`), not against the firmware source. `docs/rest-api.md` also has the table for choosing between HTTP, OSC and MQTT, and the rules that make the device's single-connection web server easy to knock over. ## Hard rules (do not violate) @@ -20,13 +21,21 @@ It is standalone (not Eurorack), powered from a 5 V power bank through a screw t 2. `hardware/bom/bom_v3.9.csv` is the BOM source of truth. The parts table in `BUILD_GUIDE.md` and the schematic in `hardware/pcb/schematic.pdf` must match it. If you change one, check the other two. (`bom_v3.0.csv` stays for people holding a v3.0 board; it is not the source of truth.) 3. License split is strict: firmware and web code = MIT; hardware designs (PCB, case STLs, Blender source) = CC-BY-SA 4.0. Two separate license files at root: `LICENSE-MIT` and `LICENSE-CC-BY-SA`. Do not merge them; the root `LICENSE` is a pointer to both so GitHub's detector sees both, not a third license. 4. Brand naming: body text = "Patternflow", physical engravings (PCB silkscreen, future case engravings) = "PATTERNFLOW", filenames and URLs = lowercase "patternflow". Never mix these in a single context. -5. Known issues of the current board are documented in `BUILD_GUIDE.md` section 10; the v2.0 fixes and leftovers are in `BUILD_GUIDE_v2.md` section 10. Reference those sections instead of restating the issues. +5. Known issues of the current board are documented in `BUILD_GUIDE.md` section 10; the v2.0 fixes and leftovers are in `docs/legacy/BUILD_GUIDE_v2.md` section 10. Reference those sections instead of restating the issues. 6. **The board has exactly one power input: `J4`, the screw terminal. Never describe USB-C as a power option.** v3.9 removed the `USB1` footprint and its `R1`/`R2` CC pull-downs outright, after a USB-C-powered v3.0 board ran for 20–30 minutes and then smoked at a connector pin ([#221](https://github.com/engmung/Patternflow/issues/221)) — the failure is delayed, so "it seems to work" is not evidence. On v3.9 the footprint does not exist; on a v3.0 board already in someone's hands, `USB1`/`R1`/`R2` stay unpopulated. Release notes and changelog entries that record the hold as it stood are history — update current-state docs, leave the record alone. 7. **The firmware core is not the place to put a feature.** `firmware/patternflow/patternflow.ino` and `firmware/patternflow/src/` are the device itself — panel, encoders, pattern loader, Wi-Fi, sleep, console — and every published firmware compiles them **unchanged**. They change for a bug or a real improvement to the device, and nothing else; a feature bends to fit the core, never the reverse. Features attach through the hooks in `firmware/patternflow/features/pf_feature.h`, and the core names none of them — no `#include`, no `#if PF__ENABLED`, not even a feature's name in a string on the screen or in a console page. Before editing the core, ask whether the edit would still be correct on a build that does not contain the feature you are working on; if not, it belongs in `features/`. The full reasoning is [`docs/EDITIONS.md`](docs/EDITIONS.md), and the header comment of `patternflow.ino` lists the regressions this rule exists because of. +8. During a cleanup window (see [`docs/rfc-cleanup.md`](docs/rfc-cleanup.md)) no PR changes behaviour; moves and renames carry a redirect or an updated link map. ## Common commands - Web dev server: `npm run dev` (inside the `web/` directory) -- Web production build: `npm run build` (inside the `web/` directory); the smoke suites are `npm run check:*` and CI runs all of them. +- Web production build: `npm run build` (inside the `web/` directory); the smoke suites are `npm run check:*`, and CI runs the ones `npm run check:ci` chains together. +- Web lint and typecheck: `npm run lint` and `npm run typecheck` (inside `web/`). CI runs lint with `--max-warnings=0`, so one warning fails a pull request the way an error does. The rules in `eslint.config.mjs` stay as they are. An exception is an `eslint-disable-next-line` comment for one rule, with its reason after `--`. `typecheck` also reads `tools/virtual-panel/`, and lint does not. +- Shared constants: change `shared/constants.json`, then run `npm run gen:shared` (inside `web/`) and commit the two files it writes. `npm run check:shared` fails while either is stale. It also reads the two copies the generator cannot write, the 1000 in the frozen `abi/pf_params.h` and the 128 and 64 in `firmware/toolchain/build_module.py`, and fails when they no longer match the JSON. +- Drift checks: `npm run check:drift` (inside `web/`) runs each file in `web/scripts/drift/`, and each compares two things the tree keeps by hand, such as `hardware/bom/bom_v3.9.csv` and the build guide's parts table. `npm run check:` runs one: `links-absolute`, `bom`, `bom-schematic`, `features`, `editions`, `presets`, `roadmap`, `changelog` or `shared`. `check:ci` ends with it, and `.github/workflows/drift.yml` runs it on every pull request with `PF_DRIFT_STRICT=1`. At a desk a check skips a side it cannot read (a tag the clone lacks, no `pdftotext`, no `gh` login); in that workflow the skip fails. One check has no script of its own: `markers` fails on a `TODO` or `FIXME` in first-party code, and runs alone as `npx tsx scripts/drift.ts markers`. +- Contract vectors: `npm run check:rest-vectors` (inside `web/`) gives each file in `docs/rest-api-vectors/` a fresh virtual panel on free ports and prints what the vector expects beside what the panel did. `check:osc-vectors`, `check:midi-vectors`, `check:mqtt-vectors` and `check:audio-vectors` do the same for their folders. The panel's clock stands still between steps, so a 500 ms release costs no real time. `check:virtual-panel` runs each preset with a JS twin on a panel of its own for two seconds of panel time. A thrown error fails it. The six are files in `web/scripts/drift/`, so `check:drift` and `drift.yml` run them with the drift checks. +- Virtual panel: `npm run virtual-panel -- --port http=8080` (inside `web/`) starts one to talk to by hand. HTTP is on 80 as on the device, and Linux keeps ports below 1024 from a process that is not root, so the flag moves it; `--port '*=0'` takes a free port for each and prints them. `--features mqtt` composes a feature in by its directory name in `firmware/patternflow/features/`. `GET /frame.png` is the frame as the LEDs would show it, and `/vp/` is a hand and the clock. The firmware has neither. `tools/virtual-panel/README.md` has the rest. +- Locked text: `npm run check:human-lock` (inside `web/`) fails when a word changes in the maintainer's own writing, the regions `docs/rfc-cleanup.md` §2.9 lists plus each path in `docs/.human-written`. Markup and link paths in them may change. Text moved word for word passes, and the move needs the maintainer's approval on the PR. A typo is reported in the PR, not fixed. `drift.yml` runs it on pull requests against the base commit; at a desk the base is the merge-base with `origin/dev`, or with `origin/main` in a clone that has no `dev`. +- Voice lint: `npm run check:voice` (inside `web/`) reads the lines a branch adds since that same base and prints `file:line` for each banned word from `docs/voice-banned.txt`, list of three, opener that says what the text is about, or closing summary. Run it by hand before opening a PR that adds prose; CI does not run it. Files given as arguments are read whole, and `-` reads a PR description from stdin. - Firmware compilation: `./firmware/bundles/build.sh` (PlatformIO, bundled toolchain — not the Arduino IDE; the IDE path cannot build the editions' library set). That builds the default: the device with no features. Named editions are `./firmware/bundles/build.sh audio`, `performance`, `clock` and `midi` (the last builds in its own PlatformIO env, named in `bundles/midi/env`, because its USB port must be the OTG controller — a build flag, not a setting). `./firmware/bundles/build.sh all` builds every composition and scans each binary for per-feature marker strings, proving each image carries exactly its features — run it before pushing anything that touches firmware. - **Changing anything in the firmware core means building every composition** — `build.sh all` is that rule as one command. A hook change that compiles against the default is not tested; the default has no features to break. The boundary rule itself is enforced by `firmware/toolchain/check_boundaries.py` in CI: core referencing a feature namespace, including from `features/`, branching on a feature flag, or naming a feature in a core console page fails the build. `check_presets.py` (also CI) keeps `web/src/lib/presets/` and `firmware/patternflow/presets/` in step. - Console pages: edit `firmware/patternflow/console/*.html`, preview with `python firmware/toolchain/console_serve.py`, then `python firmware/toolchain/console_pages.py build` (CI checks the headers stayed in sync). @@ -42,3 +51,30 @@ It is standalone (not Eurorack), powered from a 5 V power bank through a screw t - New users: `README.md` → `BUILD_GUIDE.md` - Contributors: `CONTRIBUTING.md` → `docs/REPOSITORY.md` → `docs/EDITIONS.md` (firmware) · `web/ARCHITECTURE.md` (web) - Version history: `CHANGELOG.md` + +## Writing voice (hard rule) + +Everything an agent writes that a person will read — docs, README sections, release notes, console page copy, PR descriptions, comments — must read as if the maintainer wrote it. Not "polished". Written. + +Voice reference: `docs/voice.md` describes the project voice. Read it before writing anything longer than a sentence. Match its register (Korean: 해요체 or 합니다체 as the file already uses; English: plain, first person where the project already uses it), its sentence length, and its habit of naming the specific thing — the part number, the detent count, the date — instead of the category. + +Do not: +- Open with what the document is about ("This document describes…", "이 문서는 …을 설명합니다"). Start with the thing. +- Close with a summary that repeats the body, or with "In conclusion" / "결론적으로". +- Turn prose into bullet lists, or bold phrases for emphasis. Bullets are for lists of things. +- Use these words or their Korean equivalents: seamless, robust, leverage, comprehensive, streamline, empower, cutting-edge, ensure, utilize, delve, "it's important to note", "here's", 효율적으로, 원활하게, 강력한, 최적화된, 혁신적인, 다양한, 전반적으로, 참고로, 살펴보겠습니다. +- Write in threes ("fast, simple, and reliable"). One reason is a reason; three is a pattern. +- Join clauses with em dashes (—). End the sentence and start another. +- Hedge every claim. Say it, or leave it out. +- Add emoji, decorative headers, "Note:" callouts, or a "Generated by" line. +- Mention that an AI, an agent or a tool wrote or helped write the text. Ever. +- Reformat an existing human-written paragraph into another structure. + +Do: +- Prefer one specific example over a general statement. +- Vary sentence length. A short one can stand alone. +- Keep the project's own terms (core, feature, edition, bundle, preset, module) exactly as the existing docs use them. +- For PR descriptions and commits: imperative first line, one line of why, nothing else. No "This PR introduces…". +- When unsure of tone, write less. + +Any new or rewritten text over 20 lines goes through `web/scripts/voice-lint.ts` (banned phrases, triplets, opener and closer patterns) before the PR is opened, and a person reads it before merge. diff --git a/BUILD_GUIDE.md b/BUILD_GUIDE.md index dfb0b5fef..f6fe29b44 100644 --- a/BUILD_GUIDE.md +++ b/BUILD_GUIDE.md @@ -1,6 +1,6 @@ # Patternflow v3 -- Build Guide -> **Building a v2.x board?** Use the [v2 build guide](BUILD_GUIDE_v2.md) instead — v2 and v3 parts are **not** interchangeable. +> **Building a v2.x board?** Use the [v2 build guide](docs/legacy/BUILD_GUIDE_v2.md) instead — v2 and v3 parts are **not** interchangeable. This guide walks you through building a Patternflow on the v3 board from scratch. v3.9 is the current revision; a v3.0 board follows the same steps (its USB-C footprint stays unpopulated, see §2). No prior soldering experience needed — every joint is big, forgiving through-hole, and the board was deliberately kept that simple so a first-time solderer can finish it. @@ -23,7 +23,7 @@ This guide walks you through building a Patternflow on the v3 board from scratch 4. [3D Printing](#4-3d-printing) 5. [PCB Assembly](#5-pcb-assembly) 6. [Case Assembly](#6-case-assembly) -7. [Wiring & First Power-Up](#7-wiring--first-power-up) +7. [Wiring](#7-wiring) 8. [Firmware](#8-firmware) 9. [Final Checks](#9-final-checks) 10. [Known Issues & Design Notes](#10-known-issues--design-notes) @@ -217,7 +217,7 @@ By now the enclosure halves you bonded in Section 4 have cured and the board is - The power bank slides into the internal compartment behind the board bay. - The four black knobs press-fit onto the encoder shafts — save them for **last**, after the Section 9 checks pass. -## 7. Wiring & First Power-Up +## 7. Wiring 1. Connect the HUB75 ribbon from `J1` to the panel's `IN` connector. 2. Wire `J3` to the panel's power cable (+5V / GND — double-check polarity). The wiring run is shown at **06:57** in the [assembly video](https://youtu.be/J9C9bZgkNKs). @@ -233,44 +233,38 @@ By now the enclosure halves you bonded in Section 4 have cured and the board is *Cable already through the enclosure hole, wires clamped into `J4`, board about to go in.* -4. With power off, seat the ESP32 DevKit in its sockets (orientation per silkscreen), then power on. +4. **Leave the ESP32 DevKit out of its sockets for now.** It gets its firmware on its own, out of the board (§8.1), and goes in only after that, with the power bank unplugged. The first power-up is §8.1, step 5. ## 8. Firmware -The ESP32-S3 module is flashed **separately, outside the PCB**. Already seated it? Just pop it gently out of the sockets, flash it, plug it back in, and connect power — done. **One firmware image serves every board generation** — the pin map is identical on v2.x and v3.0, so there is no board selection to get wrong. +The ESP32-S3 module is flashed **on its own, out of the board**, once; after that, patterns and firmware updates arrive over Wi-Fi. Already seated it? Lift it gently straight up out of the sockets first. **One firmware image serves every board generation** — the pin map is identical on v2.x and v3.0, so there is no board selection to get wrong. ### 8.1 Browser flash (recommended) -No installation required — desktop **Chrome or Edge** only (Web Serial; Firefox/Safari won't work). +No installation required — desktop **Chrome or Edge** only (Web Serial; Firefox/Safari won't work). Steps 1–6 follow the Play guide's [Flash chapter](https://patternflow.work/guide/play#flash) in the same order, and step 7 is its [Patterns chapter](https://patternflow.work/guide/play#patterns). > 🎬 **See it before you do it.** [patternflow.work/guide/play](https://patternflow.work/guide/play) walks through this section — the port, BOOT and RST, the Wi-Fi step, the Basics pack — on a Patternflow you can turn, and every step has a link for telling us where you got stuck. > 🔌 **Use the LEFT USB-C port** — the one on your left when the two ports face you. On the ESP32-S3 DevKit that's the board's **native USB** port (labeled `USB`); the browser flasher (Web Serial + Improv) talks to it directly. The right-hand port goes through a separate USB-to-UART bridge chip and is the one Arduino IDE uses (§8.2) — the flasher may still write the firmware through it, but the Wi-Fi step never appears there, because the firmware only listens for it on the left port. -1. Visit **[patternflow.work](https://patternflow.work)** on a desktop browser. -2. Connect the ESP32-S3 to your computer with a USB-C **data cable**, using the **left port** (see above). -3. Open the **Pattern** tab, find **Got the hardware?**, click **"Flash Patternflow"**, pick the serial port, and follow the on-screen steps. A new install erases the module first; a module already running Patternflow is updated in place and keeps its patterns and Wi-Fi. -4. **Wi-Fi.** When the install finishes, press **Next** and the flasher asks for your network (Improv-Serial). Type the name exactly — there is no list to pick from, and it is case-sensitive — and use a **2.4 GHz** network; the ESP32-S3 cannot see 5 GHz. If the install ends without asking, press **RST** on the module once, click **Flash Patternflow** again, pick the port, and choose **Connect to Wi-Fi**. Some modules do not restart into Patternflow on their own after flashing; the button does it. -5. Disconnect, seat the module back into the board sockets (orientation per silkscreen), and connect power. -6. **Load the patterns.** The image ships with **Origin only** — the rest live on the device's filesystem instead of inside the firmware, which is what freed the memory for everything else. Open **[the decks shelf](https://community.patternflow.work/community/decks)** and press **Install to my board** on the **Basics** pack: 33 patterns, one click, no account. Your browser fetches the pack and hands it to the board over your Wi-Fi, so the board is never talking to the internet itself. On a freshly flashed board the device's page first says **Storage needs formatting**: press **Format storage**, confirm, and the pack installs by itself as soon as the format is done. On Android, `patternflow.local` does not resolve — type the board's IP address (hold **K2**) into the **Device address** field on the Basics card instead. +1. **Connect the module on its own.** Hold it antenna up, ports toward you, and connect its **left port** (see above) to your computer with a USB-C **data cable**. A charge-only cable is the usual reason nothing shows up. +2. **Download mode first — every first flash, before you click anything in the browser.** Press and **hold BOOT**, **tap RST** (labelled EN on some modules), then **let go of BOOT**. The module now waits for its firmware. + + In download mode the flasher can't tell the module already runs Patternflow, so it **erases it** — installed patterns and saved Wi-Fi included. Fine on a new build. A board already running Patternflow updates over Wi-Fi instead, from its console's **Update** page, and keeps everything. +3. **Flash.** On **[patternflow.work](https://patternflow.work)**, open the **Pattern** tab, find **Got the hardware?** and click **Flash Patternflow** (the Play guide's Flash chapter has the same button). Pick the line like `USB JTAG/serial debug unit (COM4)` — the number depends on which USB port you used — and install. It takes about two minutes. Not in the list? Hold BOOT, tap RST again, then click the button again. +4. **Wi-Fi — expect it to take a second go.** Usually the install ends without asking for your network. Nothing failed: after download mode the module just hasn't restarted into Patternflow yet. Close the dialog, press **RST** once, click **Flash Patternflow** again and pick the port: this time it opens on Patternflow's own menu instead of installing. Choose **Connect to Wi-Fi**, let the short scan finish (it lists nothing, by design), and type your network's name exactly — it is case-sensitive — and its password. Use a **2.4 GHz** network; the ESP32-S3 cannot see 5 GHz. (Sometimes the same form comes straight after the install instead; fill it in there the same way.) Still won't join? Carry on: step 6 does it from a phone. +5. **Back in, power on.** Unplug the USB cable. With the power bank unplugged, seat the module in the board's sockets the way the silkscreen shows: its USB-C ports toward the board's `USB` mark, all 44 pins in, neither row shifted by one. Then plug the `J4` lead into the power bank — never the module's USB ports (Section 2). Within a second or two the panel lights with **Origin**, a red, squarish pattern. +6. **Still no Wi-Fi? The panel is a network itself.** Give it about 15 seconds after power-on, then hold **K2**, the top-left knob, for a second. `CONNECTED` and an address: it's on your Wi-Fi, carry on. `HOTSPOT` and a name like `patternflow-a1b2`: it found no network it knows, so it raised its own. Join that network from a phone or laptop — the password is `patternflow` — and open `http://192.168.4.1/`. The phone will say the network has no internet; that is true, and it stays connected. On the **Wi-Fi** page, pick your network, add its password and save: the panel shows `JOINED` and its new address, or why it couldn't. Neither on K2 yet? Wait a few seconds and hold it again. Not in your phone's list yet? Refresh it (or switch Wi-Fi off and on), and look for the exact name K2 shows. +7. **Load the patterns.** The image ships with **Origin only** — the rest live on the device's filesystem instead of inside the firmware, which is what freed the memory for everything else. Back on your own Wi-Fi, open **[the decks shelf](https://community.patternflow.work/community/decks)** and press **Install to my board** on the **Basics** pack: 33 patterns, one click, no account. Your browser fetches the pack and hands it to the board over your Wi-Fi, so the board is never talking to the internet itself. On a freshly flashed board the device's page first says **Storage needs formatting**: press **Format storage**, confirm, and the pack installs by itself as soon as the format is done. On Android, `patternflow.local` does not resolve — type the board's IP address (hold **K2**) into the **Device address** field on the Basics card instead. > 🎛️ **One pattern after flashing is correct, not a failed install.** It used to be 34 baked into the image. They moved out so that patterns can be added and removed without reflashing, which is also how anything you make yourself reaches the panel. -> 🔍 **Nothing in the port list?** A module that is running its own firmware does not always announce itself, so put it into download mode by hand before you look again: -> -> 1. Press and **hold BOOT** -> 2. **Tap and release EN / RST** (the reset button) -> 3. **Release BOOT** -> -> The picker should now offer a line like `USB JTAG/serial debug unit (COM4) – Paired`. The number depends on which USB port you used. -> -> In download mode the firmware cannot answer the flasher, so it treats the module as new and **erases it** — installed patterns and saved Wi-Fi included. Press **RST** alone to bring a module back to normal first if you only meant to update it. +> 🔍 **Still nothing in the port list?** Put the module into download mode again (step 2) and look again. Still nothing? It is almost always the cable — a charge-only USB-C one enumerates nothing at all. **There is no driver to install on this port**: the ESP32-S3 handles USB itself, so the CP2102 / CH34x links on the flasher's troubleshooting screen do not apply here. > -> Still nothing? It is almost always the cable — a charge-only USB-C one enumerates nothing at all. **There is no driver to install on this port**: the ESP32-S3 handles USB itself, so the CP2102 / CH34x links on the flasher's troubleshooting screen do not apply here. +> Only meant to update a module that already runs Patternflow, over USB? Skip download mode: plugged in as it is (or after a press of **RST** alone), the flasher recognises Patternflow and updates it in place, keeping its patterns and Wi-Fi. The console's **Update** page does the same over Wi-Fi. -> 📶 **Changing Wi-Fi later.** The network you set during flashing is **saved on the device and reused on every boot**. The panel remembers up to five networks — home, studio, a venue — and tries the most recent first, so moving it does not mean reflashing. Add or forget networks on the device's **Wi-Fi** page (the console at `patternflow.local`, or the board's IP address). A plain Arduino IDE re-upload does **not** clear the stored networks; **Tools → *Erase All Flash Before Sketch Upload*** does. +> 📶 **Changing Wi-Fi later.** The network you set during flashing is **saved on the device and reused on every boot**. The panel remembers up to five networks — home, studio, a venue — and tries the most recent first, so moving it does not mean reflashing. Add or forget networks on the device's **Wi-Fi** page (the console at `patternflow.local`, or the board's IP address). Somewhere with none of them, it raises its own `patternflow-xxxx` network again, as in step 6 (every image the flasher and the editions page install does; the older clock and MIDI try-out images from the features page don't). The hotspot's mode (`auto`, `always`, `off`) and password are on the same page. A plain Arduino IDE re-upload does **not** clear the stored networks; **Tools → *Erase All Flash Before Sketch Upload*** does. -> 📡 **No Wi-Fi where you are? The panel is one.** *(Firmware after v3.10.4; the Performance edition v0.4.0 has it already.)* About fifteen seconds after it finds no known network, the panel raises its own hotspot: `patternflow-xxxx` (the name on its NETWORK screen - hold K2), password `patternflow`. Join it from a phone or a laptop and open `http://192.168.4.1/` - the whole console, including the Wi-Fi page, so you can add the network for wherever you are next and the panel joins it at once. The phone will say the network has no internet; that is true, and it stays connected. Mode (`auto`, `always`, `off`) and the password are on the console's Wi-Fi page. *Photos from the v2 guide — the flashing flow is identical on v3.* diff --git a/CHANGELOG.md b/CHANGELOG.md index d19b7130d..0774151dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,18 +4,178 @@ All notable changes to Patternflow will be documented in this file, newest first ## [Unreleased] +### Firmware + +- **The MIDI page counts 24 detents a turn, as the encoder has.** The Sensitivity readout worked out its per-turn figure from 20, so 1 : 1 read 20/turn and 6.4 turns end to end, where a turn of the PEC11R-4220F-S0024 sends 24 steps and the sweep takes 5.3. What the panel sends did not change. +- **The vendored HUB75 driver names the upstream commit it came from.** `firmware/patternflow/src/hub75/UPSTREAM.md` gives ESP32-HUB75-MatrixPanel-DMA at tag 3.0.13 (`a622186`), copied on 31 July 2026, and `patternflow.patch` beside it is every change since: `diff -ruN` of upstream's `src/` against the folder, which applied to a clean checkout of the tag gives the folder back. `VENDORED.md` and the comments in `core_display.h` and `core_canvas.h` point at the patch instead of counting the additions. The firmware is unchanged. +- **The vendored HUB75 driver takes upstream's fix for its config constructor.** `HUB75_I2S_CFG` now sets `line_decoder` from its argument (upstream `7bf3b6c`), so the field is `TYPE138` instead of whatever was on the stack where `initDisplay()` builds the config. `src/hub75/UPSTREAM.md` lists the commit and its rebuild cherry-picks it, so `patternflow.patch` still holds only Patternflow's changes and comes out byte for byte the same. +- **The firmware reads the values it shares with the site from `src/core_shared.h`.** `config.h` takes the panel geometries and their sizes from it, `net_config.h` writes the lane scale as 24 detents times two turns, and the encoders, the bus and the sketch take the debounce, the long press and the bus scale. All come from `shared/constants.json`. No value changed, and all five images build to the same size. + ### Hardware +- **`check:bom` and `check:bom-schematic` hold the BOM to the build guide and the schematic.** Hard rule 2 as two checks. A designator, an MPN or a quantity that `hardware/bom/bom_v3.9.csv` and the parts tables in section 1 of `BUILD_GUIDE.md` do not share turns `check:ci` red, and so does a designator that the BOM and `hardware/pcb/schematic.pdf` do not share. The schematic's designators come out of its text layer through `pdftotext` (poppler); where it is not installed the check skips, and under `PF_DRIFT_STRICT=1` the skip fails. Part names on the board are not compared, since the guide words them its own way. Off the board, where there is no MPN, the rows pair in order and each guide row keeps every word of its BOM row's part. - **Besoiobiy's printed case is the second enclosure remix** (`hardware/case/remixes/besoiobiy-printed/`). Two long pieces glued side by side, a frame round the panel and a box for the board and the power bank, closed at the back by four screwed-on covers, with the panel bolted through the case into its own M3 sockets. Its README takes the place of the build guide's sections 4 and 6 and sets the order of 7 to 9; check your panel against it first, because the BOM's panel takes M4 screws on another hole pattern. The build guide's printing section and the assembly map point at it. +- **Mykyta Bilous' horizontal desktop case is the third enclosure remix** (`hardware/case/remixes/mbchars-horizontal-desktop-printed/`, [#488](https://github.com/engmung/Patternflow/pull/488) by [@mbchars](https://github.com/mbchars)). The panel lies the long way across on two removable stands, with the four controls on the right. Three front sections print face down, two white frames and an orange control section, and four joint bars on brass inserts hold them together without glue; two rear covers open onto the electronics and carry wall-mount openings. There is no power-bank bay: a separate PDSink 303PDSink01 trigger module in the control section feeds J4, the screw terminal, at 5 V. One Bambu Studio project holds all nine plates, with illustrated assembly instructions and the BOM changes beside it. ### Web +- **Pattern Lab no longer has the AI gallery or a Gemini key.** The Gallery panel, its batch generation and the key field are gone, and so is the `@google/genai` dependency: the site calls no AI itself. Copy prompt stays, with the v-field/rgb choice beside it in the Code tab now. Opening the Lab removes the gallery's saved patterns and the key from the browser, and a saved dock layout opens without the Gallery tab instead of falling back to the default. +- **Pattern Lab's preview shows add layers the way the device does.** The preview clamped each layer to 255 before blending the next, so two (200, 0, 0) add layers under a (128, 128, 128) multiply showed R 128 in the Lab and 201 in the exported pattern and on the panel. +- **The Live Editor says why pasted code does not run.** The error, and the line it failed on when the stack gives one, shows under the editor. It used to reach the browser console only, once a frame for a `draw()` that throws. +- **Community pages open when the browser blocks site data.** Reading the deck from `localStorage` threw a SecurityError that took every community page down to "This page couldn't load", and the atlas did the same reading its language. Both reads are guarded now, and a deck or language the browser refuses to store holds for the rest of the visit. +- **The home page waits for the Pattern tab before fetching the code editor and the flasher.** The Pattern panel is mounted on `/`, `/build` and `/inside` too, folded away, so every visit asked jsdelivr for Monaco's loader and unpkg for esp-web-tools. Both now load the first time the tab opens, and stay. +- **The guide's tests give the same answer on a busy machine and a fast one.** The hub hand test read Make's redraw off the wall clock and, on a fast machine, passed without the hand turning a knob; it now runs on its own clock. That turned up a simulator bug: nine clicks of K1 out and nine back left Origin's hue at 0.9999999999999993, read as "hue 1.00" with a full bar, and `wrap` now calls that the start. The NETWORK timeout test covers its 15 seconds in 150 frames instead of 900. +- **Pattern Lab's "Copied" labels clear their timer when the panel closes.** The seven labels that show after a copy, paste or clear no longer leave a `setTimeout` behind, which could fail the Lab tests after every test had passed, and a second click keeps its label for the full 1.2 s. The image import modal's wheel listener now attaches in the same commit that enables Apply. +- **posthog-js loads after the page does.** The analytics library was the largest script on every page (318 KB, 103 KB gzipped); it is now fetched with `import()` after the load event, and events captured before then are sent with their original timestamps. +- **Closing Send or Publish from its backdrop keeps To hardware open.** In Pattern Lab's To hardware dialog, a press outside the nested Send or Publish modal closed To hardware as well, and the C++ header pasted into it was lost. +- **Korean pages fetch 318 KB of Pretendard Hangul instead of 1.2 MB.** The Hangul part of each weight is now the 2,350 syllables of KS X 1001 and the jamo; the other 8,822 syllables are a second file that a page fetches only when its text has one of them. +- **Editing a deck no longer runs out after four changes.** Sharing a deck and editing it counted against the same five requests a minute, so the fifth edit within a minute of sharing (each visibility change is one) came back 429. Edits now have their own twenty a minute, the same as a pattern's or a post's. +- **The hero video has a stop button.** The YouTube embed on the home page played for 89 seconds by itself and took no clicks, so nothing could stop it (WCAG 2.2.2). The button in its corner takes the video away and leaves the photo under it. Pressed again, or after the video ends, it plays from the start. +- **Pattern Lab's dialogs close on Esc, and its header menus close on a tap outside.** To hardware, Import image and Code guide take the keyboard focus when they open and give it back to their button when they close; an Esc over Send or Publish closes only that window, so the pasted header stays. Recent and Panels close on a press outside them, on Esc and on Tab past the last item, and the Panels list tells a screen reader which panels are open. +- **The breadboard build guide fits the window from 1280px up, and loads its photos as you reach them.** At 1440px the page scrolled 146px sideways and the photos on the left were cut off. The step photos now sit in the space beside the text, up to 360px wide. On a phone the eight photos (1.9 MB) no longer download when the page opens, and the page ships 59 KB less JavaScript. +- **The deck panel and the Send window stop waiting when the build server goes quiet.** With the build worker down, both asked the server every second until the window was closed, with a spinner beside "usually a second or two". They now give up after the wait the deck bar already used (6 s, plus 1.5 s a pattern, plus 5 s), say the server has not answered, and leave a button to start the build again. The bar, the panel and the window share that rule in one hook, `lib/community/useModuleBuild.ts`. +- **The Live Editor fits a phone screen.** On a 390px phone the preset bar squeezed the pattern name to nothing and showed only "01"; the name now takes its own row above the buttons. The editor shrinks to the height left under the pinned 3D preview (330px on a 390×844 phone, 480px on desktop as before), so some of the page always stays in view to scroll by. +- **`npm run build:enqueue` queues a module build.** Without `--pfm` it wrote a whole-image row, which the worker built as modules anyway, so `build:status` reported an image it never made. +- **Pattern Lab's Pixel zoom follows the wheel's travel and holds the cell under the cursor.** Each wheel event was one zoom step, so a trackpad swipe of thirty small events ran from 6× to 32×, and the picture grew from the viewport's top-left corner. Steps now add up from the distance turned, 100 px a step, and the viewport scrolls the cursor's cell back under it. The image-import preview scales 8% per 100 px around the cursor the same way. +- **The Live Editor's creation prompt gives the step one detent really moves each knob.** It said 0.05 a detent (0.10 for Knob 2). With 24 detents a turn and two turns to a range it is 0.021, 0.206, 0.102 and 0.021, worked out from the same ranges the preview's knobs turn through. The note under the Pattern Lab card no longer says the build server compiles firmware in the browser, and links PlatformIO instead of the Arduino IDE. +- **Community dialogs close with a thumb, and the atlas works from the keyboard.** The × in the header of the community's 13 dialogs was a 10.7×23.2px target and is now 44×44px, with the header the same height. A point on the atlas shows a ring when Tab reaches it, and Space no longer scrolls the page; pin tiles can be reached with Tab and opened with Enter; the live ring stops pulsing under reduced motion. +- **Pattern Lab's Pixel panel opens with the whole picture in view.** It always opened at 6×, so a 390 px phone showed 56 of 128 columns and 19 of 64 rows. It now opens once at the zoom the Fit button would pick, when 6× does not fit. +- **The community rate limiter keeps an hour of builds through its cleanup.** Once it held more than 10,000 keys, the cleanup judged every key by the one-minute window of the call that set it off, so somebody who had used their 30 builds for the hour got a 31st. The limiter and the origin check every community route runs first now have tests. +- **Pattern Lab writes an unnamed knob and a fine knob range into `@knobs` so they read back.** An empty name went out as `-=0..1` and came back as a knob called "-", and 0.0001..0.0004 was rounded to `0..0` and dropped. The `@knobs` and `@matrix` lines and the knob wrap rule now have tests. +- **The home tabs' link rows draw a focus ring, and each tab's title is its page's h1.** A focused row under Start here or in the case card took only a cream fill, 1.23:1 against white, and now draws the LED ring the case tabs use; the join band on Inside does the same. With a tab open the hero's h1 is inert, so /build had no h1 at all. +- **A desk run of `check:drift` reports a skipped `links-absolute` as skipped.** In a clone without the release tags the check could not read 24 links and said so, but exited 0, so the runner's summary listed it as passed. It now exits with the drift checks' skip code; under `PF_DRIFT_STRICT=1` it still fails. +- **Links drawn as buttons and cards keep their look on hover.** The global `a:hover` underline outranked each CSS module's `text-decoration: none`, so 453 links on 16 pages grew an underline when pointed at, and the guide's "Stuck here? Tell us" drew a second line under its own. +- **Two unimported files and six uncalled exports are gone.** `Deck.tsx`, `waveTest.ts`, `renderPatternStill`, `cloneShow`, `deleteSession`, `useActiveCodeLayer`, `timelinePatternNames` and `licensedHeaderText` had no callers in `web/` or `tools/virtual-panel/`, and `web/ARCHITECTURE.md` no longer lists `Deck.tsx` on the landing page. +- **The 3D device and the Inside globe hold still for reduced motion.** With the system's reduced-motion setting on, the preview device stands facing the reader instead of swaying 26° each way every 42 seconds, and the globe no longer turns by itself or runs dots along its links. The LED pattern on the panel still plays. +- **`web/ARCHITECTURE.md` says what Vitest runs and when a licence header is added.** `check:panels` runs every `src/**/*.test.ts(x)`, not only the Pattern Lab panels, and the community bakes the licence header into the stored code on publish and edit. `web/README.md` lists `check:voice`. +- **The atlas says why it did not save a pin.** Every pin write ignored the server's answer, so after a 401 a dragged tile stayed at its new spot as if saved, and a dropped connection ended in an unhandled rejection. The panel now shows the server's reason, and a refused drop goes back to where the pin is saved. +- **The guide's panel simulator fails a test when a firmware number it copies changes.** `firmwareConstants.test.ts` reads the 21 timings and limits from `firmware/patternflow/` (brightness idle, SELECT settle, the bus grace, the lane release and others) and the brightness percent rounding, and checks every `FIRMWARE` key against them. +- **A pattern page stops counting the lines Pattern Lab writes on its own as the author's decisions.** The Lab stamps `@matrix 128x64` on every publish and the stock black-to-white `@ramp` on every value-field layer, so a Gemini variant published untouched showed "Framed" and "Colour shaped". Each line is now read with its parser and set against the Lab's defaults, and "Framed" names the frame. +- **The atlas fits a phone.** At 390px the page scrolled 99px sideways, "Add my pattern" opened its picker 201px past the left edge where no tile could be tapped, and the placing banner ran off both sides of the map. The toolbar buttons now wrap as one block, the picker grid no longer widens with long titles (it scrolled sideways at 1440px too), and the banner sits under the filter chips instead of on them. +- **The site's typecheck catches unused locals and parameters, switch fallthrough and a missing `override`.** It found twelve; one was a ramp table the pattern runtime rebuilt on every `setRamp` and never read. The eight `eslint-disable-next-line` comments without a reason now give one. +- **Community fields a screen reader could not name now have names.** The device address under a deck or Send build was read out as an edit box with no name, and so were the atlas's made-from select and pin knob sliders; the atlas search and the report's detail box read out their placeholder instead. The atlas names them in Korean too. +- **The home page's tabs open in a browser without WebGL.** Where WebGL 2 was turned off, `/`, Build, Pattern and Inside threw from the 3D view's renderer and showed the site's error screen. The view now asks for a context before it makes a canvas, and a scene that throws anyway is taken off, with one line standing in its place. +- **The home page poster comes at the width it is drawn.** It was the 4032-wide camera original, 2.1 MB, on every tab of the home view. On a phone at 9 Mbps the first screen's largest paint took 9.6 s, and it now takes 0.5 s. +- **The Build and Pattern tabs, and the home page on a phone, no longer download the build globe's photos.** The 3D view opened on the globe for a moment on /build and /pattern, and a phone's hidden hero globe warmed every pin's photos as well: 65 images, 8.3 MB on a 3× phone. The view now starts on the page's own tab, and the globe warms its photos only where it is on screen. +- **Community write routes answer a `null` body with a 400.** `null` parses as JSON, so it got past every route's `catch` and threw on the first field read, and 22 handlers answered an empty 500 with no CORS headers. They now read the body through one helper that answers `{"error":"Expected a JSON object."}`. +- **Pattern Lab and the Live Editor say when pasted code was cut off.** Code that stopped inside a call reported "Unexpected token 'return'", and code missing its last `}` reported "Unexpected token ')'", tokens the author never typed. The message now counts the brackets that never close and quotes the last line. +- **Pattern Lab's Recent and Panels menus open inside a phone screen.** Below 800px each menu hung from its button's right edge and opened off the left of the screen. It now takes its left edge from the header's button row, at most 360px wide, with 44px rows. The × in the Lab's dialogs grows from 30px to 44px on the same screens. +- **Send over Wi-Fi reaches the board when the address field is empty or holds a pasted `http://` address.** An emptied field made `http:///patterns`, which a browser reads as a host called `patterns`, and `http://192.168.0.44/` made a host called `http`. Every handoff and install link now cleans the address the same way, in `lib/community/deviceHost.ts`. +- **Copy buttons in Pattern Lab and the Live Editor say "Copy failed" when the browser refuses the clipboard.** The Lab's Copy prompt and the To hardware copy buttons kept their label, and the Live Editor said the creation prompt was copied before the write had finished. +- **Sharing from Pattern Lab measures the code the way the community does.** "Open in Pattern Lab" hands back the stored code with its licence header and footer on, and the publish modal counted them, so an unchanged fork or a reopened post just under the 100 KB cap was refused before it was sent, though the server takes it. +- **The community API no longer lets an edit move a pattern to MIT or CC0.** Both are retired and stay on the patterns already published under them, but a direct PATCH still moved a CC BY-SA original to CC0 and rewrote the SPDX line in its source. +- **Five community windows close on Esc and hand the focus back.** Sign-in, report, Edit details, Add firmware header and Publish a performance take the focus when they open and return it to the button that opened them; before, Esc left them open and four kept the focus on the page behind. +- **The community atlas stands its filter chips above the map on a phone.** At 390px the two rows of chips hid 21 of the map's 83 labels, the map box left 152px blank under the drawing, and touch screens showed the "wheel to zoom · drag to pan" hint, which names gestures they do not have. +- **Six pages stop prefetching the home page's 3D code.** The Patternflow link on /terms, /contact, /features, /roadmap, the 404 and /community prefetched / as soon as it was in view, and the home page's three.js and post-processing chunks came with it: /terms fetched 767,286 bytes of script at 390px, and 148,456 without them. The home page now loads when the link is clicked. +- **Pages no longer preload fonts their text doesn't use.** The root layout drops DM Sans, which set no text, and loads Newsreader and Silkscreen without a preload, so only the journal, /terms and the guide fetch them; `/` and Pattern Lab take 166 KB of fonts less. +- **A wrapping knob on a range other than 0..1 hands the pattern every detent.** Knobs 1 and 4 wrapped at ±0.5 whatever their range, so in the Lab, on community cards and on the guide's desk a pattern declaring `@knobs Hue=0..360` read a full turn as 20.8 clicks instead of 24; the wrap now sits at half the knob's own range. +- **A mask over a 0% layer in Pattern Lab gates nothing in the exports either.** The exported JS and the C++ header dropped the 0% layer before grouping and handed its mask to the layer below, so a layer the Lab showed whole came out half black; the preview did the same for a code layer that had sat at 0% since it was added. +- **The build map has a fourth kind of pin, a showing.** A unit set out somewhere for people to play is a triangle under In use, with a line back to the unit it is and no part in the web of builds. *Anbu*, a DJ night in Hongdae, the Hongik course exhibition and the Seoul Design Week party go in as showings, beside new pins for two gifts, SimonePDA's laser-cut case, Besoiobiy's printed case, the MQTT test between Ajman and Seoul, and jonwtr's norns screen mirror in Ghent. A pin's link into the site, like Build this case, changes the tab in place instead of opening a new one. +- **The Build guide keeps up with a finger on a phone.** A scroll waits for the 3D stage's script only when the finger lands on a knob. The stage's models start downloading with three.js and weigh 266 KB less, and a stage that has stopped moving no longer redraws its shadow map. +- **Pattern Lab's pixel undo survives a matrix resize.** Undo after shrinking the matrix threw a RangeError, and after growing it wrote the old rows into the new buffer. Each undo frame now keeps its size and goes back through the same centred crop or pad the resize gave the layer. +- **A link to any page previews with that page's own title and picture.** The guide pages and the home page's tabs went out with no image, and pages without Open Graph tags of their own, such as `/features` and `/editions`, previewed as the home page. `twitter:title` was the home page's everywhere. `/build/breadboard` has its own title now, and it and `/update` are in the sitemap. +- **Pattern Lab asks before Del removes a layer with work in it.** Deleting a layer has no undo and autosave wrote the smaller stack over the saved project within 3 s, so one misclick lost a pixel drawing. A blank pixel layer or a code layer still holding the starter pattern goes without asking. +- **Pattern Lab stacks its panels on a phone.** At 800px wide or less the default dock puts Preview across the top and every other panel as a full-width tab under it, where three columns left each panel about 110px. The narrow layout is saved under its own key, so a desktop arrangement survives a phone visit. +- **Pattern Lab's wheel zoom no longer scrolls the panel under it.** React attaches wheel listeners as passive, so the Pixel viewport and the image-import preview called preventDefault for nothing: each notch zoomed and scrolled at once. Both now listen natively with `passive: false`, and a sideways wheel scrolls instead of zooming out. +- **The home page video gives way to reduced motion, and the section tabs say which one is open.** With reduced motion on, the hero shows its poster and never loads the YouTube embed, which autoplays with no control to stop it. The desktop tab column is now a `Sections` nav whose open tab is `aria-pressed`, as on the phone, with its ✕ hidden from screen readers, and the panel beside it is the page's `
`. +- **The links at the end of each guide say where they go.** All guides moves to the hub over the same 3D stage instead of reloading the page. A link to another page of the site carries → and opens in the same tab without a reload, and a link away from it carries ↗ and opens in a new tab. +- **`npm run doc-map`** writes every link into the repository, from Markdown, site code, the console pages and the redirect table, to `docs/investigations/2026-10-doc-map.csv` with whether it resolves. 1007 links at the baseline, none broken. +- **`check:features` fails when /features and the feature folders disagree.** An id with no folder under `firmware/patternflow/features/`, or a folder with no id, turns `check:ci` red, and the run prints both lists and what is on one side only. `core` is the one id without a folder, because the comment on `Feature.id` says so. It is the first check `npm run check:drift` runs: every file in `web/scripts/drift/` is one, and the rest of the cleanup's step 2 lands there. +- **`check:links-absolute` fails on a GitHub link into the repository that does not land.** `web/scripts/doc-map.ts --check absolute` reads the doc map's 118 absolute links from 26 files, writes nothing, and fails on one whose path or anchor is missing at HEAD or at the tag it names, or on a repository URL in a file the map does not read. A tag the clone lacks is skipped and named, and `PF_DRIFT_STRICT=1` fails on that skip. +- **`check:editions` fails when the shelf and the bundles disagree.** A card on /editions with no folder under `firmware/bundles/`, or a bundle that is neither a card nor a try-out on /features, turns `check:ci` red. So does a folder in `web/public/flash/bin/` that no card, try-out or manifest names, and an `EDITIONS` tuple in `release.py` or `check_versions.py` that is not the cards. The default card and a card released from another repository need no bundle, and both are read from the tree. +- **`check:presets` fails when a web preset cannot reach a panel.** A `pattern-*.ts` in `web/src/lib/presets/` that `index.ts` does not import, or an import with no file, turns `check:ci` red. So does a preset with a firmware twin that `pattern_registry.h` does not compile in and the Basics pack does not carry, and a pack entry whose pattern number no web preset has. The web-only and firmware-only presets are read from `check_presets.py`, not copied. +- **`check:human-lock` fails a pull request that changes a word of the maintainer's own writing.** The journal, the manifesto, the release notes, the README's three story sections, Part 1 of each RFC and the roadmap's `detail` and `detailKo` strings are compared with the pull request's base as plain text, with the Markdown, MDX and whitespace taken out. Markup, link paths and image paths can change; a typo fix cannot. A locked file moved word for word passes, and the run says the move needs the maintainer's approval. `docs/.human-written` takes more paths, one per line. +- **`check:changelog` fails when `CHANGELOG.md` and `docs/releases/` disagree.** A release the root keeps in full equals its file once its relative links point two folders up. A line under "Older releases" matches its file's heading, and every `docs/releases/vX.Y.Z.md` is in the root one way or the other. A drifted section prints the line and column where the two copies part, and a release cut by `release.py` stays green. +- **Every pull request runs the drift checks and the text lock, whatever it touches.** `.github/workflows/drift.yml` (Drift checks) runs `check:drift` on every pull request and on push to `main`; Web CI starts only on `web/` and `hardware/case/`, so a pull request that edits only the BOM or a bundle never ran it. It fetches the tags and installs `pdftotext` and runs with `PF_DRIFT_STRICT=1`, so a check that cannot read a side fails instead of skipping. On a pull request it also runs `check:human-lock` against the base commit. +- **`check:roadmap` fails when a /roadmap node and its issues disagree.** `gh` reads the state of every issue the page names on engmung/Patternflow. A `done` node with an open issue, a `planned` node with a closed one, or an issue GitHub answers 404 for turns `check:drift` red. An issue left open on purpose goes in the check's own list, with the maintainer's reason. Without `gh`, a login or access to the repository it skips, and the Drift checks workflow runs it strict. +- **`check:voice` points at the habits of generated prose, line by line.** Run it by hand on new or rewritten text before a pull request opens. With no arguments it reads the lines the branch adds; it also takes files, and `-` reads a PR description from stdin. It flags a word from `docs/voice-banned.txt`, three adjectives or clauses after a serial comma, an opening that says what the text is about and a closing summary, each as `file:line`. CI does not run it. +- **Five of the site's twelve lint warnings are gone.** The unused `normalizePerformance` and `useMediaQuery` imports go, and so do `REASON_LABEL` on the edition card and `zoomTo` on the roadmap map, which nothing read. The map's `clampPan` is now a `useCallback` on `width` and `zoom`, so the wheel effect lists it and still runs on the same renders. Nothing the site serves changes. +- **The seven `` lint warnings carry a named exception.** The breadboard build photos, the hero poster and the journal lightbox stay plain ``, each with an `eslint-disable-next-line @next/next/no-img-element` line that says why; `npm run lint` drops from twelve warnings to five. +- **Pattern Lab's July storage keys say who wrote them and when.** The draft and ramp keys the lab wrote in July 2026, the gallery key it still writes, and the v1 show bake each name their commit and date. The comments no longer say the migration reads the draft once: it runs on every open where no project restores. `legacyGallery` becomes `gallery` with the same key string, and `saveDraft`, which nothing called, is gone. Nothing the site serves changes. +- **The site's redirects and old routes say what links them.** The two journal `?lang=en` redirects, the `/en/en` collapse in the proxy, the eight campaign short links, `/business` and `/community` on the Vercel site each carry a comment with the commit and date that made them and what still points there. The `/variants` comment now dates the console links to 3.7.0 through 3.8.0. Nothing the site serves changes. +- **Web CI fails on a lint warning.** The Lint step runs `npm run lint -- --max-warnings=0`, so the site stays at the zero warnings it reached in step 3 of the cleanup. +- **The community's old formats say what wrote them and when.** The deck's `pf-module-cart` and `pf-saved` keys, the feed's `page`, `size` and `view` parameters, the `"bin"` build format the builds route answers with 410, and the zip page's `duplicate-name` each carry a comment with the commits and dates. `moduleRunner.ts` no longer cites the removed `buildRunner.ts`. Nothing the site serves changes. +- **A new TODO or FIXME turns the drift checks red.** `web/scripts/drift/markers.ts` reads every tracked text file under `firmware/`, `web/src/`, `web/scripts/`, `tools/` and `integrations/`, and prints the file and line of either word in capitals. Vendored code is skipped, so the one `TODO` in upstream's HUB75 driver stays where its `UPSTREAM.md` records it. +- **One file holds the values the firmware and the site both carry.** `shared/constants.json` has the three panel geometries, the knob count, 24 detents a turn, the encoder and button timings and the bus scale. `npm run gen:shared` in `web/` writes it out as `firmware/patternflow/src/core_shared.h` and `web/src/lib/shared/constants.ts`, and `check:shared`, part of `check:ci` and the drift workflow, fails when either file is stale or when the frozen `abi/pf_params.h` or `build_module.py` disagrees with it. Nothing reads the two files yet. +- **The site's lint and test tooling is on its latest releases inside the current majors.** ESLint 9.39.5, `@testing-library/dom` 10.4.2, tsx 4.23.15 and `@types/node` 20.19.43, in `web/package-lock.json` only. ESLint 10, TypeScript 7, vitest 5, jsdom 29, jest-dom 7 and `@types/node` 26 wait until the cleanup is done. +- **The site reads the panel size and the knob numbers from `shared/constants.json`.** `harness.ts`, `controls.ts`, `matrix.ts`, `panelScreens.ts` and `deviceSim.ts` in `web/src/lib/` take the 128×64 canvas, the four knobs, 24 detents a turn, the bus maximum of 1000, the long press, the debounce and the edges in a detent from `web/src/lib/shared/constants.ts`, and keep every name they export. The guide's panel screens cite firmware constants by name instead of by line number, which had drifted. Nothing a page shows changes, and the rebuilt sandbox differs only where a literal became its constant's name. +- **Drizzle is on its latest patch releases.** drizzle-orm 0.45.4 and drizzle-kit 0.31.11, in `web/package-lock.json` only. +- **Tailwind is on its latest 4.x release.** `tailwindcss` and `@tailwindcss/postcss` 4.3.3, in `web/package-lock.json` only. +- **React Three is on its latest releases inside the current majors.** `@react-three/fiber` 9.8.2, `@react-three/drei` 10.7.9 and `@react-three/postprocessing` 3.2.0, in `web/package-lock.json` only. +- **zustand is on its latest release inside the current major.** 5.0.15, in `web/package-lock.json` only. +- **mediabunny is on its latest release inside the current major.** 1.61.4, in `web/package-lock.json` only. A Pattern Lab clip keeps the same video; its header now names `Mediabunny v1.61.4`, and an MP4 also carries a `btrt` bitrate box. +- **@google/genai is on its latest release inside the current major.** 2.30.0, in `web/package-lock.json` only. Pattern Lab's Generate sends Gemini the same request body; the SDK version in its `x-goog-api-client` header now reads 2.30.0. +- **posthog-js is on its latest release inside the current major.** 1.438.10, in `web/package-lock.json` only. Pageviews and clicks reach PostHog with the same names and site properties, and session replay still shows the Gemini endpoint as `(redacted)`. The PostHog chunk every page loads grows from 61 KB to 104 KB gzipped. +- **better-auth is on its latest release inside the current major.** 1.7.9, in `web/package-lock.json` only. The community's auth requests get the same answers as before, and a session made before the update stays signed in. One answer changes: a sign-in with a password over 128 characters now says "Password too long" instead of "Invalid username or password". +- **React is on its latest release inside the current major.** `react` and `react-dom` 19.3.1 in `web/package.json`, `@types/react` and `@types/react-dom` 19.3.0 in `web/package-lock.json`. The pages still run the React that Next 16.3.0 carries inside it, so the browser gets the same code. The new React runs the component tests. +- **Next is on its latest release inside the current major.** `next` and `eslint-config-next` 16.4.1 in `web/package.json`, `@next/mdx` 16.4.1 in `web/package-lock.json`. `npm audit` goes from 27 to 25: the nine advisories against Next 16.3.0 and the two against sharp 0.35.3 are gone. Pages keep their text and CSS rules. CSS module class names are shorter in production (`hryNmG_atlas`, was `Atlas-module__hryNmG__atlas`). +- **The guide's simulated board takes input that no hand made.** `DeviceSim` reads `knobs`, `params`, `paramActive`, `lanes` and `laneActive` as `/api/status` reports them, takes clicks and MIDI-note presses from a socket, lets go of one bus channel or one lane, drives its lanes from the audio WebSocket, and tells what the pattern was given and threw. The guide shows the same frames. The virtual panel of cleanup step 4 is built on it. +- **`check:virtual-panel` runs every preset on the virtual panel.** Each header in `firmware/patternflow/presets/` but the firmware-only `calib` has to be on the panel's pattern list. Each is selected over REST, runs two seconds of panel time and has to give a 128×64 `/frame.png` with no thrown error. `check:ci` and the drift workflow run it. - **/build lets you pick the case, and the 3D view shows the one you pick.** The Build panel's switch (the official v3.9 case, Besoiobiy's printed case, SimonePDA's laser-cut one; `?case=` links to one) brings up that case's card, a remix's opening with its README on GitHub, and swaps the device in the preview for that case, with the same v3.9 board, DevKit and LED panel inside (and, on Besoiobiy's case, its own knob caps). SimonePDA's case comes in its two finishes, acrylic and MDF. Each model is made by `tools/case-models` from the case's own files in this repository (the guide's v3.9 case, Besoiobiy's STLs, SimonePDA's cutting layout) and sits beside the case's photos, `web/public/cases//model.glb`. The model the page loads first is 0.32 MB, where the old v3.0 one (`3dforweb.glb`, now removed) was 1.15 MB; a remix's model loads when it is picked, and the guide takes its LED panel from the official one. +- **/build has a fourth case, mbchars' horizontal desktop one, in the switch and in the 3D view.** Its card is its README's facts and the author's photos, and the switch is four tabs, two by two on a phone. `tools/case-models/mbchars.py` puts its model together from the remix's STLs, since no assembled file exists, and checks it against the README; in it the panel lies the long way (`ledTurn` in `placement.json`), the official knob is printed white (`knobLook`), and the orange is the one in the author's 3MF, `#F46B16` (`pla_orange`). The model is 0.38 MB and loads when the case is picked; the other three rebuild byte for byte. The case is 41 cm across, so on a desktop's tall view it is drawn smaller, by as little as keeps it clear of the arrows; on a phone all four cases, and on a desktop the three upright ones, are still drawn at the one scale. The build map's pin for the same build goes by the maker's name now, mbchars (it was extraplojo). +- **The 3D view on /build has arrows and a name.** On the Build tab, arrows either side of the device step through the cases, round and back to the first, as the build map's arrows step through builds; the switch, the card and `?case=` follow. Over the device is whose case it is, the name on its tab (Official, Besoiobiy, SimonePDA, mbchars), in the build map's label. - **The preview's case is white, not beige, and the rest of it looks more like the photos.** The printed parts are a neutral white PLA (`#f4f4f2`, matte), where the guide's warm white had come along; the case front measures b\* +0.4 and L\* 89.8 where it was +1.7 and 82.5. The frame goes through the shoulder of the Neutral tone curve, which leaves what is under its knee as lit and rolls off what is brighter with its channels together, so a bright light no longer clips red and green first and leaves the white yellow. The light is made on the page from light panels, as on the guide's stage, instead of an environment map fetched from a CDN at run time. The LED panel is a grid of round LEDs on a black mask where it was solid blocks on grey (each LED becomes its whole cell once it is under about three pixels across, as on a phone), its sides and back are the panel's black frame, and what glowed before (the whites, and the bright yellows and cyans of a pattern from the editor) glows as before. The official knob is the printed knob's own profile drawn round, where it was a 32-sided prism. SimonePDA's clear acrylic is clear: the canvas is see-through, and every partly covered pixel used to reach the page far too bright, which drew the sheet frosted white; its three feet, which no photo shows on it, now come out only with the parts and on Assemble. Every case is drawn at one scale, so the LED panel is the same size in all three (the remixes drew it 3.5 to 4% smaller). The three models were rebuilt with `tools/case-models/build.sh`; the official one grows by 0.8 KB. +- **The guide's first flash starts in download mode, and its Wi-Fi assumes the step won't come.** The Play guide's 01 Flash (`/guide/play`, and `/ko`), where the Build guide sends a new DevKit, now has hold BOOT, tap RST, let go as its fourth card, before Flash Patternflow, every first flash, instead of a fallback for a missing port. The Wi-Fi card says plainly that after an install from download mode the dialog usually ends without asking, and gives the moves in order: close it, press RST once, Flash Patternflow again, the port again, Connect to Wi-Fi; its flasher screens lead with that path ("Usually: after RST"), the install-right-away case second, and it carries its own Flash Patternflow button. A new last card, after Power, is the panel's own network for when that didn't take: hold K2 for HOTSPOT and `patternflow-xxxx`, join it with the password `patternflow`, open `http://192.168.4.1/` and pick your network on the Wi-Fi page, with what to do when it isn't in the phone's list yet (it comes up about 15 seconds after power-on). The stage follows the cards, and the device simulator draws the NETWORK screen's hotspot (`setHotspot`). The Build guide's flash card, the Pattern tab's notes under Flash Patternflow and the breadboard page say the same order. Anchors `#flash-1` to `#flash-3` are where they were; `#flash-4` is now download mode. +- **`/flash` goes to the guide's Flash chapter.** It had no page, though the device's Update page ("flash it over USB at patternflow.work/flash") and every release's FLASHING.md send people there; it now redirects to `/guide/play#flash`. `/flash/manifest.json` and `/flash/bin/*` are untouched. ### Docs +- **The build service docs describe the worker that runs today.** `docs/SERVICES.md` and `web/.env.example` still said the worker compiles firmware with arduino-cli and offered `ARDUINO_CLI_PATH` and `BUILD_FQBN`, which nothing has read since whole-image builds ended on 12 August. `.env.example` now lists `PF_XTENSA_BIN`, `PF_TARGET_ABI` and `GITHUB_TOKEN`, and `src/lib/envExample.test.ts` fails when it, the code and the table in `web/ARCHITECTURE.md` disagree. +- **The docs index, the repository map and the contributor guide describe the tree as it is.** `docs/README.md` lists the cleanup RFC and its log, the rename list in `docs/REPOSITORY.md` names all 30 files the site's absolute links reach (it had ten and the BOM), `CONTRIBUTING.md` names the four build kinds and `originId`, and `docs/EDITIONS.md` says `build.sh all` builds five compositions. +- **The cleanup's progress log records steps 1 to 5 and the gate.** `docs/rfc-cleanup-progress.md` has an entry for each step and checks the six items of §2.12 at `9dff573`, and all six hold. `docs/rfc-cleanup.md` is marked done. +- **The cleanup has a baseline.** `docs/investigations/2026-10-cleanup-baseline.md` records the commit the cleanup starts from and what the five commands in `docs/rfc-cleanup.md` §2.1 said there. All five pass. `AGENTS.md` gains hard rule 8: during a cleanup window no PR changes behaviour, and moves and renames carry a redirect or an updated link map. +- **Cleanup step 1 is planned.** [`docs/investigations/2026-10-cleanup-step1-plan.md`](docs/investigations/2026-10-cleanup-step1-plan.md) cuts the step into three lanes that share no file (root moves, the changelog split, the ko/en parity list), lists what the RFC did not know about the tree (`docs/releases/` already exists; the ko/en pairs it names do not), and asks the maintainer five more questions, each with a default. +- **The v2 build guide moved to `docs/legacy/BUILD_GUIDE_v2.md`.** Every link to it in the repository and the site's v2 board link point at the new path; links from outside to the old root path break, since GitHub serves no redirect for a moved file. +- **The doc map lists the Korean/English pairs.** `npm run doc-map` in `web/` also writes `docs/investigations/2026-10-doc-parity.csv`: every text the repository keeps in both languages (the journal's posts, the guide's pages and copy, the roadmap, the atlas), one row per pair, with each side's length and whether one is missing or short. Korean is measured at 2.4 English characters a syllable, as the journal runs, so a faithful translation reads `ok`; today it flags 26 rows, among them the device's Format storage dialog, which the Korean guide quotes in English. A later `x.ko.md`, a console page's twin or a `Ko` field in `features-data.ts` lands in the same list. +- **The doc map reads the YAML under `.github/`.** `npm run doc-map` in `web/` now follows the links in the issue forms and their `config.yml`, so a PR that moves `SUPPORT.md` or `CONTRIBUTING.md` sees the contact link it would break. Six rows today, all resolving. +- **`CHANGELOG.md` is split by release into `docs/releases/`.** Every release has its own file, `vX.Y.Z.md`, back to v1.0.0, with its section copied as written and its relative links two folders up; the root keeps the three newest in full and a line linking each older one. `release.py cut` writes the new release's file and moves the fourth-newest section out of the root. The long-form notes for 3.0.0 and 3.1.0 are now `v3.0.0-notes.md` and `v3.1.0-notes.md`. +- **The community files moved to `.github/` and the audio guide to `docs/`.** `CODE_OF_CONDUCT.md`, `SECURITY.md` and `SUPPORT.md` are now in `.github/`, where GitHub still finds them for the community profile and the security tab, and `AUDIO_GUIDE.md` is `docs/AUDIO_GUIDE.md`. Every link to them in the repository, the issue chooser and the site points at the new paths. Links from outside to the old root paths break, since GitHub serves no redirect for a moved file. +- **Cleanup step 2 is planned.** [`docs/investigations/2026-10-cleanup-step2-plan.md`](docs/investigations/2026-10-cleanup-step2-plan.md) cuts the seven drift checks, the changelog check, the human-text lock and the voice lint into fourteen PRs that share no file, and records what the tree already holds for each: the schematic's text layer carries every designator, and `check_presets.py` already covers half of `check:presets`. +- **Cleanup step 3 is planned.** [`docs/investigations/2026-10-cleanup-step3-plan.md`](docs/investigations/2026-10-cleanup-step3-plan.md) cuts §2.5 into twenty-seven PRs, no two of which edit a file while both can be open, and records what scouting found: the MIDI page still counts 20 detents a turn where the encoder has 24, the HUB75 fork carries more local changes than `VENDORED.md` lists, and first-party code has no `TODO` or `FIXME` left. +- **The project's writing voice is written down.** `docs/voice.md` describes, on one page, the voice the README and the build guide already use for English, and a standard for Korean taken from published Korean editing guides: the marks of Korean carried over from English, with two lines from the web guide shown beside a version written in Korean first. `docs/voice-banned.txt` starts the list of words that stay out of the docs, for the voice check the cleanup adds later. +- **Cleanup step 4 is planned.** [`docs/investigations/2026-10-cleanup-step4-plan.md`](docs/investigations/2026-10-cleanup-step4-plan.md) cuts §2.6 into twenty-four PRs, no two of which edit a file while both can be open, and records what scouting found: the virtual panel can be the guide's `DeviceSim` behind sockets, `/api/status` shows no knob delta that arrives over OSC, MIDI, MQTT or the audio socket, and `rest-api.md` contradicts itself in eight places a vector has to choose between. +- **The docs index and the repository map catch up with step 1 of the cleanup.** `docs/README.md` and `docs/REPOSITORY.md` say the root keeps three guides and no others, describe `docs/releases/` as one file per release, and list `voice.md`. The doc map is regenerated, and all 1066 of its links resolve. +- **Cleanup step 5 is planned.** [`docs/investigations/2026-10-cleanup-step5-plan.md`](docs/investigations/2026-10-cleanup-step5-plan.md) cuts §2.7 into four PRs: a record of every place the panel size is set, the any-resolution draft, the Linux player draft, and a close that waits until steps 3 and 4 free the files it shares with them. Scouting found that the 36 references §2.7 counts are a floor (the presets alone carry 142), that the site's pattern engine already takes any size while the device and its simulator do not, and that the S-PWM panels the firmware cannot drive are the ones a Colorlight receiving card, one of the player's outputs, is built for. +- **The contributor docs say what CI runs on every pull request.** `CONTRIBUTING.md`, `web/README.md`, `web/ARCHITECTURE.md` and `docs/REPOSITORY.md` now name `drift.yml`, which runs the drift checks and `check:human-lock` on every pull request whatever it touches. `docs/REPOSITORY.md` no longer says the absolute `blob/main` links go unchecked: `check:links-absolute` fails a pull request that breaks one. +- **`AGENTS.md` carries the writing-voice rule and the step 2 check commands.** The writing-voice hard rule from `docs/rfc-cleanup.md` §2.10 is appended as the RFC writes it, and Common commands gains one line each for `check:drift`, `check:human-lock` and `check:voice`, saying where each runs. +- **The doc map is regenerated at the close of step 2 of the cleanup.** All 1072 of its links resolve. The six new rows are links the step 2 pull requests and the step 5 plan added. +- **Where the panel size lives, line by line.** `docs/investigations/2026-10-panel-geometry.md` counts every place the firmware and the site set or assume 128×64 at `e3d3e83`, from the 36 `PANEL_RES_W/H` uses §2.7 names to the presets, the module toolchain, the driver's DMA buffers and the site's simulator, with the command behind each count. It proposes nothing. The any-resolution and Linux player drafts build on it. +- **`docs/midi-spec.md` says 24 detents a turn.** `outDiv=1` is 24 steps a turn and `4` is 6, where it said 20 and 5. +- **The any-resolution RFC has a draft.** `docs/rfc-any-resolution.md` is Part 2 only. It says where each of the 36 uses of `PANEL_RES_W` and `PANEL_RES_H` goes, proposes a geometry read from NVS at boot and set on a core console page, keeps one module build per size with the size in the cache key, states the limits (classic HUB75 only, S-PWM unsupported, W × H up to 8,192 at depth 8, the stock's own memory) and orders the work as 19 PRs. Part 1 is the maintainer's and is empty. +- **The Linux player has a draft RFC.** `docs/rfc-linux-player.md` is Part 2 of the player path in the cleanup RFC's §2.7: the site's engine in a Linux process, with outputs for a Pi's HUB75 HAT through rpi-rgb-led-matrix, a Colorlight receiving card and HDMI, input from USB encoders, MIDI, OSC and the web knobs, the contracts it speaks and in what order, what it cannot do, the work as fourteen PRs, and nine questions for the maintainer. Part 1 is left for him. Nothing the firmware or the site does changes. +- **`AGENTS.md` names `shared/`, the HUB75 driver's `UPSTREAM.md` and `patternflow.patch`, and the commands step 3 added.** The repository map has `shared/constants.json` and the two files generated from it, and says the patch is rebuilt with the command in `UPSTREAM.md`, never edited by hand. Common commands gain `gen:shared` with `check:shared`, the lint gate (`--max-warnings=0`, an exception with its reason after `--`), and the `markers` drift check. +- **The two step 5 drafts are listed in `docs/README.md`.** `rfc-any-resolution.md` and `rfc-linux-player.md` link each other, and the doc map carries their rows. +- **The repository maps catch up with step 3.** `docs/REPOSITORY.md` has a top-level row for `shared/`. `web/ARCHITECTURE.md` says the pattern harness takes its constants from `shared/constants.json`, and calls `patternflow_lab_gallery_v1` the live gallery key instead of a legacy one. `firmware/README.md` sends readers of its vendored HUB75 note to `UPSTREAM.md` and `patternflow.patch` instead of naming two additions. +- **The doc map is regenerated at the close of step 3 of the cleanup.** All 1095 of its links resolve. The three new rows are links step 3 added to `docs/REPOSITORY.md` and `firmware/README.md`. +- **The REST contract has its first vectors.** `docs/rest-api-vectors/` holds nine, for `GET /api/status`, `POST /api/params` and `POST /api/sleep`, each a sentence of `docs/rest-api.md` and the requests that show a panel doing it. `npm run check:rest-vectors` in `web/` replays them against a fresh virtual panel each, and the drift workflow runs it on every pull request. +- **The MQTT contract has its first vectors.** `docs/mqtt-vectors/` holds nine, for the topic table and the two roles in `docs/mqtt-spec.md`: the sleep words and `sleep/state`, `param/N` and its empty payload, `knob/N` as a difference, `pattern` by name or slug, and what a Publisher ignores and sends. `npm run check:mqtt-vectors` in `web/` replays them against a fresh virtual panel each, through a test broker of its own (`web/scripts/drift/_broker.ts`), and the drift workflow runs it on every pull request. +- **The REST vectors reach the rest of the core.** Twenty-five more in `docs/rest-api-vectors/` read `/api/display`, `/api/knobs`, `DELETE /api/crash`, the favicon, the pattern routes and the headers every route shares. On the default build `caps` is `["patterns","params","sleep"]` and each feature's route answers the plain-text 404. +- **The MQTT vectors reach the channels and the snapshot, and `/api/mqtt` has REST vectors.** Six more in `docs/mqtt-vectors/` replay the prefix naming the channel, channels 1–4 forcing Subscriber, the retained snapshot on channels 1–4 and Live, the Publisher's eight-second heartbeat, a snapshot overwriting a `param/N` write, and Broadcast taking no snapshot. Seven in `docs/rest-api-vectors/` read `GET` and `POST /api/mqtt`: its fields, the password it never returns, `forcesSub`, the knob counts in every role, `mqttRole` in status and the `mqtt` cap. +- **The maps of the tree name the virtual panel and the contract vectors.** `docs/README.md` puts each contract's vector folder on its line, as it already did for pfst, and adds `docs/contract-vectors.md`. `tools/virtual-panel/README.md` lists its eight transports with the feature each needs and the port it answers on. `CONTRIBUTING.md` says a spec change brings its vectors along, and `web/README.md` and `web/ARCHITECTURE.md` name the five vector suites and `check:virtual-panel`. +- **The REST and MIDI specs say one thing where they said two.** HTTP writes knobs and reads them from `/api/status`, select answers with the pattern it queued, `d1`..`d4` alone are a whole `POST /api/params`, `caps` names what each edition adds, and only a path no route serves gets a plain-text `404`. In midi-spec.md, CC 24–27 are binary offset and a host's Program Change comes back as the pattern change it made. +- **`AGENTS.md` names the virtual panel, the contract vectors and their commands.** The repository map adds `mqtt-spec.md`, the vector folder beside each of the five wire contracts with `contract-vectors.md` as their format, and `tools/virtual-panel/`. Common commands gain the five `check:*-vectors` suites, `check:virtual-panel` and `npm run virtual-panel`. +- **The doc map is regenerated at the close of step 4 of the cleanup.** All 1138 of its links resolve. The 43 new rows are links step 4 added: the five vector folders' READMEs, `docs/contract-vectors.md`, `tools/virtual-panel/README.md`, and the maps of the tree that point at them. - **The build pages say what is true now.** Laser cut is no longer "being prepared" on /build, in the README, the v2 guide or `llms.txt`: SimonePDA's case is it. The printing time is about 10 hours for the official case on a 256 mm bed (/build said 10–12, the breadboard guide 11), the v2 guide and the assembly map name the v3.9 board, and the breadboard guide flashes through the DevKit's native USB port from the Pattern tab and links the sections that exist. +- **The README opens with what Patternflow is and three ways in.** The tagline and the first line are the manifesto's fixed lines (L1, L2, L3), with *Try one in your browser*, *Build one* and *Get one assembled* above the badges, and the guide's four parts follow as a list, each with its written guide beside it. Paik is told once, in *Where it began*, closed by L5 in full. Patterns come before the device, and **First light** follows the build: J4-only power, download mode first, the Wi-Fi step that usually doesn't come and what to do, unplugging before the DevKit is seated, the panel's own network, the Basics pack with *Format storage*. Lines that had gone stale were brought up to date: the Crowd Supply campaign reads as it stands, past its goal and open until 15 October, sound and MQTT shipped in editions, the hand-drawn map that still showed the Workshop is out, and the timeline runs to October. The long reference material stays, folded. +- **The READMEs and docs were tidied.** Where a doc gives the first flash (`firmware/README.md`, `PATTERN_GUIDE.md`, `SUPPORT.md`, `docs/assembly/`) it now says download mode first, that the Wi-Fi step usually doesn't appear the first time and what to do, and the hotspot. The indexes (`docs/README.md`, `docs/REPOSITORY.md`, the hardware and remix READMEs, `web/README.md`, `web/ARCHITECTURE.md`) list the guide pages, the three case remixes, `legacy_lasercut/` and the bundles that exist, and stale wording went: the NETWORK screen's old rows, the custom slots, "variant", the dead `/flash` page. `BUILD_GUIDE_v2.md` is as it was. +- **The build guide flashes in the Play guide's order.** `BUILD_GUIDE.md` §8.1 is the Play guide's Flash chapter in writing, step for step: download mode (hold BOOT, tap RST, let go) before Flash Patternflow on every first flash, where it had been the fallback for an empty port list; the Wi-Fi step expected not to come the first time, with RST, Flash Patternflow again and *Connect to Wi-Fi* as the moves; the DevKit seated with the power bank unplugged, then powered from `J4`; and the panel's own network, `patternflow-xxxx`, as the next step when that didn't take, before the Basics pack. Section 7 no longer seats the DevKit before it has firmware, so it is *Wiring* now (anchor `#7-wiring`), and the README's First light points at §8 again as the same steps in writing. Besoiobiy's case README cites the renumbered steps. + +### Tools + +- **A virtual panel for the contract vectors.** `tools/virtual-panel/` runs the guide's `DeviceSim` behind an HTTP port: `GET /frame.png` is the 128×64 frame, `/vp/` is a hand and the clock, and each transport is a file in `transports/`. `npm run virtual-panel` in `web/` starts it. +- **The virtual panel answers device state over REST.** `tools/virtual-panel/transports/rest-state.ts` serves `GET /api/status` with every field `docs/rest-api.md` documents, `POST /api/params`, `POST /api/sleep`, `GET /api/display`, `/api/knobs`, `DELETE /api/crash` and the favicon, on the guide's `DeviceSim`. +- **The virtual panel answers the pattern routes.** `GET /api/patterns`, `/api/patterns/select` and `/api/patterns/file` on `tools/virtual-panel/`: the Basics pack's `.pfm` and sidecar out of `basics.zip`, and the `PFT1` picture a pattern leaves once it has run two seconds. +- **Contract vectors have a format and a replay.** `docs/contract-vectors.md` says how a vector is written: the spec sentence it comes from, the features built in, and steps that send over HTTP, a hand, the clock or one of the four other transports and expect a reply, `/api/status`, what the pattern was given or what the panel sent and how soon. `web/scripts/drift/_vectors.ts` replays a folder of them, each against a fresh virtual panel, for the five suites of cleanup step 4. +- **The virtual panel speaks MQTT.** `tools/virtual-panel/transports/mqtt.ts` is an MQTT 3.1.1 client on Node's `net`: the Publisher and Subscriber roles, the Broadcast, channel 1 to 4, Live and Custom prefixes, every public topic in `docs/mqtt-spec.md`, and the retained snapshot every eight seconds. Like a device that was never set up, it dials nothing until a role and a broker are set. +- **The virtual panel answers `/api/mqtt`.** On a panel started with `--features mqtt`, `tools/virtual-panel/transports/mqtt-http.ts` reports the MQTT client's state and sets its broker and role in one `POST /api/mqtt`, as `docs/mqtt-spec.md` describes. `/api/status` gains the MQTT fields the firmware adds there. +- **The virtual panel speaks OSC.** `tools/virtual-panel/transports/osc.ts` listens on 9001 and sends to port 9000 of the host that last sent it a message it knows: the announce, the heartbeat, a hand's knobs and buttons, and pattern and mode changes, as `docs/osc-spec.md` lists them. `--features osc` composes it in, and `--port oscReply=0` sends each reply back to the port it came from. +- **OSC has contract vectors.** `docs/osc-vectors/` holds eleven, each one sentence of `docs/osc-spec.md` and the messages that show a panel doing it, from the ping and its announce to a `#bundle` the panel ignores. `npm run check:osc-vectors` in `web/` replays them against the virtual panel built with the `osc` feature, and CI runs it with the other drift checks. +- **The virtual panel takes the audio WebSocket.** `tools/virtual-panel/transports/audio-ws.ts` listens on 81 for the five messages `docs/audio-ws-spec.md` lists and holds each lane 500 ms past the last message that set it. `GET` and `POST /api/audio` read and flip the AUD switch. `--features audio` composes it in, and `--port ws=…` moves the socket. +- **The virtual panel speaks RTP-MIDI.** `tools/virtual-panel/transports/midi.ts` takes a session on 5004 and 5005 under the name Patternflow, two participants at most, and moves knobs, buttons and the pattern by the host → device table in `docs/midi-spec.md`: CC 20 to 27, notes 60 to 63 and Program Change on channel 1. `--features midi` composes it in, and `--port midi=N` moves the pair to N and N+1. +- **The audio WebSocket has contract vectors.** `docs/audio-ws-vectors/` holds twelve, each one sentence of `docs/audio-ws-spec.md` and the messages that show a panel doing it, from `a=` with `-` slots to a lane a hand holds for five seconds. Three more in `docs/rest-api-vectors/` read `GET` and `POST /api/audio`. `npm run check:audio-vectors` in `web/` replays them against the virtual panel built with the `audio` feature, through a WebSocket client that runs on Node 20, and CI runs it with the other drift checks. +- **The virtual panel sends MIDI back.** `tools/virtual-panel/transports/midi-out.ts` reports a hand's turns on CC 24 to 27 (a position by default, 64 ± steps with `outMode=rel`, at most one message a knob every 50 ms), a hand's buttons on notes 60 to 63 and each pattern change as Program Change, by the device → host table in `docs/midi-spec.md`. It answers `GET` and `POST /api/midi` and adds `midi` to `/api/status`. +- **MIDI has contract vectors.** `docs/midi-vectors/` holds nine, each one sentence of the host → device table in `docs/midi-spec.md` and the messages that show a panel doing it, from CC 20 at 127 landing on 1000 to a message on channel 2 the panel ignores. `npm run check:midi-vectors` in `web/` replays them against the virtual panel built with the `midi` feature, over an RTP-MIDI session `web/scripts/drift/_rtpmidi.ts` opens for each, and CI runs it with the other drift checks. +- **MIDI out and `/api/midi` have contract vectors.** Eleven files in `docs/midi-vectors/` start with `out-`, each one sentence of the device → host table or the settings in `docs/midi-spec.md`, from three detents on knob 1 arriving as CC 24 at 67 to a host's own CC that never comes back. `check:midi-vectors` replays them over its RTP-MIDI session. Nine more in `docs/rest-api-vectors/` start with `midi-` and read `GET` and `POST /api/midi` and the `midi` block in `/api/status` under `check:rest-vectors`. ## [3.11.0] - 2026-10-04 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c7d695cde..8e675ccba 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,24 +1,27 @@ # Contributing to Patternflow -Patternflow is a device, a firmware, a website and a pile of documentation, and people contribute to all of them. This page says where each kind of contribution goes, what checks it meets, and the few rules that are not negotiable. Where to *ask* things is [SUPPORT.md](SUPPORT.md); how we treat each other is [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md); what is where is [docs/REPOSITORY.md](docs/REPOSITORY.md). +Patternflow is a device, a firmware, a website and a pile of documentation, and people contribute to all of them. This page says where each kind of contribution goes, what checks it meets, and the few rules that are not negotiable. Where to *ask* things is [SUPPORT.md](.github/SUPPORT.md); how we treat each other is [CODE_OF_CONDUCT.md](.github/CODE_OF_CONDUCT.md); what is where is [docs/REPOSITORY.md](docs/REPOSITORY.md). ## Where your contribution goes | You have… | It goes in | A change touches | What CI runs on it | | :--- | :--- | :--- | :--- | | A fix to a guide, a README or a spec | the markdown file itself: the root guides, `docs/`, any folder's README | one file | every relative link must resolve (`docs-links`) | +| A fix to the **interactive guide** at [patternflow.work/guide](https://patternflow.work/guide) (Build, Play, Make, Audio) | its words are TypeScript, English and Korean side by side: `web/src/components/guide/copy.ts` for Play, `web/src/components/guide/copy/` for Build, Make, Audio and the hub | the copy, both languages | `web-ci` | | A fix to the **breadboard** build guide | `web/src/app/build/breadboard/page.tsx` — that guide is React, not markdown | JSX text | `web-ci` (`next build`) | | Your finished build, for the [build map](https://patternflow.work/inside) | `web/src/components/sections/InsideGlobe/builds.ts` plus a photo folder `web/public/builds//` — see [Your build](#your-build). Not a git person? The [Share your build](../../issues/new?template=share_build.yml) form does the same | one `Build` entry and its photos | `web-ci` (the typecheck rejects a malformed entry) | | A firmware **feature** | `firmware/patternflow/features//`, plus one line in the bundle it joins (`firmware/bundles//features_local.h`) | the folder, the bundle line, an inventory row in `features/README.md`; a console page also adds a row to `firmware/toolchain/console_pages.py`; a library also goes in `platformio.ini` | `firmware-checks` (boundary, ABI freeze, presets, console pages…) and `firmware-build` (every edition, from scratch) | | A fix to the firmware **core** (`patternflow.ino`, `src/`) | the file itself — for a bug or a real improvement to the device, never to make room for a feature | | the same, and the change must still be right on a build that has no features at all | | An enclosure or mechanical **remix** | `hardware/case/remixes/-/`, with a README that states Author, License, what it is based on, which board it fits, and the print or cut settings | one new folder; the official folders stay as they are | the README fields are checked | | A **bridge** to host software (TouchDesigner, Node-RED, Max, a DAW…) | `integrations//` with a README, plus a row in `integrations/README.md`. It talks to the panel through the contracts in `docs/` and never imports firmware or web code | one new folder | the README has to name the contract it is built against | -| A change to a **wire contract** | [`docs/rest-api.md`](docs/rest-api.md), [`osc-spec.md`](docs/osc-spec.md), [`midi-spec.md`](docs/midi-spec.md), [`mqtt-spec.md`](docs/mqtt-spec.md), [`audio-ws-spec.md`](docs/audio-ws-spec.md), [`pfst-v2-spec.md`](docs/pfst-v2-spec.md) | the spec: bump its version line and add a Version history row | `docs-links` | -| Web app work | `web/` — start at [`web/README.md`](web/README.md) and [`web/ARCHITECTURE.md`](web/ARCHITECTURE.md) | | `web-ci`: lint, typecheck, the `check:*` suites, `next build` | +| A change to a **wire contract** | [`docs/rest-api.md`](docs/rest-api.md), [`osc-spec.md`](docs/osc-spec.md), [`midi-spec.md`](docs/midi-spec.md), [`mqtt-spec.md`](docs/mqtt-spec.md), [`audio-ws-spec.md`](docs/audio-ws-spec.md), [`pfst-v2-spec.md`](docs/pfst-v2-spec.md) | the spec: bump its version line and add a Version history row. A vector that quotes a sentence you change changes with it, in the folder beside the spec ([`contract-vectors.md`](docs/contract-vectors.md)) | `docs-links`, and `drift`, which replays every vector against the [virtual panel](tools/virtual-panel/README.md) | +| Web app work | `web/` — start at [`web/README.md`](web/README.md) and [`web/ARCHITECTURE.md`](web/ARCHITECTURE.md) | | `web-ci`: lint, typecheck, `check:ci` (the smoke suites and the drift checks), `next build` | | A **pattern** | **not this repository.** Make it in the [Pattern Lab](https://patternflow.work/pattern-lab), press **Publish**, and it is on the [Community](https://community.patternflow.work/community) for every Patternflow to play, under the license you choose. [PATTERN_GUIDE.md](PATTERN_GUIDE.md) walks the loop. The presets bundled in the firmware and the Basics pack are curated by the maintainer from the repository's own sources | | | Parts sourcing tips, better photos of a build step, a clearer sentence: all of those are the first row. +Every pull request, whatever its row, also runs `drift`. It fails when two things the tree keeps by hand stop agreeing, the BOM and the parts table in `BUILD_GUIDE.md` for one, and when a word of the maintainer's own writing changes ([docs/rfc-cleanup.md §2.9](docs/rfc-cleanup.md#29-human-written-text-lock) names the files). + ## Sending it 1. Fork the repository and branch from `dev` (`fix/…`, `feat/…`, `docs/…`). @@ -75,7 +78,7 @@ A pin on the map is one entry in [`web/src/components/sections/InsideGlobe/build Inside includes builds, projects derived from Patternflow, and places where it is used. Add one entry to `web/src/components/sections/InsideGlobe/builds.ts`: a short `title`, `maker`, public `location`, `date`, `description`, and any external `links`. Photos are optional. Keep existing `id` and `slug` values stable so shared links keep working. -Choose one primary `category`: `builds` for assembled devices and enclosure changes, `projects` for ports and new tools, or `in-use` for installations, performances and other uses. This is separate from `kind`, which preserves the existing build/collaboration relationship and marker style. A maker can have several entries: a new tool and a later exhibition are different records. Link to the maker's own documentation for ongoing updates; a short introduction here is enough. Do not add unannounced work or assume an event happened from a sale or a planned appearance. +Choose one primary `category`: `builds` for assembled devices and enclosure changes, `projects` for ports and new tools, or `in-use` for installations, performances and other uses. This is separate from `kind`, which says what the pin is and picks its marker on the globe: `build`, `collaboration`, `sold` or `showing`, each explained in the comment above `BuildKind`. A collaboration, a sale or a showing names the pin it grew out of in `originId`, and the globe draws a line back to it. Left out, the line goes to the first build in the file. A maker can have several entries: a new tool and a later exhibition are different records. Link to the maker's own documentation for ongoing updates; a short introduction here is enough. Do not add unannounced work or assume an event happened from a sale or a planned appearance. Classify the subject of the entry, not its surroundings. A completed device photographed in a studio or DJ booth is still a `builds` entry. Use `in-use` when the entry documents actual use in an exhibition, performance or installation; a sale or delivery alone is not enough. Leave a category empty until a qualifying case is available. diff --git a/FEATURE_GUIDE.md b/FEATURE_GUIDE.md index 96db5e767..8b16150c4 100644 --- a/FEATURE_GUIDE.md +++ b/FEATURE_GUIDE.md @@ -160,7 +160,9 @@ python firmware/toolchain/console_pages.py check # if you added a console page Compiling is not testing — most historical regressions were invisible on the build in front of whoever caused them, which is why `build.sh all` exists. CI runs the same set ([`firmware-checks.yml`](.github/workflows/firmware-checks.yml), -[`console-sync.yml`](.github/workflows/console-sync.yml)). +[`console-sync.yml`](.github/workflows/console-sync.yml), and +[`firmware-build.yml`](.github/workflows/firmware-build.yml) for every +composition, one job each). If the user asks for a PR against this repository: it should touch only `features//` plus a bundle, pass everything above, and follow diff --git a/PATTERN_GUIDE.md b/PATTERN_GUIDE.md index ca4cf7173..3ba06a3da 100644 --- a/PATTERN_GUIDE.md +++ b/PATTERN_GUIDE.md @@ -24,22 +24,30 @@ in a window, and a practice AI to take the two prompts. ## 0. Before you start -**A USB cable is for exactly one moment.** The very first browser flash at -[patternflow.work/pattern](https://patternflow.work/pattern) — you set up -Wi-Fi as part of it, and from then on **everything is wireless.** Whether -you're in the community or in Pattern Lab, every path a pattern takes to the -device goes over Wi-Fi. +**A USB cable is for exactly one moment: the very first flash**, with the +ESP32-S3 out of the board, from **Flash Patternflow** at +[patternflow.work/pattern](https://patternflow.work/pattern). Put the module +in download mode first — hold **BOOT**, tap **RST**, let go of BOOT — then +press the button. The Wi-Fi step usually does not appear after that first +install, and that is expected: press **RST**, run **Flash Patternflow** again +and choose **Connect to Wi-Fi**, or seat the module, power the panel and join +the network it raises for itself, `patternflow-xxxx` (password +`patternflow`), to set Wi-Fi at `http://192.168.4.1/`. From then on +**everything is wireless.** Whether you're in the community or in Pattern +Lab, every path a pattern takes to the device goes over Wi-Fi. So this whole guide rests on one assumption: **the device is powered on and on the same Wi-Fi network as your computer.** If you haven't done the first flash and Wi-Fi setup yet, finish the -[firmware section of BUILD_GUIDE](BUILD_GUIDE.md#8-firmware) first. +[firmware section of BUILD_GUIDE](BUILD_GUIDE.md#8-firmware) first, or the +Play guide's [Flash chapter](https://patternflow.work/guide/play#flash), which +walks it on a board you can turn. | You need | Why | | --- | --- | | An assembled, flashed Patternflow | It's what this guide is about | | The device connected to Wi-Fi | The premise of every transfer here | -| Firmware **v3.2.0 or newer** | Pattern modules (wireless install) need it | +| Firmware **v3.10.5 or newer** (the flasher installs the current release) | Pattern modules (wireless install) need v3.2.0, the Basics pack v3.5.1, and the larger patterns, as they are built now, v3.10.5 | | A community account (optional) | Browsing, knobs and deck-building work without one. Sending to your device (building) and sharing / liking / forking need a sign-in | The device's page lives at **`patternflow.local`**. Android can't resolve @@ -55,7 +63,7 @@ travels to the device as a module — a `.pfm` file of a few KB — and arrives over Wi-Fi in seconds.** The firmware is not rebuilt and nothing is reflashed. The buttons wear slightly different names on different screens — -`Send to my Patternflow`, `SEND TO MY BOARD`, `APPLY TO MY PATTERNFLOW` — but +`Send to my Patternflow`, `SEND TO MY BOARD`, `Apply to my Patternflow` — but they are all the same thing: compile this pattern into a module, hand it to the board. @@ -208,8 +216,7 @@ Now for the making side. Click **Pattern Lab ↗** at the community's top right. Pattern Lab has a lot of panels, but **this guide uses exactly two things: pattern generation and the color ramp.** They're enough to take a pattern from -nothing to your device. Layers, pixel art, the gallery — all of that is for a -later guide. +nothing to your device. Layers and pixel art are for a later guide. ### 4-1. Pick a resolution first @@ -313,7 +320,7 @@ to **try it on the board first.** ![On to hardware](docs/images/pattern-guide/16-on-to-hardware.png) -1. Press **`APPLY TO MY PATTERNFLOW`** — it builds the module and takes +1. Press **`Apply to my Patternflow`** — it builds the module and takes seconds. 2. In the dialog, press **`Send over Wi-Fi`** — with the device on and connected, it uploads immediately. @@ -363,11 +370,12 @@ onto their device. The loop has come all the way around. | Symptom | Check | | --- | --- | +| The panel isn't on Wi-Fi, or the flasher never asked for a network | Expected after a first flash. Press **RST**, run **Flash Patternflow** again and choose **Connect to Wi-Fi** (2.4 GHz only; type the name exactly). Or power the panel, wait about 15 s, join its own network `patternflow-xxxx` (password `patternflow`; hold **K2** for the exact name; not in the phone's list yet? refresh it) and pick your network at `http://192.168.4.1/` — [BUILD_GUIDE §8](BUILD_GUIDE.md#8-firmware) | | `Send over Wi-Fi` does nothing, or fails | Device power · **same Wi-Fi network** as the computer · on Android, use the IP from the NETWORK screen (hold K2) instead of `patternflow.local` | | The device's Patterns page won't open | Same as above — try typing `patternflow.local` into the address bar directly | | A card has no `+` (add to deck) | Not a bug — the author shared it without an `.h` header (not hardware-verified) | | The `.h` conversion looks wrong on the device | Ask the AI to convert again (a plain retry often fixes it) · simplify the pattern code · try a different model | -| Modules uploaded but don't appear in the list | Firmware older than v3.2.0 — update it at [patternflow.work/update](https://patternflow.work/update) | +| Modules uploaded but don't appear in the list | Firmware older than v3.2.0 — update it at [patternflow.work/update](https://patternflow.work/update). A larger pattern that shows PATTERN FAILED on firmware older than v3.10.5 needs the same update | | A `.zip` pack was dropped but nothing installed | The pack has to contain `.pfm` modules. A zip of `.js` patterns is not a pack — build them first | | The deck's order came out wrong on the device | Re-download the pack: the running order ships inside it, and a pack built before the deck was rearranged carries the old one | | The device is out of storage | The Patterns page shows free space (KB free) at the top right — delete modules you don't use | @@ -375,5 +383,5 @@ onto their device. The loop has come all the way around. --- -*The rest of Pattern Lab — layer compositing, pixel art, the gallery — gets -its own guide later.* +*The rest of Pattern Lab, layer compositing and pixel art, gets its own guide +later.* diff --git a/README.md b/README.md index 2208549a1..5df970569 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,20 @@ +
+ # Patternflow -[![Open Source Hardware](https://img.shields.io/badge/Open_Source-Hardware-blue?style=flat-square&logo=opensourceinitiative)](https://github.com/engmung/Patternflow) +**An open-source LED synthesizer.**
+Play light patterns with your fingertips. + +**[Try one in your browser](https://patternflow.work/pattern)**  ·  **[Build one](https://patternflow.work/guide/build)**  ·  **[Get one assembled](https://www.crowdsupply.com/engmung/patternflow)** + [![License: MIT](https://img.shields.io/badge/Code-MIT-green?style=flat-square)](./LICENSE-MIT) [![License: CC BY-SA 4.0](https://img.shields.io/badge/Hardware-CC_BY--SA_4.0-orange?style=flat-square)](./LICENSE-CC-BY-SA) [![Release](https://img.shields.io/github/v/release/engmung/Patternflow?style=flat-square&color=purple&label=Release)](../../releases) -[![Crowd Supply](https://img.shields.io/badge/Crowd_Supply-Campaign_Live-d4502b?style=flat-square)](https://www.crowdsupply.com/engmung/patternflow) +[![Crowd Supply](https://img.shields.io/badge/Crowd_Supply-funded,_ends_Oct_15-d4502b?style=flat-square)](https://www.crowdsupply.com/engmung/patternflow) [![Discord](https://img.shields.io/discord/1497757947827327067?style=flat-square&logo=discord&logoColor=white&label=Discord&color=5865F2)](https://discord.gg/Vr9QtsxeTk) +
+

Patternflow: a hand turns a knob and the light reshapes Patternflow, the film: what it is, how it's built, and why @@ -15,23 +23,30 @@ ▶ the reel: 400k+ views on Instagram  ·  ▶ the film: the whole story, on YouTube

-> **Photosensitivity warning.** Patternflow displays rapidly changing light patterns that may trigger seizures in people with photosensitive epilepsy. If you experience any discomfort, stop use immediately. +> **Photosensitivity warning.** Patternflow displays rapidly changing light patterns that may trigger seizures in people with photosensitive epilepsy. Viewer discretion is advised. If you experience any discomfort, stop use immediately. + +**Every Patternflow plays every pattern we make.** Four knobs, and the pattern answers as you turn them: 8,192 points of light, every frame computed live on the device, nothing played back. A pattern is a small file, so one made anywhere plays on all of them. And every part of the instrument is open, from the circuit board to the firmware. -**Patternflow is an open-source LED synthesizer.** You play it with four knobs. 8,192 pixels across a 128 × 64 matrix respond the instant you turn one; nothing is pre-rendered, every frame is computed live on the device. A pattern is just a small file, so every Patternflow plays every pattern anyone makes. +Started by media artist SeungHun Lee ([@engmung](https://github.com/engmung)) in Seoul, and made by everyone who builds one, makes a pattern or sends a fix. -**Start with [the guide](https://patternflow.work/guide).** It builds one with you, shows you how to play it, walks you through making patterns, and connects sound and MIDI, each step on a 3D Patternflow you can turn in the browser: **[Build](https://patternflow.work/guide/build)** · **[Play](https://patternflow.work/guide/play)** · **[Make](https://patternflow.work/guide/make)** · **[Audio](https://patternflow.work/guide/audio)**. +**Start where you are.** [The guide](https://patternflow.work/guide) shows every step on a 3D Patternflow you can turn, in English and Korean, with a **Stuck here?** link on each: + +- **[Build](https://patternflow.work/guide/build):** you're soldering one from bare parts, about US$100–200 of them. Or read [BUILD_GUIDE.md](BUILD_GUIDE.md). +- **[Play](https://patternflow.work/guide/play):** yours is built. Flash it, put it on your Wi-Fi, learn the four knobs. Or read [First light](#first-light), below. +- **[Make](https://patternflow.work/guide/make):** make your own patterns, with or without a Patternflow. Or read [PATTERN_GUIDE.md](PATTERN_GUIDE.md). +- **[Audio](https://patternflow.work/guide/audio):** it plays; now add sound and MIDI with the Audio edition. Or read [AUDIO_GUIDE.md](docs/AUDIO_GUIDE.md). ## Where it began Almost every screen around us is a playback device. It takes in content and shows it to us, and we sit in front of it and watch. Patternflow is different: touch it and it answers in that instant, and nobody plays the same thing twice. -In January 2026 I stopped by the Nam June Paik Art Center, which happened to be running an event marking twenty years since Paik's death. That is how I ended up standing in front of *Participation TV* (1963): you speak into a microphone and the picture on the screen moves with your voice. It turned the viewer from someone watching into someone taking part. I spoke into it, and my voice became the picture. +In January 2026 I stopped by the Nam June Paik Art Center, which happened to be running an event marking twenty years since Paik's death. That is how I ended up standing in front of *Participation TV*: you speak into a microphone and the picture on the screen moves with your voice. It turned the viewer from someone watching into someone taking part. I spoke into it, and my voice became the picture. Two months later I connected an LED matrix and a potentiometer for the first time. -Where Paik brought the audience into the work, Patternflow hands over the making of it. +I put my first pattern, *Patternflow: Origin*, on an LED matrix with four knobs and posted it online. Some people asked how to buy it; far more asked how to build it. So everything went public: schematics, firmware, 3D models, the build guide, and the whole workflow for making new patterns. -I put that first pattern, *Patternflow: Origin*, on an LED matrix with four knobs and posted it online. Some people asked how to buy it; far more asked how to build it. So everything went public: schematics, firmware, 3D models, the build guide, and the whole workflow for making new patterns. +**A contemporary reinterpretation of Nam June Paik's *Participation TV* (1963). Paik let the audience change the image. Patternflow lets you make it, and give it away.** ## The community responded @@ -42,181 +57,239 @@ I put that first pattern, *Patternflow: Origin*, on an LED matrix with four knob Builds by Nath (UK), shooter (Poland), day (France), Enerjoy (Norway), SimonePDA (UAE), Nick (USA), and Jon (USA).

-Builders from around the world keep adding pins to the **[build map](https://patternflow.work/inside)**. Someone built theirs as a Raspberry Pi port with a 64 × 32 matrix, another layered paper and acrylic into a diffuser, others cut their own enclosures. Two of them live in DJ booths. +Two of the builds above live in DJ booths. More keep arriving on the **[build map](https://patternflow.work/inside)**: a wooden case in Mexico City, a horizontal desk case in Kyiv that is now one of the remixes in this repo, a plaster shell hanging on a wall in Germany with a Raspberry Pi inside. + +

+ Build map: a globe of Patternflows built around the world, with the story of every build +

+ +I started this alone. It hasn't worked that way for a while now. Web upload began as someone else's suggestion and proof of concept, and shipped as a main feature two days later. The pattern modules that install over Wi-Fi came from **[Simone Majocchi](https://github.com/SimonePDA)**, who designed the loader, built it and proved it on hardware before proposing it. They're core to the instrument now, and Simone went on to bring MQTT, a laser-cut case and the Performance edition. Martin Lopez has put more patterns on the Community wall than anyone, and a Patternflow went to California as thanks. -I started this alone. It hasn't worked that way for a while now. The web-upload feature wasn't mine: someone suggested it, built a proof of concept, and shared the code, and two days later it shipped as a main feature. The `.pfm` pattern modules came the same way. A contributor designed the loader, built it, and proved it on hardware before proposing it. They're core to the instrument now. +In Iran, Azmano took Patternflow's patterns, added MIDI and a microphone, and built an instrument of their own that runs in a browser, **[MOTIFLOW](https://azmano.art/portfolio-item/motiflow/)**, shown in a gallery and played at events since. Patternflow had become something people build their own instruments out of. -The clearest case started on the other side of the world. A media art collective took Patternflow's pattern modules, added MIDI control and a microphone, and built a browser-based instrument of their own: **[MOTIFLOW](https://azmano.art/portfolio-item/motiflow/)**, recently shown in a gallery. Patternflow had become something people build their own instruments out of. +Underneath is an instrument still being designed, in the open, by whoever shows up. It's already moving in directions I didn't choose, and none of it was assigned. The next one is open too: a bridge for TouchDesigner or Node-RED has a place waiting in [`integrations/`](integrations/README.md), and [Contributing](#contributing) says how to start. **Patternflow is a thing we're all making now.** ## Make it yours -### 1. The device +Try it in the Live Editor, make it in Pattern Lab, share it on Community, and it plays on every Patternflow in the world. Then build one to play it on. -The **[Full Build Guide](BUILD_GUIDE.md)** covers the official route: the custom PCB and a 3D-printed enclosure. The **[build guide in 3D](https://patternflow.work/guide/build)** is the same route, every step shown on a board you can turn. Don't want to order a board? The **[Breadboard Build Guide](https://patternflow.work/build/breadboard)** wires the same electronics with jumper wires instead, and that's a real Patternflow, not a temporary prototype. Every other combination is on the **[Assembly Map](docs/assembly/README.md)**. Parts run about US$100–200 ([BOM](BUILD_GUIDE.md#1-bill-of-materials-bom)), it's all big through-hole joints, and every first-timer who has built one finished it. Most came back saying the soldering was the fun part. +### Patterns -**When yours lights up, tell us.** Post it in [Discord](https://discord.gg/Vr9QtsxeTk) or fill in the [Share your build](../../issues/new?template=share_build.yml) form and it goes on the build map, a globe of Patternflows where each pin carries its build's story. The map is for the ones people made themselves: every pin is someone who built one from these files, in their own material, wherever they are. The goal is simple: cover it with pins. +You don't need hardware to start. The **[Live Editor](https://patternflow.work/pattern)** is a Patternflow in your browser: the same knobs, the same detents, no account and no install. Patterns are JavaScript, and you don't have to write it yourself: click **Copy creation prompt**, paste it into your AI assistant with the look you want, paste back the code it returns, and play. The template handles the encoder mapping, brightness curve and pixel buffer; you describe the look.

- Build map: a globe of Patternflows built around the world, with the story of every build + Live Editor: a Patternflow in the browser, code beside the device

-If you'd rather have one arrive assembled, tested, and flashed, the **[Crowd Supply campaign](https://www.crowdsupply.com/engmung/patternflow)** is currently live. Back the campaign to have an assembled unit, DIY kit, or custom enclosure shipped worldwide. - -### 2. Patterns +**[Pattern Lab](https://patternflow.work/pattern-lab)** is the studio, and where a pattern reaches the hardware. Make variations through any AI chat, then shape the color ramps and retune the knob ranges. Send it to your Patternflow and it builds into a small `.pfm` module that installs over Wi-Fi in about ten seconds, with no cable, no reflash and no IDE. Graphic Export renders the same pattern as a print-size PNG for a business card or an MP4 loop for a post, in your browser, with nothing uploaded. -You don't need hardware to start. The **[Live Editor](https://patternflow.work/pattern)** is a full Patternflow simulator: turn the virtual knobs and it behaves like the real thing. Patterns start as JavaScript in the browser, and you don't have to write it yourself. Click **Copy creation prompt**, paste it into your AI assistant with a description of the look you want, paste back the code it returns, and play. You don't write GLSL or touch a rendering pipeline: the template handles the encoder mapping, brightness curve and pixel buffer, and you describe the visuals. +Then publish it to the **[Community](https://community.patternflow.work/community)**: more than a hundred patterns, from quiet waves to chaos-theory studies, all playable under the same four knobs. Collect them into a deck and send it to your board in one click. Browsing needs no account, and publishing asks only a username and password, no email. New studies go up on **[Instagram](https://www.instagram.com/patternflow.work)** almost daily; publish something that stands out and it can get featured there, or ask for a collab and I'll post yours with you. The [Make guide](https://patternflow.work/guide/make) walks the whole loop as a tutorial you do as you read, no hardware needed.

- Live Editor: a full Patternflow simulator in the browser, code beside the device + The community wall: dozens of patterns by different people, each one playing, with a deck being assembled along the bottom

-**[Pattern Lab](https://patternflow.work/pattern-lab)** is the full studio, and where a pattern reaches the hardware. Generate variations in batches (in the Lab with your own Gemini key, or through any AI chat), shape color ramps, retune knob ranges, then send it to your device: it builds into a small `.pfm` module and installs over Wi-Fi, about ten seconds start to finish. You never plug in a cable, reflash the board, or open an IDE. The Graphic Export panel takes the same pattern off the panel: a PNG at print size for a business card, or an MP4 loop for a post, rendered in your browser with nothing uploaded. +### The device -When it looks right, publish it to the **[Community](https://community.patternflow.work/community)**. More than a hundred patterns are up already and the range keeps widening, from quiet waves to chaos-theory studies, every one written as code and every one playable under the same four knobs. Collect patterns into a deck and send it to your board in one click. Browsing needs no account, and publishing asks a username and password, no email. The **[Pattern Guide](PATTERN_GUIDE.md)** walks the whole loop, and the **[Make guide](https://patternflow.work/guide/make)** is that loop as a tutorial you do as you read. +The **[Build Guide](BUILD_GUIDE.md)** is the official route: the v3.9 board in a 3D-printed case, about US$100–200 in parts ([BOM](BUILD_GUIDE.md#1-bill-of-materials-bom)). Order first and build later: the parts take about two weeks to arrive, and the case prints in about ten hours on a 256 mm bed. The rest is about an hour of hands-on work, half of it soldering, and every joint is a big through-hole one. Every first-timer who has built one finished it, and most came back saying the soldering was the fun part. The **[build guide in 3D](https://patternflow.work/guide/build)** is the same route on a board you can turn. -

- The community wall: dozens of patterns by different authors, each one playing, with a deck being assembled along the bottom -

+**Buy the LED panel the [BOM](BUILD_GUIDE.md#off-the-board) links.** It's verified, and the case is measured around its screw holes. Another 128 × 64 panel can match the spec line for line and still stay completely dark, because its driver chips are wrong for the board; **[Panel Compatibility](docs/panel-compatibility.md)** says how to tell before you pay. -Patterns don't stay on the wall, either. New pattern studies go up on **[Instagram](https://www.instagram.com/patternflow.work)** almost daily. Publish something that stands out and it can get featured there, or ask for a collab on Instagram and we'll post yours together. +Other routes lead to the same instrument. No printer? **[Simone Majocchi's case](hardware/case/remixes/simonepda-lasercut/README.md)** is laser-cut from acrylic or MDF, and [two more printed cases](hardware/case/remixes/README.md) sit beside the official one. No board order? The **[breadboard build](https://patternflow.work/build/breadboard)** wires the same electronics with jumper wires, and that's a real Patternflow, not a temporary prototype. Every other combination is on the **[Assembly Map](docs/assembly/README.md)**. Holding a v2.x board? [BUILD_GUIDE_v2.md](docs/legacy/BUILD_GUIDE_v2.md), with everything it needs bundled at [v2.1.0](https://github.com/engmung/Patternflow/releases/tag/v2.1.0). The current board's known issues and design notes are [BUILD_GUIDE §10](BUILD_GUIDE.md#10-known-issues--design-notes). -### 3. The ecosystem +**When yours lights up, share it** in [Discord](https://discord.gg/Vr9QtsxeTk) or through the [Share your build](../../issues/new?template=share_build.yml) form, and I'll add your pin to the build map. The goal is simple: cover it with pins. -Patterns are the surface. Underneath is an instrument still being designed, in the open, by whoever shows up. This is the whole system on one napkin: +Rather have one arrive assembled, tested and flashed? It's on **[Crowd Supply](https://www.crowdsupply.com/engmung/patternflow)**, shipped worldwide. The campaign is past its goal and runs until 15 October 2026. -

- Hand-drawn map of the Patternflow ecosystem: the device, the GitHub files and Crowd Supply routes to it; patternflow.work with the Live Editor, Pattern Lab and Community wall; Discord, Instagram and GitHub -

+## First light -It's already moving in directions I didn't choose. One contributor is building out MQTT further than I've had time to follow: units reaching each other across a network, one person's playing coming out of somebody else's device. Another is working on sound on the board itself. Another is taking the TouchDesigner link further than I did. None of it was assigned. +For a DevKit that has never run Patternflow, or one that needs rescuing. The same steps, with the flasher's own screens and a **Stuck here?** link on each, are the Play guide's **[Flash](https://patternflow.work/guide/play#flash)** and **[Patterns](https://patternflow.work/guide/play#patterns)** chapters; [BUILD_GUIDE §8](BUILD_GUIDE.md#8-firmware) is the same order in writing, with more on each step. Already running Patternflow, or got one assembled? Skip download mode, which erases installed patterns and saved Wi-Fi: power it from `J4` and start at step 5. Later firmware comes from the console's **Update** page over Wi-Fi, which keeps everything. -**[CONTRIBUTING.md](CONTRIBUTING.md)** covers how contributions flow. Questions and ideas go to the **[Discord](https://discord.gg/Vr9QtsxeTk)**; where to ask what is **[SUPPORT.md](SUPPORT.md)**. +> **Power goes in through `J4`, the screw terminal, and nowhere else:** a USB cable from a 5 V power bank, cut, red to +5V and black to GND. Never power it through the DevKit's USB-C ports: they are for flashing, with the DevKit out of the board. -## Quick facts +1. **Flash the DevKit on its own, out of the board,** from desktop Chrome or Edge. Use its **left** port, labelled `USB` (antenna up, ports toward you): the right one flashes too, but the Wi-Fi step never appears there. Use a USB-C cable that carries data; a charge-only cable is the usual reason nothing shows up. +2. **Download mode first:** hold **BOOT**, tap **RST** (EN on some DevKits), let go of BOOT. Then press **[Flash Patternflow](https://patternflow.work/guide/play#flash-5)**, pick `USB JTAG/serial debug unit` and install. It takes about two minutes and erases the flash, which a new DevKit doesn't mind. +3. **Then the Wi-Fi, which takes a second go.** The dialog usually ends without asking for it, and nothing failed: close it. Press **RST**, press **Flash Patternflow** again and pick the port, and this time it opens on Patternflow's own menu. Choose **Connect to Wi-Fi**, let the short scan finish (it lists nothing, by design), and type your network's exact name (capitals count, 2.4 GHz only) and its password. +4. **Unplug the DevKit from the computer and, with the power bank unplugged, seat it.** Its USB-C ports go toward the `USB` mark on the board, all 44 pins in. Then plug the `J4` cable into the power bank: within a second or two a red, squarish pattern comes on. That's **Origin**, and one pattern is right for now. +5. **Give it 15 seconds, then hold K2, the top-left knob,** for a second. `CONNECTED` and an address: you're on your Wi-Fi. `HOTSPOT` and a name like `patternflow-a1b2`: it found no network it knows, so it became one. Join it (password `patternflow`; the phone will say there's no internet, which is true, and it stays connected), open `http://192.168.4.1/` and add your network on the Wi-Fi page. +6. **Back on that Wi-Fi, install the Basics pack.** On the [decks page](https://community.patternflow.work/community/decks), press **Install to my board** on the Basics card: 33 patterns, no account. The first time, the board asks to format its storage: press **Format storage**, confirm, and the pack installs by itself. The board's console is at `patternflow.local`; on Android, which can't open that name, type the address K2 shows into the card's **Device address** first. -| | | -| :--- | :--- | -| **Display** | 128 × 64 HUB75 RGB LED matrix, P2.5 (320 × 160 mm) | -| **Brain** | ESP32-S3-WROOM-1 **N16R8** (16 MB flash, 8 MB PSRAM) · standalone, no sending card | -| **Input** | 4× EC11 rotary encoders with push-switch; long-press encoder 4 to switch patterns ([controls](#playing-it)) | -| **Power** | 5 V over USB from any power bank; about **4 h per 10,000 mAh** at max brightness with a typical pattern (see [runtime](#power--runtime)) | -| **Size / weight** | 245 × 325 × 36 mm (9.6 × 12.8 × 1.4 in) · 933 g (2.06 lb) | -| **Firmware** | Arduino-compatible C++, modular pattern architecture, runtime switching (no reflash) | -| **Flashing** | Everything from the browser: one USB flash the first time, then it's all Wi-Fi. Patterns install as modules in seconds, full firmware builds land wirelessly too. A local build (PlatformIO) only for firmware development or other matrix resolutions | -| **Connectivity** | Wi-Fi and USB. Network MIDI (a MIDI port in any DAW — Ableton, Logic, Bitwig), bidirectional OSC (Max/TouchDesigner/Resolume), MQTT and audio-react each ship in an [edition](docs/EDITIONS.md) you install in one click — the way patterns do — and switching keeps your patterns, networks and settings | -| **Editions** | One image ships on the board; others are a click away on [the shelf](https://patternflow.work/editions); what each feature does, with a reel of it, is on [the features page](https://patternflow.work/features). Writing a feature or cutting your own firmware starts at **[docs/EDITIONS.md](docs/EDITIONS.md)** | -| **Build** | ~1 h hands-on (≈30 min soldering + ≈30 min assembly, first-build friendly) + ~10 h 3D printing · US$100–200 in parts ([BOM](BUILD_GUIDE.md#1-bill-of-materials-bom)) | -| **License** | MIT (firmware & web code) · CC-BY-SA 4.0 (hardware, docs, bundled patterns) · community patterns are licensed by their authors ([summary](docs/LICENSE-SUMMARY.md)) | +Panel stays dark? Check that the ribbon is in the panel's `IN`, not `OUT`, that red goes to +5V at both `J3` and `J4`, and that the power bank is on; for a panel other than the BOM's, see [Panel Compatibility](docs/panel-compatibility.md). Only starts after a press of RST? See [BUILD_GUIDE §10](BUILD_GUIDE.md#10-known-issues--design-notes). Then finish with [BUILD_GUIDE §9](BUILD_GUIDE.md#9-final-checks): every knob, one power cycle, the back panel, and the knobs last. -### Power & runtime +## Playing it -Patternflow runs off any standard USB power bank that can supply a couple of amps at 5 V. You don't need a wall adapter, so it stays portable. Rule of thumb: **about 4 hours per 10,000 mAh** at maximum brightness with a typical pattern. +Every knob does two jobs. Turn it, or give it a short press, and you move the pattern that's running; what each knob does there is the pattern's own decision, so the same knob feels different on every one. Hold it down and a device function opens instead, and a second long-press closes it. -
-Measured runtimes +

+ Patternflow controls: K1 brightness, K2 network, K3 knob numbers, K4 patterns; turn or short-press to move the running pattern, long-press for device functions +

-In testing, a 40,000 mAh bank dropped about 13% over 2 hours at **full brightness**, which is roughly **15 hours on a full charge**: +| Hold | Where | Opens | +| :--- | :--- | :--- | +| **K1** | top right | **Brightness** | +| **K2** | top left | **Network**: the board's address. Type it, or `patternflow.local`, into a browser for the web console, every feature the device has, from a phone too. With no Wi-Fi it shows the name of its own network instead | +| **K3** | bottom right | **Knob numbers**, each knob's number on the panel; the order isn't the one you'd guess | +| **K4** | bottom left | **Patterns**: turn to browse what's installed, stop on one to play it, long-press again to close | -| Power bank | Approx. runtime (max brightness) | -| :--- | :--- | -| 10,000 mAh | ~4 hours | -| 20,000 mAh | ~8 hours | -| 40,000 mAh | ~15 hours | +### What's on the device -These were measured with a bright generative pattern at maximum brightness, which is how the device is normally used. Lowering the brightness (long-press encoder 1) extends runtime well beyond these figures. +A new board boots into **Origin**, concentric sine waves sampled by an emergent grid. It is the one pattern compiled into the firmware, the failsafe a board can always boot into. Everything else lives on the board's storage as **`.pfm` modules**, up to 128 of them, installed over Wi-Fi with no reflash. Adding a pattern never costs a firmware update, and a firmware update never touches your patterns, your Wi-Fi or your storage. -**Sleep mode** takes it further: the panel goes dark, the HUB75 transfer stops and the board idles, while staying on Wi-Fi so it can be woken again from MQTT or a browser. Hold encoder 2 for the NETWORK screen, then turn encoder 1; any knob or button wakes it. The pattern that was running comes back, as it does after a full power cut. +The **Basics pack** is 33 patterns at the top of the [decks shelf](https://community.patternflow.work/community/decks), one click and no account; or drop its `.zip` on your board's Patterns page yourself. The [Live Editor](https://patternflow.work/pattern) opens with a library of its own, over forty patterns, each one loadable and remixable in the browser. -**A pattern that fills the screen with white is a different story.** The same 40,000 mAh bank dropped 9% in 30 minutes: about 26.6 W, or roughly 4.8 A at 5 V, which is above what a typical USB-A port is rated for and enough to make the connector warm. That works out to ~5.5 hours on a 40,000 mAh bank. A total-pixel-power clamp is going into the firmware so this can't happen; until then, avoid near-full-white patterns on a power bank, or use a supply and cable rated for the current. +## MIDI, OSC, MQTT & audio-react -
+

+ Patternflow beside an Ableton Live session, with the Patternflow Bridge Max for Live device open and the four knobs mapped to Live parameters +

-## Playing it +The default firmware is the instrument and nothing else, which leaves the most memory for your patterns. MIDI, OSC, MQTT and audio-react come in **editions**, other builds of the same firmware, installed in one click from **[the editions page](https://patternflow.work/editions)** the way patterns arrive. Switching, and switching back, keeps your patterns, Wi-Fi networks and settings. [The features page](https://patternflow.work/features) shows each feature with a reel of it, and [`docs/EDITIONS.md`](docs/EDITIONS.md) explains why it's arranged this way and how to build an edition of your own. -Every encoder does two jobs. Turn it, or give it a short press, and you are moving the pattern that's running. Hold it down and a device function opens instead, and a second long-press closes it again. +| | What it does | Edition | Read | +| :--- | :--- | :--- | :--- | +| **MIDI over Wi-Fi** | The panel is a MIDI port in any DAW (Ableton, Logic, Bitwig); its knobs and buttons map like any controller's (Ctrl-M in Live), and CC and Program Change play it back | Audio | [MIDI in Ableton](docs/midi-ableton.md) · [spec](docs/midi-spec.md) | +| **OSC, both ways** | Knobs, presses and pattern switches stream out to Max, TouchDesigner or Resolume; incoming OSC moves the panel exactly like a hand on the knob | Audio | [spec](docs/osc-spec.md) · [Max for Live bridge](integrations/ableton) | +| **Audio-react** | A browser tab's audio in four bands you shape, or a small microphone soldered to the DevKit; every pattern reacts with no audio code of its own | Audio | [AUDIO_GUIDE.md](docs/AUDIO_GUIDE.md) · [extension](tools/patternflow-audio-extension) | +| **MQTT** | Both ways, on the broker you already run; two panels on one broker follow each other | Performance | [spec](docs/mqtt-spec.md) | +| **HTTP** | Knob state, pattern selection, brightness, status | every firmware | [API](docs/rest-api.md) | -

- Patternflow controls: encoder 1 brightness, 2 network, 3 knob numbers, 4 patterns; turn or short-press to move the running pattern, long-press for device functions -

+The panel plays its own patterns, not a video feed: nothing streams pixels to it. Your rig plays it the way a hand does, through the knobs. -What a knob does to a running pattern is the pattern's own decision, so the same knob feels different on every one of them. That is what encoder 3 is for: hold it and each knob puts its own number on the panel, which saves counting around the front. +**Performance**, maintained by **[Simone Majocchi](https://github.com/SimonePDA)**, is for a panel that runs a room: sequences on a timeline (cue lists and the night/wake scheduler), MQTT in every role, the weather mapped onto the knobs, and the clock. -The device functions themselves are fixed. Encoder 1 is brightness. Encoder 4 opens the pattern list, where you turn to browse the names and long-press again to load one, choosing from whatever is installed at the time. Encoder 2 puts the board's IP address on the panel, and typing that into a browser opens the web console, which carries every feature the device has and works from a phone. +
+More on MIDI, OSC, MQTT and audio-react -[patternflow.work/guide/play](https://patternflow.work/guide/play) has all of this on a Patternflow you can turn, press and hold, from flashing the ESP32 to the console. The same guide builds one with you ([Build](https://patternflow.work/guide/build)) and takes you through the community and Pattern Lab ([Make](https://patternflow.work/guide/make)); [patternflow.work/guide](https://patternflow.work/guide) is where to start. +**MIDI, over the network.** RTP-MIDI over Wi-Fi: native on macOS and iOS, one free driver on Windows. The Pattern Lab's Director exports a show as exactly the four CC lanes the knobs answer to ([Director → MIDI](docs/director-midi.md)), so a show can play on the panel from Live's transport. Sensitivity per knob, an absolute or relative mode, and the session that reconnects itself all live on the panel's console. -## On the device +**OSC.** Play Patternflow as a controller for your set, let your set play the light, or both at once. OSC is the language of Max, TouchDesigner, Resolume and Processing, and it carries more than MIDI can: pattern names, floats, a heartbeat. For Ableton Live Suite there's a ready-made Max for Live bridge in [`integrations/ableton`](integrations/ableton): click Connect, map the four knobs to any Live parameters, done. -A new board boots into **Origin**, concentric sine waves sampled by an emergent grid, and long-pressing encoder 4 opens the list of whatever else is installed. Patterns live on the filesystem rather than inside the program. The firmware compiles in Origin alone, as the failsafe a board can always boot into, and everything else installs as **`.pfm` modules over Wi-Fi**, up to 128 of them, no reflash. Adding a pattern never costs a firmware update, and a firmware update never touches your patterns, your Wi-Fi, or your storage. +**MQTT.** Knob turns and pattern changes publish as they happen, and messages coming the other way move the knobs and switch patterns exactly as a hand on the knob would. It puts the panel on the same bus as the rest of a home or venue, so Home Assistant, Node-RED or a lighting desk can turn its knobs and switch its patterns with nothing Patternflow-specific in the middle. Two boards pointed at the same broker follow each other, which is the short version of why it exists. Point it at a broker on the console's **MQTT** page; a pattern can be addressed by name or by slug. Publishing `1` to `/sleep` puts the panel to sleep and `0` wakes it, with the state mirrored on `/sleep/state`: enough for a Home Assistant switch, and the one command a panel obeys in either role. Simone's work, along with the browser-side zip unpacking that makes pattern packs install in one click. -A new board is therefore nearly empty, so a set ships with it: the **Basics pack**, 33 patterns, at the top of the [decks shelf](https://community.patternflow.work/community/decks). One click installs the lot, no account and no build queue, or drop the `.zip` on your board's Patterns page yourself. The [Live Editor](https://patternflow.work/pattern) opens with its own preset library of 42 patterns, each loadable and remixable in the browser. +**Audio-react.** The Chrome/Edge extension in [`tools/patternflow-audio-extension`](tools/patternflow-audio-extension) captures the current tab's audio, splits it into four bands you shape on a response graph, and sends the levels to the panel over a WebSocket. A pattern reads them as ordinary parameters. The panel can also listen for itself: a $5 PDM microphone on five thin leads runs the same analysis on the device, with no computer involved. -A local firmware build (PlatformIO, driven by `firmware/bundles/build.sh`) is only needed for feature development or targeting an LED matrix with a different resolution; the setup is [`firmware/README.md`](firmware/README.md) and a new feature starts at [FEATURE_GUIDE.md](FEATURE_GUIDE.md). Patterns never need it: [Custom Patterns](firmware/CUSTOM_PATTERNS.md). To rebuild the shipped pack from the repo's own preset sources, see [`firmware/toolchain/make_pack.py`](firmware/toolchain/make_pack.py). +
-## MIDI, OSC, MQTT & audio-react +## Quick facts -

- Patternflow beside an Ableton Live session, with the Patternflow Bridge Max for Live device open and the four knobs mapped to Live parameters -

+| | | +| :--- | :--- | +| **Display** | 128 × 64 HUB75 RGB LED matrix, P2.5 (320 × 160 mm); the driver chips decide which panels work ([compatibility](docs/panel-compatibility.md)) | +| **Brain** | ESP32-S3-DevKitC-1 **N16R8** (16 MB flash, 8 MB PSRAM), plugged into sockets, never soldered · drives the panel itself, no sending card | +| **Board** | v3.9, every soldered part through-hole · KiCad source, Gerbers and schematic in [`hardware/`](hardware/README.md) | +| **Input** | 4× EC11 rotary encoders with push-switch ([controls](#playing-it)) | +| **Power** | 5 V from a USB power bank into **`J4`, the screw terminal**, its only power input · about **4 h per 10,000 mAh** at max brightness ([runtime](#power--runtime)) | +| **Size / weight** | 245 × 325 × 36 mm (9.6 × 12.8 × 1.4 in) · 933 g (2.06 lb) | +| **Firmware** | Arduino-compatible C++, patterns as modules switched at runtime, no reflash | +| **Flashing** | From the browser, once over USB with the DevKit out of the board; after that, patterns and firmware updates arrive over Wi-Fi | +| **Connectivity** | Wi-Fi, an [HTTP API](docs/rest-api.md), and a network of its own when no known one is in reach · MIDI, OSC, MQTT and audio-react in one-click [editions](#midi-osc-mqtt--audio-react) | +| **Build** | ~2 weeks for parts · ~10 h of 3D printing · ~1 h hands-on (≈30 min soldering + ≈30 min assembly, first-build friendly) · US$100–200 in parts ([BOM](BUILD_GUIDE.md#1-bill-of-materials-bom)) | +| **License** | MIT code · CC BY-SA 4.0 hardware, docs and bundled patterns · community patterns licensed by their authors ([License](#license)) | + +### Power & runtime -Each of these lives in an **edition** — a firmware you install from [the shelf](https://patternflow.work/editions) in one click, the same way patterns arrive, and switching keeps your patterns, Wi-Fi networks and settings. MIDI, OSC and audio-react are in **Audio**; MQTT is in **Performance**, alongside sequences and the Director. Why it is arranged that way, and how to build your own: [`docs/EDITIONS.md`](docs/EDITIONS.md). +Patternflow runs off any standard USB power bank that fits the case's compartment and can supply a couple of amps at 5 V, wired into `J4`. You don't need a wall adapter, so it stays portable. + +
+Measured runtimes, sleep mode, and full-white patterns + +In testing, a 40,000 mAh bank dropped about 13% over 2 hours at **full brightness**, which is roughly **15 hours on a full charge**: + +| Power bank | Approx. runtime (max brightness) | +| :--- | :--- | +| 10,000 mAh | ~4 hours | +| 20,000 mAh | ~8 hours | +| 40,000 mAh | ~15 hours | -**MIDI, over the network.** *New.* The panel is a MIDI port: RTP-MIDI over Wi-Fi, native on macOS and iOS, one free driver on Windows. Its four knobs and buttons map to anything in Ableton with Ctrl-M, and a DAW drives the panel back with plain CC and Program Change — the Pattern Lab's Director exports a show as exactly those four CC lanes, so a show can play on the panel from Live's transport. Sensitivity per knob, an absolute or relative mode, and the session that reconnects itself all live on the panel's console. Step by step with screenshots: [`docs/midi-ableton.md`](docs/midi-ableton.md); the contract: [`docs/midi-spec.md`](docs/midi-spec.md). +These were measured with a bright generative pattern at maximum brightness, which is how the device is normally used. Lowering the brightness (long-press K1) extends runtime well beyond these figures. -**Bidirectional OSC.** Over Wi-Fi, Patternflow speaks OSC in both directions: knob turns, button presses, and pattern switches stream out to a remote host (Ableton Live, Max/MSP, TouchDesigner, anything that speaks OSC), and incoming OSC messages drive the device exactly like physical encoder motion. Play Patternflow as a controller for your set, let your set drive the light, or both at once. OSC is the language of Max, TouchDesigner, Resolume and Processing, and it carries more than MIDI can — pattern names, floats, a heartbeat. For Ableton Live Suite there's a ready-made Max for Live bridge in [`integrations/ableton`](integrations/ableton). Click Connect, map the four knobs to any Live parameters, done. The wire protocol is documented in [`docs/osc-spec.md`](docs/osc-spec.md). +**Sleep mode** takes it further: the panel goes dark, the HUB75 transfer stops and the board idles, while staying on Wi-Fi so a browser (or MQTT, on the Performance edition) can wake it again. Hold K2 for the NETWORK screen, then turn K1; any knob or button wakes it. The pattern that was running comes back, as it does after a full power cut. -**MQTT.** Patternflow also speaks MQTT, both ways, on the broker you already run ([the contract](docs/mqtt-spec.md)). Knob turns and pattern changes publish as they happen; messages coming the other way move the knobs and switch patterns exactly as a hand on the encoder would. Two boards pointed at the same broker follow each other, which is the short version of why it exists. It also puts the device on the same bus as the rest of a home or venue setup, so Home Assistant, Node-RED or a lighting desk can drive it without anything Patternflow-specific in the middle. Point it at a broker on the device's own **MQTT** page; a pattern can be addressed by name or by slug. Publishing `1` to `/sleep` puts the panel to sleep and `0` wakes it, with the current state mirrored on `/sleep/state`. That is enough for a Home Assistant switch, and it is the one command a panel obeys whether it is set to Publisher or Subscriber. Contributed by **[@SimonePDA](https://github.com/SimonePDA)** (Simone Majocchi), along with the browser-side zip unpacking that makes pattern packs install in one click. +**A pattern that fills the screen with white is a different story.** The same 40,000 mAh bank dropped 9% in 30 minutes: about 26.6 W, or roughly 4.8 A at 5 V, which is above what a typical USB-A port is rated for and enough to make the connector warm (~5.5 hours on that bank). The firmware's total power clamp, on by default, keeps that from happening: it holds the panel to a 2.4 A budget, the high-power USB-A rating, so a white-out frame lands at roughly half brightness instead, while an ordinary bright pattern (~1.9 A) never notices. The budget is `power_budget` on the [`/api/display`](docs/rest-api.md#get-apidisplay) endpoint. -**Audio-react.** The Chrome/Edge extension in [`tools/patternflow-audio-extension`](tools/patternflow-audio-extension) captures the current tab's audio, splits it into four bands you shape on a response graph, and sends the levels to the panel over a WebSocket. A pattern reads them as ordinary parameters, so every encoder-driven pattern reacts with no audio code of its own. The panel can also listen for itself: an on-board microphone runs the same analysis on the device, with no browser involved. +
## How it's built -Patternflow is built around a standalone ESP32-S3 driving a HUB75 RGB LED matrix at low resolution, where each pixel reads as a discrete point of light with its own brightness and color. Four rotary encoders feed firmware written in Arduino-compatible C++ around a modular pattern architecture: each pattern is a self-contained module with its own setup, update, and draw routines, while the shared framework handles input, LED rendering, mode transitions, and color calibration. I designed the PCB myself; the enclosure is 3D-printed by default. Two community remixes sit beside it in [`hardware/case/remixes/`](hardware/case/remixes/README.md), another printed case and a laser-cut one in clear acrylic or MDF, and a stainless steel version is still in progress. +Patternflow is built around a standalone ESP32-S3 driving a HUB75 RGB LED matrix at low resolution, where each pixel reads as a discrete point of light with its own brightness and color. Four rotary encoders feed firmware written in Arduino-compatible C++ around a modular pattern architecture: each pattern is a self-contained module with its own setup, update and draw routines, while the shared framework handles input, LED rendering, mode transitions and color calibration. I designed the PCB myself; the enclosure is 3D-printed by default, with the community remixes in [`hardware/case/remixes/`](hardware/case/remixes/README.md) beside it. A stainless steel version is still in progress. + +A local firmware build (PlatformIO, driven by `firmware/bundles/build.sh`) is only for feature development, or for a panel the stock image can't drive (a different driver profile or module layout, [Panel Compatibility](docs/panel-compatibility.md)). The setup is [`firmware/README.md`](firmware/README.md), and a new feature starts at [FEATURE_GUIDE.md](FEATURE_GUIDE.md). Patterns never need it ([Custom Patterns](firmware/CUSTOM_PATTERNS.md)). The Basics pack is built from the repo's own preset sources and zipped by [`firmware/toolchain/make_pack.py`](firmware/toolchain/make_pack.py). ## Repository & documentation -> **Moving fast.** [v3.11.0 is out](https://github.com/engmung/Patternflow/releases/tag/v3.11.0): installed patterns run faster - the Basics pack is rebuilt and every one of its 33 patterns gains, 1.5 times the frames a second at the median, once the firmware is updated and the pack installed again - and a pattern you go back to resumes at once instead of loading again. The console is light and reads as one thing, and the Audio edition's page and browser extension are rebuilt: the page says who has the knobs and saves only what you changed, and the extension holds the knobs through silence. There is an [Audio guide](https://patternflow.work/guide/audio) beside Build, Play and Make. Audio is re-cut on this core as v0.7.0; Performance stays at v0.4.0, on core 3.10.4, until its maintainer re-cuts it. [v3.10.5](https://github.com/engmung/Patternflow/releases/tag/v3.10.5) made every pattern 2.45 ms a frame faster and moved a pattern's code to PSRAM, so patterns that were refused for size load and the console has more room while one is running; the panel raises its own Wi-Fi hotspot when it knows no network in reach, a panel that crashed says which pattern it was in and where, a pattern that hangs no longer takes the console down with it, and five requests that could reboot the board cannot any more. [v3.10.4](https://github.com/engmung/Patternflow/releases/tag/v3.10.4) made the panel film properly on a phone. It refreshes at exactly 300 Hz — the one rate the shutter speeds a phone actually uses (1/50, 1/60, 1/100, 1/30) all divide — so the rolling bands are gone at those; 1/120 and slow motion still band, and [the firmware README](firmware/README.md#refresh-rate-anti-flicker-for-video) says why and what to set. [v3.10.3](https://github.com/engmung/Patternflow/releases/tag/v3.10.3) made the panel's colours come out right: bright edges had been growing purple and sky-blue fringes and greys read pink; the cause was in the display driver — which bit plane each brightness window actually lights — and part of it was 3.10.1's own dark-end fix. The planes are exactly binary now, and because the fix is in the driver every installed pattern gets it without a rebuild. It also brings a [Knobs page](docs/rest-api.md#get-apiknobs-and-post-apiknobs) on the device (which way each encoder counts, set from the browser) and the [features catalogue](https://patternflow.work/features). Both editions were re-cut on that core. [v3.10.2](https://github.com/engmung/Patternflow/releases/tag/v3.10.2) made a Format that did not take stop reporting success, and a storage that will not mount say why. [v3.10.1](https://github.com/engmung/Patternflow/releases/tag/v3.10.1) fixed an upload that could reboot the board, a failed settings write that could make the next boot forget a good pattern, a mic-less Audio panel holding the knobs, and MIDI notes that would not let go. [v3.10.0](https://github.com/engmung/Patternflow/releases/tag/v3.10.0) made a heavy pattern come on every time. [v3.9.5](https://github.com/engmung/Patternflow/releases/tag/v3.9.5) taught the web console to survive a slow Wi-Fi link and a heavy pattern, and put thumbnails under the knob. [v3.8.0](https://github.com/engmung/Patternflow/releases/tag/v3.8.0) split the firmware into [editions](docs/EDITIONS.md) — core, Audio (on-board microphone, OSC, network MIDI) and Performance (sequences, MQTT, weather) — installable in one click and switchable without losing your patterns. [v3.2.0](https://github.com/engmung/Patternflow/releases/tag/v3.2.0) made patterns install over Wi-Fi as `.pfm` modules, no reflash. On v2.x hardware? Everything you need stays bundled at [v2.1.0](https://github.com/engmung/Patternflow/releases/tag/v2.1.0). Follow the [changelog](CHANGELOG.md) and the [journal](https://patternflow.work/journal) for what's current. +Folder by folder, and what you want to do → where to start: **[docs/REPOSITORY.md](docs/REPOSITORY.md)**. Every doc is indexed in [`docs/README.md`](docs/README.md). + +
+The top-level folders | Folder | Contents | | :--- | :--- | | `firmware/` | Arduino code for ESP32-S3, the custom pattern template, and the toolchain that builds patterns into `.pfm` modules and packs | | `hardware/` | Enclosure files and electronics source files (case, PCB, Gerbers, schematic PDF) | | `web/` | Next.js site (landing, the interactive guide, Live Editor, Pattern Lab, community, browser flasher & build server, journal); the breadboard build guide is a React page here | -| `docs/` | The contracts (HTTP, OSC, MIDI, MQTT, audio WebSocket, show files), how the firmware is put together (`EDITIONS.md`), the assembly map, walk-throughs, records and media — indexed in [`docs/README.md`](docs/README.md), with the folder-by-folder map in [`docs/REPOSITORY.md`](docs/REPOSITORY.md) | +| `docs/` | The contracts (HTTP, OSC, MIDI, MQTT, audio WebSocket, show files), how the firmware is put together (`EDITIONS.md`), the assembly map, walk-throughs, records and media | | `tools/` | Desktop-side helpers, including the audio-react browser extension | | `integrations/` | Host-software bridges: Ableton Live / Max for Live (OSC knob mapping) | | `.github/` | Issue and PR templates, and the CI that runs on every pull request (web, every firmware edition, doc links, console pages) | -**Guide (interactive):** [Start here](https://patternflow.work/guide) · [Build it](https://patternflow.work/guide/build) · [Play it](https://patternflow.work/guide/play) · [Make patterns](https://patternflow.work/guide/make) · [Sound and MIDI](https://patternflow.work/guide/audio): each step shown on a 3D Patternflow you can turn, in English and Korean -**Build:** [Full Build Guide](BUILD_GUIDE.md) · [Assembly Map](docs/assembly/README.md) · [Panel Compatibility](docs/panel-compatibility.md) · [Hardware files](hardware/README.md) -**Play:** [Pattern Guide](PATTERN_GUIDE.md) · [Custom Patterns](firmware/CUSTOM_PATTERNS.md) · [Audio Guide](AUDIO_GUIDE.md) · [MIDI in Ableton](docs/midi-ableton.md) -**Extend:** [Feature Guide](FEATURE_GUIDE.md) · [Editions](docs/EDITIONS.md) · [Firmware build](firmware/README.md) · [Web architecture](web/ARCHITECTURE.md) · [HTTP API](docs/rest-api.md) · [OSC Spec](docs/osc-spec.md) · [MIDI Spec](docs/midi-spec.md) · [Director → MIDI](docs/director-midi.md) -**Project:** [Contributing](CONTRIBUTING.md) · [Support](SUPPORT.md) · [Repository map](docs/REPOSITORY.md) · [Changelog](CHANGELOG.md) · [License Summary](docs/LICENSE-SUMMARY.md) · [Manifesto](docs/manifesto.md) +
+**Guide (interactive):** [Start here](https://patternflow.work/guide) · [Build it](https://patternflow.work/guide/build) · [Play it](https://patternflow.work/guide/play) · [Make patterns](https://patternflow.work/guide/make) · [Sound and MIDI](https://patternflow.work/guide/audio), in English and Korean
+**Build:** [Build Guide](BUILD_GUIDE.md) · [Assembly Map](docs/assembly/README.md) · [Panel Compatibility](docs/panel-compatibility.md) · [Hardware files](hardware/README.md) · [v2.x board](docs/legacy/BUILD_GUIDE_v2.md)
+**Play:** [Pattern Guide](PATTERN_GUIDE.md) · [Custom Patterns](firmware/CUSTOM_PATTERNS.md) · [Audio Guide](docs/AUDIO_GUIDE.md) · [MIDI in Ableton](docs/midi-ableton.md)
+**Extend:** [Feature Guide](FEATURE_GUIDE.md) · [Editions](docs/EDITIONS.md) · [Firmware build](firmware/README.md) · [Web architecture](web/ARCHITECTURE.md) · [HTTP API](docs/rest-api.md) · [OSC Spec](docs/osc-spec.md) · [MIDI Spec](docs/midi-spec.md) · [MQTT Spec](docs/mqtt-spec.md) · [Director → MIDI](docs/director-midi.md)
+**Project:** [Contributing](CONTRIBUTING.md) · [Support](.github/SUPPORT.md) · [Repository map](docs/REPOSITORY.md) · [Changelog](CHANGELOG.md) · [License Summary](docs/LICENSE-SUMMARY.md) · [Manifesto](docs/manifesto.md)
**Links:** [patternflow.work](https://patternflow.work) · [Community](https://community.patternflow.work/community) · [Crowd Supply](https://www.crowdsupply.com/engmung/patternflow) · [Releases](../../releases) · [Discord](https://discord.gg/Vr9QtsxeTk) · [Instagram](https://www.instagram.com/patternflow.work) +## What's new + +**Moving fast.** [v3.11.0 is out](https://github.com/engmung/Patternflow/releases/tag/v3.11.0): installed patterns run faster, a pattern you go back to resumes at once, and there is an [Audio guide](https://patternflow.work/guide/audio) beside Build, Play and Make. Follow the [changelog](CHANGELOG.md) and the [journal](https://patternflow.work/journal) for what's current. + +
+Recent releases in brief + +- **[v3.11.0](https://github.com/engmung/Patternflow/releases/tag/v3.11.0)**: installed patterns run faster. The Basics pack is rebuilt and every one of its 33 patterns runs faster, 1.5 times the frames a second at the median, once the firmware is updated and the pack installed again; a pattern you go back to resumes at once instead of loading again. The console is light and reads as one thing, and the Audio edition's page and browser extension are rebuilt: the page says who has the knobs and saves only what you changed, and the extension holds the knobs through silence. Audio is re-cut on this core as v0.7.0; Performance stays at v0.4.0, on core 3.10.4, until its maintainer re-cuts it. +- **[v3.10.5](https://github.com/engmung/Patternflow/releases/tag/v3.10.5)**: every pattern 2.45 ms a frame faster, and a pattern's code moved to PSRAM, so patterns that were refused for size load and the console has more room while one is running. The panel raises its own Wi-Fi hotspot when it knows no network in reach, a panel that crashed says which pattern it was in and where, a pattern that hangs no longer takes the console down with it, and four requests that could reboot the board cannot any more. +- **[v3.10.4](https://github.com/engmung/Patternflow/releases/tag/v3.10.4)**: the panel films properly on a phone. It refreshes at exactly 300 Hz, the one rate the shutter speeds a phone actually uses (1/50, 1/60, 1/100, 1/30) all divide, so the rolling bands are gone at those; 1/120 and slow motion still band, and [the firmware README](firmware/README.md#refresh-rate-anti-flicker-for-video) says why and what to set. +- **[v3.10.3](https://github.com/engmung/Patternflow/releases/tag/v3.10.3)**: the panel's colours come out right. Bright edges had been growing purple and sky-blue fringes and greys read pink; the cause was in the display driver (which bit plane each brightness window actually lights), and part of it was 3.10.1's own dark-end fix. The planes are exactly binary now, and because the fix is in the driver every installed pattern gets it without a rebuild. Also a [Knobs page](docs/rest-api.md#get-apiknobs-and-post-apiknobs) on the device (which way each encoder counts, set from the browser) and the [features catalogue](https://patternflow.work/features). Both editions were re-cut on that core. +- **[v3.10.2](https://github.com/engmung/Patternflow/releases/tag/v3.10.2)**: a Format that did not take stops reporting success, and a storage that will not mount says why. +- **[v3.10.1](https://github.com/engmung/Patternflow/releases/tag/v3.10.1)**: fixed an upload that could reboot the board, a failed settings write that could make the next boot forget a good pattern, a mic-less Audio panel holding the knobs, and MIDI notes that would not let go. +- **[v3.10.0](https://github.com/engmung/Patternflow/releases/tag/v3.10.0)**: a heavy pattern comes on every time. +- **[v3.9.5](https://github.com/engmung/Patternflow/releases/tag/v3.9.5)**: the web console survives a slow Wi-Fi link and a heavy pattern, and thumbnails sit under the knob. +- **[v3.9.0](https://github.com/engmung/Patternflow/releases/tag/v3.9.0)**: the panel is a MIDI port over Wi-Fi, in the Audio edition. +- **[v3.8.0](https://github.com/engmung/Patternflow/releases/tag/v3.8.0)**: the firmware split into [editions](docs/EDITIONS.md), core, Audio (on-board microphone, OSC, browser and phone audio) and Performance (sequences, MQTT, weather), installable in one click and switchable without losing your patterns. +- **[v3.2.0](https://github.com/engmung/Patternflow/releases/tag/v3.2.0)**: patterns install over Wi-Fi as `.pfm` modules, no reflash. + +
+ ## Contributing -Builds, documentation fixes, part sourcing tips, enclosure remixes, firmware features and host-software bridges are all welcome. **[CONTRIBUTING.md](CONTRIBUTING.md)** has one table of where each kind goes and what CI runs on it; **[SUPPORT.md](SUPPORT.md)** says where to ask. Patterns don't come here: publish them from the Pattern Lab to the Community. +Builds, documentation fixes, part sourcing tips, enclosure remixes, firmware features and host-software bridges are all welcome. **[CONTRIBUTING.md](CONTRIBUTING.md)** has one table of where each kind goes and what CI runs on it. Questions and ideas go to the **[Discord](https://discord.gg/Vr9QtsxeTk)**, and **[SUPPORT.md](.github/SUPPORT.md)** says where to ask what. Patterns don't come here: publish them from the Pattern Lab to the Community. Galleries, installations and press: [patternflow.work/contact](https://patternflow.work/contact). ## Story so far +
+From a visit to the Nam June Paik Art Center in January 2026 to two exhibitions in October, month by month + | When | Milestone | | :--- | :--- | -| **Jan 2026** | *Patternflow: Origin*, the first work as a new media artist, built around 3D-printed forms and the seed of what became Patternflow · visited the Nam June Paik Art Center | +| **Jan 2026** | *Patternflow: Origin*, my first work as a new media artist, built around 3D-printed forms and the seed of what became Patternflow · visited the Nam June Paik Art Center | | **Mar 2026** | The Origin pattern ran on a physical LED matrix with four knobs for the first time | | **Apr 2026** | Instagram and the Arduino subreddit responded strongly (**150,000+ views and 3,700 upvotes**), and the community asked for the files, not a product, so Patternflow went open source · first PCB fabricated *(sponsored by PCBWay)* · website live | -| **May 2026** | Reached **100 GitHub stars** · the first collaborator joined · Crowd Supply agreement · Discord community growing | -| **Jun 2026** | [Crowd Supply pre-launch page](https://www.crowdsupply.com/engmung/patternflow) live, backed by countless refinements toward mass production · Instagram passed **1,000 followers** · first community-made pattern shared | -| **Jul 2026** | Refining the design for mass production · growing an active community · outreach and promotion | -| **Aug 2026** | [Crowd Supply campaign](https://www.crowdsupply.com/engmung/patternflow) officially launched worldwide · firmware split into [editions](docs/EDITIONS.md) (Audio & Performance) over an unchanged core · global press wave across Mixmag, Synthtopia, AMAZONA.de, and MATRIXSYNTH | -| **Sep 2026** | Driving the Crowd Supply campaign to funding success · preparing mass production and fulfillment logistics · FreeRTOS dual-core network isolation, fast CIE frame push, and continuous ecosystem polish | -| **Next** | Deliver rewards to backers at the highest quality · send Patternflow further out into the world · collaborate with more artists · earn academic recognition | +| **May 2026** | Reached **100 GitHub stars** · the Live Editor and Pattern Lab went live · the first collaborator joined · Crowd Supply agreement | +| **Jun 2026** | Nath in the UK finished the first Patternflow built outside the project, from the published files alone · [Crowd Supply pre-launch page](https://www.crowdsupply.com/engmung/patternflow) live · Instagram passed **1,000 followers** · first community-made pattern shared | +| **Jul 2026** | The v3.0 board, every soldered part through-hole · patterns install over Wi-Fi as modules, Simone Majocchi's design · the Pattern Community opened · featured on Creative Applications Network | +| **Aug 2026** | [Crowd Supply campaign](https://www.crowdsupply.com/engmung/patternflow) launched worldwide · firmware split into [editions](docs/EDITIONS.md) (Audio & Performance) over an unchanged core · press in Mixmag Asia, Synthtopia, AMAZONA.de and MATRIXSYNTH | +| **Sep 2026** | The Crowd Supply campaign met its funding goal · the funded units will be printed and assembled by hand in Korea, on boards from PCBWay · the first Patternflow sold as an artwork · FreeRTOS dual-core network isolation and a faster frame push | +| **Oct 2026** | The Crowd Supply campaign, open until 15 October, reached 200% of its goal · v3.11.0 and the Audio guide · *Anbu*, an exhibition by visually impaired artists at Modu Art Space near Seoul Station, with Patternflow versions of their works to play by hand · Patternflow in *USER EXPERIENCE*, a pop-up gallery in San Francisco | +| **Next** | Deliver every Crowd Supply unit at the highest quality · send Patternflow further out into the world · collaborate with more artists · earn academic recognition | | **2028** | Grow Patternflow into a self-sustaining community and ecosystem, then move on to the next project | -Longer write-ups and the full story behind each step live on the **[journal](https://patternflow.work/journal)**: the whole process, written up at least weekly since the beginning, the thinking and the emotions included, and the parts that went badly too. If you want to know why this project exists and what it costs to keep alive, start there. +
+ +Longer write-ups and the full story behind each step live on the **[journal](https://patternflow.work/journal)**: the whole process, written up week by week since April, the thinking and the emotions included, and the parts that went badly too. If you want to know why this project exists and what it costs to keep alive, start there. ### Sponsor @@ -230,14 +303,14 @@ Patternflow's PCB fabrication and 3D-printed enclosure are sponsored by **[PCBWa ## License -A file's SPDX header is the authority where one exists; folders are not license boundaries, and files that cannot carry a header (STL, Gerber, images) take the license the nearest README states. Full breakdown in the **[License Summary](docs/LICENSE-SUMMARY.md)**. +Two licenses, in two separate files. A file's SPDX header is the authority where one exists; folders are not license boundaries, and files that cannot carry a header (STL, Gerber, images) take the license the nearest README states. Full breakdown in the **[License Summary](docs/LICENSE-SUMMARY.md)**. -- Firmware & web code: **MIT** ([LICENSE-MIT](./LICENSE-MIT)) -- Hardware, designs & docs: **CC-BY-SA 4.0** ([LICENSE-CC-BY-SA](./LICENSE-CC-BY-SA)) -- Bundled patterns (the presets shipped in the firmware and the editor): **CC-BY-SA 4.0**, per-file SPDX headers. -- Patterns contributed *to the repository*: inbound = outbound. By sending a pattern as a PR, issue or Discord post you license it under CC-BY-SA 4.0, attribution kept in the code header (no CLA). See [CONTRIBUTING.md](CONTRIBUTING.md). -- Patterns published *to the Community*: **licensed by their author**, either **CC-BY-SA 4.0** (default) or **CC-BY-4.0**. Both permit commercial use with credit; CC-BY-SA additionally requires adaptations to stay under the same license. A fork can never be looser than what it came from, and carries a `Based on:` credit to the original author in its source. +- Code (firmware, web, tools, integrations): **MIT** ([LICENSE-MIT](./LICENSE-MIT)) +- Hardware, designs & docs: **CC BY-SA 4.0** ([LICENSE-CC-BY-SA](./LICENSE-CC-BY-SA)) +- Bundled patterns (the presets shipped in the firmware and the editor): **CC BY-SA 4.0**, per-file SPDX headers. +- Anything sent *to this repository*: inbound = outbound, MIT for code and CC BY-SA 4.0 for the rest, attribution kept in the file (no CLA). See [CONTRIBUTING.md](CONTRIBUTING.md). +- Patterns published *to the Community*: **licensed by their author**, either **CC BY-SA 4.0** (default) or **CC BY 4.0**; a few older ones keep MIT or CC0, so read the pattern's own header. Both permit commercial use with credit; CC BY-SA additionally requires adaptations to stay under the same license. A fork can never be looser than what it came from, and carries a `Based on:` credit to the original author in its source. "Patternflow" is a trademark of SeungHun Lee. -The Patternflow series: LED Synthesizer (2026) · Origin (2026) +*The Patternflow series, by SeungHun Lee: LED Synthesizer (2026) · Origin (2026)* diff --git a/docs/.human-written b/docs/.human-written new file mode 100644 index 000000000..1e088aa53 --- /dev/null +++ b/docs/.human-written @@ -0,0 +1 @@ +# Paths the maintainer locks beyond docs/rfc-cleanup.md §2.9, one per line from the repository root; a directory locks what is under it. web/scripts/human-lock.ts reads this file. diff --git a/AUDIO_GUIDE.md b/docs/AUDIO_GUIDE.md similarity index 88% rename from AUDIO_GUIDE.md rename to docs/AUDIO_GUIDE.md index 6460a30e4..197c04ff1 100644 --- a/AUDIO_GUIDE.md +++ b/docs/AUDIO_GUIDE.md @@ -15,7 +15,7 @@ OSC ([here](#osc--ableton-today-anything-tomorrow)). The same ground step by step, on a 3D Patternflow in the browser: [patternflow.work/guide/audio](https://patternflow.work/guide/audio). -Like the [Feature Guide](FEATURE_GUIDE.md), this page ends with a section +Like the [Feature Guide](../FEATURE_GUIDE.md), this page ends with a section written for AI coding agents; everything above it is for people. --- @@ -25,15 +25,19 @@ written for AI coding agents; everything above it is for people. Any of these puts the same firmware on your panel: - **One click:** [patternflow.work/editions](https://patternflow.work/editions) - → Audio → flash from the browser. + → Audio → **Install to my panel**. Your browser hands the image to a panel + that already runs Patternflow, over Wi-Fi; patterns, networks and settings + stay. A DevKit that has never run Patternflow takes the core first, from + **Flash Patternflow** ([BUILD_GUIDE §8](../BUILD_GUIDE.md#8-firmware)), and + then this. - A release's `patternflow-audio.ino.bin` via the console's `/update` page. - Build it yourself: `./firmware/bundles/build.sh audio` (add `flash ` to send it over Wi-Fi). The edition bundles four features: **OSC** (Max/TouchDesigner/Ableton, see -[`docs/osc-spec.md`](docs/osc-spec.md)), **audio** (the streaming path the +[`docs/osc-spec.md`](osc-spec.md)), **audio** (the streaming path the extension uses), **audio_in** (the microphone and the mapping engine) and -**MIDI** (the panel as a network MIDI port, [`docs/midi-spec.md`](docs/midi-spec.md)). +**MIDI** (the panel as a network MIDI port, [`docs/midi-spec.md`](midi-spec.md)). ## The Chrome extension @@ -43,11 +47,11 @@ locally, and streams four knob lanes to the panel over your LAN. **Install:** it's not on the Web Store — load it straight from this repo. `chrome://extensions` → enable *Developer mode* → *Load unpacked* → pick -[`tools/patternflow-audio-extension/`](tools/patternflow-audio-extension/). +[`tools/patternflow-audio-extension/`](../tools/patternflow-audio-extension/). After pulling a newer copy, press *Reload* on its card there. Reload keeps the mapping you already had, so the starting point described below is a new install's. (Every control is described in its -[README](tools/patternflow-audio-extension/README.md), and so is how to get +[README](../tools/patternflow-audio-extension/README.md), and so is how to get that starting point on an older mapping.) **Use:** open a tab with sound → click the extension → enter your panel's @@ -83,7 +87,7 @@ Boxes map to knobs 1:1 and that's fixed on purpose — K2 *is* knob 2. ## The on-board microphone - + A small PDM microphone soldered to the DevKit lets the panel react to the room itself — no browser, no phone, nothing else running. This is an @@ -183,7 +187,7 @@ and input gain; it leaves the Microphone switch and Auto range as they are. ## The phone app (for filming) -[`tools/patternflow-audio-android/`](tools/patternflow-audio-android/) is a +[`tools/patternflow-audio-android/`](../tools/patternflow-audio-android/) is a small Android app that captures whatever the phone is playing and drives the panel with it. It exists for one job: **filming content** — play a track on the phone (reels, whatever), and the panel reacts to the same audio the @@ -191,7 +195,7 @@ video records. For everyday listening the extension or the mic is the better path. It's not on any store — build and install it yourself; the -[README](tools/patternflow-audio-android/README.md) has the full recipe. It +[README](../tools/patternflow-audio-android/README.md) has the full recipe. It opens the panel's own editor page for configuration, so everything above about boxes and curves applies unchanged. @@ -202,13 +206,13 @@ protocol macOS calls *Network MIDI*). Once a session is up the panel is an ordinary MIDI port in any DAW: four knobs in (CC 20–23 absolute, CC 24–27 relative), four buttons (notes 60–63), a pattern selector (Program Change), and the same four knobs, buttons and pattern changes out as they happen. -The contract is [`docs/midi-spec.md`](docs/midi-spec.md); the walk-through +The contract is [`docs/midi-spec.md`](midi-spec.md); the walk-through with screenshots — connecting on macOS, Windows (rtpMIDI) and Linux, mapping in Ableton, the per-knob sensitivity slider, and telling the panel your computer's address so it reconnects itself after a reboot — is -[`docs/midi-ableton.md`](docs/midi-ableton.md). +[`docs/midi-ableton.md`](midi-ableton.md). -This is the missing half of [`docs/director-midi.md`](docs/director-midi.md): +This is the missing half of [`docs/director-midi.md`](director-midi.md): the Director's `.mid` export writes CC 20–23, so drop the clip on a MIDI track, set the track's output to the panel's port, and the show plays on the panel from Live's transport. The switch on the console's **MIDI** page turns it @@ -219,11 +223,11 @@ sensitivity and message counts under `midi`. The edition also speaks **OSC** — plain OSC 1.0 over UDP, both directions. The ready-made client is the **Max for Live bridge** in -[`integrations/ableton/`](integrations/ableton/): load it in Ableton and +[`integrations/ableton/`](../integrations/ableton/): load it in Ableton and your set and the panel talk to each other. But the bridge is just one client of a written contract, -[`docs/osc-spec.md`](docs/osc-spec.md) — anything that can send a UDP OSC +[`docs/osc-spec.md`](osc-spec.md) — anything that can send a UDP OSC message can drive the panel the same way. TouchDesigner, VCV Rack and Processing speak it natively; Blender does through an OSC add-on or a few lines of Python. Send `/patternflow/ping` once and the device learns your @@ -260,12 +264,12 @@ page says which source has the knobs right now. ## For the AI agent -The hard rules — the same ones humans follow — are in [`AGENTS.md`](AGENTS.md#hard-rules-do-not-violate); the contributor flow is [`CONTRIBUTING.md`](CONTRIBUTING.md). +The hard rules — the same ones humans follow — are in [`AGENTS.md`](../AGENTS.md#hard-rules-do-not-violate); the contributor flow is [`CONTRIBUTING.md`](../CONTRIBUTING.md). You were pointed here to work on Patternflow's audio path. The map: **Firmware — the mapping engine and mic** -([`firmware/patternflow/features/audio_in/`](firmware/patternflow/features/audio_in/)): +([`firmware/patternflow/features/audio_in/`](../firmware/patternflow/features/audio_in/)): | file | role | | --- | --- | @@ -273,19 +277,19 @@ You were pointed here to work on Patternflow's audio path. The map: | `core_audio_pdm.h` | PDM mic driver (GPIO 43/44). Its header comment is the authoritative wiring + driver-choice record. | | `core_audio_fft.h` | FFT and spectrum buckets. | | `core_audio_in_map.h` | Bands, curve LUTs (33-point, interpolate-only), attack/damping glide, auto-range, the defaults a reset restores, NVS persistence. Manual mode maps linearly in amplitude between a band's two edges; the page's dB axis is a conversion at the page's edge. | -| `core_audio_in_http.h` | `/audio-in` (editor page), `GET /api/audio-in` (the configuration, read once), `POST /api/audio-in` (any subset: a request changes only the fields it carries), `POST /api/audio-in/reset` (everything, or one band with `band=N`), the `?levels=1` live poll, and the frame endpoint the phone posts spectra to. The contract is [`docs/rest-api.md`](docs/rest-api.md#microphone-audio-edition); the phone app is a second client of it, so fields are added, never renamed. | +| `core_audio_in_http.h` | `/audio-in` (editor page), `GET /api/audio-in` (the configuration, read once), `POST /api/audio-in` (any subset: a request changes only the fields it carries), `POST /api/audio-in/reset` (everything, or one band with `band=N`), the `?levels=1` live poll, and the frame endpoint the phone posts spectra to. The contract is [`docs/rest-api.md`](rest-api.md#microphone-audio-edition); the phone app is a second client of it, so fields are added, never renamed. | | `audio_in_index.h` | **Generated** — never edit (chain below). | -**Firmware — MIDI** ([`firmware/patternflow/features/midi/`](firmware/patternflow/features/midi/)): +**Firmware — MIDI** ([`firmware/patternflow/features/midi/`](../firmware/patternflow/features/midi/)): `core_midi.h` is the mapping (transport-agnostic: handlers in, a sink out), `core_midi_rtp.h` the RTP-MIDI listener on UDP 5004/5005 over lathoub's -AppleMIDI library. Contract: [`docs/midi-spec.md`](docs/midi-spec.md). +AppleMIDI library. Contract: [`docs/midi-spec.md`](midi-spec.md). `tools/rtpmidi-probe/` exercises the whole map from a PC with no MIDI driver. **Firmware — the stream path** -([`firmware/patternflow/features/audio/`](firmware/patternflow/features/audio/)): +([`firmware/patternflow/features/audio/`](../firmware/patternflow/features/audio/)): `core_audio_ws.h` is a WebSocket server on port 81. The wire contract is -[`docs/audio-ws-spec.md`](docs/audio-ws-spec.md) — clients build against the +[`docs/audio-ws-spec.md`](audio-ws-spec.md) — clients build against the spec, not the source. Probe `GET /api/status` for `"audio"` in `caps`. A lane is released 500 ms after the last message that set it, so a sender that means to hold a value resends it; the spec says which senders in this tree do. @@ -315,7 +319,7 @@ Preview without building: `python firmware/toolchain/console_serve.py` assembles `/audio-in` from those sources on every request and answers its API the way the firmware does; `/mock?audio=music`, `silence`, `off`, `fresh`, `nomic`, `stalled`, `phone`, `ext` and `noread` pick what the panel hears -([`console/README.md`](firmware/patternflow/console/README.md#the-page-that-is-assembled-audio-in)). +([`console/README.md`](../firmware/patternflow/console/README.md#the-page-that-is-assembled-audio-in)). The page has a budget, `BUDGET` in `build_audio_in_page.py`: 17,500 bytes gzipped as the panel sends it. It is the heaviest page in the console and every byte crosses the panel's Wi-Fi, so whole-line comments and indentation @@ -331,7 +335,7 @@ every request rides `PF.poll`/`PF.status`, nothing is saved until the panel's mapping has been read, and a save sends only the fields that changed. **Chrome extension** -([`tools/patternflow-audio-extension/`](tools/patternflow-audio-extension/)): +([`tools/patternflow-audio-extension/`](../tools/patternflow-audio-extension/)): `mapping.js` is the mapping model, one object (`PFMap`): defaults, the curves, level → knob. `editor.js` is the single shared editor module, and `editor-adapter.js` is its extension adapter (chrome storage, the capture's @@ -346,7 +350,7 @@ the panel. The extension keeps its cream skin; only the panel's page wears the console's. **Android app** -([`tools/patternflow-audio-android/`](tools/patternflow-audio-android/)): +([`tools/patternflow-audio-android/`](../tools/patternflow-audio-android/)): `Analyzer.kt` mirrors the extension's analysis and mapping (`offscreen.js` and `mapping.js`); `DeviceLink.kt` speaks the WS contract + syncs panel config over `/api/audio-in` + posts monitor frames; @@ -360,9 +364,9 @@ its WebView is set up without the part that shows a `window.confirm` dialog, which is why the page's Reset mapping is a button pressed twice. Open the app's editor once before releasing a change to `/audio-in`. -Rules of the road: firmware changes follow [FEATURE_GUIDE.md](FEATURE_GUIDE.md) +Rules of the road: firmware changes follow [FEATURE_GUIDE.md](../FEATURE_GUIDE.md) (hooks, checkers, `build.sh all`); protocol changes update the spec in the -same PR; API changes extend [`docs/rest-api.md`](docs/rest-api.md) and never +same PR; API changes extend [`docs/rest-api.md`](rest-api.md) and never rename what is there; editor changes run `console_pages.py build` so the panel's page follows — the editor's body is one file for both surfaces, which is the point. diff --git a/docs/EDITIONS.md b/docs/EDITIONS.md index fab3a1838..7e7b69d08 100644 --- a/docs/EDITIONS.md +++ b/docs/EDITIONS.md @@ -14,9 +14,10 @@ four knobs.** That sentence decides everything below it, and it is the only test worth applying. What that sentence names is not a feature and never composes out: the pattern -loader, the four encoders, the panel driver, Wi-Fi, `/update`, sleep, the -console. Those are the device. **The default build carries no features at -all**, and a panel running it does the whole of what Patternflow is for. +loader, the four encoders, the panel driver, Wi-Fi (its own hotspot +included), `/update`, sleep, the console. Those are the device. **The default +build carries no features at all**, and a panel running it does the whole of +what Patternflow is for. Everything else — sequences, MQTT, OSC, weather, audio — is a way of driving that device from somewhere else. Real, wanted, first-class, and not the thing @@ -217,7 +218,7 @@ deliberately re-pinned in the same commit. feature code that recompiles against a checkout, in-tree or out. Widening is tail-append only (the rule and its reason are written on the struct); the compiler surfaces every break at build, and `build.sh all` makes sure -"builds" means all three compositions. The two composition filenames and +"builds" means all five compositions. The two composition filenames and their macros are the out-of-tree seam - renamed spellings are shimmed, and shims get deleted only after their consumers migrate. @@ -290,22 +291,30 @@ Two files under `firmware/bundles//`: #include "osc/feature_osc.h" #include "audio/feature_audio.h" #include "audio_in/feature_audio_in.h" +#include "midi/feature_midi.h" #define PF_FEATURE_LIST \ &PFFeatureOsc::descriptor, \ &PFFeatureAudio::descriptor, \ - &PFFeatureAudioIn::descriptor + &PFFeatureAudioIn::descriptor, \ + &PFFeatureMidi::descriptor ``` ```c // overrides.h — what it calls itself, and anything it needs set differently #define PF_VARIANT "audio" -#define PF_VARIANT_VERSION "v0.2.0" // yours, moves at your pace -#define PF_WIFI_TX_POWER WIFI_POWER_17dBm +#define PF_VARIANT_VERSION "v0.7.0" // yours, moves at your pace +#define PF_AUDIO_IN_DRIVES_KNOBS 1 // the microphone drives the knobs here ``` +That is the Audio edition's pair with the comments taken out (v0.7.0 when +this was written). It pinned `PF_WIFI_TX_POWER` too until the core went to +full power, and dropped it in v0.7.0 — which is why every setting in an +`overrides.h` carries its reason: one copied without it stays after the reason +has gone. + A third file, `env`, is optional: one line naming the PlatformIO env to build in, for the rare edition whose difference is a build flag rather than a -setting. The MIDI edition is the one so far — its USB port has to be the OTG +setting. The MIDI bundle is the one so far — its USB port has to be the OTG controller for a MIDI device to exist, and the Arduino core decides that at compile time (`ARDUINO_USB_MODE`). Everything else about it is the two files above; `build.sh` and `shelf.sh` read the env name and look for the outputs @@ -463,6 +472,9 @@ because no phone showed it in its pick list; and the Wi-Fi setup portal is a parked branch, `feat/wifi-setup-portal`, in no edition and not to be merged as it stands — it predates the seam and edits the core. What that attempt learned is in [`investigations/2026-08-the-wifi-portal-a-samsung-never-drew.md`](investigations/2026-08-the-wifi-portal-a-samsung-never-drew.md). +The need it was for — setting Wi-Fi up from a phone — is met in the core +since 3.10.5 by the panel's own hotspot (`src/core_hotspot.h`): the +device's Wi-Fi, so core rather than a feature. --- diff --git a/docs/README.md b/docs/README.md index 096215e3e..007b2f060 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,15 +1,16 @@ # docs/ -What is here, and what is not. The rule for the split: **a guide somebody follows start to finish lives at the repository root** (`BUILD_GUIDE.md`, `BUILD_GUIDE_v2.md`, `PATTERN_GUIDE.md`, `FEATURE_GUIDE.md`, `AUDIO_GUIDE.md`); **a contract, a reference, a walk-through for one task, or a record lives here**; and a folder explains itself in its own README. The folder-by-folder map of the whole repository is [`REPOSITORY.md`](REPOSITORY.md). +What is here, and what is not. The rule for the split: **the repository root keeps three guides and no others** (`BUILD_GUIDE.md`, `PATTERN_GUIDE.md`, `FEATURE_GUIDE.md`), so a first-time visitor reads it at a glance; **a contract, a reference, a walk-through for one task (the audio guide is one), or a record lives here**; and a folder explains itself in its own README. The folder-by-folder map of the whole repository is [`REPOSITORY.md`](REPOSITORY.md). ## Contracts — other software is built against these, not against the firmware source -- [`rest-api.md`](rest-api.md) — the device's HTTP API (`/api/*`), the console pages, and the table for choosing between HTTP, OSC, MIDI and MQTT -- [`osc-spec.md`](osc-spec.md) — OSC over UDP (DAWs, Max, TouchDesigner) -- [`midi-spec.md`](midi-spec.md) — the panel as a network MIDI port -- [`mqtt-spec.md`](mqtt-spec.md) — MQTT topics, both directions, the roles, and why the channel decides whether a write sticks -- [`audio-ws-spec.md`](audio-ws-spec.md) — the audio-react WebSocket the browser extension and the phone app speak +- [`rest-api.md`](rest-api.md) — the device's HTTP API (`/api/*`), the console pages, and the table for choosing between HTTP, OSC, MIDI and MQTT, with test vectors in [`rest-api-vectors/`](rest-api-vectors/) +- [`osc-spec.md`](osc-spec.md) — OSC over UDP (DAWs, Max, TouchDesigner), with test vectors in [`osc-vectors/`](osc-vectors/) +- [`midi-spec.md`](midi-spec.md) — the panel as a network MIDI port, with test vectors in [`midi-vectors/`](midi-vectors/) and those for `/api/midi` in `rest-api-vectors/` +- [`mqtt-spec.md`](mqtt-spec.md) — MQTT topics, both directions, the roles, and why the channel decides whether a write sticks, with test vectors in [`mqtt-vectors/`](mqtt-vectors/) and those for `/api/mqtt` in `rest-api-vectors/` +- [`audio-ws-spec.md`](audio-ws-spec.md) — the audio-react WebSocket the browser extension and the phone app speak, with test vectors in [`audio-ws-vectors/`](audio-ws-vectors/) and those for `/api/audio` in `rest-api-vectors/` - [`pfst-v2-spec.md`](pfst-v2-spec.md) — the `.pfs` show table, with test vectors in [`pfst-v2-vectors/`](pfst-v2-vectors/) +- [`contract-vectors.md`](contract-vectors.md) — how a vector of the first five is written, and how `npm run check:rest-vectors` and the four like it replay them against the [virtual panel](../tools/virtual-panel/README.md), a Patternflow with no board - [`panel-compatibility.md`](panel-compatibility.md) — a buying guide first (which HUB75 panels light up and which stay dark), then the reference for other sizes ## How the firmware is put together @@ -19,32 +20,45 @@ What is here, and what is not. The rule for the split: **a guide somebody follow ## Walk-throughs — one task, start to finish +- [`AUDIO_GUIDE.md`](AUDIO_GUIDE.md) — sound into the panel - [`midi-ableton.md`](midi-ableton.md) — the panel as a MIDI port in Ableton Live, with screenshots - [`director-midi.md`](director-midi.md) — how a Director show becomes a MIDI clip -- [`assembly/`](assembly/README.md) — the assembly map: which enclosure with which electronics, and where each route is documented - [`../firmware/CUSTOM_PATTERNS.md`](../firmware/CUSTOM_PATTERNS.md) — the hands-on pattern route (the everyday one is [`PATTERN_GUIDE.md`](../PATTERN_GUIDE.md)) - [`../firmware/patternflow/console/README.md`](../firmware/patternflow/console/README.md) — editing or adding a device console page ## Building it -- [`assembly/`](assembly/README.md) — the map. The full guide is [`../BUILD_GUIDE.md`](../BUILD_GUIDE.md); the files to order are described in [`../hardware/README.md`](../hardware/README.md) +- [`assembly/`](assembly/README.md) — the assembly map: which enclosure with which electronics, and where each route is documented. The full guide is [`../BUILD_GUIDE.md`](../BUILD_GUIDE.md); the files to order are described in [`../hardware/README.md`](../hardware/README.md); enclosures other people built are [`../hardware/case/remixes/`](../hardware/case/remixes/README.md) +- The same build in 3D, step by step, is [patternflow.work/guide/build](https://patternflow.work/guide/build). Its firmware chapter hands over to the Play guide's [Flash chapter](https://patternflow.work/guide/play#flash), which is also where to look when the first flash or the Wi-Fi step goes wrong (the written version is [`BUILD_GUIDE.md` §8](../BUILD_GUIDE.md#8-firmware)) +- [`legacy/BUILD_GUIDE_v2.md`](legacy/BUILD_GUIDE_v2.md) — the build guide for the legacy v2.x board - [`build-guide/images/`](build-guide/images/) — photos for the build guides (`images/` is the v2 set, `images/v3/` the current board) - [`LICENSE-SUMMARY.md`](LICENSE-SUMMARY.md) — MIT for code, CC BY-SA 4.0 for hardware, docs and bundled patterns, and what that means for a pattern you publish or a remix you share +## On the site, not in this folder + +The interactive guide at [patternflow.work/guide](https://patternflow.work/guide) has four parts, each in English and Korean: [Build](https://patternflow.work/guide/build) (soldering one from bare parts), [Play](https://patternflow.work/guide/play) (the first hour with a built one: flash, knobs, patterns, console), [Make](https://patternflow.work/guide/make) (community and Pattern Lab) and [Audio](https://patternflow.work/guide/audio) (the Audio edition: a browser tab, the microphone, MIDI with a DAW). They cover the same ground as the written guides, and every step has a **Stuck here?** link that opens an issue with that step filled in. + ## Contributing - [`../CONTRIBUTING.md`](../CONTRIBUTING.md) — one table: what you have → where it goes → what CI runs on it -- [`../SUPPORT.md`](../SUPPORT.md) — where to ask what +- [`../.github/SUPPORT.md`](../.github/SUPPORT.md) — where to ask what - [`REPOSITORY.md`](REPOSITORY.md) — the folder-by-folder map, and the list of files that outside things link to +- [`voice.md`](voice.md) — how the docs sound in English and in Korean, with the words that stay out in [`voice-banned.txt`](voice-banned.txt) ## Running the project - [`RELEASING.md`](RELEASING.md) — the `dev` → `main` routine, cutting a release, what the workflows attach - [`SERVICES.md`](SERVICES.md) — the production hosts (community, build worker) and their systemd units. Korean. +## Drafts — Part 2 only, and nothing in them is built + +- [`rfc-any-resolution.md`](rfc-any-resolution.md) — a panel of another size on one ESP32-S3, chosen on a console page and applied at the next boot instead of in a rebuild. Classic HUB75 only, within the driver's DMA memory +- [`rfc-linux-player.md`](rfc-linux-player.md) — the site's pattern engine as a Linux program, onto a HUB75 HAT on a Raspberry Pi, a receiving card or a screen. Not firmware, and not an edition + ## Records -- [`investigations/`](investigations/) — dated post-mortems -- [`releases/`](releases/) — long-form notes for three July 2026 releases, kept as written; notes since 3.2 are on [GitHub Releases](https://github.com/engmung/Patternflow/releases), and `CHANGELOG.md` at the root is the complete record +- [`investigations/`](investigations/) — dated post-mortems, and the cleanup's plans. `npm run doc-map` in `web/` writes the two lists here: `2026-10-doc-map.csv`, every link in the tree and whether it resolves, and `2026-10-doc-parity.csv`, every Korean/English pair and which side is missing or short +- [`rfc-cleanup.md`](rfc-cleanup.md) and its [progress log](rfc-cleanup-progress.md) — the October 2026 tidy-up before the any-resolution work, done on 16 October. It changed where things live and what CI checks, and nothing the firmware or the site does. §2.9 lists the writing it locked +- [`releases/`](releases/) — every release's changelog section as written, one file each (`vX.Y.Z.md`), back to v1.0.0; `CHANGELOG.md` at the root keeps the three newest in full and links the rest. `v3.0.0-notes.md` and `v3.1.0-notes.md` are long-form notes for two July 2026 releases, beside the v3.0.0 launch checklist; notes since 3.2 are on [GitHub Releases](https://github.com/engmung/Patternflow/releases) - [`manifesto.md`](manifesto.md) — why Patternflow exists, and (section 6) how we treat each other - [`images/`](images/), [`media/`](media/) — assets the guides and the site reference diff --git a/docs/RELEASING.md b/docs/RELEASING.md index fb438b7ef..cd7bcde55 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -19,7 +19,8 @@ the `[Unreleased]` section of `CHANGELOG.md` written: python firmware/toolchain/release.py cut v3.9.4 --audio v0.5.4 --performance v0.2.4 ``` -bumps the versions, dates the changelog section, updates `AGENTS.md`, runs +bumps the versions, dates the changelog section and files it in +`docs/releases/`, updates `AGENTS.md`, runs `shelf.sh` for the core and each named edition, points the flasher manifest and the `/editions` cards at the new images, runs the web checks, commits `release: v3.9.4` and tags it. Name only the editions that should be re-cut; @@ -63,7 +64,7 @@ What the two commands do, step by step - and the way to do it by hand. 1. Make sure all intended changes are committed, and `./firmware/bundles/build.sh all` is green if anything under `firmware/` moved. 2. Bump the version the firmware reports: `PF_IMPROV_FW_VERSION` in `firmware/patternflow/net_config.h` (written `X.Y.Z`, no `v`). `shelf.sh` refuses a core image whose define disagrees with the version it is being shelved as. -3. Turn `CHANGELOG.md`'s `[Unreleased]` into `## [X.Y.Z] - YYYY-MM-DD` and open a fresh `[Unreleased]` above it. +3. Turn `CHANGELOG.md`'s `[Unreleased]` into `## [X.Y.Z] - YYYY-MM-DD` and open a fresh `[Unreleased]` above it. Copy the new section to `docs/releases/vX.Y.Z.md` (the file name always carries the `v`), with each relative link two folders up (`../../`). The root keeps three releases in full: the fourth-newest section leaves it and becomes a line at the top of *Older releases*, linking its file. 4. Update the version the docs claim: the "current" line in `AGENTS.md`, the *Moving fast* note in `README.md`, and any guide that names a release. 5. If hardware changed: confirm `BUILD_GUIDE.md`'s parts table, `hardware/bom/bom_v*.csv` and the schematic agree; regenerate the Gerber zip, renders and schematic exports with the recipe in `hardware/pcb/README.md`; add a `### Hardware` entry to the changelog section; update the board table in `hardware/README.md` and the version line in `docs/assembly/README.md`; attach the Gerber zip, the BOM CSV and the STLs to the release. 6. Stage the images the site serves: @@ -102,9 +103,9 @@ Work happens on `dev`, then lands on `main` through a pull request. `main` is pr ## Current release line -`CHANGELOG.md` is the record — one section per release, newest first — and the [releases page](https://github.com/engmung/Patternflow/releases) carries the notes and the flashable images. The shape of the line, for orientation: +Every release has its own file in `docs/releases/`, `vX.Y.Z.md`, back to `v1.0.0`. `CHANGELOG.md` holds the three newest in full, newest first, and a line for each older one. The [releases page](https://github.com/engmung/Patternflow/releases) carries the notes and the flashable images. The shape of the line, for orientation: - `v1.x` -- first public buildable release, then the multi-pattern firmware and browser flasher. - `v2.x` -- the v2.0 board (GPIO0 cold-boot fix, cleaned silkscreen), custom pattern workflow, the web platform. `v2.1.0` is the last release for v2.x hardware. -- `v3.0.0` -- the v3.0 board; `v3.9` (2026-09, unreleased as a tag) removed its USB-C footprint and changed nothing else. Every later `v3.x` is firmware/web on that hardware: `.pfm` modules over Wi-Fi (3.2), shows and the Director (3.6), the feature seam (3.7), editions (3.8). +- `v3.0.0` -- the v3.0 board; `v3.9` (2026-09, unreleased as a tag) removed its USB-C footprint and changed nothing else. Every later `v3.x` is firmware/web on that hardware: `.pfm` modules over Wi-Fi (3.2), shows and the Director (3.6), the feature seam (3.7), editions (3.8), the panel's own hotspot and pattern code in PSRAM (3.10.5), patterns that stay loaded when you leave them (3.11). - Editions on the shelf (`audio`, `performance`) carry their own version lines, independent of the project version — see `docs/EDITIONS.md`. `clock` and `midi` are bundles CI builds but the shelf does not carry. diff --git a/docs/REPOSITORY.md b/docs/REPOSITORY.md index e22c6b03d..adbf7f49b 100644 --- a/docs/REPOSITORY.md +++ b/docs/REPOSITORY.md @@ -6,27 +6,28 @@ A map for people. (AI coding agents get the same map with more rules in [`../AGE | Folder | What it is | Start at | | :--- | :--- | :--- | -| `firmware/` | The ESP32-S3 firmware. `patternflow/` is the sketch: `patternflow.ino`, `config.h` (pins, brightness, limits, calibration), `net_config.h` (Wi-Fi, OTA, update defaults), `src/` (the engine: HUB75 driver, encoders, canvas, colour, noise, Wi-Fi, web console — the **core**, compiled unchanged into every published firmware), `features/` (everything optional: OSC, audio, MQTT, MIDI, shows, weather, clock — attached through hooks the core exposes), `presets/` (the curated patterns), `abi/` (the frozen contract a `.pfm` module is compiled against), `console/` (the device's web pages as plain HTML). `bundles/` names the editions (which features compile in) and holds `build.sh`. `toolchain/` is the repo-level scripts: build a module, make the Basics pack, and the `check_*.py` that CI runs. `modules/` is the module toolchain's working folder, not a place patterns are contributed to | [`firmware/README.md`](../firmware/README.md) for building; [`EDITIONS.md`](EDITIONS.md) before touching anything | -| `hardware/` | `pcb/` (KiCad source, Gerbers per revision, schematic PDF), `case/` (STLs by printer bed size, knob plates, the Blender source, legacy v2 cases, and `remixes/` for community variants), `bom/` (the CSV that is the BOM source of truth) | [`hardware/README.md`](../hardware/README.md) | -| `web/` | The Next.js site at patternflow.work: landing page, the interactive guide (`/guide`, with `/guide/build`, `/guide/play`, `/guide/make` and `/guide/audio`), Live Editor, Pattern Lab, the community (only on the community host), browser flasher, edition shelf, device update handoff, journal, roadmap. The JS presets in `src/lib/presets/` are the source of truth for the firmware's preset headers. The breadboard build guide is a React page here, `src/app/build/breadboard/` | [`web/README.md`](../web/README.md), then [`web/ARCHITECTURE.md`](../web/ARCHITECTURE.md) | -| `docs/` | Contracts other software is built against, how the firmware is put together, walk-throughs, the assembly map, project records and media | [`README.md`](README.md) | -| `tools/` | Clients Patternflow ships that run on their own: the audio-react browser extension (also the authoring source of the device's `/audio-in` page), the Android capture app, an RTP-MIDI probe. Also `case-models/`, the scripts that make the /build page's 3D model of each case | [`tools/README.md`](../tools/README.md) | +| `firmware/` | The ESP32-S3 firmware. `patternflow/` is the sketch: `patternflow.ino`, `config.h` (pins, brightness, limits, calibration), `net_config.h` (Wi-Fi, OTA, update defaults), `src/` (the engine: HUB75 driver, encoders, canvas, colour, noise, Wi-Fi, web console — the **core**, compiled unchanged into every published firmware), `features/` (everything optional: OSC, audio, the microphone, MQTT, MIDI, shows, weather, clock, BLE — attached through hooks the core exposes), `presets/` (the curated patterns), `abi/` (the frozen contract a `.pfm` module is compiled against), `console/` (the device's web pages as plain HTML). `bundles/` names the compositions — the shelf editions `audio` and `performance`, plus `clock` and `midi`, which CI keeps compiling but the shelf does not carry — and holds `build.sh` and `shelf.sh`. `toolchain/` is the repo-level scripts: build a module, make the Basics pack, and the `check_*.py` that CI runs. `modules/` is the module toolchain's working folder, not a place patterns are contributed to. `encoder_test/` is a standalone sketch for checking the four encoders, and `CUSTOM_PATTERNS.md` the hands-on pattern route | [`firmware/README.md`](../firmware/README.md) for building; [`EDITIONS.md`](EDITIONS.md) before touching anything | +| `hardware/` | `pcb/` (KiCad source, Gerbers per revision, schematic PDF), `case/` (STLs by printer bed size, knob plates, the Blender source, legacy v2 cases, the old v1/v2 laser-cut drawings in `legacy_lasercut/`, and `remixes/` — enclosures other people designed and built, one folder each), `bom/` (the CSV that is the BOM source of truth) | [`hardware/README.md`](../hardware/README.md) | +| `web/` | The Next.js site at patternflow.work: landing page, the interactive guide (`/guide`, with `/guide/build`, `/guide/play`, `/guide/make` and `/guide/audio`), Live Editor, Pattern Lab, the community (only on the community host), the browser flasher (the **Flash Patternflow** button on the Pattern tab and in the Play guide; the images it installs are `public/flash/`), edition shelf, feature catalogue, device update handoff, journal, roadmap. The JS presets in `src/lib/presets/` are the source of truth for the firmware's preset headers. The breadboard build guide is a React page here, `src/app/build/breadboard/` | [`web/README.md`](../web/README.md), then [`web/ARCHITECTURE.md`](../web/ARCHITECTURE.md) | +| `shared/` | `constants.json`, the values the firmware and the site both carry: the panel geometries, the knob count, the encoder's 24 detents a turn, the bus maximum and the button timings. `npm run gen:shared` in `web/` writes `firmware/patternflow/src/core_shared.h` and `web/src/lib/shared/constants.ts` from it. Neither is edited by hand, and `check:shared` fails while either is stale. The folder exists because the lab counted 20 detents a turn, against the encoder's 24, until 31 July 2026 | [`constants.json`](../shared/constants.json) | +| `docs/` | Contracts other software is built against, each with a folder of test vectors beside it, how the firmware is put together, walk-throughs, the audio guide (`AUDIO_GUIDE.md`), the assembly map, the legacy v2.x build guide (`legacy/`), the writing voice (`voice.md` and `voice-banned.txt`), project records (`releases/`, one file per release) and media | [`README.md`](README.md) | +| `tools/` | Clients Patternflow ships that run on their own: the audio-react browser extension (also the authoring source of the device's `/audio-in` page), the Android capture app, an RTP-MIDI probe. Also `case-models/`, the scripts that make the /build page's 3D model of each case, the official one and every remix on its case switch. `virtual-panel/` is the other end of the contracts: a Patternflow with no board, on the firmware's ports, that the vectors in `docs/` are replayed against | [`tools/README.md`](../tools/README.md) | | `integrations/` | Bridges that run inside or beside someone else's software (Ableton / Max for Live today) and talk to the panel only through the contracts in `docs/` | [`integrations/README.md`](../integrations/README.md) | -| `.github/` | Issue and pull-request templates, the CI workflows (web, firmware editions, doc links, console-page sync, firmware release assets, Discord dev-log) and their helper scripts | the header comment of each workflow | -| root | `README.md`, the five guides (`BUILD_GUIDE.md`, `BUILD_GUIDE_v2.md`, `PATTERN_GUIDE.md`, `FEATURE_GUIDE.md`, `AUDIO_GUIDE.md`), `CHANGELOG.md`, `CONTRIBUTING.md`, `SUPPORT.md`, `CODE_OF_CONDUCT.md`, `SECURITY.md`, `CITATION.cff`, the two license texts and the `LICENSE` pointer, `AGENTS.md` (+ `CLAUDE.md`, a one-line shim) | | +| `.github/` | Issue and pull-request templates (including the guide's **Stuck here?** form and **Share your build**), `SUPPORT.md`, `CODE_OF_CONDUCT.md`, `SECURITY.md`, the CI workflows (web, every firmware composition, the core-names-no-feature checks, doc links, the drift checks, community folders carrying their README, console-page sync, firmware release assets, Discord dev-log) and their helper scripts | the header comment of each workflow | +| root | `README.md`, the three guides (`BUILD_GUIDE.md`, `PATTERN_GUIDE.md`, `FEATURE_GUIDE.md`), `CHANGELOG.md`, `CONTRIBUTING.md`, `CITATION.cff`, the two license texts and the `LICENSE` pointer, `AGENTS.md` (+ `CLAUDE.md`, a one-line shim) | | -The rule for where a document lives: **a guide somebody follows start to finish is at the root; a contract, a reference or a record is in `docs/`; a folder explains itself in its own README.** +The rule for where a document lives: **the root keeps three guides and no others, so a first-time visitor reads it at a glance; a contract, a reference, a walk-through for one task or a record is in `docs/`; a folder explains itself in its own README.** ## What you want to do → where to start | Goal | Path | | :--- | :--- | | Build one | [`README.md`](../README.md) → [`BUILD_GUIDE.md`](../BUILD_GUIDE.md) (or the [assembly map](assembly/README.md) for the other routes) → [`hardware/README.md`](../hardware/README.md) for which files to order. The same build in 3D: [patternflow.work/guide/build](https://patternflow.work/guide/build) | -| Use the one you built | [patternflow.work/guide/play](https://patternflow.work/guide/play) — flashing, the knobs, installing patterns and the console, on a Patternflow you can turn | +| Use the one you built | [patternflow.work/guide/play](https://patternflow.work/guide/play) — flashing, the knobs, installing patterns and the console, on a Patternflow you can turn. The first flash goes through download mode (hold BOOT, tap RST, let go of BOOT), and the Wi-Fi step usually does not appear the first time: the [Flash chapter](https://patternflow.work/guide/play#flash) and [`BUILD_GUIDE.md` §8](../BUILD_GUIDE.md#8-firmware) say what to do, in order | | Make a pattern | [`PATTERN_GUIDE.md`](../PATTERN_GUIDE.md) — the Pattern Lab, the community, and the device. No local toolchain involved. The same loop as a tutorial: [patternflow.work/guide/make](https://patternflow.work/guide/make). [`firmware/CUSTOM_PATTERNS.md`](../firmware/CUSTOM_PATTERNS.md) is the older, more hands-on route | | Write a firmware feature or cut an edition | [`FEATURE_GUIDE.md`](../FEATURE_GUIDE.md) → [`EDITIONS.md`](EDITIONS.md) → [`features/README.md`](../firmware/patternflow/features/README.md) → [`bundles/README.md`](../firmware/bundles/README.md) | -| Control the panel from other software | [`rest-api.md`](rest-api.md) has the table for choosing between HTTP, OSC, MIDI and MQTT; then the spec for the one you chose | -| Put sound into it | [`AUDIO_GUIDE.md`](../AUDIO_GUIDE.md). The browser tab, the microphone and MIDI with a DAW, step by step: [patternflow.work/guide/audio](https://patternflow.work/guide/audio) | +| Control the panel from other software | [`rest-api.md`](rest-api.md) has the table for choosing between HTTP, OSC, MIDI and MQTT; then the spec for the one you chose. [`tools/virtual-panel/`](../tools/virtual-panel/README.md) answers on the same ports with no board behind it | +| Put sound into it | [`AUDIO_GUIDE.md`](AUDIO_GUIDE.md). The browser tab, the microphone and MIDI with a DAW, step by step: [patternflow.work/guide/audio](https://patternflow.work/guide/audio) | | Work on the site | [`web/README.md`](../web/README.md) → [`web/ARCHITECTURE.md`](../web/ARCHITECTURE.md) | | Contribute anything | [`CONTRIBUTING.md`](../CONTRIBUTING.md) — one table of where each kind of contribution goes | | Cut a release, run the servers | [`RELEASING.md`](RELEASING.md), [`SERVICES.md`](SERVICES.md) | @@ -41,11 +42,15 @@ Filenames are lowercase with underscores. Tags are `vX.Y.Z`; the firmware source ## Files that other things link to — check before renaming -The site, the firmware and the release notes hold absolute `blob/main/…` links into this tree, and none of them is checked by CI. Before renaming or moving any of these, grep `web/src`, `web/content`, `firmware/patternflow/console` and `firmware/patternflow/features/*/*_index.h`: +The site, the firmware and the release notes hold absolute `blob/main/…` links into this tree, and a pull request that breaks one fails `check:links-absolute`. Before renaming or moving any of these, grep `web/src`, `web/content`, `firmware/patternflow/console` and `firmware/patternflow/features/*/*_index.h`: -- `README.md`, `BUILD_GUIDE.md` (three section anchors are linked from the site), `AUDIO_GUIDE.md`, `FEATURE_GUIDE.md`, `PATTERN_GUIDE.md` -- `docs/assembly/README.md`, `docs/midi-ableton.md`, `docs/midi-spec.md` (the last two are baked into the Audio edition's console pages) -- `docs/EDITIONS.md`, `docs/rest-api.md`, `firmware/README.md`, `hardware/bom/bom_v3.9.csv` +- `README.md`, `BUILD_GUIDE.md` (the site links its section anchors — §1, §2, §4, §8, §8.2 and §10 when this was written — so a renamed heading breaks them), `FEATURE_GUIDE.md`, `PATTERN_GUIDE.md`, `CHANGELOG.md`, `CONTRIBUTING.md` and `.github/SUPPORT.md` (the issue forms link the last two) +- `docs/AUDIO_GUIDE.md`, `docs/assembly/README.md`, `docs/midi-ableton.md`, `docs/midi-spec.md` (the last two are baked into the Audio edition's console pages) +- `docs/EDITIONS.md`, `docs/rest-api.md`, `docs/osc-spec.md`, `docs/mqtt-spec.md`, `docs/audio-ws-spec.md`, `docs/pfst-v2-spec.md`, `docs/director-midi.md` (the features page links each of these) +- `docs/panel-compatibility.md`, `docs/legacy/BUILD_GUIDE_v2.md`, `docs/LICENSE-SUMMARY.md`, `docs/releases/v2.0.0.md`, `firmware/README.md`, `firmware/CUSTOM_PATTERNS.md`, `hardware/README.md`, `hardware/bom/bom_v3.9.csv`, `tools/patternflow-audio-android/README.md` +- `hardware/case/README.md`, and each folder in `hardware/case/remixes/` with the files in it. The Build panel's case cards link them by name: a README, a `print_layout.3mf`, the mbchars case's `assembly.md` and `bom.md` - `web/src/app/compliance/` — the `/compliance` URL is printed on the box. Never move, redirect or noindex it. -`.github/scripts/check_links.py` checks the *relative* links in every tracked markdown file and runs on every pull request that touches one. +Folders are linked too. The features page links each feature's folder in `firmware/patternflow/features/`, and the site's Build guide links files in `hardware/case/bed_256mm/`, `hardware/case/bed_330mm/` and `hardware/pcb/`. Every row, with the file the link sits in, is in [`investigations/2026-10-doc-map.csv`](investigations/2026-10-doc-map.csv) under the kind `absolute-blob`. `npm run doc-map` in `web/` writes that list again from the tree as it is. + +`.github/scripts/check_links.py` checks the *relative* links in every tracked markdown file and runs on every pull request that touches one. `check:links-absolute` (`web/scripts/doc-map.ts --check absolute`, in `drift.yml`) checks the absolute ones on every pull request, against the tree or the tag a link names. diff --git a/docs/SERVICES.md b/docs/SERVICES.md index ee49989ab..fca4ac937 100644 --- a/docs/SERVICES.md +++ b/docs/SERVICES.md @@ -1,6 +1,6 @@ # Patternflow Server Services & Worker Management -Patternflow 커뮤니티 웹 서버 및 C++ 펌웨어 웹플래시 컴파일 빌드 워커 서비스 구성과 운영 방법 가이드입니다. +Patternflow 커뮤니티 웹 서버와, 패턴 C++ 헤더를 `.pfm` 모듈로 굽는 빌드 워커 서비스 구성과 운영 방법 가이드입니다. --- @@ -32,10 +32,10 @@ RestartSec=3 OOM 킬은 `dmesg -T | grep -i "killed process"` 로 봅니다. ### ② `patternflow-worker.service` -- **역할**: C++ 펌웨어 빌드 워커 (`scripts/build-worker.ts`) -- **설명**: 웹플래시 요청 대기열(`queued`)을 수신하여 `arduino-cli`를 이용해 ESP32-S3 바이너리를 컴파일합니다. +- **역할**: 패턴 모듈 빌드 워커 (`scripts/build-worker.ts`) +- **설명**: 빌드 대기열(`queued`)에서 잡을 하나씩 가져가 헤더마다 `firmware/toolchain/build_module.py`와 Xtensa 컴파일러로 ESP32-S3용 `.pfm` 모듈을 굽습니다. 펌웨어 이미지 통째 빌드는 2026-08-12(`4c13474`)에 끝났고, 그 뒤로 워커는 `arduino-cli`를 부르지 않습니다. - **위치**: `/etc/systemd/system/patternflow-worker.service` -- **환경변수 중요 사항**: `Environment=PATH`에 `arduino-cli` 설치 경로(`~/.local/bin` 또는 `/usr/local/bin`) 포함 필수 +- **환경변수 중요 사항**: 워커는 `python`(다른 이름이면 `PYTHON_PATH`)과 `PF_XTENSA_BIN`이 가리키는 컴파일러(아래 2단계)를 띄웁니다. `Environment=PATH`에는 Python이 있어야 하고, 예전에 넣은 `arduino-cli` 경로는 이제 쓰이지 않습니다. #### 워커는 격리된 채로 돈다 — `hardening.conf` (2026-09-04) @@ -93,9 +93,10 @@ strace로 확인한 전부다: 데이터 디렉터리(DB·WAL·산출물), 워 것으로 재현했다. 두 단계 중 첫째는 끝났다: 1. **시크릿은 유닛 밖 (완료, 2026-09-04).** 워커가 필요로 하는 변수 - (`COMMUNITY_ENABLED`, `BUILD_ENABLED`, `BUILD_FQBN`, `COMMUNITY_DB_PATH`)는 - root 전용 `/etc/patternflow/worker.env`(0600)에서 `EnvironmentFile=`로 - 받는다. `.env.local`은 `InaccessiblePaths`로 막으면 워커가 못 뜬다 — + (`COMMUNITY_ENABLED`, `BUILD_ENABLED`, `COMMUNITY_DB_PATH`, 2단계의 + `PF_XTENSA_BIN`)는 root 전용 `/etc/patternflow/worker.env`(0600)에서 + `EnvironmentFile=`로 받는다. 그날 같이 넣은 `BUILD_FQBN`은 통째 이미지 + 빌드의 보드 옵션이라 8월 12일 뒤로 읽는 코드가 없다. `.env.local`은 `InaccessiblePaths`로 막으면 워커가 못 뜬다 — `scripts/loadEnv.ts`의 `existsSync`는 접근 불가 파일에도 `true`를 돌려주고, 그다음 `process.loadEnvFile`이 EACCES에 예외를 던진다. 그래서 `BindReadOnlyPaths=/dev/null:…/.env.local`로 **빈 파일로 보이게** 한다: @@ -314,8 +315,11 @@ npm run build:enqueue ## 3. 자주 발생하는 문제 & 조치법 (Troubleshooting) -- **웹플래시 요청이 계속 `queued`에서 안 넘어감**: +- **빌드 요청이 계속 `queued`에서 안 넘어감**: - `patternflow-worker.service`가 켜져 있는지 확인: `sudo systemctl status patternflow-worker.service` - 꺼져 있다면 재시작: `sudo systemctl restart patternflow-worker.service` -- **`spawn arduino-cli ENOENT` 에러 발생**: - - `patternflow-worker.service` 파일 내 `PATH` 환경변수에 `arduino-cli` 설치 경로가 올바르게 지정되었는지 확인. +- **빌드 오류가 `The build service's compiler is not available right now. No Xtensa compiler found`로 시작함**: + - 워커가 `xtensa-esp32s3-elf-g++`를 찾지 못했습니다. `/etc/patternflow/worker.env`의 `PF_XTENSA_BIN`이 래퍼 디렉터리(`/etc/patternflow/xtensa-sandbox`)를 가리키는지, 래퍼가 부르는 `~/.arduino15/packages/esp32/tools/esp-x32/2601`이 그대로 있는지 확인. + - 오류 전문은 `npm run build:status -- `의 `--- error ---` 아래에 나옵니다. +- **빌드 오류에 `[cannot run python: spawn python ENOENT]`가 나옴**: + - 유닛의 `PATH`에 `python`이 없습니다. `worker.env`에 `PYTHON_PATH=python3`처럼 실행 파일 이름이나 경로를 넣고 워커를 재시작. diff --git a/docs/assembly/README.md b/docs/assembly/README.md index 07010a0d1..beb91b416 100644 --- a/docs/assembly/README.md +++ b/docs/assembly/README.md @@ -1,13 +1,13 @@ # Build Patternflow — Assembly Map > **Which version am I building?** Current hardware is the **v3.9 board** (the v3.0 board with the USB-C footprint removed; same case, same guide): follow **[BUILD_GUIDE.md](../../BUILD_GUIDE.md)** and take the files from the tree — [`hardware/README.md`](../../hardware/README.md) says exactly which Gerber, BOM and STL to use. The [v3.0.0 release](https://github.com/engmung/Patternflow/releases/tag/v3.0.0) bundles the previous revision's files. -> Own a **v2.x board**? Use **[BUILD_GUIDE_v2.md](../../BUILD_GUIDE_v2.md)** and the **[v2.1.0 release](https://github.com/engmung/Patternflow/releases/tag/v2.1.0)** instead. **v2 and v3 parts are not interchangeable** — the boards do not fit each other's cases. +> Own a **v2.x board**? Use **[BUILD_GUIDE_v2.md](../legacy/BUILD_GUIDE_v2.md)** and the **[v2.1.0 release](https://github.com/engmung/Patternflow/releases/tag/v2.1.0)** instead. **v2 and v3 parts are not interchangeable** — the boards do not fit each other's cases. > 🔦 **Buying the LED panel? Read [LED Panel Compatibility](../panel-compatibility.md) first.** Patternflow scans the panel directly from the ESP32-S3, so the **driver IC** decides whether it lights up — "HUB75E" on the listing does not, and a spec-matching panel with S-PWM "video wall" drivers stays completely dark with no firmware fix. Since that part number is almost never in the listing, the practical check is the **buyer reviews**: someone running it off an ESP32 or Raspberry Pi is the best evidence you'll get. Patternflow is not a single, rigid kit. It is a modular system divided into two core parts: -1. **Enclosure** — how you house the device (3D printed, or a community remix such as the laser-cut one below). +1. **Enclosure** — how you house the device (3D printed, or one of the community remixes below). 2. **Electronics** — how you wire the hardware (custom PCB, or breadboard). Build those two, flash the firmware, and your Patternflow is alive. @@ -31,6 +31,7 @@ This is the route [BUILD_GUIDE.md](../../BUILD_GUIDE.md) walks start to finish, | 3D printed enclosure | Breadboard / jumper-wire electronics | Available — [Breadboard Build Guide](https://patternflow.work/build/breadboard) | | Laser-cut acrylic or MDF sheet | Custom PCB | Community — [`remixes/simonepda-lasercut/`](../../hardware/case/remixes/simonepda-lasercut/README.md), by Simone Majocchi. Built by its author in both materials; its README stands in for the printing and case-assembly sections of the build guide | | 3D printed, glued halves with screwed-on covers | Custom PCB | Community — [`remixes/besoiobiy-printed/`](../../hardware/case/remixes/besoiobiy-printed/README.md), by Besoiobiy. Built by its author; the panel is bolted through the case into its own M3 sockets, so check your panel's thread size and socket depth first. Its README stands in for the printing and case-assembly sections of the build guide and sets the wiring order | +| 3D printed, horizontal on a desk, with stands | Custom PCB | Community — [`remixes/mbchars-horizontal-desktop-printed/`](../../hardware/case/remixes/mbchars-horizontal-desktop-printed/README.md), by Mykyta Bilous (mbchars). Built by its author; the panel lies the long way with the controls on the right, and there is no power-bank bay — a separate USB-PD trigger module feeds `J4`, the screw terminal, at 5 V. Its README and assembly notes stand in for the printing and case-assembly sections of the build guide | | Any other enclosure | either | Not an official path. Community variants live in [`hardware/case/remixes/`](../../hardware/case/remixes/README.md); the old acrylic drawings, for the v1 and v2 boards, are in [`legacy_lasercut/`](../../hardware/case/legacy_lasercut/README.md) | The breadboard path exists to make Patternflow easier and cheaper to start. A breadboard build is not just a temporary prototype — if that form is enough for you, it is a valid Patternflow build. Want a more finished object later? Move to the PCB and printed-enclosure path whenever you like. @@ -41,7 +42,7 @@ The custom PCB path is stable. PCBA may become a later electronics path for peop To bring the hardware to life you flash it with firmware, and you can **create and run your own generative patterns with AI coding assistants**. -Custom patterns no longer need a local toolchain or a reflash: a pattern built in the browser installs on the panel over Wi-Fi as a `.pfm` module in seconds (the browser flasher writes the firmware itself over USB once, and sets up Wi-Fi while it is at it). The PlatformIO route remains only for firmware feature development or targeting a different LED matrix resolution. +Custom patterns no longer need a local toolchain or a reflash: a pattern built in the browser installs on the panel over Wi-Fi as a `.pfm` module in seconds (the browser flasher writes the firmware over USB once, with the DevKit out of the board and in download mode — hold BOOT, tap RST — and Wi-Fi is set after it, usually on a second go with the flasher or from the panel's own network; the Play guide's [Flash chapter](https://patternflow.work/guide/play#flash) has the order, and [`BUILD_GUIDE.md` §8](../../BUILD_GUIDE.md#8-firmware) the details). The PlatformIO route remains only for firmware feature development or targeting a different LED matrix resolution. - **[Make your own patterns](../../PATTERN_GUIDE.md)** — the Pattern Lab, the community, and sending a pattern to your device over Wi-Fi. [`firmware/CUSTOM_PATTERNS.md`](../../firmware/CUSTOM_PATTERNS.md) is the hands-on route. @@ -50,4 +51,4 @@ Custom patterns no longer need a local toolchain or a reflash: a pattern built i | Version | Guide | Everything bundled | | --- | --- | --- | | **v3.9 board** (current; v3.0 is the same board with a USB-C footprint you leave empty) | [BUILD_GUIDE.md](../../BUILD_GUIDE.md) | the files in [`hardware/`](../../hardware/README.md); the [v3.0.0 release](https://github.com/engmung/Patternflow/releases/tag/v3.0.0) bundles the v3.0 revision | -| v2.1.0 (legacy) | [BUILD_GUIDE_v2.md](../../BUILD_GUIDE_v2.md) | [v2.1.0 release](https://github.com/engmung/Patternflow/releases/tag/v2.1.0) | +| v2.1.0 (legacy) | [BUILD_GUIDE_v2.md](../legacy/BUILD_GUIDE_v2.md) | [v2.1.0 release](https://github.com/engmung/Patternflow/releases/tag/v2.1.0) | diff --git a/docs/audio-ws-vectors/README.md b/docs/audio-ws-vectors/README.md new file mode 100644 index 000000000..c9ec8b651 --- /dev/null +++ b/docs/audio-ws-vectors/README.md @@ -0,0 +1,13 @@ +# Audio WebSocket vectors + +Each file here is one sentence of [audio-ws-spec.md](../audio-ws-spec.md) and the messages that show a panel doing it, written as [contract-vectors.md](../contract-vectors.md) sets out. `npm run check:audio-vectors` in `web/` replays every one against a fresh [virtual panel](../../tools/virtual-panel/README.md) built with the `audio` feature, and prints what the vector expects beside what the panel did. CI runs it on every pull request with the other drift checks. When the spec rewords a sentence a vector quotes, that vector fails until someone brings it along. + +A file's name starts with the part of the spec it reads. `messages-` is a row of the message table, or the rule for anything else. `lanes-` is what a lane is and what outranks it, and `release-` is the 500 ms release. `switch-` is the AUD switch. `/api/audio` is REST, so its vectors are in [rest-api-vectors](../rest-api-vectors/README.md) and start with `audio-`. + +A message is the text of one frame, `"a=0.8,-,-,0.2"`, and `{ "close": 1000 }` closes the socket with that code. Node 20 has no WebSocket client, so the suite brings one, `web/scripts/drift/_ws.ts`, and opens one socket a vector before the first step. After each message it sends a ping and waits for the pong, so the panel holds the message before the clock moves. The panel reads what has arrived at the end of a frame, 16.7 ms at 60 fps. A vector gives a lane 20 ms before it reads `/api/status`, and a `d=` 50 ms, since its clicks reach the pattern a frame later. A fresh panel has Audio-React on, as a board with nothing saved boots, so no vector switches it on first. + +`messages-d.json` reads "normalized" as a share of a parameter's whole range, which rest-api.md puts at 48 detents. A `d=` of 0.5 is 24 clicks. + +Three vectors keep the spec where the firmware does something else. `messages-a.json` sends `a=-0.5,0.25,-,-`, and the firmware takes the leading `-` for a skipped slot and puts 0.5 on the second lane (the `a=` parse in `core_audio_ws.h`). `messages-unknown.json` sends `off=x`, which the firmware reads as `off=0`. `lanes-hand.json` sends a `d=` to a knob a lane drives, and the firmware holds that knob five seconds as if a hand had turned it (`applyLaneMotion()` in `patternflow.ino`). + +Some of the spec has no vectors. The virtual panel has no microphone and no weather, so the WebSocket is the only source its lanes have. A vector talks over one socket, so two clients writing the same knob are not replayed, and neither is a socket that closes while another stays open. The one-in-flight pacing and the extension's resend timer are rules for a client, and a panel has nothing to show for them. diff --git a/docs/audio-ws-vectors/lanes-bus.json b/docs/audio-ws-vectors/lanes-bus.json new file mode 100644 index 000000000..7dd5dbd4b --- /dev/null +++ b/docs/audio-ws-vectors/lanes-bus.json @@ -0,0 +1,12 @@ +{ + "contract": "docs/audio-ws-spec.md#semantics--what-a-lane-is", + "says": "Priority per knob, highest first (see `abi/pf_params.h`):", + "features": ["audio"], + "steps": [ + { "send": { "ws": "a=0.5,0.5,-,-" } }, + { "send": { "wait": 20 }, "expect": { "status": { "laneActive": [true, true, false, false] } } }, + { "send": { "http": "POST /api/params?p1=700" } }, + { "send": { "ws": "a=0.5,0.5,-,-" } }, + { "send": { "wait": 20 }, "expect": { "status": { "params": [700, "", "", ""], "paramActive": [true, false, false, false], "laneActive": [false, true, false, false] } } } + ] +} diff --git a/docs/audio-ws-vectors/lanes-close.json b/docs/audio-ws-vectors/lanes-close.json new file mode 100644 index 000000000..86bd585cf --- /dev/null +++ b/docs/audio-ws-vectors/lanes-close.json @@ -0,0 +1,11 @@ +{ + "contract": "docs/audio-ws-spec.md#semantics--what-a-lane-is", + "says": "the firmware also releases lanes when the socket closes", + "features": ["audio"], + "steps": [ + { "send": { "ws": "a=0.25,0.5,0.75,1" } }, + { "send": { "wait": 20 }, "expect": { "status": { "laneActive": [true, true, true, true], "audioClients": 1 } } }, + { "send": { "ws": { "close": 1000 } } }, + { "send": { "wait": 20 }, "expect": { "status": { "laneActive": [false, false, false, false], "audioClients": 0 } } } + ] +} diff --git a/docs/audio-ws-vectors/lanes-hand.json b/docs/audio-ws-vectors/lanes-hand.json new file mode 100644 index 000000000..ef21309e7 --- /dev/null +++ b/docs/audio-ws-vectors/lanes-hand.json @@ -0,0 +1,17 @@ +{ + "contract": "docs/audio-ws-spec.md#semantics--what-a-lane-is", + "says": "A physical turn of an encoder takes that knob back and holds it for five seconds; keep streaming and the lane resumes when the hold expires.", + "features": ["audio"], + "steps": [ + { "send": { "ws": "a=0.5,0.5,-,-" } }, + { "send": { "wait": 20 }, "expect": { "status": { "laneActive": [true, true, false, false] } } }, + { "send": { "hand": { "knob": 1, "turn": 1 } } }, + { "send": { "ws": "d=1,v=0.25" } }, + { "send": { "wait": 20 }, "expect": { "status": { "laneActive": [false, true, false, false] } } }, + { "send": { "wait": 4700 } }, + { "send": { "ws": "a=0.5,0.5,-,-" } }, + { "send": { "wait": 20 }, "expect": { "status": { "laneActive": [false, true, false, false] } } }, + { "send": { "ws": "a=0.5,0.5,-,-" } }, + { "send": { "wait": 300 }, "expect": { "status": { "laneActive": [true, true, false, false] } } } + ] +} diff --git a/docs/audio-ws-vectors/messages-a.json b/docs/audio-ws-vectors/messages-a.json new file mode 100644 index 000000000..ba3f74ad0 --- /dev/null +++ b/docs/audio-ws-vectors/messages-a.json @@ -0,0 +1,15 @@ +{ + "contract": "docs/audio-ws-spec.md#messages", + "says": "A literal `-` in a slot leaves that lane untouched: `a=0.8,-,-,0.2`.", + "features": ["audio"], + "steps": [ + { "send": { "ws": "a=0.25,0.5,0.75,1" } }, + { "send": { "wait": 20 }, "expect": { "status": { "lanes": [0.25, 0.5, 0.75, 1], "laneActive": [true, true, true, true] } } }, + { "send": { "wait": 200 } }, + { "send": { "ws": "a=0.8,-,-,0.2" } }, + { "send": { "wait": 20 }, "expect": { "status": { "lanes": [0.8, 0.5, 0.75, 0.2], "laneActive": [true, true, true, true] } } }, + { "send": { "wait": 400 }, "expect": { "status": { "laneActive": [true, false, false, true] } } }, + { "send": { "ws": "a=-0.5,0.25,-,-" } }, + { "send": { "wait": 20 }, "expect": { "status": { "lanes": ["", 0.25, 0.75, 0.2] } } } + ] +} diff --git a/docs/audio-ws-vectors/messages-d.json b/docs/audio-ws-vectors/messages-d.json new file mode 100644 index 000000000..b0e0c37aa --- /dev/null +++ b/docs/audio-ws-vectors/messages-d.json @@ -0,0 +1,12 @@ +{ + "contract": "docs/audio-ws-spec.md#messages", + "says": "Add a normalized delta `F` (−1..1) to knob `N` — encoder-style motion rather than a level.", + "features": ["audio"], + "steps": [ + { "send": { "ws": "d=1,v=0.5" } }, + { "send": { "wait": 50 }, "expect": { "input": { "given": { "deltas": [0, 24, 0, 0] } }, "status": { "laneActive": [false, false, false, false] } } }, + { "send": { "ws": "d=1,v=-0.25" } }, + { "send": { "ws": "d=3,v=-1" } }, + { "send": { "wait": 50 }, "expect": { "input": { "given": { "deltas": [0, 12, 0, -48] } } } } + ] +} diff --git a/docs/audio-ws-vectors/messages-k.json b/docs/audio-ws-vectors/messages-k.json new file mode 100644 index 000000000..7d0b43f11 --- /dev/null +++ b/docs/audio-ws-vectors/messages-k.json @@ -0,0 +1,11 @@ +{ + "contract": "docs/audio-ws-spec.md#messages", + "says": "Set lane `N` (0..3) to `F` (clamped to `0..1`).", + "features": ["audio"], + "steps": [ + { "send": { "ws": "k=0,v=0.25" } }, + { "send": { "ws": "k=2,v=1.5" } }, + { "send": { "ws": "k=3,v=-2" } }, + { "send": { "wait": 20 }, "expect": { "status": { "lanes": [0.25, "", 1, 0], "laneActive": [true, false, true, true] } } } + ] +} diff --git a/docs/audio-ws-vectors/messages-off-n.json b/docs/audio-ws-vectors/messages-off-n.json new file mode 100644 index 000000000..e0c93db68 --- /dev/null +++ b/docs/audio-ws-vectors/messages-off-n.json @@ -0,0 +1,13 @@ +{ + "contract": "docs/audio-ws-spec.md#messages", + "says": "Release knob `N` back to encoder control.", + "features": ["audio"], + "steps": [ + { "send": { "ws": "a=0.25,0.5,0.75,1" } }, + { "send": { "wait": 20 }, "expect": { "status": { "laneActive": [true, true, true, true] } } }, + { "send": { "ws": "off=2" } }, + { "send": { "wait": 20 }, "expect": { "status": { "laneActive": [true, true, false, true] } } }, + { "send": { "ws": "off=0" } }, + { "send": { "wait": 20 }, "expect": { "status": { "laneActive": [false, true, false, true] } } } + ] +} diff --git a/docs/audio-ws-vectors/messages-off.json b/docs/audio-ws-vectors/messages-off.json new file mode 100644 index 000000000..ed702e332 --- /dev/null +++ b/docs/audio-ws-vectors/messages-off.json @@ -0,0 +1,11 @@ +{ + "contract": "docs/audio-ws-spec.md#messages", + "says": "Release all four.", + "features": ["audio"], + "steps": [ + { "send": { "ws": "a=0.25,0.5,0.75,1" } }, + { "send": { "wait": 20 }, "expect": { "status": { "laneActive": [true, true, true, true] } } }, + { "send": { "ws": "off" } }, + { "send": { "wait": 20 }, "expect": { "status": { "laneActive": [false, false, false, false] } } } + ] +} diff --git a/docs/audio-ws-vectors/messages-unknown.json b/docs/audio-ws-vectors/messages-unknown.json new file mode 100644 index 000000000..03995d998 --- /dev/null +++ b/docs/audio-ws-vectors/messages-unknown.json @@ -0,0 +1,17 @@ +{ + "contract": "docs/audio-ws-spec.md#messages", + "says": "Anything else is **silently ignored** — that is the compatibility rule.", + "features": ["audio"], + "steps": [ + { "send": { "ws": "a=0.25,0.5,0.75,1" } }, + { "send": { "wait": 20 } }, + { "send": { "ws": "x=1" } }, + { "send": { "ws": "k=4,v=0.5" } }, + { "send": { "ws": "d=4,v=1" } }, + { "send": { "ws": "off=x" } }, + { "send": { "ws": "hello" } }, + { "send": { "wait": 50 }, "expect": { "status": { "lanes": [0.25, 0.5, 0.75, 1], "laneActive": [true, true, true, true] }, "input": { "given": { "deltas": [0, 0, 0, 0] } } } }, + { "send": { "ws": "k=1,v=0" } }, + { "send": { "wait": 20 }, "expect": { "status": { "lanes": [0.25, 0, 0.75, 1] } } } + ] +} diff --git a/docs/audio-ws-vectors/release-500ms.json b/docs/audio-ws-vectors/release-500ms.json new file mode 100644 index 000000000..d4d37d3dc --- /dev/null +++ b/docs/audio-ws-vectors/release-500ms.json @@ -0,0 +1,11 @@ +{ + "contract": "docs/audio-ws-spec.md#holding-a-value--the-500-ms-release", + "says": "A lane is handed back to its encoder 500 ms after the last message that set it.", + "features": ["audio"], + "steps": [ + { "send": { "http": "POST /api/audio?on=1" } }, + { "send": { "ws": "a=0.8,-,-,0.2" } }, + { "send": { "wait": 400 }, "expect": { "status": { "laneActive": [true, false, false, true] } } }, + { "send": { "wait": 200 }, "expect": { "status": { "laneActive": [false, false, false, false] } } } + ] +} diff --git a/docs/audio-ws-vectors/release-resend.json b/docs/audio-ws-vectors/release-resend.json new file mode 100644 index 000000000..114351b02 --- /dev/null +++ b/docs/audio-ws-vectors/release-resend.json @@ -0,0 +1,14 @@ +{ + "contract": "docs/audio-ws-spec.md#holding-a-value--the-500-ms-release", + "says": "So a client that means to keep driving **resends its last message at least every 250 ms**, changed or not.", + "features": ["audio"], + "steps": [ + { "send": { "ws": "a=0.5,-,-,-" } }, + { "send": { "wait": 250 } }, + { "send": { "ws": "a=0.5,-,-,-" } }, + { "send": { "wait": 250 } }, + { "send": { "ws": "a=0.5,-,-,-" } }, + { "send": { "wait": 250 }, "expect": { "status": { "laneActive": [true, false, false, false] } } }, + { "send": { "wait": 300 }, "expect": { "status": { "laneActive": [false, false, false, false] } } } + ] +} diff --git a/docs/audio-ws-vectors/switch-off.json b/docs/audio-ws-vectors/switch-off.json new file mode 100644 index 000000000..c2cc95d50 --- /dev/null +++ b/docs/audio-ws-vectors/switch-off.json @@ -0,0 +1,16 @@ +{ + "contract": "docs/audio-ws-spec.md#the-switch-on-the-panel", + "says": "Switched off, the messages are parsed and nothing is driven — an open socket is not evidence that the panel is listening.", + "features": ["audio"], + "steps": [ + { "send": { "ws": "a=0.25,0.5,0.75,1" } }, + { "send": { "wait": 20 }, "expect": { "status": { "laneActive": [true, true, true, true] } } }, + { "send": { "http": "POST /api/audio?on=0" }, "expect": { "status": { "audioRuntime": false, "laneActive": [false, false, false, false] } } }, + { "send": { "ws": "a=0.25,0.5,0.75,1" } }, + { "send": { "ws": "d=0,v=0.5" } }, + { "send": { "wait": 50 }, "expect": { "status": { "laneActive": [false, false, false, false], "audioClients": 1 }, "input": { "given": { "deltas": [0, 0, 0, 0] } } } }, + { "send": { "http": "POST /api/audio?on=1" } }, + { "send": { "ws": "k=0,v=0.25" } }, + { "send": { "wait": 20 }, "expect": { "status": { "audioRuntime": true, "laneActive": [true, false, false, false] } } } + ] +} diff --git a/docs/contract-vectors.md b/docs/contract-vectors.md new file mode 100644 index 000000000..ed05b768a --- /dev/null +++ b/docs/contract-vectors.md @@ -0,0 +1,81 @@ +# Contract vectors + +A vector is one sentence from a contract and the steps that show a panel doing what it says. Each is a JSON file in a folder beside its spec: `rest-api-vectors/` for [rest-api.md](rest-api.md), and `osc-vectors/`, `midi-vectors/`, `mqtt-vectors/` and `audio-ws-vectors/` for the other four. Each folder has a suite in `web/scripts/drift/`, run as `npm run check:rest-vectors` in `web/` and the same way for the other four. It gives every vector a fresh [virtual panel](../tools/virtual-panel/README.md) and prints what the vector expects beside what the panel did. [`pfst-v2-vectors/`](pfst-v2-vectors/) came first and works differently: three `.pfs` files and a table in its spec. + +```json +{ + "contract": "docs/rest-api.md#post-apisleep", + "says": "`sleep` is the state **before** the transition — the request is only queued here.", + "features": [], + "steps": [ + { "send": { "http": "POST /api/sleep?on=1" }, "expect": { "reply": { "status": 200, "body": { "ok": true, "sleep": false } } } }, + { "send": { "wait": 20 }, "expect": { "status": { "sleep": true } } } + ] +} +``` + +`contract` is the spec and the heading the sentence sits under, written the way a link to it is. `says` is the sentence, copied out of the spec. Markdown and line breaks do not count, every word does. When the spec rewords it, the vector fails until someone brings it along. `features` are the features the panel is built with, by their directories in `firmware/patternflow/features/`, and `[]` is the default build. A REST vector for the Audio edition's routes names `audio` there. + +## Steps + +Each step sends one thing: + +| send | | +| --- | --- | +| `http` | `"POST /api/sleep?on=1"`, or `{ "request": "POST /api/sleep", "form": "on=1", "headers": { "Host": "patternflow.local" } }` | +| `hand` | `{ "knob": 2, "turn": -3 }` turns knob 2 three detents back. `{ "knob": 4, "down": true }` presses its button, and `false` lets go | +| `wait` | `500` moves the panel's clock 500 ms | +| `osc`, `midi`, `mqtt`, `ws` | a message on that transport, written as in the examples below; the suite's sender puts it on the wire | + +The clock stands still between steps. Only `wait` and `out` move it, so a 500 ms release costs no real time. Knobs count from 1, as every contract does. + +A step can then expect four things, each read once the step is over. `reply` is what the send answered: `status`, `headers` with their names in lower case, and `body`, parsed when it is JSON. `status` is `GET /api/status`. `input` is the panel's `/vp/state`, the knob deltas and presses the running pattern has been given since start, its name card and the first error it threw. A delta that comes over OSC or MIDI never shows in `knobs`, which counts a hand's clicks, so `input` is where it shows. `out` is what the panel sent. + +## Shapes + +An expectation is a shape. A literal has to be equal. `""`, `""`, `""`, `""`, `""`, `""` and `""` stand for any value of that type. An object names the keys it needs and lets the others pass, as rest-api.md asks of every client; `""` as a key's value forbids the key. An array has to be as long as its shape, and each element is a shape of its own. + +## What the panel sends + +`out` is a list. Each entry names one transport and a message, and the messages are matched in order on each transport. `{ "osc": ["/patternflow/version", ""], "after": 29, "within": 30 }` is that message, sent later than 29 ms and no later than 30 ms after the step's send. `within` is the step's own length when it is left out: the `wait`, or 0 for anything else. Before reading anything the clock runs on to the last `within`, and it stops at every `after` and `within` on the way, so a message is timed to the window it falls in. `""` in place of a message is silence on that transport for the window. + +OSC, a ping and the start of the announce it is answered with. A message is its address and then its arguments: + +```json +{ "contract": "docs/osc-spec.md#host--device", "features": ["osc"], + "says": "Learn/refresh sender as the remote host and reply with a full announce (hello, version, ip, pattern index/name, modes).", + "steps": [{ "send": { "osc": ["/patternflow/ping"] }, "expect": { "out": [{ "osc": ["/patternflow/hello", "Patternflow"], "within": 50 }] } }] } +``` + +MIDI, a relative CC reaching the pattern. A message is its bytes, and 176 is a control change on channel 1: + +```json +{ "contract": "docs/midi-spec.md#host--device", "features": ["midi"], + "says": "`64` = no motion, `65` = +1 detent, `63` = −1", + "steps": [{ "send": { "midi": [176, 24, 65] } }, { "send": { "wait": 20 }, "expect": { "input": { "given": { "deltas": [1, 0, 0, 0] } } } }] } +``` + +MQTT, sleep from home automation. A message is a topic and a payload: + +```json +{ "contract": "docs/mqtt-spec.md#topics", "features": ["mqtt"], + "says": "Publishing `1` to `/sleep` puts the panel to sleep, `0` wakes it, and `sleep/state` mirrors the result", + "steps": [{ "send": { "mqtt": { "topic": "patternflow/sleep", "payload": "1" } }, + "expect": { "out": [{ "mqtt": { "topic": "patternflow/sleep/state", "payload": "1" }, "within": 50 }], "status": { "sleep": true } } }] } +``` + +The audio WebSocket, two lanes let go half a second after the last message. A message is the frame's text: + +```json +{ "contract": "docs/audio-ws-spec.md#holding-a-value--the-500-ms-release", "features": ["audio"], + "says": "A lane is handed back to its encoder 500 ms after the last message that set it.", + "steps": [{ "send": { "http": "POST /api/audio?on=1" } }, { "send": { "ws": "a=0.8,-,-,0.2" } }, + { "send": { "wait": 400 }, "expect": { "status": { "laneActive": [true, false, false, true] } } }, + { "send": { "wait": 200 }, "expect": { "status": { "laneActive": [false, false, false, false] } } }] } +``` + +## Spec, not firmware + +A vector follows its spec. Where the firmware does something else, the vector keeps the spec, and the pull request that adds it lists the firmware line for an issue. Where the spec contradicts itself, the vector follows the section that defines the route or the message. Where the spec says nothing, how OSC rounds 2.5 for one, there is no vector. + +A suite brings a `Sender` for each transport other than HTTP, in a file of its own whose name starts with `_`. For each vector it opens a channel that sends a step's message and keeps a list of what the panel sent back. `_vectors.ts` does the rest, and it does not change once its suites rely on it. diff --git a/docs/director-midi.md b/docs/director-midi.md index 22d39c63b..7b5a06205 100644 --- a/docs/director-midi.md +++ b/docs/director-midi.md @@ -106,7 +106,7 @@ where the `midi` feature lives. **Leave Remote on the Output row off.** With it on, Live sends every mapped parameter's value back to the panel on the same CC, and the panel reads that position as a relative turn: the knob pins itself to one value and ticks - whenever you move it. The same applies to the USB port on the MIDI edition. + whenever you move it. The same applies to the USB port on the MIDI bundle. **Then, to send a lane:** diff --git a/docs/investigations/2026-10-build-map-additions.md b/docs/investigations/2026-10-build-map-additions.md new file mode 100644 index 000000000..1cc0330f2 --- /dev/null +++ b/docs/investigations/2026-10-build-map-additions.md @@ -0,0 +1,95 @@ +# What the build map could add + +*Scouted on 18 October 2026 at `c830467` on `dev`, from the journal, the live +site, the community Workshop and the remix folders. Nothing here is +implemented. [`builds.ts`](../../web/src/components/sections/InsideGlobe/builds.ts) +stays as it is until the photos and dates in the last column arrive.* + +## What a pin is now + +The map at [patternflow.work/inside](https://patternflow.work/inside) has 18 +pins in 12 countries. Each is one entry in `builds.ts`, with its photos in +`web/public/builds//`. An entry has a `kind` and a `category`, and they +answer different questions. + +The kind is what the pin claims. `build` is a Patternflow someone put +together, `collaboration` is a separate work made with Patternflow, and `sold` +is a unit sold as a work. The category is the filter it sits under: Builds, +Projects or In use. Fifteen pins are builds. Two are projects, day's Raspberry +Pi port and Azmano's MOTIFLOW. One is in use, the first sale in San Francisco. + +A pin's place is as precise as the maker made it: a city label gets the +city's coordinates, a country label the country's centre. Pins on one spot +draw as a single pin with a count +([`groups.ts`](../../web/src/components/sections/InsideGlobe/groups.ts)), so a +second pin in Seoul is no problem. The slug is maker then region, and a second +pin for the same maker in the same region needs a slug of its own +(`simonepda-uae` is taken). The date is a month. + +Only `build` pins join the web of lines between pins, the web that shows how +far Patternflow has spread (`webArcs` in +[`Globe.tsx`](../../web/src/components/sections/InsideGlobe/Globe.tsx)). A +collaboration or a sale gets one line back to the pin it came from. + +## A pin for a showing + +None of the three kinds fits an exhibition of a unit from Seoul. Filed as +`build`, the *Anbu* exhibition (A1) joins the spread web and counts as +Patternflow reaching somewhere new, when it is the maintainer's own unit a few +kilometres from the first pin. `collaboration` fits *Anbu* itself, because its +patterns were made from the artists' works. It does not fit the Hongdae night +(A2) or the course exhibition (A3). + +The smallest change is a fourth kind for a unit shown somewhere, drawn the way +`sold` is: its own pin shape, a line back to its origin, filtered under In use. +That touches `BuildKind` in `builds.ts` and `PinBody` in `Globe.tsx`, and it is +the maintainer's call. Without it, A1 to A3 can go in as `in-use` with kind +`build`, and the web gets three strands it should not have. + +## The maintainer's shows and use + +| | What | Seen at | On the map | Send | +| :--- | :--- | :--- | :--- | :--- | +| A1 | *Anbu* (「안부」), an exhibition by visually impaired artists at Modu Art Space near Seoul Station. Patternflow was not the exhibit. It was a corner where visitors played the artists' paintings, illustrations and sculptures as patterns: one knob picks the work, three play it, and the sound follows the knobs, through the maintainer's build of Simone's flowlocal and a laptop running Ableton. Opened 3 October 2026, with an opening event on Sunday the 4th. Taken down by 13 October. | Journal: [the-messy-desk](https://patternflow.work/journal/the-messy-desk) (the plan), [a-strange-feeling](https://patternflow.work/journal/a-strange-feeling), [all-jumbled-up](https://patternflow.work/journal/all-jumbled-up), [fine](https://patternflow.work/journal/fine) (on the site, not yet in this tree; it calls it 서울역 전시). [README](../../README.md) timeline, October 2026. | New pin, `in-use`, Seoul, 2026-10. Kind: see [A pin for a showing](#a-pin-for-a-showing). | Photos of it on show, with people playing if any were taken (the *fine* post has one, "서울역 전시, 철거 전"). The closing date. Whether to name the space, the exhibition and the artists, and their OK. How the pin credits it: Seunghun LEE, or the artists with him. The exhibition's own page, if it has one. | +| A2 | A DJ night in an LP bar in Hongdae, the day after the *Anbu* opening. A Patternflow with the microphone fitted stood by the turntables and played to the sound, the first time the audio feature was used outside home. | Journal: [all-jumbled-up](https://patternflow.work/journal/all-jumbled-up), announced in [how-to-use-part-two](https://patternflow.work/journal/how-to-use-part-two) as a café. The post's photo is in the tree: [`turntable.jpg`](../../web/public/journal/all-jumbled-up/turntable.jpg). | New pin, `in-use`, Seoul, 2026-10. | The date. The bar's name, or an OK to leave it as "Hongdae, Seoul". The DJ's OK and how to name them. The photos and video from that night, with the credit line the photographer wants. Which edition it ran (Audio, presumably). | +| A3 | The exhibition of the Hongik course 작가적 디자인 스튜디오(1), June 2026. The written process was pinned up behind the unit. The unit died on day one, and that is how the SMD parts came off the board. | Journal: [refocus](https://patternflow.work/journal/refocus), with [`exhibition-process.jpg`](../../web/public/journal/refocus/exhibition-process.jpg). Roadmap node `biz-exhibition` (2026-06-15) in [`roadmap-data.ts`](../../web/src/app/roadmap/roadmap-data.ts). | New pin, `in-use`, Seoul, 2026-06. | Where it was shown (building or gallery). The dates (the post has it running on 19 June and coming down on Monday the 22nd). One or two photos of the wall. An OK to tell the day-one failure on the pin. | +| A4 | *USER EXPERIENCE*, the pop-up gallery in San Francisco the first sale went to. The unit is shown hands-on, and the show runs through SF Tech Week. | Pin `sf-swartz`. Journal: [good-but](https://patternflow.work/journal/good-but), [a-strange-dream](https://patternflow.work/journal/a-strange-dream), [fine](https://patternflow.work/journal/fine). | The existing pin, not a new one. Its text says the show "opens in October 2026" and needs the past tense once photos go on. | Photos of the unit in the gallery. The show's dates. The gallery's OK to use them. | +| A5 | A workshop at the Seoul Design Startup Center, planned. Parts for seven or eight units are on order for it. | Journal: [all-jumbled-up](https://patternflow.work/journal/all-jumbled-up), [fine](https://patternflow.work/journal/fine). | After it happens: a new pin, `in-use`, Seoul. Each unit someone takes home finished can be its own `build` pin. | The date, the number of people, photos, and each participant's OK. Nothing until it has happened. | +| A6 | The unit that lives at the Seoul Design Startup Center office ("사무실에 1개, 집에 2개"). | Journal: [all-over-the-place](https://patternflow.work/journal/all-over-the-place). | New pin, `in-use`, Seoul, if a unit in daily use is worth one. | A photo of it in the office, and a yes or no on the pin. | + +## Units that went out + +| | What | Seen at | On the map | Send | +| :--- | :--- | :--- | :--- | :--- | +| B1 | A unit sent to a studio near Philadelphia. Its owner is helping as a kind of ambassador and is to demo it at the Grammy Creator Summit. | Journal: [all-jumbled-up](https://patternflow.work/journal/all-jumbled-up). | New pin, USA. Kind `build` if it was a gift (the Paris pin set that precedent), `sold` if it was paid for. | The studio's or person's name and their OK. The city. The month it was sent. Gift or sale. After the summit: its date and photos of the demo. | +| B2 | A unit sent to an influencer in Spain, in the hope of an Instagram post before the campaign closed. | Journal: [fine](https://patternflow.work/journal/fine) only. | New pin, Spain, kind `build` (a gift), once the post is up. | The name or handle and their OK. The city. The month it was sent. A link to the post. | +| B3 | The three units built in September with the money from the first sale. | Journal: [how-to-use-patternflow](https://patternflow.work/journal/how-to-use-patternflow). | One pin for each that left Seoul. Some may already be A1, B1 or B2. | Where each of the three is now. | + +## Builds by others + +| | What | Seen at | On the map | Send | +| :--- | :--- | :--- | :--- | :--- | +| C1 | SimonePDA's laser-cut case, cut three times in clear acrylic and in MDF. The pin `uae-simonepda` (July 2026) shows his earlier build in a dark printed case. | [`simonepda-lasercut/README.md`](../../hardware/case/remixes/simonepda-lasercut/README.md) (verified 2026-10-05), its tab on [/build](https://patternflow.work/build?case=simonepda-lasercut), the [README](../../README.md). Four photos in [`photos/`](../../hardware/case/remixes/simonepda-lasercut/photos/), CC BY-SA and already resized for the site in `web/public/cases/simonepda-lasercut/`. | New pin, `build`, `builds`, 2026-10, linking the remix folder and the /build tab. A second pin rather than more photos on the first: it is a different build, in a case anyone can cut. | Ajman or Dubai: the pin says Ajman, the journal ([how-to-use-part-two](https://patternflow.work/journal/how-to-use-part-two)) says Dubai. An OK to reuse the four remix photos, and a photo of it lit if he has one. The month of the first cut. A line from Simone, if he wants one. | +| C2 | Besoiobiy's printed case, the second enclosure remix: a frame and a box glued side by side, PATTERNFLOW lettered down the box. No pin. | [`besoiobiy-printed/README.md`](../../hardware/case/remixes/besoiobiy-printed/README.md) (verified 2026-10-08), its tab on [/build](https://patternflow.work/build?case=besoiobiy-printed). One photo of the built case, [`photo_built.jpg`](../../hardware/case/remixes/besoiobiy-printed/images/photo_built.jpg). | New pin, `build`, `builds`, 2026-10, linking the remix folder and the /build tab. | Where it is, as a city or a country: the README does not say, and an order on aliexpress.ru is not a location. How Besoiobiy wants to be named. Two or three more photos, lit. A line from them, and their OK. | +| C3 | @zhu0ran's bridge that streams a TouchDesigner TOP to the panel at 64 × 32 and sends the knobs back over OSC. | Community Workshop, Connect channel: [Touch Designer to Patternflow](https://community.patternflow.work/community/workshop/c0/t/f9562f0b4bdf4c3b), 9 August 2026. | New pin, `projects`, like day's port. If @zhu0ran is Zhuoran, the first collaborator who has the Paris unit, it goes in Paris and can hang off `paris-v1`. | Whether @zhu0ran is Zhuoran. A photo or clip of a TouchDesigner frame on the panel. The place. Their OK. | +| C4 | Simone's MQTT link, tested between his unit in Dubai and the maintainer's in Seoul: a knob turned in Dubai changed the panel in Seoul. With it, his flowlocal, a box that gives a Patternflow its own hotspot. *Anbu* ran on the maintainer's build of one. | Journal: [how-to-use-part-two](https://patternflow.work/journal/how-to-use-part-two), [the-messy-desk](https://patternflow.work/journal/the-messy-desk), with a photo of the maintainer's own flowlocal, [`flowlocal.jpg`](../../web/public/journal/the-messy-desk/flowlocal.jpg). | New pin, `collaboration`, `projects`, in Dubai, with its line back to Seoul. Better after flowlocal is published. | Whether flowlocal is public yet. Simone's OK. A photo of his box. The month. | +| C5 | The breadboard build: the same electronics on jumper wires, no board order. The guide's eight photos are its record. | [/build/breadboard](https://patternflow.work/build/breadboard), photos in [`web/public/builds/breadboard/`](../../web/public/builds/breadboard/) (the page is dated 28 June 2026). | New pin, `build`, `builds`, Seoul, 2026-06, linking the guide. | A yes or no: it is the guide's build, not a unit that went anywhere. Which photo leads (`step8.jpg`, "It's alive!", is the lit one). | +| C6 | Builds shared on Discord that have no pin. The journal counts about 50 builders from 64 panel orders, and about 10 who shared a build ([its-a-failure](https://patternflow.work/journal/its-a-failure), 1 September), and more have been posted since ([all-jumbled-up](https://patternflow.work/journal/all-jumbled-up)). Leads: cartoonmonkeystudio (CE), who debugged the Wi-Fi on their own unit in August ([the investigation](2026-08-the-panel-clock-and-the-wifi-radio.md)); a black Patternflow on an Instagram story on 11 July ([worn-out](https://patternflow.work/journal/worn-out), perhaps Enerjoy's); a two-layer tracing-paper diffuser on 29 May ([today-is-my-birthday](https://patternflow.work/journal/today-is-my-birthday), perhaps shooter's). | Discord, which this scout cannot read. | One `build` pin each. | For each build on Discord that has no pin: the name the maker wants, the place, the month, two or three photos and their OK. | + +## Pins that already exist and can take more + +| | What | Seen at | On the map | Send | +| :--- | :--- | :--- | :--- | :--- | +| D1 | MOTIFLOW has been shown in a gallery and played at several events since the pin went up. | [README](../../README.md) ("shown in a gallery and played at events since"), journal [how-to-use-part-two](https://patternflow.work/journal/how-to-use-part-two). | The pin `iran-azmano`: a sentence and a photo. | The gallery and the events, with dates, and a photo from Azmano. | +| D2 | mbchars' pin links nowhere, while his case is the third remix in this repo and a tab on /build. | Pin `ukraine-mbchars`, [`mbchars-horizontal-desktop-printed/`](../../hardware/case/remixes/mbchars-horizontal-desktop-printed/README.md). | Two links on the pin: the remix folder and [/build?case=mbchars-horizontal-desktop-printed](https://patternflow.work/build?case=mbchars-horizontal-desktop-printed). | Nothing. It can go in with any of the rows above. | + +## Looked at and left out + +- Press: the 14 pieces in the roadmap's media lane, Hackster.io in May to Midifan in September. They are about Patternflow, not places it is. +- The Nam June Paik Art Center (28 January) and the Gwangju Biennale. He went to look. +- Paris Fashion Week, planned for the Paris unit. It was never installed ([all-jumbled-up](https://patternflow.work/journal/all-jumbled-up)). +- Pitches and open calls: the Seoul Design Startup Center, SFAC, KAMS, the Lumen Prize, Art Korea Lab. None of them put a Patternflow in front of visitors. +- The museums that asked to buy in July, and the new museum in Istanbul. No names, and nothing closed. +- The interview and shoot for the project film at the Seoul Design Startup Center ([fine](https://patternflow.work/journal/fine)). +- Crowd Supply units. They ship after the campaign, and each becomes a pin when its owner shares it, the usual way. +- The XR club exhibition and the 2023 prototyping-class exhibition. Neither showed a Patternflow. diff --git a/docs/investigations/2026-10-cleanup-baseline.md b/docs/investigations/2026-10-cleanup-baseline.md new file mode 100644 index 000000000..92c1ec5a3 --- /dev/null +++ b/docs/investigations/2026-10-cleanup-baseline.md @@ -0,0 +1,83 @@ +# Cleanup baseline + +*Step 0 of [rfc-cleanup.md](../rfc-cleanup.md), §2.2. Run on 14 October 2026.* + +The cleanup starts from `8226f7ea5da15b0e0e55b15fa9ec7af136eef080` on `dev`, +the commit that added [rfc-cleanup-progress.md](../rfc-cleanup-progress.md). +Project v3.11.0, Audio v0.7.0, Performance v0.4.0. The last commit to touch +`firmware/` or `web/` before it is `caaf2c1`, on 12 October. + +The web commands ran in a cloud container on that tree with nothing added: +Node 22.22.0, npm 10.9.4, `npm ci` from `web/package-lock.json`. CI runs Node 20. + +## The five commands + +| command | result | output | +| --- | --- | --- | +| `firmware/bundles/build.sh all` | pass, on CI | five compositions built and scanned, see below | +| `npm run check:ci` | pass | 25 suites, last line `All module cache checks passed.`, 50 s | +| `npm run check:panels` | pass | 35 test files, 478 tests, 19 s | +| `npm run typecheck` | pass | `tsc --noEmit` prints nothing, 27 s | +| `npm run lint` | pass | 0 errors, 12 warnings, 50 s | + +None of the five is red, so there is no fix PR to land before Step 1. + +The container cannot build firmware. PlatformIO fetches the espressif32 platform +and the Xtensa toolchain from `api.registry.platformio.org`, and the container's +network policy refuses that host (403 on CONNECT). So `build.sh all` ran the way +CI runs it, `build.sh one ` per job, in `firmware-build.yml` +dispatched on `dev` at this SHA ([run 38560727407](https://github.com/Patternflow-lab/Patternflow/actions/runs/38560727407)). +Each job scans its image for the feature markers: + +``` +default 1102672 bytes ok +audio 1208656 bytes ok +performance 1526624 bytes ok +clock 1187088 bytes ok +midi 1163440 bytes ok +``` + +`check_footprint.py` says `every edition within tolerance` in all five. The 17 +host-side checks in `firmware-checks.yml` and the two in `console-sync.yml` pass. + +The lint warnings, for Step 3: + +| file | line | rule | +| --- | --- | --- | +| `web/scripts/performance-smoke.ts` | 16 | `@typescript-eslint/no-unused-vars` | +| `web/src/app/build/breadboard/page.tsx` | 394, 435, 476, 514, 556 | `@next/next/no-img-element` | +| `web/src/app/editions/EditionCard.tsx` | 35 | `@typescript-eslint/no-unused-vars` | +| `web/src/app/roadmap/RoadmapMap.tsx` | 272 | `@typescript-eslint/no-unused-vars` | +| `web/src/app/roadmap/RoadmapMap.tsx` | 315 | `react-hooks/exhaustive-deps` | +| `web/src/components/community/PatternCard.tsx` | 8 | `@typescript-eslint/no-unused-vars` | +| `web/src/components/journal/JournalLightbox.tsx` | 86 | `@next/next/no-img-element` | +| `web/src/components/sections/Hero.tsx` | 55 | `@next/next/no-img-element` | + +## The doc map + +`npm run doc-map` in `web/` ([doc-map.ts](../../web/scripts/doc-map.ts)) +wrote [2026-10-doc-map.csv](2026-10-doc-map.csv): 1007 links out of 193 +files, 7 of them from this file, `AGENTS.md` and the progress log as Step 0 +leaves them. 532 are relative, 329 site, 114 absolute-blob and 32 console. +None is absolute-raw; nothing links through `raw.githubusercontent.com`. + +24 of the absolute-blob rows name a release tag, `v1.0.0` to `v3.1.0`, from the +roadmap, the release notes and the hardware READMEs. A shallow clone has no +tags and reports those 24 as `no`, so fetch the tags before running the script. + +The map leaves out links that a file move cannot break: a bare `#anchor` +inside one file, the repository's front page, issues, pull requests and +releases. It also leaves out the site's own route links (`href="/guide"` in a +component), the vendored upstream docs under `hub75/`, `webserver/` and +`pubsubclient/`, and the `*_index.h` headers, which are the console pages again. + +§2.3 expects the site to link `BUILD_GUIDE_v2.md` at §1, §2, §4, §8 and §10. +At this SHA it links it once, from `BuildPanel.tsx`, with no anchor. The +anchored links go to `BUILD_GUIDE.md`. Moving the v2 guide touches that one +site link, the test beside it, and five Markdown files: `README.md`, +`BUILD_GUIDE.md`, `docs/assembly/README.md`, `hardware/README.md` and +`hardware/case/README.md`. + +## Known broken + +None. Every row in the map resolves. diff --git a/docs/investigations/2026-10-cleanup-step1-plan.md b/docs/investigations/2026-10-cleanup-step1-plan.md new file mode 100644 index 000000000..70ec2b07b --- /dev/null +++ b/docs/investigations/2026-10-cleanup-step1-plan.md @@ -0,0 +1,331 @@ +# Cleanup step 1: the plan + +*For [rfc-cleanup.md](../rfc-cleanup.md) §2.3. Scouted on 14 October 2026 at +`5b9b744` on `dev`, the merge of step 0. Nothing here is implemented.* + +Step 1 has five jobs: settle the root, move the v2 build guide, settle where +the README's narrative lives, split the changelog, and list the +Korean/English pairs. Two of them wait on the maintainer (§2.13 questions 1 +and 2). This plan cuts the step into three +lanes that never edit the same file, so three people can work at once, plus +one closing PR after they land. + +## The lanes + +| lane | PR | what | files it may edit | when | +| --- | --- | --- | --- | --- | +| A | A1 | `BUILD_GUIDE_v2.md` → `docs/legacy/` | `BUILD_GUIDE_v2.md`, `docs/legacy/`, `README.md`, `BUILD_GUIDE.md`, `AGENTS.md`, `docs/README.md`, `docs/REPOSITORY.md`, `docs/assembly/README.md`, `hardware/README.md`, `hardware/case/README.md`, `web/src/components/sections/BuildPanel.tsx`, `web/src/components/sections/BuildPanel.test.tsx`, `web/src/components/guide/stage/build/layout.ts` | now | +| A | A2 | the other root moves | the moved files and their new homes, `CONTRIBUTING.md`, `README.md`, `AGENTS.md`, `docs/README.md`, `docs/REPOSITORY.md`, `.github/ISSUE_TEMPLATE/config.yml`; if `AUDIO_GUIDE.md` moves, also `firmware/README.md`, `tools/patternflow-audio-extension/README.md`, `web/src/app/features/features-data.ts`, `web/src/components/guide/copy/audio.ts` | after question 1, after A1 merges | +| A | A3 | README narrative → `docs/story.md` | `README.md`, `docs/story.md`, `docs/README.md` | after question 2, only if the answer is "move"; after A2 | +| B | B1 | split `CHANGELOG.md` into `docs/releases/` | `CHANGELOG.md`, `docs/releases/`, `docs/RELEASING.md`, `firmware/toolchain/release.py`, `web/src/app/roadmap/roadmap-data.ts` (the `links` arrays only) | now, beside A1 and C1 | +| C | C1 | the parity CSV | `web/scripts/doc-map.ts`, `docs/investigations/2026-10-doc-parity.csv`, `docs/investigations/2026-10-doc-map.csv` | now, beside A1 and B1 | +| C | C2 | the doc map reads `.yml` too (optional) | `web/scripts/doc-map.ts`, `docs/investigations/2026-10-doc-map.csv` | after C1, before A2 if it can | +| C | C3 | close step 1 | `docs/investigations/2026-10-doc-map.csv`, `docs/investigations/2026-10-doc-parity.csv`, `docs/README.md`, `docs/REPOSITORY.md`, `docs/rfc-cleanup-progress.md` | after every A and B PR has merged | + +A file sits in one lane. A lane that finds it needs a file from another lane +does not edit it: it writes the change it needs into its PR description under +"for another lane", and the owner (or C3) carries it. + +Three lanes means three people at most, one per lane. Inside a lane the PRs go +in order. At most five cleanup PRs are open at once (§2.11); this plan opens +three at the start. + +Two shared things need a rule of their own: + +- **`docs/investigations/2026-10-doc-map.csv`** is committed only by lane C. + A and B run `npm run doc-map` in `web/` to prove their move left no row at + `resolves = no`, quote the two summary lines in the PR description, then + `git checkout docs/investigations/2026-10-doc-map.csv` before committing. + Run `git fetch --tags` first, or the 24 tag-ref rows read `no`. +- **The map documents** (`AGENTS.md`, `docs/README.md`, `docs/REPOSITORY.md`) + belong to lane A while it is moving files, and to C3 at the end. Lane B + needs a sentence changed in `docs/README.md` (finding 4); it hands that + sentence to C3. + +## What the tree says + +What the scouting found, and why the lanes are drawn where they are. The +first three the RFC did not know about. + +**1. `docs/releases/` already exists.** It holds `v3.0.0.md` and `v3.1.0.md`, +long-form notes for those releases, and `v3.0.0-launch.md`, the launch +checklist. The split wants `docs/releases/v3.0.0.md` and `v3.1.0.md` for the +changelog sections. Nothing in the tree links either file; `docs/README.md` +links the folder, and `v3.0.0-launch.md` names `v3.0.0.md` inside a code +block, as the command that was run. + +**2. None of the three pair kinds §2.3 names exists.** There is no `*.ko.md` +file. The console pages are English only. `features-data.ts` has no +`detailKo`. The pairs the repository does keep: + +- `web/content/journal/.mdx` (Korean) and `.en.mdx` (English): + 37 slugs, every one paired. +- `web/src/app/roadmap/roadmap-data.ts`: `title`/`titleKo` and + `detail`/`detailKo`, 84 items. +- `web/src/lib/atlas/data.ts`: `nm`/`nmEn`, `tex`/`texEn`, `knob`/`knobEn`, + `risk`/`riskEn`, `label`/`labelEn`, `vert`/`vertEn`, `legend`/`legendEn`, + `impl`/`implEn`. Here the bare field is Korean. +- The guide's copy, an `en` and a `ko` object per file: + `web/src/components/guide/copy.ts`, `copy/{audio,build,community,hub,lab,make}.ts`, + `desk/words.ts`, `desk/community/words.ts`, `desk/ai/answers.ts`. The type + makes both sides carry the same keys, so only "shorter by a third" can show. +- The guide's routes: `web/src/app/guide/**/page.tsx` and their `ko/` twins, + five pairs. + +`docs/SERVICES.md` is Korean only, and `docs/manifesto.md` and +`CODE_OF_CONDUCT.md` mix both languages in one file. None of the three has a +twin to compare against. + +**3. The doc map does not read `.yml`.** `.github/ISSUE_TEMPLATE/config.yml` +links `SUPPORT.md` by absolute URL and is not in the map. Mentions in +backticks are not links either, and the map skips them: +`AGENTS.md`, `docs/README.md` and `docs/REPOSITORY.md` all name the root files +that way. A move PR runs `git grep ` as well as reading the map. + +**4. The changelog.** 947 lines: a header, `[Unreleased]`, then 30 release +sections from `[3.11.0] - 2026-10-04` down to `[v1.0.0] - 2026-04 (initial +public release)`. Headings are not uniform: two carry a `v` and a title +(`[v1.1.0] - 2026-04 (Multi-pattern Update)`), and the July ones have no day +(`[3.2.0] - 2026-07`). The sections hold six relative links, one each (to +`BUILD_GUIDE.md`, three investigations, `panel-compatibility.md`, +`pfst-v2-spec.md`) that need `../../` once a section sits in +`docs/releases/`, and eight root-relative ones that do not change. + +Four places say the root file is the whole record and stop being true after +the split: the changelog's own header, `docs/RELEASING.md` ("one section per +release, newest first"), `docs/README.md` line 52, and the banners of the two +long-form notes in `docs/releases/`. + +`firmware/toolchain/release.py` is the only code that reads it. +`bump_changelog` (`cut`) turns `[Unreleased]` into a dated section; +`changelog_section` (`publish`) reads that section back for the dev → main PR. +Nothing in CI and nothing in `firmware-release.yml` reads it; `shelf.sh` +names it in one error message. `roadmap-data.ts` links it twice, once with +the label "CHANGELOG (2.0.0)", and 2.0.0 will not be in the root file after +the split. + +**5. The v2 guide.** Step 0's baseline already counted the links in: five +Markdown files and `BuildPanel.tsx` with its test, none anchored. Beyond the +links, it is named in backticks in `AGENTS.md` (the repository map and hard +rule 5, "`BUILD_GUIDE_v2.md` section 10"), in `docs/README.md` (the rule +"a guide somebody follows start to finish lives at the repository root"), in +`docs/REPOSITORY.md`, and in a comment in `layout.ts`. It carries 31 relative +links out, 22 of them to `docs/build-guide/images/`. + +`roadmap-data.ts` names it in a `detail` and a `detailKo` string ("… +BUILD_GUIDE_v2.md for existing builds."). That is locked text (§2.9) and it +records the past; it stays. The changelog names it the same way, as history. + +`web/next.config.ts` redirects paths on patternflow.work. The site has no +route that serves the v2 guide; the one link goes to github.com, which +`next.config.ts` cannot redirect. So A1 adds no redirect. + +**6. The root.** 17 tracked files that do not start with a dot. The default +list in §2.3 keeps ten (`README.md`, the three guides, `CONTRIBUTING.md`, +`CHANGELOG.md`, `AGENTS.md`, the three `LICENSE` files). The other seven: + +| file | what decides where it can live | +| --- | --- | +| `BUILD_GUIDE_v2.md` | §2.3 moves it to `docs/legacy/` whatever the answer to question 1. Lane A1. | +| `CLAUDE.md` | The one-line shim that loads `AGENTS.md` for Claude Code, which reads `CLAUDE.md` from the folder a session starts in and the folders above it. Under `docs/`, a session started at the root would run without the hard rules. | +| `CITATION.cff` | GitHub's "Cite this repository" reads it only from the root. Nothing links it. | +| `CODE_OF_CONDUCT.md`, `SECURITY.md`, `SUPPORT.md` | GitHub finds each of them in the root, `docs/` or `.github/`, so a move keeps the community profile and the security tab. 6 links in, plus the absolute one in `config.yml`. | +| `AUDIO_GUIDE.md` | `docs/README.md`'s rule puts it at the root; the default list does not. 6 links in, two of them absolute from the site (`features-data.ts`, `guide/copy/audio.ts`). | + +**7. The README's narrative.** "Where it began" (line 39, 12 lines), "The +community responded" (line 51, 23 lines), "Story so far" (line 271, 23 +lines). Under "Story so far" sits `### Sponsor`, PCBWay's credit, which is +not narrative. Nothing links the three anchors. The sections carry five +relative paths: two images (`./docs/media/community-builds.jpg`, +`./docs/media/web-build-map.png`), `integrations/README.md`, +`docs/EDITIONS.md`, and `#contributing`, an anchor in the README itself. A +verbatim move rewrites those five and nothing else. + +**8. Where the RFC and the worker rules disagree.** §2.11 names branches +`cleanup/-` and asks for a three-line PR description; the hub +hands each worker its branch name and reads it back, and asks for a longer +description. §2.1 caps a PR at 300 changed lines; the changelog split copies +about 920 lines. The worker rules say a worker does not edit `CHANGELOG.md` +unless its task says so; B1's task is to edit it. + +## Defaults this plan takes + +Each is the reasonable reading where the RFC is silent or the tree surprised +it. A lane follows them unless the maintainer answers otherwise (last +section). + +- **D1. The two long-form notes give up their names.** `docs/releases/v3.0.0.md` + becomes `v3.0.0-notes.md` and `v3.1.0.md` becomes `v3.1.0-notes.md`, by + `git mv` with no text change, next to `v3.0.0-launch.md`. `vX.Y.Z.md` is + then always the changelog section. Their banners and the launch checklist's + code block stay as written. +- **D2. Every release gets a file, the newest three too.** The root keeps the + header, `[Unreleased]`, the three newest sections in full, and a list + linking every older file. `release.py cut` writes `docs/releases/vX.Y.Z.md` + for the new release and replaces the fourth-newest section in the root + with its line in that list. The three newest then exist twice; finding that + drift is a step 2 check, not part of step 1 (question 9). +- **D3. Verbatim text does not count toward the 300 lines** when the PR proves + it is verbatim. For a `git mv` git shows the rename. For the changelog the + PR runs a reassembly check (B1 below) and quotes its output. Every other + line in the PR stays under 300. +- **D4. Branches, labels, descriptions.** Lanes use the branch the hub gives + them. The RFC's three lines (what moved, which §2.1 commands ran, which + doc-map rows it touched) go into the description as their own section. + Labels `cleanup` and `step-1` go on if the tools allow it. +- **D5. Community files move to `docs/`, not `.github/`**, because §2.3 says + "move under `docs/`" and GitHub reads both. Only if question 1 moves them. +- **D6. No redirect and no stub for the v2 guide** (finding 5). An outside + link to `blob/main/BUILD_GUIDE_v2.md` breaks. A1 lists any it can find (the + fork's release bodies, if the tools reach them) for the maintainer, who can + edit a release body; a worker does not. + +## Lane A: moves at the root + +**A1, now.** `git mv BUILD_GUIDE_v2.md docs/legacy/BUILD_GUIDE_v2.md`, then: + +1. Rewrite the 31 relative links inside it for its new depth + (`docs/build-guide/…` → `../build-guide/…`, `hardware/…` → + `../../hardware/…`). `` tags too. No word changes. +2. Point the five Markdown links at the new path, and the site link in + `BuildPanel.tsx` (`…/blob/main/docs/legacy/BUILD_GUIDE_v2.md`). Change the + expected `href` in `BuildPanel.test.tsx` to match. The test keeps checking + the link; only the path it expects moves. +3. The backtick mentions: `AGENTS.md` (repository map; hard rule 5 becomes + "`docs/legacy/BUILD_GUIDE_v2.md` section 10"), `docs/README.md` (drop it + from the root-guides list in the rule, add one line for `legacy/`), + `docs/REPOSITORY.md` (root row, and the `docs/` row), and the + `layout.ts` comment. +4. Leave alone: the `detail`/`detailKo` strings in `roadmap-data.ts`, every + mention in `CHANGELOG.md`, and `docs/releases/v3.0.0.md`'s link to + `v3.0.0:BUILD_GUIDE_v2.md` (a tag ref, still valid). + +Checks: `python .github/scripts/check_links.py`; `npm run doc-map` (0 do not +resolve, CSV discarded); `git diff -M --stat dev` shows the rename; and +because the PR touches `web/src/` and `AGENTS.md`, `npm run check:ci`, +`check:panels`, `typecheck`, `lint` in `web/`, and +`python firmware/toolchain/check_versions.py` (it parses `AGENTS.md`, and +`firmware-checks.yml` runs on any `AGENTS.md` change). `build.sh all` does not +apply: no file under `firmware/` changes. + +**A2, after question 1.** Whatever the answer moves, with the same steps: +`git mv`, rewrite the moved file's own relative links, every inbound link +from the map, every backtick mention from `git grep`, and the absolute URL in +`.github/ISSUE_TEMPLATE/config.yml` if `SUPPORT.md` moves. `CHANGELOG.md`'s +mentions stay (history). If `AUDIO_GUIDE.md` moves, the PR touches `web/src/` +and runs the four web commands. + +**A3, after question 2, only if the answer is "move".** Copy the three +sections verbatim to `docs/story.md` and rewrite the five paths in finding 7 +for the new folder (`./docs/media/…` → `media/…`, `integrations/README.md` → +`../integrations/README.md`, `docs/EDITIONS.md` → `EDITIONS.md`, +`#contributing` → `../README.md#contributing`). Leave one line in the README +pointing at it, and keep `### Sponsor` with its images in the README. The +maintainer approves on the PR (§2.9: moving locked text). Lane A's last PR. + +Done when: the root holds only the files question 1 agreed, and every move +carried its inbound links in the same PR. + +## Lane B: the changelog + +**B1, now.** One PR. Its task text must say that editing `CHANGELOG.md` is the +job. + +1. D1: `git mv` the two long-form notes to `-notes.md`. +2. Write `docs/releases/vX.Y.Z.md` for each of the 30 sections: the heading + line and the body exactly as they are in the root, with only the six + relative link paths rewritten. File names always carry the `v` + (`[3.11.0]` → `v3.11.0.md`, `[v1.1.0]` → `v1.1.0.md`). +3. Root `CHANGELOG.md` (D2): header, `[Unreleased]` untouched (the hub writes + PR lines into it), `[3.11.0]`, `[3.10.5]`, `[3.10.4]` in full, then one line + per older release linking its file. Rewrite the header sentence that says + this file is the complete record; it is the only new prose. +4. `release.py`: `cut` also writes `docs/releases/vX.Y.Z.md` and drops the + fourth-newest section from the root to a list line; add the new file to the + paths `cut` commits. `publish` keeps reading the newest section from the + root, where it still is. Update the docstring. +5. `docs/RELEASING.md`: step 3 of the checklist and "Current release line" + say what a release now writes. +6. `roadmap-data.ts`: point the "CHANGELOG (2.0.0)" link at + `docs/releases/v2.0.0.md`. Only `href` values in `links`; no `detail` or + `detailKo` string. +7. For C3: the replacement for `docs/README.md` line 52 ("`CHANGELOG.md` at + the root is the complete record"), and whether `docs/REPOSITORY.md`'s + `docs/` row should name `releases/`. + +Checks: + +- **Reassembly.** A scratch script (not committed) rebuilds the original + `CHANGELOG.md` from the root's header and `[Unreleased]` plus every + `docs/releases/vX.Y.Z.md` in order, with the link rewrite undone, and + `diff`s it against `git show dev:CHANGELOG.md`. Empty diff, quoted in the + PR. A second diff shows the three sections left in the root are equal to + their files. +- **`release.py` dry run** in a throwaway worktree + (`git worktree add`, then `cut v3.11.1 --no-build --no-commit + --no-web-checks --any-branch`; `[Unreleased]` has entries, so the dry run + dates them), showing the new file written and + `[3.10.4]` leaving the root. Remove the worktree after. + `python -m py_compile firmware/toolchain/release.py`. +- `check_links.py`, `npm run doc-map` (CSV discarded), and for + `roadmap-data.ts` the four web commands. The PR touches + `firmware/toolchain/`, so `firmware-build.yml` and `firmware-checks.yml` + run on it in CI; that is the `build.sh all` record, since this container + cannot reach the PlatformIO registry (baseline). No `.pfm` cache key can + move: the toolchain revision hashes `abi/*.h`, the shared `src/` sources, + `module.ld`, `build_module.py` and `port_preset.py` + (`web/src/lib/firmware/builderRev.ts`), and `release.py` is none of them. + +Done when: 30 files exist, the reassembly diff is empty, `release.py` and +`RELEASING.md` describe the same release, and `docs-links.yml` is green. + +## Lane C: the parity list, then the close + +**C1, now.** `doc-map.ts` gains a second output, +`docs/investigations/2026-10-doc-parity.csv`, one row per pair. A suggested +shape: `kind, en, ko, en_chars, ko_chars, state`, where `state` is `ok`, +`missing-en`, `missing-ko`, `en-short` or `ko-short` (one side under two +thirds of the other, counting characters with markup stripped). Sources are +the five kinds in finding 2. The CSV header or the script's doc comment says +that the three kinds §2.3 lists do not exist yet, so a later `*.ko.md` lands +in the same output. Characters are a rough measure across two scripts; the +column records the numbers so the maintainer can judge rather than trust the +flag. No translation, no edit to any source file. Regenerate the doc map too, +since the same command writes both. + +Checks: `npm run doc-map` twice gives byte-identical CSVs; `typecheck` and +`lint` in `web/` (a script under `web/scripts/`, not `web/src/`). + +**C2, optional.** Add `.yml` and `.yaml` under `.github/` to the doc map's +sources, so `config.yml`'s link shows up (finding 3). Small; lands before A2 +if it can. + +**C3, last.** After every A and B PR has merged: regenerate both CSVs, carry +B1's sentence into `docs/README.md` and `docs/REPOSITORY.md`, check §2.3's +"done when" list (root, inbound links, `docs-links.yml`, the parity CSV), and +write step 1 into `docs/rfc-cleanup-progress.md` the way step 0 is written +there. + +## Questions for the maintainer + +The first two are §2.13's own, numbered as there, and A2 and A3 wait on +them. + +1. **Which files stay at the root?** Suggested: the default list plus + `CLAUDE.md` and `CITATION.cff` (both work only at the root), and + `AUDIO_GUIDE.md` (`docs/README.md`'s rule calls it a root guide). + `CODE_OF_CONDUCT.md`, `SECURITY.md`, `SUPPORT.md` move to `docs/` (D5). +2. **Do the README's narrative sections stay, or move to `docs/story.md`?** + No default. Agents do not decide this (§2.3). + +The rest come from the scouting, numbered on from §2.13's six. Each has the +default the lanes use until there is an answer. + +7. **The two long-form release notes give their names to the split** (D1)? +8. **Verbatim text outside the 300-line count** when the PR proves it (D3)? +9. **A check that the root's three newest sections match their files** (D2): + add it to step 2's table? +10. **Parity sources** are the five kinds that exist, not the three §2.3 + names (finding 2)? +11. **No stub or redirect for the v2 guide** (D6). Release bodies that link + the old path are yours to edit. diff --git a/docs/investigations/2026-10-cleanup-step2-plan.md b/docs/investigations/2026-10-cleanup-step2-plan.md new file mode 100644 index 000000000..a4d45af07 --- /dev/null +++ b/docs/investigations/2026-10-cleanup-step2-plan.md @@ -0,0 +1,279 @@ +# Cleanup step 2: the plan + +*For [rfc-cleanup.md](../rfc-cleanup.md) §2.4, with the §2.9 lock and the +§2.10 voice lint. Scouted on 14 October 2026 at `5955052` on `dev`, after step +1's moves, the split and the parity list had merged. Nothing here is +implemented.* + +Step 2 is seven drift checks (§2.4). The step 1 plan sent one more here, a +check on the changelog split (its D2). Two others are checks the RFC gives no +step: the lock on human-written text (§2.9), and the voice lint (§2.10) with +the `AGENTS.md` rule that points at it. This plan cuts all of it into fourteen +PRs. No two of them edit the same file. + +## The PRs + +| k | what | files it may edit | merges after | §2.13 | +| --- | --- | --- | --- | --- | +| 1 | the runner, its library, every npm name, and `check:features` | `web/package.json`, `web/scripts/drift.ts`, `web/scripts/drift/_lib.ts`, `web/scripts/drift/features.ts` | — | — | +| 2 | `check:links-absolute` | `web/scripts/doc-map.ts`, `web/scripts/drift/links-absolute.ts` | — | — | +| 3 | `check:bom` and `check:bom-schematic` | `web/scripts/drift/_bom.ts`, `web/scripts/drift/bom.ts`, `web/scripts/drift/bom-schematic.ts` | 1 | — | +| 4 | `check:editions` | `web/scripts/drift/editions.ts` | 1 | — | +| 5 | `check:presets` | `web/scripts/drift/presets.ts` | 1 | — | +| 6 | `check:roadmap` | `web/scripts/drift/roadmap.ts` | 1, 10, and 14 if it opens | — | +| 7 | `check:changelog` (step 1 plan, D2) | `web/scripts/drift/changelog.ts` | 1 | — | +| 8 | `check:human-lock` (§2.9) | `web/scripts/human-lock.ts`, `web/scripts/lib/pr-base.ts`, `docs/.human-written` | — | 2, 5 | +| 9 | `check:voice` (§2.10) | `web/scripts/voice-lint.ts` | 8 | 6 | +| 10 | the drift workflow | `.github/workflows/drift.yml` | 1 | — | +| 11 | `AGENTS.md`: the §2.10 rule and the new commands | `AGENTS.md` | 9, 10 | 6 | +| 12 | the docs that say what CI runs | `CONTRIBUTING.md`, `web/README.md`, `web/ARCHITECTURE.md`, `docs/REPOSITORY.md` | 10 | — | +| 13 | close step 2 | `docs/rfc-cleanup-progress.md`, `docs/investigations/2026-10-doc-map.csv`, `docs/investigations/2026-10-doc-parity.csv` | every other k | — | +| 14 | roadmap drift, only if k6's first CI run finds any | `web/src/app/roadmap/roadmap-data.ts` (`status` and `issues` only) | — | — | + +Step 2 starts when step 1 is closed (§2.12). k9 and k11 also need step 1's +voice PR on `dev`, which no lane of the step 1 plan carries: k9 reads +`docs/voice-banned.txt` and k11 names `docs/voice.md`. + +A file sits in one row. A PR that finds it needs a file from another row +writes the change into its description under "for another k", and the owner +(or k13) carries it. Three people can work at once. Start with k1, k2 and k8, +which wait on nothing; after k1 merges, the rest of the checks open in any +order, three at a time, inside §2.11's limit of five. + +Two shared things have a rule of their own: + +- `web/package.json` belongs to k1. It adds every name this step needs, so no + later PR touches it, and `check:ci` picks up each check the day its file + lands (D1). No PR in this step adds a dependency. +- The doc-map CSVs are committed only by k13. Every other PR runs + `npm run doc-map` in `web/` after `git fetch --tags`, quotes the two summary + lines, and runs `git checkout` on both CSVs before committing. + +`AGENTS.md` and `.github/` each get a PR of their own (k11, k10). No PR in this +step edits `CHANGELOG.md`: each one's line rides in its description, and the +root's three newest sections already equal their files (finding 9). + +## What the tree says + +1. `check:ci` runs on fewer PRs than the checks need. `web-ci.yml` starts on + `web/**` and `hardware/case/**` only. A PR that edits only the BOM, the + build guide, a feature folder, a bundle or the changelog runs no web job, + so a check wired only into `check:ci` would never see the drift it exists + for. k10 adds a workflow that runs the checks on every PR. +2. Absolute links. With tags fetched, `npm run doc-map` reads 1051 links from + 208 files, every one resolving. 118 are absolute, from 26 files, 24 of them + on a tag (`v1.0.0` to `v3.1.0`). There are no raw.githubusercontent.com + links. `doc-map.ts` already finds and resolves all of them, `${REPO}` + templates included, so k2 asks it rather than writing a second reader. + `git grep` finds no tracked file outside the map's sources that carries a + repository URL. `actions/checkout` fetches no tags, so a tag row cannot be + checked in CI without `fetch-depth: 0`. +3. BOM and guide. Both have seven on-board rows and four off-board rows. The + names are worded apart ("Rotary encoder w/ switch" against "Rotary encoder + with push switch"), so a check on names would fail at HEAD; ref, quantity + and MPN agree. The sockets row is `U1 (sockets)` in the CSV and `—` in the + guide, and its notes say it is not a refdes. Off the board there is no ref, + the CSV writes `6-12` where the guide writes `6–12`, and every word of the + CSV's part name appears in the guide's (`USB cable (sacrificial)`, + "Sacrificial USB cable (it gets cut)"). +4. Schematic. `hardware/pcb/schematic.pdf` is KiCad output in stroke fonts, + so no designator sits in its streams as a plain string. `pdftotext` 24.02 + reads them all the same: U1, SW1 to SW4, J1, J3, J4 and C11, which is + exactly the BOM's nine. The other refdes-shaped tokens are pin names (S1 to + S3, IO4 to IO48, ESP32). The text layer carries designators, so §2.4's + warn-only clause is not needed. Nothing in `web/package.json` reads PDF. +5. Features. `features-data.ts` has ten ids; nine are the folders under + `firmware/patternflow/features/`, and the tenth is `core`, which the type's + own comment allows ("Directory under firmware/patternflow/features/, or + "core""). +6. Editions. Three cards (`core`, `performance`, `audio`) and four bundles + (`audio`, `clock`, `midi`, `performance`). `clock` and `midi` are not + drift: `features-data.ts` names each as a try-out + (`/flash/bin/clock-v0.1.5/`, `/flash/bin/midi-v0.1.0/`). The five folders + in `web/public/flash/bin/` are the three cards' images and the two + try-outs. `release.py` and `check_versions.py` each keep + `EDITIONS = ("audio", "performance")` by hand. `check_versions.py` + already checks versions and that the named images exist. +7. Presets. `check_presets.py` (`firmware-checks.yml`) already compares + `web/src/lib/presets/` with `firmware/patternflow/presets/`: 34 twins, 14 + web-only on purpose, `calib` firmware-only. `index.ts` imports all 48 web + presets. What no script looks at is how a twin reaches a panel: + `pattern_registry.h` compiles in `origin` and `calib`, and the other 33 + are the Basics pack, whose `basics.json` keeps a `presets` map of 33 + entries. The feature presets (`show/preset_black.h`, + `weather/preset_weather.h`) live in `features/`, not `presets/`. +8. Roadmap. 83 nodes, 13 with an `issues` list, 14 distinct numbers, all in + `engmung/Patternflow`. Every one of the 13 is `done`; the four `planned` + nodes have no issues. This session's GitHub access covers only the fork, + so the state of those issues was not read. Some may be open on purpose: + #221 holds USB-C "until a redesign passes", and its node records the + withdrawal, which did ship. +9. Changelog. The root keeps `[3.11.0]`, `[3.10.5]` and `[3.10.4]`, and each + equals its file in `docs/releases/` byte for byte. Below them, "Older + releases" links one file per line. +10. The lock. Every region §2.9 names exists: the README's three headings + (lines 39, 51, 271), Part 1 of `rfc-cleanup.md` and of + `rfc-core-and-variants.md`, 74 `.mdx` files in `web/content/journal/`, + `docs/manifesto.md`, 33 files in `docs/releases/` (the `-notes` and + `-launch` files too), and the roadmap's `detail`/`detailKo` strings. + `docs/.human-written` does not exist, and answer 5 adds nothing to it. +11. Voice. `docs/voice.md` and `docs/voice-banned.txt` are not on `dev`; + step 1's voice PR is writing them. The §2.10 block is not in `AGENTS.md` + and no step of the RFC owns appending it. +12. No PR here edits `firmware/` or `web/src/` except k14, so `build.sh all` + does not apply. `web/scripts/` is outside `next build`, and + `npm run typecheck` is what covers it. + +## Defaults this plan takes + +- D1. One runner. Each drift check is a file in `web/scripts/drift/`. + `web/scripts/drift.ts` runs every file there whose name does not start with + `_`, and `check:ci` calls it once, as `check:drift`. The RFC's names stay: + `npm run check:bom` runs that one file. +- D2. Skipped, not failed. A side a check cannot read where it runs (a tag + the clone lacks, no `gh` login, no `pdftotext`) prints why and passes. With + `PF_DRIFT_STRICT=1`, which k10's workflow sets, a skip fails. A contributor + running `check:ci` at their desk is not stopped by a missing tag; CI is. +- D3. Exceptions are named where the tree already names them. `core` comes + from the `Feature` type; try-outs from `tryOut`; the web-only and + firmware-only presets from `check_presets.py`, read rather than copied. + A check with an exception of its own keeps it in a list with a reason per + entry, the way `check_presets.py` does. +- D4. `check:bom-schematic` is strict from its first day (finding 4) and runs + `pdftotext` from poppler. That keeps `package-lock.json` out of the step. +- D5. The lock reads the PR's base from `PF_LOCK_BASE` (the workflow passes + the base SHA). Without it, the base is the merge-base with `origin/dev`, + then `origin/main`; with neither, it skips. A new locked file passes. A + locked file moved with its plain text unchanged passes and prints that the + move needs the maintainer's approval on the PR (§2.9); any other change to + locked text fails. +- D6. `check:voice` is not in `check:ci`. §2.10 runs it before a PR opens, + and a person reads the text before merge. With no arguments it reads the + lines a branch adds against the same base as the lock. +- D7. Branches, labels and descriptions as in the step 1 plan's D4. + +## The PRs in detail + +Every PR runs `npm run typecheck`, `npm run lint` and `npm run check:drift` in +`web/` (k2 and k8 run their script directly until k1 lands), and quotes one +failing run made on a throwaway edit, reverted before commit. A check is +proved when it fails on drift, not only when it passes. + +k1. `drift.ts` runs each check in its own process, prints one line per check +(passed, failed, skipped), and exits 1 if any failed; a name argument runs +one. `_lib.ts` is the base the other checks import: `ROOT`, `tracked()`, +`read()`, `compare(name, left, right)` (each side a label and a list; prints +both sides and what is on one side only, as §2.4 asks), `skip(name, why)` +(D2) and `done()`. In `package.json`: `check:drift`; `check:links-absolute`, +`check:bom`, `check:bom-schematic`, `check:features`, `check:editions`, +`check:presets`, `check:roadmap` and `check:changelog`, each +`tsx scripts/drift.ts `, which says so and fails while the file is not +written; `check:human-lock` (`tsx scripts/human-lock.ts`) and `check:voice` +(`tsx scripts/voice-lint.ts`); and `&& npm run check:drift` at the end of +`check:ci`. `features.ts` compares the ids with the folders (finding 5). +Runs the whole `check:ci`. + +k2. `doc-map.ts` gets a `--check absolute` mode: the same rows, nothing +written, exit 1 on an absolute row that does not resolve. A row on a ref the +clone lacks is a skip (D2). The mode also fails on a tracked file outside the +map's sources that carries a repository URL, since the map would not read it. +`links-absolute.ts` runs that mode and imports nothing from k1. Proof that the +map did not move: both CSVs byte-identical before and after +(`git diff --exit-code`). + +k3. `_bom.ts` reads `bom_v3.9.csv` and expands `SW1-SW4`. `bom.ts` matches +the on-board rows by MPN, then quantity and ref, with the sockets row as in +finding 3; the off-board rows in order, by quantity and the words of the part +name. Dashes compare equal. `bom-schematic.ts` runs +`pdftotext -layout hardware/pcb/schematic.pdf -` and compares the tokens with +a refdes prefix (U, SW, J, C, and R, D, Q, L, F for a part not yet on the +board) with the BOM's refs. This is hard rule 2 as a check; no file under +`hardware/` or `BUILD_GUIDE.md` changes. + +k4. `editions.ts` compares five lists (finding 6): the cards bar `core`, the +bundle folders, the try-outs, the folders in `web/public/flash/bin/` against +the images the cards, try-outs and manifest name, and the two `EDITIONS` +tuples. Versions stay `check_versions.py`'s job. + +k5. `presets.ts` reads `WEB_ONLY` and `FIRMWARE_ONLY` out of +`check_presets.py`. Drift: a `pattern-*.ts` that `index.ts` does not import, +or the reverse; a twin in neither `pattern_registry.h` nor `basics.json`'s +`presets`; a pack entry with no twin (finding 7). + +k6. `roadmap.ts` asks `gh api repos/engmung/Patternflow/issues/` for each +number. Drift: `done` with an open issue, `planned` with a closed one. A +worker cannot read that repository from a session scoped to the fork, so the +PR proves the logic against a stub `gh` on `PATH` giving each answer, and its +own run in k10's workflow is the first real one. If that run finds drift, +k6 stays open and k14 goes first (question 12). + +k7. `changelog.ts`: each release section in the root equals its +`docs/releases/vX.Y.Z.md` once the relative links are put back (B1's rewrite, +undone); each "Older releases" line names a file that exists; and every +`vX.Y.Z.md` (not `-notes`, not `-launch`) is in the root in full or in the +list. It reads the same layout `release.py cut` writes, so a release stays +green. + +k8. `human-lock.ts` takes the regions in finding 10, plus each path in +`docs/.human-written`. Plain text is the region with Markdown and MDX syntax +(link targets, image paths, tags, emphasis marks, heading marks) and all +whitespace removed; front matter values count as text. The roadmap strings +come out through the TypeScript parser, as `doc-map.ts` already does. A +README region runs from its `##` heading to the next. `lib/pr-base.ts` finds +the base (D5) and the files changed since it, for k9 too. +`docs/.human-written` starts with a comment line and no path (answer 5). No +locked text changes in this PR. + +k9. `voice-lint.ts` reads its banned list from `docs/voice-banned.txt` and +flags the patterns §2.10 names: banned words and phrases (English by word, +Korean as written), a list of three adjectives or clauses, an opening that +says what the document is about, and a closing summary. It prints +`file:line` and the rule. Arguments are files; none means the added lines +since the base (D6). + +k10. `.github/workflows/drift.yml`, named "Drift checks", on every pull request +and on push to `main`. Checkout with `fetch-depth: 0` (tags, and the base for +the lock); Node 20 and `npm ci` in `web/`; poppler if `pdftotext` is missing; +`npm run check:drift` with `PF_DRIFT_STRICT=1` and +`GH_TOKEN: ${{ github.token }}`; on a pull request, `npm run check:human-lock` +with `PF_LOCK_BASE` set to the base SHA, in a step that runs once +`web/scripts/human-lock.ts` exists. Permissions `contents: read` and +`issues: read`. Each check that lands after it runs strict on its own PR. + +k11. Append §2.10's block to `AGENTS.md` as the RFC writes it. Under "Common +commands", one line each for `check:drift` (in `check:ci`, and strict in +`drift.yml`), `check:human-lock` (on pull requests) and `check:voice` (by +hand, before a PR with new prose). `firmware-checks.yml` runs on any +`AGENTS.md` change, so the PR runs `python firmware/toolchain/check_versions.py` +too. + +k12. The four files say what CI runs: `CONTRIBUTING.md`'s table row for web +work, `web/README.md`'s command list, the tests paragraph of +`web/ARCHITECTURE.md`, and the line on `check_links.py` in +`docs/REPOSITORY.md`. One sentence each where one is enough. + +k13. After every other k has merged: regenerate both CSVs, check §2.4's "done +when" (seven scripts, in CI, passing at HEAD) and the three added here, and +write step 2 into `docs/rfc-cleanup-progress.md` the way step 0 is written. + +k14. Only if k6's first CI run finds drift. The PR lists each mismatch and +changes only what the maintainer decides for it: a node's `status`, or a +number in its `issues`. The `detail` and `detailKo` strings are locked. An +issue the maintainer calls open on purpose goes into k6's own list instead, +with the reason given. It is the one PR here that touches `web/src/`, so it +runs `check:ci`, `check:panels`, `typecheck` and `lint` (§2.1). + +## Questions for the maintainer + +Numbered on from the step 1 plan's eleven. Each has the default the PRs use +until there is an answer. + +12. A roadmap node marked `done` whose issue is still open: drift, or allowed? + Default: k14 asks per item, and nothing goes on k6's list without your + reason. +13. Your own edit to locked text (a typo in a journal post) turns + `check:human-lock` red. Default: no override file. You merge over the red + row or push the edit yourself. +14. Should `check:voice` gate CI? Default: no, it is run by hand (D6). +15. Keep `check_presets.py` and `check:presets` as two scripts? Default: yes. + k5 reads the Python file's lists, so they cannot disagree. diff --git a/docs/investigations/2026-10-cleanup-step3-plan.md b/docs/investigations/2026-10-cleanup-step3-plan.md new file mode 100644 index 000000000..61049e6c6 --- /dev/null +++ b/docs/investigations/2026-10-cleanup-step3-plan.md @@ -0,0 +1,295 @@ +# Cleanup step 3: the plan + +*For [rfc-cleanup.md](../rfc-cleanup.md) §2.5. Scouted on 14 October 2026 at +`2240b04` on `dev`, after the step 2 plan merged and before step 1 closed. +Nothing here is implemented.* + +§2.5 gives step 3 six jobs: lint and typecheck at zero, every legacy path named +or gone, minor and patch dependency updates, the HUB75 fork written down as a +patch and then brought up to upstream, the `TODO` and `FIXME` markers, and one +source for the constants the firmware and the site both carry. Answer 4 of +§2.13 keeps major updates out of this window. Scouting found one fix the step +exists for: the MIDI page still counts 20 detents a turn (finding 9). This plan +cuts all of it into 27 PRs. + +## The PRs + +| k | what | files it may edit | merges after | §2.13 | +| --- | --- | --- | --- | --- | +| 1 | lint: four unused names and one hook dependency | `web/scripts/performance-smoke.ts`, `web/src/app/editions/EditionCard.tsx`, `web/src/app/roadmap/RoadmapMap.tsx`, `web/src/components/community/PatternCard.tsx` | — | — | +| 2 | lint: seven `` warnings get a named exception | `web/src/app/build/breadboard/page.tsx`, `web/src/components/journal/JournalLightbox.tsx`, `web/src/components/sections/Hero.tsx` | — | — | +| 3 | CI fails on a lint warning | `.github/workflows/web-ci.yml` | 1, 2 | — | +| 4 | Pattern Lab's v1 keys | `web/src/lib/lab/persist.ts`, `web/src/lib/lab/legacyDraft.ts`, `web/src/lib/lab/serialize.ts`, `web/src/lib/lab/store/project.ts`, `web/src/lib/lab/director/bake.ts` | — | — | +| 5 | the site's redirects and old routes | `web/next.config.ts`, `web/src/lib/campaignRoutes.ts`, `web/src/proxy.ts`, `web/src/app/business/page.tsx`, `web/src/app/community/layout.tsx` | — | — | +| 6 | old formats the community and the build service still accept | `web/src/components/community/FeedControls.tsx`, `web/src/lib/community/deck.ts`, `web/src/lib/community/zipDownload.ts`, `web/src/lib/community/server/deckZip.ts`, `web/src/lib/community/server/builds.ts`, `web/src/lib/community/server/schema.ts`, `web/src/app/api/community/builds/route.ts`, `web/src/lib/firmware/moduleRunner.ts` | — | — | +| 7 | `shared/constants.json`, its generator, both generated files, `check:shared` | `shared/constants.json`, `web/scripts/gen-shared.ts`, `web/scripts/drift/shared.ts`, `firmware/patternflow/src/core_shared.h`, `web/src/lib/shared/constants.ts`, `web/package.json` | — | — | +| 8 | the firmware reads `core_shared.h` | `firmware/patternflow/config.h`, `firmware/patternflow/net_config.h`, `firmware/patternflow/src/core_encoders.h`, `firmware/patternflow/src/core_bus.h`, `firmware/patternflow/patternflow.ino` | 7 | — | +| 9 | the site reads `constants.ts` | `web/src/lib/pattern/harness.ts`, `web/src/lib/pattern/controls.ts`, `web/src/lib/pattern/matrix.ts`, `web/src/lib/guide/panelScreens.ts`, `web/src/lib/guide/deviceSim.ts`, `web/public/pattern-sandbox.html` | 7 | — | +| 10 | a fix, not a cleanup: MIDI counts 24 detents a turn | `firmware/patternflow/console/midi.html`, `firmware/patternflow/features/midi/midi_index.h`, `firmware/patternflow/features/midi/core_midi.h`, `docs/midi-spec.md` | — | — | +| 11 | the HUB75 fork as `UPSTREAM.md` and `patternflow.patch` | `firmware/patternflow/src/hub75/UPSTREAM.md`, `firmware/patternflow/src/hub75/patternflow.patch`, `firmware/patternflow/src/hub75/VENDORED.md`, `firmware/patternflow/src/core_display.h`, `firmware/patternflow/src/core_canvas.h` | — | — | +| 12 | upstream fixes, one PR per commit on k11's list | the upstream files that commit changes under `firmware/patternflow/src/hub75/`, and that folder's `UPSTREAM.md` and `patternflow.patch` | 11, then the PR before it | — | +| 13 | no new `TODO` or `FIXME` | `web/scripts/drift/markers.ts` | — | — | +| 14 | dependencies: lint and test tooling | `web/package-lock.json` | 3, 7 | 4 | +| 15 | dependencies: Drizzle | `web/package-lock.json` | 14 | 4 | +| 16 | dependencies: Tailwind | `web/package-lock.json` | 15 | 4 | +| 17 | dependencies: React Three | `web/package-lock.json` | 16 | 4 | +| 18 | dependencies: zustand | `web/package-lock.json` | 17 | 4 | +| 19 | dependencies: mediabunny | `web/package-lock.json` | 18 | 4 | +| 20 | dependencies: `@google/genai` | `web/package-lock.json` | 19 | 4 | +| 21 | dependencies: posthog-js | `web/package-lock.json` | 20 | 4 | +| 22 | dependencies: better-auth | `web/package-lock.json` | 21 | 4 | +| 23 | dependencies: React | `web/package.json`, `web/package-lock.json` | 22 | 4 | +| 24 | dependencies: Next | `web/package.json`, `web/package-lock.json` | 23 | 4 | +| 25 | `AGENTS.md`: `shared/`, the fork's two files, the new commands | `AGENTS.md` | 3, 7, 11 | — | +| 26 | the docs that map the tree | `docs/REPOSITORY.md`, `web/ARCHITECTURE.md`, `firmware/README.md` | 4, 7, 9, 11 | — | +| 27 | close step 3 | `docs/rfc-cleanup-progress.md`, `docs/investigations/2026-10-doc-map.csv`, `docs/investigations/2026-10-doc-parity.csv` | every other k | — | + +Step 3 opens when step 2 is closed (§2.12). Step 2's runner (its k1) runs every +file in `web/scripts/drift/` from `check:ci` and its workflow (k10) on every +pull request, so k7 and k13 reach CI by adding a file. Every PR here runs its +voice lint (k9) on added prose, and none edits text its §2.9 lock (k8) covers. +Neither check is in the tree today; this plan does not write them again. + +A file sits in one row, with two exceptions that run in order. The lockfile +belongs to k14 to k24, which open one at a time, and `web/package.json` to k7, +then k23, then k24. `UPSTREAM.md` and `patternflow.patch` belong to k11, then to +each k12 PR in turn. Rows that can be open at the same time share no file. +Start with any three of k1, k2, k4, k5, k6, k7, k10, k11 and k13, which wait on +nothing. §2.11's limit of five holds, and the dependency lane is one of them. + +Only k27 commits the doc-map CSVs. Every other PR runs `npm run doc-map` in +`web/` after `git fetch --tags`, quotes its two summary lines, and checks both +CSVs out again. `AGENTS.md` and `.github/` get a PR each (k25, k3). No PR edits +`CHANGELOG.md`: each one's line rides in its description. + +## What the tree says + +1. Lint and typecheck. `npm run lint` gives the baseline's twelve warnings: + four `no-unused-vars` and one `exhaustive-deps` in four files, seven + `no-img-element` in three. `npm run typecheck` prints nothing. `web-ci.yml` + gates on errors only ("warnings still pass"), so a fixed warning can return. +2. The `` warnings. `next/image` changes the markup a page serves (a + `srcset`, lazy loading, the `/_next/image` route), which §2.1(1) rules out. + The tree already makes nine line exceptions to this rule, three with a + reason after `--` (`LabWindow.tsx`, `DeskStage.tsx`, `AttachmentList.tsx`). +3. The hook warning. `clampPan` in `RoadmapMap.tsx` reads `width`, `zoom` and a + ref, and the wheel effect lists `[width, zoom]`. As a `useCallback` on those + two it can join the list, and the effect runs on the same renders as now. + `zoomTo` and `REASON_LABEL` have no reader. +4. Pattern Lab. `patternflow_lab_draft_v1` was written by the lab's autosave + from 25 July (`e19fe19`) until Pattern Lab v2 replaced it on 27 July + (`9be6e98`); `patternflow_ramp_v1` is older. Only `migrateLegacyDraft` reads + them. The comments say "v1 lab" and "read once", with no date, and "read + once" is wrong: stashing a session removes the project key + (`store/project.ts:67`), and the next open, finding none, migrates again. + `persist.ts` lines 8 to 11 say the migration lifts the draft and the + gallery; it lifts the draft and the ramp. `patternflow_lab_gallery_v1` is + called legacy and is the live gallery key. `saveDraft` has no caller. + `bakeShow` v1 "stays for … old players" and names none. +5. Redirects. `/variants` and `/flash` in `next.config.ts` name what links to + them, and both are still linked (`web_update_index.h:214`, + `.github/scripts/flashing-md.sh:61`). The `/variants` comment dates the + console links to 3.8.0; `docs/releases/v3.7.0.md` has them already. With no + comment: the two `?lang=en` journal redirects, which `proxy.ts` also handles + while setting the language cookie; the `/en/en` collapse in `proxy.ts`; the + eight campaign rewrites in `campaignRoutes.ts`; and `app/business/page.tsx`, + a redirect to `/contact` from May that nothing in the repository links. + `community/layout.tsx` gives "an old or mistyped URL" as its reason. +6. Community and the build service. `deck.ts` migrates `pf-module-cart` to + `pf-deck` (`f3f163b`, 29 July) and names the old writer but not the date. + `zipDownload.ts` keeps `duplicate-name` "for older servers", those before + `e261978` (24 September). `builds/route.ts` answers a `"bin"` build with + 410, while `server/builds.ts`, `server/schema.ts` and `moduleRunner.ts` + describe `"bin"` as live, and `moduleRunner.ts` cites a `buildRunner.ts` + that is gone. `FeedControls.tsx` strips the parameters of "an old bookmark". +7. Dependencies. `npm outdated` shows a minor or patch release inside its major + for every family in k14 to k24, Next 16.4.1 and React 19.3.1 among them. + Waiting on answer 4: TypeScript 7, vitest 5, ESLint 10, jsdom 29, jest-dom + 7, better-sqlite3 13 and its types 9, dockview 8, feed 6, `@types/node` 26 + (CI runs Node 20), and three.js 0.186, which `^0.184.0` does not allow. +8. The HUB75 fork. `src/hub75/` is ESP32-HUB75-MatrixPanel-DMA v3.0.13, + vendored on 31 July (`42bfc10`). `VENDORED.md` explains each change and + `UPSTREAM_library.properties` keeps the version; neither names a commit. The + changes go past `blitRGB888` and `resumeDMAoutput`: the buffer chain, the + 300 Hz frame padding, the OE windows inside upstream's `setBrightnessOE`, + the rounding in `pfBuildSpread`, and the on-time accessors `core_power.h` + reads, which carry no marker. `VENDORED.md`'s "Nothing else is modified" and + the "one addition" comments in `core_display.h` and `core_canvas.h` are out + of date. Upstream is outside this session's reach, so the platform files + have not been compared. `build.sh:189` and `sync_ino_to_src.py:31` clone + upstream's HEAD into `lib/HUB75`, which `platformio.ini` ignores. +9. Constants. `3b63d10` (31 July) moved the lab from 20 detents a turn to the + encoder's 24 (PEC11R-4220F-S0024), and `5b9d1b0` (v3.9.4) moved the sandbox. + The MIDI feature came later and still says 20: `console/midi.html:206` + computes its "per turn" figure as `20*g[0]/g[1]`, and `core_midi.h:69` and + `docs/midi-spec.md:119` say it in words. The firmware has no detents-a-turn + constant; its 48 (`PF_LANE_MOTION_SCALE`, `_TEMPLATE.h`) is 24 times + `TURNS_PER_FULL_RANGE`. Copies that agree: the 128×64 panel, four knobs, the + bus maximum of 1000 (also a literal in the frozen `abi/pf_params.h`), ten + bus units a click, four edges a detent, the 1000 ms long press, the 50 ms + debounce. `panelScreens.ts` mirrors twenty timings from `patternflow.ino` + by line number, most now wrong. `build_module.py` hard-codes 128 and 64. + Written for other readers: `docs/rest-api.md:382`, the Ableton bridge's + `DEFAULT_SWEEP = 48`, and the lab's AI prompt in `PatternPanel.tsx`, which + says "0.05 per detent" where the lab's step for a 0 to 1 knob is 0.5/24. +10. Generating. The cache key (`builderRev.ts`) hashes `abi/*.h`, three + toolchain files and four `src/` headers (`core_color.h`, `core_math.h`, + `core_noise.h`, `core_module_loader.h`). A new `core_shared.h`, included + from any other header, moves no key. `check_boundaries.py` reads + `src/**/*.h` as core, so its macros take a `PF_SHARED_` prefix; a + `PF_MIDI_` name fails rule R3. No workflow but step 2's watches `shared/`. + Images carry `__DATE__` and `__TIME__` (`core_build.h:53`). +11. Markers. `git grep -E "TODO|FIXME"` finds upstream's own `TODO` at + `hub75/ESP32-HUB75-MatrixPanel-I2S-DMA.cpp:126`, a `.gitignore` pattern + and the RFC's sentence. First-party code has none, and had none at seven + commits sampled on `dev` from May to October. The five §2.5 counts are not + in this repository. + +## Defaults this plan takes + +- D1. No behaviour change, except where §2.5 asks for one: the dependency + updates and the upstream fixes. k10 is a fix, reviewed as one (§2.2). Every + other PR proves its output did not move, with `check:sandbox-sync` and + `check:modcache` in `check:ci`, and D5 for the firmware. +- D2. Legacy is kept and named unless the maintainer says it goes (questions + 18, 19). A name says what wrote the data or posted the link, and when: + "written by the lab's autosave from 25 to 27 July 2026", not "v1". Code with + no reader goes. +- D3. A lint exception gives its reason on its own line, as `LabWindow.tsx` + does. `eslint.config.mjs` is not touched. +- D4. A dependency family is packages that release together or wrap one + another; a package with none is its own. Each PR takes what `npm outdated` + shows inside the current major on the day it opens, a 0.x minor counting as + a major. Caret ranges move in the lockfile (`npm update `); the pinned + `next`, `eslint-config-next`, `react` and `react-dom` move in `package.json`. +- D5. Two builds differ in their stamp, so k8 proves equality in two commits. + The first puts a `static_assert` beside each literal it will replace, + comparing it with the generated macro, and CI builds all five compositions. + The second swaps and drops the asserts, and the sizes `firmware-build.yml` + prints match the first commit's. No hashed header includes `core_shared.h`. +- D6. One generator, `web/scripts/gen-shared.ts` (`npm run gen:shared`), + writes both outputs under a GENERATED line. The check regenerates in memory + and compares, and also reads the copies that cannot be generated: the 1000 in + the frozen `abi/pf_params.h` and the 128 and 64 in `build_module.py`, which + is hashed. The copies written for other readers (finding 9) stay by hand. +- D7. `shared/constants.json` holds §2.5's four groups: the three panel + geometries with their sizes and the default; the knob count, detents a turn + and turns per full range; edges a detent, the bus maximum, bus units a + click, the long press and the debounce. The other timings wait (question 23). +- D8. Generated text is outside the 300-line count when the PR proves it + regenerates: `patternflow.patch`, the lockfile, `pattern-sandbox.html`, + `midi_index.h`. The step 1 plan's question 8 asks the same for moved text. +- D9. Branches, labels and descriptions as in the step 1 plan's D4. + +## The PRs in detail + +A PR in `web/` runs `typecheck`, `lint`, `check:ci`, and `check:panels` when +it touches `web/src/` (§2.1). A PR in `firmware/` takes `build.sh all` from +`firmware-build.yml`, since the container cannot reach PlatformIO (baseline). + +k1 to k3. k1 drops the unused `normalizePerformance` and `useMediaQuery` +imports, `REASON_LABEL` and `zoomTo`, and wraps `clampPan` as finding 3 says. +k2 puts `eslint-disable-next-line @next/next/no-img-element -- ` above +each ``: the build photos and the hero poster are fixed files shown as +they are, and the lightbox shows whichever journal image was clicked. k3 runs +the Lint step as `npm run lint -- --max-warnings=0`, with its comment to match, +and proves it exits 1 on a throwaway unused variable. + +k4 to k6. A comment on each thing findings 4 to 6 list, giving its writer or +its link and the date (`git log -S` finds both). k4 also corrects `persist.ts`'s +header, renames `legacyGallery` to `gallery` with the key string unchanged, +removes `saveDraft`, and puts the repeated migration in its description, not +its code (question 18). k5 changes "3.8.0" to "3.7.0" and removes nothing; +`doc-map.ts` reads redirect sources as routes, so the map must not change. k6 +says the `"bin"` route answers 410 and drops `buildRunner.ts`; the schema's +default stays, since changing it is a migration. + +k7. The JSON (D7), the generator and the check (D6), the two outputs, and +`gen:shared` and `check:shared` (`tsx scripts/drift.ts shared`) in +`package.json`. Nothing includes or imports the outputs yet. Proof: +`gen:shared` twice gives identical files, and `check:shared` fails on a hand +edit to either output and on a changed literal in `abi/pf_params.h`. + +k8. `config.h` takes `PANEL_GEOM_*` and their sizes (its `#ifndef` overrides +stay), `net_config.h` writes `PF_LANE_MOTION_SCALE` as detents times turns +(still overridable), `core_encoders.h` takes its edges, debounce and long +press, `core_bus.h` `PF_BUS_MAX` and the `/ 10`, `patternflow.ino` +`MODE_HOLD_MS`. Proof as D5. Every use of `PANEL_RES_W` and `PANEL_RES_H` +stays; step 5 counts them. + +k9. The five files take their values from `@/lib/shared/constants` and keep +every name they export (`docs/rest-api.md:382` names `ENCODER_CLICKS_PER_TURN`). +`panelScreens.ts` cites firmware constants by name, not line. If +`check:sandbox-sync` reports a change, rebuild and show the diff is only the +constant's new home. + +k10. `midi.html` counts 24, `console_pages.py build` regenerates +`midi_index.h`, and `core_midi.h` and `midi-spec.md` say 24 (so 1 is 24 steps +a turn and 4 is 6). `outDiv` and `outMul` keep their ranges and defaults, and +the `midi-v0.1.0` try-out stays frozen. Runs `console_pages.py check` and +`check_boundaries.py`. + +k11. With upstream checked out read-only at tag 3.0.13 (question 20), +`UPSTREAM.md` names the repository, the tag and its commit, the date of the +copy, the upstream files left out or renamed, upstream's own `TODO`, and the +command that rebuilds the patch. `patternflow.patch` is `diff -ruN` of +upstream's `src/` against this folder, over the files both carry. +`VENDORED.md`'s "What this costs" and the two core comments point at the patch +instead of counting additions. The PR lists, oldest first, the upstream commits +after 3.0.13 that touch no hunk of the patch. Proof: the patch applied to a +clean upstream checkout reproduces the folder, `diff -r` empty. + +k12. One PR per commit on that list, in its order: the change, the commit +added to `UPSTREAM.md`, the patch rebuilt. `check_blit.py` and `check_oe.py` +(they lift code out of the driver) and `firmware-build.yml` green, and a +flash-and-look on the reference panel the maintainer notes in the PR (§2.5). + +k13. `markers.ts` fails on a `TODO` or `FIXME` in tracked files under +`firmware/`, `web/src/`, `web/scripts/`, `tools/` and `integrations/`. It skips +itself and the vendored `hub75/`, `webserver/` and `pubsubclient/`, the three +`doc-map.ts` already skips. It passes at HEAD. Upstream's marker stays: editing +it would add a hunk to the patch. + +k14 to k24. One family each (D4), in the table's order: the smallest first, +Next last, because `eslint-config-next` moves with it and can bring warnings. +Each quotes `npm outdated` for its family before and after, runs +`npm run build` beside the four commands, and shows `pattern-sandbox.html` +unchanged. A family with nothing outdated on its day closes with nothing done. + +k25 and k26. `AGENTS.md` gets `shared/` in its repository map, the fork's two +files beside the vendored driver, `npm run gen:shared` under "Common commands", +and the lint gate; the PR runs `check_versions.py`, which parses `AGENTS.md`. +k26 gives `docs/REPOSITORY.md` a top-level row for `shared/`, points +`web/ARCHITECTURE.md` at the constants' source (line 59) and k4's key name +(line 80), and points `firmware/README.md`'s vendored-driver note (lines 158 to +162) at `UPSTREAM.md`. + +k27. After every other k: regenerate both CSVs, check §2.5's "done when" (lint +and typecheck clean, every legacy path named or gone, `UPSTREAM.md` and the +patch, `check:shared` in CI), and write step 3 into +`docs/rfc-cleanup-progress.md` the way step 0 is written. + +## Questions for the maintainer + +Numbered on from the step 2 plan's fifteen. Each has the default the PRs use +until there is an answer. + +16. The five markers §2.5 counts are not here (finding 11). Where were they? + Default: k13 holds first-party code at none. +17. Is a lint exception with its reason "relaxing the rule"? Default: no (D3). +18. May the v1 draft and ramp migration go? It serves browsers that opened the + lab between 25 and 27 July. Default: kept and named, and the repeated + migration (finding 4) gets a fix PR of its own. +19. `/business` and the two `?lang=en` redirects: keep or remove? Default: + keep and name. +20. k11 and k12 read `mrcodetastic/ESP32-HUB75-MatrixPanel-DMA`, which a + session scoped to the fork cannot. Default: the hub attaches it read-only. +21. Remove the two clones of upstream's HEAD the build ignores (finding 8)? + Default: not in this step; `firmware/README.md` says they satisfy headers. +22. k10 changes what the MIDI page shows. In this window, reviewed as a fix? + Default: yes. +23. Should `shared/constants.json` also carry the twenty timings + `panelScreens.ts` mirrors? Default: no, only §2.5's four groups (D7). +24. The lab's AI prompt says "0.05 per detent" (finding 9). Default: a fix + after the cleanup, outside this table. +25. Generated text outside the 300-line count (D8)? Default: yes. diff --git a/docs/investigations/2026-10-cleanup-step4-plan.md b/docs/investigations/2026-10-cleanup-step4-plan.md new file mode 100644 index 000000000..f03c4f83e --- /dev/null +++ b/docs/investigations/2026-10-cleanup-step4-plan.md @@ -0,0 +1,295 @@ +# Cleanup step 4: the plan + +*For [rfc-cleanup.md](../rfc-cleanup.md) §2.6. Scouted on 14 October 2026 at +`e3b977f` on `dev`, after the step 3 plan and `docs/voice.md` merged and before +step 1 closed. Nothing here is implemented.* + +§2.6 gives step 4 two jobs. Five contracts each get a folder of vectors and a +script that replays them, and a virtual panel speaks all five so the scripts +have a device to replay against. Answer 3 of §2.13 puts REST first. Two things +in the tree change how the RFC reads: the harness it names +(`web/src/lib/harness/`) is not a folder, and the status field a vector would +read for most knob inputs does not move for them (findings 2 and 4). This plan +cuts the step into 24 PRs. + +## The PRs + +| k | what | files it may edit | merges after | §2.13 | +| --- | --- | --- | --- | --- | +| 1 | `DeviceSim` gives a socket what it reads and writes | `web/src/lib/guide/deviceSim.ts`, `web/src/lib/guide/deviceSim.test.ts` | — | — | +| 2 | the virtual panel: process, clock, frame, fixture routes, the seam; every npm name | `tools/virtual-panel/README.md`, `tools/virtual-panel/tsconfig.json`, `tools/virtual-panel/main.ts`, `tools/virtual-panel/panel.ts`, `tools/virtual-panel/http.ts`, `tools/virtual-panel/png.ts`, `web/package.json` | — | — | +| 3 | the vector format and the replay library | `docs/contract-vectors.md`, `web/scripts/drift/_vectors.ts` | 2 | — | +| 4 | REST: device state | `tools/virtual-panel/transports/rest-state.ts` | 1, 2 | 3 | +| 5 | REST: patterns | `tools/virtual-panel/transports/rest-patterns.ts` | 2 | 3 | +| 6 | REST vectors: status, params, sleep; `check:rest-vectors` | `docs/rest-api-vectors/README.md`, `docs/rest-api-vectors/state-*.json`, `web/scripts/drift/rest-vectors.ts` | 3, 4 | 3 | +| 7 | REST vectors: the rest of the core, and what a build without a feature does not serve | `docs/rest-api-vectors/device-*.json`, `docs/rest-api-vectors/patterns-*.json`, `docs/rest-api-vectors/absent-*.json` | 5, 6 | 3 | +| 8 | `check:virtual-panel`: every preset runs | `web/scripts/drift/virtual-panel.ts` | 5 | — | +| 9 | MQTT: the client, topics, roles, channels | `tools/virtual-panel/transports/mqtt.ts` | 1, 2 | — | +| 10 | MQTT: `/api/mqtt` and its status fields | `tools/virtual-panel/transports/mqtt-http.ts` | 9 | — | +| 11 | MQTT vectors: topics and roles; the test broker; `check:mqtt-vectors` | `docs/mqtt-vectors/README.md`, `docs/mqtt-vectors/topics-*.json`, `web/scripts/drift/_broker.ts`, `web/scripts/drift/mqtt-vectors.ts` | 3, 4, 10 | — | +| 12 | MQTT vectors: channels, the snapshot, `/api/mqtt` | `docs/mqtt-vectors/channels-*.json`, `docs/rest-api-vectors/mqtt-*.json` | 6, 11 | — | +| 13 | OSC | `tools/virtual-panel/transports/osc.ts` | 1, 2 | — | +| 14 | OSC vectors; `check:osc-vectors` | `docs/osc-vectors/`, `web/scripts/drift/_osc.ts`, `web/scripts/drift/osc-vectors.ts` | 3, 4, 13 | — | +| 15 | MIDI: the RTP-MIDI session and what comes in | `tools/virtual-panel/transports/midi.ts` | 1, 2 | — | +| 16 | MIDI: what goes out, `/api/midi` | `tools/virtual-panel/transports/midi-out.ts` | 15 | — | +| 17 | MIDI vectors in; the test initiator; `check:midi-vectors` | `docs/midi-vectors/README.md`, `docs/midi-vectors/in-*.json`, `web/scripts/drift/_rtpmidi.ts`, `web/scripts/drift/midi-vectors.ts` | 3, 4, 15 | — | +| 18 | MIDI vectors out, `/api/midi` | `docs/midi-vectors/out-*.json`, `docs/rest-api-vectors/midi-*.json` | 6, 16, 17 | — | +| 19 | the audio WebSocket and `/api/audio` | `tools/virtual-panel/transports/audio-ws.ts` | 1, 2 | — | +| 20 | audio vectors; `check:audio-vectors` | `docs/audio-ws-vectors/`, `docs/rest-api-vectors/audio-*.json`, `web/scripts/drift/_ws.ts`, `web/scripts/drift/audio-vectors.ts` | 3, 4, 6, 19 | — | +| 21 | `AGENTS.md`: the virtual panel, the vector folders, the commands | `AGENTS.md` | 1 to 20 | — | +| 22 | the docs that map the tree | `docs/README.md`, `docs/REPOSITORY.md`, `CONTRIBUTING.md`, `tools/README.md`, `tools/virtual-panel/README.md`, `web/ARCHITECTURE.md`, `web/README.md` | 1 to 20 | — | +| 23 | spec lines the vectors could not follow, once ruled on | `docs/rest-api.md`, `docs/midi-spec.md`, and each vector that quotes a line it changes | 6, 7, 11, 12, 14, 17, 18, 20 | — | +| 24 | close step 4 | `docs/rfc-cleanup-progress.md`, `docs/investigations/2026-10-doc-map.csv`, `docs/investigations/2026-10-doc-parity.csv` | every other k | — | + +Step 4 opens when step 3 is closed (§2.12). Step 2's runner (its k1) runs every +file in `web/scripts/drift/` from `check:ci`, and its workflow (k10) runs them +strict on every pull request, so a suite reaches CI by adding a file and no PR +edits `check:ci` or `.github/`. k2 adds every npm name the step needs to +`web/package.json`. Prose follows [voice.md](../voice.md) and goes through step +2's voice lint (its k9, `web/scripts/voice-lint.ts`); no PR edits text step 2's +lock (its k8, `web/scripts/human-lock.ts`) covers. Neither script is in the +tree today, and this plan does not write them again. + +A file sits in one row, with two exceptions that run in order. +`tools/virtual-panel/README.md` belongs to k2, then to k22. A vector that +quotes a line k23 corrects is edited by k23, which opens after every vector PR. +Rows that can be open at the same time share no file. Start with k1 and k2, +which wait on nothing; after k2, k3 and k5; after both, k4, k9, k13, k15 and +k19, inside §2.11's limit of five. A contract's vectors can be drafted beside +its transport and merge after it. Only k24 commits the doc-map CSVs: every +other PR runs `npm run doc-map` after `git fetch --tags`, quotes its two +summary lines and checks both CSVs out again. `AGENTS.md` gets a PR of its own +(k21). No PR edits `CHANGELOG.md`; each one's line rides in its description. + +## What the tree says + +1. The model. `docs/pfst-v2-vectors/` holds three real `.pfs` files. What they + should give is printed in `pfst-v2-spec.md` §5, and `check:pfstvectors` + runs an executable model of the spec's player rule over them, not the + firmware. Nothing replays the other five contracts. +2. The harness. `web/src/lib/harness/` does not exist. Patterns run in + `PatternRuntime` (`web/src/lib/pattern/harness.ts`), plain TypeScript with + no DOM. Beside it, `DeviceSim` (`web/src/lib/guide/deviceSim.ts`) already + plays the firmware rules the contracts lean on, for the guide's 3D board: + the bus (ten units a click, a 250 ms grace), the lanes (500 ms release, a + five-second hand hold), sleep, select, the Basics order. It keeps bus and + lane values private, and its one way to move a knob is `turn`, a hand. + `console_demo.py` (the guide's `pf-demo.js`) and `console_serve.py` also + answer HTTP; neither speaks UDP, and the second disagrees with the spec. +3. Ports and packages. HTTP on 80, the audio WebSocket on 81, OSC in on 9001 + with every reply sent to 9000 (`osc_config.h:38,41`), RTP-MIDI on 5004 and + 5005; for MQTT the panel is a broker's client. Linux keeps ports below 1024 + from an unprivileged process, and Actions runners are not root. The + lockfile has `ws` only as something two packages pull in, and no MQTT, OSC + or PNG package. Node 20, which CI runs, has no WebSocket server. +4. What a client can read. `knobs` in `/api/status` is the physical click count + (`patternflow.ino:1214`), so an OSC delta, MIDI CC 24 to 27, an MQTT + `knob/N` or an audio `d=` never shows there. `params`, `paramActive`, + `lanes`, `laneActive`, `active` and `sleep` do. A held MIDI note, an MQTT + `message` and everything the panel sends are seen only on the wire or on + the panel. `tools/rtpmidi-probe` asserts nothing, and its CC 24 step waits + for `knobs` to move. +5. REST against itself. `rest-api.md` says HTTP cannot write knobs (lines 596, + 604) and documents `POST /api/params` (160). It sends knob reads to + `/api/mqtt` (595) and to `/api/status` (321). It calls MIDI the only + absolute transport (604). Select answers with the state before the switch + (38), and its example with the pick (259). A `pN` is required (165) and + `dN` alone is allowed (178). Every error has a JSON body (610) and the 404 + a plain one (21). The clock is "Performance edition" (491) and "Utility + edition" (620). The caps line (110) leaves out `audio-in`, `clock` and + `midi`. +6. REST against the firmware. Undocumented: `rN=1` releases a channel, a `dN` + past ±100 is a 400, `/api/patterns/file` and `/api/knobs` have replies the + spec does not list, and status carries `variantVersion`, `hostAlias`, + `txDbm` and `nvs`. `/api/display` clamps neither `screen` nor `level`. +7. The other four against the firmware. OSC rounds a float half away from zero, + and an unknown address still teaches the panel its host. MIDI reads CC 24 + to 27 as binary offset (`core_midi.h:287`), as `midi-spec.md` line 57 says + and line 40 does not; CC 120, 121 and 123 clear held notes. An empty MQTT + `param/N` does not free a channel set in the last 250 ms + (`core_bus.h:107`), and any `knob/N` drops the hold and moves 16 at most. The + audio socket reads `a=-0.5` as a skipped slot and puts 0.5 on the next + lane, takes `off=x` as `off=0`, and frees lanes when the last client + leaves, not when a socket closes. Every delta starts the five-second hold + (`patternflow.ino:1333`), where `audio-ws-spec.md` line 47 says a physical + turn. MIDI over USB needs the S3's OTG controller, and a Node process has no + USB device side; `midi_test.cpp` (CI) checks that receive map natively. +8. Presets. `firmware/patternflow/presets/` has 35 headers: 34 with a JS twin, + and `calib`, firmware-only by `FIRMWARE_ONLY` in `check_presets.py`. + `DeviceSim` with the Basics pack runs the same 34. `basics.zip` carries each + module's sidecar, `absoluteReady` and knob names included. + +## Defaults this plan takes + +- D1. The virtual panel is `DeviceSim` behind sockets. A rule `DeviceSim` has + is not written again; what it lacks, k1 adds without changing what the guide + shows. Numbers come from `web/src/lib/shared/constants.ts` (step 3, k7), and + width and height from one place, so §2.7's parameterised harness changes one + line here. +- D2. No new dependency. Each protocol is the subset its spec names, on Node's + own `http`, `dgram`, `net`, `crypto` and `zlib`. The lockfile does not move. +- D3. Ports. Defaults are the firmware's numbers, so the browser extension or + an rtpMIDI driver can point at a virtual panel. Each takes an override, and + the suites pass 0 for any free port, so they run without root. +- D4. Time. `npm run virtual-panel` runs in real time. A suite starts the panel + on a manual clock and calls `advance(ms)`, which lets the sockets drain + first, so the 500 ms release and the eight-second snapshot take no waiting. +- D5. One seam, fixed by k2. A transport is a file in + `tools/virtual-panel/transports/`, attached when the caller asks for its + feature (REST always). It gets the `DeviceSim`, the clock, the HTTP routes, a + hook into `/api/status`, its cap, its ports, and events for a hand turn, a + button, a pattern change and sleep. A transport that needs more says so + under "for another k"; none edits `panel.ts`. +- D6. Fixture routes. `GET /frame.png` as §2.6 says, and under `/vp/` what no + contract reaches: a hand, the clock, and what the pattern is given and shows + (knob deltas, buttons, the banner, a thrown error). The firmware has none. +- D7. The vector format, fixed by k3 in `docs/contract-vectors.md`. One JSON + file per vector: `contract` (spec and anchor), `says` (the sentence it comes + from), `features`, and `steps`, each a `send` (`http`, `osc`, `midi`, + `mqtt`, `ws`, `hand`, `wait`) and an `expect`: `reply` (status, headers, + body), `status` (fields of `/api/status`), `out` (what the panel sent, and + how soon) or `input` (D6). A shape is a literal, a type such as + `""`, or an object naming the keys it needs; other keys pass + (`rest-api.md` line 5), and `""` forbids one. A vector whose `says` + is gone from its spec fails, Markdown and whitespace aside, so a spec edit + brings its vectors along. +- D8. Spec, not firmware. Vectors and the virtual panel follow the spec. Where + the spec contradicts itself (finding 5), the vector follows the section + that defines the route or message, and the PR names the other line for k23. + Where the firmware disagrees (findings 6 and 7), the vector keeps the spec. + Where the spec says nothing (`rN`, how a float rounds), there is no vector. +- D9. Issues. Each vector PR lists the firmware disagreements it encodes under + "for an issue": the vector, the spec line, the firmware line (question 26). +- D10. The suites are drift checks; a file starting with `_` is a library. A + suite starts a fresh panel per vector, talks to it only through its ports, + and prints both sides through `compare` in `_lib.ts`. Once k3 merges, + `_vectors.ts` is frozen, and a suite keeps its sender in its own `_` file. +- D11. Not replayed: Wi-Fi, the hotspot, firmware update, install, delete and + format (no volume), the microphone, the clock feature, shows, weather, MIDI + over USB. Each vector folder's README says so. +- D12. Branches, labels and descriptions as in the step 1 plan's D4. Vector JSON + is written by hand and counts toward the 300 lines. `typecheck` and `lint` + read `web/` only; k2 adds `tools/virtual-panel/` to `typecheck`, not `lint`. + +## The PRs in detail + +k1 touches `web/src/`, so it runs `typecheck`, `lint`, `check:ci` and +`check:panels` (§2.1). Every other PR runs `typecheck` and its own check, and +quotes one failing run made on a throwaway edit, reverted before commit. No PR +touches `firmware/`. + +k1. `DeviceSim` gains reads of the bus values and holds, the lane values and +the click counts; a knob delta that is not a hand, which takes no lane and +frees no channel (the specs give that to a hand, `mqtt-spec.md` line 38, +`midi-spec.md` line 40); the release of one channel (an empty `param/N`) and +of one lane (`off=N`); and a lane source for the WebSocket. Proof: +`deviceSim.test.ts` passes as it was, a new case covers each addition, and +`check:sandbox-sync` shows the sandbox unchanged. + +k2. `panel.ts` holds `start(options)`: the features (none is the default +build), the pack (Basics), a port per transport, the clock. It returns the +bound ports, `advance(ms)` and `close()`; `main.ts` takes the same as flags. +`http.ts` serves one request at a time (`rest-api.md` line 18), reads the query +and a form body only (26), and gives anything unrouted the plain-text 404 with +`no-store` (21). `tsconfig.json` extends `web/tsconfig.json`, `@/` mapped. +In `web/package.json`: `virtual-panel` (`tsx ../tools/virtual-panel/main.ts`), +`tsc --noEmit -p ../tools/virtual-panel` in `typecheck`, and +`check:rest-vectors`, `check:mqtt-vectors`, `check:osc-vectors`, +`check:midi-vectors`, `check:audio-vectors` and `check:virtual-panel`, each +`tsx scripts/drift.ts `, which fails and says why until its file lands. +Proof: `/frame.png` shows Origin, and a `/vp/turn` changes the next one. + +k3. `docs/contract-vectors.md` is D7 with one example per transport. +`_vectors.ts` reads a folder, starts a panel per vector, runs its steps with +its own senders (`http`, `hand`, `wait`) and those a suite passes in, matches +shapes, checks `says`, and prints both sides. + +k4 and k5. `rest-state.ts`: `GET /api/status` with every field the table +documents (`rest-api.md` lines 72 to 112), caps from its features, and what +transports add; `/api/params`, `/api/sleep`, `/api/display`, `/api/knobs`, +`DELETE /api/crash` (its 404) and the favicon. `rest-patterns.ts`: +`GET /api/patterns` (Origin as the preset, the rest as modules), select by +`index`, `name` and `step` with its 404 and CORS, and `/api/patterns/file` for +`ext=json` (the sidecars in `basics.zip`, read as `console_demo.py` reads +them) and `ext=thumb` (`PFT1`, the last frame in RGB565). + +k6 and k7. k6: `/api/status` (the documented keys, by type), `/api/params` (a +subset, the reply, a hold a hand takes back), `/api/sleep` (each spelling of +`on`, the 400 and its text, the state before the switch), the README and +`rest-vectors.ts`. k7: display, knobs, crash, the favicon, the 404, +`no-store`, the pattern routes, and `absent-*`: on the default build +`/api/mqtt`, `/api/midi` and `/api/audio` answer 404 and `caps` is +`["patterns","params","sleep"]` (lines 110, 613). + +k8. `virtual-panel.ts` lists the headers in `firmware/patternflow/presets/`, +drops `FIRMWARE_ONLY` as `check_presets.py` names it, and needs the rest on the +panel's list. It selects each over REST, advances two seconds, reads a 128×64 +PNG, and fails on a thrown error `/vp/state` reports. + +k9 to k12. `mqtt.ts` is an MQTT 3.1.1 client at QoS 0 with retain, keepalive +and no wildcards (`mqtt-spec.md` line 12), the roles and channels (13, 14), +the topics (22 to 28), and the eight-second snapshot. `mqtt-http.ts` is +`/api/mqtt` (`rest-api.md` lines 343 to 367) and the `mqtt` fields in status. +`_broker.ts` is enough of a broker to connect, subscribe, publish and retain. +k11: the topics in each role, the words `sleep` takes, `param/N` and its empty +payload, `knob/N` as a difference, `pattern` by name or slug. k12: channels 1 +to 4 forcing Subscriber, Broadcast taking no snapshot, a snapshot's `param` +overwriting a write, and `/api/mqtt`. + +k13 and k14. `osc.ts` listens on 9001 and replies to the sender's address on +9000: `i` and `f` arguments, 256 bytes, no `#bundle`, eight datagrams a frame, +the announce burst, the heartbeat, a hand's `delta` and `clicks`, buttons and +pattern changes. `_osc.ts` packs and reads OSC. The vectors: a ping and its +burst, a delta as an int and as a float any rounding settles (2.6), a pattern +index, `content/toggle` doing nothing, an unknown address, a `#bundle`. + +k15 to k18. `midi.ts` is the session listener on 5004 and 5005 (invitation, +clock sync, `BY`), RTP-MIDI's command list, the channel rule and the receive +table (`midi-spec.md` lines 39 to 42). `midi-out.ts` is the send table (57 to +60), with `outDiv`, `outMul` and the 50 ms pacing, plus `/api/midi` and `midi` +in status. `_rtpmidi.ts` is an initiator shaped like `rtpmidi_probe.py`. In: +CC 20 at 0, 1, 64 and 127 (0, 8, 504, 1000), CC 24 at 65 and 63, where lines +40 and 57 agree, notes 60 to 63, Program Change, another channel. Out: a +hand's turn in `abs` and `rel`, the pacing, Program Change, no echo. + +k19 and k20. `audio-ws.ts` is a WebSocket server on 81, the grammar in +`audio-ws-spec.md` lines 27 to 31, the 500 ms release, `audioRuntime` and +`/api/audio`; `_ws.ts` a client for Node 20. The vectors: `a=` with `-` slots, +`k=` clamped, `d=`, `off=N` and `off`, an unknown prefix, a lane let go 500 ms +after its last message and kept while it is resent, a hand taking a lane for +five seconds, and messages ignored while `audioRuntime` is off. + +k21 and k22. `AGENTS.md` maps `tools/virtual-panel` and a vector folder beside +each contract (adding the missing `mqtt-spec.md`) and lists the new commands; +it runs `check_versions.py`, which parses the file. k22: `docs/README.md` +names each contract's vectors as it names pfst's, and the format; +`docs/REPOSITORY.md` and `tools/README.md` name the virtual panel, the one +thing in `tools/` that is not a client, and `tools/virtual-panel/README.md` +lists its transports; `CONTRIBUTING.md`'s wire-contract row says a spec change +moves its vectors; `web/ARCHITECTURE.md` (line 121) and `web/README.md` name +the suites. + +k23. Only for the lines the maintainer rules on (question 27): it corrects the +losing line in `rest-api.md` or `midi-spec.md`, adds a version-history row, and +re-quotes each vector that quoted it. A docs fix, reviewed as one. + +k24. After every other k: regenerate both CSVs, check §2.6's "done when" (five +suites in CI against the virtual panel, every preset running) and §2.12's +third item (REST and MQTT, the full preset list), and write step 4 into +`docs/rfc-cleanup-progress.md` the way step 0 is written. + +## Questions for the maintainer + +Numbered on from the step 3 plan's 25, each with the default the PRs use. + +26. Who opens the issues the vector PRs list (D9)? A session scoped to the fork + cannot write upstream. Default: you, from each PR's list. +27. Where the spec contradicts itself, does the route's own section win (D8)? + Select is the sharpest case: line 38 says the old state, and the example + and the firmware give the new pick. Default: yes, and k23 fixes the other. +28. The five-second hold: the spec gives it to a hand, the firmware to any + delta (finding 7). An integration tested against the virtual panel meets a + different panel there. Default: an issue, and the spec stands. +29. The suites reach the virtual panel only through its ports, so a + `--panel
` could walk a real device too, except for vectors that + use `/vp/` or the manual clock. Default: not in this step. diff --git a/docs/investigations/2026-10-cleanup-step5-plan.md b/docs/investigations/2026-10-cleanup-step5-plan.md new file mode 100644 index 000000000..eb5e53589 --- /dev/null +++ b/docs/investigations/2026-10-cleanup-step5-plan.md @@ -0,0 +1,289 @@ +# Cleanup step 5: the plan + +*For [rfc-cleanup.md](../rfc-cleanup.md) §2.7. Scouted on 15 October 2026 at +`d4668ad` on `dev`, after the step 4 plan merged and while step 1 was closing. +Nothing here is implemented.* + +§2.7 gives step 5 no code. It asks for two RFC drafts, Part 2 only: +`docs/rfc-any-resolution.md`, the firmware path, and `docs/rfc-linux-player.md`, +the player path. Each ends with questions for the maintainer, and §2.12 counts +both toward the gate (its item 6). Step 5 is also the one step that runs beside +others: it may start once step 2 is merged, while steps 3 and 4 are still open. +Two things in the tree shape the drafts. The panel size is set or assumed in far +more places than the 36 §2.7 counts (finding 1). And the site already runs a +pattern at any size, while the device and the site's simulator of it do not +(findings 2 and 6). This plan cuts the step into four PRs. + +## The PRs + +| k | what | files it may edit | merges after | §2.13 | +| --- | --- | --- | --- | --- | +| 1 | where the panel size lives, as a record | `docs/investigations/2026-10-panel-geometry.md` | — | — | +| 2 | the any-resolution draft | `docs/rfc-any-resolution.md` | 1 | 3, 5 | +| 3 | the Linux player draft | `docs/rfc-linux-player.md` | 1 | 3, 5 | +| 4 | close step 5 | `docs/rfc-cleanup-progress.md`, `docs/README.md`, `docs/investigations/2026-10-doc-map.csv`, `docs/investigations/2026-10-doc-parity.csv`; in `docs/rfc-any-resolution.md` and `docs/rfc-linux-player.md`, the one line naming the other draft | 1, 2, 3; and step 3's k27 and step 4's k22 and k24 if one is open | — | + +Step 5 opens when step 2 is closed (§2.12), so its PRs can be open beside step +3's and step 4's. k1, k2 and k3 each make a new file and share none with any PR +of any step. k1 waits on nothing. k2 and k3 can be written beside it and merge +after it, because both link its record. Three people can work at once, one per +row, inside §2.11's limit of five open cleanup PRs, counted across steps. + +The shared places belong to k4: `docs/README.md`, the progress log and the two +doc-map CSVs. Step 3's k27 and step 4's k22 and k24 edit the same files, so k4 +opens only when none of them is open. Until k4, each draft names the other in +backticks, and k4 turns both mentions into links. That line in each draft is the +one exception to a file sitting in one row, and it runs in order. Every other PR +runs `npm run doc-map` after `git fetch --tags`, quotes its two summary lines +and checks both CSVs out again. + +No PR here edits `AGENTS.md`, `CHANGELOG.md`, `.github/` or `web/package.json`. +A draft changes no command, no path an agent works in and no workflow, and +`docs-links.yml` and step 2's checks already read a new file under `docs/`. A PR +that finds it needs one of them writes the change under "for another k", and the +change becomes a PR of its own after k4. Each PR's changelog line rides in its +description. Prose follows [voice.md](../voice.md) and goes through step 2's +voice lint (its k9, `web/scripts/voice-lint.ts`). Part 1 of every RFC is text +step 2's lock covers (its k8, `web/scripts/human-lock.ts`), and no PR here +writes any. Neither script is in the tree today, and this plan does not write +them again. + +## What the tree says + +1. The count. §2.7's 25, four and seven count occurrences of `PANEL_RES_W` and + `PANEL_RES_H`, not lines. Under `src/` the 25 sit on 21 lines in six files, + and eight of them are in comments (`core_display.h:18`, `core_mem.h:17`, + four in `core_tables.h`). In `features/` the four are the clock's JSON and + the weather preset. In `abi/` two of the seven are comments. The count leaves + out where the size is set (`config.h`, 11), the presets (142 across all 35 + headers, with arrays sized by it in `preset_0629_2.h` and `preset_calib.h`), + `PFCanvas::W` and `H` (`core_canvas.h:38`, aliased again in + `core_thumbs.h:44`) and the 24 KB canvas buffer they size + (`core_canvas.h:40`). It also leaves out the menus, which ask the driver + (`dma_display->width()`, `core_ui_text.h`), and four sizes typed as numbers: + `core_ui_text.h:16`, `preset_weather.h:491`, `console/clock.html:414` and + `preset_0712_2.h:60`. The module toolchain types 128 and 64 too + (`build_module.py:51`). +2. Geometry today is chosen at compile time. `config.h:81-108` offers three: + one 128×64 module (the default), one 64×64, and two 64×64 chained into + 128×64, which `platformio.ini` builds as `firmware64x2`. Each sets the + module size, the chain and the canvas, and two `#error`s hold the canvas to + module width times chain. The driver IC is a separate line, `PANEL_PROFILE` + (`config.h:168`). One call hands the geometry to the driver + (`core_display.h:20`). A pattern already declares its own grid, with + `setFrame` on the device and `// @matrix` on the site, and both put it on the + panel directly, rotated or letterboxed by the same rule (`matrix.ts:127` + says to keep the two in step). The panel is what no setting changes without a + rebuild. +3. Modules. A `.pfm` is ESP32-S3 machine code built for one size. + `pf_module.h:61-72` stops a build that lacks `-DPF_PANEL_W` and `H`, and the + host and the module each refuse the other at a different size + (`pf_module.h:312`, `core_module_loader.h:1145`). Both ABI structs already + carry `panel_w` and `panel_h` (`pf_abi.h:82` and `117`), and the ABI is + frozen at 1, append only. The cache key is `(source_sha, builder_rev)` + (`web/src/lib/community/server/schema.ts:725`). The size reaches it only + because `builder_rev` hashes `build_module.py`, where 128 and 64 are typed. + The sidecar records `panel_w` and `panel_h`, and the device never reads them. +4. Memory. The driver allocates its row buffers once, at `begin()`, in internal + DMA RAM (`MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA`, + `hub75/ESP32-HUB75-MatrixPanel-I2S-DMA.h:195`). `SPIRAM_DMA_BUFFER` is not + set anywhere. A row takes width × colour depth × 2 bytes (`.h:168`), the + depth is the default 8, and the display is double-buffered + (`core_display.h:22`). By that formula 128×64 at 1/32 scan takes 128 KB, a + figure the tree does not state. When a size does not fit, `begin()` fails with + "Not enough memory" (`.cpp:123`). The vendored blit adds limits of its own: + an even width and depth 8 for the fast path, and a timing argument worked out + for 128 words at 16 MHz (`hub75/VENDORED.md:147-188`). +5. Panels. `config.h:153-160` and + [panel-compatibility.md](../panel-compatibility.md) sort driver ICs into three + tiers. Plain shift-register panels and panels that need an init sequence + work. S-PWM panels (ICN2053, FM6353, MBI5051 and the rest) stay dark whatever + is set, because they need a grey-scale clock the library cannot make + (upstream issue 642, closed wontfix). They are built to be fed by a Novastar, + Huidu or Colorlight receiving card, which is one of the outputs §2.7 gives + the Linux player. +6. The site. `PatternRuntime` (`web/src/lib/pattern/harness.ts`) imports + nothing and touches no DOM. It compiles a pattern with `new Function` and + writes an RGBA buffer, and its constructor takes a width and a height, 128 + and 64 by default (lines 1, 2 and 486). `// @matrix` takes any size from 8 to + 512 (`matrix.ts:46`), and the sandbox resizes to it per pattern. 47 of the 48 + JS presets draw from `display.width` and `display.height`, and two of them, + `pattern-0719.ts` and `pattern-0712-2.ts`, also type a size of their own. + Fixed at 128×64: `DeviceSim` (through `panelScreens.ts:42`), the 3D panel's + texture and shader (`LedMatrixTexture.ts:20`, + `components/3d/patterns/common.ts:55`), the guide's preview + (`Extras.tsx:185`) and the AI prompts (`gemini.ts:226`). + `web/src/lib/harness/`, the folder §2.6 names, does not exist (step 4's + finding 2). +7. Steps not yet merged. The any-resolution draft leans on two things that are + only planned: `shared/constants.json`, which will hold the three geometries + (step 3, k7), and the virtual panel, `DeviceSim` behind sockets with its width + and height in one place (step 4, k2 and D1). Step 3's k8 keeps every use of + `PANEL_RES_W` and `PANEL_RES_H` so that step 5 can count them. None of it is + on `dev` today, and step 5 may start before any of it is. +8. Linux. Nothing in the tree runs on Linux, and nothing outside §2.7 names + rpi-rgb-led-matrix or HDMI. Two builds on the site already run Patternflow on + a Raspberry Pi: one with a 64×32 matrix (`InsideGlobe/builds.ts:130`), one a + Pi 4 with a HUB75 HAT and its own firmware (`builds.ts:333`). A `.pfm` loads + only through the device's relocator, and the flasher installs an ESP32-S3 + image with ESP Web Tools (`web/public/flash/manifest.json`), so neither + crosses over, as §2.7 says. Knobs over the web today are the console's four + sliders posting to `/api/params` (`console/home.html:112`). The site's knobs + drive only its own preview, and no page asks for Web MIDI. +9. Contracts. Of the five, only `rest-api.md` states a size: + `"panel": "128x64"` in status (line 48), the thumbnail as a 128×64 frame + (274), the sidecar's `panel_w` and `panel_h` (284) and `/api/clock`'s `w` + and `h` (499). + OSC, MIDI, MQTT and the audio socket never name one. A device at another size + changes REST and nothing else on the wire. +10. Settings. No console page stores a panel setting. The nearest is `/knobs` + (`core_knobs_http.h`), which changes a hardware property without a rebuild + and keeps it in NVS under `pf_knobs` (`core_encoders.h:102`). Brightness and + the power budget are kept the same way. Because the DMA buffers are + allocated at `begin()`, a geometry chosen on a page would take effect at the + next boot. +11. The form. `rfc-cleanup.md` opens with a dated line, a status block and a + paragraph saying Part 1 is the maintainer's and Part 2 the specification. + §2.9 locks Part 1 of every RFC under `docs/`. Step 2's lock passes a new + locked file and fails any later change to it (its D5), so Part 1 written into + a draft after it merges turns `check:human-lock` red. Its finding 10 names + Part 1 of two RFCs. Whether it reads a third depends on k8 listing files or + reading every `docs/rfc-*.md`. + +## Defaults this plan takes + +- D1. The form. Each draft takes `rfc-cleanup.md`'s frame: the title, a dated + line, a status block that says "Draft", the paragraph on Part 1 and Part 2 + copied as written, `# Part 1 — Why` with nothing under it, then + `# Part 2 — The specification` in numbered sections, the last one "Open + questions for the maintainer". The any-resolution status block names §2.12's + gate as what moves it to proposed. The player's names nothing (question 34). + Neither draft goes into `docs/.human-written` (answer 5): §2.9 already locks + Part 1 of every RFC. +- D2. The inventory is a record. k1 lists every place the size is set or + assumed, by file and line, with the SHA and the `git grep` behind each count. + §2.7's three numbers keep rows of their own, so a reader can find 25, four and + seven. The any-resolution draft carries a table per file and links the record + (question 31). +- D3. The tree at the PR's SHA, plans for the rest. Facts come from `dev` when + the PR opens. Where a draft leans on something not yet merged (finding 7), it + names the plan and its k, writes the path in backticks, and says what changes + for the draft if that plan changes. +- D4. No implementation. No code, no prototype branch, no build to measure. A + number the tree does not state, like the DMA bytes for a size, is worked from + the driver's formula with its file and line, and marked as arithmetic. +- D5. Estimates in this cleanup's units. Each proposal section ends with the + files it would touch, how many PRs that is by §2.1's rule (300 lines, one + concern), and whether those files are the core (hard rule 7), the frozen ABI + (append only), a feature or the site. That is the unit the maintainer already + reviews in. +- D6. Words. "Geometry" is `config.h`'s word for module size and chain. "Size" + is the canvas, width × height. The player is a program, not an edition: + edition, feature and core mean what `EDITIONS.md` says they mean. +- D7. Branches, labels and descriptions as in the step 1 plan's D4, with the + label `step-5`. + +## The PRs in detail + +Every PR touches only `docs/`, so the commands in §2.1(1) do not apply. Each runs +`python .github/scripts/check_links.py`, `npm run doc-map` (CSVs checked out +again), `npm run check:voice` on what it adds and `npm run check:human-lock`, +and quotes the output. A person reads each draft before it merges (§2.10). + +k1. `docs/investigations/2026-10-panel-geometry.md`, about 200 lines. A header +gives the SHA and the commands. Then a table per place, with file, line, code or +comment, and what the line does with the size: the 36 occurrences §2.7 counts; +`config.h`; `PFCanvas` and what reads it; the menus; the presets, one row per +file with its count and its arrays; the sizes typed by hand; the module +toolchain and the cache key (finding 3); HTTP and the specs (finding 9); the +driver's allocation and the blit's limits (finding 4); and the site (finding 6). +It proposes nothing. Proof: each count, run again from its quoted command, gives +the same number. + +k2. `docs/rfc-any-resolution.md`, Part 2, under 300 lines, in sections sized by +D5: + +- What changes and what stays: one ESP32-S3 and one chain; the stock 128×64 + build unchanged; `setFrame` and `// @matrix` as they are. +- Where the size lives: the table per file from k1, linked. +- The runtime geometry: a struct of canvas size, module size, chain and driver + profile, filled from `config.h`'s values as compile-time defaults and from NVS + at boot, before `begin()`. Which of the 36 read it instead of the macro, which + stay compile-time and why (the presets' arrays, the macro in the frozen ABI), + and what becomes an allocation (the canvas buffer). +- Modules: one build per size, `build_module.py` taking the size, the cache key + gaining W×H as §2.7 asks, the schema migration that costs and the cheaper way + (question 33), and what the device shows for a module built at another size. +- The console page: a core page, since the panel is the device (hard rule 7), + kept in NVS like `pf_knobs` and applied at the next boot. It offers only + geometries that fit the memory bound, and it says how to get back when a + geometry leaves the panel dark. `/api/status`'s `panel` and the other REST + lines in finding 9. +- The site: `PatternRuntime` already takes a size. What remains is `DeviceSim`, + the 3D panel and the previews (finding 6), with `shared/constants.json` as + their source (step 3, k7). +- The virtual panel at other sizes: its size from step 4's one place, + `/frame.png` at that size, `/api/status` reporting it, REST first (answer 3), + and `check:virtual-panel` run at a second size. +- The limits, stated as limits: classic HUB75 only (tiers 1 and 2), S-PWM + unsupported (finding 5), the memory bound with the formula and a table of the + sizes and depths that fit, and the blit's even width and timing (finding 4). +- The order of the work and its PR count. +- Open questions. + +k3. `docs/rfc-linux-player.md`, Part 2, under 300 lines, in sections sized by +D5: + +- What it is: a Linux process that plays patterns with the site's engine onto a + panel or a screen. Not firmware, and not an edition. +- The engine: `PatternRuntime` under Node (finding 6), the JS presets and the + Basics order, community patterns as JS, and the size taken from the output. +- The outputs, each with what it needs and what it costs. rpi-rgb-led-matrix on + a Pi's GPIO or a HAT, and how a Node process reaches a C++ library (an addon, + a child process or a pipe). A Colorlight-class receiving card over raw + Ethernet, which also lights S-PWM panels and LED walls (finding 5). HDMI. +- The inputs: USB encoders, MIDI (ALSA and RTP-MIDI), OSC on 9001, and the web + knobs as the console has them, sliders posting to `/api/params` (finding 8). +- The contracts: which of the five it speaks and in what order, REST first + (answer 3). What it can share with step 4's virtual panel, which speaks all + five in Node over `DeviceSim`, and what it must not take from it (the `/vp/` + routes, the manual clock). +- What it cannot do: no `.pfm`, no browser flash, and none of the C++ features + (weather, clock, shows, the microphone) unless they are written again. +- Where it lives and how it ships, with the two Pi builds on the site as prior + work (finding 8, question 34). +- The order of the work and its PR count. +- Open questions. + +k4. After k1, k2 and k3, and while none of step 3's k27 or step 4's k22 and +k24 is open: check §2.7's "done when" (both files, every section D5 asks for, +open questions last) and §2.12's sixth item. Turn each draft's backtick mention +of the other into a link. Give `docs/README.md` one line for each draft. +Regenerate both CSVs. Write step 5 into `docs/rfc-cleanup-progress.md` the way +step 0 is written, and mark item 6 met in the gate line. If step 2's lock does +not read the drafts' Part 1 (finding 11), the entry says so, and the fix to +`web/scripts/human-lock.ts` is a PR of its own. + +## Questions for the maintainer + +Numbered on from the step 4 plan's 29. Each has the default the PRs use until +there is an answer. + +30. Part 1 of each draft stays empty until you write it. Written after the draft + merges, it turns `check:human-lock` red (finding 11). Default: you write it + on k2's or k3's branch before merge, which the lock reads as a new file; + otherwise step 2's question 13 applies. +31. The inventory as a record of its own, with the draft carrying a table per + file (D2)? §2.7 puts the inventory in the draft. Default: yes. +32. Which sizes does the any-resolution draft cost the work for? `config.h` has + three geometries, the roadmap names 64×32, 128×64 and a commercial sign, and + the site takes anything from 8 to 512. Default: what one ESP32-S3 can drive + by finding 4's formula. Walls and signs go to the player. +33. §2.7 extends the cache key with W×H. The size already moves `builder_rev` + through `build_module.py`, and a new column in the key is a migration of the + community database. Default: the draft proposes the column, costs the + migration and names the other way beside it. +34. Where does the player live: in this repository, or in its own, as the Home + Assistant integration now does? And does it move to proposed with + any-resolution at the gate? Default: the draft sets out both homes and picks + neither, and it stays a draft until you say. diff --git a/docs/investigations/2026-10-doc-map.csv b/docs/investigations/2026-10-doc-map.csv new file mode 100644 index 000000000..0017a3b3f --- /dev/null +++ b/docs/investigations/2026-10-doc-map.csv @@ -0,0 +1,1145 @@ +from,to,kind,anchor,resolves +.github/CODE_OF_CONDUCT.md,.github/SECURITY.md,relative,,yes +.github/CODE_OF_CONDUCT.md,docs/manifesto.md,relative,6-how-we-treat-each-other,yes +.github/FUNDING.yml,/,site,,yes +.github/ISSUE_TEMPLATE/config.yml,.github/SUPPORT.md,absolute-blob,,yes +.github/ISSUE_TEMPLATE/config.yml,CONTRIBUTING.md,absolute-blob,,yes +.github/ISSUE_TEMPLATE/docs_issue.yml,/build,site,,yes +.github/ISSUE_TEMPLATE/share_build.yml,/inside,site,,yes +.github/ISSUE_TEMPLATE/share_build.yml,CONTRIBUTING.md,absolute-blob,your-build,yes +.github/PULL_REQUEST_TEMPLATE.md,CONTRIBUTING.md,absolute-blob,where-your-contribution-goes,yes +.github/SECURITY.md,/editions,site,,yes +.github/SECURITY.md,/pattern,site,,yes +.github/SECURITY.md,/update,site,,yes +.github/SECURITY.md,docs/rest-api.md,relative,transport,yes +.github/SUPPORT.md,.github/SECURITY.md,relative,,yes +.github/SUPPORT.md,/guide,site,,yes +.github/SUPPORT.md,/guide/play,site,flash,yes +.github/SUPPORT.md,/inside,site,,yes +.github/SUPPORT.md,/pattern,site,,yes +.github/SUPPORT.md,/pattern-lab,site,,yes +.github/SUPPORT.md,BUILD_GUIDE.md,relative,8-firmware,yes +.github/SUPPORT.md,CONTRIBUTING.md,relative,,yes +AGENTS.md,docs/EDITIONS.md,relative,,yes +AGENTS.md,docs/rfc-cleanup.md,relative,,yes +BUILD_GUIDE.md,/,site,,yes +BUILD_GUIDE.md,/editions,site,,yes +BUILD_GUIDE.md,/guide/build,site,,yes +BUILD_GUIDE.md,/guide/play,site,,yes +BUILD_GUIDE.md,/guide/play,site,flash,yes +BUILD_GUIDE.md,/guide/play,site,patterns,yes +BUILD_GUIDE.md,PATTERN_GUIDE.md,relative,,yes +BUILD_GUIDE.md,docs/EDITIONS.md,relative,,yes +BUILD_GUIDE.md,docs/build-guide/images/esp32_insert.jpg,relative,,yes +BUILD_GUIDE.md,docs/build-guide/images/v3/01_printing.jpg,relative,,yes +BUILD_GUIDE.md,docs/build-guide/images/v3/02_printed_parts.jpg,relative,,yes +BUILD_GUIDE.md,docs/build-guide/images/v3/03_glue_mount.jpg,relative,,yes +BUILD_GUIDE.md,docs/build-guide/images/v3/04_seam_gap.jpg,relative,,yes +BUILD_GUIDE.md,docs/build-guide/images/v3/06_usbc_bad.jpg,relative,,yes +BUILD_GUIDE.md,docs/build-guide/images/v3/07_usbc_good.jpg,relative,,yes +BUILD_GUIDE.md,docs/build-guide/images/v3/16_power_path_compare.jpg,relative,,yes +BUILD_GUIDE.md,docs/build-guide/images/v3/17_screw_terminal_wiring.jpg,relative,,yes +BUILD_GUIDE.md,docs/build-guide/images/web_flash.jpg,relative,,yes +BUILD_GUIDE.md,docs/legacy/BUILD_GUIDE_v2.md,relative,,yes +BUILD_GUIDE.md,docs/midi-ableton.md,relative,,yes +BUILD_GUIDE.md,docs/panel-compatibility.md,relative,,yes +BUILD_GUIDE.md,firmware/CUSTOM_PATTERNS.md,relative,,yes +BUILD_GUIDE.md,firmware/README.md,relative,,yes +BUILD_GUIDE.md,hardware/bom/README.md,relative,,yes +BUILD_GUIDE.md,hardware/bom/bom_v3.0.csv,relative,,yes +BUILD_GUIDE.md,hardware/bom/bom_v3.9.csv,relative,,yes +BUILD_GUIDE.md,hardware/case,relative,,yes +BUILD_GUIDE.md,hardware/case/bed_256mm/patternflow_v3.3mf,relative,,yes +BUILD_GUIDE.md,hardware/case/remixes/besoiobiy-printed/README.md,relative,,yes +BUILD_GUIDE.md,hardware/case/remixes/simonepda-lasercut/README.md,relative,,yes +BUILD_GUIDE.md,integrations/ableton,relative,,yes +CHANGELOG.md,/guide,site,,yes +CHANGELOG.md,/guide/build,site,,yes +CHANGELOG.md,/guide/make,site,,yes +CHANGELOG.md,/guide/play,site,,yes +CHANGELOG.md,docs/releases,relative,,yes +CHANGELOG.md,docs/releases/v1.0.0.md,relative,,yes +CHANGELOG.md,docs/releases/v1.1.0.md,relative,,yes +CHANGELOG.md,docs/releases/v2.0.0.md,relative,,yes +CHANGELOG.md,docs/releases/v2.1.0.md,relative,,yes +CHANGELOG.md,docs/releases/v3.0.0.md,relative,,yes +CHANGELOG.md,docs/releases/v3.1.0.md,relative,,yes +CHANGELOG.md,docs/releases/v3.10.0.md,relative,,yes +CHANGELOG.md,docs/releases/v3.10.1.md,relative,,yes +CHANGELOG.md,docs/releases/v3.10.2.md,relative,,yes +CHANGELOG.md,docs/releases/v3.10.3.md,relative,,yes +CHANGELOG.md,docs/releases/v3.2.0.md,relative,,yes +CHANGELOG.md,docs/releases/v3.3.0.md,relative,,yes +CHANGELOG.md,docs/releases/v3.4.0.md,relative,,yes +CHANGELOG.md,docs/releases/v3.4.9.md,relative,,yes +CHANGELOG.md,docs/releases/v3.5.0.md,relative,,yes +CHANGELOG.md,docs/releases/v3.5.1.md,relative,,yes +CHANGELOG.md,docs/releases/v3.5.2.md,relative,,yes +CHANGELOG.md,docs/releases/v3.6.3.md,relative,,yes +CHANGELOG.md,docs/releases/v3.7.0.md,relative,,yes +CHANGELOG.md,docs/releases/v3.7.1.md,relative,,yes +CHANGELOG.md,docs/releases/v3.8.0.md,relative,,yes +CHANGELOG.md,docs/releases/v3.9.0.md,relative,,yes +CHANGELOG.md,docs/releases/v3.9.1.md,relative,,yes +CHANGELOG.md,docs/releases/v3.9.2.md,relative,,yes +CHANGELOG.md,docs/releases/v3.9.3.md,relative,,yes +CHANGELOG.md,docs/releases/v3.9.4.md,relative,,yes +CHANGELOG.md,docs/releases/v3.9.5.md,relative,,yes +CONTRIBUTING.md,.github/CODE_OF_CONDUCT.md,relative,,yes +CONTRIBUTING.md,.github/SUPPORT.md,relative,,yes +CONTRIBUTING.md,/guide,site,,yes +CONTRIBUTING.md,/inside,site,,yes +CONTRIBUTING.md,/pattern-lab,site,,yes +CONTRIBUTING.md,AGENTS.md,relative,hard-rules-do-not-violate,yes +CONTRIBUTING.md,FEATURE_GUIDE.md,relative,,yes +CONTRIBUTING.md,LICENSE-CC-BY-SA,relative,,yes +CONTRIBUTING.md,LICENSE-MIT,relative,,yes +CONTRIBUTING.md,PATTERN_GUIDE.md,relative,,yes +CONTRIBUTING.md,docs/EDITIONS.md,relative,,yes +CONTRIBUTING.md,docs/LICENSE-SUMMARY.md,relative,,yes +CONTRIBUTING.md,docs/RELEASING.md,relative,,yes +CONTRIBUTING.md,docs/REPOSITORY.md,relative,,yes +CONTRIBUTING.md,docs/audio-ws-spec.md,relative,,yes +CONTRIBUTING.md,docs/contract-vectors.md,relative,,yes +CONTRIBUTING.md,docs/midi-spec.md,relative,,yes +CONTRIBUTING.md,docs/mqtt-spec.md,relative,,yes +CONTRIBUTING.md,docs/osc-spec.md,relative,,yes +CONTRIBUTING.md,docs/pfst-v2-spec.md,relative,,yes +CONTRIBUTING.md,docs/rest-api.md,relative,,yes +CONTRIBUTING.md,docs/rfc-cleanup.md,relative,29-human-written-text-lock,yes +CONTRIBUTING.md,firmware/bundles/README.md,relative,,yes +CONTRIBUTING.md,firmware/patternflow/features/README.md,relative,,yes +CONTRIBUTING.md,tools/virtual-panel/README.md,relative,,yes +CONTRIBUTING.md,web/ARCHITECTURE.md,relative,,yes +CONTRIBUTING.md,web/README.md,relative,,yes +CONTRIBUTING.md,web/src/components/sections/InsideGlobe/builds.ts,relative,,yes +FEATURE_GUIDE.md,.github/workflows/console-sync.yml,relative,,yes +FEATURE_GUIDE.md,.github/workflows/firmware-build.yml,relative,,yes +FEATURE_GUIDE.md,.github/workflows/firmware-checks.yml,relative,,yes +FEATURE_GUIDE.md,AGENTS.md,relative,hard-rules-do-not-violate,yes +FEATURE_GUIDE.md,CONTRIBUTING.md,relative,,yes +FEATURE_GUIDE.md,docs/EDITIONS.md,relative,,yes +FEATURE_GUIDE.md,docs/audio-ws-spec.md,relative,,yes +FEATURE_GUIDE.md,docs/midi-spec.md,relative,,yes +FEATURE_GUIDE.md,firmware/bundles/README.md,relative,,yes +FEATURE_GUIDE.md,firmware/bundles/audio,relative,,yes +FEATURE_GUIDE.md,firmware/patternflow/features/README.md,relative,,yes +FEATURE_GUIDE.md,firmware/patternflow/features/audio_in,relative,,yes +FEATURE_GUIDE.md,firmware/patternflow/features/osc,relative,,yes +FEATURE_GUIDE.md,firmware/patternflow/features/show,relative,,yes +PATTERN_GUIDE.md,/community,site,,yes +PATTERN_GUIDE.md,/guide/make,site,,yes +PATTERN_GUIDE.md,/guide/play,site,flash,yes +PATTERN_GUIDE.md,/pattern,site,,yes +PATTERN_GUIDE.md,/update,site,,yes +PATTERN_GUIDE.md,BUILD_GUIDE.md,relative,,yes +PATTERN_GUIDE.md,BUILD_GUIDE.md,relative,8-firmware,yes +PATTERN_GUIDE.md,docs/images/pattern-guide/01-landing-to-community.png,relative,,yes +PATTERN_GUIDE.md,docs/images/pattern-guide/02-community-home.png,relative,,yes +PATTERN_GUIDE.md,docs/images/pattern-guide/03-the-wall.png,relative,,yes +PATTERN_GUIDE.md,docs/images/pattern-guide/04-card-hover-knobs.png,relative,,yes +PATTERN_GUIDE.md,docs/images/pattern-guide/05-deck.png,relative,,yes +PATTERN_GUIDE.md,docs/images/pattern-guide/06-send-to-my-board.png,relative,,yes +PATTERN_GUIDE.md,docs/images/pattern-guide/07-modules-built.png,relative,,yes +PATTERN_GUIDE.md,docs/images/pattern-guide/08-device-patterns-page.png,relative,,yes +PATTERN_GUIDE.md,docs/images/pattern-guide/09-delete-modules.png,relative,,yes +PATTERN_GUIDE.md,docs/images/pattern-guide/10-pattern-detail.png,relative,,yes +PATTERN_GUIDE.md,docs/images/pattern-guide/11-pattern-lab.png,relative,,yes +PATTERN_GUIDE.md,docs/images/pattern-guide/12-resolution.png,relative,,yes +PATTERN_GUIDE.md,docs/images/pattern-guide/13-color-ramp.png,relative,,yes +PATTERN_GUIDE.md,docs/images/pattern-guide/14-share-to-hardware.png,relative,,yes +PATTERN_GUIDE.md,docs/images/pattern-guide/15-convert-header.png,relative,,yes +PATTERN_GUIDE.md,docs/images/pattern-guide/16-on-to-hardware.png,relative,,yes +PATTERN_GUIDE.md,docs/images/pattern-guide/17-send-over-wifi.png,relative,,yes +PATTERN_GUIDE.md,docs/images/pattern-guide/18-publish.png,relative,,yes +README.md,.github/SUPPORT.md,relative,,yes +README.md,/,site,,yes +README.md,/build/breadboard,site,,yes +README.md,/contact,site,,yes +README.md,/editions,site,,yes +README.md,/features,site,,yes +README.md,/guide,site,,yes +README.md,/guide/audio,site,,yes +README.md,/guide/build,site,,yes +README.md,/guide/make,site,,yes +README.md,/guide/play,site,,yes +README.md,/guide/play,site,flash,yes +README.md,/guide/play,site,flash-5,yes +README.md,/guide/play,site,patterns,yes +README.md,/inside,site,,yes +README.md,/journal,site,,yes +README.md,/pattern,site,,yes +README.md,/pattern-lab,site,,yes +README.md,BUILD_GUIDE.md,relative,,yes +README.md,BUILD_GUIDE.md,relative,1-bill-of-materials-bom,yes +README.md,BUILD_GUIDE.md,relative,10-known-issues--design-notes,yes +README.md,BUILD_GUIDE.md,relative,8-firmware,yes +README.md,BUILD_GUIDE.md,relative,9-final-checks,yes +README.md,BUILD_GUIDE.md,relative,off-the-board,yes +README.md,CHANGELOG.md,relative,,yes +README.md,CONTRIBUTING.md,relative,,yes +README.md,FEATURE_GUIDE.md,relative,,yes +README.md,LICENSE-CC-BY-SA,relative,,yes +README.md,LICENSE-MIT,relative,,yes +README.md,PATTERN_GUIDE.md,relative,,yes +README.md,docs/AUDIO_GUIDE.md,relative,,yes +README.md,docs/EDITIONS.md,relative,,yes +README.md,docs/LICENSE-SUMMARY.md,relative,,yes +README.md,docs/README.md,relative,,yes +README.md,docs/REPOSITORY.md,relative,,yes +README.md,docs/assembly/README.md,relative,,yes +README.md,docs/director-midi.md,relative,,yes +README.md,docs/legacy/BUILD_GUIDE_v2.md,relative,,yes +README.md,docs/manifesto.md,relative,,yes +README.md,docs/media/community-builds.jpg,relative,,yes +README.md,docs/media/community-library.png,relative,,yes +README.md,docs/media/daw-ableton.jpg,relative,,yes +README.md,docs/media/device-card.png,relative,,yes +README.md,docs/media/hero-loop.webp,relative,,yes +README.md,docs/media/pcbway-3d-print.jpg,relative,,yes +README.md,docs/media/pcbway-logo.png,relative,,yes +README.md,docs/media/web-build-map.png,relative,,yes +README.md,docs/media/web-live-editor.png,relative,,yes +README.md,docs/midi-ableton.md,relative,,yes +README.md,docs/midi-spec.md,relative,,yes +README.md,docs/mqtt-spec.md,relative,,yes +README.md,docs/osc-spec.md,relative,,yes +README.md,docs/panel-compatibility.md,relative,,yes +README.md,docs/rest-api.md,relative,,yes +README.md,docs/rest-api.md,relative,get-apidisplay,yes +README.md,docs/rest-api.md,relative,get-apiknobs-and-post-apiknobs,yes +README.md,firmware/CUSTOM_PATTERNS.md,relative,,yes +README.md,firmware/README.md,relative,,yes +README.md,firmware/README.md,relative,refresh-rate-anti-flicker-for-video,yes +README.md,firmware/toolchain/make_pack.py,relative,,yes +README.md,hardware/README.md,relative,,yes +README.md,hardware/case/remixes/README.md,relative,,yes +README.md,hardware/case/remixes/simonepda-lasercut/README.md,relative,,yes +README.md,integrations/README.md,relative,,yes +README.md,integrations/ableton,relative,,yes +README.md,tools/patternflow-audio-extension,relative,,yes +README.md,web/ARCHITECTURE.md,relative,,yes +README.md,web/public/journal/v1-30-days/first-pcb.jpg,relative,,yes +docs/AUDIO_GUIDE.md,/editions,site,,yes +docs/AUDIO_GUIDE.md,/guide/audio,site,,yes +docs/AUDIO_GUIDE.md,AGENTS.md,relative,hard-rules-do-not-violate,yes +docs/AUDIO_GUIDE.md,BUILD_GUIDE.md,relative,8-firmware,yes +docs/AUDIO_GUIDE.md,CONTRIBUTING.md,relative,,yes +docs/AUDIO_GUIDE.md,FEATURE_GUIDE.md,relative,,yes +docs/AUDIO_GUIDE.md,docs/audio-ws-spec.md,relative,,yes +docs/AUDIO_GUIDE.md,docs/director-midi.md,relative,,yes +docs/AUDIO_GUIDE.md,docs/images/audio-guide/pdm-mic-wiring.jpg,relative,,yes +docs/AUDIO_GUIDE.md,docs/midi-ableton.md,relative,,yes +docs/AUDIO_GUIDE.md,docs/midi-spec.md,relative,,yes +docs/AUDIO_GUIDE.md,docs/osc-spec.md,relative,,yes +docs/AUDIO_GUIDE.md,docs/rest-api.md,relative,,yes +docs/AUDIO_GUIDE.md,docs/rest-api.md,relative,microphone-audio-edition,yes +docs/AUDIO_GUIDE.md,firmware/patternflow/console/README.md,relative,the-page-that-is-assembled-audio-in,yes +docs/AUDIO_GUIDE.md,firmware/patternflow/features/audio,relative,,yes +docs/AUDIO_GUIDE.md,firmware/patternflow/features/audio_in,relative,,yes +docs/AUDIO_GUIDE.md,firmware/patternflow/features/midi,relative,,yes +docs/AUDIO_GUIDE.md,integrations/ableton,relative,,yes +docs/AUDIO_GUIDE.md,tools/patternflow-audio-android,relative,,yes +docs/AUDIO_GUIDE.md,tools/patternflow-audio-android/README.md,relative,,yes +docs/AUDIO_GUIDE.md,tools/patternflow-audio-extension,relative,,yes +docs/AUDIO_GUIDE.md,tools/patternflow-audio-extension/README.md,relative,,yes +docs/EDITIONS.md,/editions,site,,yes +docs/EDITIONS.md,PATTERN_GUIDE.md,relative,,yes +docs/EDITIONS.md,docs/RELEASING.md,relative,,yes +docs/EDITIONS.md,docs/audio-ws-spec.md,relative,,yes +docs/EDITIONS.md,docs/investigations/2026-08-the-wifi-portal-a-samsung-never-drew.md,relative,,yes +docs/EDITIONS.md,docs/osc-spec.md,relative,,yes +docs/EDITIONS.md,docs/rest-api.md,relative,,yes +docs/EDITIONS.md,docs/rfc-core-and-variants.md,relative,,yes +docs/EDITIONS.md,docs/rfc-core-and-variants.md,relative,213-what-changed-and-why-step-5-is-withdrawn,yes +docs/EDITIONS.md,docs/rfc-core-and-variants.md,relative,214-the-formats-are-frozen,yes +docs/EDITIONS.md,firmware/CUSTOM_PATTERNS.md,relative,,yes +docs/EDITIONS.md,firmware/bundles/README.md,relative,,yes +docs/EDITIONS.md,firmware/patternflow/features/README.md,relative,,yes +docs/LICENSE-SUMMARY.md,/community,site,,yes +docs/LICENSE-SUMMARY.md,/terms,site,,yes +docs/LICENSE-SUMMARY.md,CONTRIBUTING.md,relative,,yes +docs/README.md,.github/SUPPORT.md,relative,,yes +docs/README.md,/guide,site,,yes +docs/README.md,/guide/audio,site,,yes +docs/README.md,/guide/build,site,,yes +docs/README.md,/guide/make,site,,yes +docs/README.md,/guide/play,site,,yes +docs/README.md,/guide/play,site,flash,yes +docs/README.md,BUILD_GUIDE.md,relative,,yes +docs/README.md,BUILD_GUIDE.md,relative,8-firmware,yes +docs/README.md,CONTRIBUTING.md,relative,,yes +docs/README.md,PATTERN_GUIDE.md,relative,,yes +docs/README.md,docs/AUDIO_GUIDE.md,relative,,yes +docs/README.md,docs/EDITIONS.md,relative,,yes +docs/README.md,docs/LICENSE-SUMMARY.md,relative,,yes +docs/README.md,docs/RELEASING.md,relative,,yes +docs/README.md,docs/REPOSITORY.md,relative,,yes +docs/README.md,docs/SERVICES.md,relative,,yes +docs/README.md,docs/assembly/README.md,relative,,yes +docs/README.md,docs/audio-ws-spec.md,relative,,yes +docs/README.md,docs/audio-ws-vectors,relative,,yes +docs/README.md,docs/build-guide/images,relative,,yes +docs/README.md,docs/contract-vectors.md,relative,,yes +docs/README.md,docs/director-midi.md,relative,,yes +docs/README.md,docs/images,relative,,yes +docs/README.md,docs/investigations,relative,,yes +docs/README.md,docs/legacy/BUILD_GUIDE_v2.md,relative,,yes +docs/README.md,docs/manifesto.md,relative,,yes +docs/README.md,docs/media,relative,,yes +docs/README.md,docs/midi-ableton.md,relative,,yes +docs/README.md,docs/midi-spec.md,relative,,yes +docs/README.md,docs/midi-vectors,relative,,yes +docs/README.md,docs/mqtt-spec.md,relative,,yes +docs/README.md,docs/mqtt-vectors,relative,,yes +docs/README.md,docs/osc-spec.md,relative,,yes +docs/README.md,docs/osc-vectors,relative,,yes +docs/README.md,docs/panel-compatibility.md,relative,,yes +docs/README.md,docs/pfst-v2-spec.md,relative,,yes +docs/README.md,docs/pfst-v2-vectors,relative,,yes +docs/README.md,docs/releases,relative,,yes +docs/README.md,docs/rest-api-vectors,relative,,yes +docs/README.md,docs/rest-api.md,relative,,yes +docs/README.md,docs/rfc-any-resolution.md,relative,,yes +docs/README.md,docs/rfc-core-and-variants-progress.md,relative,,yes +docs/README.md,docs/rfc-core-and-variants.md,relative,,yes +docs/README.md,docs/rfc-linux-player.md,relative,,yes +docs/README.md,docs/voice-banned.txt,relative,,yes +docs/README.md,docs/voice.md,relative,,yes +docs/README.md,firmware/CUSTOM_PATTERNS.md,relative,,yes +docs/README.md,firmware/patternflow/console/README.md,relative,,yes +docs/README.md,hardware/README.md,relative,,yes +docs/README.md,hardware/case/remixes/README.md,relative,,yes +docs/README.md,tools/virtual-panel/README.md,relative,,yes +docs/REPOSITORY.md,/guide/audio,site,,yes +docs/REPOSITORY.md,/guide/build,site,,yes +docs/REPOSITORY.md,/guide/make,site,,yes +docs/REPOSITORY.md,/guide/play,site,,yes +docs/REPOSITORY.md,/guide/play,site,flash,yes +docs/REPOSITORY.md,AGENTS.md,relative,,yes +docs/REPOSITORY.md,BUILD_GUIDE.md,relative,,yes +docs/REPOSITORY.md,BUILD_GUIDE.md,relative,8-firmware,yes +docs/REPOSITORY.md,CONTRIBUTING.md,relative,,yes +docs/REPOSITORY.md,FEATURE_GUIDE.md,relative,,yes +docs/REPOSITORY.md,PATTERN_GUIDE.md,relative,,yes +docs/REPOSITORY.md,README.md,relative,,yes +docs/REPOSITORY.md,docs/AUDIO_GUIDE.md,relative,,yes +docs/REPOSITORY.md,docs/EDITIONS.md,relative,,yes +docs/REPOSITORY.md,docs/README.md,relative,,yes +docs/REPOSITORY.md,docs/RELEASING.md,relative,,yes +docs/REPOSITORY.md,docs/SERVICES.md,relative,,yes +docs/REPOSITORY.md,docs/assembly/README.md,relative,,yes +docs/REPOSITORY.md,docs/rest-api.md,relative,,yes +docs/REPOSITORY.md,firmware/CUSTOM_PATTERNS.md,relative,,yes +docs/REPOSITORY.md,firmware/README.md,relative,,yes +docs/REPOSITORY.md,firmware/bundles/README.md,relative,,yes +docs/REPOSITORY.md,firmware/patternflow/features/README.md,relative,,yes +docs/REPOSITORY.md,hardware/README.md,relative,,yes +docs/REPOSITORY.md,integrations/README.md,relative,,yes +docs/REPOSITORY.md,shared/constants.json,relative,,yes +docs/REPOSITORY.md,tools/README.md,relative,,yes +docs/REPOSITORY.md,tools/virtual-panel/README.md,relative,,yes +docs/REPOSITORY.md,web/ARCHITECTURE.md,relative,,yes +docs/REPOSITORY.md,web/README.md,relative,,yes +docs/assembly/README.md,/build/breadboard,site,,yes +docs/assembly/README.md,/guide/build,site,,yes +docs/assembly/README.md,/guide/play,site,flash,yes +docs/assembly/README.md,BUILD_GUIDE.md,relative,,yes +docs/assembly/README.md,BUILD_GUIDE.md,relative,8-firmware,yes +docs/assembly/README.md,PATTERN_GUIDE.md,relative,,yes +docs/assembly/README.md,docs/legacy/BUILD_GUIDE_v2.md,relative,,yes +docs/assembly/README.md,docs/panel-compatibility.md,relative,,yes +docs/assembly/README.md,firmware/CUSTOM_PATTERNS.md,relative,,yes +docs/assembly/README.md,hardware/README.md,relative,,yes +docs/assembly/README.md,hardware/case/README.md,relative,,yes +docs/assembly/README.md,hardware/case/legacy_lasercut/README.md,relative,,yes +docs/assembly/README.md,hardware/case/remixes/README.md,relative,,yes +docs/assembly/README.md,hardware/case/remixes/besoiobiy-printed/README.md,relative,,yes +docs/assembly/README.md,hardware/case/remixes/mbchars-horizontal-desktop-printed/README.md,relative,,yes +docs/assembly/README.md,hardware/case/remixes/simonepda-lasercut/README.md,relative,,yes +docs/assembly/README.md,hardware/pcb/README.md,relative,,yes +docs/audio-ws-spec.md,docs/osc-spec.md,relative,,yes +docs/audio-ws-spec.md,docs/rest-api.md,relative,,yes +docs/audio-ws-spec.md,docs/rest-api.md,relative,audio-react-audio-edition,yes +docs/audio-ws-vectors/README.md,docs/audio-ws-spec.md,relative,,yes +docs/audio-ws-vectors/README.md,docs/contract-vectors.md,relative,,yes +docs/audio-ws-vectors/README.md,docs/rest-api-vectors/README.md,relative,,yes +docs/audio-ws-vectors/README.md,tools/virtual-panel/README.md,relative,,yes +docs/contract-vectors.md,docs/pfst-v2-vectors,relative,,yes +docs/contract-vectors.md,docs/rest-api.md,relative,,yes +docs/contract-vectors.md,tools/virtual-panel/README.md,relative,,yes +docs/director-midi.md,docs/audio-ws-spec.md,relative,,yes +docs/director-midi.md,docs/osc-spec.md,relative,,yes +docs/director-midi.md,integrations/ableton/README.md,relative,,yes +docs/director-midi.md,integrations/ableton/docs/recording-sync.md,relative,,yes +docs/investigations/2026-09-firmware-runtime.md,docs/investigations/2026-09-runtime-improvements.json,relative,,yes +docs/investigations/2026-09-firmware-runtime.md,docs/investigations/2026-09-runtime-research/README.md,relative,,yes +docs/investigations/2026-09-runtime-research/README.md,docs/investigations/2026-09-firmware-runtime.md,relative,,yes +docs/investigations/2026-10-cleanup-baseline.md,docs/investigations/2026-10-doc-map.csv,relative,,yes +docs/investigations/2026-10-cleanup-baseline.md,docs/rfc-cleanup-progress.md,relative,,yes +docs/investigations/2026-10-cleanup-baseline.md,docs/rfc-cleanup.md,relative,,yes +docs/investigations/2026-10-cleanup-baseline.md,web/scripts/doc-map.ts,relative,,yes +docs/investigations/2026-10-cleanup-step1-plan.md,docs/rfc-cleanup.md,relative,,yes +docs/investigations/2026-10-cleanup-step2-plan.md,docs/rfc-cleanup.md,relative,,yes +docs/investigations/2026-10-cleanup-step3-plan.md,docs/rfc-cleanup.md,relative,,yes +docs/investigations/2026-10-cleanup-step4-plan.md,docs/rfc-cleanup.md,relative,,yes +docs/investigations/2026-10-cleanup-step4-plan.md,docs/voice.md,relative,,yes +docs/investigations/2026-10-cleanup-step5-plan.md,docs/panel-compatibility.md,relative,,yes +docs/investigations/2026-10-cleanup-step5-plan.md,docs/rfc-cleanup.md,relative,,yes +docs/investigations/2026-10-cleanup-step5-plan.md,docs/voice.md,relative,,yes +docs/investigations/2026-10-panel-geometry.md,docs/investigations/2026-10-cleanup-step5-plan.md,relative,,yes +docs/investigations/2026-10-panel-geometry.md,docs/rfc-cleanup.md,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,/,site,,yes +docs/legacy/BUILD_GUIDE_v2.md,/build/breadboard,site,,yes +docs/legacy/BUILD_GUIDE_v2.md,BUILD_GUIDE.md,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,BUILD_GUIDE.md,relative,6-case-assembly,yes +docs/legacy/BUILD_GUIDE_v2.md,docs/assembly/README.md,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,docs/build-guide/images/all_parts.jpg,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,docs/build-guide/images/back_cover.jpg,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,docs/build-guide/images/case_bond.jpg,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,docs/build-guide/images/divider_bond.jpg,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,docs/build-guide/images/encoder_back_correct.jpg,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,docs/build-guide/images/encoder_front_wrong.jpg,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,docs/build-guide/images/encoder_nut.jpg,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,docs/build-guide/images/esp32_insert.jpg,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,docs/build-guide/images/first_boot.jpg,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,docs/build-guide/images/knobs.jpg,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,docs/build-guide/images/matrix_bump_cut1.jpg,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,docs/build-guide/images/matrix_bump_cut2.jpg,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,docs/build-guide/images/matrix_screw.jpg,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,docs/build-guide/images/parts_layout.jpg,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,docs/build-guide/images/pcb_assembly_setup.jpg,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,docs/build-guide/images/pcb_insert.webm,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,docs/build-guide/images/pcb_inserted.jpg,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,docs/build-guide/images/pcb_left_screw.jpg,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,docs/build-guide/images/pcb_slider.jpg,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,docs/build-guide/images/power_wire_measure.jpg,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,docs/build-guide/images/usb_wire_cut.jpg,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,docs/build-guide/images/web_flash.jpg,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,docs/panel-compatibility.md,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,firmware/CUSTOM_PATTERNS.md,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,firmware/README.md,relative,required-board-package,yes +docs/legacy/BUILD_GUIDE_v2.md,firmware/patternflow/config.h,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,hardware/case/legacy_v2/encloser_v2.1.stl,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,hardware/pcb/gerber,relative,,yes +docs/legacy/BUILD_GUIDE_v2.md,v2.1.0:hardware/case,absolute-blob,,yes +docs/manifesto.md,/inside,site,,yes +docs/midi-ableton.md,docs/director-midi.md,relative,,yes +docs/midi-ableton.md,docs/images/midi/ableton-5-remote.png,relative,,yes +docs/midi-ableton.md,docs/images/midi/rtpmidi-1-new-session.png,relative,,yes +docs/midi-ableton.md,docs/images/midi/rtpmidi-2-enable.png,relative,,yes +docs/midi-ableton.md,docs/images/midi/rtpmidi-3-connect.png,relative,,yes +docs/midi-ableton.md,docs/images/midi/rtpmidi-4-connected.png,relative,,yes +docs/midi-ableton.md,docs/midi-spec.md,relative,,yes +docs/midi-spec.md,docs/EDITIONS.md,relative,,yes +docs/midi-spec.md,docs/director-midi.md,relative,,yes +docs/midi-spec.md,docs/osc-spec.md,relative,,yes +docs/midi-spec.md,docs/rest-api.md,relative,,yes +docs/midi-vectors/README.md,docs/contract-vectors.md,relative,,yes +docs/midi-vectors/README.md,docs/midi-spec.md,relative,,yes +docs/midi-vectors/README.md,docs/rest-api-vectors/README.md,relative,,yes +docs/midi-vectors/README.md,tools/rtpmidi-probe/rtpmidi_probe.py,relative,,yes +docs/midi-vectors/README.md,tools/virtual-panel/README.md,relative,,yes +docs/mqtt-spec.md,docs/EDITIONS.md,relative,,yes +docs/mqtt-spec.md,docs/midi-spec.md,relative,,yes +docs/mqtt-spec.md,docs/osc-spec.md,relative,,yes +docs/mqtt-spec.md,docs/rest-api.md,relative,,yes +docs/mqtt-spec.md,docs/rest-api.md,relative,choosing-a-transport,yes +docs/mqtt-spec.md,docs/rest-api.md,relative,knob-ordering,yes +docs/mqtt-vectors/README.md,docs/contract-vectors.md,relative,,yes +docs/mqtt-vectors/README.md,docs/mqtt-spec.md,relative,,yes +docs/mqtt-vectors/README.md,docs/rest-api-vectors/README.md,relative,,yes +docs/mqtt-vectors/README.md,tools/virtual-panel/README.md,relative,,yes +docs/osc-vectors/README.md,docs/contract-vectors.md,relative,,yes +docs/osc-vectors/README.md,docs/osc-spec.md,relative,,yes +docs/osc-vectors/README.md,tools/virtual-panel/README.md,relative,,yes +docs/panel-compatibility.md,docs/media/panels/dp5125d_sm5166p.jpg,relative,,yes +docs/panel-compatibility.md,docs/media/panels/fm6124ej.jpg,relative,,yes +docs/panel-compatibility.md,docs/media/panels/icn2037bp.jpg,relative,,yes +docs/panel-compatibility.md,firmware/README.md,relative,,yes +docs/panel-compatibility.md,firmware/patternflow/config.h,relative,,yes +docs/panel-compatibility.md,firmware/patternflow/src/core_display.h,relative,,yes +docs/panel-compatibility.md,hardware/bom/README.md,relative,,yes +docs/pfst-v2-spec.md,docs/pfst-v2-vectors,relative,,yes +docs/releases/v1.0.0.md,BUILD_GUIDE.md,relative,,yes +docs/releases/v3.0.0-launch.md,docs/RELEASING.md,relative,,yes +docs/releases/v3.0.0-notes.md,/,site,,yes +docs/releases/v3.0.0-notes.md,BUILD_GUIDE.md,absolute-blob,2-power-input--use-the-screw-terminal,yes +docs/releases/v3.0.0-notes.md,v3.0.0:BUILD_GUIDE.md,absolute-blob,,yes +docs/releases/v3.0.0-notes.md,v3.0.0:BUILD_GUIDE_v2.md,absolute-blob,,yes +docs/releases/v3.0.0-notes.md,v3.0.0:CHANGELOG.md,absolute-blob,,yes +docs/releases/v3.0.0-notes.md,v3.0.0:hardware/bom/bom_v3.0.csv,absolute-blob,,yes +docs/releases/v3.1.0-notes.md,/roadmap,site,,yes +docs/releases/v3.1.0-notes.md,v3.1.0:BUILD_GUIDE.md,absolute-blob,,yes +docs/releases/v3.1.0-notes.md,v3.1.0:BUILD_GUIDE.md,absolute-blob,2-power-input--use-the-screw-terminal,yes +docs/releases/v3.1.0-notes.md,v3.1.0:CHANGELOG.md,absolute-blob,,yes +docs/releases/v3.1.0.md,/roadmap,site,,yes +docs/releases/v3.10.0.md,docs/investigations/2026-09-firmware-runtime.md,relative,,yes +docs/releases/v3.10.5.md,/guide,site,,yes +docs/releases/v3.10.5.md,/guide/build,site,,yes +docs/releases/v3.10.5.md,/guide/make,site,,yes +docs/releases/v3.10.5.md,/guide/play,site,,yes +docs/releases/v3.3.0.md,/panel-tuner.html,site,,yes +docs/releases/v3.3.0.md,docs/panel-compatibility.md,relative,,yes +docs/releases/v3.4.0.md,/update,site,,yes +docs/releases/v3.5.1.md,docs/investigations/2026-08-core2-heap-and-the-5s-stall.md,relative,,yes +docs/releases/v3.6.3.md,docs/investigations/2026-08-the-panel-clock-and-the-wifi-radio.md,relative,,yes +docs/releases/v3.6.3.md,docs/pfst-v2-spec.md,relative,,yes +docs/releases/v3.7.0.md,/variants,site,,yes +docs/releases/v3.8.0.md,/variants,site,,yes +docs/rest-api-vectors/README.md,docs/contract-vectors.md,relative,,yes +docs/rest-api-vectors/README.md,docs/rest-api.md,relative,,yes +docs/rest-api-vectors/README.md,tools/virtual-panel/README.md,relative,,yes +docs/rest-api.md,docs/EDITIONS.md,relative,,yes +docs/rest-api.md,docs/audio-ws-spec.md,relative,,yes +docs/rest-api.md,docs/audio-ws-spec.md,relative,the-switch-on-the-panel,yes +docs/rest-api.md,docs/midi-spec.md,relative,,yes +docs/rest-api.md,docs/mqtt-spec.md,relative,,yes +docs/rfc-any-resolution.md,docs/investigations/2026-08-core2-heap-and-the-5s-stall.md,relative,,yes +docs/rfc-any-resolution.md,docs/investigations/2026-10-panel-geometry.md,relative,,yes +docs/rfc-any-resolution.md,docs/panel-compatibility.md,relative,9-why-hub75e-guarantees-nothing-and-the-three-tiers,yes +docs/rfc-any-resolution.md,docs/rfc-cleanup.md,relative,212-order-and-what-done-means,yes +docs/rfc-any-resolution.md,docs/rfc-linux-player.md,relative,a-receiving-card,yes +docs/rfc-cleanup-progress.md,docs/contract-vectors.md,relative,,yes +docs/rfc-cleanup-progress.md,docs/investigations/2026-10-cleanup-baseline.md,relative,,yes +docs/rfc-cleanup-progress.md,docs/investigations/2026-10-cleanup-step1-plan.md,relative,,yes +docs/rfc-cleanup-progress.md,docs/investigations/2026-10-cleanup-step2-plan.md,relative,,yes +docs/rfc-cleanup-progress.md,docs/investigations/2026-10-cleanup-step3-plan.md,relative,,yes +docs/rfc-cleanup-progress.md,docs/investigations/2026-10-cleanup-step4-plan.md,relative,,yes +docs/rfc-cleanup-progress.md,docs/investigations/2026-10-cleanup-step5-plan.md,relative,,yes +docs/rfc-cleanup-progress.md,docs/investigations/2026-10-doc-map.csv,relative,,yes +docs/rfc-cleanup-progress.md,docs/investigations/2026-10-doc-parity.csv,relative,,yes +docs/rfc-cleanup-progress.md,docs/investigations/2026-10-panel-geometry.md,relative,,yes +docs/rfc-cleanup-progress.md,docs/rfc-any-resolution.md,relative,,yes +docs/rfc-cleanup-progress.md,docs/rfc-cleanup.md,relative,,yes +docs/rfc-cleanup-progress.md,docs/rfc-linux-player.md,relative,,yes +docs/rfc-cleanup.md,docs/rfc-cleanup-progress.md,relative,,yes +docs/rfc-core-and-variants-progress.md,docs/EDITIONS.md,relative,,yes +docs/rfc-core-and-variants-progress.md,docs/pfst-v2-vectors,relative,,yes +docs/rfc-core-and-variants-progress.md,docs/rest-api.md,relative,,yes +docs/rfc-core-and-variants-progress.md,docs/rfc-core-and-variants.md,relative,,yes +docs/rfc-core-and-variants-progress.md,firmware/patternflow/features/README.md,relative,,yes +docs/rfc-core-and-variants.md,/variants,site,,yes +docs/rfc-core-and-variants.md,docs/EDITIONS.md,relative,,yes +docs/rfc-core-and-variants.md,docs/pfst-v2-spec.md,relative,,yes +docs/rfc-core-and-variants.md,docs/rfc-core-and-variants-progress.md,relative,,yes +docs/rfc-core-and-variants.md,docs/rfc-core-and-variants-progress.md,relative,what-the-slim-core-measures,yes +docs/rfc-linux-player.md,docs/EDITIONS.md,relative,the-three-words,yes +docs/rfc-linux-player.md,docs/audio-ws-spec.md,relative,,yes +docs/rfc-linux-player.md,docs/investigations/2026-10-cleanup-step4-plan.md,relative,,yes +docs/rfc-linux-player.md,docs/investigations/2026-10-panel-geometry.md,relative,the-site,yes +docs/rfc-linux-player.md,docs/midi-spec.md,relative,,yes +docs/rfc-linux-player.md,docs/mqtt-spec.md,relative,,yes +docs/rfc-linux-player.md,docs/osc-spec.md,relative,,yes +docs/rfc-linux-player.md,docs/panel-compatibility.md,relative,,yes +docs/rfc-linux-player.md,docs/rest-api.md,relative,,yes +docs/rfc-linux-player.md,docs/rfc-any-resolution.md,relative,,yes +docs/rfc-linux-player.md,docs/rfc-cleanup.md,relative,,yes +docs/voice.md,AGENTS.md,relative,,yes +docs/voice.md,BUILD_GUIDE.md,relative,,yes +docs/voice.md,README.md,relative,,yes +docs/voice.md,docs/EDITIONS.md,relative,the-three-words,yes +docs/voice.md,docs/rfc-cleanup.md,relative,210-writing-voice,yes +docs/voice.md,docs/voice-banned.txt,relative,,yes +firmware/CUSTOM_PATTERNS.md,/,site,,yes +firmware/CUSTOM_PATTERNS.md,/update,site,,yes +firmware/CUSTOM_PATTERNS.md,firmware/README.md,relative,loadable-pattern-modules-pfm,yes +firmware/CUSTOM_PATTERNS.md,firmware/README.md,relative,required-board-package,yes +firmware/README.md,/editions,site,,yes +firmware/README.md,/guide/play,site,flash,yes +firmware/README.md,/update,site,,yes +firmware/README.md,BUILD_GUIDE.md,relative,8-firmware,yes +firmware/README.md,PATTERN_GUIDE.md,relative,,yes +firmware/README.md,docs/AUDIO_GUIDE.md,relative,,yes +firmware/README.md,docs/EDITIONS.md,relative,,yes +firmware/README.md,docs/README.md,relative,,yes +firmware/README.md,docs/audio-ws-spec.md,relative,,yes +firmware/README.md,docs/investigations/2026-08-the-panel-clock-and-the-wifi-radio.md,relative,,yes +firmware/README.md,docs/investigations/2026-09-firmware-runtime.md,relative,,yes +firmware/README.md,docs/midi-ableton.md,relative,,yes +firmware/README.md,docs/midi-spec.md,relative,,yes +firmware/README.md,docs/mqtt-spec.md,relative,,yes +firmware/README.md,docs/osc-spec.md,relative,,yes +firmware/README.md,docs/panel-compatibility.md,relative,,yes +firmware/README.md,docs/rest-api.md,relative,,yes +firmware/README.md,firmware/CUSTOM_PATTERNS.md,relative,,yes +firmware/README.md,firmware/bundles/README.md,relative,,yes +firmware/README.md,firmware/patternflow/console,relative,,yes +firmware/README.md,firmware/patternflow/console/README.md,relative,,yes +firmware/README.md,firmware/patternflow/src/hub75/UPSTREAM.md,relative,,yes +firmware/README.md,firmware/patternflow/src/hub75/VENDORED.md,relative,,yes +firmware/README.md,integrations/ableton,relative,,yes +firmware/README.md,tools/README.md,relative,,yes +firmware/bundles/README.md,/editions,site,,yes +firmware/bundles/README.md,docs/EDITIONS.md,relative,,yes +firmware/bundles/README.md,firmware/patternflow/features/README.md,relative,,yes +firmware/modules/README.md,/pattern-lab,site,,yes +firmware/modules/README.md,PATTERN_GUIDE.md,relative,,yes +firmware/patternflow/README.md,CONTRIBUTING.md,relative,,yes +firmware/patternflow/README.md,PATTERN_GUIDE.md,relative,,yes +firmware/patternflow/README.md,docs/LICENSE-SUMMARY.md,relative,,yes +firmware/patternflow/README.md,docs/panel-compatibility.md,relative,,yes +firmware/patternflow/README.md,firmware/CUSTOM_PATTERNS.md,relative,,yes +firmware/patternflow/README.md,firmware/README.md,relative,,yes +firmware/patternflow/README.md,firmware/patternflow/_TEMPLATE.h,relative,,yes +firmware/patternflow/README.md,firmware/patternflow/config.h,relative,,yes +firmware/patternflow/README.md,firmware/patternflow/pattern_registry.h,relative,,yes +firmware/patternflow/README.md,firmware/patternflow/presets,relative,,yes +firmware/patternflow/console/_audio_in_bar.html,/,console,,yes +firmware/patternflow/console/_audio_in_bar.html,/status,console,,yes +firmware/patternflow/console/_audio_in_bar.html,tools/patternflow-audio-extension,absolute-blob,readme,yes +firmware/patternflow/console/audio-in.html,/,console,,yes +firmware/patternflow/console/audio-in.html,/pf-console.js,console,,yes +firmware/patternflow/console/audio-in.html,/status,console,,yes +firmware/patternflow/console/audio-in.html,tools/patternflow-audio-extension,absolute-blob,readme,yes +firmware/patternflow/console/clock.html,/pf-console.js,console,,yes +firmware/patternflow/console/home.html,/,site,,yes +firmware/patternflow/console/home.html,/editions,site,,yes +firmware/patternflow/console/home.html,/flash/manifest.json,site,,yes +firmware/patternflow/console/home.html,/knobs,console,,yes +firmware/patternflow/console/home.html,/patterns,console,,yes +firmware/patternflow/console/home.html,/pf-console.js,console,,yes +firmware/patternflow/console/home.html,/status,console,,yes +firmware/patternflow/console/home.html,/update,console,,yes +firmware/patternflow/console/home.html,/update,site,,yes +firmware/patternflow/console/home.html,/wifi,console,,yes +firmware/patternflow/console/knobs.html,/,console,,yes +firmware/patternflow/console/knobs.html,/pf-console.js,console,,yes +firmware/patternflow/console/knobs.html,/status,console,,yes +firmware/patternflow/console/midi.html,/pf-console.js,console,,yes +firmware/patternflow/console/midi.html,docs/midi-ableton.md,absolute-blob,,yes +firmware/patternflow/console/midi.html,docs/midi-spec.md,absolute-blob,,yes +firmware/patternflow/console/mqtt.html,/pf-console.js,console,,yes +firmware/patternflow/console/patterns.html,/,console,,yes +firmware/patternflow/console/patterns.html,/,site,,yes +firmware/patternflow/console/patterns.html,/patterns/fflate.js,console,,yes +firmware/patternflow/console/patterns.html,/pf-console.js,console,,yes +firmware/patternflow/console/show.html,/patterns/fflate.js,console,,yes +firmware/patternflow/console/show.html,/pf-console.js,console,,yes +firmware/patternflow/console/show.html,/weather,console,,yes +firmware/patternflow/console/status.html,/,console,,yes +firmware/patternflow/console/status.html,/patterns,console,,yes +firmware/patternflow/console/status.html,/pf-console.js,console,,yes +firmware/patternflow/console/update.html,/,console,,yes +firmware/patternflow/console/update.html,/,site,,yes +firmware/patternflow/console/update.html,/editions,site,,yes +firmware/patternflow/console/update.html,/pf-console.js,console,,yes +firmware/patternflow/console/update.html,/update,site,,yes +firmware/patternflow/console/weather.html,/pf-console.js,console,,yes +firmware/patternflow/console/wifi.html,/,console,,yes +firmware/patternflow/console/wifi.html,/pf-console.js,console,,yes +firmware/patternflow/console/wifi.html,/status,console,,yes +firmware/patternflow/features/README.md,docs/EDITIONS.md,relative,,yes +firmware/patternflow/features/README.md,docs/rfc-core-and-variants.md,relative,,yes +firmware/patternflow/features/README.md,firmware/bundles/README.md,relative,,yes +firmware/patternflow/presets/README.md,/pattern-lab,site,,yes +firmware/patternflow/presets/README.md,docs/LICENSE-SUMMARY.md,relative,,yes +firmware/patternflow/presets/README.md,firmware/CUSTOM_PATTERNS.md,relative,,yes +firmware/patternflow/presets/README.md,firmware/patternflow/README.md,relative,,yes +firmware/patternflow/presets/README.md,firmware/patternflow/pattern_registry.h,relative,,yes +firmware/patternflow/presets/preset_0628.h,/,site,,yes +firmware/patternflow/presets/preset_0629.h,/,site,,yes +firmware/patternflow/presets/preset_0629_2.h,/,site,,yes +firmware/patternflow/presets/preset_0701.h,/,site,,yes +firmware/patternflow/presets/preset_0707.h,/pattern-lab,site,,yes +firmware/patternflow/presets/preset_0710.h,/pattern-lab,site,,yes +firmware/patternflow/presets/preset_0712.h,/pattern-lab,site,,yes +firmware/patternflow/presets/preset_0712_2.h,/pattern-lab,site,,yes +firmware/patternflow/presets/preset_0713.h,/pattern-lab,site,,yes +firmware/patternflow/presets/preset_0715.h,/pattern,site,,yes +firmware/patternflow/presets/preset_0716.h,/pattern,site,,yes +firmware/patternflow/presets/preset_0718.h,/pattern-lab,site,,yes +firmware/patternflow/presets/preset_0719.h,/pattern-lab,site,,yes +firmware/toolchain/README.md,docs/EDITIONS.md,relative,,yes +firmware/toolchain/README.md,docs/RELEASING.md,relative,,yes +firmware/toolchain/README.md,firmware/patternflow/console/README.md,relative,,yes +hardware/README.md,/build,site,,yes +hardware/README.md,/guide/build,site,,yes +hardware/README.md,BUILD_GUIDE.md,relative,,yes +hardware/README.md,LICENSE-CC-BY-SA,relative,,yes +hardware/README.md,docs/assembly/README.md,relative,,yes +hardware/README.md,docs/legacy/BUILD_GUIDE_v2.md,relative,,yes +hardware/README.md,hardware/bom/README.md,relative,,yes +hardware/README.md,hardware/bom/bom_v3.0.csv,relative,,yes +hardware/README.md,hardware/bom/bom_v3.9.csv,relative,,yes +hardware/README.md,hardware/case/README.md,relative,,yes +hardware/README.md,hardware/case/legacy_lasercut/README.md,relative,,yes +hardware/README.md,hardware/case/remixes/README.md,relative,,yes +hardware/README.md,hardware/pcb/README.md,relative,,yes +hardware/README.md,hardware/pcb/gerber,relative,,yes +hardware/README.md,v2.1.0:BUILD_GUIDE.md,absolute-blob,1-bill-of-materials-bom,yes +hardware/bom/README.md,docs/panel-compatibility.md,relative,,yes +hardware/bom/README.md,firmware/patternflow/config.h,relative,,yes +hardware/bom/README.md,v2.1.0:BUILD_GUIDE.md,absolute-blob,1-bill-of-materials-bom,yes +hardware/case/README.md,BUILD_GUIDE.md,relative,,yes +hardware/case/README.md,BUILD_GUIDE.md,relative,2-power-input--use-the-screw-terminal,yes +hardware/case/README.md,BUILD_GUIDE.md,relative,6-case-assembly,yes +hardware/case/README.md,docs/legacy/BUILD_GUIDE_v2.md,relative,,yes +hardware/case/README.md,hardware/bom,relative,,yes +hardware/case/README.md,hardware/case/bed_256mm,relative,,yes +hardware/case/README.md,hardware/case/bed_256mm/patternflow_v3.3mf,relative,,yes +hardware/case/README.md,hardware/case/bed_330mm,relative,,yes +hardware/case/README.md,hardware/case/knobs,relative,,yes +hardware/case/README.md,hardware/case/legacy_lasercut/README.md,relative,,yes +hardware/case/README.md,hardware/case/legacy_v2,relative,,yes +hardware/case/README.md,hardware/case/remixes/README.md,relative,,yes +hardware/case/README.md,hardware/case/remixes/besoiobiy-printed/README.md,relative,,yes +hardware/case/README.md,hardware/case/remixes/mbchars-horizontal-desktop-printed/README.md,relative,,yes +hardware/case/README.md,hardware/case/remixes/simonepda-lasercut/README.md,relative,,yes +hardware/case/README.md,v2.1.0:hardware/case,absolute-blob,,yes +hardware/case/legacy_lasercut/README.md,hardware/case/remixes/README.md,relative,,yes +hardware/case/legacy_lasercut/README.md,hardware/case/remixes/simonepda-lasercut/README.md,relative,,yes +hardware/case/remixes/README.md,/build,site,,yes +hardware/case/remixes/README.md,/inside,site,,yes +hardware/case/remixes/README.md,CONTRIBUTING.md,relative,your-build,yes +hardware/case/remixes/README.md,hardware/case/remixes/besoiobiy-printed/README.md,relative,,yes +hardware/case/remixes/README.md,hardware/case/remixes/mbchars-horizontal-desktop-printed/README.md,relative,,yes +hardware/case/remixes/README.md,hardware/case/remixes/simonepda-lasercut/README.md,relative,,yes +hardware/case/remixes/README.md,tools/case-models/README.md,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,BUILD_GUIDE.md,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,BUILD_GUIDE.md,relative,2-power-input--use-the-screw-terminal,yes +hardware/case/remixes/besoiobiy-printed/README.md,BUILD_GUIDE.md,relative,6-case-assembly,yes +hardware/case/remixes/besoiobiy-printed/README.md,docs/panel-compatibility.md,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/images,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/images/fig1_corner_bulge.png,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/images/fig2_panel_gap.png,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/images/fig3_variants.png,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/images/fig4_grooves.png,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/images/fig5_glue_order.png,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/images/fig6_inserts_and_screws.png,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/images/fig7_panel_bolts.png,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/images/fig8_ribbon_connector.png,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/images/photo_built.jpg,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/images/render_exploded_back.png,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/images/render_exploded_front.png,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/images/render_parts.png,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/print_layout.3mf,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/source/patternbox.blend,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/source/patternbox.stl,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/stl,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/stl/box_bottom.stl,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/stl/box_bottom_no_bridge.stl,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/stl/box_bottom_plain.stl,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/stl/box_top.stl,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/stl/box_top_no_bridge.stl,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/stl/box_top_plain.stl,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/stl/cover_box_bottom.stl,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/stl/cover_box_top.stl,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/stl/cover_frame_bottom.stl,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/stl/cover_frame_top.stl,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/stl/frame_bottom.stl,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/stl/frame_top.stl,relative,,yes +hardware/case/remixes/besoiobiy-printed/README.md,hardware/case/remixes/besoiobiy-printed/stl/knob_cap.stl,relative,,yes +hardware/case/remixes/mbchars-horizontal-desktop-printed/README.md,BUILD_GUIDE.md,relative,2-power-input--use-the-screw-terminal,yes +hardware/case/remixes/mbchars-horizontal-desktop-printed/README.md,LICENSE-CC-BY-SA,relative,,yes +hardware/case/remixes/mbchars-horizontal-desktop-printed/README.md,hardware/case/remixes/mbchars-horizontal-desktop-printed/assembly.md,relative,,yes +hardware/case/remixes/mbchars-horizontal-desktop-printed/README.md,hardware/case/remixes/mbchars-horizontal-desktop-printed/bom.md,relative,,yes +hardware/case/remixes/mbchars-horizontal-desktop-printed/README.md,hardware/case/remixes/mbchars-horizontal-desktop-printed/images/photo_front.jpg,relative,,yes +hardware/case/remixes/mbchars-horizontal-desktop-printed/README.md,hardware/case/remixes/mbchars-horizontal-desktop-printed/images/photo_front_angle.jpg,relative,,yes +hardware/case/remixes/mbchars-horizontal-desktop-printed/README.md,hardware/case/remixes/mbchars-horizontal-desktop-printed/print_layout.3mf,relative,,yes +hardware/case/remixes/mbchars-horizontal-desktop-printed/README.md,hardware/case/remixes/mbchars-horizontal-desktop-printed/stl/alignment_pin_2mm.stl,relative,,yes +hardware/case/remixes/mbchars-horizontal-desktop-printed/README.md,hardware/case/remixes/mbchars-horizontal-desktop-printed/stl/control_section.stl,relative,,yes +hardware/case/remixes/mbchars-horizontal-desktop-printed/README.md,hardware/case/remixes/mbchars-horizontal-desktop-printed/stl/joint_bar.stl,relative,,yes +hardware/case/remixes/mbchars-horizontal-desktop-printed/README.md,hardware/case/remixes/mbchars-horizontal-desktop-printed/stl/knob.stl,relative,,yes +hardware/case/remixes/mbchars-horizontal-desktop-printed/README.md,hardware/case/remixes/mbchars-horizontal-desktop-printed/stl/rear_control_cover.stl,relative,,yes +hardware/case/remixes/mbchars-horizontal-desktop-printed/README.md,hardware/case/remixes/mbchars-horizontal-desktop-printed/stl/rear_display_cover.stl,relative,,yes +hardware/case/remixes/mbchars-horizontal-desktop-printed/README.md,hardware/case/remixes/mbchars-horizontal-desktop-printed/stl/stand.stl,relative,,yes +hardware/case/remixes/mbchars-horizontal-desktop-printed/README.md,hardware/case/remixes/mbchars-horizontal-desktop-printed/stl/usb_retainer.stl,relative,,yes +hardware/case/remixes/mbchars-horizontal-desktop-printed/README.md,hardware/case/remixes/mbchars-horizontal-desktop-printed/stl/white_inner_frame.stl,relative,,yes +hardware/case/remixes/mbchars-horizontal-desktop-printed/README.md,hardware/case/remixes/mbchars-horizontal-desktop-printed/stl/white_outer_frame.stl,relative,,yes +hardware/case/remixes/mbchars-horizontal-desktop-printed/assembly.md,BUILD_GUIDE.md,relative,,yes +hardware/case/remixes/mbchars-horizontal-desktop-printed/assembly.md,hardware/case/remixes/mbchars-horizontal-desktop-printed/bom.md,relative,,yes +hardware/case/remixes/mbchars-horizontal-desktop-printed/assembly.md,hardware/case/remixes/mbchars-horizontal-desktop-printed/images/01_frame_connections.png,relative,,yes +hardware/case/remixes/mbchars-horizontal-desktop-printed/assembly.md,hardware/case/remixes/mbchars-horizontal-desktop-printed/images/02_joint_section.png,relative,,yes +hardware/case/remixes/mbchars-horizontal-desktop-printed/assembly.md,hardware/case/remixes/mbchars-horizontal-desktop-printed/images/03_optional_pins.png,relative,,yes +hardware/case/remixes/mbchars-horizontal-desktop-printed/assembly.md,hardware/case/remixes/mbchars-horizontal-desktop-printed/images/04_usb_installation.png,relative,,yes +hardware/case/remixes/mbchars-horizontal-desktop-printed/assembly.md,hardware/case/remixes/mbchars-horizontal-desktop-printed/images/05_connections.png,relative,,yes +hardware/case/remixes/mbchars-horizontal-desktop-printed/assembly.md,hardware/case/remixes/mbchars-horizontal-desktop-printed/images/photo_rear_assembly.jpg,relative,,yes +hardware/case/remixes/mbchars-horizontal-desktop-printed/assembly.md,hardware/case/remixes/mbchars-horizontal-desktop-printed/images/photo_socket_detail.jpg,relative,,yes +hardware/case/remixes/mbchars-horizontal-desktop-printed/bom.md,hardware/bom/bom_v3.0.csv,relative,,yes +hardware/case/remixes/mbchars-horizontal-desktop-printed/bom.md,hardware/case/remixes/mbchars-horizontal-desktop-printed/README.md,relative,print-files,yes +hardware/case/remixes/simonepda-lasercut/README.md,BUILD_GUIDE.md,relative,,yes +hardware/case/remixes/simonepda-lasercut/README.md,hardware/case/knobs,relative,,yes +hardware/case/remixes/simonepda-lasercut/README.md,hardware/case/remixes/simonepda-lasercut/lasercut_layout.cdr,relative,,yes +hardware/case/remixes/simonepda-lasercut/README.md,hardware/case/remixes/simonepda-lasercut/lasercut_layout.pdf,relative,,yes +hardware/case/remixes/simonepda-lasercut/README.md,hardware/case/remixes/simonepda-lasercut/photos/acrylic_front.jpg,relative,,yes +hardware/case/remixes/simonepda-lasercut/README.md,hardware/case/remixes/simonepda-lasercut/photos/board_in_box.jpg,relative,,yes +hardware/case/remixes/simonepda-lasercut/README.md,hardware/case/remixes/simonepda-lasercut/photos/mdf_back.jpg,relative,,yes +hardware/case/remixes/simonepda-lasercut/README.md,hardware/case/remixes/simonepda-lasercut/photos/mdf_front.jpg,relative,,yes +hardware/pcb/README.md,LICENSE-CC-BY-SA,relative,,yes +hardware/pcb/README.md,hardware/bom,relative,,yes +hardware/pcb/README.md,hardware/pcb/gerber,relative,,yes +hardware/pcb/README.md,hardware/pcb/images/board_render_bottom.png,relative,,yes +hardware/pcb/README.md,hardware/pcb/images/board_render_top.png,relative,,yes +hardware/pcb/README.md,hardware/pcb/images/schematic.svg,relative,,yes +hardware/pcb/README.md,hardware/pcb/schematic.pdf,relative,,yes +hardware/pcb/gerber/experiment/README.md,hardware/pcb/gerber,relative,,yes +integrations/README.md,/editions,site,,yes +integrations/README.md,docs/midi-spec.md,relative,,yes +integrations/README.md,docs/mqtt-spec.md,relative,,yes +integrations/README.md,docs/osc-spec.md,relative,,yes +integrations/README.md,docs/rest-api.md,relative,,yes +integrations/README.md,integrations/ableton/README.md,relative,,yes +integrations/README.md,tools/README.md,relative,,yes +integrations/ableton/README.md,/editions,site,,yes +integrations/ableton/README.md,docs/EDITIONS.md,relative,,yes +integrations/ableton/README.md,docs/director-midi.md,relative,,yes +integrations/ableton/README.md,docs/osc-spec.md,relative,,yes +integrations/ableton/README.md,integrations/ableton/docs/m4l-osc-guide.md,relative,,yes +integrations/ableton/README.md,integrations/ableton/docs/recording-sync.md,relative,,yes +integrations/ableton/docs/recording-sync.md,docs/osc-spec.md,relative,,yes +tools/README.md,LICENSE-MIT,relative,,yes +tools/README.md,docs/README.md,relative,contracts--other-software-is-built-against-these-not-against-the-firmware-source,yes +tools/README.md,docs/audio-ws-spec.md,relative,,yes +tools/README.md,docs/midi-spec.md,relative,,yes +tools/README.md,docs/mqtt-spec.md,relative,,yes +tools/README.md,docs/osc-spec.md,relative,,yes +tools/README.md,docs/rest-api.md,relative,,yes +tools/README.md,docs/rest-api.md,relative,microphone-audio-edition,yes +tools/README.md,integrations/README.md,relative,,yes +tools/README.md,tools/case-models/README.md,relative,,yes +tools/README.md,tools/patternflow-audio-android/README.md,relative,,yes +tools/README.md,tools/patternflow-audio-extension/README.md,relative,,yes +tools/README.md,tools/rtpmidi-probe/README.md,relative,,yes +tools/README.md,tools/virtual-panel/README.md,relative,,yes +tools/case-models/README.md,/build,site,,yes +tools/patternflow-audio-android/README.md,/editions,site,,yes +tools/patternflow-audio-extension/README.md,/editions,site,,yes +tools/patternflow-audio-extension/README.md,docs/AUDIO_GUIDE.md,relative,,yes +tools/patternflow-audio-extension/README.md,docs/audio-ws-spec.md,relative,,yes +tools/patternflow-audio-extension/README.md,firmware/patternflow/console/README.md,relative,the-page-that-is-assembled-audio-in,yes +tools/rtpmidi-probe/README.md,docs/midi-spec.md,relative,,yes +tools/virtual-panel/README.md,docs/README.md,relative,,yes +tools/virtual-panel/README.md,docs/audio-ws-spec.md,relative,,yes +tools/virtual-panel/README.md,docs/midi-spec.md,relative,,yes +tools/virtual-panel/README.md,docs/mqtt-spec.md,relative,,yes +tools/virtual-panel/README.md,docs/osc-spec.md,relative,,yes +tools/virtual-panel/README.md,docs/rest-api.md,relative,,yes +tools/virtual-panel/README.md,docs/rfc-cleanup.md,relative,26-step-4--the-test-spine,yes +web/ARCHITECTURE.md,/,site,,yes +web/ARCHITECTURE.md,docs/SERVICES.md,relative,,yes +web/README.md,/,site,,yes +web/README.md,CONTRIBUTING.md,relative,,yes +web/README.md,LICENSE-MIT,relative,,yes +web/README.md,tools/case-models/README.md,relative,,yes +web/README.md,web/ARCHITECTURE.md,relative,,yes +web/README.md,web/ARCHITECTURE.md,relative,environment-variables,yes +web/content/inside.md,CONTRIBUTING.md,absolute-blob,,yes +web/content/journal/a-strange-dream.en.mdx,/journal/a-strange-dream/certificate.jpg,site,,yes +web/content/journal/a-strange-dream.mdx,/journal/a-strange-dream/certificate.jpg,site,,yes +web/content/journal/a-strange-feeling.en.mdx,/journal/a-strange-feeling/build-guide.jpg,site,,yes +web/content/journal/a-strange-feeling.mdx,/journal/a-strange-feeling/build-guide.jpg,site,,yes +web/content/journal/all-jumbled-up.en.mdx,/journal/all-jumbled-up/turntable.jpg,site,,yes +web/content/journal/all-jumbled-up.mdx,/journal/all-jumbled-up/turntable.jpg,site,,yes +web/content/journal/all-over-the-place.en.mdx,/journal/all-over-the-place/funding-spreadsheet.png,site,,yes +web/content/journal/all-over-the-place.mdx,/journal/all-over-the-place/funding-spreadsheet.png,site,,yes +web/content/journal/community.en.mdx,/roadmap,site,,yes +web/content/journal/community.mdx,/roadmap,site,,yes +web/content/journal/experiment-after-experiment.en.mdx,/pattern-lab,site,,yes +web/content/journal/experiment-after-experiment.mdx,/pattern-lab,site,,yes +web/content/journal/faster-faster.en.mdx,/community,site,,yes +web/content/journal/faster-faster.en.mdx,/journal/faster-faster/crowd-supply-subscribers.png,site,,yes +web/content/journal/faster-faster.mdx,/community,site,,yes +web/content/journal/faster-faster.mdx,/journal/faster-faster/crowd-supply-subscribers.png,site,,yes +web/content/journal/good-but.en.mdx,/journal/good-but/astra-patterns.png,site,,yes +web/content/journal/good-but.en.mdx,/journal/good-but/funding-success.png,site,,yes +web/content/journal/good-but.mdx,/journal/good-but/astra-patterns.png,site,,yes +web/content/journal/good-but.mdx,/journal/good-but/funding-success.png,site,,yes +web/content/journal/how-to-use-patternflow.en.mdx,/journal/how-to-use-patternflow/gwangju-biennale.jpg,site,,yes +web/content/journal/how-to-use-patternflow.mdx,/journal/how-to-use-patternflow/gwangju-biennale.jpg,site,,yes +web/content/journal/i-hope-it-works-out.en.mdx,/journal/i-hope-it-works-out/crowd-supply-subscribers.png,site,,yes +web/content/journal/i-hope-it-works-out.mdx,/journal/i-hope-it-works-out/crowd-supply-subscribers.png,site,,yes +web/content/journal/ill-just-do-it.en.mdx,/journal/ill-just-do-it/at-a-glance.png,site,,yes +web/content/journal/ill-just-do-it.en.mdx,/journal/ill-just-do-it/the-lab.png,site,,yes +web/content/journal/ill-just-do-it.mdx,/journal/ill-just-do-it/at-a-glance.png,site,,yes +web/content/journal/ill-just-do-it.mdx,/journal/ill-just-do-it/the-lab.png,site,,yes +web/content/journal/its-a-failure.en.mdx,/journal/its-a-failure/pdm-mic.jpg,site,,yes +web/content/journal/its-a-failure.en.mdx,/journal/its-a-failure/petg-enclosure.jpg,site,,yes +web/content/journal/its-a-failure.mdx,/journal/its-a-failure/pdm-mic.jpg,site,,yes +web/content/journal/its-a-failure.mdx,/journal/its-a-failure/petg-enclosure.jpg,site,,yes +web/content/journal/maybe-this-will-work.en.mdx,/journal/maybe-this-will-work/filming-setup.jpg,site,,yes +web/content/journal/maybe-this-will-work.mdx,/journal/maybe-this-will-work/filming-setup.jpg,site,,yes +web/content/journal/me-and-patternflow.en.mdx,/journal/me-and-patternflow/instagram-grid.png,site,,yes +web/content/journal/me-and-patternflow.en.mdx,/journal/me-and-patternflow/pattern-lab.png,site,,yes +web/content/journal/me-and-patternflow.en.mdx,/journal/me-and-patternflow/posthog-session-replay.png,site,,yes +web/content/journal/me-and-patternflow.en.mdx,/journal/me-and-patternflow/price-comment.png,site,,yes +web/content/journal/me-and-patternflow.en.mdx,/journal/me-and-patternflow/robot-k456.jpg,site,,yes +web/content/journal/me-and-patternflow.en.mdx,/journal/me-and-patternflow/tally-dashboard.png,site,,yes +web/content/journal/me-and-patternflow.en.mdx,/journal/wins-and-losses-next-step/en,site,,yes +web/content/journal/me-and-patternflow.mdx,/journal/me-and-patternflow/instagram-grid.png,site,,yes +web/content/journal/me-and-patternflow.mdx,/journal/me-and-patternflow/pattern-lab.png,site,,yes +web/content/journal/me-and-patternflow.mdx,/journal/me-and-patternflow/posthog-session-replay.png,site,,yes +web/content/journal/me-and-patternflow.mdx,/journal/me-and-patternflow/price-comment.png,site,,yes +web/content/journal/me-and-patternflow.mdx,/journal/me-and-patternflow/robot-k456.jpg,site,,yes +web/content/journal/me-and-patternflow.mdx,/journal/me-and-patternflow/tally-dashboard.png,site,,yes +web/content/journal/me-and-patternflow.mdx,/journal/wins-and-losses-next-step,site,,yes +web/content/journal/nam-june-paik-me-patternflow.en.mdx,/journal/nam-june-paik-me-patternflow/pattern-30.png,site,,yes +web/content/journal/nam-june-paik-me-patternflow.mdx,/journal/nam-june-paik-me-patternflow/pattern-30.png,site,,yes +web/content/journal/negative.en.mdx,/journal/negative/emc-test-scan.jpg,site,,yes +web/content/journal/negative.en.mdx,/journal/negative/funding-dashboard.png,site,,yes +web/content/journal/negative.mdx,/journal/negative/emc-test-scan.jpg,site,,yes +web/content/journal/negative.mdx,/journal/negative/funding-dashboard.png,site,,yes +web/content/journal/new-possibilities.en.mdx,/journal/new-possibilities/kkuji-tree.jpg,site,,yes +web/content/journal/new-possibilities.en.mdx,/journal/new-possibilities/pattern-map.png,site,,yes +web/content/journal/new-possibilities.mdx,/journal/new-possibilities/kkuji-tree.jpg,site,,yes +web/content/journal/new-possibilities.mdx,/journal/new-possibilities/pattern-map.png,site,,yes +web/content/journal/refocus.en.mdx,/journal/refocus/blender-3d-modeling.png,site,,yes +web/content/journal/refocus.en.mdx,/journal/refocus/exhibition-process.jpg,site,,yes +web/content/journal/refocus.en.mdx,/journal/refocus/pcb-discord-feedback.png,site,,yes +web/content/journal/refocus.en.mdx,/journal/refocus/pcbway-delivery-1.jpg,site,,yes +web/content/journal/refocus.en.mdx,/journal/refocus/pcbway-delivery-2.jpg,site,,yes +web/content/journal/refocus.mdx,/journal/refocus/blender-3d-modeling.png,site,,yes +web/content/journal/refocus.mdx,/journal/refocus/exhibition-process.jpg,site,,yes +web/content/journal/refocus.mdx,/journal/refocus/pcb-discord-feedback.png,site,,yes +web/content/journal/refocus.mdx,/journal/refocus/pcbway-delivery-1.jpg,site,,yes +web/content/journal/refocus.mdx,/journal/refocus/pcbway-delivery-2.jpg,site,,yes +web/content/journal/right-before-launch.en.mdx,/journal/right-before-launch/business-cards.png,site,,yes +web/content/journal/right-before-launch.mdx,/journal/right-before-launch/business-cards.png,site,,yes +web/content/journal/shaking-it-off.en.mdx,/journal/shaking-it-off/flooded-lawn.jpg,site,,yes +web/content/journal/shaking-it-off.mdx,/journal/shaking-it-off/flooded-lawn.jpg,site,,yes +web/content/journal/the-messy-desk.en.mdx,/journal/the-messy-desk/flowlocal.jpg,site,,yes +web/content/journal/the-messy-desk.en.mdx,/journal/the-messy-desk/messy-desk.jpg,site,,yes +web/content/journal/the-messy-desk.en.mdx,/journal/the-messy-desk/park-ice-cream.jpg,site,,yes +web/content/journal/the-messy-desk.mdx,/journal/the-messy-desk/flowlocal.jpg,site,,yes +web/content/journal/the-messy-desk.mdx,/journal/the-messy-desk/messy-desk.jpg,site,,yes +web/content/journal/the-messy-desk.mdx,/journal/the-messy-desk/park-ice-cream.jpg,site,,yes +web/content/journal/this-is-the-worst.en.mdx,/journal/this-is-the-worst/studio-product-shoot.jpg,site,,yes +web/content/journal/this-is-the-worst.mdx,/journal/this-is-the-worst/studio-product-shoot.jpg,site,,yes +web/content/journal/today-is-my-birthday.en.mdx,/journal/today-is-my-birthday/crowd-supply-contract-signed.png,site,,yes +web/content/journal/today-is-my-birthday.en.mdx,/journal/today-is-my-birthday/crowd-supply-contract-top.png,site,,yes +web/content/journal/today-is-my-birthday.en.mdx,/journal/today-is-my-birthday/diffuser-discord.png,site,,yes +web/content/journal/today-is-my-birthday.mdx,/journal/today-is-my-birthday/crowd-supply-contract-signed.png,site,,yes +web/content/journal/today-is-my-birthday.mdx,/journal/today-is-my-birthday/crowd-supply-contract-top.png,site,,yes +web/content/journal/today-is-my-birthday.mdx,/journal/today-is-my-birthday/diffuser-discord.png,site,,yes +web/content/journal/v1-30-days.en.mdx,/journal/v1-30-days/assembled-pcb-back.jpg,site,,yes +web/content/journal/v1-30-days.en.mdx,/journal/v1-30-days/debugging-wires-esp32-potentiometer.jpg,site,,yes +web/content/journal/v1-30-days.en.mdx,/journal/v1-30-days/first-pcb.jpg,site,,yes +web/content/journal/v1-30-days.en.mdx,/journal/v1-30-days/kicad-schematic-screenshot.png,site,,yes +web/content/journal/v1-30-days.en.mdx,/journal/v1-30-days/led-panel-in-nature-1.jpg,site,,yes +web/content/journal/v1-30-days.en.mdx,/journal/v1-30-days/led-panel-in-nature-2.jpg,site,,yes +web/content/journal/v1-30-days.en.mdx,/journal/v1-30-days/participation-tv-experience.jpg,site,,yes +web/content/journal/v1-30-days.en.mdx,/journal/v1-30-days/patternflow-origin-interaction.png,site,,yes +web/content/journal/v1-30-days.en.mdx,/journal/v1-30-days/patternflow-v1-complete.jpg,site,,yes +web/content/journal/v1-30-days.en.mdx,/journal/v1-30-days/reddit-comment-1.png,site,,yes +web/content/journal/v1-30-days.en.mdx,/journal/v1-30-days/reddit-comment-2.png,site,,yes +web/content/journal/v1-30-days.en.mdx,/journal/v1-30-days/robot-k456.jpg,site,,yes +web/content/journal/v1-30-days.en.mdx,/journal/v1-30-days/tangled-wires-led-panel.jpg,site,,yes +web/content/journal/v1-30-days.en.mdx,/journal/v1-30-days/warped-3d-print.jpg,site,,yes +web/content/journal/v1-30-days.mdx,/journal/v1-30-days/assembled-pcb-back.jpg,site,,yes +web/content/journal/v1-30-days.mdx,/journal/v1-30-days/debugging-wires-esp32-potentiometer.jpg,site,,yes +web/content/journal/v1-30-days.mdx,/journal/v1-30-days/first-pcb.jpg,site,,yes +web/content/journal/v1-30-days.mdx,/journal/v1-30-days/kicad-schematic-screenshot.png,site,,yes +web/content/journal/v1-30-days.mdx,/journal/v1-30-days/led-panel-in-nature-1.jpg,site,,yes +web/content/journal/v1-30-days.mdx,/journal/v1-30-days/led-panel-in-nature-2.jpg,site,,yes +web/content/journal/v1-30-days.mdx,/journal/v1-30-days/participation-tv-experience.jpg,site,,yes +web/content/journal/v1-30-days.mdx,/journal/v1-30-days/patternflow-origin-interaction.png,site,,yes +web/content/journal/v1-30-days.mdx,/journal/v1-30-days/patternflow-v1-complete.jpg,site,,yes +web/content/journal/v1-30-days.mdx,/journal/v1-30-days/reddit-comment-1.png,site,,yes +web/content/journal/v1-30-days.mdx,/journal/v1-30-days/reddit-comment-2.png,site,,yes +web/content/journal/v1-30-days.mdx,/journal/v1-30-days/robot-k456.jpg,site,,yes +web/content/journal/v1-30-days.mdx,/journal/v1-30-days/tangled-wires-led-panel.jpg,site,,yes +web/content/journal/v1-30-days.mdx,/journal/v1-30-days/warped-3d-print.jpg,site,,yes +web/content/journal/what-is-patternflow.en.mdx,/journal/what-is-patternflow/viral-x-post.png,site,,yes +web/content/journal/what-is-patternflow.mdx,/journal/what-is-patternflow/viral-x-post.png,site,,yes +web/content/journal/wins-and-losses-next-step.en.mdx,/journal/wins-and-losses-next-step/io0-pullup-resistor.jpg,site,,yes +web/content/journal/wins-and-losses-next-step.en.mdx,/journal/wins-and-losses-next-step/remote-rendering-failure.webm,site,,yes +web/content/journal/wins-and-losses-next-step.mdx,/journal/wins-and-losses-next-step/io0-pullup-resistor.jpg,site,,yes +web/content/journal/wins-and-losses-next-step.mdx,/journal/wins-and-losses-next-step/remote-rendering-failure.webm,site,,yes +web/content/journal/worn-out.en.mdx,/journal/worn-out/c-type-patternflow.jpg,site,,yes +web/content/journal/worn-out.en.mdx,/journal/worn-out/studying-sound.jpg,site,,yes +web/content/journal/worn-out.mdx,/journal/worn-out/c-type-patternflow.jpg,site,,yes +web/content/journal/worn-out.mdx,/journal/worn-out/studying-sound.jpg,site,,yes +web/next.config.ts,/editions,site,,yes +web/next.config.ts,/guide/play,site,flash,yes +web/next.config.ts,/journal/:slug/en,site,,yes +web/next.config.ts,/journal/en,site,,yes +web/scripts/deck-pack-smoke.ts,/community/p/p123,site,,yes +web/scripts/drift/editions.ts,/,site,,yes +web/scripts/license-smoke.ts,/community/p/abc123,site,,yes +web/scripts/license-smoke.ts,/pattern-lab,site,,yes +web/scripts/module-cache-smoke.ts,/community/p/p-author,site,,yes +web/src/app/build/breadboard/page.tsx,BUILD_GUIDE.md,absolute-blob,,yes +web/src/app/build/breadboard/page.tsx,BUILD_GUIDE.md,absolute-blob,4-3d-printing,yes +web/src/app/build/breadboard/page.tsx,BUILD_GUIDE.md,absolute-blob,82-arduino-ide-custom-builds,yes +web/src/app/build/breadboard/page.tsx,docs/panel-compatibility.md,absolute-blob,,yes +web/src/app/build/page.tsx,/build,site,,yes +web/src/app/compliance/page.tsx,/compliance,site,,yes +web/src/app/editions/editions-data.ts,/flash/bin/audio-v0.7.0/patternflow.ino.bin,site,,yes +web/src/app/editions/editions-data.ts,/flash/bin/core-v3.11.0/patternflow.ino.bin,site,,yes +web/src/app/editions/editions-data.ts,/flash/bin/performance-v0.4.0/patternflow.ino.bin,site,,yes +web/src/app/editions/editions-data.ts,firmware/bundles/audio,absolute-blob,,yes +web/src/app/editions/editions-data.ts,firmware/bundles/performance,absolute-blob,,yes +web/src/app/editions/page.tsx,docs/EDITIONS.md,absolute-blob,,yes +web/src/app/editions/page.tsx,docs/EDITIONS.md,absolute-blob,getting-an-edition-listed,yes +web/src/app/features/TryInstall.tsx,/,site,,yes +web/src/app/features/features-data.ts,PATTERN_GUIDE.md,absolute-blob,,yes +web/src/app/features/features-data.ts,docs/AUDIO_GUIDE.md,absolute-blob,,yes +web/src/app/features/features-data.ts,docs/audio-ws-spec.md,absolute-blob,,yes +web/src/app/features/features-data.ts,docs/director-midi.md,absolute-blob,,yes +web/src/app/features/features-data.ts,docs/midi-spec.md,absolute-blob,,yes +web/src/app/features/features-data.ts,docs/mqtt-spec.md,absolute-blob,,yes +web/src/app/features/features-data.ts,docs/osc-spec.md,absolute-blob,,yes +web/src/app/features/features-data.ts,docs/pfst-v2-spec.md,absolute-blob,,yes +web/src/app/features/features-data.ts,docs/rest-api.md,absolute-blob,,yes +web/src/app/features/features-data.ts,firmware/bundles/midi,absolute-blob,,yes +web/src/app/features/features-data.ts,firmware/patternflow/features/audio,absolute-blob,,yes +web/src/app/features/features-data.ts,firmware/patternflow/features/audio_in,absolute-blob,,yes +web/src/app/features/features-data.ts,firmware/patternflow/features/ble,absolute-blob,,yes +web/src/app/features/features-data.ts,firmware/patternflow/features/clock,absolute-blob,,yes +web/src/app/features/features-data.ts,firmware/patternflow/features/midi,absolute-blob,,yes +web/src/app/features/features-data.ts,firmware/patternflow/features/mqtt,absolute-blob,,yes +web/src/app/features/features-data.ts,firmware/patternflow/features/osc,absolute-blob,,yes +web/src/app/features/features-data.ts,firmware/patternflow/features/show,absolute-blob,,yes +web/src/app/features/features-data.ts,firmware/patternflow/features/weather,absolute-blob,,yes +web/src/app/features/features-data.ts,integrations/ableton,absolute-blob,,yes +web/src/app/features/features-data.ts,tools/patternflow-audio-android,absolute-blob,,yes +web/src/app/features/features-data.ts,tools/patternflow-audio-extension,absolute-blob,,yes +web/src/app/features/page.tsx,FEATURE_GUIDE.md,absolute-blob,,yes +web/src/app/features/page.tsx,docs/EDITIONS.md,absolute-blob,building-an-edition,yes +web/src/app/features/page.tsx,firmware/patternflow/features,absolute-blob,,yes +web/src/app/feed.xml/route.ts,/,site,,yes +web/src/app/feed.xml/route.ts,/feed.xml,site,,yes +web/src/app/feed.xml/route.ts,/journal,site,,yes +web/src/app/inside/[build]/page.tsx,/,site,,yes +web/src/app/inside/page.tsx,/inside,site,,yes +web/src/app/layout.tsx,/,site,,yes +web/src/app/layout.tsx,/,site,organization,yes +web/src/app/layout.tsx,/,site,person,yes +web/src/app/layout.tsx,/,site,website,yes +web/src/app/layout.tsx,/,site,work,yes +web/src/app/layout.tsx,/apple-touch-icon.png,site,,yes +web/src/app/layout.tsx,/og-image.png,site,,yes +web/src/app/pattern/page.tsx,/pattern,site,,yes +web/src/app/roadmap/roadmap-data.ts,/,site,,yes +web/src/app/roadmap/roadmap-data.ts,/build/breadboard,site,,yes +web/src/app/roadmap/roadmap-data.ts,/community,site,,yes +web/src/app/roadmap/roadmap-data.ts,/inside/nath-uk,site,,yes +web/src/app/roadmap/roadmap-data.ts,/journal,site,,yes +web/src/app/roadmap/roadmap-data.ts,/journal/community,site,,yes +web/src/app/roadmap/roadmap-data.ts,/journal/faster-faster,site,,yes +web/src/app/roadmap/roadmap-data.ts,/journal/i-hope-it-works-out,site,,yes +web/src/app/roadmap/roadmap-data.ts,/journal/nam-june-paik-me-patternflow,site,,yes +web/src/app/roadmap/roadmap-data.ts,/journal/refocus,site,,yes +web/src/app/roadmap/roadmap-data.ts,/journal/right-before-launch,site,,yes +web/src/app/roadmap/roadmap-data.ts,/journal/stopped,site,,yes +web/src/app/roadmap/roadmap-data.ts,/journal/v1-30-days,site,,yes +web/src/app/roadmap/roadmap-data.ts,/journal/v3-and-beyond,site,,yes +web/src/app/roadmap/roadmap-data.ts,/journal/what-is-patternflow,site,,yes +web/src/app/roadmap/roadmap-data.ts,/journal/wins-and-losses-next-step,site,,yes +web/src/app/roadmap/roadmap-data.ts,/panel-tuner.html,site,,yes +web/src/app/roadmap/roadmap-data.ts,/pattern,site,,yes +web/src/app/roadmap/roadmap-data.ts,/pattern-lab,site,,yes +web/src/app/roadmap/roadmap-data.ts,BUILD_GUIDE.md,absolute-blob,2-power-input--use-the-screw-terminal,yes +web/src/app/roadmap/roadmap-data.ts,CHANGELOG.md,absolute-blob,,yes +web/src/app/roadmap/roadmap-data.ts,docs/EDITIONS.md,absolute-blob,,yes +web/src/app/roadmap/roadmap-data.ts,docs/osc-spec.md,absolute-blob,,yes +web/src/app/roadmap/roadmap-data.ts,docs/panel-compatibility.md,absolute-blob,,yes +web/src/app/roadmap/roadmap-data.ts,docs/releases/v2.0.0.md,absolute-blob,,yes +web/src/app/roadmap/roadmap-data.ts,firmware/CUSTOM_PATTERNS.md,absolute-blob,,yes +web/src/app/roadmap/roadmap-data.ts,integrations/ableton,absolute-blob,,yes +web/src/app/roadmap/roadmap-data.ts,v1.0.0:docs/BUILD.md,absolute-blob,,yes +web/src/app/roadmap/roadmap-data.ts,v1.0.0:firmware/patternflow_v1,absolute-blob,,yes +web/src/app/roadmap/roadmap-data.ts,v1.0.0:hardware/case,absolute-blob,,yes +web/src/app/roadmap/roadmap-data.ts,v1.0.0:hardware/pcb,absolute-blob,,yes +web/src/app/roadmap/roadmap-data.ts,v2.0.0:docs/BUILD.md,absolute-blob,,yes +web/src/app/roadmap/roadmap-data.ts,v2.1.0:BUILD_GUIDE.md,absolute-blob,,yes +web/src/app/roadmap/roadmap-data.ts,v2.1.0:firmware/patternflow,absolute-blob,,yes +web/src/app/roadmap/roadmap-data.ts,v2.1.0:hardware/case/source,absolute-blob,,yes +web/src/app/roadmap/roadmap-data.ts,v2.1.0:hardware/pcb,absolute-blob,,yes +web/src/app/roadmap/roadmap-data.ts,v3.0.0:BUILD_GUIDE.md,absolute-blob,,yes +web/src/app/roadmap/roadmap-data.ts,v3.0.0:hardware/bom/bom_v3.0.csv,absolute-blob,,yes +web/src/app/roadmap/roadmap-data.ts,v3.0.0:hardware/case,absolute-blob,,yes +web/src/app/roadmap/roadmap-data.ts,v3.0.0:hardware/pcb,absolute-blob,,yes +web/src/app/robots.ts,/,site,,yes +web/src/app/robots.ts,/sitemap.xml,site,,yes +web/src/app/sitemap.ts,/,site,,yes +web/src/app/sitemap.ts,/build,site,,yes +web/src/app/sitemap.ts,/compliance,site,,yes +web/src/app/sitemap.ts,/contact,site,,yes +web/src/app/sitemap.ts,/editions,site,,yes +web/src/app/sitemap.ts,/features,site,,yes +web/src/app/sitemap.ts,/guide,site,,yes +web/src/app/sitemap.ts,/guide/audio,site,,yes +web/src/app/sitemap.ts,/guide/audio/ko,site,,yes +web/src/app/sitemap.ts,/guide/build,site,,yes +web/src/app/sitemap.ts,/guide/build/ko,site,,yes +web/src/app/sitemap.ts,/guide/ko,site,,yes +web/src/app/sitemap.ts,/guide/make,site,,yes +web/src/app/sitemap.ts,/guide/make/ko,site,,yes +web/src/app/sitemap.ts,/guide/play,site,,yes +web/src/app/sitemap.ts,/guide/play/ko,site,,yes +web/src/app/sitemap.ts,/inside,site,,yes +web/src/app/sitemap.ts,/journal,site,,yes +web/src/app/sitemap.ts,/journal/en,site,,yes +web/src/app/sitemap.ts,/pattern,site,,yes +web/src/app/sitemap.ts,/roadmap,site,,yes +web/src/app/sitemap.ts,/terms,site,,yes +web/src/app/terms/page.tsx,docs/LICENSE-SUMMARY.md,absolute-blob,,yes +web/src/components/guide/build/build.test.ts,BUILD_GUIDE.md,absolute-blob,1-bill-of-materials-bom,yes +web/src/components/guide/build/build.test.ts,BUILD_GUIDE.md,absolute-blob,10-known-issues--design-notes,yes +web/src/components/guide/build/build.test.ts,docs/panel-compatibility.md,absolute-blob,,yes +web/src/components/guide/copy/audio.ts,docs/AUDIO_GUIDE.md,absolute-blob,,yes +web/src/components/guide/copy/audio.ts,docs/director-midi.md,absolute-blob,,yes +web/src/components/guide/copy/audio.ts,docs/midi-spec.md,absolute-blob,,yes +web/src/components/guide/copy/audio.ts,docs/osc-spec.md,absolute-blob,,yes +web/src/components/guide/copy/audio.ts,tools/patternflow-audio-android/README.md,absolute-blob,,yes +web/src/components/guide/copy/build.ts,BUILD_GUIDE.md,absolute-blob,,yes +web/src/components/guide/copy/build.ts,BUILD_GUIDE.md,absolute-blob,1-bill-of-materials-bom,yes +web/src/components/guide/copy/build.ts,BUILD_GUIDE.md,absolute-blob,10-known-issues--design-notes,yes +web/src/components/guide/copy/build.ts,BUILD_GUIDE.md,absolute-blob,4-3d-printing,yes +web/src/components/guide/copy/build.ts,BUILD_GUIDE.md,absolute-blob,6-case-assembly,yes +web/src/components/guide/copy/build.ts,BUILD_GUIDE.md,absolute-blob,what-you-also-need-not-in-bom,yes +web/src/components/guide/copy/build.ts,docs/panel-compatibility.md,absolute-blob,,yes +web/src/components/guide/copy/build.ts,hardware/case,absolute-blob,,yes +web/src/components/guide/copy/build.ts,hardware/case/bed_256mm/encloser.stl,absolute-blob,,yes +web/src/components/guide/copy/build.ts,hardware/case/bed_256mm/for_other_panels,absolute-blob,,yes +web/src/components/guide/copy/build.ts,hardware/case/bed_256mm/patternflow_v3.3mf,absolute-blob,,yes +web/src/components/guide/copy/build.ts,hardware/case/bed_330mm/encloser.stl,absolute-blob,,yes +web/src/components/guide/copy/build.ts,hardware/pcb/gerber/patternflow_v3.9_gerber.zip,absolute-blob,,yes +web/src/components/guide/copy/build.ts,hardware/pcb/kicad,absolute-blob,,yes +web/src/components/guide/desk/ai/headers.ts,/pattern-lab,site,,yes +web/src/components/sections/BuildPanel.test.tsx,docs/legacy/BUILD_GUIDE_v2.md,absolute-blob,,yes +web/src/components/sections/BuildPanel.test.tsx,docs/panel-compatibility.md,absolute-blob,,yes +web/src/components/sections/BuildPanel.test.tsx,hardware/case/README.md,absolute-blob,for_other_panels--using-a-different-led-panel,yes +web/src/components/sections/BuildPanel.test.tsx,hardware/case/remixes/besoiobiy-printed,absolute-blob,,yes +web/src/components/sections/BuildPanel.test.tsx,hardware/case/remixes/besoiobiy-printed/README.md,absolute-blob,,yes +web/src/components/sections/BuildPanel.test.tsx,hardware/case/remixes/besoiobiy-printed/README.md,absolute-blob,check-your-panel-first,yes +web/src/components/sections/BuildPanel.test.tsx,hardware/case/remixes/besoiobiy-printed/print_layout.3mf,absolute-blob,,yes +web/src/components/sections/BuildPanel.test.tsx,hardware/case/remixes/mbchars-horizontal-desktop-printed,absolute-blob,,yes +web/src/components/sections/BuildPanel.test.tsx,hardware/case/remixes/mbchars-horizontal-desktop-printed/README.md,absolute-blob,,yes +web/src/components/sections/BuildPanel.test.tsx,hardware/case/remixes/mbchars-horizontal-desktop-printed/assembly.md,absolute-blob,,yes +web/src/components/sections/BuildPanel.test.tsx,hardware/case/remixes/simonepda-lasercut,absolute-blob,,yes +web/src/components/sections/BuildPanel.test.tsx,hardware/case/remixes/simonepda-lasercut/README.md,absolute-blob,,yes +web/src/components/sections/BuildPanel.test.tsx,hardware/case/remixes/simonepda-lasercut/lasercut_layout.pdf,absolute-blob,,yes +web/src/components/sections/BuildPanel.tsx,BUILD_GUIDE.md,absolute-blob,,yes +web/src/components/sections/BuildPanel.tsx,docs/assembly/README.md,absolute-blob,,yes +web/src/components/sections/BuildPanel.tsx,docs/legacy/BUILD_GUIDE_v2.md,absolute-blob,,yes +web/src/components/sections/BuildPanel.tsx,docs/panel-compatibility.md,absolute-blob,,yes +web/src/components/sections/BuildPanel.tsx,hardware/README.md,absolute-blob,,yes +web/src/components/sections/PatternPanel.tsx,BUILD_GUIDE.md,absolute-blob,8-firmware,yes +web/src/components/sections/PatternPanel.tsx,firmware/README.md,absolute-blob,,yes +web/src/lib/ai/gemini.ts,/pattern-lab,site,,yes +web/src/lib/pattern/packs.ts,/,site,,yes +web/src/lib/pattern/share.ts,/community,site,,yes +web/src/lib/pattern/share.ts,/pattern,site,,yes +web/src/lib/pattern/share.ts,/pattern-lab,site,,yes +web/src/lib/presets/README.md,/pattern-lab,site,,yes +web/src/lib/presets/README.md,firmware/modules/README.md,relative,,yes +web/src/lib/presets/README.md,firmware/patternflow/presets/README.md,relative,,yes +web/src/lib/presets/README.md,web/ARCHITECTURE.md,relative,pattern-system,yes +web/src/lib/presets/pattern-0624.ts,/,site,,yes +web/src/lib/presets/pattern-0624.ts,/pattern-lab,site,,yes +web/src/lib/presets/pattern-0628.ts,/,site,,yes +web/src/lib/presets/pattern-0628.ts,/pattern-lab,site,,yes +web/src/lib/presets/pattern-0629-2.ts,/,site,,yes +web/src/lib/presets/pattern-0629.ts,/,site,,yes +web/src/lib/presets/pattern-0701.ts,/,site,,yes +web/src/lib/presets/pattern-0707.ts,/pattern-lab,site,,yes +web/src/lib/presets/pattern-0710.ts,/pattern-lab,site,,yes +web/src/lib/presets/pattern-0712-2.ts,/pattern-lab,site,,yes +web/src/lib/presets/pattern-0712.ts,/pattern-lab,site,,yes +web/src/lib/presets/pattern-0713.ts,/pattern-lab,site,,yes +web/src/lib/presets/pattern-0715.ts,/pattern-lab,site,,yes +web/src/lib/presets/pattern-0716.ts,/pattern,site,,yes +web/src/lib/presets/pattern-0718.ts,/pattern-lab,site,,yes +web/src/lib/presets/pattern-0719.ts,/pattern-lab,site,,yes diff --git a/docs/investigations/2026-10-doc-parity.csv b/docs/investigations/2026-10-doc-parity.csv new file mode 100644 index 000000000..3765b95e5 --- /dev/null +++ b/docs/investigations/2026-10-doc-parity.csv @@ -0,0 +1,1367 @@ +kind,en,ko,en_chars,ko_chars,state +copy,web/src/components/community/AtlasClient.tsx#UI.en.addPattern,web/src/components/community/AtlasClient.tsx#UI.ko.addPattern,12,6,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.allChip,web/src/components/community/AtlasClient.tsx#UI.ko.allChip,3,2,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.axisChaos,web/src/components/community/AtlasClient.tsx#UI.ko.axisChaos,6,8,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.axisField,web/src/components/community/AtlasClient.tsx#UI.ko.axisField,6,7,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.axisOrder,web/src/components/community/AtlasClient.tsx#UI.ko.axisOrder,6,8,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.axisWire,web/src/components/community/AtlasClient.tsx#UI.ko.axisWire,5,6,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.cancel,web/src/components/community/AtlasClient.tsx#UI.ko.cancel,6,2,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.copiedLayout,web/src/components/community/AtlasClient.tsx#UI.ko.copiedLayout,12,9,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.copiedPrompt,web/src/components/community/AtlasClient.tsx#UI.ko.copiedPrompt,37,23,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.copyLayout,web/src/components/community/AtlasClient.tsx#UI.ko.copyLayout,10,4,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.copyPrompt,web/src/components/community/AtlasClient.tsx#UI.ko.copyPrompt,10,6,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.edit,web/src/components/community/AtlasClient.tsx#UI.ko.edit,10,4,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.editing,web/src/components/community/AtlasClient.tsx#UI.ko.editing,18,8,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.fileResearch,web/src/components/community/AtlasClient.tsx#UI.ko.fileResearch,27,15,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.introTitle,web/src/components/community/AtlasClient.tsx#UI.ko.introTitle,18,6,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.knobs,web/src/components/community/AtlasClient.tsx#UI.ko.knobs,5,2,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.legendPin,web/src/components/community/AtlasClient.tsx#UI.ko.legendPin,39,15,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.linkNone,web/src/components/community/AtlasClient.tsx#UI.ko.linkNone,6,4,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.madeFrom,web/src/components/community/AtlasClient.tsx#UI.ko.madeFrom,14,6,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.moveHint,web/src/components/community/AtlasClient.tsx#UI.ko.moveHint,32,17,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.newBadge,web/src/components/community/AtlasClient.tsx#UI.ko.newBadge,3,4,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.newChip,web/src/components/community/AtlasClient.tsx#UI.ko.newChip,4,5,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.openPattern,web/src/components/community/AtlasClient.tsx#UI.ko.openPattern,12,5,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.patternsFrom,web/src/components/community/AtlasClient.tsx#UI.ko.patternsFrom,22,11,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.pickerHead,web/src/components/community/AtlasClient.tsx#UI.ko.pickerHead,26,9,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.pickerSearch,web/src/components/community/AtlasClient.tsx#UI.ko.pickerSearch,14,7,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.pinChip,web/src/components/community/AtlasClient.tsx#UI.ko.pinChip,6,1,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.placeBanner,web/src/components/community/AtlasClient.tsx#UI.ko.placeBanner,35,15,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.placeBannerResearch,web/src/components/community/AtlasClient.tsx#UI.ko.placeBannerResearch,45,17,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.privateTag,web/src/components/community/AtlasClient.tsx#UI.ko.privateTag,16,8,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.promotePin,web/src/components/community/AtlasClient.tsx#UI.ko.promotePin,18,8,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.removePin,web/src/components/community/AtlasClient.tsx#UI.ko.removePin,16,7,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.researchBadge,web/src/components/community/AtlasClient.tsx#UI.ko.researchBadge,12,4,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.researchFrom,web/src/components/community/AtlasClient.tsx#UI.ko.researchFrom,17,5,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.researchHint,web/src/components/community/AtlasClient.tsx#UI.ko.researchHint,44,25,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.secImpl,web/src/components/community/AtlasClient.tsx#UI.ko.secImpl,14,2,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.secKnob,web/src/components/community/AtlasClient.tsx#UI.ko.secKnob,28,9,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.secPrompt,web/src/components/community/AtlasClient.tsx#UI.ko.secPrompt,33,18,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.secRisk,web/src/components/community/AtlasClient.tsx#UI.ko.secRisk,7,2,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.secTexture,web/src/components/community/AtlasClient.tsx#UI.ko.secTexture,7,2,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.secVert,web/src/components/community/AtlasClient.tsx#UI.ko.secVert,12,3,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.tagline,web/src/components/community/AtlasClient.tsx#UI.ko.tagline,42,18,ok +copy,web/src/components/community/AtlasClient.tsx#UI.en.zoomHint,web/src/components/community/AtlasClient.tsx#UI.ko.zoomHint,41,16,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.audioIn.label.en,web/src/components/guide/AudioShots.tsx#SETS.audioIn.label.ko,41,24,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.audioIn.shots[0].alt.en,web/src/components/guide/AudioShots.tsx#SETS.audioIn.shots[0].alt.ko,103,64,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.audioIn.shots[0].en,web/src/components/guide/AudioShots.tsx#SETS.audioIn.shots[0].ko,41,29,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.audioIn.shots[1].alt.en,web/src/components/guide/AudioShots.tsx#SETS.audioIn.shots[1].alt.ko,129,76,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.audioIn.shots[1].en,web/src/components/guide/AudioShots.tsx#SETS.audioIn.shots[1].ko,40,24,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.audioInMap.label.en,web/src/components/guide/AudioShots.tsx#SETS.audioInMap.label.ko,29,18,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.audioInMap.shots[0].alt.en,web/src/components/guide/AudioShots.tsx#SETS.audioInMap.shots[0].alt.ko,112,64,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.audioInMap.shots[0].en,web/src/components/guide/AudioShots.tsx#SETS.audioInMap.shots[0].ko,44,29,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.audioInNoMic.label.en,web/src/components/guide/AudioShots.tsx#SETS.audioInNoMic.label.ko,42,18,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.audioInNoMic.shots[0].alt.en,web/src/components/guide/AudioShots.tsx#SETS.audioInNoMic.shots[0].alt.ko,171,121,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.audioInNoMic.shots[0].en,web/src/components/guide/AudioShots.tsx#SETS.audioInNoMic.shots[0].ko,71,42,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.liveRemote.label.en,web/src/components/guide/AudioShots.tsx#SETS.liveRemote.label.ko,22,18,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.liveRemote.shots[0].alt.en,web/src/components/guide/AudioShots.tsx#SETS.liveRemote.shots[0].alt.ko,134,87,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.liveRemote.shots[0].en,web/src/components/guide/AudioShots.tsx#SETS.liveRemote.shots[0].ko,69,43,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.micPart.label.en,web/src/components/guide/AudioShots.tsx#SETS.micPart.label.ko,21,9,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.micPart.shots[0].alt.en,web/src/components/guide/AudioShots.tsx#SETS.micPart.shots[0].alt.ko,104,61,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.micPart.shots[0].en,web/src/components/guide/AudioShots.tsx#SETS.micPart.shots[0].ko,66,49,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.micSeated.label.en,web/src/components/guide/AudioShots.tsx#SETS.micSeated.label.ko,27,18,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.micSeated.shots[0].alt.en,web/src/components/guide/AudioShots.tsx#SETS.micSeated.shots[0].alt.ko,163,77,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.micSeated.shots[0].en,web/src/components/guide/AudioShots.tsx#SETS.micSeated.shots[0].ko,118,64,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.micWiring.label.en,web/src/components/guide/AudioShots.tsx#SETS.micWiring.label.ko,40,18,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.micWiring.shots[0].alt.en,web/src/components/guide/AudioShots.tsx#SETS.micWiring.shots[0].alt.ko,163,81,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.micWiring.shots[0].en,web/src/components/guide/AudioShots.tsx#SETS.micWiring.shots[0].ko,88,50,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.midiPage.label.en,web/src/components/guide/AudioShots.tsx#SETS.midiPage.label.ko,31,20,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.midiPage.shots[0].alt.en,web/src/components/guide/AudioShots.tsx#SETS.midiPage.shots[0].alt.ko,168,111,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.midiPage.shots[0].en,web/src/components/guide/AudioShots.tsx#SETS.midiPage.shots[0].ko,37,27,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.midiSession.label.en,web/src/components/guide/AudioShots.tsx#SETS.midiSession.label.ko,28,17,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.midiSession.shots[0].alt.en,web/src/components/guide/AudioShots.tsx#SETS.midiSession.shots[0].alt.ko,171,105,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.midiSession.shots[0].en,web/src/components/guide/AudioShots.tsx#SETS.midiSession.shots[0].ko,79,50,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.popup.label.en,web/src/components/guide/AudioShots.tsx#SETS.popup.label.ko,39,17,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.popup.shots[0].alt.en,web/src/components/guide/AudioShots.tsx#SETS.popup.shots[0].alt.ko,109,63,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.popup.shots[0].en,web/src/components/guide/AudioShots.tsx#SETS.popup.shots[0].ko,38,26,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.popup.shots[1].alt.en,web/src/components/guide/AudioShots.tsx#SETS.popup.shots[1].alt.ko,130,57,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.popup.shots[1].en,web/src/components/guide/AudioShots.tsx#SETS.popup.shots[1].ko,41,27,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.popupStates.label.en,web/src/components/guide/AudioShots.tsx#SETS.popupStates.label.ko,42,19,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.popupStates.shots[0].alt.en,web/src/components/guide/AudioShots.tsx#SETS.popupStates.shots[0].alt.ko,125,66,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.popupStates.shots[0].en,web/src/components/guide/AudioShots.tsx#SETS.popupStates.shots[0].ko,46,28,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.popupStates.shots[1].alt.en,web/src/components/guide/AudioShots.tsx#SETS.popupStates.shots[1].alt.ko,104,51,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.popupStates.shots[1].en,web/src/components/guide/AudioShots.tsx#SETS.popupStates.shots[1].ko,43,22,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.rtpmidi.label.en,web/src/components/guide/AudioShots.tsx#SETS.rtpmidi.label.ko,22,11,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.rtpmidi.shots[0].alt.en,web/src/components/guide/AudioShots.tsx#SETS.rtpmidi.shots[0].alt.ko,83,59,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.rtpmidi.shots[0].en,web/src/components/guide/AudioShots.tsx#SETS.rtpmidi.shots[0].ko,45,35,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.rtpmidi.shots[1].alt.en,web/src/components/guide/AudioShots.tsx#SETS.rtpmidi.shots[1].alt.ko,116,60,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.rtpmidi.shots[1].en,web/src/components/guide/AudioShots.tsx#SETS.rtpmidi.shots[1].ko,41,24,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.rtpmidi.shots[2].alt.en,web/src/components/guide/AudioShots.tsx#SETS.rtpmidi.shots[2].alt.ko,105,73,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.rtpmidi.shots[2].en,web/src/components/guide/AudioShots.tsx#SETS.rtpmidi.shots[2].ko,44,34,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.rtpmidi.shots[3].alt.en,web/src/components/guide/AudioShots.tsx#SETS.rtpmidi.shots[3].alt.ko,71,48,ok +copy,web/src/components/guide/AudioShots.tsx#SETS.rtpmidi.shots[3].en,web/src/components/guide/AudioShots.tsx#SETS.rtpmidi.shots[3].ko,44,28,ok +copy,web/src/components/guide/AudioShots.tsx#UI.en.from,web/src/components/guide/AudioShots.tsx#UI.ko.from,10,3,ok +copy,web/src/components/guide/AudioShots.tsx#UI.en.leads,web/src/components/guide/AudioShots.tsx#UI.ko.leads,49,20,ok +copy,web/src/components/guide/AudioShots.tsx#UI.en.step,web/src/components/guide/AudioShots.tsx#UI.ko.step,6,2,ok +copy,web/src/components/guide/CommunityShots.tsx#DECK.en,web/src/components/guide/CommunityShots.tsx#DECK.ko,11,4,ok +copy,web/src/components/guide/CommunityShots.tsx#DECK_BAR.en,web/src/components/guide/CommunityShots.tsx#DECK_BAR.ko,17,4,ok +copy,web/src/components/guide/CommunityShots.tsx#PATTERN.en,web/src/components/guide/CommunityShots.tsx#PATTERN.ko,14,5,ok +copy,web/src/components/guide/CommunityShots.tsx#STEPS[0][0].en,web/src/components/guide/CommunityShots.tsx#STEPS[0][0].ko,61,32,ok +copy,web/src/components/guide/CommunityShots.tsx#STEPS[0][1].en,web/src/components/guide/CommunityShots.tsx#STEPS[0][1].ko,57,40,ok +copy,web/src/components/guide/CommunityShots.tsx#STEPS[0][2].en,web/src/components/guide/CommunityShots.tsx#STEPS[0][2].ko,71,34,ok +copy,web/src/components/guide/CommunityShots.tsx#STEPS[1][0].en,web/src/components/guide/CommunityShots.tsx#STEPS[1][0].ko,65,28,ok +copy,web/src/components/guide/CommunityShots.tsx#STEPS[1][1].en,web/src/components/guide/CommunityShots.tsx#STEPS[1][1].ko,39,18,ok +copy,web/src/components/guide/CommunityShots.tsx#STEPS[1][2].en,web/src/components/guide/CommunityShots.tsx#STEPS[1][2].ko,81,50,ok +copy,web/src/components/guide/CommunityShots.tsx#STEPS[2][0].en,web/src/components/guide/CommunityShots.tsx#STEPS[2][0].ko,45,25,ok +copy,web/src/components/guide/CommunityShots.tsx#STEPS[2][1].en,web/src/components/guide/CommunityShots.tsx#STEPS[2][1].ko,81,36,ok +copy,web/src/components/guide/CommunityShots.tsx#STEPS[2][2].en,web/src/components/guide/CommunityShots.tsx#STEPS[2][2].ko,90,55,ok +copy,web/src/components/guide/CommunityShots.tsx#STEPS[3][0].en,web/src/components/guide/CommunityShots.tsx#STEPS[3][0].ko,41,22,ok +copy,web/src/components/guide/CommunityShots.tsx#STEPS[3][1].en,web/src/components/guide/CommunityShots.tsx#STEPS[3][1].ko,64,27,ok +copy,web/src/components/guide/CommunityShots.tsx#STEPS[3][2].en,web/src/components/guide/CommunityShots.tsx#STEPS[3][2].ko,58,27,ok +copy,web/src/components/guide/CommunityShots.tsx#STEPS[3][3].en,web/src/components/guide/CommunityShots.tsx#STEPS[3][3].ko,70,32,ok +copy,web/src/components/guide/CommunityShots.tsx#STEPS[3][4].en,web/src/components/guide/CommunityShots.tsx#STEPS[3][4].ko,80,41,ok +copy,web/src/components/guide/CommunityShots.tsx#STEPS[4][0].en,web/src/components/guide/CommunityShots.tsx#STEPS[4][0].ko,66,33,ok +copy,web/src/components/guide/CommunityShots.tsx#STEPS[4][1].en,web/src/components/guide/CommunityShots.tsx#STEPS[4][1].ko,75,41,ok +copy,web/src/components/guide/CommunityShots.tsx#STEPS[4][2].en,web/src/components/guide/CommunityShots.tsx#STEPS[4][2].ko,65,33,ok +copy,web/src/components/guide/CommunityShots.tsx#STEPS[4][3].en,web/src/components/guide/CommunityShots.tsx#STEPS[4][3].ko,69,39,ok +copy,web/src/components/guide/CommunityShots.tsx#STEPS[5][0].en,web/src/components/guide/CommunityShots.tsx#STEPS[5][0].ko,38,21,ok +copy,web/src/components/guide/CommunityShots.tsx#STEPS[5][1].en,web/src/components/guide/CommunityShots.tsx#STEPS[5][1].ko,66,29,ok +copy,web/src/components/guide/CommunityShots.tsx#STEPS[5][2].en,web/src/components/guide/CommunityShots.tsx#STEPS[5][2].ko,53,22,ok +copy,web/src/components/guide/CommunityShots.tsx#UI.en.label,web/src/components/guide/CommunityShots.tsx#UI.ko.label,27,8,ok +copy,web/src/components/guide/CommunityShots.tsx#UI.en.step,web/src/components/guide/CommunityShots.tsx#UI.ko.step,6,2,ok +copy,web/src/components/guide/ConsoleWindow.tsx#UI.en.close,web/src/components/guide/ConsoleWindow.tsx#UI.ko.close,5,2,ok +copy,web/src/components/guide/ConsoleWindow.tsx#UI.en.label,web/src/components/guide/ConsoleWindow.tsx#UI.ko.label,16,4,ok +copy,web/src/components/guide/ConsoleWindow.tsx#UI.en.open,web/src/components/guide/ConsoleWindow.tsx#UI.ko.open,4,2,ok +copy,web/src/components/guide/EditorWindow.tsx#EDITOR_WINDOW_WORDS.en.caption,web/src/components/guide/EditorWindow.tsx#EDITOR_WINDOW_WORDS.ko.caption,79,56,en-short +copy,web/src/components/guide/EditorWindow.tsx#EDITOR_WINDOW_WORDS.en.close,web/src/components/guide/EditorWindow.tsx#EDITOR_WINDOW_WORDS.ko.close,5,2,ok +copy,web/src/components/guide/EditorWindow.tsx#EDITOR_WINDOW_WORDS.en.label,web/src/components/guide/EditorWindow.tsx#EDITOR_WINDOW_WORDS.ko.label,16,5,ok +copy,web/src/components/guide/EditorWindow.tsx#EDITOR_WINDOW_WORDS.en.open,web/src/components/guide/EditorWindow.tsx#EDITOR_WINDOW_WORDS.ko.open,4,2,ok +copy,web/src/components/guide/FlasherShots.tsx#CONNECTED.en,web/src/components/guide/FlasherShots.tsx#CONNECTED.ko,49,29,ok +copy,web/src/components/guide/FlasherShots.tsx#CONNECTING.en,web/src/components/guide/FlasherShots.tsx#CONNECTING.ko,37,18,ok +copy,web/src/components/guide/FlasherShots.tsx#SCANNING.en,web/src/components/guide/FlasherShots.tsx#SCANNING.ko,62,35,ok +copy,web/src/components/guide/FlasherShots.tsx#SETS.dashboard[0].en,web/src/components/guide/FlasherShots.tsx#SETS.dashboard[0].ko,104,68,ok +copy,web/src/components/guide/FlasherShots.tsx#SETS.dashboard[2].en,web/src/components/guide/FlasherShots.tsx#SETS.dashboard[2].ko,92,62,ok +copy,web/src/components/guide/FlasherShots.tsx#SETS.install[0].en,web/src/components/guide/FlasherShots.tsx#SETS.install[0].ko,64,36,ok +copy,web/src/components/guide/FlasherShots.tsx#SETS.install[1].en,web/src/components/guide/FlasherShots.tsx#SETS.install[1].ko,43,29,ok +copy,web/src/components/guide/FlasherShots.tsx#SETS.install[2].en,web/src/components/guide/FlasherShots.tsx#SETS.install[2].ko,41,25,ok +copy,web/src/components/guide/FlasherShots.tsx#SETS.install[3].en,web/src/components/guide/FlasherShots.tsx#SETS.install[3].ko,43,20,ok +copy,web/src/components/guide/FlasherShots.tsx#SETS.install[4].en,web/src/components/guide/FlasherShots.tsx#SETS.install[4].ko,90,48,ok +copy,web/src/components/guide/FlasherShots.tsx#SETS.install[5].en,web/src/components/guide/FlasherShots.tsx#SETS.install[5].ko,116,62,ok +copy,web/src/components/guide/FlasherShots.tsx#SETS.wifi[0].en,web/src/components/guide/FlasherShots.tsx#SETS.wifi[0].ko,105,48,ok +copy,web/src/components/guide/FlasherShots.tsx#SETS.wifi[2].en,web/src/components/guide/FlasherShots.tsx#SETS.wifi[2].ko,90,47,ok +copy,web/src/components/guide/FlasherShots.tsx#SET_LABEL.dashboard.en,web/src/components/guide/FlasherShots.tsx#SET_LABEL.dashboard.ko,16,8,ok +copy,web/src/components/guide/FlasherShots.tsx#SET_LABEL.install.en,web/src/components/guide/FlasherShots.tsx#SET_LABEL.install.ko,7,2,ok +copy,web/src/components/guide/FlasherShots.tsx#SET_LABEL.wifi.en,web/src/components/guide/FlasherShots.tsx#SET_LABEL.wifi.ko,19,9,ok +copy,web/src/components/guide/FlasherShots.tsx#UI.en.label,web/src/components/guide/FlasherShots.tsx#UI.ko.label,26,15,ok +copy,web/src/components/guide/FlasherShots.tsx#UI.en.sets,web/src/components/guide/FlasherShots.tsx#UI.ko.sets,19,8,ok +copy,web/src/components/guide/FlasherShots.tsx#UI.en.step,web/src/components/guide/FlasherShots.tsx#UI.ko.step,6,2,ok +copy,web/src/components/guide/LabShots.tsx#UI.en.label,web/src/components/guide/LabShots.tsx#UI.ko.label,25,7,ok +copy,web/src/components/guide/LabShots.tsx#UI.en.step,web/src/components/guide/LabShots.tsx#UI.ko.step,6,2,ok +copy,web/src/components/guide/LabShots.tsx#WORDS.apply.en,web/src/components/guide/LabShots.tsx#WORDS.apply.ko,107,64,ok +copy,web/src/components/guide/LabShots.tsx#WORDS.blend.en,web/src/components/guide/LabShots.tsx#WORDS.blend.ko,96,60,ok +copy,web/src/components/guide/LabShots.tsx#WORDS.bw.en,web/src/components/guide/LabShots.tsx#WORDS.bw.ko,65,40,ok +copy,web/src/components/guide/LabShots.tsx#WORDS.color.en,web/src/components/guide/LabShots.tsx#WORDS.color.ko,109,62,ok +copy,web/src/components/guide/LabShots.tsx#WORDS.convert.en,web/src/components/guide/LabShots.tsx#WORDS.convert.ko,118,58,ok +copy,web/src/components/guide/LabShots.tsx#WORDS.copyPrompt.en,web/src/components/guide/LabShots.tsx#WORDS.copyPrompt.ko,46,22,ok +copy,web/src/components/guide/LabShots.tsx#WORDS.director.en,web/src/components/guide/LabShots.tsx#WORDS.director.ko,146,77,ok +copy,web/src/components/guide/LabShots.tsx#WORDS.exportGo.en,web/src/components/guide/LabShots.tsx#WORDS.exportGo.ko,54,39,ok +copy,web/src/components/guide/LabShots.tsx#WORDS.exportSize.en,web/src/components/guide/LabShots.tsx#WORDS.exportSize.ko,70,56,ok +copy,web/src/components/guide/LabShots.tsx#WORDS.gallery.en,web/src/components/guide/LabShots.tsx#WORDS.gallery.ko,106,63,ok +copy,web/src/components/guide/LabShots.tsx#WORDS.hwLayers.en,web/src/components/guide/LabShots.tsx#WORDS.hwLayers.ko,87,51,ok +copy,web/src/components/guide/LabShots.tsx#WORDS.knobs.en,web/src/components/guide/LabShots.tsx#WORDS.knobs.ko,80,33,ok +copy,web/src/components/guide/LabShots.tsx#WORDS.layers.en,web/src/components/guide/LabShots.tsx#WORDS.layers.ko,69,35,ok +copy,web/src/components/guide/LabShots.tsx#WORDS.mask.en,web/src/components/guide/LabShots.tsx#WORDS.mask.ko,82,56,ok +copy,web/src/components/guide/LabShots.tsx#WORDS.name.en,web/src/components/guide/LabShots.tsx#WORDS.name.ko,82,54,ok +copy,web/src/components/guide/LabShots.tsx#WORDS.paste.en,web/src/components/guide/LabShots.tsx#WORDS.paste.ko,74,31,ok +copy,web/src/components/guide/LabShots.tsx#WORDS.pasted.en,web/src/components/guide/LabShots.tsx#WORDS.pasted.ko,83,57,ok +copy,web/src/components/guide/LabShots.tsx#WORDS.perf.en,web/src/components/guide/LabShots.tsx#WORDS.perf.ko,89,47,ok +copy,web/src/components/guide/LabShots.tsx#WORDS.port.en,web/src/components/guide/LabShots.tsx#WORDS.port.ko,89,43,ok +copy,web/src/components/guide/LabShots.tsx#WORDS.previewLayers.en,web/src/components/guide/LabShots.tsx#WORDS.previewLayers.ko,24,21,ok +copy,web/src/components/guide/LabShots.tsx#WORDS.push.en,web/src/components/guide/LabShots.tsx#WORDS.push.ko,55,27,ok +copy,web/src/components/guide/LabShots.tsx#WORDS.ramp.en,web/src/components/guide/LabShots.tsx#WORDS.ramp.ko,40,21,ok +copy,web/src/components/guide/LabShots.tsx#WORDS.rampRandom.en,web/src/components/guide/LabShots.tsx#WORDS.rampRandom.ko,71,40,ok +copy,web/src/components/guide/LabShots.tsx#WORDS.recent.en,web/src/components/guide/LabShots.tsx#WORDS.recent.ko,72,41,ok +copy,web/src/components/guide/LabShots.tsx#WORDS.saved.en,web/src/components/guide/LabShots.tsx#WORDS.saved.ko,65,35,ok +copy,web/src/components/guide/LabShots.tsx#WORDS.upload.en,web/src/components/guide/LabShots.tsx#WORDS.upload.ko,138,76,ok +copy,web/src/components/guide/LabWindow.tsx#LAB_WINDOW_WORDS.en.open,web/src/components/guide/LabWindow.tsx#LAB_WINDOW_WORDS.ko.open,4,2,ok +copy,web/src/components/guide/LabWindow.tsx#LAB_WINDOW_WORDS.en.phoneOpen,web/src/components/guide/LabWindow.tsx#LAB_WINDOW_WORDS.ko.phoneOpen,14,5,ok +copy,web/src/components/guide/copy.ts#COPY.en.console.lede,web/src/components/guide/copy.ts#COPY.ko.console.lede,56,28,ok +copy,web/src/components/guide/copy.ts#COPY.en.console.steps[0].body[0],web/src/components/guide/copy.ts#COPY.ko.console.steps[0].body[0],127,75,ok +copy,web/src/components/guide/copy.ts#COPY.en.console.steps[0].body[1],web/src/components/guide/copy.ts#COPY.ko.console.steps[0].body[1],105,60,ok +copy,web/src/components/guide/copy.ts#COPY.en.console.steps[0].title,web/src/components/guide/copy.ts#COPY.ko.console.steps[0].title,17,9,ok +copy,web/src/components/guide/copy.ts#COPY.en.console.steps[1].body[0],web/src/components/guide/copy.ts#COPY.ko.console.steps[1].body[0],109,64,ok +copy,web/src/components/guide/copy.ts#COPY.en.console.steps[1].body[1],web/src/components/guide/copy.ts#COPY.ko.console.steps[1].body[1],37,21,ok +copy,web/src/components/guide/copy.ts#COPY.en.console.steps[1].kicker,web/src/components/guide/copy.ts#COPY.ko.console.steps[1].kicker,8,3,ok +copy,web/src/components/guide/copy.ts#COPY.en.console.steps[1].title,web/src/components/guide/copy.ts#COPY.ko.console.steps[1].title,18,9,ok +copy,web/src/components/guide/copy.ts#COPY.en.console.steps[2].body[0],web/src/components/guide/copy.ts#COPY.ko.console.steps[2].body[0],157,76,ok +copy,web/src/components/guide/copy.ts#COPY.en.console.steps[2].kicker,web/src/components/guide/copy.ts#COPY.ko.console.steps[2].kicker,6,3,ok +copy,web/src/components/guide/copy.ts#COPY.en.console.steps[2].title,web/src/components/guide/copy.ts#COPY.ko.console.steps[2].title,11,7,ok +copy,web/src/components/guide/copy.ts#COPY.en.console.title,web/src/components/guide/copy.ts#COPY.ko.console.title,10,3,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.lede,web/src/components/guide/copy.ts#COPY.ko.flash.lede,84,43,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[0].body[0],web/src/components/guide/copy.ts#COPY.ko.flash.steps[0].body[0],137,63,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[0].kicker,web/src/components/guide/copy.ts#COPY.ko.flash.steps[0].kicker,8,5,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[0].note,web/src/components/guide/copy.ts#COPY.ko.flash.steps[0].note,58,29,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[0].title,web/src/components/guide/copy.ts#COPY.ko.flash.steps[0].title,30,16,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[1].body[0],web/src/components/guide/copy.ts#COPY.ko.flash.steps[1].body[0],101,44,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[1].kicker,web/src/components/guide/copy.ts#COPY.ko.flash.steps[1].kicker,14,6,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[1].title,web/src/components/guide/copy.ts#COPY.ko.flash.steps[1].title,25,15,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[2].body[0],web/src/components/guide/copy.ts#COPY.ko.flash.steps[2].body[0],93,43,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[2].body[1],web/src/components/guide/copy.ts#COPY.ko.flash.steps[2].body[1],87,39,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[2].kicker,web/src/components/guide/copy.ts#COPY.ko.flash.steps[2].kicker,11,4,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[2].title,web/src/components/guide/copy.ts#COPY.ko.flash.steps[2].title,19,7,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[2].warn,web/src/components/guide/copy.ts#COPY.ko.flash.steps[2].warn,73,34,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[3].body[0],web/src/components/guide/copy.ts#COPY.ko.flash.steps[3].body[0],130,73,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[3].kicker,web/src/components/guide/copy.ts#COPY.ko.flash.steps[3].kicker,12,6,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[3].title,web/src/components/guide/copy.ts#COPY.ko.flash.steps[3].title,22,22,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[3].warn,web/src/components/guide/copy.ts#COPY.ko.flash.steps[3].warn,201,105,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[4].body[0],web/src/components/guide/copy.ts#COPY.ko.flash.steps[4].body[0],155,101,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[4].body[1],web/src/components/guide/copy.ts#COPY.ko.flash.steps[4].body[1],58,35,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[4].kicker,web/src/components/guide/copy.ts#COPY.ko.flash.steps[4].kicker,5,2,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[4].title,web/src/components/guide/copy.ts#COPY.ko.flash.steps[4].title,17,23,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[5].body[0],web/src/components/guide/copy.ts#COPY.ko.flash.steps[5].body[0],119,56,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[5].body[1],web/src/components/guide/copy.ts#COPY.ko.flash.steps[5].body[1],225,119,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[5].kicker,web/src/components/guide/copy.ts#COPY.ko.flash.steps[5].kicker,5,4,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[5].title,web/src/components/guide/copy.ts#COPY.ko.flash.steps[5].title,22,14,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[5].warn,web/src/components/guide/copy.ts#COPY.ko.flash.steps[5].warn,93,58,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[6].body[0],web/src/components/guide/copy.ts#COPY.ko.flash.steps[6].body[0],136,64,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[6].body[1],web/src/components/guide/copy.ts#COPY.ko.flash.steps[6].body[1],65,35,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[6].kicker,web/src/components/guide/copy.ts#COPY.ko.flash.steps[6].kicker,5,2,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[6].note,web/src/components/guide/copy.ts#COPY.ko.flash.steps[6].note,66,32,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[6].title,web/src/components/guide/copy.ts#COPY.ko.flash.steps[6].title,15,8,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[7].body[0],web/src/components/guide/copy.ts#COPY.ko.flash.steps[7].body[0],167,113,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[7].body[1],web/src/components/guide/copy.ts#COPY.ko.flash.steps[7].body[1],124,72,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[7].body[2],web/src/components/guide/copy.ts#COPY.ko.flash.steps[7].body[2],142,68,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[7].kicker,web/src/components/guide/copy.ts#COPY.ko.flash.steps[7].kicker,13,10,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[7].title,web/src/components/guide/copy.ts#COPY.ko.flash.steps[7].title,25,13,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.steps[7].warn,web/src/components/guide/copy.ts#COPY.ko.flash.steps[7].warn,160,77,ok +copy,web/src/components/guide/copy.ts#COPY.en.flash.title,web/src/components/guide/copy.ts#COPY.ko.flash.title,12,5,ok +copy,web/src/components/guide/copy.ts#COPY.en.knobs.lede,web/src/components/guide/copy.ts#COPY.ko.knobs.lede,41,20,ok +copy,web/src/components/guide/copy.ts#COPY.en.knobs.steps[0].body[0],web/src/components/guide/copy.ts#COPY.ko.knobs.steps[0].body[0],70,35,ok +copy,web/src/components/guide/copy.ts#COPY.en.knobs.steps[0].kicker,web/src/components/guide/copy.ts#COPY.ko.knobs.steps[0].kicker,10,2,ok +copy,web/src/components/guide/copy.ts#COPY.en.knobs.steps[0].note,web/src/components/guide/copy.ts#COPY.ko.knobs.steps[0].note,82,52,ok +copy,web/src/components/guide/copy.ts#COPY.en.knobs.steps[0].title,web/src/components/guide/copy.ts#COPY.ko.knobs.steps[0].title,23,11,ok +copy,web/src/components/guide/copy.ts#COPY.en.knobs.steps[1].body[0],web/src/components/guide/copy.ts#COPY.ko.knobs.steps[1].body[0],105,53,ok +copy,web/src/components/guide/copy.ts#COPY.en.knobs.steps[1].body[1],web/src/components/guide/copy.ts#COPY.ko.knobs.steps[1].body[1],45,21,ok +copy,web/src/components/guide/copy.ts#COPY.en.knobs.steps[1].body[2],web/src/components/guide/copy.ts#COPY.ko.knobs.steps[1].body[2],156,77,ok +copy,web/src/components/guide/copy.ts#COPY.en.knobs.steps[1].kicker,web/src/components/guide/copy.ts#COPY.ko.knobs.steps[1].kicker,4,3,ok +copy,web/src/components/guide/copy.ts#COPY.en.knobs.steps[1].title,web/src/components/guide/copy.ts#COPY.ko.knobs.steps[1].title,27,11,ok +copy,web/src/components/guide/copy.ts#COPY.en.knobs.steps[2].body[0],web/src/components/guide/copy.ts#COPY.ko.knobs.steps[2].body[0],126,66,ok +copy,web/src/components/guide/copy.ts#COPY.en.knobs.steps[2].kicker,web/src/components/guide/copy.ts#COPY.ko.knobs.steps[2].kicker,5,3,ok +copy,web/src/components/guide/copy.ts#COPY.en.knobs.steps[2].title,web/src/components/guide/copy.ts#COPY.ko.knobs.steps[2].title,18,9,ok +copy,web/src/components/guide/copy.ts#COPY.en.knobs.steps[3].body[0],web/src/components/guide/copy.ts#COPY.ko.knobs.steps[3].body[0],120,58,ok +copy,web/src/components/guide/copy.ts#COPY.en.knobs.steps[3].kicker,web/src/components/guide/copy.ts#COPY.ko.knobs.steps[3].kicker,6,3,ok +copy,web/src/components/guide/copy.ts#COPY.en.knobs.steps[3].title,web/src/components/guide/copy.ts#COPY.ko.knobs.steps[3].title,11,3,ok +copy,web/src/components/guide/copy.ts#COPY.en.knobs.steps[4].body[0],web/src/components/guide/copy.ts#COPY.ko.knobs.steps[4].body[0],109,48,ok +copy,web/src/components/guide/copy.ts#COPY.en.knobs.steps[4].body[1],web/src/components/guide/copy.ts#COPY.ko.knobs.steps[4].body[1],48,28,ok +copy,web/src/components/guide/copy.ts#COPY.en.knobs.steps[4].kicker,web/src/components/guide/copy.ts#COPY.ko.knobs.steps[4].kicker,6,3,ok +copy,web/src/components/guide/copy.ts#COPY.en.knobs.steps[4].title,web/src/components/guide/copy.ts#COPY.ko.knobs.steps[4].title,11,5,ok +copy,web/src/components/guide/copy.ts#COPY.en.knobs.steps[5].body[0],web/src/components/guide/copy.ts#COPY.ko.knobs.steps[5].body[0],73,41,ok +copy,web/src/components/guide/copy.ts#COPY.en.knobs.steps[5].kicker,web/src/components/guide/copy.ts#COPY.ko.knobs.steps[5].kicker,6,3,ok +copy,web/src/components/guide/copy.ts#COPY.en.knobs.steps[5].title,web/src/components/guide/copy.ts#COPY.ko.knobs.steps[5].title,17,10,ok +copy,web/src/components/guide/copy.ts#COPY.en.knobs.steps[6].body[0],web/src/components/guide/copy.ts#COPY.ko.knobs.steps[6].body[0],100,55,ok +copy,web/src/components/guide/copy.ts#COPY.en.knobs.steps[6].body[1],web/src/components/guide/copy.ts#COPY.ko.knobs.steps[6].body[1],29,20,ok +copy,web/src/components/guide/copy.ts#COPY.en.knobs.steps[6].kicker,web/src/components/guide/copy.ts#COPY.ko.knobs.steps[6].kicker,6,3,ok +copy,web/src/components/guide/copy.ts#COPY.en.knobs.steps[6].title,web/src/components/guide/copy.ts#COPY.ko.knobs.steps[6].title,9,3,ok +copy,web/src/components/guide/copy.ts#COPY.en.knobs.title,web/src/components/guide/copy.ts#COPY.ko.knobs.title,9,4,ok +copy,web/src/components/guide/copy.ts#COPY.en.langSwitch.label,web/src/components/guide/copy.ts#COPY.ko.langSwitch.label,3,7,ok +copy,web/src/components/guide/copy.ts#COPY.en.meta.description,web/src/components/guide/copy.ts#COPY.ko.meta.description,131,62,ok +copy,web/src/components/guide/copy.ts#COPY.en.meta.title,web/src/components/guide/copy.ts#COPY.ko.meta.title,20,8,ok +copy,web/src/components/guide/copy.ts#COPY.en.name,web/src/components/guide/copy.ts#COPY.ko.name,4,2,ok +copy,web/src/components/guide/copy.ts#COPY.en.next.groups[0].items[0],web/src/components/guide/copy.ts#COPY.ko.next.groups[0].items[0],26,9,ok +copy,web/src/components/guide/copy.ts#COPY.en.next.groups[0].items[1],web/src/components/guide/copy.ts#COPY.ko.next.groups[0].items[1],40,19,ok +copy,web/src/components/guide/copy.ts#COPY.en.next.groups[0].label,web/src/components/guide/copy.ts#COPY.ko.next.groups[0].label,12,5,ok +copy,web/src/components/guide/copy.ts#COPY.en.next.groups[1].items[0],web/src/components/guide/copy.ts#COPY.ko.next.groups[1].items[0],38,20,ok +copy,web/src/components/guide/copy.ts#COPY.en.next.groups[1].label,web/src/components/guide/copy.ts#COPY.ko.next.groups[1].label,13,6,ok +copy,web/src/components/guide/copy.ts#COPY.en.next.groups[2].items[0],web/src/components/guide/copy.ts#COPY.ko.next.groups[2].items[0],8,3,ok +copy,web/src/components/guide/copy.ts#COPY.en.next.groups[2].items[2],web/src/components/guide/copy.ts#COPY.ko.next.groups[2].items[2],5,2,ok +copy,web/src/components/guide/copy.ts#COPY.en.next.groups[2].items[3],web/src/components/guide/copy.ts#COPY.ko.next.groups[2].items[3],11,4,ok +copy,web/src/components/guide/copy.ts#COPY.en.next.groups[2].label,web/src/components/guide/copy.ts#COPY.ko.next.groups[2].label,9,3,ok +copy,web/src/components/guide/copy.ts#COPY.en.next.lede,web/src/components/guide/copy.ts#COPY.ko.next.lede,90,45,ok +copy,web/src/components/guide/copy.ts#COPY.en.next.report.where,web/src/components/guide/copy.ts#COPY.ko.next.report.where,31,14,ok +copy,web/src/components/guide/copy.ts#COPY.en.next.title,web/src/components/guide/copy.ts#COPY.ko.next.title,17,10,ok +copy,web/src/components/guide/copy.ts#COPY.en.next.until,web/src/components/guide/copy.ts#COPY.ko.next.until,31,14,ok +copy,web/src/components/guide/copy.ts#COPY.en.opening.back.label,web/src/components/guide/copy.ts#COPY.ko.opening.back.label,9,5,ok +copy,web/src/components/guide/copy.ts#COPY.en.opening.chapters[0],web/src/components/guide/copy.ts#COPY.ko.opening.chapters[0],5,2,ok +copy,web/src/components/guide/copy.ts#COPY.en.opening.chapters[1],web/src/components/guide/copy.ts#COPY.ko.opening.chapters[1],5,2,ok +copy,web/src/components/guide/copy.ts#COPY.en.opening.chapters[2],web/src/components/guide/copy.ts#COPY.ko.opening.chapters[2],8,2,ok +copy,web/src/components/guide/copy.ts#COPY.en.opening.chapters[3],web/src/components/guide/copy.ts#COPY.ko.opening.chapters[3],7,2,ok +copy,web/src/components/guide/copy.ts#COPY.en.opening.kicker,web/src/components/guide/copy.ts#COPY.ko.opening.kicker,13,6,ok +copy,web/src/components/guide/copy.ts#COPY.en.opening.lede,web/src/components/guide/copy.ts#COPY.ko.opening.lede,143,61,ok +copy,web/src/components/guide/copy.ts#COPY.en.opening.scroll,web/src/components/guide/copy.ts#COPY.ko.opening.scroll,6,3,ok +copy,web/src/components/guide/copy.ts#COPY.en.opening.title,web/src/components/guide/copy.ts#COPY.ko.opening.title,12,8,ok +copy,web/src/components/guide/copy.ts#COPY.en.patterns.lede,web/src/components/guide/copy.ts#COPY.ko.patterns.lede,63,34,ok +copy,web/src/components/guide/copy.ts#COPY.en.patterns.steps[0].body[0],web/src/components/guide/copy.ts#COPY.ko.patterns.steps[0].body[0],123,55,ok +copy,web/src/components/guide/copy.ts#COPY.en.patterns.steps[0].title,web/src/components/guide/copy.ts#COPY.ko.patterns.steps[0].title,23,12,ok +copy,web/src/components/guide/copy.ts#COPY.en.patterns.steps[1].body[0],web/src/components/guide/copy.ts#COPY.ko.patterns.steps[1].body[0],128,63,ok +copy,web/src/components/guide/copy.ts#COPY.en.patterns.steps[1].body[1],web/src/components/guide/copy.ts#COPY.ko.patterns.steps[1].body[1],105,63,ok +copy,web/src/components/guide/copy.ts#COPY.en.patterns.steps[1].title,web/src/components/guide/copy.ts#COPY.ko.patterns.steps[1].title,24,13,ok +copy,web/src/components/guide/copy.ts#COPY.en.patterns.steps[2].body[0],web/src/components/guide/copy.ts#COPY.ko.patterns.steps[2].body[0],144,75,ok +copy,web/src/components/guide/copy.ts#COPY.en.patterns.steps[2].body[1],web/src/components/guide/copy.ts#COPY.ko.patterns.steps[2].body[1],123,60,ok +copy,web/src/components/guide/copy.ts#COPY.en.patterns.steps[2].kicker,web/src/components/guide/copy.ts#COPY.ko.patterns.steps[2].kicker,7,2,ok +copy,web/src/components/guide/copy.ts#COPY.en.patterns.steps[2].title,web/src/components/guide/copy.ts#COPY.ko.patterns.steps[2].title,17,23,ok +copy,web/src/components/guide/copy.ts#COPY.en.patterns.steps[2].warn,web/src/components/guide/copy.ts#COPY.ko.patterns.steps[2].warn,91,65,ok +copy,web/src/components/guide/copy.ts#COPY.en.patterns.steps[3].body[0],web/src/components/guide/copy.ts#COPY.ko.patterns.steps[3].body[0],59,40,ok +copy,web/src/components/guide/copy.ts#COPY.en.patterns.steps[3].note,web/src/components/guide/copy.ts#COPY.ko.patterns.steps[3].note,68,27,ok +copy,web/src/components/guide/copy.ts#COPY.en.patterns.steps[3].title,web/src/components/guide/copy.ts#COPY.ko.patterns.steps[3].title,12,7,ok +copy,web/src/components/guide/copy.ts#COPY.en.patterns.title,web/src/components/guide/copy.ts#COPY.ko.patterns.title,12,5,ok +copy,web/src/components/guide/copy.ts#COPY.en.ui.bootSteps[0],web/src/components/guide/copy.ts#COPY.ko.ui.bootSteps[0],8,8,ok +copy,web/src/components/guide/copy.ts#COPY.en.ui.bootSteps[1],web/src/components/guide/copy.ts#COPY.ko.ui.bootSteps[1],6,5,ok +copy,web/src/components/guide/copy.ts#COPY.en.ui.bootSteps[2],web/src/components/guide/copy.ts#COPY.ko.ui.bootSteps[2],11,6,ok +copy,web/src/components/guide/copy.ts#COPY.en.ui.flashUnsupported,web/src/components/guide/copy.ts#COPY.ko.ui.flashUnsupported,35,18,ok +copy,web/src/components/guide/copy.ts#COPY.en.ui.install.confirm,web/src/components/guide/copy.ts#COPY.ko.ui.install.confirm,86,86,missing-ko +copy,web/src/components/guide/copy.ts#COPY.en.ui.noteBy,web/src/components/guide/copy.ts#COPY.ko.ui.noteBy,12,8,ok +copy,web/src/components/guide/copy.ts#COPY.en.ui.openDecks,web/src/components/guide/copy.ts#COPY.ko.ui.openDecks,12,3,ok +copy,web/src/components/guide/copy.ts#COPY.en.ui.pages[0].body,web/src/components/guide/copy.ts#COPY.ko.ui.pages[0].body,64,33,ok +copy,web/src/components/guide/copy.ts#COPY.en.ui.pages[1].body,web/src/components/guide/copy.ts#COPY.ko.ui.pages[1].body,39,23,ok +copy,web/src/components/guide/copy.ts#COPY.en.ui.pages[2].body,web/src/components/guide/copy.ts#COPY.ko.ui.pages[2].body,42,15,ok +copy,web/src/components/guide/copy.ts#COPY.en.ui.pages[3].body,web/src/components/guide/copy.ts#COPY.ko.ui.pages[3].body,38,21,ok +copy,web/src/components/guide/copy.ts#COPY.en.ui.replay,web/src/components/guide/copy.ts#COPY.ko.ui.replay,6,4,ok +copy,web/src/components/guide/copy.ts#COPY.en.ui.report.general,web/src/components/guide/copy.ts#COPY.ko.ui.report.general,25,14,ok +copy,web/src/components/guide/copy.ts#COPY.en.ui.report.generalWhere,web/src/components/guide/copy.ts#COPY.ko.ui.report.generalWhere,27,12,ok +copy,web/src/components/guide/copy.ts#COPY.en.ui.report.hint,web/src/components/guide/copy.ts#COPY.ko.ui.report.hint,44,19,ok +copy,web/src/components/guide/copy.ts#COPY.en.ui.report.step,web/src/components/guide/copy.ts#COPY.ko.ui.report.step,16,13,ok +copy,web/src/components/guide/copy.ts#COPY.en.ui.tourLabel,web/src/components/guide/copy.ts#COPY.ko.ui.tourLabel,12,5,ok +copy,web/src/components/guide/copy.ts#COPY.en.ui.wip,web/src/components/guide/copy.ts#COPY.ko.ui.wip,62,24,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.browser.lede,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.browser.lede,39,17,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.browser.needs.label,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.browser.needs.label,30,15,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.browser.steps[0].body[0],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.browser.steps[0].body[0],102,77,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.browser.steps[0].body[1],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.browser.steps[0].body[1],145,163,en-short +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.browser.steps[0].body[2],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.browser.steps[0].body[2],104,77,en-short +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.browser.steps[0].kicker,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.browser.steps[0].kicker,12,6,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.browser.steps[0].title,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.browser.steps[0].title,24,11,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.browser.steps[1].body[0],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.browser.steps[1].body[0],166,95,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.browser.steps[1].body[1],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.browser.steps[1].body[1],154,89,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.browser.steps[1].kicker,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.browser.steps[1].kicker,5,2,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.browser.steps[1].note,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.browser.steps[1].note,133,70,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.browser.steps[1].title,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.browser.steps[1].title,22,10,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.browser.steps[2].body[0],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.browser.steps[2].body[0],98,61,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.browser.steps[2].body[1],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.browser.steps[2].body[1],129,66,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.browser.steps[2].body[2],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.browser.steps[2].body[2],96,47,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.browser.steps[2].kicker,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.browser.steps[2].kicker,8,9,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.browser.steps[2].title,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.browser.steps[2].title,20,12,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.browser.steps[3].body[0],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.browser.steps[3].body[0],187,102,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.browser.steps[3].body[1],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.browser.steps[3].body[1],167,93,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.browser.steps[3].body[2],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.browser.steps[3].body[2],132,69,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.browser.steps[3].kicker,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.browser.steps[3].kicker,9,3,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.browser.steps[3].title,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.browser.steps[3].title,28,16,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.browser.title,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.browser.title,11,5,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.edition.lede,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.edition.lede,60,33,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.edition.steps[0].body[0],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.edition.steps[0].body[0],175,86,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.edition.steps[0].body[1],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.edition.steps[0].body[1],117,57,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.edition.steps[0].kicker,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.edition.steps[0].kicker,9,3,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.edition.steps[0].title,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.edition.steps[0].title,26,13,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.edition.steps[1].body[0],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.edition.steps[1].body[0],161,112,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.edition.steps[1].body[1],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.edition.steps[1].body[1],74,44,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.edition.steps[1].body[2],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.edition.steps[1].body[2],104,58,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.edition.steps[1].kicker,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.edition.steps[1].kicker,7,2,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.edition.steps[1].title,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.edition.steps[1].title,17,23,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.edition.steps[1].warn,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.edition.steps[1].warn,104,69,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.edition.steps[2].body[0],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.edition.steps[2].body[0],84,44,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.edition.steps[2].body[1],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.edition.steps[2].body[1],106,79,en-short +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.edition.steps[2].body[2],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.edition.steps[2].body[2],100,57,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.edition.steps[2].kicker,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.edition.steps[2].kicker,5,2,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.edition.steps[2].title,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.edition.steps[2].title,18,14,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.edition.title,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.edition.title,15,6,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.meta.description,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.meta.description,149,71,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.meta.title,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.meta.title,25,15,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.lede,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.lede,69,38,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.needs.label,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.needs.label,30,15,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[0].body[0],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[0].body[0],137,101,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[0].kicker,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[0].kicker,7,2,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[0].title,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[0].title,26,14,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[0].warn,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[0].warn,108,62,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[1].body[0],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[1].body[0],130,64,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[1].body[1],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[1].body[1],45,18,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[1].kicker,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[1].kicker,8,5,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[1].title,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[1].title,10,7,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[2].body[0],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[2].body[0],109,63,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[2].body[1],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[2].body[1],123,56,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[2].kicker,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[2].kicker,20,7,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[2].title,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[2].title,19,8,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[2].warn,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[2].warn,77,40,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[3].body[0],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[3].body[0],144,64,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[3].kicker,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[3].kicker,6,4,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[3].title,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[3].title,30,17,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[3].warn,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[3].warn,92,53,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[4].body[0],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[4].body[0],127,84,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[4].body[1],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[4].body[1],76,52,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[4].kicker,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[4].kicker,10,2,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[4].title,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[4].title,25,20,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[5].body[0],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[5].body[0],200,111,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[5].body[1],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[5].body[1],72,32,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[5].kicker,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[5].kicker,10,2,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[5].note,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[5].note,137,72,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[5].title,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[5].title,26,13,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[6].body[0],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[6].body[0],87,61,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[6].body[1],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[6].body[1],76,52,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[6].body[2],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[6].body[2],98,63,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[6].body[3],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[6].body[3],103,53,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[6].kicker,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[6].kicker,6,5,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.steps[6].title,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.steps[6].title,16,6,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.mic.title,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.mic.title,13,3,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.lede,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.lede,97,51,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.needs.label,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.needs.label,30,15,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.steps[0].body[0],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.steps[0].body[0],124,73,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.steps[0].body[1],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.steps[0].body[1],102,85,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.steps[0].body[2],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.steps[0].body[2],92,63,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.steps[0].kicker,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.steps[0].kicker,7,2,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.steps[0].title,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.steps[0].title,24,16,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.steps[0].warn,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.steps[0].warn,101,67,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.steps[1].body[0],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.steps[1].body[0],110,80,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.steps[1].body[1],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.steps[1].body[1],135,92,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.steps[1].body[2],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.steps[1].body[2],109,78,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.steps[1].title,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.steps[1].title,19,17,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.steps[1].warn,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.steps[1].warn,59,47,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.steps[2].body[0],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.steps[2].body[0],79,37,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.steps[2].body[1],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.steps[2].body[1],113,74,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.steps[2].body[2],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.steps[2].body[2],117,84,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.steps[2].body[3],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.steps[2].body[3],166,105,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.steps[2].kicker,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.steps[2].kicker,6,1,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.steps[2].note,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.steps[2].note,144,92,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.steps[2].title,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.steps[2].title,30,14,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.steps[3].body[0],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.steps[3].body[0],186,141,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.steps[3].body[1],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.steps[3].body[1],137,94,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.steps[3].body[2],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.steps[3].body[2],57,47,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.steps[3].kicker,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.steps[3].kicker,8,2,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.steps[3].title,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.steps[3].title,21,11,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.steps[4].body[0],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.steps[4].body[0],181,100,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.steps[4].body[1],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.steps[4].body[1],136,85,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.steps[4].kicker,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.steps[4].kicker,13,4,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.steps[4].title,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.steps[4].title,16,22,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.midi.title,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.midi.title,12,8,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.name,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.name,5,3,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.next.lede,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.next.lede,164,85,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.next.links[1].label,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.next.links[1].label,11,2,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.next.links[2].label,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.next.links[2].label,19,10,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.next.links[3].label,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.next.links[3].label,10,5,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.next.links[6].label,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.next.links[6].label,9,5,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.next.report.where,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.next.report.where,32,15,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.next.title,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.next.title,17,12,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.next.until,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.next.until,39,18,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.opening.back.label,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.opening.back.label,9,5,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.opening.chapters[0],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.opening.chapters[0],15,6,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.opening.chapters[1],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.opening.chapters[1],11,5,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.opening.chapters[2],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.opening.chapters[2],13,3,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.opening.chapters[3],web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.opening.chapters[3],12,8,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.opening.groups.first,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.opening.groups.first,5,2,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.opening.groups.rest,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.opening.groups.rest,15,8,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.opening.kicker,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.opening.kicker,14,7,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.opening.lede,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.opening.lede,180,91,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.opening.scroll,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.opening.scroll,6,3,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.opening.title,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.opening.title,19,12,ok +copy,web/src/components/guide/copy/audio.ts#AUDIO_COPY.en.ui.editions.label,web/src/components/guide/copy/audio.ts#AUDIO_COPY.ko.ui.editions.label,29,27,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.bom.guide.label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.bom.guide.label,26,17,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.bom.labels.mpn,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.bom.labels.mpn,7,4,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.bom.labels.name,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.bom.labels.name,8,5,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.bom.labels.spec,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.bom.labels.spec,4,2,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.bom.missing,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.bom.missing,25,16,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.bom.more.M4 screw,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.bom.more.M4 screw,68,31,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.bom.more.SW1-SW4,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.bom.more.SW1-SW4,92,61,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.bom.more.U1,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.bom.more.U1,65,31,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.bom.panel.listing.label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.bom.panel.listing.label,18,7,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.bom.panel.note,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.bom.panel.note,51,25,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.bom.panel.other.label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.bom.panel.other.label,18,5,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.bom.source,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.bom.source,10,5,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.bom.tips.C11,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.bom.tips.C11,16,7,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.bom.tips.J1,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.bom.tips.J1,20,10,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.bom.tips.J3,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.bom.tips.J3,17,10,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.bom.tips.J4,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.bom.tips.J4,18,8,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.bom.tips.M4 screw,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.bom.tips.M4 screw,34,23,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.bom.tips.SW1-SW4,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.bom.tips.SW1-SW4,26,16,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.bom.tips.U1,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.bom.tips.U1,22,14,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.bom.tips.U1 (sockets),web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.bom.tips.U1 (sockets),21,13,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.bom.tips.USB cable (sacrificial),web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.bom.tips.USB cable (sacrificial),35,15,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.bom.tips.USB power bank,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.bom.tips.USB power bank,32,15,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.caseFiles.head[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.caseFiles.head[0],7,3,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.caseFiles.head[1],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.caseFiles.head[1],5,5,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.caseFiles.links[2].label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.caseFiles.links[2].label,15,7,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.caseFiles.rows[0].note,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.caseFiles.rows[0].note,16,10,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.caseFiles.rows[1].bed,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.caseFiles.rows[1].bed,10,11,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.caseFiles.rows[1].note,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.caseFiles.rows[1].note,55,36,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.caseFiles.rows[2].bed,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.caseFiles.rows[2].bed,12,4,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.caseFiles.rows[2].note,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.caseFiles.rows[2].note,61,28,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.caseFiles.settingsLabel,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.caseFiles.settingsLabel,13,4,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.caseFiles.settings[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.caseFiles.settings[0],11,7,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.caseFiles.settings[1],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.caseFiles.settings[1],11,8,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.caseFiles.settings[2],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.caseFiles.settings[2],24,11,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.caseFiles.settings[3],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.caseFiles.settings[3],7,3,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.caseFiles.settings[4],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.caseFiles.settings[4],9,6,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.checks.checkKnobs[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.checks.checkKnobs[0],16,8,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.checks.checkKnobs[1],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.checks.checkKnobs[1],16,8,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.checks.checkKnobs[2],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.checks.checkKnobs[2],16,8,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.checks.checkKnobs[3],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.checks.checkKnobs[3],16,8,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.figures[0].label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.figures[0].label,19,4,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.figures[0].value,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.figures[0].value,6,2,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.figures[1].label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.figures[1].label,10,2,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.figures[1].value,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.figures[1].value,3,4,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.figures[2].label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.figures[2].label,22,5,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.figures[2].value,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.figures[2].value,2,3,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.handoff.label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.handoff.label,14,7,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.handoff.line,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.handoff.line,124,52,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.knobMap.screens[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.knobMap.screens[0],10,2,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.knobMap.screens[1],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.knobMap.screens[1],28,22,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.knobMap.screens[2],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.knobMap.screens[2],16,6,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.knobMap.screens[3],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.knobMap.screens[3],13,5,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.links.linksBack[0].label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.links.linksBack[0].label,39,14,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.links.linksGpio0[0].label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.links.linksGpio0[0].label,27,18,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.links.linksGpio0[1].label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.links.linksGpio0[1].label,25,19,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.links.linksPcb[0].label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.links.linksPcb[0].label,19,12,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.links.linksPcb[1].label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.links.linksPcb[1].label,14,10,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.links.linksPcb[2].label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.links.linksPcb[2].label,11,7,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.links.linksPlay03[0].label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.links.linksPlay03[0].label,19,10,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.links.linksWiring[0].label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.links.linksWiring[0].label,34,12,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.order.skip,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.order.skip,72,56,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.order.steps[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.order.steps[0],7,2,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.order.steps[3],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.order.steps[3],8,3,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.order.video.label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.order.video.label,26,9,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.terminals.black,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.terminals.black,5,2,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.terminals.j3,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.terminals.j3,13,5,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.terminals.j4,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.terminals.j4,7,4,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.terminals.red,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.terminals.red,3,2,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.terminals.seen,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.terminals.seen,62,31,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.tools.items[0].label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.tools.items[0].label,31,15,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.tools.items[1].label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.tools.items[1].label,38,17,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.tools.items[2].label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.tools.items[2].label,34,12,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.tools.items[3].label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.tools.items[3].label,62,21,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.tools.items[4].label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.tools.items[4].label,47,20,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.tools.items[5].label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.tools.items[5].label,46,21,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.tools.items[6].label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.tools.items[6].label,33,11,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.cards.tools.kept,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.cards.tools.kept,27,14,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[0].copy.lede,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[0].copy.lede,60,29,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[0].copy.steps[0].body[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[0].copy.steps[0].body[0],85,54,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[0].copy.steps[0].kicker,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[0].copy.steps[0].kicker,10,3,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[0].copy.steps[0].title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[0].copy.steps[0].title,45,15,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[0].copy.steps[1].body[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[0].copy.steps[1].body[0],117,63,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[0].copy.steps[1].kicker,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[0].copy.steps[1].kicker,11,3,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[0].copy.steps[1].title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[0].copy.steps[1].title,32,16,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[0].copy.steps[1].warn,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[0].copy.steps[1].warn,118,68,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[0].copy.steps[2].body[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[0].copy.steps[2].body[0],25,12,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[0].copy.steps[2].kicker,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[0].copy.steps[2].kicker,10,4,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[0].copy.steps[2].title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[0].copy.steps[2].title,12,6,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[0].copy.steps[3].body[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[0].copy.steps[3].body[0],104,55,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[0].copy.steps[3].kicker,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[0].copy.steps[3].kicker,13,5,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[0].copy.steps[3].title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[0].copy.steps[3].title,22,12,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[0].copy.title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[0].copy.title,14,5,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[1].copy.lede,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[1].copy.lede,62,30,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[1].copy.steps[0].body[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[1].copy.steps[0].body[0],141,83,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[1].copy.steps[0].kicker,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[1].copy.steps[0].kicker,8,2,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[1].copy.steps[0].title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[1].copy.steps[0].title,18,12,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[1].copy.steps[0].warn,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[1].copy.steps[0].warn,97,65,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[1].copy.steps[1].body[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[1].copy.steps[1].body[0],135,77,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[1].copy.steps[1].kicker,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[1].copy.steps[1].kicker,7,3,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[1].copy.steps[1].title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[1].copy.steps[1].title,30,15,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[1].copy.steps[2].body[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[1].copy.steps[2].body[0],120,84,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[1].copy.steps[2].body[1],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[1].copy.steps[2].body[1],54,25,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[1].copy.steps[2].kicker,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[1].copy.steps[2].kicker,5,2,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[1].copy.steps[2].title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[1].copy.steps[2].title,27,11,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[1].copy.steps[3].body[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[1].copy.steps[3].body[0],150,68,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[1].copy.steps[3].body[1],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[1].copy.steps[3].body[1],83,47,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[1].copy.steps[3].body[2],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[1].copy.steps[3].body[2],62,35,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[1].copy.steps[3].kicker,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[1].copy.steps[3].kicker,9,6,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[1].copy.steps[3].title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[1].copy.steps[3].title,23,13,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[1].copy.title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[1].copy.title,26,10,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[2].copy.lede,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[2].copy.lede,57,30,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[2].copy.steps[0].body[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[2].copy.steps[0].body[0],151,62,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[2].copy.steps[0].body[1],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[2].copy.steps[0].body[1],44,25,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[2].copy.steps[0].kicker,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[2].copy.steps[0].kicker,7,2,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[2].copy.steps[0].title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[2].copy.steps[0].title,30,12,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[2].copy.steps[1].body[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[2].copy.steps[1].body[0],104,52,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[2].copy.steps[1].body[1],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[2].copy.steps[1].body[1],195,102,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[2].copy.steps[1].body[2],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[2].copy.steps[1].body[2],76,39,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[2].copy.steps[1].title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[2].copy.steps[1].title,32,13,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[2].copy.steps[2].body[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[2].copy.steps[2].body[0],135,65,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[2].copy.steps[2].body[1],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[2].copy.steps[2].body[1],26,11,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[2].copy.steps[2].title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[2].copy.steps[2].title,30,12,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[2].copy.steps[2].warn,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[2].copy.steps[2].warn,65,35,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[2].copy.steps[3].body[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[2].copy.steps[3].body[0],157,85,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[2].copy.steps[3].body[1],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[2].copy.steps[3].body[1],30,15,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[2].copy.steps[3].title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[2].copy.steps[3].title,33,14,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[2].copy.steps[3].warn,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[2].copy.steps[3].warn,133,54,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[2].copy.title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[2].copy.title,14,4,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[3].copy.lede,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[3].copy.lede,65,29,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[3].copy.steps[0].body[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[3].copy.steps[0].body[0],126,70,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[3].copy.steps[0].body[1],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[3].copy.steps[0].body[1],100,51,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[3].copy.steps[0].kicker,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[3].copy.steps[0].kicker,8,2,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[3].copy.steps[0].title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[3].copy.steps[0].title,28,15,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[3].copy.steps[0].warn,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[3].copy.steps[0].warn,79,41,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[3].copy.steps[1].body[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[3].copy.steps[1].body[0],174,87,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[3].copy.steps[1].body[1],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[3].copy.steps[1].body[1],76,45,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[3].copy.steps[1].kicker,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[3].copy.steps[1].kicker,8,4,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[3].copy.steps[1].title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[3].copy.steps[1].title,20,11,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[3].copy.steps[2].body[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[3].copy.steps[2].body[0],78,64,en-short +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[3].copy.steps[2].body[1],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[3].copy.steps[2].body[1],154,76,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[3].copy.steps[2].body[2],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[3].copy.steps[2].body[2],69,45,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[3].copy.steps[2].kicker,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[3].copy.steps[2].kicker,9,3,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[3].copy.steps[2].title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[3].copy.steps[2].title,28,11,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[3].copy.steps[2].warn,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[3].copy.steps[2].warn,58,40,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[3].copy.steps[3].body[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[3].copy.steps[3].body[0],159,71,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[3].copy.steps[3].kicker,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[3].copy.steps[3].kicker,8,2,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[3].copy.steps[3].title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[3].copy.steps[3].title,22,10,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[3].copy.steps[4].body[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[3].copy.steps[4].body[0],135,47,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[3].copy.steps[4].body[1],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[3].copy.steps[4].body[1],34,26,en-short +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[3].copy.steps[4].kicker,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[3].copy.steps[4].kicker,4,2,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[3].copy.steps[4].title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[3].copy.steps[4].title,19,11,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[3].copy.title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[3].copy.title,11,6,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[4].copy.lede,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[4].copy.lede,59,31,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[4].copy.steps[0].body[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[4].copy.steps[0].body[0],121,63,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[4].copy.steps[0].body[1],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[4].copy.steps[0].body[1],48,22,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[4].copy.steps[0].kicker,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[4].copy.steps[0].kicker,6,5,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[4].copy.steps[0].title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[4].copy.steps[0].title,21,12,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[4].copy.steps[1].body[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[4].copy.steps[1].body[0],142,63,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[4].copy.steps[1].body[1],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[4].copy.steps[1].body[1],103,62,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[4].copy.steps[1].title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[4].copy.steps[1].title,22,9,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[4].copy.steps[1].warn,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[4].copy.steps[1].warn,61,31,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[4].copy.title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[4].copy.title,6,2,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[5].copy.lede,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[5].copy.lede,44,27,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[5].copy.steps[0].body[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[5].copy.steps[0].body[0],179,109,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[5].copy.steps[0].body[1],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[5].copy.steps[0].body[1],207,138,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[5].copy.steps[0].kicker,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[5].copy.steps[0].kicker,5,2,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[5].copy.steps[0].title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[5].copy.steps[0].title,22,11,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[5].copy.steps[1].body[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[5].copy.steps[1].body[0],142,65,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[5].copy.steps[1].body[1],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[5].copy.steps[1].body[1],78,40,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[5].copy.steps[1].kicker,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[5].copy.steps[1].kicker,6,2,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[5].copy.steps[1].title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[5].copy.steps[1].title,23,17,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[5].copy.steps[2].body[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[5].copy.steps[2].body[0],97,42,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[5].copy.steps[2].body[1],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[5].copy.steps[2].body[1],74,44,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[5].copy.steps[2].body[2],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[5].copy.steps[2].body[2],210,106,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[5].copy.steps[2].kicker,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[5].copy.steps[2].kicker,10,3,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[5].copy.steps[2].title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[5].copy.steps[2].title,19,10,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[5].copy.title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[5].copy.title,21,7,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[6].copy.lede,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[6].copy.lede,70,41,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[6].copy.steps[0].body[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[6].copy.steps[0].body[0],109,65,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[6].copy.steps[0].body[1],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[6].copy.steps[0].body[1],39,24,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[6].copy.steps[0].kicker,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[6].copy.steps[0].kicker,12,6,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[6].copy.steps[0].title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[6].copy.steps[0].title,19,12,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[6].copy.steps[1].body[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[6].copy.steps[1].body[0],75,40,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[6].copy.steps[1].kicker,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[6].copy.steps[1].kicker,4,4,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[6].copy.steps[1].title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[6].copy.steps[1].title,23,8,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[6].copy.steps[2].body[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[6].copy.steps[2].body[0],129,69,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[6].copy.steps[2].kicker,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[6].copy.steps[2].kicker,10,4,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[6].copy.steps[2].title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[6].copy.steps[2].title,13,11,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[6].copy.steps[3].body[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[6].copy.steps[3].body[0],106,46,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[6].copy.steps[3].body[1],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[6].copy.steps[3].body[1],54,21,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[6].copy.steps[3].kicker,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[6].copy.steps[3].kicker,12,4,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[6].copy.steps[3].title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[6].copy.steps[3].title,27,13,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[6].copy.steps[4].body[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[6].copy.steps[4].body[0],78,40,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[6].copy.steps[4].body[1],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[6].copy.steps[4].body[1],25,11,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[6].copy.steps[4].kicker,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[6].copy.steps[4].kicker,4,3,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[6].copy.steps[4].title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[6].copy.steps[4].title,18,11,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.chapters[6].copy.title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.chapters[6].copy.title,13,6,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.meta.description,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.meta.description,162,71,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.meta.title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.meta.title,17,9,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.name,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.name,5,2,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.next.groups[0].items[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.next.groups[0].items[0],21,12,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.next.groups[0].items[1],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.next.groups[0].items[1],25,12,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.next.groups[0].items[2],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.next.groups[0].items[2],31,14,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.next.groups[0].label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.next.groups[0].label,12,5,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.next.lede,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.next.lede,90,37,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.next.links[0].label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.next.links[0].label,25,19,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.next.links[1].label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.next.links[1].label,14,4,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.next.links[2].label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.next.links[2].label,13,4,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.next.report.where,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.next.report.where,32,14,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.next.title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.next.title,20,13,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.next.until,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.next.until,54,23,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.opening.back.label,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.opening.back.label,9,5,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.opening.chapters[0],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.opening.chapters[0],6,2,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.opening.chapters[1],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.opening.chapters[1],11,5,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.opening.chapters[2],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.opening.chapters[2],6,2,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.opening.chapters[3],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.opening.chapters[3],11,3,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.opening.chapters[4],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.opening.chapters[4],4,2,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.opening.chapters[5],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.opening.chapters[5],8,3,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.opening.chapters[6],web/src/components/guide/copy/build.ts#BUILD_COPY.ko.opening.chapters[6],11,6,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.opening.kicker,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.opening.kicker,14,6,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.opening.lede,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.opening.lede,167,66,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.opening.scroll,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.opening.scroll,6,3,ok +copy,web/src/components/guide/copy/build.ts#BUILD_COPY.en.opening.title,web/src/components/guide/copy/build.ts#BUILD_COPY.ko.opening.title,20,11,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.lede,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.lede,64,34,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.steps[0].body[0],web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.steps[0].body[0],161,97,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.steps[0].body[1],web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.steps[0].body[1],81,40,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.steps[0].kicker,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.steps[0].kicker,7,1,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.steps[0].title,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.steps[0].title,21,10,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.steps[1].body[0],web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.steps[1].body[0],127,63,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.steps[1].body[1],web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.steps[1].body[1],58,42,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.steps[1].note,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.steps[1].note,138,72,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.steps[1].title,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.steps[1].title,20,14,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.steps[2].body[0],web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.steps[2].body[0],159,98,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.steps[2].body[1],web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.steps[2].body[1],41,22,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.steps[2].kicker,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.steps[2].kicker,10,4,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.steps[2].title,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.steps[2].title,19,10,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.steps[2].warn,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.steps[2].warn,62,39,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.steps[3].body[0],web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.steps[3].body[0],145,77,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.steps[3].body[1],web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.steps[3].body[1],119,69,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.steps[3].kicker,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.steps[3].kicker,8,2,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.steps[3].title,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.steps[3].title,19,8,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.steps[3].warn,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.steps[3].warn,135,88,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.steps[4].body[0],web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.steps[4].body[0],172,115,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.steps[4].body[1],web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.steps[4].body[1],61,42,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.steps[4].kicker,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.steps[4].kicker,10,3,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.steps[4].title,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.steps[4].title,20,12,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.steps[5].body[0],web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.steps[5].body[0],123,69,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.steps[5].body[1],web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.steps[5].body[1],126,79,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.steps[5].kicker,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.steps[5].kicker,11,4,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.steps[5].note,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.steps[5].note,105,49,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.steps[5].title,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.steps[5].title,15,7,ok +copy,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.en.title,web/src/components/guide/copy/community.ts#COMMUNITY_COPY.ko.title,12,4,ok +copy,web/src/components/guide/copy/hub.ts#HUB_COPY.en.guides.audio.situation,web/src/components/guide/copy/hub.ts#HUB_COPY.ko.guides.audio.situation,27,19,ok +copy,web/src/components/guide/copy/hub.ts#HUB_COPY.en.guides.build.situation,web/src/components/guide/copy/hub.ts#HUB_COPY.ko.guides.build.situation,32,15,ok +copy,web/src/components/guide/copy/hub.ts#HUB_COPY.en.guides.make.situation,web/src/components/guide/copy/hub.ts#HUB_COPY.ko.guides.make.situation,23,17,ok +copy,web/src/components/guide/copy/hub.ts#HUB_COPY.en.guides.play.situation,web/src/components/guide/copy/hub.ts#HUB_COPY.ko.guides.play.situation,23,13,ok +copy,web/src/components/guide/copy/hub.ts#HUB_COPY.en.kicker,web/src/components/guide/copy/hub.ts#HUB_COPY.ko.kicker,8,3,ok +copy,web/src/components/guide/copy/hub.ts#HUB_COPY.en.meta.description,web/src/components/guide/copy/hub.ts#HUB_COPY.ko.meta.description,142,66,ok +copy,web/src/components/guide/copy/hub.ts#HUB_COPY.en.meta.title,web/src/components/guide/copy/hub.ts#HUB_COPY.ko.meta.title,19,8,ok +copy,web/src/components/guide/copy/hub.ts#HUB_COPY.en.report.label,web/src/components/guide/copy/hub.ts#HUB_COPY.ko.report.label,12,10,ok +copy,web/src/components/guide/copy/hub.ts#HUB_COPY.en.report.where,web/src/components/guide/copy/hub.ts#HUB_COPY.ko.report.where,27,12,ok +copy,web/src/components/guide/copy/hub.ts#HUB_COPY.en.title,web/src/components/guide/copy/hub.ts#HUB_COPY.ko.title,17,12,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.lede,web/src/components/guide/copy/lab.ts#LAB_COPY.ko.lede,70,40,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[0].body[0],web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[0].body[0],180,107,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[0].body[1],web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[0].body[1],161,73,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[0].title,web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[0].title,11,6,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[10].body[0],web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[10].body[0],137,99,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[10].body[1],web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[10].body[1],147,83,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[10].kicker,web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[10].kicker,12,4,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[10].note,web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[10].note,108,52,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[10].title,web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[10].title,11,4,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[1].body[0],web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[1].body[0],114,65,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[1].body[1],web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[1].body[1],70,41,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[1].kicker,web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[1].kicker,7,2,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[1].title,web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[1].title,12,8,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[2].body[0],web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[2].body[0],117,68,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[2].body[1],web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[2].body[1],162,80,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[2].kicker,web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[2].kicker,7,4,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[2].title,web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[2].title,20,11,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[2].warn,web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[2].warn,101,58,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[3].body[0],web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[3].body[0],119,74,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[3].body[1],web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[3].body[1],126,66,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[3].title,web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[3].title,21,7,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[4].body[0],web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[4].body[0],90,48,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[4].body[1],web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[4].body[1],160,75,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[4].kicker,web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[4].kicker,5,2,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[4].title,web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[4].title,19,10,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[5].body[0],web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[5].body[0],130,88,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[5].body[1],web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[5].body[1],39,25,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[5].title,web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[5].title,26,15,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[6].body[0],web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[6].body[0],105,89,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[6].body[1],web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[6].body[1],175,117,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[6].title,web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[6].title,14,8,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[6].warn,web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[6].warn,120,71,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[7].body[0],web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[7].body[0],135,88,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[7].body[1],web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[7].body[1],84,55,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[7].kicker,web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[7].kicker,6,3,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[7].note,web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[7].note,156,79,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[7].title,web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[7].title,10,4,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[8].body[0],web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[8].body[0],135,92,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[8].note,web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[8].note,110,56,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[8].title,web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[8].title,17,6,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[9].body[0],web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[9].body[0],120,65,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[9].body[1],web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[9].body[1],80,47,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[9].note,web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[9].note,180,89,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.steps[9].title,web/src/components/guide/copy/lab.ts#LAB_COPY.ko.steps[9].title,21,8,ok +copy,web/src/components/guide/copy/lab.ts#LAB_COPY.en.title,web/src/components/guide/copy/lab.ts#LAB_COPY.ko.title,14,3,ok +copy,web/src/components/guide/copy/make.ts#MAKE_COPY.en.meta.description,web/src/components/guide/copy/make.ts#MAKE_COPY.ko.meta.description,194,78,ok +copy,web/src/components/guide/copy/make.ts#MAKE_COPY.en.meta.title,web/src/components/guide/copy/make.ts#MAKE_COPY.ko.meta.title,30,11,ok +copy,web/src/components/guide/copy/make.ts#MAKE_COPY.en.name,web/src/components/guide/copy/make.ts#MAKE_COPY.ko.name,4,3,ok +copy,web/src/components/guide/copy/make.ts#MAKE_COPY.en.next.groups[0].items[0],web/src/components/guide/copy/make.ts#MAKE_COPY.ko.next.groups[0].items[0],38,20,ok +copy,web/src/components/guide/copy/make.ts#MAKE_COPY.en.next.groups[0].label,web/src/components/guide/copy/make.ts#MAKE_COPY.ko.next.groups[0].label,13,6,ok +copy,web/src/components/guide/copy/make.ts#MAKE_COPY.en.next.groups[1].items[0],web/src/components/guide/copy/make.ts#MAKE_COPY.ko.next.groups[1].items[0],8,3,ok +copy,web/src/components/guide/copy/make.ts#MAKE_COPY.en.next.groups[1].items[2],web/src/components/guide/copy/make.ts#MAKE_COPY.ko.next.groups[1].items[2],5,2,ok +copy,web/src/components/guide/copy/make.ts#MAKE_COPY.en.next.groups[1].items[3],web/src/components/guide/copy/make.ts#MAKE_COPY.ko.next.groups[1].items[3],11,4,ok +copy,web/src/components/guide/copy/make.ts#MAKE_COPY.en.next.groups[1].label,web/src/components/guide/copy/make.ts#MAKE_COPY.ko.next.groups[1].label,16,10,ok +copy,web/src/components/guide/copy/make.ts#MAKE_COPY.en.next.lede,web/src/components/guide/copy/make.ts#MAKE_COPY.ko.next.lede,105,51,ok +copy,web/src/components/guide/copy/make.ts#MAKE_COPY.en.next.report.where,web/src/components/guide/copy/make.ts#MAKE_COPY.ko.next.report.where,31,15,ok +copy,web/src/components/guide/copy/make.ts#MAKE_COPY.en.next.title,web/src/components/guide/copy/make.ts#MAKE_COPY.ko.next.title,12,8,ok +copy,web/src/components/guide/copy/make.ts#MAKE_COPY.en.next.until,web/src/components/guide/copy/make.ts#MAKE_COPY.ko.next.until,31,14,ok +copy,web/src/components/guide/copy/make.ts#MAKE_COPY.en.opening.back.label,web/src/components/guide/copy/make.ts#MAKE_COPY.ko.opening.back.label,9,5,ok +copy,web/src/components/guide/copy/make.ts#MAKE_COPY.en.opening.chapters[0],web/src/components/guide/copy/make.ts#MAKE_COPY.ko.opening.chapters[0],9,4,ok +copy,web/src/components/guide/copy/make.ts#MAKE_COPY.en.opening.chapters[1],web/src/components/guide/copy/make.ts#MAKE_COPY.ko.opening.chapters[1],10,3,ok +copy,web/src/components/guide/copy/make.ts#MAKE_COPY.en.opening.kicker,web/src/components/guide/copy/make.ts#MAKE_COPY.ko.opening.kicker,13,7,ok +copy,web/src/components/guide/copy/make.ts#MAKE_COPY.en.opening.lede,web/src/components/guide/copy/make.ts#MAKE_COPY.ko.opening.lede,132,61,ok +copy,web/src/components/guide/copy/make.ts#MAKE_COPY.en.opening.scroll,web/src/components/guide/copy/make.ts#MAKE_COPY.ko.opening.scroll,6,3,ok +copy,web/src/components/guide/copy/make.ts#MAKE_COPY.en.opening.title,web/src/components/guide/copy/make.ts#MAKE_COPY.ko.opening.title,12,7,ok +copy,web/src/components/guide/desk/ai/answers.ts#AI_WORDS.en.conversion,web/src/components/guide/desk/ai/answers.ts#AI_WORDS.ko.conversion,172,76,ok +copy,web/src/components/guide/desk/ai/answers.ts#AI_WORDS.en.copied,web/src/components/guide/desk/ai/answers.ts#AI_WORDS.ko.copied,7,4,ok +copy,web/src/components/guide/desk/ai/answers.ts#AI_WORDS.en.copy,web/src/components/guide/desk/ai/answers.ts#AI_WORDS.ko.copy,4,2,ok +copy,web/src/components/guide/desk/ai/answers.ts#AI_WORDS.en.copyByHand,web/src/components/guide/desk/ai/answers.ts#AI_WORDS.ko.copyByHand,14,10,ok +copy,web/src/components/guide/desk/ai/answers.ts#AI_WORDS.en.hello,web/src/components/guide/desk/ai/answers.ts#AI_WORDS.ko.hello,80,40,ok +copy,web/src/components/guide/desk/ai/answers.ts#AI_WORDS.en.input,web/src/components/guide/desk/ai/answers.ts#AI_WORDS.ko.input,22,10,ok +copy,web/src/components/guide/desk/ai/answers.ts#AI_WORDS.en.layer,web/src/components/guide/desk/ai/answers.ts#AI_WORDS.ko.layer,110,87,ok +copy,web/src/components/guide/desk/ai/answers.ts#AI_WORDS.en.other,web/src/components/guide/desk/ai/answers.ts#AI_WORDS.ko.other,137,96,ok +copy,web/src/components/guide/desk/ai/answers.ts#AI_WORDS.en.pasted,web/src/components/guide/desk/ai/answers.ts#AI_WORDS.ko.pasted,20,7,ok +copy,web/src/components/guide/desk/ai/answers.ts#AI_WORDS.en.placeholder,web/src/components/guide/desk/ai/answers.ts#AI_WORDS.ko.placeholder,18,10,ok +copy,web/src/components/guide/desk/ai/answers.ts#AI_WORDS.en.send,web/src/components/guide/desk/ai/answers.ts#AI_WORDS.ko.send,4,3,ok +copy,web/src/components/guide/desk/ai/answers.ts#AI_WORDS.en.thinking,web/src/components/guide/desk/ai/answers.ts#AI_WORDS.ko.thinking,8,4,ok +copy,web/src/components/guide/desk/ai/answers.ts#AI_WORDS.en.thread,web/src/components/guide/desk/ai/answers.ts#AI_WORDS.ko.thread,29,9,ok +copy,web/src/components/guide/desk/ai/answers.ts#AI_WORDS.en.variation,web/src/components/guide/desk/ai/answers.ts#AI_WORDS.ko.variation,64,39,ok +copy,web/src/components/guide/desk/apps/ai.tsx#AI_APP.chrome.title.en,web/src/components/guide/desk/apps/ai.tsx#AI_APP.chrome.title.ko,21,13,ok +copy,web/src/components/guide/desk/apps/community.tsx#COMMUNITY_APP.chrome.badge.en,web/src/components/guide/desk/apps/community.tsx#COMMUNITY_APP.chrome.badge.ko,8,3,ok +copy,web/src/components/guide/desk/community/words.ts#PRACTICE_WORDS.en.banner,web/src/components/guide/desk/community/words.ts#PRACTICE_WORDS.ko.banner,94,55,ok +copy,web/src/components/guide/desk/community/words.ts#PRACTICE_WORDS.en.close,web/src/components/guide/desk/community/words.ts#PRACTICE_WORDS.ko.close,5,2,ok +copy,web/src/components/guide/desk/community/words.ts#PRACTICE_WORDS.en.note,web/src/components/guide/desk/community/words.ts#PRACTICE_WORDS.ko.note,1250,739,ok +copy,web/src/components/guide/desk/community/words.ts#PRACTICE_WORDS.en.tag,web/src/components/guide/desk/community/words.ts#PRACTICE_WORDS.ko.tag,8,2,ok +copy,web/src/components/guide/desk/words.ts#DESK_WORDS.en.again,web/src/components/guide/desk/words.ts#DESK_WORDS.ko.again,11,4,ok +copy,web/src/components/guide/desk/words.ts#DESK_WORDS.en.bringForward,web/src/components/guide/desk/words.ts#DESK_WORDS.ko.bringForward,15,5,ok +copy,web/src/components/guide/desk/words.ts#DESK_WORDS.en.deskLabel,web/src/components/guide/desk/words.ts#DESK_WORDS.ko.deskLabel,12,3,ok +copy,web/src/components/guide/desk/words.ts#DESK_WORDS.en.done,web/src/components/guide/desk/words.ts#DESK_WORDS.ko.done,4,4,ok +copy,web/src/components/guide/desk/words.ts#DESK_WORDS.en.labBadge,web/src/components/guide/desk/words.ts#DESK_WORDS.ko.labBadge,7,2,ok +copy,web/src/components/guide/desk/words.ts#DESK_WORDS.en.otherTab,web/src/components/guide/desk/words.ts#DESK_WORDS.ko.otherTab,86,45,ok +copy,web/src/components/guide/desk/words.ts#DESK_WORDS.en.toWindow,web/src/components/guide/desk/words.ts#DESK_WORDS.ko.toWindow,13,5,ok +copy,web/src/components/guide/desk/words.ts#DESK_WORDS.en.watch,web/src/components/guide/desk/words.ts#DESK_WORDS.ko.watch,16,9,ok +copy,web/src/components/guide/desk/words.ts#DESK_WORDS.en.yourTurn,web/src/components/guide/desk/words.ts#DESK_WORDS.ko.yourTurn,8,7,ok +copy,web/src/components/guide/stage/build/BuildStage.tsx#TAGS[15].text.en,web/src/components/guide/stage/build/BuildStage.tsx#TAGS[15].text.ko,9,4,ok +copy,web/src/components/guide/stage/build/BuildStage.tsx#TAGS[16].text.en,web/src/components/guide/stage/build/BuildStage.tsx#TAGS[16].text.ko,7,5,ok +copy,web/src/components/guide/stage/build/BuildStage.tsx#TAGS[17].text.en,web/src/components/guide/stage/build/BuildStage.tsx#TAGS[17].text.ko,16,8,ok +copy,web/src/components/guide/stage/build/BuildStage.tsx#TAGS[18].text.en,web/src/components/guide/stage/build/BuildStage.tsx#TAGS[18].text.ko,16,8,ok +copy,web/src/components/guide/stage/build/BuildStage.tsx#TAGS[19].text.en,web/src/components/guide/stage/build/BuildStage.tsx#TAGS[19].text.ko,16,7,ok +copy,web/src/components/guide/stage/build/BuildStage.tsx#TAGS[20].text.en,web/src/components/guide/stage/build/BuildStage.tsx#TAGS[20].text.ko,8,4,ok +copy,web/src/components/guide/stage/build/BuildStage.tsx#TAGS[21].text.en,web/src/components/guide/stage/build/BuildStage.tsx#TAGS[21].text.ko,8,6,ok +copy,web/src/components/guide/stage/build/BuildStage.tsx#TAGS[22].text.en,web/src/components/guide/stage/build/BuildStage.tsx#TAGS[22].text.ko,9,5,ok +copy,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[0][1].say.en,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[0][1].say.ko,15,10,ok +copy,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[0][2].say.en,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[0][2].say.ko,18,11,ok +copy,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[0][3].say.en,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[0][3].say.ko,30,15,ok +copy,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[1][1].say.en,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[1][1].say.ko,23,13,ok +copy,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[1][2].say.en,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[1][2].say.ko,25,13,ok +copy,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[1][3].say.en,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[1][3].say.ko,17,16,ok +copy,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[2][1].say.en,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[2][1].say.ko,12,12,ok +copy,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[2][2].say.en,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[2][2].say.ko,25,24,ok +copy,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[2][3].say.en,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[2][3].say.ko,17,20,ok +copy,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[3][1].say.en,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[3][1].say.ko,17,11,ok +copy,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[3][2].say.en,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[3][2].say.ko,23,15,ok +copy,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[3][3].say.en,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[3][3].say.ko,20,15,ok +copy,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[3][4].say.en,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[3][4].say.ko,17,10,ok +copy,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[3][5].say.en,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[3][5].say.ko,21,9,ok +copy,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[3][6].say.en,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[3][6].say.ko,18,17,ok +copy,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[3][7].say.en,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[3][7].say.ko,17,20,ok +copy,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[4][0].say.en,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[4][0].say.ko,14,8,ok +copy,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[4][1].say.en,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[4][1].say.ko,36,28,ok +copy,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[4][2].say.en,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[4][2].say.ko,9,9,ok +copy,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[4][3].say.en,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[4][3].say.ko,21,20,ok +copy,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[4][4].say.en,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[4][4].say.ko,29,22,ok +copy,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[5][1].say.en,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[5][1].say.ko,14,9,ok +copy,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[5][2].say.en,web/src/components/guide/tutorials/community.ts#COMMUNITY_TUTORIAL[5][2].say.ko,21,20,ok +copy,web/src/components/guide/tutorials/community.ts#toWall.say.en,web/src/components/guide/tutorials/community.ts#toWall.say.ko,17,11,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[0][0].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[0][0].say.ko,10,4,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[0][1].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[0][1].say.ko,7,2,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[0][2].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[0][2].say.ko,8,2,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[0][3].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[0][3].say.ko,12,9,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[10][0].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[10][0].say.ko,9,8,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[10][1].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[10][1].say.ko,16,10,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[1][1].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[1][1].say.ko,9,6,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[1][2].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[1][2].say.ko,18,10,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[2][1].en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[2][1].ko,14,9,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[2][2].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[2][2].say.ko,15,14,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[2][4].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[2][4].say.ko,10,9,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[3][1].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[3][1].say.ko,15,14,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[3][2].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[3][2].say.ko,23,12,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[4][1].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[4][1].say.ko,13,8,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[4][2].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[4][2].say.ko,21,10,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[4][3].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[4][3].say.ko,21,10,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[5][2].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[5][2].say.ko,15,7,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[5][3].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[5][3].say.ko,23,27,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[6][2].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[6][2].say.ko,14,10,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[6][3].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[6][3].say.ko,22,15,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[6][4].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[6][4].say.ko,10,9,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[6][5].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[6][5].say.ko,13,8,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[7][1].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[7][1].say.ko,10,9,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[7][2].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[7][2].say.ko,19,20,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[7][3].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[7][3].say.ko,23,14,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[8][1].en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[8][1].ko,17,17,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[8][2].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[8][2].say.ko,9,6,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[8][3].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[8][3].say.ko,12,11,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[9][1].en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[9][1].ko,15,12,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[9][2].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[9][2].say.ko,19,8,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[9][3].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[9][3].say.ko,18,10,ok +copy,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[9][4].say.en,web/src/components/guide/tutorials/lab.ts#LAB_TUTORIAL[9][4].say.ko,6,5,ok +copy,web/src/components/guide/tutorials/lab.ts#askAi[0].say.en,web/src/components/guide/tutorials/lab.ts#askAi[0].say.ko,11,8,ok +copy,web/src/components/guide/tutorials/lab.ts#askAi[1].say.en,web/src/components/guide/tutorials/lab.ts#askAi[1].say.ko,6,3,ok +copy,web/src/components/guide/tutorials/lab.ts#askAi[2].say.en,web/src/components/guide/tutorials/lab.ts#askAi[2].say.ko,13,8,ok +copy,web/src/components/guide/tutorials/lab.ts#askAi[3].say.en,web/src/components/guide/tutorials/lab.ts#askAi[3].say.ko,9,7,ok +copy,web/src/components/guide/tutorials/lab.ts#closeHw.say.en,web/src/components/guide/tutorials/lab.ts#closeHw.say.ko,20,7,ok +copy,web/src/components/guide/tutorials/lab.ts#openHw.say.en,web/src/components/guide/tutorials/lab.ts#openHw.say.ko,15,14,ok +copy,web/src/components/guide/tutorials/lab.ts#unstack[0].say.en,web/src/components/guide/tutorials/lab.ts#unstack[0].say.ko,37,24,en-short +field,web/src/app/roadmap/roadmap-data.ts#LANES[case].label,web/src/app/roadmap/roadmap-data.ts#LANES[case].labelKo,9,4,ok +field,web/src/app/roadmap/roadmap-data.ts#LANES[community].label,web/src/app/roadmap/roadmap-data.ts#LANES[community].labelKo,9,4,ok +field,web/src/app/roadmap/roadmap-data.ts#LANES[firmware].label,web/src/app/roadmap/roadmap-data.ts#LANES[firmware].labelKo,8,3,ok +field,web/src/app/roadmap/roadmap-data.ts#LANES[guides].label,web/src/app/roadmap/roadmap-data.ts#LANES[guides].labelKo,6,5,ok +field,web/src/app/roadmap/roadmap-data.ts#LANES[media].label,web/src/app/roadmap/roadmap-data.ts#LANES[media].labelKo,11,6,ok +field,web/src/app/roadmap/roadmap-data.ts#LANES[pcb].label,web/src/app/roadmap/roadmap-data.ts#LANES[pcb].labelKo,3,5,ok +field,web/src/app/roadmap/roadmap-data.ts#LANES[tools].label,web/src/app/roadmap/roadmap-data.ts#LANES[tools].labelKo,12,5,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-campaign-success].detail,web/src/app/roadmap/roadmap-data.ts#NODES[biz-campaign-success].detailKo,142,67,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-campaign-success].title,web/src/app/roadmap/roadmap-data.ts#NODES[biz-campaign-success].titleKo,26,14,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-cs-150].detail,web/src/app/roadmap/roadmap-data.ts#NODES[biz-cs-150].detailKo,309,144,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-cs-150].title,web/src/app/roadmap/roadmap-data.ts#NODES[biz-cs-150].titleKo,18,9,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-cs].detail,web/src/app/roadmap/roadmap-data.ts#NODES[biz-cs].detailKo,266,138,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-cs].title,web/src/app/roadmap/roadmap-data.ts#NODES[biz-cs].titleKo,19,13,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-designright].detail,web/src/app/roadmap/roadmap-data.ts#NODES[biz-designright].detailKo,201,108,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-designright].title,web/src/app/roadmap/roadmap-data.ts#NODES[biz-designright].titleKo,19,13,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-discord].detail,web/src/app/roadmap/roadmap-data.ts#NODES[biz-discord].detailKo,275,138,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-discord].title,web/src/app/roadmap/roadmap-data.ts#NODES[biz-discord].titleKo,15,9,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-exhibition].detail,web/src/app/roadmap/roadmap-data.ts#NODES[biz-exhibition].detailKo,281,135,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-exhibition].title,web/src/app/roadmap/roadmap-data.ts#NODES[biz-exhibition].titleKo,10,2,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-kams].detail,web/src/app/roadmap/roadmap-data.ts#NODES[biz-kams].detailKo,212,126,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-kams].title,web/src/app/roadmap/roadmap-data.ts#NODES[biz-kams].titleKo,28,20,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-launch].detail,web/src/app/roadmap/roadmap-data.ts#NODES[biz-launch].detailKo,208,107,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-launch].title,web/src/app/roadmap/roadmap-data.ts#NODES[biz-launch].titleKo,14,10,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-market].detail,web/src/app/roadmap/roadmap-data.ts#NODES[biz-market].detailKo,324,169,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-market].title,web/src/app/roadmap/roadmap-data.ts#NODES[biz-market].titleKo,18,8,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-nath-build].detail,web/src/app/roadmap/roadmap-data.ts#NODES[biz-nath-build].detailKo,159,77,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-nath-build].title,web/src/app/roadmap/roadmap-data.ts#NODES[biz-nath-build].titleKo,19,9,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-opensource].detail,web/src/app/roadmap/roadmap-data.ts#NODES[biz-opensource].detailKo,240,133,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-opensource].title,web/src/app/roadmap/roadmap-data.ts#NODES[biz-opensource].titleKo,20,5,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-pcbway-order].detail,web/src/app/roadmap/roadmap-data.ts#NODES[biz-pcbway-order].detailKo,309,146,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-pcbway-order].title,web/src/app/roadmap/roadmap-data.ts#NODES[biz-pcbway-order].titleKo,17,10,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-prelaunch].detail,web/src/app/roadmap/roadmap-data.ts#NODES[biz-prelaunch].detailKo,241,127,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-prelaunch].title,web/src/app/roadmap/roadmap-data.ts#NODES[biz-prelaunch].titleKo,14,12,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-reddit].detail,web/src/app/roadmap/roadmap-data.ts#NODES[biz-reddit].detailKo,341,171,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-reddit].title,web/src/app/roadmap/roadmap-data.ts#NODES[biz-reddit].titleKo,15,5,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-sdsc].detail,web/src/app/roadmap/roadmap-data.ts#NODES[biz-sdsc].detailKo,241,120,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-sdsc].title,web/src/app/roadmap/roadmap-data.ts#NODES[biz-sdsc].titleKo,24,11,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-sfac].detail,web/src/app/roadmap/roadmap-data.ts#NODES[biz-sfac].detailKo,236,94,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[biz-sfac].title,web/src/app/roadmap/roadmap-data.ts#NODES[biz-sfac].titleKo,18,15,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[case-exp].detail,web/src/app/roadmap/roadmap-data.ts#NODES[case-exp].detailKo,353,180,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[case-exp].title,web/src/app/roadmap/roadmap-data.ts#NODES[case-exp].titleKo,16,8,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[case-laser].detail,web/src/app/roadmap/roadmap-data.ts#NODES[case-laser].detailKo,404,218,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[case-laser].title,web/src/app/roadmap/roadmap-data.ts#NODES[case-laser].titleKo,24,12,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[case-petg].detail,web/src/app/roadmap/roadmap-data.ts#NODES[case-petg].detailKo,164,87,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[case-petg].title,web/src/app/roadmap/roadmap-data.ts#NODES[case-petg].titleKo,20,12,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[case-proto].detail,web/src/app/roadmap/roadmap-data.ts#NODES[case-proto].detailKo,342,179,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[case-proto].title,web/src/app/roadmap/roadmap-data.ts#NODES[case-proto].titleKo,23,10,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[case-snapfit].detail,web/src/app/roadmap/roadmap-data.ts#NODES[case-snapfit].detailKo,198,116,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[case-snapfit].title,web/src/app/roadmap/roadmap-data.ts#NODES[case-snapfit].titleKo,17,9,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[case-v1].detail,web/src/app/roadmap/roadmap-data.ts#NODES[case-v1].detailKo,301,154,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[case-v1].title,web/src/app/roadmap/roadmap-data.ts#NODES[case-v1].titleKo,14,8,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[case-v3].detail,web/src/app/roadmap/roadmap-data.ts#NODES[case-v3].detailKo,369,222,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[case-v3].title,web/src/app/roadmap/roadmap-data.ts#NODES[case-v3].titleKo,19,9,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[community-discussions].detail,web/src/app/roadmap/roadmap-data.ts#NODES[community-discussions].detailKo,393,201,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[community-discussions].title,web/src/app/roadmap/roadmap-data.ts#NODES[community-discussions].titleKo,16,6,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[community-terms].detail,web/src/app/roadmap/roadmap-data.ts#NODES[community-terms].detailKo,280,127,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[community-terms].title,web/src/app/roadmap/roadmap-data.ts#NODES[community-terms].titleKo,22,9,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-ableton].detail,web/src/app/roadmap/roadmap-data.ts#NODES[fw-ableton].detailKo,292,172,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-ableton].title,web/src/app/roadmap/roadmap-data.ts#NODES[fw-ableton].titleKo,13,16,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-browser-build].detail,web/src/app/roadmap/roadmap-data.ts#NODES[fw-browser-build].detailKo,319,174,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-browser-build].title,web/src/app/roadmap/roadmap-data.ts#NODES[fw-browser-build].titleKo,21,10,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-color-calibration].detail,web/src/app/roadmap/roadmap-data.ts#NODES[fw-color-calibration].detailKo,784,402,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-color-calibration].title,web/src/app/roadmap/roadmap-data.ts#NODES[fw-color-calibration].titleKo,21,5,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-dualcore-pipeline].detail,web/src/app/roadmap/roadmap-data.ts#NODES[fw-dualcore-pipeline].detailKo,377,227,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-dualcore-pipeline].title,web/src/app/roadmap/roadmap-data.ts#NODES[fw-dualcore-pipeline].titleKo,34,20,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-editions].detail,web/src/app/roadmap/roadmap-data.ts#NODES[fw-editions].detailKo,420,268,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-editions].title,web/src/app/roadmap/roadmap-data.ts#NODES[fw-editions].titleKo,28,20,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-foundation].detail,web/src/app/roadmap/roadmap-data.ts#NODES[fw-foundation].detailKo,365,212,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-foundation].title,web/src/app/roadmap/roadmap-data.ts#NODES[fw-foundation].titleKo,18,10,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-improv].detail,web/src/app/roadmap/roadmap-data.ts#NODES[fw-improv].detailKo,166,92,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-improv].title,web/src/app/roadmap/roadmap-data.ts#NODES[fw-improv].titleKo,11,13,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-knobs].detail,web/src/app/roadmap/roadmap-data.ts#NODES[fw-knobs].detailKo,463,243,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-knobs].title,web/src/app/roadmap/roadmap-data.ts#NODES[fw-knobs].titleKo,17,7,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-modules].detail,web/src/app/roadmap/roadmap-data.ts#NODES[fw-modules].detailKo,431,276,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-modules].title,web/src/app/roadmap/roadmap-data.ts#NODES[fw-modules].titleKo,22,13,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-osc].detail,web/src/app/roadmap/roadmap-data.ts#NODES[fw-osc].detailKo,307,176,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-osc].title,web/src/app/roadmap/roadmap-data.ts#NODES[fw-osc].titleKo,13,11,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-presets].detail,web/src/app/roadmap/roadmap-data.ts#NODES[fw-presets].detailKo,329,189,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-presets].title,web/src/app/roadmap/roadmap-data.ts#NODES[fw-presets].titleKo,18,9,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-resolution].detail,web/src/app/roadmap/roadmap-data.ts#NODES[fw-resolution].detailKo,288,158,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-resolution].title,web/src/app/roadmap/roadmap-data.ts#NODES[fw-resolution].titleKo,20,7,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-v11].detail,web/src/app/roadmap/roadmap-data.ts#NODES[fw-v11].detailKo,331,211,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-v11].title,web/src/app/roadmap/roadmap-data.ts#NODES[fw-v11].titleKo,19,9,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-v1].detail,web/src/app/roadmap/roadmap-data.ts#NODES[fw-v1].detailKo,190,131,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-v1].title,web/src/app/roadmap/roadmap-data.ts#NODES[fw-v1].titleKo,12,7,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-v2].detail,web/src/app/roadmap/roadmap-data.ts#NODES[fw-v2].detailKo,280,159,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[fw-v2].title,web/src/app/roadmap/roadmap-data.ts#NODES[fw-v2].titleKo,13,9,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[guide-breadboard].detail,web/src/app/roadmap/roadmap-data.ts#NODES[guide-breadboard].detailKo,205,121,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[guide-breadboard].title,web/src/app/roadmap/roadmap-data.ts#NODES[guide-breadboard].titleKo,15,17,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[guide-panel].detail,web/src/app/roadmap/roadmap-data.ts#NODES[guide-panel].detailKo,374,215,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[guide-panel].title,web/src/app/roadmap/roadmap-data.ts#NODES[guide-panel].titleKo,23,11,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[guide-pattern].detail,web/src/app/roadmap/roadmap-data.ts#NODES[guide-pattern].detailKo,342,181,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[guide-pattern].title,web/src/app/roadmap/roadmap-data.ts#NODES[guide-pattern].titleKo,21,7,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[guide-photos].detail,web/src/app/roadmap/roadmap-data.ts#NODES[guide-photos].detailKo,238,119,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[guide-photos].title,web/src/app/roadmap/roadmap-data.ts#NODES[guide-photos].titleKo,12,10,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[guide-rebuild].detail,web/src/app/roadmap/roadmap-data.ts#NODES[guide-rebuild].detailKo,281,166,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[guide-rebuild].title,web/src/app/roadmap/roadmap-data.ts#NODES[guide-rebuild].titleKo,16,11,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[guide-tht].detail,web/src/app/roadmap/roadmap-data.ts#NODES[guide-tht].detailKo,229,133,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[guide-tht].title,web/src/app/roadmap/roadmap-data.ts#NODES[guide-tht].titleKo,15,17,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[guide-v1].detail,web/src/app/roadmap/roadmap-data.ts#NODES[guide-v1].detailKo,282,129,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[guide-v1].title,web/src/app/roadmap/roadmap-data.ts#NODES[guide-v1].titleKo,12,7,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-amazona].detail,web/src/app/roadmap/roadmap-data.ts#NODES[media-amazona].detailKo,201,153,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-amazona].title,web/src/app/roadmap/roadmap-data.ts#NODES[media-amazona].titleKo,10,16,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-can].detail,web/src/app/roadmap/roadmap-data.ts#NODES[media-can].detailKo,265,179,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-can].title,web/src/app/roadmap/roadmap-data.ts#NODES[media-can].titleKo,20,25,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-digikey-youtube].detail,web/src/app/roadmap/roadmap-data.ts#NODES[media-digikey-youtube].detailKo,118,80,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-digikey-youtube].title,web/src/app/roadmap/roadmap-data.ts#NODES[media-digikey-youtube].titleKo,14,15,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-fabscene].detail,web/src/app/roadmap/roadmap-data.ts#NODES[media-fabscene].detailKo,256,147,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-fabscene].title,web/src/app/roadmap/roadmap-data.ts#NODES[media-fabscene].titleKo,14,15,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-hackster-1].detail,web/src/app/roadmap/roadmap-data.ts#NODES[media-hackster-1].detailKo,199,129,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-hackster-1].title,web/src/app/roadmap/roadmap-data.ts#NODES[media-hackster-1].titleKo,12,15,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-hackster-2].detail,web/src/app/roadmap/roadmap-data.ts#NODES[media-hackster-2].detailKo,160,116,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-hackster-2].title,web/src/app/roadmap/roadmap-data.ts#NODES[media-hackster-2].titleKo,12,15,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-howtogeek].detail,web/src/app/roadmap/roadmap-data.ts#NODES[media-howtogeek].detailKo,203,122,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-howtogeek].title,web/src/app/roadmap/roadmap-data.ts#NODES[media-howtogeek].titleKo,10,16,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-matrixsynth].detail,web/src/app/roadmap/roadmap-data.ts#NODES[media-matrixsynth].detailKo,191,149,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-matrixsynth].title,web/src/app/roadmap/roadmap-data.ts#NODES[media-matrixsynth].titleKo,11,19,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-midifan].detail,web/src/app/roadmap/roadmap-data.ts#NODES[media-midifan].detailKo,180,140,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-midifan].title,web/src/app/roadmap/roadmap-data.ts#NODES[media-midifan].titleKo,7,12,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-mixmag].detail,web/src/app/roadmap/roadmap-data.ts#NODES[media-mixmag].detailKo,253,156,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-mixmag].title,web/src/app/roadmap/roadmap-data.ts#NODES[media-mixmag].titleKo,10,18,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-mmr].detail,web/src/app/roadmap/roadmap-data.ts#NODES[media-mmr].detailKo,233,143,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-mmr].title,web/src/app/roadmap/roadmap-data.ts#NODES[media-mmr].titleKo,11,19,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-synthtopia].detail,web/src/app/roadmap/roadmap-data.ts#NODES[media-synthtopia].detailKo,206,160,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-synthtopia].title,web/src/app/roadmap/roadmap-data.ts#NODES[media-synthtopia].titleKo,10,17,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-twitter-viral].detail,web/src/app/roadmap/roadmap-data.ts#NODES[media-twitter-viral].detailKo,282,152,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-twitter-viral].title,web/src/app/roadmap/roadmap-data.ts#NODES[media-twitter-viral].titleKo,14,9,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-video].detail,web/src/app/roadmap/roadmap-data.ts#NODES[media-video].detailKo,277,139,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-video].title,web/src/app/roadmap/roadmap-data.ts#NODES[media-video].titleKo,10,4,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-yanko].detail,web/src/app/roadmap/roadmap-data.ts#NODES[media-yanko].detailKo,139,102,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[media-yanko].title,web/src/app/roadmap/roadmap-data.ts#NODES[media-yanko].titleKo,11,18,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-proto-v0].detail,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-proto-v0].detailKo,376,212,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-proto-v0].title,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-proto-v0].titleKo,22,8,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-usbc-safety].detail,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-usbc-safety].detailKo,327,178,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-usbc-safety].title,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-usbc-safety].titleKo,19,11,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-v1].detail,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-v1].detailKo,285,175,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-v1].title,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-v1].titleKo,7,6,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-v21].detail,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-v21].detailKo,313,177,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-v21].title,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-v21].titleKo,11,8,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-v22].detail,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-v22].detailKo,358,176,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-v22].title,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-v22].titleKo,18,15,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-v2].detail,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-v2].detailKo,326,200,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-v2].title,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-v2].titleKo,14,8,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-v3-test].detail,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-v3-test].detailKo,247,151,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-v3-test].title,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-v3-test].titleKo,13,9,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-v3].detail,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-v3].detailKo,324,175,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-v3].title,web/src/app/roadmap/roadmap-data.ts#NODES[pcb-v3].titleKo,11,8,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[tools-director-export].detail,web/src/app/roadmap/roadmap-data.ts#NODES[tools-director-export].detailKo,263,129,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[tools-director-export].title,web/src/app/roadmap/roadmap-data.ts#NODES[tools-director-export].titleKo,22,21,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[tools-editor].detail,web/src/app/roadmap/roadmap-data.ts#NODES[tools-editor].detailKo,244,129,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[tools-editor].title,web/src/app/roadmap/roadmap-data.ts#NODES[tools-editor].titleKo,10,7,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[tools-gemini].detail,web/src/app/roadmap/roadmap-data.ts#NODES[tools-gemini].detailKo,191,104,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[tools-gemini].title,web/src/app/roadmap/roadmap-data.ts#NODES[tools-gemini].titleKo,16,12,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[tools-lab-mobile].detail,web/src/app/roadmap/roadmap-data.ts#NODES[tools-lab-mobile].detailKo,282,149,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[tools-lab-mobile].title,web/src/app/roadmap/roadmap-data.ts#NODES[tools-lab-mobile].titleKo,22,11,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[tools-lab-v2].detail,web/src/app/roadmap/roadmap-data.ts#NODES[tools-lab-v2].detailKo,441,262,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[tools-lab-v2].title,web/src/app/roadmap/roadmap-data.ts#NODES[tools-lab-v2].titleKo,19,9,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[tools-lab].detail,web/src/app/roadmap/roadmap-data.ts#NODES[tools-lab].detailKo,251,125,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[tools-lab].title,web/src/app/roadmap/roadmap-data.ts#NODES[tools-lab].titleKo,10,15,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[tools-multiagent].detail,web/src/app/roadmap/roadmap-data.ts#NODES[tools-multiagent].detailKo,200,107,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[tools-multiagent].title,web/src/app/roadmap/roadmap-data.ts#NODES[tools-multiagent].titleKo,21,10,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[tools-oklab].detail,web/src/app/roadmap/roadmap-data.ts#NODES[tools-oklab].detailKo,701,354,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[tools-oklab].title,web/src/app/roadmap/roadmap-data.ts#NODES[tools-oklab].titleKo,20,7,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[tools-origin-studio].detail,web/src/app/roadmap/roadmap-data.ts#NODES[tools-origin-studio].detailKo,194,92,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[tools-origin-studio].title,web/src/app/roadmap/roadmap-data.ts#NODES[tools-origin-studio].titleKo,16,8,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[tools-origin].detail,web/src/app/roadmap/roadmap-data.ts#NODES[tools-origin].detailKo,333,195,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[tools-origin].title,web/src/app/roadmap/roadmap-data.ts#NODES[tools-origin].titleKo,17,8,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[tools-paik].detail,web/src/app/roadmap/roadmap-data.ts#NODES[tools-paik].detailKo,363,204,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[tools-paik].title,web/src/app/roadmap/roadmap-data.ts#NODES[tools-paik].titleKo,20,7,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[tools-reflow].detail,web/src/app/roadmap/roadmap-data.ts#NODES[tools-reflow].detailKo,277,137,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[tools-reflow].title,web/src/app/roadmap/roadmap-data.ts#NODES[tools-reflow].titleKo,19,11,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[tools-stack].detail,web/src/app/roadmap/roadmap-data.ts#NODES[tools-stack].detailKo,308,160,ok +field,web/src/app/roadmap/roadmap-data.ts#NODES[tools-stack].title,web/src/app/roadmap/roadmap-data.ts#NODES[tools-stack].titleKo,17,10,ok +field,web/src/lib/atlas/data.ts#ENTRIES[adhesion].knobEn,web/src/lib/atlas/data.ts#ENTRIES[adhesion].knob,158,65,ok +field,web/src/lib/atlas/data.ts#ENTRIES[adhesion].nmEn,web/src/lib/atlas/data.ts#ENTRIES[adhesion].nm,17,14,ok +field,web/src/lib/atlas/data.ts#ENTRIES[adhesion].texEn,web/src/lib/atlas/data.ts#ENTRIES[adhesion].tex,199,102,ok +field,web/src/lib/atlas/data.ts#ENTRIES[billiard].knobEn,web/src/lib/atlas/data.ts#ENTRIES[billiard].knob,113,47,ok +field,web/src/lib/atlas/data.ts#ENTRIES[billiard].nmEn,web/src/lib/atlas/data.ts#ENTRIES[billiard].nm,34,17,ok +field,web/src/lib/atlas/data.ts#ENTRIES[billiard].texEn,web/src/lib/atlas/data.ts#ENTRIES[billiard].tex,146,63,ok +field,web/src/lib/atlas/data.ts#ENTRIES[blight].implEn,web/src/lib/atlas/data.ts#ENTRIES[blight].impl,92,53,ok +field,web/src/lib/atlas/data.ts#ENTRIES[blight].knobEn,web/src/lib/atlas/data.ts#ENTRIES[blight].knob,93,33,ko-short +field,web/src/lib/atlas/data.ts#ENTRIES[blight].nmEn,web/src/lib/atlas/data.ts#ENTRIES[blight].nm,23,13,ok +field,web/src/lib/atlas/data.ts#ENTRIES[blight].texEn,web/src/lib/atlas/data.ts#ENTRIES[blight].tex,78,27,ok +field,web/src/lib/atlas/data.ts#ENTRIES[braid].knobEn,web/src/lib/atlas/data.ts#ENTRIES[braid].knob,169,89,ok +field,web/src/lib/atlas/data.ts#ENTRIES[braid].nmEn,web/src/lib/atlas/data.ts#ENTRIES[braid].nm,16,12,ok +field,web/src/lib/atlas/data.ts#ENTRIES[braid].riskEn,web/src/lib/atlas/data.ts#ENTRIES[braid].risk,131,53,ok +field,web/src/lib/atlas/data.ts#ENTRIES[braid].texEn,web/src/lib/atlas/data.ts#ENTRIES[braid].tex,178,82,ok +field,web/src/lib/atlas/data.ts#ENTRIES[braid].vertEn,web/src/lib/atlas/data.ts#ENTRIES[braid].vert,88,42,ok +field,web/src/lib/atlas/data.ts#ENTRIES[branched].knobEn,web/src/lib/atlas/data.ts#ENTRIES[branched].knob,206,78,ok +field,web/src/lib/atlas/data.ts#ENTRIES[branched].nmEn,web/src/lib/atlas/data.ts#ENTRIES[branched].nm,12,15,ok +field,web/src/lib/atlas/data.ts#ENTRIES[branched].riskEn,web/src/lib/atlas/data.ts#ENTRIES[branched].risk,136,55,ok +field,web/src/lib/atlas/data.ts#ENTRIES[branched].texEn,web/src/lib/atlas/data.ts#ENTRIES[branched].tex,170,81,ok +field,web/src/lib/atlas/data.ts#ENTRIES[branched].vertEn,web/src/lib/atlas/data.ts#ENTRIES[branched].vert,76,33,ok +field,web/src/lib/atlas/data.ts#ENTRIES[cahn].knobEn,web/src/lib/atlas/data.ts#ENTRIES[cahn].knob,62,23,ok +field,web/src/lib/atlas/data.ts#ENTRIES[cahn].nmEn,web/src/lib/atlas/data.ts#ENTRIES[cahn].nm,23,16,ok +field,web/src/lib/atlas/data.ts#ENTRIES[cahn].texEn,web/src/lib/atlas/data.ts#ENTRIES[cahn].tex,92,31,ok +field,web/src/lib/atlas/data.ts#ENTRIES[cascade].knobEn,web/src/lib/atlas/data.ts#ENTRIES[cascade].knob,86,29,ok +field,web/src/lib/atlas/data.ts#ENTRIES[cascade].nmEn,web/src/lib/atlas/data.ts#ENTRIES[cascade].nm,23,11,ok +field,web/src/lib/atlas/data.ts#ENTRIES[cascade].riskEn,web/src/lib/atlas/data.ts#ENTRIES[cascade].risk,97,40,ok +field,web/src/lib/atlas/data.ts#ENTRIES[cascade].texEn,web/src/lib/atlas/data.ts#ENTRIES[cascade].tex,154,80,ok +field,web/src/lib/atlas/data.ts#ENTRIES[cascade].vertEn,web/src/lib/atlas/data.ts#ENTRIES[cascade].vert,81,38,ok +field,web/src/lib/atlas/data.ts#ENTRIES[caustic].knobEn,web/src/lib/atlas/data.ts#ENTRIES[caustic].knob,61,28,ok +field,web/src/lib/atlas/data.ts#ENTRIES[caustic].nmEn,web/src/lib/atlas/data.ts#ENTRIES[caustic].nm,18,7,ok +field,web/src/lib/atlas/data.ts#ENTRIES[caustic].texEn,web/src/lib/atlas/data.ts#ENTRIES[caustic].tex,87,35,ok +field,web/src/lib/atlas/data.ts#ENTRIES[cgl1d].knobEn,web/src/lib/atlas/data.ts#ENTRIES[cgl1d].knob,43,14,ko-short +field,web/src/lib/atlas/data.ts#ENTRIES[cgl1d].nmEn,web/src/lib/atlas/data.ts#ENTRIES[cgl1d].nm,14,7,ok +field,web/src/lib/atlas/data.ts#ENTRIES[cgl1d].texEn,web/src/lib/atlas/data.ts#ENTRIES[cgl1d].tex,61,23,ok +field,web/src/lib/atlas/data.ts#ENTRIES[cgl].knobEn,web/src/lib/atlas/data.ts#ENTRIES[cgl].knob,59,31,ok +field,web/src/lib/atlas/data.ts#ENTRIES[cgl].nmEn,web/src/lib/atlas/data.ts#ENTRIES[cgl].nm,19,7,ok +field,web/src/lib/atlas/data.ts#ENTRIES[cgl].texEn,web/src/lib/atlas/data.ts#ENTRIES[cgl].tex,73,27,ok +field,web/src/lib/atlas/data.ts#ENTRIES[chimera].knobEn,web/src/lib/atlas/data.ts#ENTRIES[chimera].knob,71,28,ok +field,web/src/lib/atlas/data.ts#ENTRIES[chimera].nmEn,web/src/lib/atlas/data.ts#ENTRIES[chimera].nm,25,13,ok +field,web/src/lib/atlas/data.ts#ENTRIES[chimera].texEn,web/src/lib/atlas/data.ts#ENTRIES[chimera].tex,163,63,ok +field,web/src/lib/atlas/data.ts#ENTRIES[chirikov].knobEn,web/src/lib/atlas/data.ts#ENTRIES[chirikov].knob,67,26,ok +field,web/src/lib/atlas/data.ts#ENTRIES[chirikov].nmEn,web/src/lib/atlas/data.ts#ENTRIES[chirikov].nm,22,8,ok +field,web/src/lib/atlas/data.ts#ENTRIES[chirikov].texEn,web/src/lib/atlas/data.ts#ENTRIES[chirikov].tex,78,29,ok +field,web/src/lib/atlas/data.ts#ENTRIES[chladni].knobEn,web/src/lib/atlas/data.ts#ENTRIES[chladni].knob,74,35,ok +field,web/src/lib/atlas/data.ts#ENTRIES[chladni].nmEn,web/src/lib/atlas/data.ts#ENTRIES[chladni].nm,24,13,ok +field,web/src/lib/atlas/data.ts#ENTRIES[chladni].riskEn,web/src/lib/atlas/data.ts#ENTRIES[chladni].risk,78,27,ok +field,web/src/lib/atlas/data.ts#ENTRIES[chladni].texEn,web/src/lib/atlas/data.ts#ENTRIES[chladni].tex,142,47,ok +field,web/src/lib/atlas/data.ts#ENTRIES[choke].implEn,web/src/lib/atlas/data.ts#ENTRIES[choke].impl,136,59,ok +field,web/src/lib/atlas/data.ts#ENTRIES[choke].knobEn,web/src/lib/atlas/data.ts#ENTRIES[choke].knob,126,39,ko-short +field,web/src/lib/atlas/data.ts#ENTRIES[choke].nmEn,web/src/lib/atlas/data.ts#ENTRIES[choke].nm,25,13,ok +field,web/src/lib/atlas/data.ts#ENTRIES[choke].texEn,web/src/lib/atlas/data.ts#ENTRIES[choke].tex,144,65,ok +field,web/src/lib/atlas/data.ts#ENTRIES[clifford].knobEn,web/src/lib/atlas/data.ts#ENTRIES[clifford].knob,88,37,ok +field,web/src/lib/atlas/data.ts#ENTRIES[clifford].nmEn,web/src/lib/atlas/data.ts#ENTRIES[clifford].nm,18,17,ok +field,web/src/lib/atlas/data.ts#ENTRIES[clifford].riskEn,web/src/lib/atlas/data.ts#ENTRIES[clifford].risk,81,32,ok +field,web/src/lib/atlas/data.ts#ENTRIES[clifford].texEn,web/src/lib/atlas/data.ts#ENTRIES[clifford].tex,116,43,ok +field,web/src/lib/atlas/data.ts#ENTRIES[clifford].vertEn,web/src/lib/atlas/data.ts#ENTRIES[clifford].vert,75,24,ok +field,web/src/lib/atlas/data.ts#ENTRIES[crease].knobEn,web/src/lib/atlas/data.ts#ENTRIES[crease].knob,56,29,ok +field,web/src/lib/atlas/data.ts#ENTRIES[crease].nmEn,web/src/lib/atlas/data.ts#ENTRIES[crease].nm,25,16,ok +field,web/src/lib/atlas/data.ts#ENTRIES[crease].texEn,web/src/lib/atlas/data.ts#ENTRIES[crease].tex,108,66,ok +field,web/src/lib/atlas/data.ts#ENTRIES[creep].knobEn,web/src/lib/atlas/data.ts#ENTRIES[creep].knob,97,31,ko-short +field,web/src/lib/atlas/data.ts#ENTRIES[creep].nmEn,web/src/lib/atlas/data.ts#ENTRIES[creep].nm,20,11,ok +field,web/src/lib/atlas/data.ts#ENTRIES[creep].texEn,web/src/lib/atlas/data.ts#ENTRIES[creep].tex,147,67,ok +field,web/src/lib/atlas/data.ts#ENTRIES[curtain].knobEn,web/src/lib/atlas/data.ts#ENTRIES[curtain].knob,75,44,ok +field,web/src/lib/atlas/data.ts#ENTRIES[curtain].nmEn,web/src/lib/atlas/data.ts#ENTRIES[curtain].nm,30,18,ok +field,web/src/lib/atlas/data.ts#ENTRIES[curtain].texEn,web/src/lib/atlas/data.ts#ENTRIES[curtain].tex,166,70,ok +field,web/src/lib/atlas/data.ts#ENTRIES[curtain].vertEn,web/src/lib/atlas/data.ts#ENTRIES[curtain].vert,65,28,ok +field,web/src/lib/atlas/data.ts#ENTRIES[dla].knobEn,web/src/lib/atlas/data.ts#ENTRIES[dla].knob,66,27,ok +field,web/src/lib/atlas/data.ts#ENTRIES[dla].nmEn,web/src/lib/atlas/data.ts#ENTRIES[dla].nm,19,8,ok +field,web/src/lib/atlas/data.ts#ENTRIES[dla].riskEn,web/src/lib/atlas/data.ts#ENTRIES[dla].risk,62,29,ok +field,web/src/lib/atlas/data.ts#ENTRIES[dla].texEn,web/src/lib/atlas/data.ts#ENTRIES[dla].tex,81,25,ok +field,web/src/lib/atlas/data.ts#ENTRIES[fhn].knobEn,web/src/lib/atlas/data.ts#ENTRIES[fhn].knob,62,24,ok +field,web/src/lib/atlas/data.ts#ENTRIES[fhn].riskEn,web/src/lib/atlas/data.ts#ENTRIES[fhn].risk,56,31,ok +field,web/src/lib/atlas/data.ts#ENTRIES[fhn].texEn,web/src/lib/atlas/data.ts#ENTRIES[fhn].tex,51,21,ok +field,web/src/lib/atlas/data.ts#ENTRIES[flame].implEn,web/src/lib/atlas/data.ts#ENTRIES[flame].impl,83,61,ok +field,web/src/lib/atlas/data.ts#ENTRIES[flame].knobEn,web/src/lib/atlas/data.ts#ENTRIES[flame].knob,48,27,ok +field,web/src/lib/atlas/data.ts#ENTRIES[flame].nmEn,web/src/lib/atlas/data.ts#ENTRIES[flame].nm,12,6,ok +field,web/src/lib/atlas/data.ts#ENTRIES[flame].texEn,web/src/lib/atlas/data.ts#ENTRIES[flame].tex,72,36,ok +field,web/src/lib/atlas/data.ts#ENTRIES[fluid].knobEn,web/src/lib/atlas/data.ts#ENTRIES[fluid].knob,59,36,ok +field,web/src/lib/atlas/data.ts#ENTRIES[fluid].nmEn,web/src/lib/atlas/data.ts#ENTRIES[fluid].nm,15,14,ok +field,web/src/lib/atlas/data.ts#ENTRIES[fluid].riskEn,web/src/lib/atlas/data.ts#ENTRIES[fluid].risk,104,50,ok +field,web/src/lib/atlas/data.ts#ENTRIES[fluid].texEn,web/src/lib/atlas/data.ts#ENTRIES[fluid].tex,101,40,ok +field,web/src/lib/atlas/data.ts#ENTRIES[forestfire].knobEn,web/src/lib/atlas/data.ts#ENTRIES[forestfire].knob,91,46,ok +field,web/src/lib/atlas/data.ts#ENTRIES[forestfire].nmEn,web/src/lib/atlas/data.ts#ENTRIES[forestfire].nm,22,6,ok +field,web/src/lib/atlas/data.ts#ENTRIES[forestfire].texEn,web/src/lib/atlas/data.ts#ENTRIES[forestfire].tex,97,42,ok +field,web/src/lib/atlas/data.ts#ENTRIES[fpu].knobEn,web/src/lib/atlas/data.ts#ENTRIES[fpu].knob,77,28,ok +field,web/src/lib/atlas/data.ts#ENTRIES[fpu].nmEn,web/src/lib/atlas/data.ts#ENTRIES[fpu].nm,19,8,ok +field,web/src/lib/atlas/data.ts#ENTRIES[fpu].riskEn,web/src/lib/atlas/data.ts#ENTRIES[fpu].risk,153,56,ok +field,web/src/lib/atlas/data.ts#ENTRIES[fpu].texEn,web/src/lib/atlas/data.ts#ENTRIES[fpu].tex,170,61,ok +field,web/src/lib/atlas/data.ts#ENTRIES[ftle].knobEn,web/src/lib/atlas/data.ts#ENTRIES[ftle].knob,112,43,ok +field,web/src/lib/atlas/data.ts#ENTRIES[ftle].nmEn,web/src/lib/atlas/data.ts#ENTRIES[ftle].nm,22,10,ok +field,web/src/lib/atlas/data.ts#ENTRIES[ftle].texEn,web/src/lib/atlas/data.ts#ENTRIES[ftle].tex,169,86,ok +field,web/src/lib/atlas/data.ts#ENTRIES[gale].knobEn,web/src/lib/atlas/data.ts#ENTRIES[gale].knob,50,23,ok +field,web/src/lib/atlas/data.ts#ENTRIES[gale].nmEn,web/src/lib/atlas/data.ts#ENTRIES[gale].nm,23,7,ok +field,web/src/lib/atlas/data.ts#ENTRIES[gale].texEn,web/src/lib/atlas/data.ts#ENTRIES[gale].tex,61,23,ok +field,web/src/lib/atlas/data.ts#ENTRIES[gmira].knobEn,web/src/lib/atlas/data.ts#ENTRIES[gmira].knob,60,20,ko-short +field,web/src/lib/atlas/data.ts#ENTRIES[gmira].nmEn,web/src/lib/atlas/data.ts#ENTRIES[gmira].nm,16,15,ok +field,web/src/lib/atlas/data.ts#ENTRIES[gmira].texEn,web/src/lib/atlas/data.ts#ENTRIES[gmira].tex,52,30,ok +field,web/src/lib/atlas/data.ts#ENTRIES[harmono].knobEn,web/src/lib/atlas/data.ts#ENTRIES[harmono].knob,74,30,ok +field,web/src/lib/atlas/data.ts#ENTRIES[harmono].nmEn,web/src/lib/atlas/data.ts#ENTRIES[harmono].nm,12,6,ok +field,web/src/lib/atlas/data.ts#ENTRIES[harmono].riskEn,web/src/lib/atlas/data.ts#ENTRIES[harmono].risk,58,19,ok +field,web/src/lib/atlas/data.ts#ENTRIES[harmono].texEn,web/src/lib/atlas/data.ts#ENTRIES[harmono].tex,69,21,ok +field,web/src/lib/atlas/data.ts#ENTRIES[hybrid].knobEn,web/src/lib/atlas/data.ts#ENTRIES[hybrid].knob,64,21,ok +field,web/src/lib/atlas/data.ts#ENTRIES[hybrid].nmEn,web/src/lib/atlas/data.ts#ENTRIES[hybrid].nm,18,5,ok +field,web/src/lib/atlas/data.ts#ENTRIES[hybrid].riskEn,web/src/lib/atlas/data.ts#ENTRIES[hybrid].risk,92,41,ok +field,web/src/lib/atlas/data.ts#ENTRIES[hybrid].texEn,web/src/lib/atlas/data.ts#ENTRIES[hybrid].tex,102,39,ok +field,web/src/lib/atlas/data.ts#ENTRIES[interf].knobEn,web/src/lib/atlas/data.ts#ENTRIES[interf].knob,69,19,ko-short +field,web/src/lib/atlas/data.ts#ENTRIES[interf].nmEn,web/src/lib/atlas/data.ts#ENTRIES[interf].nm,28,8,ok +field,web/src/lib/atlas/data.ts#ENTRIES[interf].riskEn,web/src/lib/atlas/data.ts#ENTRIES[interf].risk,82,31,ok +field,web/src/lib/atlas/data.ts#ENTRIES[interf].texEn,web/src/lib/atlas/data.ts#ENTRIES[interf].tex,82,28,ok +field,web/src/lib/atlas/data.ts#ENTRIES[invasion].implEn,web/src/lib/atlas/data.ts#ENTRIES[invasion].impl,91,48,ok +field,web/src/lib/atlas/data.ts#ENTRIES[invasion].knobEn,web/src/lib/atlas/data.ts#ENTRIES[invasion].knob,76,31,ok +field,web/src/lib/atlas/data.ts#ENTRIES[invasion].nmEn,web/src/lib/atlas/data.ts#ENTRIES[invasion].nm,19,7,ok +field,web/src/lib/atlas/data.ts#ENTRIES[invasion].texEn,web/src/lib/atlas/data.ts#ENTRIES[invasion].tex,150,52,ok +field,web/src/lib/atlas/data.ts#ENTRIES[ising].implEn,web/src/lib/atlas/data.ts#ENTRIES[ising].impl,100,58,ok +field,web/src/lib/atlas/data.ts#ENTRIES[ising].knobEn,web/src/lib/atlas/data.ts#ENTRIES[ising].knob,59,24,ok +field,web/src/lib/atlas/data.ts#ENTRIES[ising].nmEn,web/src/lib/atlas/data.ts#ENTRIES[ising].nm,14,8,ok +field,web/src/lib/atlas/data.ts#ENTRIES[ising].texEn,web/src/lib/atlas/data.ts#ENTRIES[ising].tex,83,22,ko-short +field,web/src/lib/atlas/data.ts#ENTRIES[kdv].knobEn,web/src/lib/atlas/data.ts#ENTRIES[kdv].knob,52,14,ko-short +field,web/src/lib/atlas/data.ts#ENTRIES[kdv].nmEn,web/src/lib/atlas/data.ts#ENTRIES[kdv].nm,20,8,ok +field,web/src/lib/atlas/data.ts#ENTRIES[kdv].riskEn,web/src/lib/atlas/data.ts#ENTRIES[kdv].risk,56,35,ok +field,web/src/lib/atlas/data.ts#ENTRIES[kdv].texEn,web/src/lib/atlas/data.ts#ENTRIES[kdv].tex,60,21,ok +field,web/src/lib/atlas/data.ts#ENTRIES[kneaded].knobEn,web/src/lib/atlas/data.ts#ENTRIES[kneaded].knob,90,40,ok +field,web/src/lib/atlas/data.ts#ENTRIES[kneaded].nmEn,web/src/lib/atlas/data.ts#ENTRIES[kneaded].nm,28,15,ok +field,web/src/lib/atlas/data.ts#ENTRIES[kneaded].texEn,web/src/lib/atlas/data.ts#ENTRIES[kneaded].tex,151,60,ok +field,web/src/lib/atlas/data.ts#ENTRIES[kpz].knobEn,web/src/lib/atlas/data.ts#ENTRIES[kpz].knob,109,50,ok +field,web/src/lib/atlas/data.ts#ENTRIES[kpz].nmEn,web/src/lib/atlas/data.ts#ENTRIES[kpz].nm,18,7,ok +field,web/src/lib/atlas/data.ts#ENTRIES[kpz].texEn,web/src/lib/atlas/data.ts#ENTRIES[kpz].tex,100,37,ok +field,web/src/lib/atlas/data.ts#ENTRIES[ks2d].knobEn,web/src/lib/atlas/data.ts#ENTRIES[ks2d].knob,67,24,ok +field,web/src/lib/atlas/data.ts#ENTRIES[ks2d].nmEn,web/src/lib/atlas/data.ts#ENTRIES[ks2d].nm,27,9,ok +field,web/src/lib/atlas/data.ts#ENTRIES[ks2d].texEn,web/src/lib/atlas/data.ts#ENTRIES[ks2d].tex,143,63,ok +field,web/src/lib/atlas/data.ts#ENTRIES[lenia].knobEn,web/src/lib/atlas/data.ts#ENTRIES[lenia].knob,78,35,ok +field,web/src/lib/atlas/data.ts#ENTRIES[lenia].riskEn,web/src/lib/atlas/data.ts#ENTRIES[lenia].risk,66,30,ok +field,web/src/lib/atlas/data.ts#ENTRIES[lenia].texEn,web/src/lib/atlas/data.ts#ENTRIES[lenia].tex,68,22,ok +field,web/src/lib/atlas/data.ts#ENTRIES[lic].knobEn,web/src/lib/atlas/data.ts#ENTRIES[lic].knob,72,26,ok +field,web/src/lib/atlas/data.ts#ENTRIES[lic].nmEn,web/src/lib/atlas/data.ts#ENTRIES[lic].nm,19,7,ok +field,web/src/lib/atlas/data.ts#ENTRIES[lic].riskEn,web/src/lib/atlas/data.ts#ENTRIES[lic].risk,50,22,ok +field,web/src/lib/atlas/data.ts#ENTRIES[lic].texEn,web/src/lib/atlas/data.ts#ENTRIES[lic].tex,139,57,ok +field,web/src/lib/atlas/data.ts#ENTRIES[lineage].implEn,web/src/lib/atlas/data.ts#ENTRIES[lineage].impl,93,54,ok +field,web/src/lib/atlas/data.ts#ENTRIES[lineage].knobEn,web/src/lib/atlas/data.ts#ENTRIES[lineage].knob,72,32,ok +field,web/src/lib/atlas/data.ts#ENTRIES[lineage].nmEn,web/src/lib/atlas/data.ts#ENTRIES[lineage].nm,19,12,ok +field,web/src/lib/atlas/data.ts#ENTRIES[lineage].texEn,web/src/lib/atlas/data.ts#ENTRIES[lineage].tex,73,35,ok +field,web/src/lib/atlas/data.ts#ENTRIES[lle].knobEn,web/src/lib/atlas/data.ts#ENTRIES[lle].knob,112,41,ok +field,web/src/lib/atlas/data.ts#ENTRIES[lle].nmEn,web/src/lib/atlas/data.ts#ENTRIES[lle].nm,25,8,ok +field,web/src/lib/atlas/data.ts#ENTRIES[lle].texEn,web/src/lib/atlas/data.ts#ENTRIES[lle].tex,131,54,ok +field,web/src/lib/atlas/data.ts#ENTRIES[logistic].knobEn,web/src/lib/atlas/data.ts#ENTRIES[logistic].knob,65,21,ko-short +field,web/src/lib/atlas/data.ts#ENTRIES[logistic].nmEn,web/src/lib/atlas/data.ts#ENTRIES[logistic].nm,23,8,ok +field,web/src/lib/atlas/data.ts#ENTRIES[logistic].riskEn,web/src/lib/atlas/data.ts#ENTRIES[logistic].risk,80,29,ok +field,web/src/lib/atlas/data.ts#ENTRIES[logistic].texEn,web/src/lib/atlas/data.ts#ENTRIES[logistic].tex,75,30,ok +field,web/src/lib/atlas/data.ts#ENTRIES[lorenz].knobEn,web/src/lib/atlas/data.ts#ENTRIES[lorenz].knob,73,32,ok +field,web/src/lib/atlas/data.ts#ENTRIES[lorenz].nmEn,web/src/lib/atlas/data.ts#ENTRIES[lorenz].nm,24,21,ok +field,web/src/lib/atlas/data.ts#ENTRIES[lorenz].texEn,web/src/lib/atlas/data.ts#ENTRIES[lorenz].tex,87,29,ok +field,web/src/lib/atlas/data.ts#ENTRIES[mirror].implEn,web/src/lib/atlas/data.ts#ENTRIES[mirror].impl,110,74,ok +field,web/src/lib/atlas/data.ts#ENTRIES[mirror].knobEn,web/src/lib/atlas/data.ts#ENTRIES[mirror].knob,62,25,ok +field,web/src/lib/atlas/data.ts#ENTRIES[mirror].nmEn,web/src/lib/atlas/data.ts#ENTRIES[mirror].nm,30,10,ok +field,web/src/lib/atlas/data.ts#ENTRIES[mirror].texEn,web/src/lib/atlas/data.ts#ENTRIES[mirror].tex,82,39,ok +field,web/src/lib/atlas/data.ts#ENTRIES[mitosis].knobEn,web/src/lib/atlas/data.ts#ENTRIES[mitosis].knob,87,32,ok +field,web/src/lib/atlas/data.ts#ENTRIES[mitosis].nmEn,web/src/lib/atlas/data.ts#ENTRIES[mitosis].nm,24,6,ok +field,web/src/lib/atlas/data.ts#ENTRIES[mitosis].texEn,web/src/lib/atlas/data.ts#ENTRIES[mitosis].tex,128,54,ok +field,web/src/lib/atlas/data.ts#ENTRIES[mphysarum].knobEn,web/src/lib/atlas/data.ts#ENTRIES[mphysarum].knob,57,17,ko-short +field,web/src/lib/atlas/data.ts#ENTRIES[mphysarum].nmEn,web/src/lib/atlas/data.ts#ENTRIES[mphysarum].nm,16,4,ok +field,web/src/lib/atlas/data.ts#ENTRIES[mphysarum].texEn,web/src/lib/atlas/data.ts#ENTRIES[mphysarum].tex,86,27,ok +field,web/src/lib/atlas/data.ts#ENTRIES[nca].knobEn,web/src/lib/atlas/data.ts#ENTRIES[nca].knob,85,34,ok +field,web/src/lib/atlas/data.ts#ENTRIES[nca].riskEn,web/src/lib/atlas/data.ts#ENTRIES[nca].risk,57,24,ok +field,web/src/lib/atlas/data.ts#ENTRIES[nca].texEn,web/src/lib/atlas/data.ts#ENTRIES[nca].tex,96,38,ok +field,web/src/lib/atlas/data.ts#ENTRIES[nematic].knobEn,web/src/lib/atlas/data.ts#ENTRIES[nematic].knob,89,29,ok +field,web/src/lib/atlas/data.ts#ENTRIES[nematic].nmEn,web/src/lib/atlas/data.ts#ENTRIES[nematic].nm,29,9,ok +field,web/src/lib/atlas/data.ts#ENTRIES[nematic].texEn,web/src/lib/atlas/data.ts#ENTRIES[nematic].tex,167,66,ok +field,web/src/lib/atlas/data.ts#ENTRIES[oslo].knobEn,web/src/lib/atlas/data.ts#ENTRIES[oslo].knob,115,45,ok +field,web/src/lib/atlas/data.ts#ENTRIES[oslo].nmEn,web/src/lib/atlas/data.ts#ENTRIES[oslo].nm,19,9,ok +field,web/src/lib/atlas/data.ts#ENTRIES[oslo].texEn,web/src/lib/atlas/data.ts#ENTRIES[oslo].tex,91,37,ok +field,web/src/lib/atlas/data.ts#ENTRIES[ouroboros].knobEn,web/src/lib/atlas/data.ts#ENTRIES[ouroboros].knob,58,19,ko-short +field,web/src/lib/atlas/data.ts#ENTRIES[ouroboros].nmEn,web/src/lib/atlas/data.ts#ENTRIES[ouroboros].nm,17,5,ok +field,web/src/lib/atlas/data.ts#ENTRIES[ouroboros].riskEn,web/src/lib/atlas/data.ts#ENTRIES[ouroboros].risk,186,74,ok +field,web/src/lib/atlas/data.ts#ENTRIES[ouroboros].texEn,web/src/lib/atlas/data.ts#ENTRIES[ouroboros].tex,89,33,ok +field,web/src/lib/atlas/data.ts#ENTRIES[physarum].knobEn,web/src/lib/atlas/data.ts#ENTRIES[physarum].knob,74,32,ok +field,web/src/lib/atlas/data.ts#ENTRIES[physarum].nmEn,web/src/lib/atlas/data.ts#ENTRIES[physarum].nm,13,10,ok +field,web/src/lib/atlas/data.ts#ENTRIES[physarum].texEn,web/src/lib/atlas/data.ts#ENTRIES[physarum].tex,65,25,ok +field,web/src/lib/atlas/data.ts#ENTRIES[plastic].knobEn,web/src/lib/atlas/data.ts#ENTRIES[plastic].knob,227,83,ok +field,web/src/lib/atlas/data.ts#ENTRIES[plastic].nmEn,web/src/lib/atlas/data.ts#ENTRIES[plastic].nm,20,15,ok +field,web/src/lib/atlas/data.ts#ENTRIES[plastic].riskEn,web/src/lib/atlas/data.ts#ENTRIES[plastic].risk,100,45,ok +field,web/src/lib/atlas/data.ts#ENTRIES[plastic].texEn,web/src/lib/atlas/data.ts#ENTRIES[plastic].tex,184,82,ok +field,web/src/lib/atlas/data.ts#ENTRIES[plastic].vertEn,web/src/lib/atlas/data.ts#ENTRIES[plastic].vert,57,20,ok +field,web/src/lib/atlas/data.ts#ENTRIES[potts].knobEn,web/src/lib/atlas/data.ts#ENTRIES[potts].knob,89,40,ok +field,web/src/lib/atlas/data.ts#ENTRIES[potts].nmEn,web/src/lib/atlas/data.ts#ENTRIES[potts].nm,16,11,ok +field,web/src/lib/atlas/data.ts#ENTRIES[potts].texEn,web/src/lib/atlas/data.ts#ENTRIES[potts].tex,127,52,ok +field,web/src/lib/atlas/data.ts#ENTRIES[resonance].knobEn,web/src/lib/atlas/data.ts#ENTRIES[resonance].knob,85,29,ok +field,web/src/lib/atlas/data.ts#ENTRIES[resonance].nmEn,web/src/lib/atlas/data.ts#ENTRIES[resonance].nm,29,19,ok +field,web/src/lib/atlas/data.ts#ENTRIES[resonance].texEn,web/src/lib/atlas/data.ts#ENTRIES[resonance].tex,165,59,ok +field,web/src/lib/atlas/data.ts#ENTRIES[rogue].knobEn,web/src/lib/atlas/data.ts#ENTRIES[rogue].knob,121,32,ko-short +field,web/src/lib/atlas/data.ts#ENTRIES[rogue].nmEn,web/src/lib/atlas/data.ts#ENTRIES[rogue].nm,29,13,ok +field,web/src/lib/atlas/data.ts#ENTRIES[rogue].riskEn,web/src/lib/atlas/data.ts#ENTRIES[rogue].risk,107,42,ok +field,web/src/lib/atlas/data.ts#ENTRIES[rogue].texEn,web/src/lib/atlas/data.ts#ENTRIES[rogue].tex,141,74,ok +field,web/src/lib/atlas/data.ts#ENTRIES[rps].knobEn,web/src/lib/atlas/data.ts#ENTRIES[rps].knob,118,36,ko-short +field,web/src/lib/atlas/data.ts#ENTRIES[rps].nmEn,web/src/lib/atlas/data.ts#ENTRIES[rps].nm,26,7,ok +field,web/src/lib/atlas/data.ts#ENTRIES[rps].texEn,web/src/lib/atlas/data.ts#ENTRIES[rps].tex,113,41,ok +field,web/src/lib/atlas/data.ts#ENTRIES[rule54].knobEn,web/src/lib/atlas/data.ts#ENTRIES[rule54].knob,100,33,ok +field,web/src/lib/atlas/data.ts#ENTRIES[rule54].nmEn,web/src/lib/atlas/data.ts#ENTRIES[rule54].nm,15,12,ok +field,web/src/lib/atlas/data.ts#ENTRIES[rule54].riskEn,web/src/lib/atlas/data.ts#ENTRIES[rule54].risk,111,43,ok +field,web/src/lib/atlas/data.ts#ENTRIES[rule54].texEn,web/src/lib/atlas/data.ts#ENTRIES[rule54].tex,144,54,ok +field,web/src/lib/atlas/data.ts#ENTRIES[sandpile].implEn,web/src/lib/atlas/data.ts#ENTRIES[sandpile].impl,84,42,ok +field,web/src/lib/atlas/data.ts#ENTRIES[sandpile].knobEn,web/src/lib/atlas/data.ts#ENTRIES[sandpile].knob,58,18,ko-short +field,web/src/lib/atlas/data.ts#ENTRIES[sandpile].nmEn,web/src/lib/atlas/data.ts#ENTRIES[sandpile].nm,17,9,ok +field,web/src/lib/atlas/data.ts#ENTRIES[sandpile].texEn,web/src/lib/atlas/data.ts#ENTRIES[sandpile].tex,101,33,ok +field,web/src/lib/atlas/data.ts#ENTRIES[schrod].knobEn,web/src/lib/atlas/data.ts#ENTRIES[schrod].knob,48,14,ko-short +field,web/src/lib/atlas/data.ts#ENTRIES[schrod].nmEn,web/src/lib/atlas/data.ts#ENTRIES[schrod].nm,17,13,ok +field,web/src/lib/atlas/data.ts#ENTRIES[schrod].texEn,web/src/lib/atlas/data.ts#ENTRIES[schrod].tex,75,25,ok +field,web/src/lib/atlas/data.ts#ENTRIES[sh].knobEn,web/src/lib/atlas/data.ts#ENTRIES[sh].knob,59,20,ok +field,web/src/lib/atlas/data.ts#ENTRIES[sh].riskEn,web/src/lib/atlas/data.ts#ENTRIES[sh].risk,50,18,ok +field,web/src/lib/atlas/data.ts#ENTRIES[sh].texEn,web/src/lib/atlas/data.ts#ENTRIES[sh].tex,54,21,ok +field,web/src/lib/atlas/data.ts#ENTRIES[sinegordon].knobEn,web/src/lib/atlas/data.ts#ENTRIES[sinegordon].knob,160,59,ok +field,web/src/lib/atlas/data.ts#ENTRIES[sinegordon].nmEn,web/src/lib/atlas/data.ts#ENTRIES[sinegordon].nm,26,12,ok +field,web/src/lib/atlas/data.ts#ENTRIES[sinegordon].texEn,web/src/lib/atlas/data.ts#ENTRIES[sinegordon].tex,149,58,ok +field,web/src/lib/atlas/data.ts#ENTRIES[smoke].knobEn,web/src/lib/atlas/data.ts#ENTRIES[smoke].knob,56,23,ok +field,web/src/lib/atlas/data.ts#ENTRIES[smoke].nmEn,web/src/lib/atlas/data.ts#ENTRIES[smoke].nm,15,11,ok +field,web/src/lib/atlas/data.ts#ENTRIES[smoke].riskEn,web/src/lib/atlas/data.ts#ENTRIES[smoke].risk,139,67,ok +field,web/src/lib/atlas/data.ts#ENTRIES[smoke].texEn,web/src/lib/atlas/data.ts#ENTRIES[smoke].tex,71,26,ok +field,web/src/lib/atlas/data.ts#ENTRIES[smoke].vertEn,web/src/lib/atlas/data.ts#ENTRIES[smoke].vert,49,20,ok +field,web/src/lib/atlas/data.ts#ENTRIES[snowline].knobEn,web/src/lib/atlas/data.ts#ENTRIES[snowline].knob,203,84,ok +field,web/src/lib/atlas/data.ts#ENTRIES[snowline].nmEn,web/src/lib/atlas/data.ts#ENTRIES[snowline].nm,30,7,ok +field,web/src/lib/atlas/data.ts#ENTRIES[snowline].riskEn,web/src/lib/atlas/data.ts#ENTRIES[snowline].risk,164,69,ok +field,web/src/lib/atlas/data.ts#ENTRIES[snowline].texEn,web/src/lib/atlas/data.ts#ENTRIES[snowline].tex,224,93,ok +field,web/src/lib/atlas/data.ts#ENTRIES[snowline].vertEn,web/src/lib/atlas/data.ts#ENTRIES[snowline].vert,91,27,ko-short +field,web/src/lib/atlas/data.ts#ENTRIES[sprott].knobEn,web/src/lib/atlas/data.ts#ENTRIES[sprott].knob,57,24,ok +field,web/src/lib/atlas/data.ts#ENTRIES[sprott].nmEn,web/src/lib/atlas/data.ts#ENTRIES[sprott].nm,29,9,ok +field,web/src/lib/atlas/data.ts#ENTRIES[sprott].texEn,web/src/lib/atlas/data.ts#ENTRIES[sprott].tex,87,54,ok +field,web/src/lib/atlas/data.ts#ENTRIES[superflow].knobEn,web/src/lib/atlas/data.ts#ENTRIES[superflow].knob,153,67,ok +field,web/src/lib/atlas/data.ts#ENTRIES[superflow].nmEn,web/src/lib/atlas/data.ts#ENTRIES[superflow].nm,24,9,ok +field,web/src/lib/atlas/data.ts#ENTRIES[superflow].riskEn,web/src/lib/atlas/data.ts#ENTRIES[superflow].risk,111,61,ok +field,web/src/lib/atlas/data.ts#ENTRIES[superflow].texEn,web/src/lib/atlas/data.ts#ENTRIES[superflow].tex,176,94,ok +field,web/src/lib/atlas/data.ts#ENTRIES[superflow].vertEn,web/src/lib/atlas/data.ts#ENTRIES[superflow].vert,65,29,ok +field,web/src/lib/atlas/data.ts#ENTRIES[tongues].knobEn,web/src/lib/atlas/data.ts#ENTRIES[tongues].knob,112,30,ko-short +field,web/src/lib/atlas/data.ts#ENTRIES[tongues].nmEn,web/src/lib/atlas/data.ts#ENTRIES[tongues].nm,30,19,ok +field,web/src/lib/atlas/data.ts#ENTRIES[tongues].texEn,web/src/lib/atlas/data.ts#ENTRIES[tongues].tex,166,81,ok +field,web/src/lib/atlas/data.ts#ENTRIES[traffic].knobEn,web/src/lib/atlas/data.ts#ENTRIES[traffic].knob,85,31,ko-short +field,web/src/lib/atlas/data.ts#ENTRIES[traffic].nmEn,web/src/lib/atlas/data.ts#ENTRIES[traffic].nm,20,7,ok +field,web/src/lib/atlas/data.ts#ENTRIES[traffic].riskEn,web/src/lib/atlas/data.ts#ENTRIES[traffic].risk,77,30,ok +field,web/src/lib/atlas/data.ts#ENTRIES[traffic].texEn,web/src/lib/atlas/data.ts#ENTRIES[traffic].tex,87,45,ok +field,web/src/lib/atlas/data.ts#ENTRIES[vicinal].knobEn,web/src/lib/atlas/data.ts#ENTRIES[vicinal].knob,223,105,ok +field,web/src/lib/atlas/data.ts#ENTRIES[vicinal].nmEn,web/src/lib/atlas/data.ts#ENTRIES[vicinal].nm,15,13,ok +field,web/src/lib/atlas/data.ts#ENTRIES[vicinal].riskEn,web/src/lib/atlas/data.ts#ENTRIES[vicinal].risk,167,68,ok +field,web/src/lib/atlas/data.ts#ENTRIES[vicinal].texEn,web/src/lib/atlas/data.ts#ENTRIES[vicinal].tex,214,108,ok +field,web/src/lib/atlas/data.ts#ENTRIES[vicinal].vertEn,web/src/lib/atlas/data.ts#ENTRIES[vicinal].vert,70,30,ok +field,web/src/lib/atlas/data.ts#ENTRIES[vlasov].knobEn,web/src/lib/atlas/data.ts#ENTRIES[vlasov].knob,165,71,ok +field,web/src/lib/atlas/data.ts#ENTRIES[vlasov].nmEn,web/src/lib/atlas/data.ts#ENTRIES[vlasov].nm,29,11,ok +field,web/src/lib/atlas/data.ts#ENTRIES[vlasov].riskEn,web/src/lib/atlas/data.ts#ENTRIES[vlasov].risk,110,45,ok +field,web/src/lib/atlas/data.ts#ENTRIES[vlasov].texEn,web/src/lib/atlas/data.ts#ENTRIES[vlasov].tex,169,83,ok +field,web/src/lib/atlas/data.ts#ENTRIES[vlasov].vertEn,web/src/lib/atlas/data.ts#ENTRIES[vlasov].vert,130,49,ok +field,web/src/lib/atlas/data.ts#ENTRIES[vortexglass].knobEn,web/src/lib/atlas/data.ts#ENTRIES[vortexglass].knob,128,45,ok +field,web/src/lib/atlas/data.ts#ENTRIES[vortexglass].nmEn,web/src/lib/atlas/data.ts#ENTRIES[vortexglass].nm,22,16,ok +field,web/src/lib/atlas/data.ts#ENTRIES[vortexglass].texEn,web/src/lib/atlas/data.ts#ENTRIES[vortexglass].tex,188,75,ok +field,web/src/lib/atlas/data.ts#ENTRIES[voter].knobEn,web/src/lib/atlas/data.ts#ENTRIES[voter].knob,102,41,ok +field,web/src/lib/atlas/data.ts#ENTRIES[voter].nmEn,web/src/lib/atlas/data.ts#ENTRIES[voter].nm,19,7,ok +field,web/src/lib/atlas/data.ts#ENTRIES[voter].riskEn,web/src/lib/atlas/data.ts#ENTRIES[voter].risk,87,36,ok +field,web/src/lib/atlas/data.ts#ENTRIES[voter].texEn,web/src/lib/atlas/data.ts#ENTRIES[voter].tex,125,49,ok +field,web/src/lib/atlas/data.ts#ENTRIES[xy].knobEn,web/src/lib/atlas/data.ts#ENTRIES[xy].knob,112,47,ok +field,web/src/lib/atlas/data.ts#ENTRIES[xy].nmEn,web/src/lib/atlas/data.ts#ENTRIES[xy].nm,17,8,ok +field,web/src/lib/atlas/data.ts#ENTRIES[xy].texEn,web/src/lib/atlas/data.ts#ENTRIES[xy].tex,98,41,ok +field,web/src/lib/atlas/data.ts#ENTRIES[zrp].knobEn,web/src/lib/atlas/data.ts#ENTRIES[zrp].knob,82,36,ok +field,web/src/lib/atlas/data.ts#ENTRIES[zrp].nmEn,web/src/lib/atlas/data.ts#ENTRIES[zrp].nm,18,5,ok +field,web/src/lib/atlas/data.ts#ENTRIES[zrp].texEn,web/src/lib/atlas/data.ts#ENTRIES[zrp].tex,119,55,ok +field,web/src/lib/atlas/data.ts#FAMILIES.advect.labelEn,web/src/lib/atlas/data.ts#FAMILIES.advect.label,14,4,ok +field,web/src/lib/atlas/data.ts#FAMILIES.critical.labelEn,web/src/lib/atlas/data.ts#FAMILIES.critical.label,18,8,ok +field,web/src/lib/atlas/data.ts#FAMILIES.feedback.labelEn,web/src/lib/atlas/data.ts#FAMILIES.feedback.label,17,5,ok +field,web/src/lib/atlas/data.ts#FAMILIES.ink.labelEn,web/src/lib/atlas/data.ts#FAMILIES.ink.label,20,6,ok +field,web/src/lib/atlas/data.ts#FAMILIES.invent.labelEn,web/src/lib/atlas/data.ts#FAMILIES.invent.label,19,4,ok +field,web/src/lib/atlas/data.ts#FAMILIES.life.labelEn,web/src/lib/atlas/data.ts#FAMILIES.life.label,14,4,ok +field,web/src/lib/atlas/data.ts#FAMILIES.optics.labelEn,web/src/lib/atlas/data.ts#FAMILIES.optics.label,19,4,ok +field,web/src/lib/atlas/data.ts#FAMILIES.pde.labelEn,web/src/lib/atlas/data.ts#FAMILIES.pde.label,9,5,ok +field,web/src/lib/atlas/data.ts#FAMILIES.waterfall.labelEn,web/src/lib/atlas/data.ts#FAMILIES.waterfall.label,17,4,ok +field,web/src/lib/atlas/data.ts#STATUSES.active.labelEn,web/src/lib/atlas/data.ts#STATUSES.active.label,9,3,ok +field,web/src/lib/atlas/data.ts#STATUSES.active.legendEn,web/src/lib/atlas/data.ts#STATUSES.active.legend,58,18,ok +field,web/src/lib/atlas/data.ts#STATUSES.hold.labelEn,web/src/lib/atlas/data.ts#STATUSES.hold.label,6,2,ok +field,web/src/lib/atlas/data.ts#STATUSES.hold.legendEn,web/src/lib/atlas/data.ts#STATUSES.hold.legend,36,10,ok +field,web/src/lib/atlas/data.ts#STATUSES.invented.labelEn,web/src/lib/atlas/data.ts#STATUSES.invented.label,8,2,ok +field,web/src/lib/atlas/data.ts#STATUSES.invented.legendEn,web/src/lib/atlas/data.ts#STATUSES.invented.legend,55,23,ok +field,web/src/lib/atlas/data.ts#STATUSES.retired.labelEn,web/src/lib/atlas/data.ts#STATUSES.retired.label,9,3,ok +field,web/src/lib/atlas/data.ts#STATUSES.retired.legendEn,web/src/lib/atlas/data.ts#STATUSES.retired.legend,82,31,ok +field,web/src/lib/atlas/data.ts#STATUSES.unexplored.labelEn,web/src/lib/atlas/data.ts#STATUSES.unexplored.label,9,3,ok +field,web/src/lib/atlas/data.ts#STATUSES.unexplored.legendEn,web/src/lib/atlas/data.ts#STATUSES.unexplored.legend,49,18,ok +field,web/src/lib/atlas/data.ts#STATUSES.verified.labelEn,web/src/lib/atlas/data.ts#STATUSES.verified.label,8,3,ok +field,web/src/lib/atlas/data.ts#STATUSES.verified.legendEn,web/src/lib/atlas/data.ts#STATUSES.verified.legend,31,11,ok +file,web/content/journal/a-strange-dream.en.mdx,web/content/journal/a-strange-dream.mdx,3324,1343,ok +file,web/content/journal/a-strange-feeling.en.mdx,web/content/journal/a-strange-feeling.mdx,3788,1686,ok +file,web/content/journal/all-jumbled-up.en.mdx,web/content/journal/all-jumbled-up.mdx,4264,1816,ok +file,web/content/journal/all-over-the-place.en.mdx,web/content/journal/all-over-the-place.mdx,11515,5042,ok +file,web/content/journal/community.en.mdx,web/content/journal/community.mdx,6207,2668,ok +file,web/content/journal/experiment-after-experiment.en.mdx,web/content/journal/experiment-after-experiment.mdx,4227,1902,ok +file,web/content/journal/faster-faster.en.mdx,web/content/journal/faster-faster.mdx,5676,2619,ok +file,web/content/journal/good-but.en.mdx,web/content/journal/good-but.mdx,2989,1258,ok +file,web/content/journal/headache.en.mdx,web/content/journal/headache.mdx,7978,3503,ok +file,web/content/journal/how-to-use-part-two.en.mdx,web/content/journal/how-to-use-part-two.mdx,9248,4315,ok +file,web/content/journal/how-to-use-patternflow.en.mdx,web/content/journal/how-to-use-patternflow.mdx,9577,4446,ok +file,web/content/journal/i-hope-it-works-out.en.mdx,web/content/journal/i-hope-it-works-out.mdx,4566,1954,ok +file,web/content/journal/ill-just-do-it.en.mdx,web/content/journal/ill-just-do-it.mdx,4124,1826,ok +file,web/content/journal/its-a-failure.en.mdx,web/content/journal/its-a-failure.mdx,5757,2463,ok +file,web/content/journal/keep-going-without-falling-apart.en.mdx,web/content/journal/keep-going-without-falling-apart.mdx,5649,2433,ok +file,web/content/journal/make-it-easy-to-build.en.mdx,web/content/journal/make-it-easy-to-build.mdx,4107,1973,ok +file,web/content/journal/maybe-this-will-work.en.mdx,web/content/journal/maybe-this-will-work.mdx,3533,1521,ok +file,web/content/journal/me-and-patternflow.en.mdx,web/content/journal/me-and-patternflow.mdx,23807,10181,ok +file,web/content/journal/nam-june-paik-me-patternflow.en.mdx,web/content/journal/nam-june-paik-me-patternflow.mdx,13791,6014,ok +file,web/content/journal/negative.en.mdx,web/content/journal/negative.mdx,5380,2361,ok +file,web/content/journal/new-possibilities.en.mdx,web/content/journal/new-possibilities.mdx,4261,1828,ok +file,web/content/journal/refocus.en.mdx,web/content/journal/refocus.mdx,8601,3693,ok +file,web/content/journal/right-before-launch.en.mdx,web/content/journal/right-before-launch.mdx,1870,819,ok +file,web/content/journal/shaking-it-off.en.mdx,web/content/journal/shaking-it-off.mdx,3025,1305,ok +file,web/content/journal/stopped.en.mdx,web/content/journal/stopped.mdx,5399,2249,ok +file,web/content/journal/the-30th-entry.en.mdx,web/content/journal/the-30th-entry.mdx,1268,511,ok +file,web/content/journal/the-messy-desk.en.mdx,web/content/journal/the-messy-desk.mdx,9441,4208,ok +file,web/content/journal/the-next-step.en.mdx,web/content/journal/the-next-step.mdx,5231,2335,ok +file,web/content/journal/this-is-the-worst.en.mdx,web/content/journal/this-is-the-worst.mdx,4230,1837,ok +file,web/content/journal/today-is-my-birthday.en.mdx,web/content/journal/today-is-my-birthday.mdx,10198,4270,ok +file,web/content/journal/v1-30-days.en.mdx,web/content/journal/v1-30-days.mdx,16286,7501,ok +file,web/content/journal/v3-and-beyond.en.mdx,web/content/journal/v3-and-beyond.mdx,3066,1366,ok +file,web/content/journal/what-is-patternflow.en.mdx,web/content/journal/what-is-patternflow.mdx,8676,3895,ok +file,web/content/journal/who-we-are.en.mdx,web/content/journal/who-we-are.mdx,5948,2580,ok +file,web/content/journal/why-i-write-these.en.mdx,web/content/journal/why-i-write-these.mdx,4857,2184,ok +file,web/content/journal/wins-and-losses-next-step.en.mdx,web/content/journal/wins-and-losses-next-step.mdx,3053,1423,ok +file,web/content/journal/worn-out.en.mdx,web/content/journal/worn-out.mdx,7774,3412,ok +route,web/src/app/guide/audio/page.tsx,web/src/app/guide/audio/ko/page.tsx,,,ok +route,web/src/app/guide/build/page.tsx,web/src/app/guide/build/ko/page.tsx,,,ok +route,web/src/app/guide/make/page.tsx,web/src/app/guide/make/ko/page.tsx,,,ok +route,web/src/app/guide/page.tsx,web/src/app/guide/ko/page.tsx,,,ok +route,web/src/app/guide/play/page.tsx,web/src/app/guide/play/ko/page.tsx,,,ok +route,web/src/app/journal/[slug]/en/page.tsx,web/src/app/journal/[slug]/page.tsx,,,ok +route,web/src/app/journal/en/page.tsx,web/src/app/journal/page.tsx,,,ok diff --git a/docs/investigations/2026-10-panel-geometry.md b/docs/investigations/2026-10-panel-geometry.md new file mode 100644 index 000000000..633325262 --- /dev/null +++ b/docs/investigations/2026-10-panel-geometry.md @@ -0,0 +1,254 @@ +# Where the panel size lives + +*PR 1 of [the step 5 plan](2026-10-cleanup-step5-plan.md), for +[rfc-cleanup.md](../rfc-cleanup.md) §2.7. Counted on 15 October 2026 at +`e3d3e83` on `dev`. A record of the tree. It proposes nothing.* + +The stock panel is 128×64, and the tree says so in more places than one macro. +Every count here comes from a command run at the repository root at `e3d3e83`, +and the command is quoted with its number or its table. A different number on a +later tree means the tree moved there. + +## The macro + +`PANEL_RES_W` and `PANEL_RES_H` are set in `config.h`. §2.7's 25, four and +seven count occurrences, not lines, so `core_display.h:18`, which names both, +counts twice. Each row below is one `` in these three commands: + +```sh +git grep -o -E 'PANEL_RES_(W|H)' -- | wc -l # occurrences +git grep -n -E 'PANEL_RES_(W|H)' -- | wc -l # lines +git grep -l -E 'PANEL_RES_(W|H)' -- | wc -l # files +``` + +| path | occurrences | lines | files | in comments | +| --- | --- | --- | --- | --- | +| `firmware/patternflow/src` | 25 | 21 | 6 | 8 | +| `firmware/patternflow/features` | 4 | 4 | 2 | 0 | +| `firmware/patternflow/abi` | 7 | 7 | 2 | 2 | +| `firmware/patternflow/config.h` | 11 | 11 | 1 | 1 | +| `firmware/patternflow/presets` | 142 | 128 | 35 | 6 | + +`git grep -o -E 'PANEL_RES_(W|H)' -- . ':!docs' | wc -l` gives 245, so 56 sit +outside those five rows. `firmware/patternflow/README.md` has 5 and +`_TEMPLATE.h` 3. The four probe modules under `firmware/toolchain/tests/modules/` +have 16 between them. The site has 32. Of those, 28 are in C++ the site writes +or asks a model to write: `web/src/lib/lab/cppPrompt.ts` 14, +`web/src/components/guide/desk/ai/headers.ts` 12 and +`web/scripts/module-build-smoke.ts` 2. The other four are comments and prompt +text in `patternWaveSaw.ts` (2), `hExport.ts` and `matrix.ts`. + +### `src/`, 25 + +| file and line | uses | what the line does with the size | +| --- | --- | --- | +| `core_canvas.h:38-39` | 2 | `PFCanvas::W` and `H`, the canvas every preset draws into | +| `core_display.h:18` | 2, comment | warns never to give the driver the canvas size | +| `core_mem.h:17` | 2, comment | an example per-pixel buffer | +| `core_module_loader.h:601-602` | 2 | `panel_w` and `panel_h` in the host API each module receives | +| `core_module_loader.h:1145` | 2 | refuses a module stamped with another size | +| `core_status_http.h:282, 284` | 2 | `"panel": "128x64"` in `/api/status` | +| `core_tables.h:11, 24-26` | 4, comment | how `rT` is scaled, and an example loop | +| `core_tables.h:52, 57` | 3 | `PFTables::idx`, and the length of the two tables | +| `core_tables.h:70-76` | 6 | the table fill: centre, scale and both loops | + +### `features/`, 4 + +| file and line | uses | what the line does with the size | +| --- | --- | --- | +| `clock/core_clock_http.h:116, 118` | 2 | `w` and `h` in `/api/clock`, for the console page's preview | +| `weather/preset_weather.h:492-493` | 2 | the compact layout's frame (the extended one is typed, below) | + +### `abi/`, 7 + +| file and line | uses | what the line does with the size | +| --- | --- | --- | +| `pf_abi.h:116` | 1, comment | why the loader refuses a mismatch | +| `pf_module.h:47` | 1, comment | the same, from the module's side | +| `pf_module.h:71-72` | 2 | defines both macros from `-DPF_PANEL_W` and `-DPF_PANEL_H` | +| `pf_module.h:175-176` | 2 | the module's own `PFCanvas::W` and `H` | +| `pf_module.h:281` | 1 | the module's `PFTables::idx` | + +The ABI carries the size as `panel_w` and `panel_h` in both structs: +`PFHostAPI` at `pf_abi.h:82-83` and `PFPatternModule` at `pf_abi.h:117-118`. +`pf_module.h:61-66` stops a module build that lacks either flag, `:306` stamps +the descriptor and `:312` has the module refuse a host of another size. The ABI +is at version 1, frozen and append only. + +## Where it is set + +`config.h:81-108` offers three geometries, and `PANEL_GEOMETRY` picks one. + +| geometry | module | chain | canvas | how it is built | +| --- | --- | --- | --- | --- | +| `PANEL_GEOM_128x64_SINGLE` | 128×64 | 1 | 128×64 | the default (`config.h:87`), `env:firmware` | +| `PANEL_GEOM_64x64_SINGLE` | 64×64 | 1 | 64×64 | an edit to `config.h` | +| `PANEL_GEOM_64x64_CHAIN2` | 64×64 | 2 | 128×64 | `env:firmware64x2` (`platformio.ini:121-126`) | + +No workflow and no bundle builds the last two. Each branch sets `PANEL_PHYS_W`, +`PANEL_PHYS_H`, `PANEL_CHAIN` and the two canvas macros, and two `#error`s +(`config.h:110-115`) hold the canvas to module width × chain by module height. +`core_display.h:20` is the one line that hands the driver a geometry, and the +only code outside `config.h` that reads `PANEL_PHYS_*` or `PANEL_CHAIN`. The +driver IC is a separate line, `PANEL_PROFILE` (`config.h:168`), and it does not +touch the size. + +## The canvas and what reads it + +`core_canvas.h:38-42` gives `PFCanvas::W`, `H` and a global RGB888 array of +W × H × 3 bytes, 24,576 at 128×64, sized when the firmware links. `setFrame` +(`core_canvas.h:137-163`) compares a pattern's grid with W and H: equal draws +straight through, swapped rotates, anything else is centred and letterboxed. +`present()` passes W and H to the compose hook (`:227`) and the buffer to the +driver's `blitRGB888`. + +Outside the file, `core_thumbs.h:44-45` aliases W and H as `PFThumbs::W` and +`H`. A thumbnail is W × H in RGB565 behind an 8-byte header, 16,392 bytes at +128×64, kept in PSRAM (`core_thumbs.h:139`). `core_module_loader.h:603` hands +the buffer to modules. `PFTables::init` (`core_tables.h:57-59`) allocates two +float tables of W × H through `PFMem`, 32,768 bytes each at 128×64. + +## The menus + +The menus never read the macro. They ask the driver, and the driver answers for +the rotation the menu set, so a portrait screen gets 64 × 128. +`git grep -n -E 'dma_display->(width|height)\(\)' -- firmware/patternflow | wc -l` +gives 31: 20 in `patternflow.ino`, 6 in `src/core_ui_text.h` and 5 in +features (`show/core_show_schedule.h` 2, `show/core_show_select.h` 2, +`weather/feature_weather.h` 1). + +## Sizes typed by hand + +| file and line | what is typed | +| --- | --- | +| `firmware/patternflow/src/core_ui_text.h:16` | `UI_PORTRAIT_TILE = 64`, the square portrait menus lay out in | +| `firmware/patternflow/features/weather/preset_weather.h:491` | `EXT_W = 64, EXT_H = 128`, the extended layout's frame | +| `firmware/patternflow/presets/preset_0712_2.h:60-61` | `VW = 64`, `VH = 128`, its portrait grid | +| `firmware/patternflow/console/clock.html:414` | 128 and 64 for the preview until `/api/clock` answers | +| `firmware/patternflow/console/weather.html:62` | "Compact (128×64)", a menu label | +| `firmware/toolchain/build_module.py:51-52` | `PANEL_W = 128`, `PANEL_H = 64` for every module build | +| `firmware/toolchain/make_pack.py:199` | `"panel": {"w": 128, "h": 64}` in a pack's manifest | +| `firmware/toolchain/console_serve.py:332, 1614` | the console preview server's `/api/status` and `/api/clock` | +| `firmware/toolchain/console_demo.py:516` | the demo console's status | +| `firmware/toolchain/tests/thumbs_test.cpp:36` | the host test's `PFCanvas::W = 128, H = 64` | + +## The presets + +`preset_0629_2.h` sizes four arrays by the macro, two globals at lines 53-54 and +two on the stack at 101 and 106. `preset_0713.h` sizes two on the stack through +its local `H` (186-187). `preset_calib.h:119` allocates `4 × W × 3` bytes and +`preset_0719.h:100` W × H floats. `preset_0712.h`, `preset_0712_2.h` and +`preset_0713.h` turn a portrait grid onto the panel by hand (`v = W - 1 - x`) +instead of calling `setFrame`, so they take the panel to be wider than it is +tall. + +| file | uses | lines | | file | uses | lines | +| --- | --- | --- | --- | --- | --- | --- | +| `preset_0510.h` | 2 | 2 | | `preset_0628.h` | 2 | 2 | +| `preset_0511.h` | 2 | 2 | | `preset_0629.h` | 2 | 2 | +| `preset_0512.h` | 4 | 4 | | `preset_0629_2.h` | 12 | 12 | +| `preset_0513.h` | 2 | 1 | | `preset_0701.h` | 9 | 8 | +| `preset_0514.h` | 2 | 2 | | `preset_0707.h` | 2 | 2 | +| `preset_0515.h` | 2 | 2 | | `preset_0710.h` | 10 | 8 | +| `preset_0515_3.h` | 2 | 2 | | `preset_0712.h` | 2 | 2 | +| `preset_0515_4.h` | 2 | 2 | | `preset_0712_2.h` | 2 | 2 | +| `preset_0518.h` | 2 | 1 | | `preset_0713.h` | 2 | 2 | +| `preset_0519_1.h` | 2 | 2 | | `preset_0715.h` | 4 | 4 | +| `preset_0520.h` | 2 | 2 | | `preset_0716.h` | 2 | 2 | +| `preset_0521.h` | 2 | 2 | | `preset_0718.h` | 4 | 4 | +| `preset_0522.h` | 2 | 2 | | `preset_0719.h` | 16 | 12 | +| `preset_0527.h` | 2 | 2 | | `preset_a_big_hit.h` | 2 | 2 | +| `preset_0528.h` | 2 | 2 | | `preset_calib.h` | 21 | 20 | +| `preset_0531.h` | 2 | 2 | | `preset_origin.h` | 4 | 4 | +| `preset_0601.h` | 2 | 2 | | `preset_wave_saw.h` | 8 | 5 | +| `preset_0602.h` | 2 | 1 | | | | | + +25 of the 35 use it exactly twice, as `w` and `h` or as the two pixel loops. +The six in comments are in `preset_0710.h` (84, 88) and `preset_wave_saw.h` (77). + +## Modules and the cache key + +A `.pfm` is built for one size. `build_module.py:258-259` passes its typed 128 +and 64 as `-DPF_PANEL_W` and `-DPF_PANEL_H`, and `:323-324` writes the same two +into the sidecar as `panel_w` and `panel_h`. The device never reads the +sidecar's pair. It checks the descriptor (`core_module_loader.h:1145`). + +The community's compiled-module cache is keyed `(source_sha, builder_rev)` +(`web/src/lib/community/server/schema.ts:725`). `builder_rev` is a digest of the +toolchain (`web/src/lib/firmware/builderRev.ts`), and `build_module.py` is one +of its inputs (`:45`). The size reaches the key only because it is typed in that +file. + +## HTTP and the specs + +Of the five wire contracts, only `docs/rest-api.md` states a size: `"panel": +"128x64"` in status (line 48, described at 77), the thumbnail as a 128×64 frame +of 16,392 bytes (274), the sidecar's `panel_w` and `panel_h` (284), and +`/api/clock`'s `w` and `h` (499). `osc-spec.md`, `mqtt-spec.md` and +`audio-ws-spec.md` have no 128 and no 64. `midi-spec.md` has 128 twice, both +queue lengths (96, 130). + +## The driver's memory + +The driver is vendored in `firmware/patternflow/src/hub75/`, and `.h` and `.cpp` +below are its `ESP32-HUB75-MatrixPanel-I2S-DMA` pair. It allocates its row +buffers once, in `begin()` (`.cpp:111-133`), one per row pair per buffer. A row +is module width × chain pixels, and there are module height / 2 of them +(`.h:750-751`, with `MATRIX_ROWS_IN_PARALLEL` 2 at `.h:67`). Each takes width × +colour depth × 2 bytes (`.h:165-168`, words of `uint16_t` at `.h:87`), from +`MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA` (`.h:195`). The PSRAM branch at `.h:186` +needs `SPIRAM_DMA_BUFFER`, and nothing in the tree defines it. The depth is the +default 8 (`.h:80`, at most 12 at `.h:83`), and nothing sets another. +`core_display.h:22` turns on double buffering. When a row does not fit, +`begin()` logs "Not enough memory" (`.cpp:123`) and returns false. + +The tree states no total, though `begin()` logs one at boot (`.cpp:134`). +Worked from that formula, as arithmetic, with two buffers at depth 8 and the DMA +descriptors (`.cpp:236`) left out: + +| geometry | row | rows | both buffers | +| --- | --- | --- | --- | +| 128×64, one module | 128 × 8 × 2 = 2,048 B | 32 | 131,072 B | +| 64×64, one module | 64 × 8 × 2 = 1,024 B | 32 | 65,536 B | +| 64×64, chain of 2 | 128 × 8 × 2 = 2,048 B | 32 | 131,072 B | + +The vendored blit adds limits of its own. Its fast passes need depth 8 and an +even width (`.cpp:979`), and an odd width or any other depth goes through the +older loop. Above depth 10 it refuses to write the frame (`.cpp:538`, `:958`). +The host test runs it at nine widths from 2 to 256, 127 among them +(`firmware/toolchain/tests/blit_test.cpp:73`). `src/hub75/VENDORED.md:147-188` +works out why the blit never tears for 12 buffers of 128 words at 16 MHz: 96 µs +a row pair against the blit's 109. The line it draws is buffers per row × words +per row / pixel clock (`VENDORED.md:172`), so a wider row moves it. + +## The site + +`web/src/lib/pattern/harness.ts:1-2` sets `PATTERN_MATRIX_WIDTH = 128` and +`PATTERN_MATRIX_HEIGHT = 64`, and both `PatternRuntime` (`:486`) and +`renderPatternStill` (`:652-653`) default to them. `matrix.ts:30` keeps its own +`DEFAULT_MATRIX` of 128×64. `// @matrix` takes 8 to 512 on a side +(`matrix.ts:46-47`), and `matrix.ts:127` says its fit must match `core_canvas.h`. + +`git grep -n -E 'new PatternRuntime\(' -- web/src | wc -l` gives 7. Four take +the pattern's frame (`sandbox/sandbox.ts:51`, `lib/lab/engine.ts:109`, +`GalleryPanel.tsx:106`, `desk/community/live.ts:46`), and `harness.ts:665` its +caller's. Two are fixed: `lib/guide/deviceSim.ts:167` through +`panelScreens.ts:42-43`, and `components/guide/Extras.tsx:185`, typed. The +sandbox canvas starts at 128×64 (`pattern-sandbox.template.html:18`) and resizes +to each frame (`sandbox.ts:104-107`). + +| what | where the size comes from | +| --- | --- | +| `DeviceSim` and the guide's stage | `PANEL_W`, `PANEL_H` in `lib/guide/panelScreens.ts:42-43`, read by `deviceSim.ts` and five stage files (`Device.tsx`, `Fx.tsx`, `look/ledPanel.ts`, `look/panelGlow.ts`, `world/hubMake.ts`) | +| the 3D panel's texture | typed, `components/3d/LedMatrixTexture.ts:20-21, 29` | +| the 3D shaders | `vec2(128.0, 64.0)`, typed 5 times (`git grep -n -F 'vec2(128.0, 64.0)' -- web/src`): `patterns/common.ts:55`, `HeroScene.tsx:63, 66`, `patternFlowOriginal.ts:23`, `patternWaveSaw.ts:45` | +| the guide's preview | typed, `Extras.tsx:185` and the `ImageData` at `:248` | +| the AI prompts | typed defaults, `lib/ai/gemini.ts:226, 387` | +| Pattern Lab's C++ export | `PATTERN_MATRIX_*`, `lib/lab/cppPrompt.ts:68` and `hExport.ts:165` | +| a pack's manifest | the `panel` field typed in `make_pack.py`, read as `lib/pattern/packs.ts:54` | + +`git grep -l -E '\b(display|d)\.(width|height)\b' -- 'web/src/lib/presets/pattern-*.ts' | wc -l` +gives 48, every JS preset, so each reads the size from the display +(`pattern-0716.ts` through `d`). `pattern-0719.ts` also types 128×64 for its +density map (33, 70, 132-134, 146) and its centre (40, 75). `pattern-0712-2.ts` +types its portrait grid as 64×128 (43, 53). No JS preset carries `// @matrix`. diff --git a/BUILD_GUIDE_v2.md b/docs/legacy/BUILD_GUIDE_v2.md similarity index 89% rename from BUILD_GUIDE_v2.md rename to docs/legacy/BUILD_GUIDE_v2.md index ae0b0dbed..ff70dcc59 100644 --- a/BUILD_GUIDE_v2.md +++ b/docs/legacy/BUILD_GUIDE_v2.md @@ -2,7 +2,7 @@ This guide walks you through building a Patternflow v2.0.0 from scratch. It assumes basic familiarity with through-hole soldering and 3D printing. -This is the current detailed path for a hand-soldered official PCB plus a PLA 3D printed enclosure. For the broader assembly map, see [docs/assembly/README.md](docs/assembly/README.md); the community cases it lists, laser-cut and printed, are for the v3 board. +This is the current detailed path for a hand-soldered official PCB plus a PLA 3D printed enclosure. For the broader assembly map, see [docs/assembly/README.md](../assembly/README.md); the community cases it lists, laser-cut and printed, are for the v3 board. > **No PCB? Try the breadboard build.** If you don't want to order or solder a custom PCB, there's a complete **PCB-free path** that wires the same parts together with a snapped-off breadboard power rail and jumper wires — no soldering iron required. See the **[Breadboard Build Guide](https://patternflow.work/build/breadboard)**. You can always move up to this PCB build later. @@ -10,13 +10,13 @@ This is the current detailed path for a hand-soldered official PCB plus a PLA 3D **Skill level:** Beginner-to-intermediate. If you've assembled a mechanical keyboard or built a basic Arduino project, you're ready — this is an all through-hole build. -> 🚀 **This is the legacy v2.x guide.** The current build is the **v3.9** board — see the main **[Build Guide](BUILD_GUIDE.md)**. Keep following this page only if you already have v2.x parts (v2 and v3 boards/cases are not interchangeable). +> 🚀 **This is the legacy v2.x guide.** The current build is the **v3.9** board — see the main **[Build Guide](../../BUILD_GUIDE.md)**. Keep following this page only if you already have v2.x parts (v2 and v3 boards/cases are not interchangeable). > > Photos in this guide were shot on an early build; where a photo looks slightly off, follow the written steps. > **What changed in v2.0.0.** PCB now includes a 10k pullup on GPIO0 (resolves the v1 cold-boot issue), silkscreen cleaned up to clearly mark R vs. C designators and the correct encoder solder side, firmware ships with a built-in custom-pattern template usable with any AI coding assistant, and the case files now include a 15mm encoder knob variant. Most of the case geometry is unchanged from v1; see Section 10 for the issues still open and the deliberate design notes worth knowing. -![All parts laid out before assembly](docs/build-guide/images/all_parts.jpg) +![All parts laid out before assembly](../build-guide/images/all_parts.jpg) --- @@ -63,14 +63,14 @@ This is the current detailed path for a hand-soldered official PCB plus a PLA 3D > > Patternflow drives the panel **directly from the ESP32-S3** — there is no sending/receiving card. So the panel's **driver IC** decides whether it works, and "HUB75E" on the listing is a connector, not a compatibility promise. A spec-matching panel with the wrong chips stays **completely dark** even when wiring, power and firmware are all correct. > -> - ✅ **Works:** classic shift-register drivers — **74HC595**, **FM6124**, **FM6126A**, **ICN2037**, **ICN2038S**, **DP5125D**, **DP3246**, **MBI5124**, **SM162xx**. Stock firmware drives most of these as-is; only if the panel comes up completely dark do you set `PANEL_PROFILE` in [`firmware/patternflow/config.h`](firmware/patternflow/config.h) to match your chip. +> - ✅ **Works:** classic shift-register drivers — **74HC595**, **FM6124**, **FM6126A**, **ICN2037**, **ICN2038S**, **DP5125D**, **DP3246**, **MBI5124**, **SM162xx**. Stock firmware drives most of these as-is; only if the panel comes up completely dark do you set `PANEL_PROFILE` in [`firmware/patternflow/config.h`](../../firmware/patternflow/config.h) to match your chip. > - ❌ **Does NOT work:** S-PWM / GCLK "video wall" drivers — **ICN2053**, **FM6353**, **FM6363C/FM6373C**, **DP3264/DP3265**, **ICND2055**, **MBI505x**. Usually sold on a **very high refresh rate (1920/3840Hz)** and/or that they **require a Nova/Linsn/Colorlight/Huidu receiving card**. > -> 📖 **Read [LED Panel Compatibility](docs/panel-compatibility.md) before ordering** — full chip lists, how to read the markings on a panel you already own, a symptom→cause table, and a copy-paste question to send the seller. The linked panel above is a known-good one. +> 📖 **Read [LED Panel Compatibility](../panel-compatibility.md) before ordering** — full chip lists, how to read the markings on a panel you already own, a symptom→cause table, and a copy-paste question to send the seller. The linked panel above is a known-good one. -PCB: order from your preferred fab using the **`patternflow_v2.1_gerber.zip`** Gerbers in [`hardware/pcb/gerber/`](hardware/pcb/gerber/) (or the KiCad source in `hardware/pcb/kicad/`). I used PCBway (sponsored). +PCB: order from your preferred fab using the **`patternflow_v2.1_gerber.zip`** Gerbers in [`hardware/pcb/gerber/`](../../hardware/pcb/gerber/) (or the KiCad source in `hardware/pcb/kicad/`). I used PCBway (sponsored). -> ⚠️ **For this (v2) guide, order v2.1 only.** This guide and its case files are built around the v2.1 board. The newer **v3 board** (`patternflow_v3.9_gerber.zip`; the v3 generation was verified in [#114](https://github.com/engmung/Patternflow/issues/114)) is a different size — it does **not** fit the v2 cases described here, and v2.1 boards don't fit the v3 cases. The v3 board has its own guide — the main [BUILD_GUIDE.md](BUILD_GUIDE.md). +> ⚠️ **For this (v2) guide, order v2.1 only.** This guide and its case files are built around the v2.1 board. The newer **v3 board** (`patternflow_v3.9_gerber.zip`; the v3 generation was verified in [#114](https://github.com/engmung/Patternflow/issues/114)) is a different size — it does **not** fit the v2 cases described here, and v2.1 boards don't fit the v3 cases. The v3 board has its own guide — the main [BUILD_GUIDE.md](../../BUILD_GUIDE.md). > ⚠️ **Don't order through the PCBWay shared project for this guide.** The [Patternflow PCBWay project](https://www.pcbway.com/project/shareproject/Patternflow_An_LED_synthesizer_776d796c.html) now carries the **v3.0 board**, which does not fit the v2 cases described here. For a v2 build, download `patternflow_v2.1_gerber.zip` and upload it to your fab yourself. @@ -103,7 +103,7 @@ PCB: order from your preferred fab using the **`patternflow_v2.1_gerber.zip`** G **Print the main body, dividers, and one knob file.** Print the knob STL that matches your encoder shaft length — `knobs_15mm.stl` or `knobs_20mm.stl`. Each file is one print job; each knob STL contains all four knobs. -> 💡 **Easier option — skip the gluing (and the nippers).** [`hardware/case/legacy_v2/encloser_v2.1.stl`](hardware/case/legacy_v2/encloser_v2.1.stl) packs the newer **snap-fit** design, cut for the v2.1 board, into one STL — body, LED-panel mount, and knobs all inside. Print & assembly verified. Open it in your slicer, split the objects, print the knobs in black and the rest in white — then skip Section 3 entirely (no bonding; assembly follows the [v3 guide's Section 6](BUILD_GUIDE.md#6-case-assembly) sequence) **and skip Section 5.1 too**: the design recesses the LED matrix's alignment bumps, so no trimming. Bonus: a snap-fit back panel and two wall-mount holes. The plates below remain the classic, fully photo-documented path. +> 💡 **Easier option — skip the gluing (and the nippers).** [`hardware/case/legacy_v2/encloser_v2.1.stl`](../../hardware/case/legacy_v2/encloser_v2.1.stl) packs the newer **snap-fit** design, cut for the v2.1 board, into one STL — body, LED-panel mount, and knobs all inside. Print & assembly verified. Open it in your slicer, split the objects, print the knobs in black and the rest in white — then skip Section 3 entirely (no bonding; assembly follows the [v3 guide's Section 6](../../BUILD_GUIDE.md#6-case-assembly) sequence) **and skip Section 5.1 too**: the design recesses the LED matrix's alignment bumps, so no trimming. Bonus: a snap-fit back panel and two wall-mount holes. The plates below remain the classic, fully photo-documented path. > **Note:** an `easyfit` main-body variant (alignment tabs along the bond seam) existed briefly but was retired over a fit defect ([Issue #154](https://github.com/engmung/Patternflow/issues/154)). Use the standard `plate_main.stl`; the old variant remains at the [v2.1.0 tag](https://github.com/engmung/Patternflow/tree/v2.1.0/hardware/case) if you're curious. @@ -139,7 +139,7 @@ Apply super glue along the seam between the upper and lower halves of the main b Same procedure — bond the upper and lower halves of the back panel together. - + ### 3.3 Bond the internal divider @@ -147,7 +147,7 @@ Inside the case, there's an internal divider that separates the LED matrix volum **Insert the divider from the front side (the power bank / lower side), sliding it up into position.** Apply super glue along the divider edges to bond it to the case interior. Do **not** insert it from the back. - + Allow ~5 minutes after every bond step for the glue to fully cure before handling. @@ -159,7 +159,7 @@ This is an all through-hole build. Work small-to-tall — shortest parts first, > **No SMD required.** The PCB has footprints for optional 0805 passives (encoder pull-ups, filter caps, ESP32 decoupling caps) and an R13 GPIO0 pull-up, but they are **not populated** — a unit runs fine without any of them. See the design note in Section 10 for the rationale and the on-module cold-boot fix if you ever need it. - + ### Through-Hole Pass @@ -178,7 +178,7 @@ Solder, in order (small/short to tall): Wrong side (front) vs. correct side (back): - + The photos in this guide show the earlier 20mm-shaft encoders. The 15mm EC11 encoders install the same way; only the matching knob STL changes. @@ -267,7 +267,7 @@ The LED matrix has two small alignment bumps on its back, diagonally opposite ea > This step only applies to the glued-plate body. If you printed `encloser_v2.1.stl` (the 💡 option in Section 2), skip it — that design has recesses for the bumps. - + ### 5.2 Screw the matrix into the case @@ -277,7 +277,7 @@ The LED matrix has two small alignment bumps on its back, diagonally opposite ea > The screws thread directly into the LED matrix's mounting holes. Don't over-tighten. - + --- @@ -285,13 +285,13 @@ The LED matrix has two small alignment bumps on its back, diagonally opposite ea At this point the matrix is in the case but the PCB is **not** yet installed. You'll do all the wire-side work first — connecting the USB power input, the matrix power, and the HUB75 ribbon to the PCB while it's still loose and easy to handle. Then in Section 7 the whole PCB-with-cables-attached assembly drops into the case. - + ### 6.1 Wire the USB power input to J2 Cut the sacrificial USB cable short — trim it to a length that routes from the power bank compartment, through the divider hole, to the PCB position without excessive slack. Strip the +5V (red) and GND (black) wires. - + Pass the cable through the divider hole. Connect to **J2** (with the PCB-side facing you): @@ -300,13 +300,13 @@ Pass the cable through the divider hole. Connect to **J2** (with the PCB-side fa Tighten with a small flathead. - + ### 6.2 Wire the LED matrix power to J3 The LED matrix ships with a power cable (red/black) that has two red (+) and two black (−) wires. Hold it up to estimate reach to **J3** before cutting — give it just enough length to route cleanly without strain. Then cut, strip, and bundle each pair (the two reds together, the two blacks together) before inserting. - + Polarity matches J2: **inner = +5V (red pair)**, **outer = GND (black pair)**. @@ -333,31 +333,31 @@ The PCB sits in the dedicated PCB slot, with the rotary encoders facing through 3. While tilting the PCB toward flat, guide the upper encoders into their slots simultaneously. 4. Push the PCB flat against the case interior. - + ### 7.2 Secure the encoders from the front From the **front** of the case, attach each rotary encoder's nut and tighten with a wrench or pliers. This both secures the encoder shafts to the front face and locks the PCB in place. - + ### 7.3 Attach the back cover Slide the back cover panel into place along the rear of the case. - + ### 7.4 Close the PCB compartment slider Slide the PCB compartment cover panel into its slot to close off the electronics section. - + ### 7.5 Attach the knobs Press-fit the four black knobs onto the encoder shafts. Use the knob set that matches your encoder shaft length: `knobs_15mm.stl` for 15mm encoders, or `knobs_20mm.stl` for 20mm encoders. - + At this point the **Patternflow body is mechanically complete.** The only thing left is the brain. @@ -385,7 +385,7 @@ No installation required. Works on any desktop with Chrome or Edge. 3. Scroll to the **Patterns** section and click **"Flash Patternflow"**. 4. Select the correct serial port when prompted and follow the on-screen steps. Wi-Fi can be provisioned right there too (Improv-Serial). - + 5. Once flashing is complete, disconnect the USB-C cable. @@ -393,7 +393,7 @@ No installation required. Works on any desktop with Chrome or Edge. ### 8.2 Arduino IDE (Manual / Custom Builds) -Use this method if you want to modify the firmware source, add custom patterns, or if the browser flasher doesn't work for your setup. Custom patterns are not added to the release flasher automatically; you compile and upload your own firmware build. See [firmware/CUSTOM_PATTERNS.md](firmware/CUSTOM_PATTERNS.md). +Use this method if you want to modify the firmware source, add custom patterns, or if the browser flasher doesn't work for your setup. Custom patterns are not added to the release flasher automatically; you compile and upload your own firmware build. See [firmware/CUSTOM_PATTERNS.md](../../firmware/CUSTOM_PATTERNS.md). #### Prerequisites @@ -416,7 +416,7 @@ Use this method if you want to modify the firmware source, add custom patterns, > cd firmware/patternflow && pio run -t upload > ``` > -> Full detail in [firmware/README.md](firmware/README.md#required-board-package). +> Full detail in [firmware/README.md](../../firmware/README.md#required-board-package). #### Board Settings @@ -462,7 +462,7 @@ OTA updates work via Arduino IDE's network port option once the device has been With flashing complete and the USB cable disconnected, plug the ESP32-S3 module into the female pin sockets on the Patternflow PCB. - + --- @@ -474,7 +474,7 @@ With flashing complete and the USB cable disconnected, plug the ESP32-S3 module 4. The LED matrix should illuminate with the default pattern within a second or two. 5. Turn the four knobs to confirm they all respond. - + > If your unit does not boot reliably, press RESET on the ESP32-S3 module once. On v2.0 boards this should not be necessary -- if it consistently is, [open an issue](https://github.com/engmung/Patternflow/issues) with your module source (AliExpress / Espressif / other) and a photo of the GPIO0 area on your PCB. diff --git a/docs/midi-spec.md b/docs/midi-spec.md index 68e0124d7..f31ca293b 100644 --- a/docs/midi-spec.md +++ b/docs/midi-spec.md @@ -29,15 +29,16 @@ The mapping below is transport-independent. What ships today: | **USB** (a USB-MIDI class device — the MIDI edition) | The DevKit's USB port, the one the panel is flashed over, plugged into the computer. No driver and no session: the panel enumerates as a MIDI device named `Patternflow` (a virtual serial port comes with it — that is the console's log and Improv, leave it be). MIDI edition only: the port has to be the S3's USB-OTG controller for a MIDI device to exist, and that is a build-time choice ([`EDITIONS.md`](EDITIONS.md)). **Data only** — the panel is powered from `J4` as always, never from that cable. Both transports share the map below and can be up at once. | Channel: **1** by default (`PF_MIDI_CHANNEL`; `0` listens on every channel -and sends on 1). MIDI Thru is off — the panel never echoes a host's messages -back to it. +and sends on 1). MIDI Thru is off: the panel never echoes a host's CC or note +back to it. A Program Change from the host comes back as the pattern change it +made, as every pattern change does. ## Host → device | Message | Meaning | |---|---| | **CC 20 · 21 · 22 · 23** | Knob 1–4, **absolute**. `0..127` maps to the device's `0..1000` bus (rounded; 127 = 1000). The value **holds** until a hand turns that knob, exactly like `POST /api/params` and MQTT `param/N`. | -| **CC 24 · 25 · 26 · 27** | Knob 1–4, **relative**. `64` = no motion, `65` = +1 detent, `63` = −1, and so on (two's-complement offset). Merged with physical motion at 1× per step — the same lane as OSC's `/patternflow/knob/N/delta`. Does not release an absolute hold. | +| **CC 24 · 25 · 26 · 27** | Knob 1–4, **relative**. `64` = no motion, `65` = +1 detent, `63` = −1, and so on (binary offset). Merged with physical motion at 1× per step — the same lane as OSC's `/patternflow/knob/N/delta`. Does not release an absolute hold. | | **Note 60 · 61 · 62 · 63** (C4–D♯4) | Button 1–4. Note-on = press (edge), the button reads as held for as long as the note is held; note-off (or velocity 0) releases. | | **Program Change `n`** | Select pattern index `n` (0-based, the order on `/patterns`). Treated as a person's choice: it is remembered across reboots, like a knob pick. Ignored while a show claims the panel. | @@ -48,7 +49,7 @@ the channel is `0`. The device's rules, not MIDI's: **a hand on an encoder beats everything.** An absolute CC pins a knob until that encoder moves; relative CCs and notes are -just more input, and the audio lanes yield to both. +just more input, and the audio lanes yield to the hand and to an absolute CC. ## Device → host @@ -116,7 +117,7 @@ most. | `POST /api/midi?outMode=abs\|rel` | knobs out as a virtual position (default) or as relative steps. Persisted. | | `POST /api/midi?outMul=N` | outbound sensitivity the other way: steps per detent, `1..8`. Setting one of `outMul`/`outDiv` above 1 resets the other to 1; the console page shows both as one scale from ×8 through 1:1 to 1/16. | | `...&knob=1..4` | Apply `outMul`/`outDiv` to one knob; without it, to all four. Each knob keeps its own ratio; `GET` reports `outMul` and `outDiv` as four-element arrays. | -| `POST /api/midi?outDiv=N` | outbound sensitivity: detents per outbound step, `1..16`, persisted on the panel. The encoders have 20 detents a turn, so `1` is 20 steps per turn and `4` is 5. Default `1` (`PF_MIDI_OUT_DIVISOR`). | +| `POST /api/midi?outDiv=N` | outbound sensitivity: detents per outbound step, `1..16`, persisted on the panel. The encoders have 24 detents a turn, so `1` is 24 steps per turn and `4` is 6. Default `1` (`PF_MIDI_OUT_DIVISOR`). | | `POST /api/midi?host=` | A host to **invite**: the panel sends the session invitation itself on boot and again every 20 s while no session is up, so a panel that reboots comes back into the DAW without anyone reopening rtpMIDI or Audio MIDI Setup. The host must accept invitations (rtpMIDI: *Who may connect to me: Anyone*; macOS: the same setting in the Network MIDI window). Empty string clears it. Persisted. | ## Why these numbers @@ -135,6 +136,10 @@ most. - **1.2** (unreleased) — additive receive diagnostics, transport service independent of display frames, and suppression of outbound CCs generated from absolute-bus compatibility clicks. No new transport or CC mapping. + Where this file said two things, the section that defines the message now + stands: CC 24–27 are binary offset, as their examples always were; a host's + Program Change comes back as the pattern change it made; the audio lanes + yield to a hand and an absolute CC. - **1.1** (2026-09-06) — outbound CC 24–27 paced: one message per knob per 50 ms at most (`PF_MIDI_OUT_MIN_INTERVAL_MS`), the final value always sent; `rel` carries the steps summed since the previous message. On the Audio diff --git a/docs/midi-vectors/README.md b/docs/midi-vectors/README.md new file mode 100644 index 000000000..271f5751c --- /dev/null +++ b/docs/midi-vectors/README.md @@ -0,0 +1,15 @@ +# MIDI vectors + +Each file here is one sentence of [midi-spec.md](../midi-spec.md) and the messages that show a panel doing it, written as [contract-vectors.md](../contract-vectors.md) sets out. `npm run check:midi-vectors` in `web/` replays every one against a fresh [virtual panel](../../tools/virtual-panel/README.md) built with the `midi` feature, and prints what the vector expects beside what the panel did. CI runs it on every pull request with the other drift checks. When the spec rewords a sentence a vector quotes, that vector fails until someone brings it along. + +A file's name says which table of the spec it reads. `in-` is a row of *Host → device*, or the channel rule under it, and shows what the panel does with that message. `out-` is a row of *Device → host*, something the panel sends when a hand moves it. `/api/midi` is REST, so its vectors are in [rest-api-vectors](../rest-api-vectors/README.md) and start with `midi-`. + +A message is its bytes, the status and then its data. `[176, 24, 65]` is CC 24 at 65 on channel 1, `[144, 60, 127]` is note 60 on and `[192, 3]` is Program Change 3. Each one the vector sends goes in an RTP-MIDI packet of its own. What the panel sends is read back into the same form, one message to an entry, with running status filled in. + +The suite brings the session. `web/scripts/drift/_rtpmidi.ts` is an initiator shaped like [`rtpmidi_probe.py`](../../tools/rtpmidi-probe/rtpmidi_probe.py). Before the first step it invites the panel on its control port and then on the data port after it, and syncs clocks once. After the last step it says `BY`. + +The panel applies what has arrived at the end of a frame, and the pattern is given it on the next. So a vector gives a message 20 ms, a frame at 60 fps and a little more, before it looks. A hand does not take back a channel a CC wrote less than 250 ms before (`ABSOLUTE_RELEASE_GRACE_MS` in `core_bus.h`). That rule is the firmware's, not the spec's, and the REST vectors wait it out the same way: `in-cc-hold.json` holds the channel a second before the turn. + +One vector keeps the spec where the firmware does something else. `in-ignored.json` sends CC 120, 121 and 123 while a note is held, and the firmware lets the note go on any of them (`core_midi.h`). + +Some of the spec has no vectors. MIDI over USB needs the S3's OTG controller, and a Node process has no USB device side. The receive map both transports share is checked natively in CI by `midi_test.cpp`. A vector opens one session, so the limit of two participants is not replayed, and the virtual panel does not advertise itself over Bonjour. It does not reboot or run shows, so a Program Change remembered across a reboot and one a show ignores are not replayed either. Channel `0` is a build setting. CC 24 to 27 are sent only at 63, 64 and 65, where the spec's two readings of a relative value agree. diff --git a/docs/midi-vectors/in-cc-absolute.json b/docs/midi-vectors/in-cc-absolute.json new file mode 100644 index 000000000..1cd64c29f --- /dev/null +++ b/docs/midi-vectors/in-cc-absolute.json @@ -0,0 +1,19 @@ +{ + "contract": "docs/midi-spec.md#host--device", + "says": "`0..127` maps to the device's `0..1000` bus (rounded; 127 = 1000).", + "features": ["midi"], + "steps": [ + { "send": { "midi": [176, 20, 0] } }, + { "send": { "wait": 20 }, "expect": { "status": { "params": [0, "", "", ""], "paramActive": [true, false, false, false] } } }, + { "send": { "midi": [176, 20, 1] } }, + { "send": { "wait": 20 }, "expect": { "status": { "params": [8, "", "", ""] } } }, + { "send": { "midi": [176, 20, 64] } }, + { "send": { "wait": 20 }, "expect": { "status": { "params": [504, "", "", ""] } } }, + { "send": { "midi": [176, 20, 127] } }, + { "send": { "wait": 20 }, "expect": { "status": { "params": [1000, "", "", ""] } } }, + { "send": { "midi": [176, 21, 127] } }, + { "send": { "midi": [176, 22, 64] } }, + { "send": { "midi": [176, 23, 1] } }, + { "send": { "wait": 20 }, "expect": { "status": { "params": [1000, 1000, 504, 8], "paramActive": [true, true, true, true] } } } + ] +} diff --git a/docs/midi-vectors/in-cc-hold.json b/docs/midi-vectors/in-cc-hold.json new file mode 100644 index 000000000..56eede142 --- /dev/null +++ b/docs/midi-vectors/in-cc-hold.json @@ -0,0 +1,13 @@ +{ + "contract": "docs/midi-spec.md#host--device", + "says": "The value **holds** until a hand turns that knob, exactly like `POST /api/params` and MQTT `param/N`.", + "features": ["midi"], + "steps": [ + { "send": { "midi": [176, 21, 100] } }, + { "send": { "wait": 1000 }, "expect": { "status": { "params": ["", 787, "", ""], "paramActive": [false, true, false, false] } } }, + { "send": { "hand": { "knob": 1, "turn": 1 } } }, + { "send": { "wait": 20 }, "expect": { "status": { "paramActive": [false, true, false, false] } } }, + { "send": { "hand": { "knob": 2, "turn": -1 } } }, + { "send": { "wait": 20 }, "expect": { "status": { "paramActive": [false, false, false, false] } } } + ] +} diff --git a/docs/midi-vectors/in-cc-keeps-hold.json b/docs/midi-vectors/in-cc-keeps-hold.json new file mode 100644 index 000000000..48caf6ab3 --- /dev/null +++ b/docs/midi-vectors/in-cc-keeps-hold.json @@ -0,0 +1,11 @@ +{ + "contract": "docs/midi-spec.md#host--device", + "says": "Does not release an absolute hold.", + "features": ["midi"], + "steps": [ + { "send": { "midi": [176, 20, 127] } }, + { "send": { "wait": 1000 }, "expect": { "status": { "paramActive": [true, false, false, false] } } }, + { "send": { "midi": [176, 24, 63] } }, + { "send": { "wait": 20 }, "expect": { "status": { "params": [1000, "", "", ""], "paramActive": [true, false, false, false] } } } + ] +} diff --git a/docs/midi-vectors/in-cc-merged.json b/docs/midi-vectors/in-cc-merged.json new file mode 100644 index 000000000..15aec9fc2 --- /dev/null +++ b/docs/midi-vectors/in-cc-merged.json @@ -0,0 +1,12 @@ +{ + "contract": "docs/midi-spec.md#host--device", + "says": "Merged with physical motion at 1× per step — the same lane as OSC's `/patternflow/knob/N/delta`.", + "features": ["midi"], + "steps": [ + { "send": { "hand": { "knob": 3, "turn": 2 } } }, + { "send": { "midi": [176, 26, 65] } }, + { "send": { "midi": [176, 26, 65] } }, + { "send": { "midi": [176, 26, 65] } }, + { "send": { "wait": 20 }, "expect": { "input": { "given": { "deltas": [0, 0, 5, 0] } } } } + ] +} diff --git a/docs/midi-vectors/in-cc-relative.json b/docs/midi-vectors/in-cc-relative.json new file mode 100644 index 000000000..b13a0feaa --- /dev/null +++ b/docs/midi-vectors/in-cc-relative.json @@ -0,0 +1,15 @@ +{ + "contract": "docs/midi-spec.md#host--device", + "says": "`64` = no motion, `65` = +1 detent, `63` = −1", + "features": ["midi"], + "steps": [ + { "send": { "midi": [176, 24, 65] } }, + { "send": { "midi": [176, 25, 63] } }, + { "send": { "midi": [176, 26, 64] } }, + { "send": { "wait": 20 }, "expect": { "input": { "given": { "deltas": [1, -1, 0, 0] } } } }, + { "send": { "midi": [176, 27, 63] } }, + { "send": { "midi": [176, 27, 63] } }, + { "send": { "midi": [176, 24, 65] } }, + { "send": { "wait": 20 }, "expect": { "input": { "given": { "deltas": [2, -1, 0, -2] } } } } + ] +} diff --git a/docs/midi-vectors/in-channel.json b/docs/midi-vectors/in-channel.json new file mode 100644 index 000000000..707c0a6ac --- /dev/null +++ b/docs/midi-vectors/in-channel.json @@ -0,0 +1,14 @@ +{ + "contract": "docs/midi-spec.md#host--device", + "says": "Messages on another channel are ignored unless the channel is `0`.", + "features": ["midi"], + "steps": [ + { "send": { "midi": [177, 20, 127] } }, + { "send": { "midi": [178, 25, 65] } }, + { "send": { "midi": [159, 60, 127] } }, + { "send": { "midi": [193, 3] } }, + { "send": { "wait": 20 }, "expect": { "status": { "paramActive": [false, false, false, false] }, "input": { "given": { "deltas": [0, 0, 0, 0], "presses": [0, 0, 0, 0] }, "pattern": { "index": 0 } } } }, + { "send": { "midi": [176, 25, 65] } }, + { "send": { "wait": 20 }, "expect": { "input": { "given": { "deltas": [0, 1, 0, 0] } } } } + ] +} diff --git a/docs/midi-vectors/in-ignored.json b/docs/midi-vectors/in-ignored.json new file mode 100644 index 000000000..b3532ce6a --- /dev/null +++ b/docs/midi-vectors/in-ignored.json @@ -0,0 +1,20 @@ +{ + "contract": "docs/midi-spec.md#host--device", + "says": "Everything else is ignored.", + "features": ["midi"], + "steps": [ + { "send": { "midi": [144, 60, 127] } }, + { "send": { "wait": 20 }, "expect": { "input": { "given": { "held": [true, false, false, false] } } } }, + { "send": { "midi": [176, 123, 0] } }, + { "send": { "midi": [176, 120, 0] } }, + { "send": { "midi": [176, 121, 0] } }, + { "send": { "midi": [176, 19, 127] } }, + { "send": { "midi": [176, 28, 65] } }, + { "send": { "midi": [144, 64, 127] } }, + { "send": { "midi": [144, 59, 127] } }, + { "send": { "midi": [224, 0, 127] } }, + { "send": { "midi": [208, 100] } }, + { "send": { "midi": [160, 61, 100] } }, + { "send": { "wait": 20 }, "expect": { "status": { "paramActive": [false, false, false, false] }, "input": { "given": { "deltas": [0, 0, 0, 0], "presses": [1, 0, 0, 0], "held": [true, false, false, false] }, "pattern": { "index": 0 } } } } + ] +} diff --git a/docs/midi-vectors/in-notes.json b/docs/midi-vectors/in-notes.json new file mode 100644 index 000000000..74173ff96 --- /dev/null +++ b/docs/midi-vectors/in-notes.json @@ -0,0 +1,18 @@ +{ + "contract": "docs/midi-spec.md#host--device", + "says": "Note-on = press (edge), the button reads as held for as long as the note is held; note-off (or velocity 0) releases.", + "features": ["midi"], + "steps": [ + { "send": { "midi": [144, 60, 127] } }, + { "send": { "wait": 20 }, "expect": { "input": { "given": { "presses": [1, 0, 0, 0], "held": [true, false, false, false] } } } }, + { "send": { "wait": 1000 }, "expect": { "input": { "given": { "presses": [1, 0, 0, 0], "held": [true, false, false, false] } } } }, + { "send": { "midi": [144, 61, 127] } }, + { "send": { "midi": [144, 62, 1] } }, + { "send": { "midi": [144, 63, 64] } }, + { "send": { "wait": 20 }, "expect": { "input": { "given": { "presses": [1, 1, 1, 1], "held": [true, true, true, true] } } } }, + { "send": { "midi": [128, 60, 0] } }, + { "send": { "midi": [144, 61, 0] } }, + { "send": { "midi": [128, 62, 64] } }, + { "send": { "wait": 20 }, "expect": { "input": { "given": { "presses": [1, 1, 1, 1], "held": [false, false, false, true] } } } } + ] +} diff --git a/docs/midi-vectors/in-program-change.json b/docs/midi-vectors/in-program-change.json new file mode 100644 index 000000000..71b403f6a --- /dev/null +++ b/docs/midi-vectors/in-program-change.json @@ -0,0 +1,13 @@ +{ + "contract": "docs/midi-spec.md#host--device", + "says": "Select pattern index `n` (0-based, the order on `/patterns`).", + "features": ["midi"], + "steps": [ + { "send": { "midi": [192, 3] } }, + { "send": { "wait": 20 } }, + { "send": { "http": "GET /api/patterns" }, "expect": { "reply": { "body": { "active": 3 } } } }, + { "send": { "midi": [192, 0] } }, + { "send": { "wait": 20 } }, + { "send": { "http": "GET /api/patterns" }, "expect": { "reply": { "body": { "active": 0 } } } } + ] +} diff --git a/docs/midi-vectors/out-abs-same.json b/docs/midi-vectors/out-abs-same.json new file mode 100644 index 000000000..8b49e6bd3 --- /dev/null +++ b/docs/midi-vectors/out-abs-same.json @@ -0,0 +1,12 @@ +{ + "contract": "docs/midi-spec.md#device--host", + "says": "in `abs` only a position that differs from the last one sent goes out", + "features": ["midi"], + "steps": [ + { "send": { "hand": { "knob": 2, "turn": 1 } }, "expect": { "out": [{ "midi": [176, 25, 65], "within": 20 }] } }, + { "send": { "hand": { "knob": 2, "turn": 1 } }, "expect": { "out": [{ "midi": "", "within": 20 }] } }, + { "send": { "hand": { "knob": 2, "turn": -1 } }, "expect": { "out": [{ "midi": "", "within": 20 }] } }, + { "send": { "wait": 100 }, "expect": { "out": [{ "midi": "" }] } }, + { "send": { "hand": { "knob": 2, "turn": 1 } }, "expect": { "out": [{ "midi": [176, 25, 66], "within": 20 }] } } + ] +} diff --git a/docs/midi-vectors/out-buttons.json b/docs/midi-vectors/out-buttons.json new file mode 100644 index 000000000..937eca170 --- /dev/null +++ b/docs/midi-vectors/out-buttons.json @@ -0,0 +1,13 @@ +{ + "contract": "docs/midi-spec.md#device--host", + "says": "Encoder button 1–4 pressed", + "features": ["midi"], + "steps": [ + { "send": { "hand": { "knob": 1, "down": true } }, "expect": { "out": [{ "midi": [144, 60, 127], "within": 20 }] } }, + { "send": { "wait": 500 }, "expect": { "out": [{ "midi": "" }] } }, + { "send": { "hand": { "knob": 1, "down": false } }, "expect": { "out": [{ "midi": [128, 60, ""], "within": 20 }] } }, + { "send": { "hand": { "knob": 4, "down": true } }, "expect": { "out": [{ "midi": [144, 63, 127], "within": 20 }] } }, + { "send": { "wait": 100 } }, + { "send": { "hand": { "knob": 4, "down": false } }, "expect": { "out": [{ "midi": [128, 63, ""], "within": 20 }, { "midi": "" }] } } + ] +} diff --git a/docs/midi-vectors/out-cc-abs.json b/docs/midi-vectors/out-cc-abs.json new file mode 100644 index 000000000..6e43938b2 --- /dev/null +++ b/docs/midi-vectors/out-cc-abs.json @@ -0,0 +1,15 @@ +{ + "contract": "docs/midi-spec.md#device--host", + "says": "the value is a virtual position `0..127` the panel keeps per knob — starts at 64, moves by that knob's `outMul` steps per `outDiv` detents, clamps at the ends", + "features": ["midi"], + "steps": [ + { "send": { "hand": { "knob": 1, "turn": 3 } }, "expect": { "out": [{ "midi": [176, 24, 67], "within": 20 }] } }, + { "send": { "hand": { "knob": 2, "turn": -2 } }, "expect": { "out": [{ "midi": [176, 25, 62], "within": 20 }] } }, + { "send": { "wait": 100 } }, + { "send": { "hand": { "knob": 1, "turn": 62 } }, "expect": { "out": [{ "midi": [176, 24, 127], "within": 20 }] } }, + { "send": { "hand": { "knob": 2, "turn": -63 } }, "expect": { "out": [{ "midi": [176, 25, 0], "within": 20 }] } }, + { "send": { "wait": 100 } }, + { "send": { "hand": { "knob": 1, "turn": -1 } }, "expect": { "out": [{ "midi": [176, 24, 126], "within": 20 }] } }, + { "send": { "hand": { "knob": 2, "turn": 1 } }, "expect": { "out": [{ "midi": [176, 25, 1], "within": 20 }] } } + ] +} diff --git a/docs/midi-vectors/out-cc-div.json b/docs/midi-vectors/out-cc-div.json new file mode 100644 index 000000000..f315fa360 --- /dev/null +++ b/docs/midi-vectors/out-cc-div.json @@ -0,0 +1,13 @@ +{ + "contract": "docs/midi-spec.md#settings", + "says": "The encoders have 24 detents a turn, so `1` is 24 steps per turn and `4` is 6.", + "features": ["midi"], + "steps": [ + { "send": { "http": "POST /api/midi?outDiv=4&knob=1" } }, + { "send": { "hand": { "knob": 1, "turn": 24 } }, "expect": { "out": [{ "midi": [176, 24, 70], "within": 20 }] } }, + { "send": { "hand": { "knob": 2, "turn": 24 } }, "expect": { "out": [{ "midi": [176, 25, 88], "within": 20 }] } }, + { "send": { "wait": 100 } }, + { "send": { "hand": { "knob": 1, "turn": 3 } }, "expect": { "out": [{ "midi": "", "within": 100 }] } }, + { "send": { "hand": { "knob": 1, "turn": 1 } }, "expect": { "out": [{ "midi": [176, 24, 71], "within": 20 }] } } + ] +} diff --git a/docs/midi-vectors/out-cc-mul.json b/docs/midi-vectors/out-cc-mul.json new file mode 100644 index 000000000..b38e25389 --- /dev/null +++ b/docs/midi-vectors/out-cc-mul.json @@ -0,0 +1,12 @@ +{ + "contract": "docs/midi-spec.md#settings", + "says": "outbound sensitivity the other way: steps per detent, `1..8`.", + "features": ["midi"], + "steps": [ + { "send": { "http": "POST /api/midi?outMul=8&knob=4" } }, + { "send": { "hand": { "knob": 4, "turn": 2 } }, "expect": { "out": [{ "midi": [176, 27, 80], "within": 20 }] } }, + { "send": { "hand": { "knob": 3, "turn": 2 } }, "expect": { "out": [{ "midi": [176, 26, 66], "within": 20 }] } }, + { "send": { "wait": 100 } }, + { "send": { "hand": { "knob": 4, "turn": -1 } }, "expect": { "out": [{ "midi": [176, 27, 72], "within": 20 }] } } + ] +} diff --git a/docs/midi-vectors/out-cc-rel.json b/docs/midi-vectors/out-cc-rel.json new file mode 100644 index 000000000..4112487d1 --- /dev/null +++ b/docs/midi-vectors/out-cc-rel.json @@ -0,0 +1,12 @@ +{ + "contract": "docs/midi-spec.md#device--host", + "says": "`64 ± steps` since the previous message (binary offset)", + "features": ["midi"], + "steps": [ + { "send": { "http": "POST /api/midi?outMode=rel" } }, + { "send": { "hand": { "knob": 3, "turn": -2 } }, "expect": { "out": [{ "midi": [176, 26, 62], "within": 20 }] } }, + { "send": { "wait": 100 } }, + { "send": { "hand": { "knob": 3, "turn": 5 } }, "expect": { "out": [{ "midi": [176, 26, 69], "within": 20 }] } }, + { "send": { "hand": { "knob": 1, "turn": 1 } }, "expect": { "out": [{ "midi": [176, 24, 65], "within": 20 }] } } + ] +} diff --git a/docs/midi-vectors/out-hand-only.json b/docs/midi-vectors/out-hand-only.json new file mode 100644 index 000000000..b4aeed980 --- /dev/null +++ b/docs/midi-vectors/out-hand-only.json @@ -0,0 +1,10 @@ +{ + "contract": "docs/midi-spec.md#device--host", + "says": "Encoder 1–4 turned by a hand.", + "features": ["midi"], + "steps": [ + { "send": { "http": "POST /api/params?p1=900" } }, + { "send": { "wait": 100 }, "expect": { "out": [{ "midi": "" }], "status": { "params": [900, "", "", ""] } } }, + { "send": { "hand": { "knob": 1, "turn": 1 } }, "expect": { "out": [{ "midi": [176, 24, 65], "within": 20 }] } } + ] +} diff --git a/docs/midi-vectors/out-no-echo.json b/docs/midi-vectors/out-no-echo.json new file mode 100644 index 000000000..dfa213a23 --- /dev/null +++ b/docs/midi-vectors/out-no-echo.json @@ -0,0 +1,13 @@ +{ + "contract": "docs/midi-spec.md#device--host", + "says": "a CC or note the host itself sent is never reflected back", + "features": ["midi"], + "steps": [ + { "send": { "midi": [176, 20, 100] } }, + { "send": { "midi": [176, 25, 65] } }, + { "send": { "midi": [144, 62, 127] } }, + { "send": { "wait": 100 }, "expect": { "out": [{ "midi": "" }], "status": { "paramActive": [true, false, false, false] }, "input": { "given": { "deltas": [0, 1, 0, 0], "presses": [0, 0, 1, 0] } } } }, + { "send": { "midi": [128, 62, 0] } }, + { "send": { "wait": 100 }, "expect": { "out": [{ "midi": "" }] } } + ] +} diff --git a/docs/midi-vectors/out-pacing.json b/docs/midi-vectors/out-pacing.json new file mode 100644 index 000000000..3566af9c1 --- /dev/null +++ b/docs/midi-vectors/out-pacing.json @@ -0,0 +1,11 @@ +{ + "contract": "docs/midi-spec.md#device--host", + "says": "at most one message per knob every 50 ms (≤ 20/s per knob, `PF_MIDI_OUT_MIN_INTERVAL_MS`), trailing-edge so the final value always arrives", + "features": ["midi"], + "steps": [ + { "send": { "hand": { "knob": 1, "turn": 1 } }, "expect": { "out": [{ "midi": [176, 24, 65], "within": 20 }] } }, + { "send": { "hand": { "knob": 1, "turn": 1 } }, "expect": { "out": [{ "midi": "", "within": 20 }] } }, + { "send": { "hand": { "knob": 1, "turn": 1 } }, "expect": { "out": [{ "midi": "", "within": 10 }] } }, + { "send": { "wait": 50 }, "expect": { "out": [{ "midi": [176, 24, 67] }, { "midi": "" }] } } + ] +} diff --git a/docs/midi-vectors/out-program-change.json b/docs/midi-vectors/out-program-change.json new file mode 100644 index 000000000..37a1cc42f --- /dev/null +++ b/docs/midi-vectors/out-program-change.json @@ -0,0 +1,10 @@ +{ + "contract": "docs/midi-spec.md#device--host", + "says": "The pattern changed (by anyone: knob, console, OSC, MIDI, a show), for `n ≤ 127`", + "features": ["midi"], + "steps": [ + { "send": { "http": "GET /api/patterns/select?index=2" }, "expect": { "out": [{ "midi": [192, 2], "within": 20 }] } }, + { "send": { "midi": [192, 5] }, "expect": { "out": [{ "midi": [192, 5], "within": 50 }] } }, + { "send": { "wait": 100 }, "expect": { "out": [{ "midi": "" }] } } + ] +} diff --git a/docs/midi-vectors/out-rel-sum.json b/docs/midi-vectors/out-rel-sum.json new file mode 100644 index 000000000..dc90b33e1 --- /dev/null +++ b/docs/midi-vectors/out-rel-sum.json @@ -0,0 +1,17 @@ +{ + "contract": "docs/midi-spec.md#device--host", + "says": "in `rel` the steps sum between messages and a sum leaving ±63 is sent at once", + "features": ["midi"], + "steps": [ + { "send": { "http": "POST /api/midi?outMode=rel" } }, + { "send": { "hand": { "knob": 4, "turn": 1 } }, "expect": { "out": [{ "midi": [176, 27, 65], "within": 20 }] } }, + { "send": { "hand": { "knob": 4, "turn": 2 } }, "expect": { "out": [{ "midi": "", "within": 20 }] } }, + { "send": { "hand": { "knob": 4, "turn": 3 } }, "expect": { "out": [{ "midi": "", "within": 10 }] } }, + { "send": { "wait": 50 }, "expect": { "out": [{ "midi": [176, 27, 69] }, { "midi": "" }] } }, + { "send": { "wait": 100 } }, + { "send": { "hand": { "knob": 4, "turn": 1 } }, "expect": { "out": [{ "midi": [176, 27, 65], "within": 20 }] } }, + { "send": { "hand": { "knob": 4, "turn": 40 } }, "expect": { "out": [{ "midi": "", "within": 20 }] } }, + { "send": { "hand": { "knob": 4, "turn": 40 } }, "expect": { "out": [{ "midi": [176, 27, 127], "within": 20 }] } }, + { "send": { "wait": 60 }, "expect": { "out": [{ "midi": [176, 27, 81] }, { "midi": "" }] } } + ] +} diff --git a/docs/mqtt-vectors/README.md b/docs/mqtt-vectors/README.md new file mode 100644 index 000000000..a8d9d00ca --- /dev/null +++ b/docs/mqtt-vectors/README.md @@ -0,0 +1,13 @@ +# MQTT vectors + +Each file here is one sentence of [mqtt-spec.md](../mqtt-spec.md) and the steps that show a panel doing it, written as [contract-vectors.md](../contract-vectors.md) sets out. `npm run check:mqtt-vectors` in `web/` replays every one against a fresh [virtual panel](../../tools/virtual-panel/README.md) built with the `mqtt` feature, and prints what the vector expects beside what the panel did. CI runs it on every pull request with the other drift checks. When the spec rewords a sentence a vector quotes, that vector fails until someone brings it along. + +A Patternflow is a broker's client, so the suite brings the broker. `web/scripts/drift/_broker.ts` starts one for each vector on a free port. It speaks MQTT 3.1.1 at QoS 0 and keeps retained messages, and a topic matches only itself. Before the first step the suite points the panel at it with `POST /api/mqtt`, host and port only, which leaves the role off. A vector picks the role in its first step, `POST /api/mqtt?role=subscriber` for one, and the panel dials on its next frame, so a `wait` comes next. A message the vector sends is `{ "topic": "patternflow/sleep", "payload": "1" }`, with `"retain": true` to keep it on the broker. It reaches the panel only on a topic the panel subscribed to, as on any broker. `out` is each message the panel published, with its `retain` flag beside the topic and payload. + +A file's name starts with the part of the spec it reads. `topics-` is the topic table and the two roles, and `channels-` is the channels and the snapshot. `/api/mqtt` is REST, so its vectors are in [rest-api-vectors](../rest-api-vectors/README.md) and start with `mqtt-`. + +The banner a `message` puts up is read with `GET /vp/mqtt`. That route is the virtual panel's own. The firmware has none, and `/frame.png` does not draw the banner. + +Two vectors keep the spec where the firmware does something else. `topics-param.json` empties `param/3` 20 ms after writing it, and the firmware keeps a channel written in the last 250 ms (`ABSOLUTE_RELEASE_GRACE_MS` in `core_bus.h`). `topics-knob.json` sends a count 27 past the one before, and the firmware cuts that to 16 (`KNOB_PENDING_FLUSH` in `core_mqtt.h`). + +Some of the spec has no vectors. The Director's topics (`query`, `select`, `select/ack`) are for one tool, and every other client ignores them. How soon a panel dials again after losing its broker is the firmware's to decide, not the spec's. The broker takes any user and password, so a refused login is not replayed. diff --git a/docs/mqtt-vectors/channels-broadcast.json b/docs/mqtt-vectors/channels-broadcast.json new file mode 100644 index 000000000..c4316d54c --- /dev/null +++ b/docs/mqtt-vectors/channels-broadcast.json @@ -0,0 +1,14 @@ +{ + "contract": "docs/mqtt-spec.md#the-channel-decides-whether-your-writes-survive", + "says": "**Broadcast has no snapshot subscription.**", + "features": ["mqtt"], + "steps": [ + { "send": { "mqtt": { "topic": "patternflow/snapshot", "payload": "{\"param\":[100,200,300,400]}", "retain": true } } }, + { "send": { "http": "POST /api/mqtt?prefix=patternflow&role=subscriber" } }, + { "send": { "wait": 20 }, "expect": { "status": { "mqttConnected": true, "paramActive": [false, false, false, false] } } }, + { "send": { "mqtt": { "topic": "patternflow/param/2", "payload": "800" } } }, + { "send": { "wait": 20 }, "expect": { "status": { "params": ["", 800, "", ""], "paramActive": [false, true, false, false] } } }, + { "send": { "mqtt": { "topic": "patternflow/snapshot", "payload": "{\"param\":[100,200,300,400]}", "retain": true } } }, + { "send": { "wait": 20 }, "expect": { "status": { "params": ["", 800, "", ""], "paramActive": [false, true, false, false] } } } + ] +} diff --git a/docs/mqtt-vectors/channels-forced.json b/docs/mqtt-vectors/channels-forced.json new file mode 100644 index 000000000..d4f284356 --- /dev/null +++ b/docs/mqtt-vectors/channels-forced.json @@ -0,0 +1,20 @@ +{ + "contract": "docs/mqtt-spec.md#transport", + "says": "Channels 1–4 force the Subscriber role.", + "features": ["mqtt"], + "steps": [ + { "send": { "http": "POST /api/mqtt?prefix=patternflow5&role=publisher" } }, + { "send": { "wait": 20 }, "expect": { "status": { "mqttRole": "publisher", "mqttConnected": true } } }, + { "send": { "http": "POST /api/mqtt?prefix=patternflow1" }, "expect": { "status": { "mqttRole": "subscriber" } } }, + { "send": { "http": "POST /api/mqtt?role=publisher" }, "expect": { "status": { "mqttRole": "subscriber" } } }, + { "send": { "http": "POST /api/mqtt?prefix=patternflow2" } }, + { "send": { "http": "POST /api/mqtt?role=publisher" }, "expect": { "status": { "mqttRole": "subscriber" } } }, + { "send": { "http": "POST /api/mqtt?prefix=patternflow3" } }, + { "send": { "http": "POST /api/mqtt?role=publisher" }, "expect": { "status": { "mqttRole": "subscriber" } } }, + { "send": { "http": "POST /api/mqtt?prefix=patternflow4" } }, + { "send": { "http": "POST /api/mqtt?role=publisher" }, "expect": { "status": { "mqttRole": "subscriber" } } }, + { "send": { "wait": 20 }, "expect": { "status": { "mqttRole": "subscriber", "mqttConnected": true } } }, + { "send": { "mqtt": { "topic": "patternflow4/pattern", "payload": "Wave Saw" } } }, + { "send": { "wait": 20 }, "expect": { "status": { "active": "Wave Saw" } } } + ] +} diff --git a/docs/mqtt-vectors/channels-heartbeat.json b/docs/mqtt-vectors/channels-heartbeat.json new file mode 100644 index 000000000..2ed1ecbd1 --- /dev/null +++ b/docs/mqtt-vectors/channels-heartbeat.json @@ -0,0 +1,15 @@ +{ + "contract": "docs/mqtt-spec.md#the-channel-decides-whether-your-writes-survive", + "says": "A Publisher on the channel re-sends one every 8 seconds, and the retained copy is redelivered on every reconnect.", + "features": ["mqtt"], + "steps": [ + { "send": { "http": "POST /api/mqtt?prefix=patternflow5&role=publisher" } }, + { "send": { "wait": 20 }, "expect": { + "status": { "mqttRole": "publisher", "mqttConnected": true, "paramActive": [false, false, false, false] }, + "out": [{ "mqtt": { "topic": "patternflow5/snapshot", "payload": "", "retain": true } }] } }, + { "send": { "wait": 7900 }, "expect": { "out": [{ "mqtt": "" }] } }, + { "send": { "wait": 200 }, "expect": { "out": [{ "mqtt": { "topic": "patternflow5/snapshot", "payload": "", "retain": true } }] } }, + { "send": { "http": "POST /api/mqtt?role=subscriber" } }, + { "send": { "wait": 20 }, "expect": { "status": { "mqttRole": "subscriber", "mqttConnected": true, "paramActive": [true, true, true, true] } } } + ] +} diff --git a/docs/mqtt-vectors/channels-overwrite.json b/docs/mqtt-vectors/channels-overwrite.json new file mode 100644 index 000000000..4ba87021a --- /dev/null +++ b/docs/mqtt-vectors/channels-overwrite.json @@ -0,0 +1,19 @@ +{ + "contract": "docs/mqtt-spec.md#the-channel-decides-whether-your-writes-survive", + "says": "So on those five channels an external controller is not the only writer: its value is overwritten a moment later by whatever the snapshot last said, with every write succeeding and nothing reporting an error.", + "features": ["mqtt"], + "steps": [ + { "send": { "http": "POST /api/mqtt?prefix=patternflow2&role=subscriber" } }, + { "send": { "wait": 20 }, "expect": { "status": { "mqttConnected": true } } }, + { "send": { "mqtt": { "topic": "patternflow2/param/1", "payload": "900" } } }, + { "send": { "wait": 20 }, "expect": { "status": { "params": [900, "", "", ""], "paramActive": [true, false, false, false] } } }, + { "send": { "mqtt": { "topic": "patternflow2/snapshot", "payload": "{\"param\":[250,500,500,500]}", "retain": true } } }, + { "send": { "wait": 20 }, "expect": { "status": { "params": [250, 500, 500, 500], "paramActive": [true, true, true, true] } } }, + { "send": { "http": "POST /api/mqtt?prefix=patternflow5" } }, + { "send": { "wait": 20 }, "expect": { "status": { "mqttRole": "subscriber", "mqttConnected": true } } }, + { "send": { "mqtt": { "topic": "patternflow5/param/3", "payload": "900" } } }, + { "send": { "wait": 20 }, "expect": { "status": { "params": [250, 500, 900, 500], "paramActive": [true, true, true, true] } } }, + { "send": { "mqtt": { "topic": "patternflow5/snapshot", "payload": "{\"param\":[250,500,640,500]}", "retain": true } } }, + { "send": { "wait": 20 }, "expect": { "status": { "params": [250, 500, 640, 500], "paramActive": [true, true, true, true] } } } + ] +} diff --git a/docs/mqtt-vectors/channels-prefix.json b/docs/mqtt-vectors/channels-prefix.json new file mode 100644 index 000000000..31a0bfac3 --- /dev/null +++ b/docs/mqtt-vectors/channels-prefix.json @@ -0,0 +1,20 @@ +{ + "contract": "docs/mqtt-spec.md#transport", + "says": "Prefix `patternflow` is **Broadcast**; `patternflow1` … `patternflow4` are **channels 1–4**; `patternflow5` is **Live**; anything else is **Custom**.", + "features": ["mqtt"], + "steps": [ + { "send": { "http": "POST /api/mqtt?prefix=patternflow3&role=subscriber" }, "expect": { "reply": { "body": { "channel": "ch3", "prefix": "patternflow3" } } } }, + { "send": { "wait": 20 }, "expect": { "status": { "mqttConnected": true } } }, + { "send": { "mqtt": { "topic": "patternflow/param/1", "payload": "900" } } }, + { "send": { "wait": 20 }, "expect": { "status": { "paramActive": [false, false, false, false] } } }, + { "send": { "mqtt": { "topic": "patternflow3/param/1", "payload": "900" } } }, + { "send": { "wait": 20 }, "expect": { "status": { "params": [900, "", "", ""], "paramActive": [true, false, false, false] } } }, + { "send": { "http": "POST /api/mqtt?prefix=patternflow" }, "expect": { "reply": { "body": { "channel": "broadcast" } } } }, + { "send": { "http": "POST /api/mqtt?prefix=patternflow1" }, "expect": { "reply": { "body": { "channel": "ch1" } } } }, + { "send": { "http": "POST /api/mqtt?prefix=patternflow2" }, "expect": { "reply": { "body": { "channel": "ch2" } } } }, + { "send": { "http": "POST /api/mqtt?prefix=patternflow4" }, "expect": { "reply": { "body": { "channel": "ch4" } } } }, + { "send": { "http": "POST /api/mqtt?prefix=patternflow5" }, "expect": { "reply": { "body": { "channel": "live" } } } }, + { "send": { "http": "POST /api/mqtt?prefix=patternflow6" }, "expect": { "reply": { "body": { "channel": "custom" } } } }, + { "send": { "http": "POST /api/mqtt?prefix=studio" }, "expect": { "reply": { "body": { "channel": "custom" } } } } + ] +} diff --git a/docs/mqtt-vectors/channels-snapshot.json b/docs/mqtt-vectors/channels-snapshot.json new file mode 100644 index 000000000..3171c2dd2 --- /dev/null +++ b/docs/mqtt-vectors/channels-snapshot.json @@ -0,0 +1,22 @@ +{ + "contract": "docs/mqtt-spec.md#the-channel-decides-whether-your-writes-survive", + "says": "Channels 1–4 and Live subscribe to the retained `/snapshot`, whose `param:[…]` the firmware applies straight onto the knobs, exactly as if it had arrived on `param/`.", + "features": ["mqtt"], + "steps": [ + { "send": { "mqtt": { "topic": "patternflow1/snapshot", "payload": "{\"param\":[110,120,130,140]}", "retain": true } } }, + { "send": { "mqtt": { "topic": "patternflow2/snapshot", "payload": "{\"param\":[210,220,230,240]}", "retain": true } } }, + { "send": { "mqtt": { "topic": "patternflow3/snapshot", "payload": "{\"param\":[310,320,330,340]}", "retain": true } } }, + { "send": { "mqtt": { "topic": "patternflow4/snapshot", "payload": "{\"param\":[410,420,430,440]}", "retain": true } } }, + { "send": { "mqtt": { "topic": "patternflow5/snapshot", "payload": "{\"param\":[510,520,530,540]}", "retain": true } } }, + { "send": { "http": "POST /api/mqtt?prefix=patternflow1&role=subscriber" } }, + { "send": { "wait": 20 }, "expect": { "status": { "params": [110, 120, 130, 140], "paramActive": [true, true, true, true] } } }, + { "send": { "http": "POST /api/mqtt?prefix=patternflow2" } }, + { "send": { "wait": 20 }, "expect": { "status": { "params": [210, 220, 230, 240], "paramActive": [true, true, true, true] } } }, + { "send": { "http": "POST /api/mqtt?prefix=patternflow3" } }, + { "send": { "wait": 20 }, "expect": { "status": { "params": [310, 320, 330, 340], "paramActive": [true, true, true, true] } } }, + { "send": { "http": "POST /api/mqtt?prefix=patternflow4" } }, + { "send": { "wait": 20 }, "expect": { "status": { "params": [410, 420, 430, 440], "paramActive": [true, true, true, true] } } }, + { "send": { "http": "POST /api/mqtt?prefix=patternflow5" } }, + { "send": { "wait": 20 }, "expect": { "status": { "params": [510, 520, 530, 540], "paramActive": [true, true, true, true] } } } + ] +} diff --git a/docs/mqtt-vectors/topics-either-role.json b/docs/mqtt-vectors/topics-either-role.json new file mode 100644 index 000000000..301c4bbd7 --- /dev/null +++ b/docs/mqtt-vectors/topics-either-role.json @@ -0,0 +1,17 @@ +{ + "contract": "docs/mqtt-spec.md#topics", + "says": "`sleep` and `message` are the two topics a panel obeys in **either** role", + "features": ["mqtt"], + "steps": [ + { "send": { "http": "POST /api/mqtt?role=subscriber" } }, + { "send": { "wait": 20 }, "expect": { "status": { "mqttConnected": true } } }, + { "send": { "mqtt": { "topic": "patternflow/message", "payload": "Doors at nine" } } }, + { "send": { "http": "GET /vp/mqtt" }, "expect": { "reply": { "body": { "banner": "Doors at nine" } } } }, + { "send": { "http": "POST /api/mqtt?role=publisher" } }, + { "send": { "wait": 20 }, "expect": { "status": { "mqttRole": "publisher", "mqttConnected": true } } }, + { "send": { "mqtt": { "topic": "patternflow/message", "payload": "Last call" } } }, + { "send": { "http": "GET /vp/mqtt" }, "expect": { "reply": { "body": { "banner": "Last call" } } } }, + { "send": { "mqtt": { "topic": "patternflow/sleep", "payload": "1" } } }, + { "send": { "wait": 20 }, "expect": { "status": { "sleep": true } } } + ] +} diff --git a/docs/mqtt-vectors/topics-knob.json b/docs/mqtt-vectors/topics-knob.json new file mode 100644 index 000000000..687c1b07b --- /dev/null +++ b/docs/mqtt-vectors/topics-knob.json @@ -0,0 +1,19 @@ +{ + "contract": "docs/mqtt-spec.md#which-topic-to-write", + "says": "The panel diffs it against the last value it received and injects the difference as a detent delta.", + "features": ["mqtt"], + "steps": [ + { "send": { "http": "POST /api/mqtt?role=subscriber" } }, + { "send": { "wait": 20 }, "expect": { "status": { "mqttConnected": true } } }, + { "send": { "mqtt": { "topic": "patternflow/knob/2", "payload": "0" } } }, + { "send": { "wait": 20 }, "expect": { "input": { "given": { "deltas": [0, 0, 0, 0] } } } }, + { "send": { "mqtt": { "topic": "patternflow/knob/2", "payload": "5" } } }, + { "send": { "wait": 20 }, "expect": { "input": { "given": { "deltas": [0, 5, 0, 0] } } } }, + { "send": { "mqtt": { "topic": "patternflow/knob/2", "payload": "5" } } }, + { "send": { "wait": 20 }, "expect": { "input": { "given": { "deltas": [0, 5, 0, 0] } } } }, + { "send": { "mqtt": { "topic": "patternflow/knob/2", "payload": "-2" } } }, + { "send": { "wait": 20 }, "expect": { "input": { "given": { "deltas": [0, -2, 0, 0] } } } }, + { "send": { "mqtt": { "topic": "patternflow/knob/2", "payload": "25" } } }, + { "send": { "wait": 20 }, "expect": { "input": { "given": { "deltas": [0, 25, 0, 0] } } } } + ] +} diff --git a/docs/mqtt-vectors/topics-param.json b/docs/mqtt-vectors/topics-param.json new file mode 100644 index 000000000..6b232676e --- /dev/null +++ b/docs/mqtt-vectors/topics-param.json @@ -0,0 +1,17 @@ +{ + "contract": "docs/mqtt-spec.md#topics", + "says": "absolute `0`–`1000`; empty payload releases the hold", + "features": ["mqtt"], + "steps": [ + { "send": { "http": "POST /api/mqtt?role=subscriber" } }, + { "send": { "wait": 20 }, "expect": { "status": { "mqttConnected": true } } }, + { "send": { "mqtt": { "topic": "patternflow/param/3", "payload": "750" } } }, + { "send": { "wait": 20 }, "expect": { "status": { "params": ["", "", 750, ""], "paramActive": [false, false, true, false] } } }, + { "send": { "mqtt": { "topic": "patternflow/param/3", "payload": "1000" } } }, + { "send": { "wait": 20 }, "expect": { "status": { "params": ["", "", 1000, ""], "paramActive": [false, false, true, false] } } }, + { "send": { "mqtt": { "topic": "patternflow/param/3", "payload": "0" } } }, + { "send": { "wait": 20 }, "expect": { "status": { "params": ["", "", 0, ""], "paramActive": [false, false, true, false] } } }, + { "send": { "mqtt": { "topic": "patternflow/param/3", "payload": "" } } }, + { "send": { "wait": 20 }, "expect": { "status": { "paramActive": [false, false, false, false] } } } + ] +} diff --git a/docs/mqtt-vectors/topics-pattern.json b/docs/mqtt-vectors/topics-pattern.json new file mode 100644 index 000000000..30ec819cb --- /dev/null +++ b/docs/mqtt-vectors/topics-pattern.json @@ -0,0 +1,15 @@ +{ + "contract": "docs/mqtt-spec.md#topics", + "says": "the pattern's display name or slug", + "features": ["mqtt"], + "steps": [ + { "send": { "http": "POST /api/mqtt?role=subscriber" } }, + { "send": { "wait": 20 }, "expect": { "status": { "mqttConnected": true } } }, + { "send": { "mqtt": { "topic": "patternflow/pattern", "payload": "Wave Saw" } } }, + { "send": { "wait": 20 }, "expect": { "status": { "active": "Wave Saw" } } }, + { "send": { "mqtt": { "topic": "patternflow/pattern", "payload": "Origin" } } }, + { "send": { "wait": 20 }, "expect": { "status": { "active": "Origin" } } }, + { "send": { "mqtt": { "topic": "patternflow/pattern", "payload": "wave_saw" } } }, + { "send": { "wait": 20 }, "expect": { "status": { "active": "Wave Saw" } } } + ] +} diff --git a/docs/mqtt-vectors/topics-publisher-ignores.json b/docs/mqtt-vectors/topics-publisher-ignores.json new file mode 100644 index 000000000..8c4522f15 --- /dev/null +++ b/docs/mqtt-vectors/topics-publisher-ignores.json @@ -0,0 +1,16 @@ +{ + "contract": "docs/mqtt-spec.md#which-topic-to-write", + "says": "`knob`, `param` and `pattern` are obeyed **only in Subscriber role**; a Publisher ignores them silently.", + "features": ["mqtt"], + "steps": [ + { "send": { "http": "POST /api/mqtt?role=publisher" } }, + { "send": { "wait": 20 }, "expect": { "status": { "mqttRole": "publisher", "mqttConnected": true } } }, + { "send": { "mqtt": { "topic": "patternflow/knob/1", "payload": "5" } } }, + { "send": { "mqtt": { "topic": "patternflow/param/2", "payload": "800" } } }, + { "send": { "mqtt": { "topic": "patternflow/pattern", "payload": "Wave Saw" } } }, + { "send": { "wait": 20 }, "expect": { + "out": [{ "mqtt": "" }], + "input": { "given": { "deltas": [0, 0, 0, 0] } }, + "status": { "active": "Origin", "paramActive": [false, false, false, false] } } } + ] +} diff --git a/docs/mqtt-vectors/topics-publisher.json b/docs/mqtt-vectors/topics-publisher.json new file mode 100644 index 000000000..670286e7d --- /dev/null +++ b/docs/mqtt-vectors/topics-publisher.json @@ -0,0 +1,13 @@ +{ + "contract": "docs/mqtt-spec.md#transport", + "says": "`publisher` (sends knobs, pattern and, on a show channel, a snapshot heartbeat)", + "features": ["mqtt"], + "steps": [ + { "send": { "http": "POST /api/mqtt?role=publisher" } }, + { "send": { "wait": 20 }, "expect": { "status": { "mqttRole": "publisher", "mqttConnected": true } } }, + { "send": { "hand": { "knob": 2, "turn": -3 } }, "expect": { "out": [{ "mqtt": { "topic": "patternflow/knob/2", "payload": "-3", "retain": false } }] } }, + { "send": { "hand": { "knob": 2, "turn": 1 } }, "expect": { "out": [{ "mqtt": { "topic": "patternflow/knob/2", "payload": "-2", "retain": false } }] } }, + { "send": { "http": "GET /api/patterns/select?index=1" } }, + { "send": { "wait": 20 }, "expect": { "out": [{ "mqtt": { "topic": "patternflow/pattern", "payload": "", "retain": false } }] } } + ] +} diff --git a/docs/mqtt-vectors/topics-sleep-state.json b/docs/mqtt-vectors/topics-sleep-state.json new file mode 100644 index 000000000..d5de0b48a --- /dev/null +++ b/docs/mqtt-vectors/topics-sleep-state.json @@ -0,0 +1,13 @@ +{ + "contract": "docs/mqtt-spec.md#topics", + "says": "`0` or `1`, on every change and once per connection", + "features": ["mqtt"], + "steps": [ + { "send": { "http": "POST /api/mqtt?role=subscriber" } }, + { "send": { "wait": 20 }, "expect": { "out": [{ "mqtt": { "topic": "patternflow/sleep/state", "payload": "0", "retain": false } }, { "mqtt": "" }] } }, + { "send": { "http": "POST /api/sleep?on=1" } }, + { "send": { "wait": 20 }, "expect": { "out": [{ "mqtt": { "topic": "patternflow/sleep/state", "payload": "1", "retain": false } }] } }, + { "send": { "http": "POST /api/mqtt?role=publisher" } }, + { "send": { "wait": 20 }, "expect": { "status": { "mqttRole": "publisher", "mqttConnected": true }, "out": [{ "mqtt": { "topic": "patternflow/sleep/state", "payload": "1", "retain": false } }] } } + ] +} diff --git a/docs/mqtt-vectors/topics-sleep-words.json b/docs/mqtt-vectors/topics-sleep-words.json new file mode 100644 index 000000000..6ceaa4be7 --- /dev/null +++ b/docs/mqtt-vectors/topics-sleep-words.json @@ -0,0 +1,19 @@ +{ + "contract": "docs/mqtt-spec.md#topics", + "says": "`1` / `on` / `true` / `sleep` · `0` / `off` / `false` / `wake` · `toggle`", + "features": ["mqtt"], + "steps": [ + { "send": { "http": "POST /api/mqtt?role=subscriber" } }, + { "send": { "wait": 20 }, "expect": { "status": { "mqttConnected": true } } }, + { "send": { "mqtt": { "topic": "patternflow/sleep", "payload": "1" } }, "expect": { "out": [{ "mqtt": { "topic": "patternflow/sleep/state", "payload": "1" }, "within": 20 }], "status": { "sleep": true } } }, + { "send": { "mqtt": { "topic": "patternflow/sleep", "payload": "0" } }, "expect": { "out": [{ "mqtt": { "topic": "patternflow/sleep/state", "payload": "0" }, "within": 20 }], "status": { "sleep": false } } }, + { "send": { "mqtt": { "topic": "patternflow/sleep", "payload": "on" } }, "expect": { "out": [{ "mqtt": { "topic": "patternflow/sleep/state", "payload": "1" }, "within": 20 }], "status": { "sleep": true } } }, + { "send": { "mqtt": { "topic": "patternflow/sleep", "payload": "off" } }, "expect": { "out": [{ "mqtt": { "topic": "patternflow/sleep/state", "payload": "0" }, "within": 20 }], "status": { "sleep": false } } }, + { "send": { "mqtt": { "topic": "patternflow/sleep", "payload": "true" } }, "expect": { "out": [{ "mqtt": { "topic": "patternflow/sleep/state", "payload": "1" }, "within": 20 }], "status": { "sleep": true } } }, + { "send": { "mqtt": { "topic": "patternflow/sleep", "payload": "false" } }, "expect": { "out": [{ "mqtt": { "topic": "patternflow/sleep/state", "payload": "0" }, "within": 20 }], "status": { "sleep": false } } }, + { "send": { "mqtt": { "topic": "patternflow/sleep", "payload": "sleep" } }, "expect": { "out": [{ "mqtt": { "topic": "patternflow/sleep/state", "payload": "1" }, "within": 20 }], "status": { "sleep": true } } }, + { "send": { "mqtt": { "topic": "patternflow/sleep", "payload": "wake" } }, "expect": { "out": [{ "mqtt": { "topic": "patternflow/sleep/state", "payload": "0" }, "within": 20 }], "status": { "sleep": false } } }, + { "send": { "mqtt": { "topic": "patternflow/sleep", "payload": "toggle" } }, "expect": { "out": [{ "mqtt": { "topic": "patternflow/sleep/state", "payload": "1" }, "within": 20 }], "status": { "sleep": true } } }, + { "send": { "mqtt": { "topic": "patternflow/sleep", "payload": "toggle" } }, "expect": { "out": [{ "mqtt": { "topic": "patternflow/sleep/state", "payload": "0" }, "within": 20 }], "status": { "sleep": false } } } + ] +} diff --git a/docs/mqtt-vectors/topics-sleep.json b/docs/mqtt-vectors/topics-sleep.json new file mode 100644 index 000000000..09f29507d --- /dev/null +++ b/docs/mqtt-vectors/topics-sleep.json @@ -0,0 +1,11 @@ +{ + "contract": "docs/mqtt-spec.md#topics", + "says": "Publishing `1` to `/sleep` puts the panel to sleep, `0` wakes it, and `sleep/state` mirrors the result", + "features": ["mqtt"], + "steps": [ + { "send": { "http": "POST /api/mqtt?role=subscriber" } }, + { "send": { "wait": 20 }, "expect": { "status": { "mqttConnected": true, "sleep": false } } }, + { "send": { "mqtt": { "topic": "patternflow/sleep", "payload": "1" } }, "expect": { "out": [{ "mqtt": { "topic": "patternflow/sleep/state", "payload": "1" }, "within": 20 }], "status": { "sleep": true } } }, + { "send": { "mqtt": { "topic": "patternflow/sleep", "payload": "0" } }, "expect": { "out": [{ "mqtt": { "topic": "patternflow/sleep/state", "payload": "0" }, "within": 20 }], "status": { "sleep": false } } } + ] +} diff --git a/docs/osc-vectors/README.md b/docs/osc-vectors/README.md new file mode 100644 index 000000000..e227f91c9 --- /dev/null +++ b/docs/osc-vectors/README.md @@ -0,0 +1,11 @@ +# OSC vectors + +Each file here is one sentence of [osc-spec.md](../osc-spec.md) and the messages that show a panel doing it, written as [contract-vectors.md](../contract-vectors.md) sets out. `npm run check:osc-vectors` in `web/` replays every one against a fresh [virtual panel](../../tools/virtual-panel/README.md) built with the `osc` feature, and prints what the vector expects beside what the panel did. CI runs it on every pull request with the other drift checks. When the spec rewords a sentence a vector quotes, that vector fails until someone brings it along. + +A file's name says which table of the spec it reads. `in-` is a row of *Host → device*, or the transport rules every message obeys, and shows what the panel does with that message. `out-` is a row of *Device → host*, something the panel sends without being asked. + +A message is its address and then its arguments. A whole number goes on the wire as an `int32`, any other number as a `float32` and text as a string, so `["/patternflow/knob/1/delta", 2.6]` sends a float. `["#bundle", ["/patternflow/ping"]]` is a bundle with a ping in it, its time tag set to "immediately". What the panel sends is read back into the same form. The suite sends from one UDP socket. A device answers on port 9000, and the virtual panel, started with `oscReply` at 0, answers to the port the message came from. + +The panel reads what has arrived at the end of a frame and answers in that frame. A knob delta reaches the pattern a frame after it is read. So a vector gives each message 50 ms, three frames at 60 fps, before it looks. + +Some of the spec has no vectors. The virtual panel has no radio, so the announce on Wi-Fi connect and `patternflow.local` are not replayed. A vector talks from one address, so neither is a host that changes. A knob a lane is moving is not reported, and a panel built with `osc` alone has no lane to move one. diff --git a/docs/osc-vectors/in-bundle.json b/docs/osc-vectors/in-bundle.json new file mode 100644 index 000000000..e8393700c --- /dev/null +++ b/docs/osc-vectors/in-bundle.json @@ -0,0 +1,12 @@ +{ + "contract": "docs/osc-spec.md#transport", + "says": "Plain OSC 1.0 messages. No `#bundle` support — send bare messages.", + "features": ["osc"], + "steps": [ + { "send": { "osc": ["#bundle", ["/patternflow/ping"], ["/patternflow/knob/1/delta", 2]] } }, + { "send": { "wait": 50 }, "expect": { "out": [{ "osc": "" }], "input": { "given": { "deltas": [0, 0, 0, 0] } } } }, + { "send": { "osc": ["/patternflow/ping"] } }, + { "send": { "osc": ["/patternflow/knob/1/delta", 2] } }, + { "send": { "wait": 50 }, "expect": { "out": [{ "osc": ["/patternflow/hello", "Patternflow"] }], "input": { "given": { "deltas": [2, 0, 0, 0] } } } } + ] +} diff --git a/docs/osc-vectors/in-content-toggle.json b/docs/osc-vectors/in-content-toggle.json new file mode 100644 index 000000000..38215085c --- /dev/null +++ b/docs/osc-vectors/in-content-toggle.json @@ -0,0 +1,17 @@ +{ + "contract": "docs/osc-spec.md#host--device", + "says": "No-op. Accepted and discarded — the Pattern/Video content-mode split was removed.", + "features": ["osc"], + "steps": [ + { "send": { "osc": ["/patternflow/ping"] } }, + { "send": { "wait": 50 }, "expect": { "out": [{ "osc": ["/patternflow/hello", "Patternflow"] }] } }, + { "send": { "osc": ["/patternflow/content/toggle"] } }, + { + "send": { "wait": 50 }, + "expect": { + "out": [{ "osc": "" }], + "input": { "mode": "run", "pattern": { "index": 0 }, "given": { "deltas": [0, 0, 0, 0], "presses": [0, 0, 0, 0] }, "banner": null } + } + } + ] +} diff --git a/docs/osc-vectors/in-float.json b/docs/osc-vectors/in-float.json new file mode 100644 index 000000000..625f4c4c6 --- /dev/null +++ b/docs/osc-vectors/in-float.json @@ -0,0 +1,12 @@ +{ + "contract": "docs/osc-spec.md#transport", + "says": "Numeric arguments may be sent as OSC `int32` (`i`) or `float32` (`f`); the device accepts both and rounds floats.", + "features": ["osc"], + "steps": [ + { "send": { "osc": ["/patternflow/knob/1/delta", 2.6] } }, + { "send": { "osc": ["/patternflow/knob/2/delta", -2.6] } }, + { "send": { "osc": ["/patternflow/pattern/index", 1.6] } }, + { "send": { "wait": 50 }, "expect": { "input": { "given": { "deltas": [3, -3, 0, 0] } } } }, + { "send": { "http": "GET /api/patterns" }, "expect": { "reply": { "body": { "active": 2 } } } } + ] +} diff --git a/docs/osc-vectors/in-knob-delta.json b/docs/osc-vectors/in-knob-delta.json new file mode 100644 index 000000000..f61329f62 --- /dev/null +++ b/docs/osc-vectors/in-knob-delta.json @@ -0,0 +1,11 @@ +{ + "contract": "docs/osc-spec.md#host--device", + "says": "Virtual rotation on knob N (1–4), applied on top of physical motion at 1× per detent", + "features": ["osc"], + "steps": [ + { "send": { "hand": { "knob": 2, "turn": 1 } } }, + { "send": { "osc": ["/patternflow/knob/2/delta", 3] } }, + { "send": { "osc": ["/patternflow/knob/3/delta", -2] } }, + { "send": { "wait": 50 }, "expect": { "input": { "given": { "deltas": [0, 4, -2, 0] } } } } + ] +} diff --git a/docs/osc-vectors/in-pattern-index.json b/docs/osc-vectors/in-pattern-index.json new file mode 100644 index 000000000..99f722cd0 --- /dev/null +++ b/docs/osc-vectors/in-pattern-index.json @@ -0,0 +1,13 @@ +{ + "contract": "docs/osc-spec.md#host--device", + "says": "Switch to the pattern at this registry index", + "features": ["osc"], + "steps": [ + { "send": { "osc": ["/patternflow/pattern/index", 3] } }, + { "send": { "wait": 50 } }, + { "send": { "http": "GET /api/patterns" }, "expect": { "reply": { "body": { "active": 3 } } } }, + { "send": { "osc": ["/patternflow/pattern/index", 0] } }, + { "send": { "wait": 50 } }, + { "send": { "http": "GET /api/patterns" }, "expect": { "reply": { "body": { "active": 0 } } } } + ] +} diff --git a/docs/osc-vectors/in-ping.json b/docs/osc-vectors/in-ping.json new file mode 100644 index 000000000..614ea2667 --- /dev/null +++ b/docs/osc-vectors/in-ping.json @@ -0,0 +1,22 @@ +{ + "contract": "docs/osc-spec.md#host--device", + "says": "Learn/refresh sender as the remote host and reply with a full announce (hello, version, ip, pattern index/name, modes).", + "features": ["osc"], + "steps": [ + { "send": { "osc": ["/patternflow/ping"] } }, + { + "send": { "wait": 50 }, + "expect": { + "out": [ + { "osc": ["/patternflow/hello", "Patternflow"] }, + { "osc": ["/patternflow/version", ""] }, + { "osc": ["/patternflow/ip", ""] }, + { "osc": ["/patternflow/pattern/index", ""] }, + { "osc": ["/patternflow/pattern/name", ""] }, + { "osc": ["/patternflow/content/mode", 0] }, + { "osc": ["/patternflow/app/mode", ""] } + ] + } + } + ] +} diff --git a/docs/osc-vectors/in-unknown.json b/docs/osc-vectors/in-unknown.json new file mode 100644 index 000000000..e704da4e2 --- /dev/null +++ b/docs/osc-vectors/in-unknown.json @@ -0,0 +1,12 @@ +{ + "contract": "docs/osc-spec.md#host--device", + "says": "Unknown addresses are ignored silently.", + "features": ["osc"], + "steps": [ + { "send": { "osc": ["/patternflow/knob/5/delta", 1] } }, + { "send": { "osc": ["/patternflow/pattern/next"] } }, + { "send": { "wait": 50 }, "expect": { "out": [{ "osc": "" }], "input": { "pattern": { "index": 0 }, "given": { "deltas": [0, 0, 0, 0] } } } }, + { "send": { "osc": ["/patternflow/ping"] } }, + { "send": { "wait": 50 }, "expect": { "out": [{ "osc": ["/patternflow/hello", "Patternflow"] }] } } + ] +} diff --git a/docs/osc-vectors/out-button.json b/docs/osc-vectors/out-button.json new file mode 100644 index 000000000..1e3d4f999 --- /dev/null +++ b/docs/osc-vectors/out-button.json @@ -0,0 +1,13 @@ +{ + "contract": "docs/osc-spec.md#device--host", + "says": "Encoder button N pressed", + "features": ["osc"], + "steps": [ + { "send": { "osc": ["/patternflow/ping"] } }, + { "send": { "wait": 50 } }, + { "send": { "hand": { "knob": 3, "down": true } } }, + { "send": { "wait": 50 }, "expect": { "out": [{ "osc": ["/patternflow/button/3/press", 1] }, { "osc": ["/patternflow/button/3/held", 1] }] } }, + { "send": { "hand": { "knob": 3, "down": false } } }, + { "send": { "wait": 50 }, "expect": { "out": [{ "osc": ["/patternflow/button/3/held", 0] }, { "osc": "" }] } } + ] +} diff --git a/docs/osc-vectors/out-heartbeat.json b/docs/osc-vectors/out-heartbeat.json new file mode 100644 index 000000000..a3eb111ba --- /dev/null +++ b/docs/osc-vectors/out-heartbeat.json @@ -0,0 +1,11 @@ +{ + "contract": "docs/osc-spec.md#device--host", + "says": "Every 1 s while connected", + "features": ["osc"], + "steps": [ + { "send": { "wait": 1500 }, "expect": { "out": [{ "osc": "" }] } }, + { "send": { "osc": ["/patternflow/ping"] } }, + { "send": { "wait": 1000 }, "expect": { "out": [{ "osc": ["/patternflow/hello", "Patternflow"] }, { "osc": ["/patternflow/heartbeat", ""] }, { "osc": "" }] } }, + { "send": { "wait": 1000 }, "expect": { "out": [{ "osc": ["/patternflow/heartbeat", ""] }, { "osc": "" }] } } + ] +} diff --git a/docs/osc-vectors/out-knob.json b/docs/osc-vectors/out-knob.json new file mode 100644 index 000000000..1dff2f2ce --- /dev/null +++ b/docs/osc-vectors/out-knob.json @@ -0,0 +1,15 @@ +{ + "contract": "docs/osc-spec.md#device--host", + "says": "Encoder N (1–4) turned by a hand; signed detent delta for this frame.", + "features": ["osc"], + "steps": [ + { "send": { "osc": ["/patternflow/ping"] } }, + { "send": { "wait": 50 } }, + { "send": { "hand": { "knob": 1, "turn": 2 } } }, + { "send": { "wait": 50 }, "expect": { "out": [{ "osc": ["/patternflow/knob/1/delta", 2] }, { "osc": ["/patternflow/knob/1/clicks", 2] }, { "osc": "" }] } }, + { "send": { "hand": { "knob": 1, "turn": -3 } } }, + { "send": { "wait": 50 }, "expect": { "out": [{ "osc": ["/patternflow/knob/1/delta", -3] }, { "osc": ["/patternflow/knob/1/clicks", -1] }, { "osc": "" }] } }, + { "send": { "osc": ["/patternflow/knob/1/delta", 5] } }, + { "send": { "wait": 50 }, "expect": { "out": [{ "osc": "" }], "input": { "given": { "deltas": [4, 0, 0, 0] } } } } + ] +} diff --git a/docs/osc-vectors/out-pattern.json b/docs/osc-vectors/out-pattern.json new file mode 100644 index 000000000..21fdd2d2a --- /dev/null +++ b/docs/osc-vectors/out-pattern.json @@ -0,0 +1,14 @@ +{ + "contract": "docs/osc-spec.md#device--host", + "says": "Pattern changed (or announce)", + "features": ["osc"], + "steps": [ + { "send": { "osc": ["/patternflow/ping"] } }, + { "send": { "wait": 50 } }, + { "send": { "http": "GET /api/patterns/select?index=2" } }, + { + "send": { "wait": 50 }, + "expect": { "out": [{ "osc": ["/patternflow/pattern/index", 2] }, { "osc": ["/patternflow/pattern/name", ""] }, { "osc": "" }] } + } + ] +} diff --git a/docs/releases/v1.0.0.md b/docs/releases/v1.0.0.md new file mode 100644 index 000000000..ca2aa603d --- /dev/null +++ b/docs/releases/v1.0.0.md @@ -0,0 +1,39 @@ +## [v1.0.0] - 2026-04 (initial public release) + +The first publicly buildable version of Patternflow. + +### Hardware +- 128x64 px HUB75 LED matrix (P2.5, 320x160 mm) +- ESP32-S3-WROOM-1 (N16R8 -- 16MB Flash, 8MB PSRAM) +- 4x EC11 rotary encoders with push-switches +- Custom PCB (KiCad) -- fabricated via PCBway sponsorship +- 3D-printed PLA case (white body, black knobs) +- Powered by user-supplied USB power bank, with internal mounting compartment + +### Firmware +- Arduino-based firmware for ESP32-S3 +- HUB75 DMA driver +- Default pattern set + +### Documentation +- `docs/BUILD.md` -- full build guide with BOM and assembly walkthrough +- AliExpress affiliate sourcing links for all electronic components +- KiCad project files for PCB +- STL files for case (3 prints total) + +### License +- Firmware & web: MIT +- Hardware & designs: CC-BY-SA 4.0 +- "Patternflow" is a trademark of SeungHun Lee + +### Known Issues +See [BUILD_GUIDE.md](../../BUILD_GUIDE.md) -- Known Issues section for full details. +- Reset button must be pressed once after power-up +- Rotary encoder direction reversed in PCB (compensated in firmware) +- Silkscreen ambiguity between 0805 caps and resistors +- LED matrix alignment bumps require trimming during assembly + +### Acknowledgments +- **PCBway** -- for sponsoring v1.0 PCB fabrication +- **r/arduino community** -- 1.6k upvotes on the prototype thread that pushed this from "just a personal project" toward open source +- **Doyoon** -- for the suggestion that started the LED matrix direction diff --git a/docs/releases/v1.1.0.md b/docs/releases/v1.1.0.md new file mode 100644 index 000000000..bf36b63c1 --- /dev/null +++ b/docs/releases/v1.1.0.md @@ -0,0 +1,16 @@ +## [v1.1.0] - 2026-04 (Multi-pattern Update) + +This update consolidates multiple patterns into a single firmware and introduces a runtime pattern selection mode. + +### Firmware +- **Refactored Modular Architecture**: Patterns are now modularized (`pattern_*.h`) and registered in a central registry. +- **Unified Input Handling**: Introduced `InputFrame` to share normalized encoder and button states across patterns. +- **Pattern Selection Mode**: Long-press Encoder 4 (1 second) to enter/exit the pattern selection UI. +- **New Pattern**: Added `Wave1_Saw` (rotated sawtooth waves with fractal noise distortion). +- **Improved Performance**: Replaced macros with namespace-scoped constants and optimized LUT usage. + +### Web +- **Consolidated Flasher**: The web flasher now provides a single "PatternFlow OS v1.1.0" image containing all patterns. +- **Simplified UI**: Removed individual pattern buttons in favor of the all-in-one OS build. + +--- diff --git a/docs/releases/v2.0.0.md b/docs/releases/v2.0.0.md new file mode 100644 index 000000000..7099e3294 --- /dev/null +++ b/docs/releases/v2.0.0.md @@ -0,0 +1,24 @@ +## [2.0.0] - 2026-05 + +### Added +- **Custom pattern workflow.** New `firmware/CUSTOM_PATTERNS.md` with a prompt template and step-by-step guide for creating patterns using AI coding assistants. Drop in a new `pattern_*.h` file, register it, flash. +- **Web platform.** patternflow.work is now substantially complete: browser-based firmware flasher (Chrome/Edge), Live Editor, journal, and build map. +- **PCB R13** -- 10k pullup on GPIO0. Resolves the v1 cold-boot issue. +- **Silkscreen revisions** -- clear R/C designators, explicit encoder back-side marking. + +### Changed +- **Unified versioning.** Project, firmware, PCB, and case are tracked as a single Patternflow version. v2.0.0 covers all of them. +- **Pattern naming canonicalized.** "Origin" and "Wave Saw" are the display names across firmware, web, and docs. Firmware filename `pattern_wave1.h` renamed to `pattern_wave_saw.h`. +- **Build guide reorganized.** Known Issues section split into "Fixed in v2.0," "Still open," and "Design notes." + +### Fixed +- **Cold-boot unreliability** after extended power-off. Root cause: GPIO0 strapping pin floating. Full story: [Issue #16](https://github.com/engmung/Patternflow/issues/16). Credit to @idranoutof1d and u/Infrated on r/AskElectronics. + +### Still open +- **Issue #19** -- LED matrix alignment bumps require manual trimming. Will be addressed when the LED diffuser variant ships. + +### Deliberate non-changes +- **C11 (1000uF bulk cap)** retained despite USB inrush concerns. Patternflow is power-bank-powered, not desktop-USB-powered, and the cap improves boot transient stability. +- **Encoder direction** handled in firmware (sign inversion) rather than via PCB footprint re-spin. Transparent to users. + +--- diff --git a/docs/releases/v2.1.0.md b/docs/releases/v2.1.0.md new file mode 100644 index 000000000..a0752f2d6 --- /dev/null +++ b/docs/releases/v2.1.0.md @@ -0,0 +1,61 @@ +## [2.1.0] - 2026-07 + +The final consolidation release of the v2.x line before v3.0. Everything a v2.x builder needs — the v2.1 board, the case options, the current firmware and web tools — is pinned here. + +### Hardware — PCB + +- **v2.1 Gerbers are the recommended board** (`hardware/pcb/gerber/patternflow_v2.1_gerber.zip`). Over v2.0: reworked ESP32↔J1 (HUB75) routing and silkscreen fixes (clearer C15/R10 reference positions). The build guide pins PCB orders to v2.1. +- **v3.0 test board** lives in `hardware/pcb/gerber/experiment/` — hybrid power input (USB-C plus a 2-pin screw terminal bypass for beginners), all-through-hole, DRC clean. **Unverified — do not order** ([#114](https://github.com/engmung/Patternflow/issues/114)). This absorbs the earlier v2.2 USB-C experiment. +- Schematic component placement tidied to mirror the physical board layout. + +### Hardware — Case + +- **One-piece snap-fit enclosure** (`print-ready/variants/oneshot_v2_1/2.stl`) promoted to a supported print option ([#113](https://github.com/engmung/Patternflow/issues/113)): single-piece body plus snap-fit closing part, no gluing. Needs a ~330 mm bed (H2S-class). Includes a wall-mount hanger hole; stable print confirmed. +- **`easyfit` main plate variant** with alignment tabs along the bond seam — ⚠️ current STL is missing the LED matrix divider slot; do not print as-is ([#154](https://github.com/engmung/Patternflow/issues/154)). +- **Divided snap-fit experiments** (`print-ready/experiment/`): the one-piece design split to fit 256 mm (P1S-class) beds, including the latest `v2.1_divided.stl`. Modeled but **not print-tested yet**. +- `oneshot_v3-wip/` quarantined — for the upcoming v3.0 board only, not compatible with v2.x. +- USB-C adapter clearance fixed after the encoder position change. +- STLs are stored as regular git files (not LFS) so GitHub's *Download ZIP* works; the Blender source stays in LFS and is attached to releases. +- v1 Blender/SVG source for the original laser-cut acrylic case added for reference. + +### Firmware + +- **Foundation refactor.** Patterns render through `PFCanvas` instead of touching the HUB75 driver directly; shared `core_math` / `core_color` / `core_noise` libraries; foundation modules moved into `src/`. +- **Color and refresh quality**: gamma LUT applied in `PFCanvas::present()`, per-channel white balance and saturation boost, panel refresh raised to ~240 Hz to eliminate phone-camera flicker bands. +- **Controls**: encoder acceleration; K1 long-press global brightness mode; K2 long-press OSC info screen and runtime toggle (persisted in NVS); K3 knob-map screen; K1/K2 logical mapping fixed; encoder direction corrected for Bourns PEC11R parts. +- **Two-way OSC as a sidechannel** (no longer a content mode): accepts knob, pattern, and content commands; `/patternflow/ping` full-announce for late-starting hosts; `/patternflow/version` sent with `hello`; remote host auto-learned from the last valid sender (`PF_OSC_REMOTE_HOST` now optional); up to 8 datagrams drained per frame; numeric args accepted as int or float. +- **Wireless workflows**: ArduinoOTA flashing (with Arduino IDE 2.x workaround documented) and Improv-Serial Wi-Fi provisioning from the browser flasher. +- **Live pattern preview** behind the SELECT screen. +- **Audio-react foundation**: WebSocket server routing browser audio analysis through virtual knobs (opt-in override). +- **Pattern system**: preset library plus reusable custom slots with a custom-first registry (Origin stays pattern 1); memory-for-math optimization toolkit (`fastPow`, LUTs, typed angle constants replacing Arduino macros). +- Video Baker `PFV1` playback support and a standalone rotary encoder test sketch. + +### Web (patternflow.work) + +- **Pattern Lab**: development harness with calibrated knobs and encoder buttons; in-app Gemini pattern generation (bring-your-own-key); color ramp + v-field mode with a gradient editor; Experiment tab — a layer-stack patch editor that compiles to pattern code, with knob bindings. +- **Video Baker** tool for baking patterns to `PFV1` video. +- **Live Editor**: hardened C++ conversion prompt (exact helper signatures, expensive-math decision table, pre-baked ramp LUTs, knob ranges and `@knobs` annotations carried through), collapsible preset library with shuffle, pattern sharing modal and links, source-aware Discord share. +- **Build globe**: community builds shown on an interactive globe inside the Inside viewer, with multi-link support per build. +- **Interactive project map** replacing the status page, including the project's origins (PCBWay sponsorship, Nam June Paik Art Center). +- **SEO / AI discoverability**: robots.txt, sitemap, JSON-LD, `llms.txt`. +- **Crowd Supply pre-launch funnel**: hero and mobile floating CTAs link to the campaign page. +- CI via GitHub Actions: web build, lint as a hard gate, Discord notifications. + +### Integrations + +- **Ableton Live integration** (`integrations/ableton/`). A Max for Live bridge device maps the four hardware knobs to any Live parameters over OSC (relative encoder deltas, per-slot sweep sensitivity, mappings saved with the Live set), plus guides for M4L/OSC pitfalls and a filming-with-synced-sound workflow. +- **OSC spec** (`docs/osc-spec.md`): the wire protocol as a versioned contract for third-party integrations. + +### Docs + +- **Build guide moved to the repo root** as `BUILD_GUIDE.md`, with an all-through-hole BOM and PCB orders pinned to the v2.1 Gerbers. +- **Breadboard-only build guide** — the no-PCB path is now documented and surfaced as available. +- HUB75 driver-IC selection and panel compatibility guidance. +- Pattern licensing stated: CC-BY-SA-4.0, inbound=outbound. +- Issue form templates, a "Share your build" discussion form, and a development workflow section in CONTRIBUTING. + +### Known issues + +- `easyfit` main plate STL missing the divider slot ([#154](https://github.com/engmung/Patternflow/issues/154)). +- Divided (256 mm bed) snap-fit enclosure not yet print-tested. +- LED matrix alignment bumps still require manual trimming ([#19](https://github.com/engmung/Patternflow/issues/19)). diff --git a/docs/releases/v3.0.0-notes.md b/docs/releases/v3.0.0-notes.md new file mode 100644 index 000000000..599220fb9 --- /dev/null +++ b/docs/releases/v3.0.0-notes.md @@ -0,0 +1,51 @@ +# Patternflow v3.0.0 — the hybrid-power generation + +> **Historical (2026-07).** Long-form notes for one release, kept as written. Release notes since 3.2 live on [GitHub Releases](https://github.com/engmung/Patternflow/releases); `CHANGELOG.md` at the root is the complete record. + +The biggest hardware revision since launch. The v3.0 board and enclosure were rebuilt around one goal: **anyone should be able to solder and assemble this.** + +**Building one? Start here → [BUILD_GUIDE.md](https://github.com/engmung/Patternflow/blob/v3.0.0/BUILD_GUIDE.md)** — the PCB soldering is covered by a [full video walkthrough](https://youtu.be/NZCjMBCsDAc). + +## Highlights + +### The board: two power inputs, zero SMD +- **Screw-terminal power** — strip a USB cable, clamp two wires into the 2-pin terminal on the back of the board. No soldering iron needed for the power input. + > ⏸️ **Update (2026-07):** the board also carries a USB-C footprint (`USB1`), but **that input is on hold** — a USB-C-powered board ran fine for 20–30+ minutes and then smoked at a connector pin, destroying the receptacle and power path ([#221](https://github.com/engmung/Patternflow/issues/221)). Leave `USB1`/`R1`/`R2` unpopulated and power the board through `J4` for now. See [BUILD_GUIDE §2](https://github.com/engmung/Patternflow/blob/main/BUILD_GUIDE.md#2-power-input--use-the-screw-terminal). +- **No SMD passives at all** — every part you solder is through-hole. The whole board is beginner-solderable. +- Fabricated, assembled, and verified on the screw-terminal input. + +### The enclosure: snap-fit, printable on a 256 mm bed +- Folders named by **printer bed size**: `bed_256mm/encloser.stl` (one STL, ~10 h on a P1S) or `bed_330mm/` one-piece for large-format printers. Knobs print separately in black — white body, black knobs. +- **Bambu printers: one click** — the case is on [MakerWorld](https://makerworld.com/ko/models/3072492-patternflow-open-source-led-synthesizer-case#profileId-3459015) with tuned print profiles; the same 4-plate project is attached below as `patternflow_v3.3mf`. +- **Snap-fit back panel**, **two wall-mount holes**, and recesses for the LED panel's alignment bumps — **the nipper-trimming step that's been with us since v1.0 is gone** ([#19](https://github.com/engmung/Patternflow/issues/19)). +- Using a different LED panel than the recommended one? `bed_256mm/for_other_panels/` has an adjustable-mount variant ([#169](https://github.com/engmung/Patternflow/issues/169)). + +### The BOM: every part by part number +- [`hardware/bom/bom_v3.0.csv`](https://github.com/engmung/Patternflow/blob/v3.0.0/hardware/bom/bom_v3.0.csv) — machine-readable, every part specified by MPN, sourcing notes included. Order from Mouser/DigiKey/LCSC or find the same parts anywhere. + +### The guide: watch, don't decipher +- [BUILD_GUIDE.md](https://github.com/engmung/Patternflow/blob/v3.0.0/BUILD_GUIDE.md) rewritten for v3.0 — **two full video walkthroughs** ([PCB soldering](https://youtu.be/NZCjMBCsDAc) and [assembly to first power-on](https://youtu.be/J9C9bZgkNKs)), photo-documented printing/bonding steps, a collapsible netlist-derived pin reference, and a dedicated OSC/Ableton build path. +- PCB ordering routed through the [PCBWay shared project](https://www.pcbway.com/project/shareproject/Patternflow_An_LED_synthesizer_776d796c.html) (no Gerber upload needed) — any fab works with the attached zip too. + +### Firmware +- **One image for every board generation** — v2.x and v3.0 share an identical pin map, so the [browser flasher](https://patternflow.work) just works on both. No board selection to get wrong. +- **Bigger preset library, sturdier engine** — thirteen new presets from the June–July daily batch, three fresh custom-slot patterns, and a new PSRAM-first allocator (`PFMem`) so a fully loaded pattern image can't starve the Wi-Fi/DMA DRAM budget. + +## Breaking changes + +- **v2.x boards and v3.0 cases are not interchangeable** (and vice versa) — different board size, different port positions. +- Already own a v2.x build? Everything you need stays bundled at the [v2.1.0 release](https://github.com/engmung/Patternflow/releases/tag/v2.1.0), and the v2 guide lives on as [`BUILD_GUIDE_v2.md`](https://github.com/engmung/Patternflow/blob/v3.0.0/BUILD_GUIDE_v2.md). Bonus for v2 owners: `hardware/case/legacy_v2/encloser_v2.1.stl` is a newly verified snap-fit case for the v2.1 board — no gluing, no trimming. + +## Assets + +- `patternflow_v3_case_stl_pack.zip` — all v3 case STLs (256 mm kit, 330 mm one-piece, adjustable-mount variant, knob plates) +- `patternflow_v3.3mf` — Bambu Studio project (4 pre-arranged plates with print settings; same as the [MakerWorld listing](https://makerworld.com/ko/models/3072492-patternflow-open-source-led-synthesizer-case#profileId-3459015)) +- `patternflow_v3.0_gerber.zip` — the verified board +- `patternflow_v3_firmware.zip` — prebuilt ESP32-S3 binaries (the browser-flasher image) with esptool offsets in the included README +- `patternflow_case.blend` — Blender source for every printed part (stored in Git LFS; attached here so *Download ZIP* users get the real file) + +Full details in the [CHANGELOG](https://github.com/engmung/Patternflow/blob/v3.0.0/CHANGELOG.md). + +--- + +Firmware & web: MIT · Hardware & designs: CC-BY-SA 4.0 diff --git a/docs/releases/v3.0.0.md b/docs/releases/v3.0.0.md index 599220fb9..f6299efcd 100644 --- a/docs/releases/v3.0.0.md +++ b/docs/releases/v3.0.0.md @@ -1,51 +1,17 @@ -# Patternflow v3.0.0 — the hybrid-power generation - -> **Historical (2026-07).** Long-form notes for one release, kept as written. Release notes since 3.2 live on [GitHub Releases](https://github.com/engmung/Patternflow/releases); `CHANGELOG.md` at the root is the complete record. - -The biggest hardware revision since launch. The v3.0 board and enclosure were rebuilt around one goal: **anyone should be able to solder and assemble this.** - -**Building one? Start here → [BUILD_GUIDE.md](https://github.com/engmung/Patternflow/blob/v3.0.0/BUILD_GUIDE.md)** — the PCB soldering is covered by a [full video walkthrough](https://youtu.be/NZCjMBCsDAc). - -## Highlights - -### The board: two power inputs, zero SMD -- **Screw-terminal power** — strip a USB cable, clamp two wires into the 2-pin terminal on the back of the board. No soldering iron needed for the power input. - > ⏸️ **Update (2026-07):** the board also carries a USB-C footprint (`USB1`), but **that input is on hold** — a USB-C-powered board ran fine for 20–30+ minutes and then smoked at a connector pin, destroying the receptacle and power path ([#221](https://github.com/engmung/Patternflow/issues/221)). Leave `USB1`/`R1`/`R2` unpopulated and power the board through `J4` for now. See [BUILD_GUIDE §2](https://github.com/engmung/Patternflow/blob/main/BUILD_GUIDE.md#2-power-input--use-the-screw-terminal). -- **No SMD passives at all** — every part you solder is through-hole. The whole board is beginner-solderable. -- Fabricated, assembled, and verified on the screw-terminal input. - -### The enclosure: snap-fit, printable on a 256 mm bed -- Folders named by **printer bed size**: `bed_256mm/encloser.stl` (one STL, ~10 h on a P1S) or `bed_330mm/` one-piece for large-format printers. Knobs print separately in black — white body, black knobs. -- **Bambu printers: one click** — the case is on [MakerWorld](https://makerworld.com/ko/models/3072492-patternflow-open-source-led-synthesizer-case#profileId-3459015) with tuned print profiles; the same 4-plate project is attached below as `patternflow_v3.3mf`. -- **Snap-fit back panel**, **two wall-mount holes**, and recesses for the LED panel's alignment bumps — **the nipper-trimming step that's been with us since v1.0 is gone** ([#19](https://github.com/engmung/Patternflow/issues/19)). -- Using a different LED panel than the recommended one? `bed_256mm/for_other_panels/` has an adjustable-mount variant ([#169](https://github.com/engmung/Patternflow/issues/169)). - -### The BOM: every part by part number -- [`hardware/bom/bom_v3.0.csv`](https://github.com/engmung/Patternflow/blob/v3.0.0/hardware/bom/bom_v3.0.csv) — machine-readable, every part specified by MPN, sourcing notes included. Order from Mouser/DigiKey/LCSC or find the same parts anywhere. - -### The guide: watch, don't decipher -- [BUILD_GUIDE.md](https://github.com/engmung/Patternflow/blob/v3.0.0/BUILD_GUIDE.md) rewritten for v3.0 — **two full video walkthroughs** ([PCB soldering](https://youtu.be/NZCjMBCsDAc) and [assembly to first power-on](https://youtu.be/J9C9bZgkNKs)), photo-documented printing/bonding steps, a collapsible netlist-derived pin reference, and a dedicated OSC/Ableton build path. -- PCB ordering routed through the [PCBWay shared project](https://www.pcbway.com/project/shareproject/Patternflow_An_LED_synthesizer_776d796c.html) (no Gerber upload needed) — any fab works with the attached zip too. - -### Firmware -- **One image for every board generation** — v2.x and v3.0 share an identical pin map, so the [browser flasher](https://patternflow.work) just works on both. No board selection to get wrong. -- **Bigger preset library, sturdier engine** — thirteen new presets from the June–July daily batch, three fresh custom-slot patterns, and a new PSRAM-first allocator (`PFMem`) so a fully loaded pattern image can't starve the Wi-Fi/DMA DRAM budget. - -## Breaking changes - -- **v2.x boards and v3.0 cases are not interchangeable** (and vice versa) — different board size, different port positions. -- Already own a v2.x build? Everything you need stays bundled at the [v2.1.0 release](https://github.com/engmung/Patternflow/releases/tag/v2.1.0), and the v2 guide lives on as [`BUILD_GUIDE_v2.md`](https://github.com/engmung/Patternflow/blob/v3.0.0/BUILD_GUIDE_v2.md). Bonus for v2 owners: `hardware/case/legacy_v2/encloser_v2.1.stl` is a newly verified snap-fit case for the v2.1 board — no gluing, no trimming. - -## Assets - -- `patternflow_v3_case_stl_pack.zip` — all v3 case STLs (256 mm kit, 330 mm one-piece, adjustable-mount variant, knob plates) -- `patternflow_v3.3mf` — Bambu Studio project (4 pre-arranged plates with print settings; same as the [MakerWorld listing](https://makerworld.com/ko/models/3072492-patternflow-open-source-led-synthesizer-case#profileId-3459015)) -- `patternflow_v3.0_gerber.zip` — the verified board -- `patternflow_v3_firmware.zip` — prebuilt ESP32-S3 binaries (the browser-flasher image) with esptool offsets in the included README -- `patternflow_case.blend` — Blender source for every printed part (stored in Git LFS; attached here so *Download ZIP* users get the real file) - -Full details in the [CHANGELOG](https://github.com/engmung/Patternflow/blob/v3.0.0/CHANGELOG.md). - ---- - -Firmware & web: MIT · Hardware & designs: CC-BY-SA 4.0 +## [3.0.0] - 2026-07 + +### Added +- **v3.0 board promoted to the recommended revision** (`hardware/pcb/gerber/patternflow_v3.0_gerber.zip`). Fabricated, assembled, and verified ([#114](https://github.com/engmung/Patternflow/issues/114)): hybrid power input — USB-C (`USB1` + 5.1k CC pull-downs) or a back-side 2-pin screw terminal (`J4`) as the beginner bypass — all-through-hole, no SMD passives. **Not size-compatible with v2.x cases (and vice versa).** +- **Machine-readable BOM** (`hardware/bom/bom_v3.0.csv`): every part by manufacturer part number, per-unit quantities, assembly-critical notes. The LED panel remains an AliExpress link (its mounting-screw positions match the case). +- **Firmware preset library + PFMem.** Curated patterns now live in `firmware/patternflow/presets/` — thirteen new presets (the June–July daily batch) join the library, and the three custom slots ship with fresh patterns. New `src/core_mem.h` (**PFMem**) allocates framebuffer-sized pattern state PSRAM-first, so a fully loaded preset image can no longer boot-loop the device on DRAM pressure. The browser-flasher image carries all of it and self-reports 3.0.0. +- **v3 build guide** — now the main `BUILD_GUIDE.md`: [PCB soldering](https://youtu.be/NZCjMBCsDAc) and [assembly-to-first-power-on](https://youtu.be/J9C9bZgkNKs) each covered by a full video walkthrough, photo-documented print/case/wiring steps, netlist-derived pin reference. The v2 guide moved to `BUILD_GUIDE_v2.md` for existing v2.x builds. + +### Changed +- **`hardware/case/` reorganized by printer bed size**: `bed_256mm/encloser.stl` (the standard print — body frame, back panels, and LED-panel mount in one STL, ~10 h) and `bed_330mm/` (one-piece snap-fit); knobs print separately from `knobs/` in black, required for every build. `bed_256mm/for_other_panels/` holds the community divided variant with an adjustable LED-panel mount ([#169](https://github.com/engmung/Patternflow/issues/169)-tested) for panels other than the BOM-linked one. All v2.x-board cases moved to `case/legacy_v2/`. The snap-fit design adds two wall-mount holes and recesses for the panel's alignment bumps — no more nipper trimming ([#19](https://github.com/engmung/Patternflow/issues/19)). +- **Browser-flasher firmware refreshed** to the latest build — one image, compatible with both v2.x and v3.0 boards. The `patternflow_v2_firmware.zip` release asset on v2.1.0 was updated to match. +- The defective `easyfit` plate variant was removed ([#154](https://github.com/engmung/Patternflow/issues/154)); it remains at the v2.1.0 tag. +- Encoder shaft guidance neutralized: 15mm and 20mm are functionally identical; the BOM reference part (Bourns PEC11R-4220F-S0024) is 20mm. + +### Fixed +- C11 schematic value typo (`1000uF/1.6V` → `1000uF/16V`). +- `bed_256mm/encloser.stl` briefly carried the **v2.1** cut of the enclosure; it now holds the v3.0 cut. The v2.1 version — print & assembly verified — moved to `legacy_v2/encloser_v2.1.stl` and is the recommended v2 print (replacing both the untested `divided_v2.1.stl` and the glued plates as the default v2 path). diff --git a/docs/releases/v3.1.0-notes.md b/docs/releases/v3.1.0-notes.md new file mode 100644 index 000000000..ef7271f95 --- /dev/null +++ b/docs/releases/v3.1.0-notes.md @@ -0,0 +1,48 @@ +# Patternflow v3.1.0 — the browser does everything now + +> **Historical (2026-07).** Long-form notes for one release, kept as written. Release notes since 3.2 live on [GitHub Releases](https://github.com/engmung/Patternflow/releases); `CHANGELOG.md` at the root is the complete record. + +**Hardware unchanged** — the v3.0 board and case carry over exactly as they are. This release is the software half of the instrument catching up: a pattern community, firmware builds that run on a server and flash from the browser, wireless updates, and one important piece of safety guidance. + +**Building one? Start here → [BUILD_GUIDE.md](https://github.com/engmung/Patternflow/blob/v3.1.0/BUILD_GUIDE.md)** — about 30 minutes of (deliberately easy, all through-hole) soldering, 30 minutes of assembly, ~10 hours of printer time, around US$100 in parts. + +## ⚠️ Safety first: power through the screw terminal only + +The v3.0 board's USB-C power input is **withdrawn from service** ([#221](https://github.com/engmung/Patternflow/issues/221)): a USB-C-powered board ran fine for 20–30+ minutes, then smoked at a connector pin. The failure is *delayed* — "it seems to work" proves nothing. Until the redesign passes: + +- Power the board through **`J4`, the 2-pin screw terminal** (strip a USB cable, screw the wires in). +- **Leave `USB1`, `R1`, and `R2` unpopulated.** + +[BUILD_GUIDE §2](https://github.com/engmung/Patternflow/blob/v3.1.0/BUILD_GUIDE.md#2-power-input--use-the-screw-terminal) has the full story and photos. + +## Highlights + +### The Community +Patterns now have a home: **[community.patternflow.work](https://community.patternflow.work/community)**. A live feed you can play — hover any card and the pattern runs, scroll-wheel turns its knobs — with detail pages where you edit the code in place. Publish from Pattern Lab, and remixes record their fork lineage so you can see what grew out of what. Patterns that ship a hardware-verified `.h` are flagged and filterable for when you want something to flash right now. No account needed to browse or edit; username + password only to publish. + +### Firmware builds, from the browser +The toolchain is gone ([#230](https://github.com/engmung/Patternflow/issues/230)): a build server compiles a complete firmware image containing your pattern — about 30 seconds — and the browser writes it to the board over USB (desktop Chrome/Edge). No IDE, no board package, no registry editing. And after the first flash sets up Wi-Fi, **Send over Wi-Fi** ([#232](https://github.com/engmung/Patternflow/issues/232)) pushes the next builds to the device with no cable at all. The Arduino IDE is now only for firmware feature development or targeting a different matrix resolution. + +### Pattern Lab, the studio +Pattern Lab grew into the professional editing tool: batch AI generation in-app (bring your own free Gemini key), color ramps, custom frames carried end to end (`// @matrix` travels from the lab through the community sandbox into the firmware conversion), session autosave, and a mobile-first Copy prompt / Paste response flow. Unused features were cut so the tool stays sharp. + +### The website +The pattern section was rebuilt around how Patternflow actually works now — try 42 presets one tap away, make your own with AI, then Pattern Lab and the Community carry it to hardware. The build section answers the two questions every builder asks first (**~US$100, ~1 hour of hands-on work**) and routes ordering straight through the [PCBWay shared project](https://www.pcbway.com/project/shareproject/Patternflow_An_LED_synthesizer_776d796c.html) and the [MakerWorld case listing](https://makerworld.com/en/models/3072492-patternflow-open-source-led-synthesizer-case#profileId-3459015). A new interactive [project roadmap](https://patternflow.work/roadmap) shows the whole plan on one map. + +## Hardware note + +No new board, no new case. `patternflow_v3.0_gerber.zip` remains the board to order, and the v3 case files are unchanged — grab either from the [v3.0.0 release](https://github.com/engmung/Patternflow/releases/tag/v3.0.0) or the attachments below. On v2.x hardware? Everything stays bundled at [v2.1.0](https://github.com/engmung/Patternflow/releases/tag/v2.1.0). + +## Assets + +- `patternflow_v3_firmware.zip` — prebuilt ESP32-S3 binaries (the browser-flasher image: 34 presets + 3 custom slots) with esptool offsets in the included README +- `patternflow_v3.0_gerber.zip` — the verified board (unchanged from v3.0.0) +- `patternflow_v3_case_stl_pack.zip` — all v3 case STLs (unchanged from v3.0.0) +- `patternflow_v3.3mf` — Bambu Studio project (4 pre-arranged plates; same as the MakerWorld listing) +- `patternflow_case.blend` — Blender source for every printed part (Git LFS; attached so *Download ZIP* users get the real file) + +Full details in the [CHANGELOG](https://github.com/engmung/Patternflow/blob/v3.1.0/CHANGELOG.md). + +--- + +Firmware & web: MIT · Hardware & designs: CC-BY-SA 4.0 diff --git a/docs/releases/v3.1.0.md b/docs/releases/v3.1.0.md index ef7271f95..553028275 100644 --- a/docs/releases/v3.1.0.md +++ b/docs/releases/v3.1.0.md @@ -1,48 +1,26 @@ -# Patternflow v3.1.0 — the browser does everything now - -> **Historical (2026-07).** Long-form notes for one release, kept as written. Release notes since 3.2 live on [GitHub Releases](https://github.com/engmung/Patternflow/releases); `CHANGELOG.md` at the root is the complete record. - -**Hardware unchanged** — the v3.0 board and case carry over exactly as they are. This release is the software half of the instrument catching up: a pattern community, firmware builds that run on a server and flash from the browser, wireless updates, and one important piece of safety guidance. - -**Building one? Start here → [BUILD_GUIDE.md](https://github.com/engmung/Patternflow/blob/v3.1.0/BUILD_GUIDE.md)** — about 30 minutes of (deliberately easy, all through-hole) soldering, 30 minutes of assembly, ~10 hours of printer time, around US$100 in parts. - -## ⚠️ Safety first: power through the screw terminal only - -The v3.0 board's USB-C power input is **withdrawn from service** ([#221](https://github.com/engmung/Patternflow/issues/221)): a USB-C-powered board ran fine for 20–30+ minutes, then smoked at a connector pin. The failure is *delayed* — "it seems to work" proves nothing. Until the redesign passes: - -- Power the board through **`J4`, the 2-pin screw terminal** (strip a USB cable, screw the wires in). -- **Leave `USB1`, `R1`, and `R2` unpopulated.** - -[BUILD_GUIDE §2](https://github.com/engmung/Patternflow/blob/v3.1.0/BUILD_GUIDE.md#2-power-input--use-the-screw-terminal) has the full story and photos. - -## Highlights - -### The Community -Patterns now have a home: **[community.patternflow.work](https://community.patternflow.work/community)**. A live feed you can play — hover any card and the pattern runs, scroll-wheel turns its knobs — with detail pages where you edit the code in place. Publish from Pattern Lab, and remixes record their fork lineage so you can see what grew out of what. Patterns that ship a hardware-verified `.h` are flagged and filterable for when you want something to flash right now. No account needed to browse or edit; username + password only to publish. - -### Firmware builds, from the browser -The toolchain is gone ([#230](https://github.com/engmung/Patternflow/issues/230)): a build server compiles a complete firmware image containing your pattern — about 30 seconds — and the browser writes it to the board over USB (desktop Chrome/Edge). No IDE, no board package, no registry editing. And after the first flash sets up Wi-Fi, **Send over Wi-Fi** ([#232](https://github.com/engmung/Patternflow/issues/232)) pushes the next builds to the device with no cable at all. The Arduino IDE is now only for firmware feature development or targeting a different matrix resolution. - -### Pattern Lab, the studio -Pattern Lab grew into the professional editing tool: batch AI generation in-app (bring your own free Gemini key), color ramps, custom frames carried end to end (`// @matrix` travels from the lab through the community sandbox into the firmware conversion), session autosave, and a mobile-first Copy prompt / Paste response flow. Unused features were cut so the tool stays sharp. - -### The website -The pattern section was rebuilt around how Patternflow actually works now — try 42 presets one tap away, make your own with AI, then Pattern Lab and the Community carry it to hardware. The build section answers the two questions every builder asks first (**~US$100, ~1 hour of hands-on work**) and routes ordering straight through the [PCBWay shared project](https://www.pcbway.com/project/shareproject/Patternflow_An_LED_synthesizer_776d796c.html) and the [MakerWorld case listing](https://makerworld.com/en/models/3072492-patternflow-open-source-led-synthesizer-case#profileId-3459015). A new interactive [project roadmap](https://patternflow.work/roadmap) shows the whole plan on one map. - -## Hardware note - -No new board, no new case. `patternflow_v3.0_gerber.zip` remains the board to order, and the v3 case files are unchanged — grab either from the [v3.0.0 release](https://github.com/engmung/Patternflow/releases/tag/v3.0.0) or the attachments below. On v2.x hardware? Everything stays bundled at [v2.1.0](https://github.com/engmung/Patternflow/releases/tag/v2.1.0). - -## Assets - -- `patternflow_v3_firmware.zip` — prebuilt ESP32-S3 binaries (the browser-flasher image: 34 presets + 3 custom slots) with esptool offsets in the included README -- `patternflow_v3.0_gerber.zip` — the verified board (unchanged from v3.0.0) -- `patternflow_v3_case_stl_pack.zip` — all v3 case STLs (unchanged from v3.0.0) -- `patternflow_v3.3mf` — Bambu Studio project (4 pre-arranged plates; same as the MakerWorld listing) -- `patternflow_case.blend` — Blender source for every printed part (Git LFS; attached so *Download ZIP* users get the real file) - -Full details in the [CHANGELOG](https://github.com/engmung/Patternflow/blob/v3.1.0/CHANGELOG.md). - ---- - -Firmware & web: MIT · Hardware & designs: CC-BY-SA 4.0 +## [3.1.0] - 2026-07 + +A software, safety-guidance, and docs release — **hardware unchanged**: the v3.0 board and case carry over as-is (the Gerber keeps its `v3.0` name). + +### Safety +- **USB-C power input withdrawn from service** ([#221](https://github.com/engmung/Patternflow/issues/221)): a USB-C-powered board ran fine for 20–30+ minutes, then smoked at a connector pin, destroying the receptacle and power path. Power through the `J4` screw terminal only and leave `USB1`/`R1`/`R2` unpopulated until the redesign passes. Guide §2, the assembly map, and the v3.0.0 release notes all carry the hold now. +- The USB-C solder-joint comparison photos returned to the guide (§5) — the safety reference stays visible while soldering, video or not. + +### Added +- **Pattern Community** ([community.patternflow.work](https://community.patternflow.work/community)): a paged feed with hover-to-play live previews and scroll-wheel knob control, detail pages with in-place code editing and live knobs, publishing with recorded fork lineage, likes and sorting, comments, user profiles, and a Discussions text board ([#217](https://github.com/engmung/Patternflow/pull/217)). Patterns shipping a hardware-verified `.h` are flagged and filterable; licence headers (CC-BY-SA, inbound = outbound) are baked into the shared source. Browse and edit without an account — username + password only to publish. A self-hosting deployment guide is included. +- **Browser firmware builds** ([#230](https://github.com/engmung/Patternflow/issues/230)): a build queue + worker compiles a complete firmware image containing your pattern (~30 s) and the browser flashes it over Web Serial — no IDE, no board package, no registry editing. OSC ships enabled by default ([#231](https://github.com/engmung/Patternflow/issues/231)). +- **Send over Wi-Fi** ([#232](https://github.com/engmung/Patternflow/issues/232)): finished builds hand off to the device's own update page — after the first USB flash (which also sets up Wi-Fi), new patterns go over the air. +- **Pattern frames, end to end**: a pattern declares the matrix it was composed for (`// @matrix`) and that single fact travels through Pattern Lab, the community sandbox, and the firmware conversion — portrait and custom resolutions render correctly everywhere, and Pattern Lab accepts any frame typed directly. +- **Pattern Lab**: in-app batch AI generation (bring-your-own Gemini key), session autosave to localStorage, and a mobile Copy prompt / Paste response action bar. +- **Interactive project roadmap** at [/roadmap](https://patternflow.work/roadmap): the whole project on one zoomable map, with a Korean toggle and a media/press lane. +- **Build map**: every pin gets its own link, collaboration credits, a photo lightbox, and a phone layout. +- **MakerWorld listing** for the case, with tuned print profiles; the same 4-plate Bambu Studio project ships in the repo as `hardware/case/patternflow_v3.3mf`. Linked from the build guide §4 and the case README. + +### Changed +- **Landing pattern section rebuilt** around the current flow: a preset jump bar above the editor (42 presets, numbered), Pattern Lab promoted beside Community, the wired first-flash folded into the how-to steps with an accented Flash button, and Pattern Lab / Community vision cards replacing the outdated Arduino/Discord guide. The build section gained a cost & time reality check (~US$100 in parts · ~1 h hands-on · first-build friendly) and a spotlighted Build Guide v3.0.0 card with PCBWay / MakerWorld / release-bundle shortcuts. +- **Pattern Lab decluttered**: snapshots, sweep, the Experiment layer stack, Copy JSON, pause, and the Discord share flow removed; Share to Community and Build firmware remain. Mobile UI reworked across Pattern Lab and the community feed (full-width knob rows, dense static-thumbnail feed on phones). +- **Docs caught up with the product**: the main README (browser build server, Wi-Fi updates, 42 Live-Editor / 34 firmware preset counts, Pattern Lab & Community sections), the assembly map (v2/v3 distinction with release bundles, corrected power guidance), and the BUILD_GUIDE intro (no soldering experience needed; ~30 min soldering + ~30 min assembly). + +### Fixed +- Community feed thumbnails rendered at the wrong frame size (a shadowed loop variable in the sandbox's still renderer) — non-default-frame patterns now preview correctly. +- Build service: `patternflow_secrets.h` is synced into the sketch directory on slot updates, and board build options are pinned so server builds match the IDE. diff --git a/docs/releases/v3.10.0.md b/docs/releases/v3.10.0.md new file mode 100644 index 000000000..79c372ec5 --- /dev/null +++ b/docs/releases/v3.10.0.md @@ -0,0 +1,19 @@ +## [3.10.0] - 2026-09-08 + +### Improved — Audio MIDI +- Network MIDI socket service runs independently of pattern rendering, with ordered, bounded event queues and PSRAM payload storage. Incoming controls still apply on the render task. Per-knob receive counters, queue overflow and RTP/parser diagnostics expose where traffic is lost. +- Absolute-bus compatibility clicks no longer appear as physical encoder MIDI output, preventing incoming automation from being reflected as outbound CCs. + +### Improved — firmware core +- **Pattern storage mutations wait outside the render loop.** Conditional frame-boundary requests retry until a loader finishes; uploads/deletes then hold new activations until the catalog is rebuilt. Selections made during the hold retain their file identity across reordering. Restoration is asynchronous, and interrupted uploads remove their partial file before resuming. A reusable loader worker reserves its stack at boot, avoiding repeated stack allocation and a synchronous fallback under fragmentation; that stack is 8 KB, against a measured 2,980 B high-water across the heaviest catalog modules. +- **Module allocations are priced once, for the whole module, before anything is allocated.** The service reserve now *places* data and *prices* code: a module's executable sections are summed from its ELF section headers and admitted against `free - reserve` in a single decision, so the verdict cannot depend on the order sections are walked. Executable sections are placed first, because they are the only part with no PSRAM to fall back on; data can lose its placement to PSRAM but can never fail a load on its own. Nothing allocates in order to discover whether it was allowed to. Dynamic module allocations retain the existing 16-slot lifetime model, with a configurable 4 MiB byte limit and usage/refusal counters. `/api/status` publishes both sides of the comparison as `moduleMemory.budget` and `codeBytes`. Pattern code and the frozen ABI are unchanged. +- **Loading work has a defined cadence and the whole loop is observable.** A loading loop has an 8 ms minimum period; unchanged running-mode thumbnails refresh at most every 64 ms. Status gains whole-loop timing, short mutation timing, module allocation/worker-stack diagnostics and network-maintenance counters. Normal pattern rendering remains uncapped. +- **Slow HTTP traffic leaves opportunities for Wi-Fi/name maintenance.** Page bodies use nonblocking socket writes under the existing progress and total-time budgets; request-parser waits and conditional loop waits also service a bounded maintenance hook. This adds no network worker or stack, and does not re-enter HTTP handling. +- **The HUB75 blit now emits word accesses for its paired columns.** Its `memcpy` preserved aliasing rules but hid the DMA buffer's alignment from Xtensa GCC, which generated byte loads/stores and a stack temporary. Supplying the proven alignment retains the same colour/control bits and reduces measured `presentUs` from about **6.86 to 5.90 ms (14%)**, without another framebuffer. An odd-width buffer uses the scalar path. The host suite compares over 30 million DMA words, including FIFO order and depths 2–8. Bench conditions and frame-time results: [`docs/investigations/2026-09-firmware-runtime.md`](../../docs/investigations/2026-09-firmware-runtime.md). +- **Thumbnail disk I/O leaves the frame loop.** One immutable PSRAM snapshot is handed to the existing network task; completed reads become visible at a frame boundary. No additional task stack, and optional thumbnails never fall back to scarce internal RAM. Deletion/format generations reject stale results; a new capture wins over a pending read. `/api/status` gains `thumbs` counters and separate capture/I/O timing. +- **Wi-Fi gives a brief dropout time to recover on the working SSID** before cycling saved alternatives. DHCP address changes also trigger the connection hook. Name-service startup and partial registration failures retry on the network task, preserving services registered by features; duplicate announcements for one connect edge are coalesced. The MIDI feature retries its own service registration. Status gains `network` diagnostics and `POST /api/wifi/reconnect` requests a delayed reconnect without rebooting or rewriting credentials. + +### Fixed — module loader +- **A module could be refused while the RAM it needed was free.** The service reserve was applied as a veto rather than a placement rule, and for executable allocations the check ignored the requested size entirely, so a 2 KB `.text` was refused exactly as hard as a 200 KB one whenever byte-addressable internal free dipped under the reserve. Combined with a persistent 12 KB loader stack, the module's share of internal RAM fell to about 3.7 KB against roughly 700 B of boot-to-boot variance — heavy patterns started intermittently, and which ones changed with every boot. Pricing the whole module up front and returning 4 KB of loader stack raises that share to about 7.8 KB, enough for the heaviest module in the shipped Basics pack (5,364 B of executable image) to clear it by 2.4 KB. An admission failure now counts as a refusal so the loader's existing retry absorbs a transient dip caused by an in-flight HTTP response. +- **`api->alloc()` is zeroed again.** The frozen ABI documents it as a PSRAM-preferred *zeroed* allocation, and the runtime rework dropped the fill, so a pattern that allocated a trail map or accumulator and read it before writing was reading whatever the previous module left behind. +- Reject overflowing allocation/relocation ranges and unterminated ELF string-table entries; share header validation between upload and load. Copy unresolved-symbol diagnostics before freeing the image they point into. The frozen module ABI is unchanged; native regression tests also accept all 33 shipped Basics modules. diff --git a/docs/releases/v3.10.1.md b/docs/releases/v3.10.1.md new file mode 100644 index 000000000..9eb3c05f0 --- /dev/null +++ b/docs/releases/v3.10.1.md @@ -0,0 +1,227 @@ +## [3.10.1] - 2026-09-10 + +### Fixed — the pattern SDK + +Two helpers that every pattern compiles into itself did not do what their +names said, and a third gained a variant it was missing. The two fixes change what a pattern draws, and because `abi/pf_module.h` +*includes* these files rather than calling into the host, the change reaches only +**newly built** `.pfm` modules: an installed module and a rebuild of its own source +will render differently on the same firmware. Rebuild a module to pick these up. + +- **`PFColor::sampleRampLerp` is new; `sampleRamp` is unchanged.** `sampleRamp` + holds the last stop at or below `t`, giving flat bands with hard edges. That + reads like a gradient function that forgot to interpolate, and it is not one — + Origin is built on it and its stepped colour is deliberate. Patterns that want + a gradient now call `sampleRampLerp`, which interpolates between the bracketing + stops and costs one soft-float division per sample; the C++ conversion prompt + names both and says which is which. +- **`PFColor::hsvToRgb` rounds.** Each channel was `(uint8_t)(f * 255.0f)` — + truncation, no `+ 0.5f` — so the function sat half a level dark on every channel, + always in the same direction: a mean of **−0.470 LSB** swept over the HSV cube. + `buildPowLUT`, four functions below in the same header, has always rounded. +- **`PFNoise::cellHash`'s seed decorrelates.** It was added to `gx` before the same + hash, making `cellHash(x, y, s)` identical to `cellHash(x + s, y)` at **every one + of 262,144 lattice points** — two layers seeded 0 and 1 were one field and a copy + of it slid a cell sideways. The cost is visible in `preset_0713`, which draws seeds + 7 through 13 for each firefly's speed, phase and brightness and got one sequence + offset by one index each time, so every "independent" parameter was the same + stream in disguise. The seed now passes through the permutation table, because it + cannot enter by addition anywhere in that expression: adding it to the final index + is the same as adding it to `gy`, and adding it to both coordinates merely + translates diagonally — measured against every shift within ±4 cells, that obvious + repair still correlates **1.0000** with the unseeded field. What shipped correlates + **0.0128**. Two extra reads of a 512-byte table that is already hot. + The two-argument overload is untouched, so `valueNoise2D`, `perlin2D` and + `fractal2D` render exactly as before. + +### Improved — pattern authoring + +- **`floorf`, `fminf` and `fmaxf` stop being calls inside the SDK's own helpers.** + On this chip each one is a call into libm, not an instruction. `PFMath` gains + `ifloor`, `floorF`, `clamp` and `clamp01`; `fract`, `hsvToRgb` and the noise + lattice now use them, with results identical bit for bit. Measured on a rebuilt + module on the panel: 1.2% faster. The C++ conversion prompt now tells new + patterns to use them — and stops telling them that `sqrtf` is cheap or that + `perlin2D` spans −1..1 (it runs to about ±1.51). Installed `.pfm` modules are + unchanged; the helpers reach a module when it is next built. + +### Fixed — the panel + +- **Raising a code no longer lowers the light.** A bit plane's contribution is + its OE window times the number of times the DMA descriptor chain repeats it, + and the repeats already carry one factor of two per plane. The window had to + supply the rest, and it was derived by shifting — which can only halve, and + could not express the ratios the repeat counts need. Worse, the plane index + was mapped through `(2 * depth - colouridx) % depth`, which sends plane 0 — + the least significant — to the branch that hands it the same full-length + window the most significant plane gets. + + Measured at depth 8: the planes delivered `125, 31, 126, 500, 1000, 2000, + 4000, 8000` where binary wants `62, 125, 250, 500, …`. Plane 0 twice what it + should be, plane 1 a quarter, plane 2 a half. Swept over all 256 codes and + three channels that is **26 places where raising the code lowers the light**, + two of them by half, back to back — and 44 of 256 levels whose channel ratio + departs from the configured white balance. Seen on a panel: level 14 reads red + where level 13 is neutral grey. + + The window is computed now rather than approached: `u · 2^d / repeats(d)`. + Inversions go to **zero** and the ratio departures to 11, at every brightness. + Full-scale luminance moves about 1%, so the eye-converged brightness and + white-balance constants keep their meaning. No per-pixel cost — this runs once + when brightness changes. + + The arithmetic is now a pure function, `pfOEWindowPixels()`, lifted verbatim + into a new host suite (`check_oe.py`). Nothing had ever tested it, because + reaching `setBrightnessOE` needs a live DMA allocation — but the defect was + never in writing the buffer. + +- **The blit refuses a colour depth it cannot represent.** Its plane fields pack + five planes into each of two words, so ten is the ceiling; above that the + shifts lost bits and then became undefined, while the configuration allowed up + to twelve. Nothing ships above eight, so this was latent — and the host suite + swept 2–8, so nothing would have found it. It now sweeps 2–10. + +- **Dark greys stop coming out coloured.** Only the top bits of the 16-bit CIE + value reach a bit plane, and the rest was dropped rather than rounded. Truncation + is biased downward by up to half a plane step everywhere, which is invisible in + the bright half and is the entire signal in the dark end: eight input levels + emitted nothing at all, and neighbouring neutral greys came out as *opposite* + colour casts, because each channel crossed its threshold at a different code. + Seen on the panel: a neutral level 9 read blue, 17 read cyan and 18 read red. + Rounding to nearest is one line in a table built once per depth change — no + per-pixel cost at all. Computed over all 256 levels and three channels: dead + levels 8 → 4, level 9 becomes neutral, 17 and 18 both become the same neutral + grey, and mid-tone ratios land closer to the intended white balance (level 64 + goes from 10/11/10 to 10/11/11). Full-scale white moves by one unit on blue + only, so the hand-converged `LED_WB_*` constants keep their meaning and do not + need re-converging. + + Because the blit is firmware rather than part of each module's compiled copy, + this is the one fix in this release that reaches **already-installed `.pfm` + modules** — every pattern gets it without being rebuilt. + + What this is *not*: the panel's response was already monotone. Claims that + raising a code could lower the light were tested directly — alternating levels + 63/64, 107/108 and 127/128 against a control pair that pulses obviously — and + nothing was visible at any of them. There are no inversions to fix. + +### Fixed — audio in + +- **A panel with no microphone no longer takes the knobs.** The Hann window used + the symmetric denominator (`N - 1`) where a stream analysed hop after hop needs + the periodic one (`N`). Only the periodic window has a DFT of exactly three + bins, so only it keeps a constant input out of every other band — and the + dead-rail check downstream is precisely a test for "constant input, nothing in + the bands". Computed over the shipped band edges with a flat rail, the + symmetric window gave 0.0666 / 1.8e-3 / 1.3e-4 / 1.3e-5, three of the four + above the 1e-4 threshold; the periodic one gives ~1e-13 in all four. So the + check returned false for every dead rail there has ever been, an audio-edition + panel with the mic switched on but nothing wired claimed all four lanes and + pinned them, and a hand on a knob sprang back. The measurement recorded in that + function's own comment was taken before there was a window at all, which is how + adding one broke it without touching it. +- **A backlogged microphone no longer colours the lanes for seconds.** The drain + loop keeps only the newest hop, so after it ran the window was built from two + pieces of audio that were not adjacent, spliced at index 256 — the exact centre, + where the Hann term peaks at 2.0 and attenuates the discontinuity by nothing. + Reading alternately into two hop buffers keeps the hop that actually precedes + the newest one. The drain itself was right and is unchanged; only the seam was + wrong. + +### Fixed — MIDI + +- **A stuck note can be cleared, and stops disabling the panel's own button.** + Held-note state had exactly one clearing edge — a note-off for that note — on + the one input path that can lose packets. Three ways out were missing: the + channel-mode messages (All Sound Off, Reset All Controllers, All Notes Off) fell + through the CC handler and did nothing, so the control a person reaches for when + a note sticks was inert; turning MIDI off at runtime discarded the note-off + while `fillInput` kept asserting the button, because it was the one handler with + no runtime gate; and an RTP peer that vanished took the release with it. All + three now clear. This matters more than it sounds: `observeFrame` reads + `btnHeld && !noteHeld` to decide a press was physical, so a stuck MIDI note also + silenced the encoder button under it. + +### Fixed — the parameter bus + +- **How far a legacy pattern travels no longer depends on how finely the sender + chopped the move.** An absolute write reaches a delta-only pattern as clicks at + ten bus units each, and the rule "never fewer than one for a change" turned a + smooth ten-second ease into one click per update: 830 of them at 83 fps where + the travel means 100, and half that at 41 fps. The remainder is carried now, so + the total is the travel and the frame rate is out of it — which is what the show + player already claimed of itself. +- **`dN` on an unheld channel works.** `applyRemoteDelta` accepted a click on any + channel but only the held branch drained the queue, so a delta sent to a channel + nobody was holding did nothing visible and then arrived in full the moment + something held it, or was wiped by the next release. It is also bounded now: + `d1=999999999` was accepted and handed to a pattern as that many clicks. +- **`POST /api/params?rN=1` releases a channel.** The bus could be written over + HTTP but not let go of over HTTP — the only ways back were a hand on that + encoder, or MQTT and the show player, which the default, audio and clock + editions do not carry. And since holding a channel also clears the audio lane + under it, one console slider could silence a mic lane with no way to undo it + from the same console. + +### Fixed — patterns and storage + +- **Re-uploading a pattern updates its picture.** Both upload paths and the + library pull invalidated the sidecar cache under a slug and not the thumbnail, + so editing a pattern in the Lab and sending it back under the same name left the + previous version's frame on the volume, and SELECT drew it under the new name. + It survived reboots. +- **A stalled frame no longer makes the picture jump.** `dt` was an unbounded + `millis()` delta, and installing a pattern, an NVS commit or a thumbnail write + all produce frames far longer than a nominal one — measured at 273,949 µs + against 12,000 µs on an ordinary upload session. Every pattern integrates + against that number, so it moved everything a quarter of a second in one step. + Bounded at 100 ms, at the single place it is produced. + +### Fixed — firmware core + +- **An upload that stalled could panic the board.** The wait loop in the request + parser's `_uploadReadByte()` was the one of three that never yielded: upstream + writes it brace-less, so inserting the maintenance poll above the `delay(2)` + without adding braces made the poll the entire body and left the delay outside the + loop. It runs on the network task — Core 0, priority 1 — where a loop that never + blocks starves the idle task, and this SDK configuration watches that idle task + with a panic at five seconds. Installing a pattern is precisely when the link is + under load. +- **A failed settings write no longer deletes a good pattern.** Two paths cleared + their own retry flag *before* the write it guarded. The damaging one is the boot + latch: it cleared its flag and then wrote `pat_trying = false`, so a write that + failed left the latch set on disk with nothing left to clear it — and the next + boot reads a set latch as "the last boot died with this pattern resident" and + forgets a pattern that was never at fault. Both now clear only once the write is + confirmed, the pattern save reuses its existing 3 s debounce as a retry backoff, + and the namespace open is checked. `/api/status` gains `nvs.usable` and + `nvs.failures`, because the whole difficulty with this failure is that its symptom + arrives a boot later. The brightness, power and Wi-Fi writes are unchanged. + +### Added — checks + +- **`check_math.py`** pins what `core_math.h`, `core_color.h` and `core_noise.h` + compute. `abi/pf_module.h` includes those three into every `.pfm`, so they are the + published pattern SDK — and nothing here had ever run a number through them. Their + accuracy lived in doc-comments, two of which were wrong about their own function + (`fastAtan2` is 7.4× tighter than it claims; `approxLength` is 6.8% where the + comment says ~5%). 19 rows, each a **band** rather than a ceiling, so an accuracy + change fails in either direction and has to be re-pinned deliberately. Note what + `abi.sums` cannot do here: it hashes the three files in `abi/`, so it passes + unmoved while the behaviour those files include changes underneath it. +- **`check_footprint.py`** reads each edition's elf and pins its static internal RAM. + CI built four images and then printed their flash sizes — the one resource that is + not scarce. Internal DRAM is, and the module code budget is its residual. Measured + by the *address* of the linker's own `.dram0.heap_start` marker rather than a sum + of section names, which would quietly measure less if a toolchain renamed them. +- **The shipped modules are priced, not just validated.** The ELF suite already + opened all 33 `.pfm` in the Basics pack; it now sums their executable sections by + the loader's own admission rule and fails over a ceiling. Nothing checked that the + modules this repository ships can be admitted by the firmware it ships, which is a + failure it has already had, and which took a hardware bisect to find. +- **A declared hook must be dispatched, and a dispatched hook must be called.** The + boundary checker had five rules and all five policed what the core must *not* say. + The worst regression this project has shipped went the other way: the per-frame + feature dispatch went missing and every feature's loop silently stopped, on a build + that compiled, linked, ran and served every route. Compiling four editions cannot + catch that — a hook nobody calls type-checks perfectly. diff --git a/docs/releases/v3.10.2.md b/docs/releases/v3.10.2.md new file mode 100644 index 000000000..9dab58e7b --- /dev/null +++ b/docs/releases/v3.10.2.md @@ -0,0 +1,23 @@ +## [3.10.2] - 2026-09-11 + +### Fixed — pattern storage + +- **A Format that did not take no longer reports success.** `FFat.format()` never + reads back what it wrote, and the flash driver never checks that a program + landed. On a board whose flash silently drops writes, every step returned OK: + the console said "storage ready", and the next mount failed with the same + `Error: -1` as an unformatted board, which sent the owner round the Format + button indefinitely (#424). The mount that follows a Format is the first read, + so it now decides the result: if it fails, Format fails, and says why. +- **A failed mount says why.** The core's line is `Mounting FFat partition + failed! Error: -1` whatever the cause, because FATFS's own reason is logged at + a level this SDK compiles out. The first failure in a boot now reads the boot + sector through the wear-levelling layer and records what it found: `not + formatted`, `format did not stick: storage reads back blank`, or the first and + last bytes of a sector that is not a filesystem. The reason goes to serial and + to the new `fsError` field in `/api/status`. It runs at most once per boot and + once after a Format, and never on a mounted volume. +- `/api/status` gains `flashId`, the flash chip's JEDEC id, so a fault that + follows one flash part is visible without USB. +- A comment in `pattern_registry.h` still described the automatic + format-on-failure removed in 3.2.0, and was being read as current behaviour. diff --git a/docs/releases/v3.10.3.md b/docs/releases/v3.10.3.md new file mode 100644 index 000000000..f4a2cb59d --- /dev/null +++ b/docs/releases/v3.10.3.md @@ -0,0 +1,23 @@ +## [3.10.3] - 2026-09-17 + +### Hardware + +- **v3.9 board** (2026-09-04). The USB-C power receptacle `USB1` and its CC pull-downs `R1`/`R2` — which v3.0 shipped and every document told you to leave unpopulated after the delayed burnout in [#221](https://github.com/engmung/Patternflow/issues/221) — come off the board. `J4`, the screw terminal, is the only power input there is. Edge cuts and mounting holes are byte-identical to v3.0, so the v3 cases fit both. The 330 mm case gains a pass-through to the DevKit's USB port (data, for wired MIDI/OSC and flashing — not power) and a cable exit slot. Gerbers, BOM, renders and schematic regenerated; `bom_v3.9.csv` is the BOM source of truth. + +### Firmware + +- **Purple and sky-blue fringes round bright edges are gone, greys stop reading pink, and the panel refreshes at 325 Hz.** A plane's bits are on the LEDs while the *next* buffer in the DMA chain clocks in, so the OE window written into buffer `b` lights the plane latched before it - the driver's own `clearFrameBuffer()` says so. 3.10.1 read upstream's full window on buffer 0 as an LSB bug and shrank it; it was the top plane's display time, and raising a code from 127 to 128 then lowered the light by 42% (upstream: 28%). On a pattern with a near-white background each channel crosses that code at a different point along every edge that fades to dark, so edges grew a purple and then a sky-blue fringe. 3.10.1's analysis was also made at transition bit 0; this panel ran at 4. Upstream's chain cannot be made binary without throwing light away - solved exactly it keeps 42% of the row lit, and on the panel that was too dark - so the chain is new as well: twelve buffers a row (`0 1 2 3 4 5 6 7 6 7 7 7`) instead of fifteen, windows `1, 1/32 … 1/2, 1, 1`, exactly binary, 66% of the row lit (upstream, not binary: 80%) and 325 Hz where it was 260. `begin()` now picks a chain *scheme* against `min_refresh_rate` rather than a transition bit: a slower clock costs brightness, never colour depth. `oe_test.cpp` walks the chain itself instead of sharing a formula with the driver; the 30,000 assertions that passed 3.10.1 had modelled delivered light the same wrong way. Checked on hardware at every step by switching builds. The power clamp's full-white figure was measured on upstream's windows and now over-estimates by about a fifth; left alone until measured. **White balance is identity again:** `LED_WB_*` 0.930 / 1.000 / 0.975 had been converged by eye at full white on top of upstream's weights, so it corrected the driver at one level and not the panel; it read pink across the greys, and the reference panel uncorrected reads neutral (checked on the GRAYS screen). A panel that carries its own `LED_WB_*` in `patternflow_secrets.h` should re-judge it. `/panel-tuner.html?device=` connects straight to a panel. +- **Knobs page.** `/knobs` on the device console, and `GET`/`POST /api/knobs` behind it: which way each encoder counts and how many edges make one click, per knob, with a live readout to check against. These are properties of the part soldered in - some encoders count the other way round, some give two edges per detent - and until now the answer was `INVERT_ENCODER` in `config.h` and a rebuild. Stored on the device; a saved change does not move the knob. The Live guide also gains the one setting that made a wired or wireless MIDI knob pin itself and tick: *Remote* on the port's Output row sends every mapped value straight back. +- **The Clock edition leaves the shelf.** The clock is a feature; `bundles/clock` stays in the tree as a composition CI keeps compiling, and its last image, `clock-v0.1.5`, stays on the v3.10.2 tag. The shelf is what its maintainer stands behind — Core and Audio, and Performance from its own — and it is short on purpose. +- **An edition ships on its own.** `release.py edition vA.B.C` bumps, shelves, points the card and commits: one command for an edition's maintainer to cut from a pull request, with no core release in between. Firmware CI builds the five compositions in parallel, one job each, and pins each one's static RAM. +- **MIDI edition** (`firmware/bundles/midi`, v0.1.0): the DevKit's USB port as a class-compliant MIDI device, beside RTP-MIDI — plug the panel into a computer with the cable it is flashed over and it is a MIDI port named `Patternflow`, no driver and no session. Same map as every transport (`docs/midi-spec.md`); `midiUsb` in `/api/status`. The port has to be the S3's USB-OTG controller for a MIDI device to exist, which is a build flag the other images cannot carry (it is what makes the port their serial console), so the edition builds in its own PlatformIO env — `env`, the third file a bundle may now have. Data only: the panel is powered from `J4` as always. In the tree and built by CI; not on the shelf yet. +- **Storage errors say why.** An upload whose destination will not open reports `errno`, so a missing `/patterns` (ENOENT) reads differently from a volume that will not write (EIO), and storage is diagnosed again after a successful mount, so a later failure carries a reason instead of an empty `fsError`. From RayWolters' investigation on [#424](https://github.com/engmung/Patternflow/issues/424). + +### Web + +- **The features page.** `/features` is the catalogue the shelf never was: every feature as one row — name, one line, and a tag saying where it lives (every firmware, Audio, Performance, a recipe in the tree, or an attempt that did not work) — so the whole list fits on a screen, and a row opens to what it needs, which firmware carries it, the links and a reel of it running. No headings between rows: which firmware carries a feature is a fact on its row, never a category over it, or the shelf would be back one level up. Reels are Instagram embeds by permalink, loaded only when a row is opened. The shelf and the catalogue share one header: Firmware and Features as a pair of tabs where the title was, the current one the title, the other a click away. +- **Try-out images on the features page.** The clock and the USB-MIDI build are not on the shelf, and until now trying either meant building it. Their rows carry a frozen image now — `clock-v0.1.5` as it shipped with 3.10.2, `midi-v0.1.0` built from the tree on 2026-09-17 — installed the way the shelf installs, through the panel's own `/update` page. Frozen means frozen: the image is the whole firmware as it was, core included, and nobody bumps it when the core moves; patterns, Wi-Fi networks and settings stay, and the way back is one click on the shelf. `check_versions.py` reads these folders from `features-data.ts`, so the rule that the shelf holds only what is named still holds. + +### Docs + +- **The repository explains itself to a contributor.** One table in `CONTRIBUTING.md` of what you have → where it goes → what CI runs on it; `docs/REPOSITORY.md` as the folder-by-folder map for people; `SUPPORT.md`, `CODE_OF_CONDUCT.md`, `SECURITY.md`, `CITATION.cff` and a root `LICENSE` pointer; a README in every contributor-facing subtree (`tools/`, `integrations/`, `firmware/modules/`, `firmware/toolchain/`, both `presets/`); `docs/mqtt-spec.md` beside the other contracts; `hardware/case/remixes/` as the named home for community enclosure variants, with a one-step CI check of its READMEs. Dead pointers removed: GitHub Discussions (never enabled), pattern pull requests (patterns are published from the Pattern Lab to the Community), the `.agents/` folder, `release.yml`. Stale facts fixed in place: the assembly map and `hardware/README.md` now name the v3.9 board, `firmware/README.md` no longer teaches the console pause that ended in 3.6.3, and the Ableton bridge says "install the Audio edition" instead of "rebuild with OSC". diff --git a/docs/releases/v3.10.4.md b/docs/releases/v3.10.4.md new file mode 100644 index 000000000..407d171e2 --- /dev/null +++ b/docs/releases/v3.10.4.md @@ -0,0 +1,5 @@ +## [3.10.4] - 2026-09-19 + +### Firmware + +- **The panel refreshes at exactly 300 Hz, because that is what a phone can film.** A row is lit once per refresh for 1/32 of it - a pulse, not a glow - so a rolling shutter collects a whole number of pulses per sensor line, and unless the exposure is an exact multiple of the refresh period some lines get N and some N + 1: bands. Phones pin exposure to 1/50, 1/100, 1/25 or 1/60, 1/30 to dodge mains flicker, and 300 Hz is the lowest rate all of those divide. 3.10.3's 325.5 Hz sat close to the worst case for both 1/50 (6.51 periods - the whole picture banded) and 1/60 (5.43), and it showed: more lines on video than the 260 Hz it replaced, which had happened to sit nearer whole numbers. Every frame now ends in 4,181 blank clocks, output off, bringing it to 16 MHz / 300 = 53,333. Simulated band contrast at 1/25, 1/30, 1/50, 1/60 and 1/100 goes to zero, and it looked that way through a phone. It costs about 8% of the light and no CPU at all - the DMA engine walks the chain by itself; frame and push times on the panel are unchanged. **1/120 and faster still band**, at any rate this clock can reach (that would take 600 Hz), and so does slow motion: lock the shutter at 1/60 or 1/50, or turn the panel down so the phone lengthens its exposure. `PF_TARGET_REFRESH_HZ` (0 to switch off); never pads below `min_refresh_rate`. The old claim in `firmware/README.md` - past 240 Hz a camera "averages 4+ cycles and the bands disappear" - was wrong, and is replaced with how it actually works. diff --git a/docs/releases/v3.10.5.md b/docs/releases/v3.10.5.md new file mode 100644 index 000000000..2b92dd8f9 --- /dev/null +++ b/docs/releases/v3.10.5.md @@ -0,0 +1,33 @@ +## [3.10.5] - 2026-10-02 + +### Firmware + +- **A panel that crashed says where.** `resetReason` in `/api/status` said `"panic"` or `"task_wdt"` and nothing else - not where, and not which of forty installed patterns was on. The SDK had been writing a core dump to its own flash partition on every panic all along, and nothing read it. At boot the firmware now reads that dump's summary - the task, the cause, the faulting address, the PC and up to sixteen return addresses, and the hash of the image that crashed - and it keeps a breadcrumb through the reset: the pattern's slug, which of its calls was running (loading, constructors, `setup`, `update`, `draw`, or none of them) and where the module's code was executing from. Status gains a `crash` object with both. With the two together an address inside a module comes out as an offset (`"+0x4e"`) that the `.pfm`'s own symbol table resolves, whether the code ran from PSRAM or from internal RAM. `DELETE /api/crash` clears it, and the same record is printed on serial at boot (`[CRASH] ...`). It reports and does nothing else: no pattern is skipped, no reboot is forced, and what the panel does after a crash is what it did before. The breadcrumb covers the boot that follows a panic or a watchdog and is gone with the power; the dump stays in flash, so a board that panicked on an older firmware shows that dump, marked `fromThisReset: false`, until it is cleared. Checked on a board: a module stored through a null pointer in `draw()` while running from PSRAM, and after the reboot status named the pattern, the phase, `pc` `+0x4e` and the caller at `+0xb6` - the offsets the module's own symbols give for those two functions - with the dump read in 62 ms; a dump left by an earlier firmware appeared as `fromThisReset: false`. It costs 296 bytes of internal RAM. `docs/rest-api.md` ("The crash record") says how to read one and decode it, and `firmware/toolchain/check_crash.py`, a new host check, boots the logic through every reset reason against every state of the breadcrumb and the dump; CI runs it. +- **Every pattern is 2.45 ms a frame faster.** Copying a finished frame to the panel's bit-planes was the largest fixed cost in the loop: 5.96 ms of every frame, nearly half of Origin's. The kernel was one long loop with more live values than the CPU has registers and a plane count it only learned at run time; it is now three short passes with the 8-bit case written in, 390 instructions a column pair where there were 635, emitting exactly the same words (`check_blit.py` compares 70 million of them). Measured on two boards, alternating images: `presentUs` 5,960 -> 3,496 µs on the default and Audio editions alike, so Origin goes from 12.1 to 9.6 ms a frame and a 30 ms pattern to 27.6. With white balance or gamma tuned away from the defaults it is 3.8 ms. It also takes 1 KB less internal RAM than the loop it replaces. `/api/status` gains `loopStackMin`. +- **A pattern's code no longer competes with the network for memory.** A loaded module's code used to need one contiguous block of internal executable RAM - the same RAM the console, lwIP and every feature run on - because "the S3 cannot execute from PSRAM". That is true only of the address the heap hands back: on the S3 the instruction and data buses share one MMU table, so the same PSRAM page can be fetched at that address + `0x06000000`. The loader now places code in PSRAM, writes and relocates it through the heap's pointer, and runs it through the alias (`src/core_module_memory.h`, `execAddress()` in `src/core_module_loader.h`). On the Audio edition a module with 23 KB or 32 KB of code was refused and now loads, and internal heap with a module resident is about 27.8 KB whatever the size of its code, where a 10 KB one used to leave 21-23 KB. On a second panel a community pattern with 8 KB of code was being refused one load in four for want of 528 bytes; it now loads every time and leaves 29 KB free instead of 22. Measured on two boards, same boot, alternating: catalogue and community modules within -0.1..+1.2% of their old frame time; modules built to be the worst case (10-32 KB of code, all of it executed every frame) 2.6-5.3% slower; 400 switches between modules of different sizes with no reset and no refusal. The code block is given whole cache lines to itself, the load reads it back through the instruction bus before calling it, and a unit on which that ever fails goes back to internal RAM until reboot (`moduleMemory.codePolicy`). `load.code` in `/api/status` says where the code is. Module files and the ABI are unchanged. +- **A request that stops half-way can no longer reboot the panel.** A multipart `POST` that ended after its first boundary - to any address, not only an upload - left the web server's form parser reading empty lines for ever with nothing to wait for, and the Core-0 watchdog reset the board five seconds later. The parser now knows a line that never ended from a blank one and gives the form up, tells an upload handler whose file had already arrived that it was aborted, and leaves none of the form's fields behind to answer for the next request (`src/webserver/VENDORED.md`, Fix 4). Reproduced on a panel before, gone after. +- **Three more requests that could reboot it, found on a PC first.** `curl -X POST` to `/api/patterns` or `/update` with no form in it called the upload handler with no upload to read and panicked the board; a multipart request naming a 9,000-character boundary overflowed the network task's stack; and a raw `PUT /update?size=N` never saw its own `size`. All three are fixed in the vendored server (`VENDORED.md`, Fix 5), and all three came out of `firmware/toolchain/check_parser.py`, a new host check that replays cut-off, stalled and malformed requests - 13,953 of them - through the real parser and fails if it ever spins or holds; CI runs it. +- **A quote in a pattern's name no longer breaks the pattern list.** `/api/patterns` wrote names into its reply unescaped, so one `"` or `\` in a title made the whole reply unparseable and the `/patterns` page could not list, select or delete anything - including the pattern responsible. Names are escaped now, a sidecar name is read as the JSON string it is - an escaped quote no longer ends it, and `\uXXXX` becomes the character, so "Dynamic Moiré" is that before the pattern has ever been loaded and not only after - and a name too long for its slot is cut between characters rather than through one. The hotspot password got the same escaping in `/api/hotspot`. +- **A module's global constructors can use the host.** They ran before the module had been handed its host API, so a pattern with a namespace-scope initialiser that allocated, logged or asked for a random number (`float* trail = PFMem::allocFloats(n);`) crashed the panel every time it was picked, having built and uploaded cleanly. Constructors now run after the entry point and before `setup()`. +- **Lanes keep working past day 25.** The five seconds a knob keeps to itself after a hand touches it was timed with a signed compare meant to survive the 49-day millisecond wrap; what it did was move the wrap to 24.9 days, after which a lane on any knob not touched since was held off for the following 24.9. A panel left alone would have stopped answering its microphone (Audio) or its weather (Performance) on day 25. Found by reading, not by waiting: the hold is a flag that ends once now. +- **A pattern that never returns no longer takes the console with it.** A module's `draw()` runs on the render loop, and the render loop is on no watchdog: one `while (a > PI) a -= 2 * PI` on a value that has reached infinity - which float32 does where the browser's doubles did not - and the panel holds its last frame for good. The first console request that needed the loop then waited for it without limit, and the `/patterns` page opens with one; the server answers one connection at a time, so `/api/status`, `/update` and Reboot, which need nothing from the loop, went silent behind it, and a frozen panel could be neither diagnosed nor restarted without pulling the plug. A request now gives the loop up once it has not reached its frame boundary for 20 s (`PF_LOOP_STALL_MS` - measured from the loop's own stamp, not from how long the request has waited, so an upload waiting out a slow `setup()` is not mistaken for it) and answers `503 render loop is not answering`; the request is taken back with one atomic exchange, so the loop can never run it on a stack frame that has already returned. `/api/status` carries `loopAgeMs`, `loopStalled` and `loopSyncGaveUp`, read without the loop's help, and every handler in the core and in the features answers the refusal instead of using what its body never set. The Status page says "not answering for 47s" in red, where it would have gone on showing the frame rate from before the stop under "awake". Nothing restarts the panel by itself: this reports and survives, and the Reboot button does the rest. Twenty seconds is clear of one feature lookup with the internet down and not of two in a row, so on Performance with a dead uplink a healthy panel can read "not answering" for some seconds and then carry on - which is why it does not say "hung". The console has to have come up once for any of this: its routes are registered by the loop on the first network link. Found by reading - no stock pattern hangs - then reproduced on a panel with a module that hangs on purpose: before, the pattern list got no reply and after it neither did status, until the board was reset over USB; after, the list answered 503 in 18 s, sixty more requests were refused at once with the heap unchanged, the Status, Update and home pages still loaded, and Reboot brought the panel back. Held by a host test that races the two sides against each other; `firmware/toolchain/tests/modules/_hang_probe` is a module that hangs on purpose, for the bench (`src/core_loop_sync.h`). +- **No more stray letters on the panel's own screens.** A portrait line holds ten characters, and Adafruit GFX wraps an eleventh onto the next line by itself: the KNOB MAP's "TURN = SHOW" put a lone "W" in front of "K3 = EXIT", the install screen's "web console" did the same, and the default host name "patternflow" lost its "w" on the hotspot and UPDATE screens. The labels are shorter now ("TURN=SHOW", "console"), and names that can be longer than a line are broken into lines of ten by `drawCenteredFit` — "pattern" / "flow-a1b2", "pattern" / "flow.local" — keeping every line where it was. +- **A deck sent from the community's dock keeps its order.** The console's one-click install from a link (`/patterns?src=`) took only `.pfm` and `.json` from a build's file list, so the `catalog.txt` every deck build carries was left behind and the deck landed in alphabetical order. It now takes exactly what a dropped zip does (`catalog.txt` and `.pfs` too) - one rule, `installable()`, for every way in. +- **A firmware upload survives a Wi-Fi hitch.** The `/update` connection now waits up to two minutes for a stalled upload instead of five seconds, and the page gives up after five minutes with a message instead of waiting forever. Checked on a panel with a 12 s stall halfway through a flash. `PUT /update` takes a raw image, for `curl -T`. From Simone Majocchi ([#450](https://github.com/engmung/Patternflow/pull/450)). +- **Two 64×64 modules chained make a 128×64 panel.** `PANEL_GEOMETRY` in `config.h` picks the stock 128×64, one 64×64, or two 64×64 daisy-chained (the `firmware64x2` PlatformIO env). The driver is told the module and the chain, and everything else sees the canvas; the stock build is unchanged. From Simone Majocchi ([#446](https://github.com/engmung/Patternflow/pull/446)). +- **Performance v0.4.0 carries the clock and a Weather face**, and its show catalogue holds 100 sequences instead of 48. An MQTT subscriber following a fast sequence stream now drains the broker and applies the latest value per knob each frame instead of falling one message per frame behind (a jump of more than 16 clicks in one frame is capped), and a publisher no longer replays its own retained snapshot when it reconnects. From Simone Majocchi ([#445](https://github.com/engmung/Patternflow/pull/445), [#448](https://github.com/engmung/Patternflow/pull/448), [#449](https://github.com/engmung/Patternflow/pull/449)). +- **The panel is a network of its own.** With no known Wi-Fi in reach it raises a hotspot, `patternflow-a1b2` (its alias), WPA2, password `patternflow` until changed, and the whole console is at `http://192.168.4.1/` on it - patterns, knobs, the Wi-Fi page to add the next place's network, updates. Modes on `/wifi`: `auto` (default: up fifteen seconds after the last link, down once a network is joined and nobody is on it), `always`, `off`; `GET`/`POST /api/hotspot`, and a `hotspot` object in status. The NETWORK screen shows the name while the hotspot is what there is. What the bench decided (`src/core_hotspot.h` says why, line by line): the channel comes from a scan - the least loaded of 1/6/11 - never a fixed one; alone, the radio runs AP-only, and the station comes back only for a rare probe with nobody connected or when credentials arrive; a DNS responder on the hotspot resolves every name to the panel and the phone's internet probe fails within a second instead of timing out for twenty; the hotspot is 20 MHz. Pretending to be the internet was tried and put a Samsung into its limited-connectivity state, which drops the network. +- **Console services start on any link.** Every core page, and the audio, microphone, clock and MQTT pages, used to wait for `WL_CONNECTED` before registering - on the hotspot a phone got an address and port 80 never opened. `PatternflowWifi::linkUp()` is the condition now, the station or the hotspot, and the hotspot raises the same link edge the station does. +- **Full transmit power.** The 13 dBm cap from 2026-08 is retired (`PF_WIFI_TX_POWER` is the radio's 19.5 dBm again). Measured on the hotspot: a phone next to the panel took 4-9 s per 10 KB page at 13 dBm and under a second at full power - the phone's own transmitter had hidden the asymmetry, and a router's antenna had hidden it on the home network. Owner's decision. +- **Switching console tabs no longer downloads the page again.** Page addresses carry the firmware's build (`/patterns?v=2a535f30`), and a page asked for under the running build is cached for good - a second visit to a tab costs no request at all (measured on a panel: `/patterns` 15.5 KB in 335 ms the first time, nothing and 16 ms after). A new build is a new address, so an update is never answered from an old copy; an open console notices the new `build` in `/api/status` and moves to it by itself, unless you are in the middle of typing or an upload, when it offers a reload instead. Pages without `v` revalidate every time (`ETag` + `304`), an old `v` gets a `302` to the current one, and the shared header script is addressed by its own checksum (`/pf-console.js?h=…`, stamped in by `console_pages.py`). Only names that can only be the panel - an IP, a bare name, `.local` - are ever given a cache lifetime: the hotspot answers every DNS name, and a phone must not keep the console as someone else's site. On a home network the other tabs are fetched in the background once the page is idle, so even a first visit is instant; not on the hotspot, where the link is the scarce thing. `/api/status` gains `build`, `viaHotspot` and `busy`, and escapes the network's and pattern's names (an SSID with a quote in it made the reply invalid JSON). +- **One header for the whole console, and one way to talk to the panel.** The shared header now lives in a shadow root, so no page's CSS moves it - it had a different height, width and position on every tab, which is most of why switching felt jumpy. On a phone it is two fixed rows with the tabs in one sideways-scrolling strip (Knobs is a tab now), and it says when the panel is not answering, restarting, or has been updated. Pages reach the device through `window.PF`: polls wait for the previous reply, back off on a slow link, pause in a hidden tab and stop the moment you tap another tab, so the next page is first in the one-connection server's queue; the page's own requests go before the header's status request; uploads hold everything else back and ask before you leave. A small fallback is stamped into every page, so a page still works if the header script fails to arrive. `console_serve.py --slow` makes the desk as slow as the hotspot (round trips, a 5.7 KB TCP window, one request at a time) to design against. +- **Setting the panel up from its own hotspot.** `/wifi` puts *Add a network* first when you are on the hotspot or nothing is saved, lists the networks the panel's channel scan saw so you can tap one instead of typing it, stops a case-only typo with an inline "did you mean" before it is saved, and reports what became of the network: joined, with the address to open, or why not - wrong password, not found, refused, no answer - on the page and on the panel's LEDs. On the hotspot with no station link, a network sent from the page is tried at once. The console's pages were gone over for phones on the way: sliders that let the page scroll, inputs that do not zoom iOS, `accept="*/*"` for the Android file picker, the hotspot shown as what it is instead of "offline", a status page that copies its diagnostics, an update page that confirms the new version after the reboot. +- **`always` no longer keeps the panel off its network after a boot.** Raising the hotspot starts with a channel scan, and the scan cuts the station's first connection attempt short; with the hotspot up the station is retried only every five minutes, so a panel set to `always` with good credentials sat off its network for five minutes after every boot (the console looked dead from the LAN). The hotspot now gives the station one attempt the moment it is up - on the bench it joined 1.4 s later - and goes AP-only only if that fails. The console also stopped calling a slow link dead: one reply over the timeout (now 12 s at least, not 5) no longer turns the header's state to offline, two failures in a row do - with the hotspot and the station both up, pages took 4-5 s and the console read "offline" after the first one. +- **The console's server waits one second, not five, for a connection that sends nothing.** A browser opens connections it never uses; each idle one held for 5 s put the page's real requests behind it, which on the hotspot was the console appearing only after the browser gave up on its spares. + +### Web + +- **A guide you can play along with, in three parts, at [/guide](https://patternflow.work/guide)** (work in progress, marked WIP). `/guide` is where you pick one by where your Patternflow is, each with its chapters, in order. **Build**, at [/guide/build](https://patternflow.work/guide/build), is for soldering one from bare parts: 01 Gather (the parts list is `bom_v3.9.csv` itself), 02 Order & print, 03 Solder, 04 Into the case, 05 Wire & power, 06 Firmware (which hands over to Play's 01 Flash) and 07 Check & close, each step played on the real v3.9 board and case in 3D, in the order the build happens; the board's known issues stay in `BUILD_GUIDE.md` §10, linked. **Play**, at [/guide/play](https://patternflow.work/guide/play), is the first hour with a built one, 01 Flash, 02 Knobs, 03 Patterns, 04 Console, on the real v3.9 hardware in 3D, running a port of the firmware's input logic and screens, with the real flasher dialogs and a live copy of the device console. **Make**, at [/guide/make](https://patternflow.work/guide/make), is 01 Community and 02 Pattern Lab, done by hand: on a big enough screen your own Pattern Lab, a practice community (placeholder patterns, connected to nothing) and a practice AI with set answers sit beside the steps, and a pointer shows each move and waits for yours; phones get the real screens. Sound, MIDI & OSC, MQTT, Clock, Performance and Editions join Make as they are written. Each guide numbers its own chapters from 01. Links into the first version, when Play was `/guide` itself (`/guide#flash`, `/guide#knobs-3`), land on the same step under `/guide/play`. English and Korean. Every step has a "Stuck here?" link to a GitHub issue that already says which guide, chapter and step (`guide_stuck.yml`). The 3D stage is one continuous world: moving between the hub, Build and Play is a scene rather than a page load, and pointing at a guide on the hub previews it on the device (Build comes apart, Play turns a knob, Make redraws the panel). The LED panel lights the room with the pattern it is playing, and the page's accent follows that colour. Pretendard is now served in four script-range files, so an English page no longer downloads the Hangul glyphs (a Korean page renders identically). README, BUILD_GUIDE, PATTERN_GUIDE and the assembly map link to the guide. +- **The community's Performances note says .pfs**, which is what the Director saves, instead of "Save-JSON". +- **A moderator can take a pattern or deck off the wall without deleting it.** Somebody else's public pattern or deck now offers moderators *Make private*, with an optional reason: the softer removal, for things like the same pattern posted a fifth time. Nothing about the work changes and its author keeps it — they can still open it, edit it, take it into the lab — it just stops being shown to anyone else. On a pattern the reason is posted underneath as the moderator's comment, where the author can answer it; either way the author is told in their alerts. While the take-down stands the author cannot make it public again (a take-down its subject can undo in one click is a request); *Restore to public* puts it back exactly as it was. A moderator still cannot publish what an author made private themselves. A deck slot left empty by a take-down reads "made private by a moderator", not "by its author". Moderators can now comment on private patterns and keep their alerts about them, so the thread under a take-down works both ways. Migration `0025` adds `hidden_at` to patterns and decks; `check:hidemod` drives it all through the real routes. +- **One press of Publish is one post.** A double-click on *Publish to the wall*, or a mouse switch that bounces, could put the same pattern on the wall twice in the same second ([#455](https://github.com/engmung/Patternflow/issues/455)). The button's disabled state was React state, a render too late for the second click, and after a successful publish it came back enabled while the page changed. The publish, thread, reply, deck, header, performance and report forms now refuse a second press in the same tick (`useSubmitLatch`), and a published pattern keeps its button down until the page changes; a thread whose files failed to attach offers *Open the thread* instead of posting it again. Behind them the server answers an identical submission from the same account within a minute with the post it already made, looked up and inserted in one synchronous transaction so two requests arriving together cannot both get through. `check:publish` fires two identical publishes into the route at the same instant; a version that awaits between the lookup and the insert fails it. diff --git a/docs/releases/v3.11.0.md b/docs/releases/v3.11.0.md new file mode 100644 index 000000000..4ad258a27 --- /dev/null +++ b/docs/releases/v3.11.0.md @@ -0,0 +1,70 @@ +## [3.11.0] - 2026-10-04 + +### Firmware + +- **Going back to a pattern no longer loads it again.** An installed pattern (a `.pfm` module) was unloaded the moment another pattern took the panel, so every return paid for a whole load: reading the file, relocating it, its constructors and its own `setup()`. For most of the catalogue that is 25-100 ms; for the heavy ones it was the pause you could see - Branched Flow 3.3 s, Two Stream 4.1 s, Burgers and Wave Cascade 0.6 s each. A module that has run now stays loaded in PSRAM ("parked") when another pattern takes over, and picking it again resumes it: nothing is read or relocated, `setup()` does not run again, and the pattern carries on from where it was, as the built-in presets always have. Measured on two boards, a return takes 115-197 µs. Picked while another pattern is still loading, it comes back once that load is done; picked during an install, it is loaded afresh afterwards, because an install empties what is parked. Up to 16 modules stay parked (`PF_MODULE_RESIDENT_MAX`; `0` turns this off). +- **SELECT shows patterns that are already in memory live.** A preset or a parked module comes on as the knob reaches it and runs behind the overlay instead of showing its thumbnail, as long as the pattern being left can be kept too. A pattern that has to be loaded still waits for the knob to rest. +- **A module's data now goes to PSRAM first, as its code already did.** Only a module that holds no internal RAM at all is kept, so the console and Wi-Fi give up nothing for parked patterns. Measured A-B-B-A on two boards: 13 of 16 module runs within noise (-1.1..+0.8%), Burgers +2.6% (38.9 -> 39.9 ms a frame), Wave Cascade +2.2%, Breakout Arcade +0.85%; in exchange 4-6 KB more internal heap with a module running, up to 9.9 KB. +- **Parked patterns give their memory back.** Least recently used first, whenever a new load would otherwise start with less than 4.5 MB of PSRAM free (the most one pattern may allocate, plus 512 KB), and whenever a running pattern's own allocation would leave less than 512 KB. A pattern installed, replaced or deleted - from the console, or by a feature that installs patterns - is never resumed from the old file. Checked on two boards with 12-13 modules parked: internal heap stayed flat, an install emptied the table and PSRAM came back by exactly the parked bytes (683 KB), and with five 1.5 MB test modules PSRAM never fell under about 3.1 MB free, with no refused load. `/api/status` gains `load.resumed`, `load.resumeUs` and `moduleMemory.resident`. +- **What this takes away:** a pattern that wants a fresh start every time it is picked cannot have one. `setup()` runs once per load, as `firmware/CUSTOM_PATTERNS.md` now says. A restart of the panel, or any install, delete or reorder on the Patterns page, is what starts one again. +- **The console is light, and its six core pages look like one thing.** It opens light; dark is still the toggle in the header, and a choice made there is remembered. Console, Patterns, Status, Wi-Fi, Knobs and Update now share one set of parts - buttons, chips, list rows, fields, notices, section titles - from a base block every page opens with (`console/README.md`, "The look"), with one accent, the LED orange, kept for what is live: the current tab, the playing pattern, the panel being on. The home page leads with the panel itself: what is playing with its arrows, on/sleep, brightness and the four knobs, with the device's facts under them in the same card; the arrow keys step through patterns as the arrow buttons do. Which edition the panel runs has a card of its own instead of being one more row among the facts: its name, its versions, the feature pages it carries as links, the update notice when there is one, and a button to the other editions (or a line saying the panel's own hotspot has no internet). The numbered index of every page with a paragraph each is now a list of one-line links. The header no longer scrolls sideways on a phone: Console and Patterns, the build's own feature pages, then Wi-Fi, Knobs, Update and Status, wrapped onto rows. Long explanations are a line, with the rest behind a disclosure where it is needed. Windows showed every small label in Courier New because no font in the monospace list was a Windows one; the list now includes Cascadia Mono and Consolas. The Knobs page has an *All* row that sets direction and edges per click for the four at once (it reads *Mixed* while they differ). The Status page shows the crash record (`crash` in `/api/status`) when there is one, with a button to clear it. Feature pages are not edited and take the light palette from the header. Home is 8.9 KB gzipped (was 10.9), Knobs grew by 0.15 KB for its All row, the others are within 40 bytes of what they were, and the header script is 9.6 KB. On a panel on the home network a page still opens in about 0.1 s the first time and in under 0.01 s after. Every request, control and message the pages had is still there; checked on a panel by stepping through patterns, sleeping and waking it, dimming it, dragging a knob and playing a pattern from the list. The guide's live console demo is regenerated to match. +- **The Audio page is a console page now: it says who has the knobs, and it saves only what you changed** (Audio edition). `/audio-in` was the one page the light console left behind: its own dark palette, 12 px type, square corners, a second brand line. It opens light like the rest now, with the console's buttons, chips and list rows, and it does not scroll sideways on a phone (checked at 390 and 320 px). Above the editor is a **Sources** list. *Microphone* has a chip that reads `off`, `starting`, `listening`, `not detected` (nothing on the data pin; the page says to check the DAT lead) or `not answering`, with the input gain beside it; a board with no microphone fitted used to look the same as a healthy one in a silent room. *Browser extension and phone app* is the Audio-React switch, the same one as `AUD` on the NETWORK screen, with `off`, `waiting`, `connected` or `phone app`, shown only on a build that has that path. The line at the top of the page says which of them has the knobs. It does not know about a knob pinned from the home page's sliders, MIDI CC 20-23 or `/api/params`; that knob stays pinned until its encoder is turned. Under it the mapping editor is the extension's, with what that gained in this release (see Tools): the knobs are K1 to K4 with their tabs under the plot, the level axis is in dB, and Preview takes the plot's place. On this page the line in each box, the cursor on the curve and the readout now show where the firmware has the level (`outputs` and `levelsN` from the poll); the page used to work that out a second time on its own dB scale, which is not the scale the firmware maps a hand-set window on. A panel that has heard nothing draws its stored windows dashed, where it drew four boxes flat on the axis. *Reset K1* asks the firmware for that knob's default: the page's own table of defaults was the extension's, and put a 5-16 kHz band on an axis that ends at 8. *Reset mapping* is pressed twice and says what it resets, in place of a `confirm()` dialog, which the phone app's WebView is not set up to show. Saving: nothing is written until the panel's mapping has been read. A read that failed used to leave the editor on the extension's defaults with nothing on screen to say so, and the next touch saved four default bands over the panel's own. An edit now sends the fields that changed, one request at a time - an Attack nudge is `attack=0.611`, a box's top edge is that band's `inMin` and `inMax` - where every edit posted the whole mapping, 41 arguments, and two open tabs wrote their stale copies over each other's edits. A save the panel did not take is said so, tried once more, and kept for the next change; an edit made just before leaving the page is still sent. Polling: the levels poll rides the console's shared lane (`PF.poll`) as every other page's requests do, so it waits behind an upload in another tab and slows itself on a slow link. Measured on the mock, about 9 polls a second with the microphone on, one every 1.5 s with it off (it had been 9.2 a second there too, for numbers that cannot change), one a second while the panel is not answering, and 0.6 a second on a link as slow as the hotspot. Weight: 17.2 KB gzipped as the panel sends it, where it was 19.9, because comments and indentation are dropped as the page is assembled; CI fails the page above 17.5 KB. `console_pages.py build` assembles the page as its first step, so there is one build command where there were two, and `console_serve.py` serves it assembled from its sources on every request against a port of the firmware's audio handlers, with `/mock?audio=` states for music, a quiet room, no microphone, a stalled one, the phone app and a read that never answers. Checked on that mock in Chromium, light and dark, with no console errors in any state, and on a panel with the microphone: the mapping stored on it survived the update, the page read before it wrote, one slider step posted one field (`attack=0.645`) and held across a reload, and frame time and microphone drops were unchanged with the page open. Not yet on a phone or in the phone app's WebView. +- **The Audio edition transmits at the core's Wi-Fi power** (19.5 dBm). It had been left pinned at 17 dBm when the core's 13 dBm cap was retired in 3.10.5, so it was the one build with less than full power. +- **A microphone that is not delivering no longer moves the knobs, and the microphone API takes one field at a time** (Audio edition). When the PDM driver did not start, or the microphone stopped returning samples, the analysis carried on over its built-in three-tone test signal and those tones reached the knobs, while `source` said `synth (mic stalled)`. The microphone now drives the knobs only while it is returning samples. `POST /api/audio-in` applies a band when any one of its suffixed fields is present; before, a request carrying only `inMax2` was answered `{"ok":true}` and changed nothing, because only `hzMin`, `lut`, `knob`, `muted` or `outMin` counted as naming a band. A `band` outside 0..3 is refused with `400` before anything else in the request is applied. `POST /api/audio-in/reset` takes `band=N` and resets that band and its curve alone, naming it in the answer; a full reset now includes `attack`, and still leaves the microphone switch and auto range alone. `hzRange` is printed to two decimals (`31.25`, the bound a band is really held to). The poll reply is built in one allocation where it grew through about forty, ten times a second. Nothing is removed or renamed: the phone app reads the same configuration it did, and `d=` stays in the WebSocket protocol for earlier extensions. Static RAM is what it was (161,216 bytes of DRAM, the pinned figure). `docs/rest-api.md` describes the microphone endpoints field by field, including what had been there unwritten (both spellings of a band field, `smoothing`, `attack`, `meta`, `lut`, `env`, the phone's `frame` post, every `source` string, and that a body sent without the form `Content-Type` is not read, which turns a `band=2` reset into a reset of everything). `docs/audio-ws-spec.md` 1.1 states the 500 ms lane release that version 1 said did not exist, and which senders hold a value through it: the extension does, the Android capture app does not yet. Checked on a host by running the old and the new handlers on the same requests (78 assertions), by building every composition, and by the one-field post above on a panel. + +### Web + +- **The guide has a fourth part: Audio** (`/guide/audio`, Korean at `/guide/audio/ko`). Getting the Audio edition onto the board first, then three sections in no order: sound from a browser tab, the on-board microphone, and MIDI with a DAW (network MIDI, Ableton Live as the worked example). The browser section runs the extension's own mapping editor on its demo source, and the boxes drawn there turn the knobs of the 3D board; a hand on a knob takes it for five seconds, as on the real one. The rest is captures of the real pages and the two photos of the microphone. OSC, the phone app and the Director's `.mid` export are links at the end. The hub (`/guide`) has four cards. + +### Docs + +- **The microphone is five leads on four places, and the docs say so.** `AUDIO_GUIDE.md`, the Audio card on `/editions` and `firmware/bundles/README.md` said four wires; GND and SEL both go to GND, which makes five. `AUDIO_GUIDE.md` also names the Audio page's editor by its heading, **Mapping**. +- **The MIDI console page points at Live's current settings page** (Settings → Link, Tempo & MIDI, not Preferences → MIDI) and no longer mentions a MIDI row on the board's NETWORK screen: that screen shows the first two switchable features, and on the Audio edition those are OSC and AUD. `docs/midi-spec.md` says the same. + +### Tools + +- **The audio extension holds the knobs through silence, and Live means the panel is being driven** (`tools/patternflow-audio-extension`, 0.2.0). The panel hands a knob back to its encoder 500 ms after the last message that set it, and the extension did not send a frame equal to the last one. So half a second into a silent tab, a paused track or a Gate curve resting on one step, the knobs were let go while the popup said Live: measured, not one message in ten seconds of silence. It now sends an unchanged frame again about every 210 ms (five a second, no gap longer than 223 ms), and the knobs rest where each band rests until Stop. A knob turned by hand during a pause goes back there when its five-second hold ends, and a microphone that is also switched on does not get the knobs during a pause. Owner's decision. Muting a knob hands it back at once (`off=N`), where it waited for the panel's timeout. *Live* used to mean the socket was open, which it also is on a panel whose Audio-React switch is off and which ignores every frame; the extension now asks the panel once when the socket opens (`GET /api/audio`), and off reads *Aud off* with a **Turn on** button. A socket that will not open is explained, where every failure read "WebSocket error": no panel at that address, a panel that does not run the Audio edition, or something that answers and refuses the socket. A silent tab reads "No sound from this tab yet." The popup has one button, Start until a tab is captured and Stop while one is; *Release knobs* is gone (Stop does that), and *Test without audio* holds each knob where its slider is put. The address is taken on Enter, on leaving the field and on Start, where it reconnected on every keystroke (typing `192.168.0.42` tried `192.168.0.4` on the way), and it has a storage key of its own, so an editor tab left open no longer writes an old address back. Closing the captured tab ends the capture and says so, and a restarted service worker no longer shows Idle over a running capture. The frequency labels under the popup's spectrum sit where their frequencies are ("1k" had been over 317 Hz). In the mapping editor the bands are K1 to K4 everywhere, the knob tabs sit directly under the plot, the level axis and the curve's readout are in dB, and the hints are plain sentences ("This band still runs its old boost curve", on a band nobody had touched, is gone). A new install starts with Auto range on and the Smooth curve, as the panel does and as `AUDIO_GUIDE.md` already said; a mapping that is already stored keeps what it has. Switching Auto range off goes back to the windows set by hand, where it replaced all four with wherever the boxes were; dragging a top or bottom edge is what takes them over. Preview puts its scope where the plot is, so the sliders, the curve and the output range it demonstrates stay in reach, where it was a dialog over them. The editor holds together at half a screen's width, and on a touch screen a swipe that starts on an empty part of the plot scrolls the page while one on a box drags it. One model (`mapping.js`) holds the defaults and the level-to-knob arithmetic for the capture, the popup, the editor and the panel's page, where the first three each had a copy. The extension keeps its cream skin. After pulling, press Reload on `chrome://extensions`; the card should read 0.2.0. Reload keeps the stored mapping, so a copy you already had opens as it was, which unless you had changed them is Auto range off and no curve: for the new defaults, press Reset on each of the four knobs in the editor and switch Auto range on, or remove the extension and load it again (which also forgets the panel's address). Checked with the unpacked extension in headless Chromium against a loopback socket and a stand-in for the panel's HTTP side (44 popup checks, and the wire in each state), and against a panel: a tone in the captured tab swung K1 between 0.30 and 0.85, four seconds of silence left the lanes held at their rest value where they used to be released after half a second, switching Audio-React off on the panel turned the popup to *Aud off*, and Stop handed the knobs back to the microphone. Touch was tried only in emulation, and the panel requests only in the Chromium Playwright ships, not in a current Chrome with its local-network permission prompt. + +### Patterns + +- **Installed patterns run faster, and nobody has to edit one.** A pattern on a panel is a small compiled program (a `.pfm`). Two things about how it was compiled were costing frames, and both change where a module is built: + - It was built for size (`-Os`), on a note in the SDK that `-O2` bought about 2 %. Modules are now built at `-O2` (`build_module.py`). + - Every `floorf`, `ceilf`, `truncf`, `roundf`, `fminf`, `fmaxf` and `fmodf` in a pixel loop was a call out of the pattern into the firmware's maths library: 185 to 840 ns each as timed (`truncf` was not), 8,192 pixels a frame. The module SDK now gives those seven inline code (`abi/pf_libm.h`), so a header that calls them per pixel - most community patterns do - stops paying for the call. + + Frame time on one board, in one boot. The Basics presets as they are today, GCC 8.4: + + | | 0510 | 0512 | 0515-4 | 0520 | 0531 | 0601 | + |---|---|---|---|---|---|---| + | `-Os` | 11.9 ms | 23.2 | 15.0 | 23.4 | 13.7 | 15.0 | + | `-O2` | 10.3 ms | 22.6 | 11.2 | 19.7 | 12.4 | 13.6 | + + That is 3 to 34 % more frames a second; GCC 14.2 at `-O2` was another 1 to 8 % faster. Four presets as they stood before they were tuned by hand, with `floorf` and `fmodf` per pixel: + + | | 0510 | 0515-4 | 0520 | 0601 | + |---|---|---|---|---| + | built as before | 14.8 ms | 25.3 | 26.9 | 26.0 | + | `-O2` | 13.3 ms | 23.7 | 23.1 | 24.5 | + | `-O2`, maths inline | 10.3 ms | 17.5 | 20.3 | 20.2 | + + The last row was measured with a first version of the header. With the header as committed, all 147 distinct public community patterns build (the old recipe's compiler crashed on two of them), and each was loaded and run on a board: no reset, no refused load. +- **What is exact, and what is not.** The seven functions are exact: for every input, each returns the bits the library function returns. Each computes inline only the inputs that have one right answer, and hands the rest - a NaN, an infinity, a magnitude past the range its proof covers - to the call the pattern made before. `check_libm.py` holds them to that in CI: all 4,294,967,296 floats through `floorf`, `ceilf`, `truncf` and `roundf`, 419 million pairs through `fminf` and `fmaxf`, 481 million through `fmodf` by each of its two paths - twenty billion comparisons, no mismatch, under GCC with sanitizers and under MSVC. `check_module_libm.py` then reads a module the real compiler built, to see that this code is what a pattern's calls became, and `tests/modules/_math_probe` is the module that checks on a board what no PC can: that the panel's multiply-subtract is fused. It reported `math ok 9/9` on a board, over 1.17 million comparisons against the firmware's own library. One footnote: for `fminf`/`fmaxf` of a zero against a zero, or of a NaN, "the call the pattern made before" means what that call got from this compiler, which is not always newlib's answer for the arguments as written - GCC answers a call with constant arguments itself, and GCC 12 and later may swap the fallback's operands. + + What is not exact is the pattern's own arithmetic. `-O2` and inline code both move where GCC fuses `a*b+c` into one instruction (`-ffp-contract=fast`), so a value in a rebuilt pattern can differ in its last bit from the build before. Two cases, worked through: + - The cell hash written as one line, `sinf(floorf(x)*12.9898f + floorf(y)*78.233f)*43758.5453f`: 20 of 128 cells hash differently. + - A helper that ends in a multiply and that `-O2` inlines - a smoothstep: `mul.s` then `sub.s` becomes one `msub.s`. + + It shows only where a pattern amplifies the last bit: a float hash, state carried between frames, rarely the edge of a `floorf` band. Of 168 public community headers, one uses the 43758 float hash; 21 use the integer `PFNoise::cellHash`, which is unaffected. +- **The Basics pack is rebuilt**, for the first time since 15 August: the same 33 patterns in the same order, from today's preset sources, at `-O2`, with the firmware's own compiler. Against the August binaries on the same board, every one of the 33 is faster: 1.11 to 2.36 times the frames a second, 1.52 at the median (47 fps to 77 across the pack). + + | | 0510 | 0512 | 0515-4 | 0520 | 0531 | 0601 | + |---|---|---|---|---|---|---| + | August pack | 18.1 ms | 25.1 | 26.1 | 34.4 | 15.5 | 28.5 | + | rebuilt | 10.3 ms | 22.6 | 11.1 | 19.7 | 11.1 | 12.7 | + + It is also the first rebuild to carry the two SDK fixes of 3.10.1, and those change what is drawn. `cellHash`'s seed decorrelates, so Firefly Hollow and Midsummer Sea get a different random stream. `hsvToRgb` rounds, so the 16 patterns in the pack that colour through it are up to one level brighter on a channel. The pack is 166 KB where it was 122, and holds 104 KB of code where it held 63. +- **What the new builds need from the firmware.** + - **3.5.1 or later, for Basics.** Its modules are now stamped for the parameter bus (descriptor 2, where August's were 1), so a show can drive them - and firmware 3.2.0 to 3.4.0 refuses all 33. Update the firmware *before* reinstalling the pack: reinstalling replaces files that load with files that firmware will not. + - **3.10.5 or later, for the larger ones.** Firmware before 3.10.5 places a module's code in internal RAM, and the new builds are larger: 1.36 times the code at the median, over 145 community patterns built both ways with GCC 8.4. Thirteen of those 145 fitted an Audio build's budget of about 7.8 KB before and no longer do; nor does Breakout Arcade in Basics, at 14.2 KB where it was 5.4. From 3.10.5 code runs from PSRAM, and its size does not decide whether a module loads. + - Nothing else: a module built this way imports only names every loader since 3.2.0 resolves. +- **What you have to do.** A pattern already on a panel keeps the build it was installed with; nothing is rebuilt on the device. Update the firmware if the list above says to. Then install the Basics pack again from the site, and send a community pattern or a deck again: the community server keeps one build of each pattern, keyed by everything that goes into a build, so once it is redeployed each is compiled again the first time someone asks for it. +- **A module is 2.7 % slower than the same pattern compiled in, not 20 %.** The SDK said 20 % (a 2026-07 measurement: Origin, 53 fps against 43) and blamed the relocatable code model. Measured again on one board, A-B-B-A in one boot: Origin compiled in draws a frame in 9.56 ms, Origin as a module in 9.81. diff --git a/docs/releases/v3.2.0.md b/docs/releases/v3.2.0.md new file mode 100644 index 000000000..9a357027f --- /dev/null +++ b/docs/releases/v3.2.0.md @@ -0,0 +1,33 @@ +## [3.2.0] - 2026-07 + +Patterns stop needing a firmware build. **Hardware unchanged** — v3.0 board and case carry over as-is. + +### Added +- **Loadable pattern modules (`.pfm`)** ([#232](https://github.com/engmung/Patternflow/issues/232), [#242](https://github.com/engmung/Patternflow/pull/242)): a pattern compiles to a relocatable Xtensa ELF of a few KB, is sent to the device over Wi-Fi, and appears in the list immediately — no reflash, no reboot, no 1 MB image for a 6 KB pattern. Design and the working proof of concept came from **Simone Majocchi ([@SimonePDA](https://github.com/SimonePDA))**: a frozen C ABI between host and module, a linker script collapsing each module to four sections, and an on-device relocator. Switching patterns costs 6–11 ms; up to **128** modules can be installed, and installing costs nothing at runtime. +- **Module cart**: collect patterns across the community — from a card's own button or the pattern page — and build the lot in one request. "Send over Wi-Fi" points the device's pattern manager at the result and it fetches and installs every file itself. +- **Pattern manager on the device** (`/patterns`): install, list, multi-select delete, and format the pattern partition, with a per-file queue showing progress, retries and per-file results. Console navigation now spans every device page. +- **Pattern Lab: a staged hardware flow.** `To hardware` converts the composition to a `.h` first — one prompt for a single layer, a deterministic scaffold with a prompt per layer for a stack — then offers what a header makes possible: install it as a module, build a full image, or publish it to the community with the header attached so the pattern lands hardware-ready. +- **Recent works in Pattern Lab**: opening someone else's pattern replaces the canvas instead of stacking its layers onto yours, and what was in progress is parked in a ring of three. +- **Pattern Lab rebuilt as a layered, dockable editor platform.** The single-pattern lab is now a workspace for compositions: + - **Layer stack** — code-pattern layers and pixel-art layers composite bottom-to-top, each with visibility, opacity, and a blend mode (normal / add / multiply / screen). Layers select, rename, duplicate, reorder (drag), and report their runtime errors in place. + - **Layer masks** — any layer can flip from painting to masking the layer directly below it, binary on purpose (one compare per pixel on the ESP32): a pixel-art mask reveals wherever something is drawn, a code mask reveals where its ramp-colored output crosses 0.5 luminance — so the color ramp is the mask control. Invertible, stackable (masks intersect), and replicated 1:1 in flattened exports. + - **Per-layer color ramps with alpha** — the ramp is chained to its code layer (two stacked patterns keep their own palettes), and ramp stops carry an alpha value drawn over a checkerboard: a value field can fade to transparent and reveal the layers beneath ("Fade low" sets that up in one click). Recolor maps alpha by luminance too, so RGB patterns can become translucent layers. The default ramp is neutral black→white (Reset restores it), which maps a value field straight onto the mask threshold. + - **Pixel panel** — draw straight onto the matrix: pencil/eraser/line/rect/ellipse/fill with brush sizes, eyedropper (alt-click anywhere), a magic eraser with tolerance for background removal (contiguous or whole-layer chroma cut), undo/redo, zoom with middle-drag panning, pixel grid, and a live onion backdrop of the other layers for alignment. A select tool lifts a boxed region to move it (drag or arrow keys) and rescale it nearest-neighbour — pixels stay crisp — before stamping it back down. Image import places in context: the other layers animate behind the preview while you drag the image into position and scale it (wheel or slider), with fit modes, nearest/smooth scaling and source-corner background removal — Apply stamps over the layer's existing pixels. + - **Dockable panels** (dockview) — Preview, Layers, Code, Pixel, Gallery, Knobs, and Color Ramp move like Photoshop panels: drag tabs between groups, split, float. The arrangement persists to localStorage, with a Panels menu and one-click layout reset. + - **Flattened exports** — publishing flattens the visible stack into one standalone JS pattern: each ramp baked as an RGBA lookup table, pixel art embedded as base64 with a small portable decoder, blend/opacity/mask compositing inlined. The community sandbox and fork lineage consume it unchanged. + - **Compositions that survive sharing** — the published code also carries the FULL layer project as one `// @stack` comment line (deflate-compressed, size-guarded): the sandbox ignores it, but "Open in Pattern Lab" — or pasting the code — restores every layer, ramp, mask and pixel bitmap as an editable stack on top of whatever is in progress, with fork lineage intact. + - **Firmware .h for layer stacks, the safe way** — a deterministic C++ scaffold generator plus per-layer AI translation. Pattern Lab emits finished code for everything mechanical (layer buffers, one shared knob accumulator, RGBA ramp LUTs byte-for-byte, pixel art as RLE data, mask tests, blend compositing — same math as the preview) with compiling no-op stubs in marked slots; each code layer gets its own small conversion prompt whose output is just one `namespace L` block that the export wizard validates and swaps in. The model never sees the machine data it used to corrupt, and the two historic conversion bug classes — hand-rolled knob delta accumulation and reproduced color LUTs — are gone from the prompt entirely. The assembled header flows straight into the existing browser build + flash pipeline. + - **One shared knob set** — the four knobs stay global, like the four physical encoders: every code layer reads the same input (per-layer knob targeting stays open as a future mode). `@knobs` lines retune the shared set on load; range digit-dragging and encoder push buttons carry over, and the previous draft migrates into a one-layer project on first open. Gallery cards can also stack a generated variant as a new layer. + +### Fixed +- **The browser flasher was shipping firmware without the module loader.** It served the image built for 3.1.0, so anyone who flashed from the site got a board that could not receive a single pattern from the community. The stock image is now built from the release source and version-stamped per release. +- **A pattern named "Dynamic Moiré" could neither build nor load** — three separate faults, all from one non-ASCII character in a name: the toolchain scripts crashed printing it on a cp949 console, a loader guard tested names for printable ASCII and called anything else a relocation bug, and the panel's ASCII font drew it as mojibake. Names are UTF-8 everywhere now; only the panel folds them. +- **A pattern that fails to load says why.** `/api/status` reports `loadError` and the panel draws the reason, instead of a torn frame that reads as "the pattern is broken". +- **"Uploaded" means "verified"** — after writing a `.pfm` the device reopens it and checks it is structurally a module, which is what catches truncation. Bad files are deleted and reported rather than counted as success. +- **A never-used board can start.** The pattern partition ships unformatted; `/patterns` offers an explicit Format button exactly when the volume will not mount. Formatting is never automatic — an earlier auto-format wiped every installed module after a crash corrupted the FAT. + +### Known issues +- **Repeated uploads can wedge the device**, and the way out is a power cycle. Seen while installing many modules in a row. Not diagnosed — `/update/status` now reports `lastError`, `received`/`expected` and an attempt counter, so the next occurrence can be read rather than guessed at. +- **A firmware upload from the browser occasionally aborts partway** ("Connection lost during upload", ~29 % in the observed case) and succeeds on a retry. Two candidate causes were tested on hardware and disproved: request contention (an upload completes normally with another client polling every 2 s) and the page's own status poll. Same instrumentation applies. +- **Opening a console page still pauses the running pattern.** A resident module holds ~7 KB of the ~15 KB internal heap, dropping the largest free block to ~3 KB, and lwIP cannot deliver a 16 KB page from there. Three fixes were tried and are recorded in `firmware/README.md`; an async server would not help, since it shares the same heap. +- **A module runs ~20 % slower** than the same source compiled into the firmware — the cost of the relocatable code model, not something a compiler flag reaches. diff --git a/docs/releases/v3.3.0.md b/docs/releases/v3.3.0.md new file mode 100644 index 000000000..9b22f6fae --- /dev/null +++ b/docs/releases/v3.3.0.md @@ -0,0 +1,32 @@ +## [3.3.0] - 2026-08-09 + +Color, end to end: perceptual ramps in the editor and a panel you can actually calibrate. **Hardware unchanged** — v3.0 board and case carry over as-is. + +### Added +- **Runtime display calibration** ([#287](https://github.com/engmung/Patternflow/pull/287)): white balance, gamma and saturation were compile-time constants, so finding a number cost a reflash per guess. They are runtime state now, tunable live over `GET /api/display` while looking at the panel. Values are session-only; the converged ones go back into `config.h` as the shipped defaults. Compile the endpoint out with `-DPF_DISPLAY_HTTP_ENABLED=0`. +- **Calibration test card**, as an overlay rather than a pattern — knob browsing stays a curated list. `?screen=0..3` draws it over the running pattern (which freezes underneath) and `?screen=-1` resumes; a long-press on K4 or any pattern switch also dismisses it, so a closed browser tab can never strand the panel on a test screen. Four screens: white field at an adjustable level, a 16-step staircase over a smooth gradient, color bars at full and half drive, and sRGB-versus-OKLab ramp pairs shown on the actual LEDs. +- **[Panel tuner](https://patternflow.work/panel-tuner.html)** (`web/public/panel-tuner.html`): sliders driving `/api/display` beside a canvas rendering the same test frames in raw sRGB. The reference is the point — you cannot judge a color against a memory, only against another color. Generates a `config.h` block once the values settle. Plain HTTP on the device's LAN, so run it from a local dev server. +- **Remote pattern selection** — `GET /api/patterns/select?name=|index=`, through the same deferred-consume path OSC already used. +- **Perceptual color ramps in Pattern Lab and the community sandbox**: `oklab`, `oklch short` and `oklch long` join the existing modes. Blending complementary stops in sRGB collapses the middle into grey and sweeping hue in HSV makes lightness pulse; interpolating in OKLab does neither. Out-of-gamut blends are pulled back by lowering chroma at constant lightness and hue rather than clipping channels. Because ramps bake to a 256-entry lookup table web-side, the new modes reached the firmware, the C++ conversion and `.pfm` builds with no device-side change. The ramp panel gained a lightness strip and a monotonicity read-out. + +- **[LED panel compatibility guide](../../docs/panel-compatibility.md)** ([#258](https://github.com/engmung/Patternflow/pull/258), [#259](https://github.com/engmung/Patternflow/issues/259)): a panel matching our spec line for line can still stay completely black, because HUB75E is a connector and not a protocol — the driver ICs decide. The catch is that the driver IC is essentially never in the listing (we audited our own: it appears in the title, spec table, marketplace AI summary and attached PDF manual zero times), so the guide leads with what actually works — reading buyer reviews for anyone running it off an ESP32 — then asking the seller, with the chip taxonomy kept as reference for a panel already in hand. Includes a verified-panel table with photos, how to tell the LED driver from the buffer and the row driver on a board, and a symptom→cause table. Opened up by **[@SimonePDA](https://github.com/SimonePDA)**, who hit the failure and researched it properly. +- **`PANEL_PROFILE` covers all six library drivers** instead of two — `PANEL_STANDARD`, `PANEL_HIGHREFRESH`, `PANEL_FM6124`, `PANEL_ICN2038S`, `PANEL_MBI5124`, `PANEL_DP3246`. Existing values keep their numbers and the default is unchanged. + +### Changed +- **Shipped color defaults are now one panel's measured numbers**, not plausible ones: `LED_WB` 0.930/1.000/0.975 (that panel leans warm — red needed trimming hardest, not the green cut the folklore predicts) and `LED_SAT_BOOST` 1.62. The saturation direction reversed the theory that predicted it: narrow-band LED primaries cover more than sRGB, which argues for cutting saturation, but placed beside a monitor showing the same frame the panel reads washed out, and the boost held at both 14 % and 100 % brightness. **Existing devices will look different after this update** — more saturated, less warm. Panels vary; tune your own with `/api/display` and land the numbers in `config.h`. +- **Encoder acceleration removed — one detent, one step** ([#262](https://github.com/engmung/Patternflow/pull/262)). A fast spin used to multiply each detent ×2 to ×5. Compared against a linear build with a temporary per-knob tuning page (four curves live at once) and linear won outright — the multiplier was worst on the parameter that most needs landing on a value, since Origin's Mode knob picks a discrete preset and a quick turn skipped five at a time. OSC deltas had already been routed around it for that reason, which was the tell. +- **Knob travel now derives from the parameter's range.** It was a fixed per-knob constant that never looked at min/max — so a 0..1 knob crossed in one turn while a 0..100 knob needed a hundred, and the step handed to the C++ conversion inherited the same blind spot (which is why ~80 of ~120 step constants in the preset library are the identical `0.05f`). Travel is now `(max - min)` per two turns, so every knob crosses its range in the same motion whatever it drives. **Existing presets keep their compiled-in constants** — this lands on patterns generated from here on. + +### Fixed +- **Calibration tables no longer sit in internal DRAM** ([#288](https://github.com/engmung/Patternflow/pull/288)). The test card's ramp tables shipped as a 1.5 KB namespace static — the exact mistake `core_mem.h` exists to prevent. Statics lock internal DRAM from boot, and internal DRAM is what lwIP's page delivery starves on: with them resident every console page truncated at ~5.6 KB and module uploads failed. They allocate from PSRAM in `setup()` now, and `/patterns` delivers its full 16 KB in 0.4 s. +- **Encoder decoder resynced onto bounced states** ([#262](https://github.com/engmung/Patternflow/pull/262)). The quadrature ISR updated its reference state on transitions that cannot physically happen, so a bounced jump moved the decoder to the wrong phase and the following clean edges read as a missed detent or a step in the wrong direction. Illegal transitions are now discarded, plus a `ENC_BOUNCE_US` time filter — the A/B lines have no RC filter and only the ESP32's weak internal pull-ups, so the decoder has to do that work itself. +- **`ENCODER_CLICKS_PER_TURN` was 20; the reference encoder has 24.** It sat next to a comment promising one web rotation equals one physical turn, so the preview it claimed to mirror was 20 % out of step. The BOM part is a Bourns PEC11R-4220F-S0024 — the `S0024` suffix means 24 detents, confirmed across five distributors. Sourcing a different encoder? Generic EC11s ship as 20, 24 or 30, and this constant has to match what is actually in the build. +- **Panel-profile guidance was backwards.** `config.h` advised setting `PANEL_HIGHREFRESH` for an FM6126A/FM6124 panel — which would send anyone holding the reference panel to change a setting they don't need, since its `FM6124EJ` runs on `PANEL_STANDARD` with no init at all. The rule is now empirical: stay on the default unless the panel comes up completely dark. It also advised "swap to FM6124 if dark/distorted"; `FM6124`, `FM6126A` and `ICN2038S` all dispatch to the same `fm6124init()` with no per-chip branch, so that swap does nothing. +- **"Walk away if the listing mentions a receiving card"** would have rejected our own verified panel — that sentence is factory boilerplate in every LED module manual. The red flag is now scoped to the listing, not the PDF. +- Brightness is **K1** long-press, not K2 (`README.md`). +- Removed the Pattern/Video content mode from the docs — it was deleted from the firmware, but `firmware/README.md`, `docs/osc-spec.md` and the OSC address tables still described it. `/patternflow/content/toggle` is now documented as an accepted no-op. +- `firmware/README.md` long-press reference rewritten against the code: the K2 screen is NETWORK (not "OSC info"), its toggles are knob **rotation** not clicks, and K3 long-press is the KNOB MAP screen. +- `CUSTOM_PATTERNS.md` file tree updated to the `presets/` layout; `firmware/patternflow/README.md` no longer describes the removed hand-edited `custom1..3` slots or enumerate a preset list that had fallen thirteen behind. +- v2 build guide realigned to the current firmware: **USB CDC On Boot enabled**, browser flashing on the left/native USB port and Arduino IDE on the right. One image serves every board generation, and the site can't ship a per-version build. +- Panel mounting screws are **6–12**, not 6 (v2) or 12 (v3) — all 12 holes is the exact fit, 6 holds it firmly. +- The v2 BOM said the panel's ribbon and power cable are "both used as-is" while §6.2 tells you to cut the power cable. diff --git a/docs/releases/v3.4.0.md b/docs/releases/v3.4.0.md new file mode 100644 index 000000000..44cb57031 --- /dev/null +++ b/docs/releases/v3.4.0.md @@ -0,0 +1,26 @@ +## [3.4.0] - 2026-08-12 + +Patterns leave the firmware and live on the filesystem, so a board ships nearly empty — and a pack ships with it. **Hardware unchanged**; v3.0 board and case carry over as-is. + +### Added +- **A pattern set ships with Patternflow.** The **Basics pack** — 33 patterns — is built from `firmware/patternflow/presets/*.h` by the new `firmware/toolchain/make_pack.py`, committed to the repo at `web/public/packs/basics.zip`, and offered at the top of the community's decks shelf. No account, no build queue, and it works against a database that has never been seeded, because the person who just got their board lit has none of those. Built from our own sources rather than a ready-made set: every module carries an author its source actually declares, and the tool refuses to publish one that does not. `Patternflow` names the pack as publisher; the per-pattern author stays in each `.json`, where the licence expects to find it. Rebuilds are byte-identical, so an unchanged pattern set produces no diff. +- **A pack installs from a link.** `/patterns?src=` already ferried a build's file listing; it now also takes a plain `.zip`. Your browser fetches it, unpacks it and posts the modules to the board, so the device never needs TLS — it has nowhere near the heap for a handshake. This closes the deck loop too: a deck's download URL was a file you fetched and dragged, and is now one click. +- **Firmware updates have their own page** ([patternflow.work/update](https://patternflow.work/update)), reached from a banner on the device console when a newer release exists. Your browser downloads the image and hands it to the board over the LAN. Building a whole firmware image with a pattern baked into it is gone — a `.pfm` module does that job in 6 KB without a reflash. +- **A deck has a downloadable pack at a stable URL**, built once per running order and cached against a fingerprint of that order, so a deck nobody edits is compiled once ever. No sign-in: a public deck id is the capability, and the compile is charged to the deck's owner rather than to whoever pastes the link. +- **Install a whole pattern pack from one `.zip`**, contributed by **Simone Majocchi ([@SimonePDA](https://github.com/SimonePDA))**. A pack of `.pfm` + `.json` — what a community deck export produces — is unpacked **in the browser** by a vendored [fflate](https://github.com/101arrowz/fflate) and its members join the ordinary upload queue, so the device never sees an archive and its upload path is untouched. `catalog.txt` may ride along as the running order. Entry names are split on both `/` and `\`: Windows-made zips use backslashes, and without that the junk filter and the duplicate check silently stop matching, files upload under their full path, and the device accepts them, lists them, and loses them on reboot. +- **MQTT sidechannel** (`/mqtt`), also from **[@SimonePDA](https://github.com/SimonePDA)** — his protocol design, role model and topic layout. Knob clicks and the pattern name go out as retained topics (`/knob/1..4`, `/pattern`); a panel set to Subscriber follows them, so two panels stay in sync and Home Assistant sees plain values on plain topics. The role (Off / Publisher / Subscriber) is chosen at runtime and kept in NVS; compiling it in dials nothing until a broker host is set in `patternflow_secrets.h`. +- **Play a pattern from the list** — click a name on `/patterns`. **Arrange the running order** by dragging rows, then `Save order`, which writes the `catalog.txt` the registry reads. **`Select all`** beside the existing bulk delete. + +### Changed +- **Origin is the only pattern compiled into the firmware.** Every preset costs internal DRAM, and with 34 of them a 128×64 board had roughly 1 KB of headroom — `/patterns` returned a truncated page as soon as anything else wanted RAM, which is what made MQTT look impossible to fit. Dropping them frees ~6 KB of DRAM and 49 KB of flash: internal heap goes 11,052 → 16,648 and `/patterns` 1.98 s → 0.03 s. The sources stay in `presets/` as the editable originals and ship as a pattern pack instead. **Updating loses the built-in showcase until you install the Basics pack** — which is one click from the decks shelf. +- **The device console is dark**, and reorganised around what each page is for. The home page leads with a device card (now playing, patterns, storage, network, memory, MQTT, uptime) over grouped rows, two columns on a desktop screen, and tells you when a newer firmware exists — the check runs in your browser against the public flasher manifest, so the device still never talks to the internet. Audio sync is honestly labelled Early. The marketing site stays on paper cream; the device is the thing glowing in a dark room. +- **Pattern names on the panel wrap instead of running off the edge.** The SELECT screen is 64 px wide in portrait — about ten characters — so a community pattern called "Retro Digital Tapestry" was clipped at both ends, and the old size-2 branch clipped from six characters on. Names now word-wrap in the stock 5×7 font. (A narrower bitmap font fits more characters and could not be read at this pixel pitch.) +- **Clearing a library is one request instead of one per pattern.** Each delete used to make the device rescan FATFS and reload the resident module, so emptying fifty was a minute of watching rows vanish one at a time. The endpoint takes the whole list — or `*` for everything, which also catches modules the loader rejected at boot and that therefore never appear in the list — and rescans once at the end. Deliberately without collecting the names into an array first: a `String[128]` of filenames is ~11 KB against ~7 KB of free internal heap, so `*` reopens the directory each pass and carries one name at a time. +- **A deck holds twenty patterns** instead of ten. +- **The delete looks like it is working.** It is one request the device answers only when finished, so the page sat still for about five seconds, which is how a hang looks. The selected rows now dim on the click, a bar sweeps under the status line, and the message counts the seconds. The bar is deliberately indeterminate — the device reports nothing from inside the pass, so a percentage would be invented. + +### Fixed +- **Release images are scanned before publishing.** `net_config.h` bakes in whatever `patternflow_secrets.h` defines, and v2.1.0 and v3.0.0 shipped with the maintainer's home Wi-Fi credentials in plaintext because of it. Images are now built from a sketch copy with that file removed and scanned for every value it defines, with a build that still *has* the secrets used as a control — if the control passes, the scan is broken rather than the build clean. +- **The README described a device nobody has**: 34 built-in presets (it is Origin alone now) and a full-firmware path that carries a pattern along (there isn't one). +- **An unreachable MQTT broker no longer looks like a dead device.** Resolving the host and opening the socket ran synchronously in the render loop every 5 s, so a broker that stopped answering held the loop for seconds at a time — the panel still pinged and still accepted TCP, but served nothing. The address is resolved once and cached, the TCP connect and socket reads are bounded, and retries back off 5 s → 15 s → 60 s. +- **A subscriber no longer jumps when it joins.** A retained knob value was added to the local count rather than differenced against it, so a panel whose knob was not at zero landed at local + remote instead of matching the publisher. diff --git a/docs/releases/v3.4.9.md b/docs/releases/v3.4.9.md new file mode 100644 index 000000000..517eef8a1 --- /dev/null +++ b/docs/releases/v3.4.9.md @@ -0,0 +1,4 @@ +## [3.4.9] - 2026-08-18 + +### Fixed +- **The browser flasher was still serving v3.4.0, which predates the power clamp** — units flashed from the site between 08-12 and 08-18 could pull ~4.8 A at full white against the "max 2.4 A" printed on the box. This release moves the served image to a build that carries the clamp (`BUDGET_DEFAULT_MA = 2400`, enabled by default), the 13 dBm Wi-Fi TX cap, sleep mode (#314) and the boot latch (#316). Devices flashed from the site in that window need a reflash to pick the clamp up. diff --git a/docs/releases/v3.5.0.md b/docs/releases/v3.5.0.md new file mode 100644 index 000000000..cb1b75d70 --- /dev/null +++ b/docs/releases/v3.5.0.md @@ -0,0 +1,4 @@ +## [3.5.0] - 2026-08-18 + +### Changed +- **Releases build on Arduino core 2.x (IDF 4.4) via PlatformIO** (`firmware/patternflow/platformio.ini`). Core 3.x occupies ~71 KB more internal RAM before the sketch starts — mostly cache carve-out — and on this board that was the difference between a large community pattern loading and being refused, and between a heavy pattern running and the console dying. Measured after services: 15,320 B free / 7,668 B largest block on core 3.3.8 against **98,708 / 90,100** on core 2.0.17. No firmware source changed; a `.pfm` needs one contiguous internal executable block, so the largest-block number is the ceiling on pattern size. Local builds should install ESP32 board package **2.0.x**, not latest — the build guides now say so. diff --git a/docs/releases/v3.5.1.md b/docs/releases/v3.5.1.md new file mode 100644 index 000000000..501b59cb5 --- /dev/null +++ b/docs/releases/v3.5.1.md @@ -0,0 +1,4 @@ +## [3.5.1] - 2026-08-19 + +### Fixed +- **Every wireless upload was paying a fixed five-second stall.** The core 2.x `WebServer` raw-body loop asks `readBytes()` for a full buffer even on the final chunk, so every pattern install, sidecar and catalog write sat out the 5 s stream timeout (a 447-byte file took 5.4 s; a body of exactly 1,436 bytes took 0.4 s — that probe is what convicted it). The library is now vendored under `src/webserver/` with the core 3.x read-exactly-remaining behaviour; a 29 KB pattern installs in 0.8 s and `/update` doubled to ~107 KB/s. The full investigation — where the core 3 heap went, the dead ends, the probe method — is written up in [docs/investigations/2026-08-core2-heap-and-the-5s-stall.md](../../docs/investigations/2026-08-core2-heap-and-the-5s-stall.md). diff --git a/docs/releases/v3.5.2.md b/docs/releases/v3.5.2.md new file mode 100644 index 000000000..4fdb8e994 --- /dev/null +++ b/docs/releases/v3.5.2.md @@ -0,0 +1,4 @@ +## [3.5.2] - 2026-08-19 + +### Fixed +- **`cosh` and its relatives now resolve, so soliton patterns stop reading as impossible.** The module loader's host-symbol table carried `tanhf` but not `sinhf`/`coshf` — and sech(x) = 1/cosh(x) is the closed form of a soliton, so an entire genre of wave patterns failed to load with `unresolved symbol: coshf`, which looks exactly like "too heavy for the board". A systematic audit (every Basics-pack module plus a synthetic probe of the full libc/libm surface) added 40+ names in bulk: both hyperbolic families, `erff`/`erfcf`, the `rintf` family, 64-bit integer helpers, `qsort`/`bsearch`/`strto*`, string/ctype, and an `atexit` shim. `build_module.py` now checks every `.pfm` against the loader's own table at build time, so a pattern that the device would refuse fails on the build machine with the missing names spelled out. The Pattern Lab C++ prompt learned the new reality too: lookup tables are the house style (measured: 0–64 KB of static LUTs all render at 62 fps; a 4 KB sech² table took a soliton from 18 to 33 fps), and the old ~2 KB static ceiling — set when the largest free block was 7.7 KB — is now ~32 KB. diff --git a/docs/releases/v3.6.3.md b/docs/releases/v3.6.3.md new file mode 100644 index 000000000..3397e484f --- /dev/null +++ b/docs/releases/v3.6.3.md @@ -0,0 +1,47 @@ +## [3.6.3] - 2026-08-25 + +The performance release. It started as groundwork to meet **Simone Majocchi's ([@SimonePDA](https://github.com/SimonePDA)) performance-director work**, and ships as the whole thing: his on-device show player, scheduler and weather stack integrated onto this tree's core, a show-authoring Director inside Pattern Lab, and a show format that finally moves the way the curves were drawn. A pattern, its show, its firmware port and its published post now share **one name**, end to end. + +### Added +- **The panel plays shows by itself.** Simone's performance-director firmware is integrated whole: `.pfs` show tables install from the new **Sequences** console page (`/show`), play on a wall-clock (cues fire by `millis()`, not by frame), chain into playlists with per-run variance, and follow a night/wake schedule with a dimmed clock face. His weather stack (`/weather`), his `MatrixLight6/8X` console fonts and his **FlowLocal** MQTT appliance mode (off by default, hard backoff, anti-brick guards kept) came along. The show buffers live in PSRAM, so a 256-cue show costs the pattern engine nothing it can feel. +- **A show pauses and resumes in place.** Pause banks the wall clock, resume re-bases it, so cues — and a v2 ease mid-ramp — continue exactly where they stopped, and the paused look stays on the panel (absolute holds are kept). The Sequences page carries the Pause/Resume button; `POST /api/shows/control` accepts `op=pause` / `op=resume`; status reports `paused`. Start with nothing ticked does the obvious thing too: it continues a paused show, or replays what is loaded, instead of scolding about checkboxes. +- **Pick which saved network the panel boots on.** Slot order is recency, which is wrong the moment a panel lives somewhere it was not last provisioned — every boot then fails on an absent network before the walk wraps around. `/wifi` grows a Boot network picker (stored in NVS, applied on the next boot) and a Reboot button. Ported from @SimonePDA's tree. +- **PFST v2 — the show file learned to glide.** Version byte 2 reinterprets cue time as **deciseconds** and adds one cue flag, EASE: *lerp the set channels toward each channel's next cue*. Byte layout unchanged, v1 files play exactly as before, and the player's cost was measured on the panel at **zero** — frame time is indistinguishable idle / v1 / v2 (15.7–16.0 ms), because the lerp is a pure function of the clock. A 60-second authored curve that took 61 staircase cues ships as 11 eased pieces within 0.8 % of the drawn bezier. The full proposal with the hardware table is [docs/pfst-v2-spec.md](../../docs/pfst-v2-spec.md). +- **Pattern Lab grew a Director panel** — show authoring where the pattern already lives. Four knob lanes plus a message lane; double-click drops a keyframe, segments hold or ease along **Blender-style bezier handles you grab and pull**. Keyframes sit on the v2 wire grid (0.1 s) — the **snap toggle** (1 s / 0.5 s / 0.2 s / 0.1 s) is an authoring aid, not a format limit — and playback drives the real knobs through the authored curves, so the live preview *is* the show. Export downloads the `.pfs` the panel plays; import round-trips a v2 file back into editable segments exactly. Publishing a pattern offers to attach the show on the community's performance rail. +- **The piece has a name, and everything answers to it.** A single name field sits center of the Pattern Lab header. To hardware pins the C++ `NAME` to it — and stamps it back onto a pasted translation when the AI "improves" it, which is not hypothetical: a show authored for `HexagonalHivePulse` once animated nothing because the model had quietly retitled the port `HexHivePulse`. The Director names the `.pfs` after it, **stamps it into the show's opening cue so the device switches to the right pattern the moment the show starts** (verified on hardware), and Share opens titled with it. Projects from before the field existed inherit the name they were already living under. +- **One console chrome.** Every device console page now loads a single `/pf-console.js` that owns the header band, the nav — and a **light theme**, one toggle on every page, remembered across all of them. Adding a console page to the nav is now one edit in one file, which is how `/show` stopped being a page half the console never linked to. +- **A pixel drawing copies out as pattern code** — the Pixel layer's *Copy as code* hands the drawing to whatever AI you use as an editable, commented pattern program, and the layer stays put. The C++ conversion prompt also honours the layer's **Recolor** toggle with pre-baked ramp LUTs. +- **Your own post reopens as itself.** Opening one of your published patterns in the lab now marks the session as *editing* that post — Share updates it in place, same page, same likes and forks — instead of publishing a fork of your own work. Detach with one click if you did want a new post. +- **A moderator can repair a broken `.h`** — and only the `.h`: the firmware port on a community pattern can be fixed in place when it will not compile, while the title, the JavaScript and the licence stay exactly the author's. A pattern with no port cannot grow one this way. +- **Pattern Lab grew a Capture panel** — the pattern as a picture or a clip, not only as a thing that goes on the panel: a PNG for a business-card background, an MP4/WebM loop for a post, at 1050 × 600 @ 300 dpi, Full HD, 4K, square, vertical, or any size up to 4096 px, turned 90°/180°/270° if the panel would hang that way, over black, a colour, or real transparency (unpainted areas clear, or dark-to-clear so it glows on white paper). Pause on the frame you want, step frame by frame, run it at ¼× to 4×. Clips render offline at a fixed 24/30/60 fps, so a frame that takes half a second to draw is still one frame in the file. All of it runs in the browser, in a Web Worker with its own engine — the live preview keeps its 60 fps while the stage grinds through 4K, and nothing is uploaded anywhere. + The interesting part is **what "re-render at a bigger size" means for pattern code that was written for 128 × 64.** Most of it was: `cx = 64`, `Math.sin(x * 0.1)`, a `Float32Array(128 * 64)` — and run at 1024 px such a pattern comes back as four tiles, a small picture in a corner, or nothing, with no way to tell from the source which it will be. So the *Auto* look runs a probe first: the stack at its own matrix and at an aspect-true multiple, both box-filtered back to the matrix, compared on texture density and layout. Measured over all 46 bundled presets, the dozen that scale cleanly and the rest separate with no overlap. Those that scale are re-rendered for real; the others are upscaled as crisp pixel blocks at the requested size with the verdict spelled out, plus a **Copy any-size prompt** button that asks whatever AI you use to rewrite the pattern in frame-relative units — the same copy-paste loop as the C++ conversion. Paste the answer back and Auto re-checks it by itself. +- **Home Assistant integration** (`custom_components/patternflow/`) — a panel shows up as a device, discovered over mDNS, with an On/Sleep switch, a pattern picker, the four knobs, and the numbers worth watching. It also ships a **dashboard card**: the running pattern playing live with the four encoders laid over it as zones you drag — mouse or touch, unlike the community wall's card, which is wheel-only and therefore does nothing at all on a phone. Everything runs on the LAN over the device's own HTTP API. No cloud, no account, and **no broker for anything except turning a knob**. + That exception is the whole shape of this. Knob *positions* are readable over plain HTTP — `/api/mqtt` reports them in any MQTT role and with no broker configured, because the firmware copies the input frame into that state before it checks the role. Knob *writes* have no HTTP path at all: `/api/knob` and `/remote` were built and removed as unused, so a write goes out over MQTT and only a **Subscriber** obeys it. Setup offers the role change when that is the only thing missing, and says what it costs — a Subscriber stops publishing its own knob turns. A refused write names which of the four ways it was refused rather than failing quietly. + The card **never asks the panel for pixels**, which is not a shortcut but the design: a device-streamed frame preview was built, shipped and removed the same day because polling it captured the render loop until the device read as dead. So the preview is the pattern's own JavaScript running in a sandboxed iframe in your browser, driven by the values Home Assistant holds — exactly what `core_patterns_http.h` says a live preview should be if one ever came back. The card bundles the Basics pack's 33 patterns, matched to the modules on your device by slug; a community pattern or a preset shows the controls without a picture rather than an error, because a device stores compiled modules and no source. Installable **through HACS** as a custom repository: the component sits at `custom_components/patternflow/` in the repository root, because that path is hardcoded in HACS and nothing in `hacs.json` can point elsewhere. Its tests, lint config and scripts stay in `integrations/homeassistant/`. Each release carries a `patternflow-homeassistant.zip`, which is what HACS actually downloads, and the project version is stamped into the manifest on the way in. +- **The device's HTTP API has a written contract**, `docs/rest-api.md`, alongside `docs/osc-spec.md`. It documents what exists today and, more usefully, the rules that are invisible from an endpoint list and have each already cost a feature: the server takes **one connection** and pauses drawing while it answers, so automated clients keep to `/api/*`; and `POST /api/sleep` and `/api/patterns/select` report the state **before** the transition, because stopping a DMA engine does not belong inside an open response. Also written down: that there is no MAC, serial or unique id on any endpoint, so two panels need distinct `PF_OTA_HOSTNAME` values. (The third rule it originally recorded — console pages evicting the pattern for 25 s — died during this release; see *Changed*.) +- **Sleep mode** — the panel goes dark and the board idles without anything being unplugged. There is an **On / Sleep switch on the device console's home page**; you can also turn K1 on the NETWORK screen, publish to MQTT `​/sleep`, or `POST /api/sleep`. Any knob turn or button press wakes it, as does an incoming firmware image. Waking gives the pattern straight back even when the open console was holding it paused. It stays **on the network** throughout, which is the whole design: `esp_deep_sleep` would take the draw to microamps and take the radio with it, and then "lights out from the sofa" has no way back. So the LEDs go off, the HUB75 DMA transfer stops (the driver ICs are otherwise clocked at 15 MHz whether or not anything is lit), Wi-Fi drops to modem sleep, the CPU to 80 MHz, and the loop yields instead of spinning. The device publishes `​/sleep/state` on every change, so Home Assistant sees it — and obeys the command in **either** MQTT role, because a panel that publishes its knobs is still a panel you want to be able to switch off. `PF_SLEEP_STOP_DMA 0` in `config.h` is the fallback if a particular panel comes back garbled. +- **The selected pattern survives a power cut.** It used to start at Origin on every boot. It is saved as a **slug**, not an index, because installing or deleting a single `.pfm` renumbers the list and an index would come back as somebody else's pattern; the write is debounced so spinning K4 through fifty patterns is one NVS write rather than fifty. A remembered pattern that no longer exists — or no longer loads — falls back to Origin with a log line instead of greeting you with the PATTERN FAILED screen. +- **Performances live under patterns**, on the same social rails as firmware ports: anyone records a timed knob ride in the Director tool and publishes the Save-JSON on the pattern's page — live immediately, credited to the recorder, with the author's own recording (or their pin) deciding which one represents the pattern. Each recording downloads as the editable `.json` or the packed `.pfs` the panel's player reads. +- **Like from the wall.** Every pattern card grew a heart beside its add-to-deck button — the count updates in place, and signed-out visitors get the sign-in modal at the moment they click, same rule as everywhere else. +- **Packs carry performances.** A deck owner can attach a Director performance JSON to their deck; the pack zip then includes `performance.json` (the editable source) and the encoded `.pfs` show table. Today's firmware simply ignores the `.pfs` on install — the pack stays a normal pattern pack — and the performance firmware picks it up the day it ships. The server-side PFST encoder is byte-identical to the Director's own saves. +- **Absolute parameter bus (0..1000), prepared.** MQTT `​/param/1..4` can pin any knob of an absolute-ready pattern to an exact value; physical encoder motion releases the hold, and plain deltas keep working exactly as before. Patterns opt in through one `PFParams::apply` line per knob — every preset, the Basics pack, and all convertible community headers are converted, and everything the site generates is absolute-ready from birth. `/mqtt` grew channel presets (Broadcast / Ch 1–4 / Live) with retained per-channel snapshots, and a **Director mode** that points the panel at a local authoring broker without disturbing the saved one. + +### Changed +- **Opening a console page no longer pauses the pattern.** The 25-second eviction was the honest price of serving whole pages from the core-3 builds' ~15 KB of free DRAM; the core-2 builds freed ~96 KB, and the page sender now streams PROGMEM in small slices under a hard 5-second budget, tolerating socket stalls instead of holding the loop. `status.consolePaused` still exists and now means exactly one thing: a pattern-install batch is in progress. `docs/rest-api.md` is updated to match. +- **Module ABI descriptor is now 2** (the appended absolute-param fields). The loader accepts 1 and 2, so every existing `.pfm` keeps loading; **pre-absolute firmware refuses new modules cleanly** instead of misreading them. This was the gate that tied the release to the performance firmware — and this is the release that ships both. +- **MQTT retention now follows the show policy**: knob/pattern topics publish non-retained; the broadcast banner and channel snapshots are the retained exceptions. +- **Release builds pin `platform = espressif32@7.0.1`** so a machine with a different PlatformIO fork installed cannot silently resolve another core. + +### Fixed +- **A show could not move the knobs of a pattern built by the site.** The module build targeted ABI 1 unless `PF_TARGET_ABI=2` was set, and at ABI 1 `pf_params.h` compiles the absolute tier *out* — so a converted pattern declaring `ABSOLUTE_READY = true` shipped unable to read the absolute bus. A Director show then set the parameters, the device held them (`paramActive` true, values sweeping), the pattern ignored them, and every layer reported success: the show ran and nothing moved. Builds now target ABI 2 by default, which the loader has accepted since v3.5.1 and the browser flasher has served ever since; `PF_TARGET_ABI=1` still builds for firmware older than that. Caught on the first show authored end to end in the lab, and folded into this release. +- **The reconnect path deleted the Wi-Fi network that had just worked.** `addNetwork` took its arguments by reference and the reconnect path passes a saved slot itself, so the shift loop overwrote the referenced string mid-move: joining the second saved network turned `[home, studio]` into `[home, home]` — the working network gone, the absent one duplicated, and the panel unreachable after the next reboot. It now copies before it shifts, and duplicate slots already written into NVS are healed on load. +- **The panel clock was documented as 15 MHz and it is 16, which is the wrong place to look for it.** `HZ_15M` is `16000000` and `HZ_10M` is `8000000` — identical to `HZ_8M`, so there is no step between 8 and 16 MHz at all. Both the `core_display.h` EMC warning and the firmware README repeated the enum's name instead of its value, which sends anyone hunting harmonics on a spectrum analyser to 15 MHz and its multiples rather than 16, and made "drop to `HZ_10M`" read as a small step when it halves the clock. Corrected in both, along with the trade the two settings actually make: `i2sspeed` and `min_refresh_rate` move together, and dropping the clock while leaving `min_refresh_rate` at 240 is the worst case — it forces the library's maximum colour-depth sacrifice to hold a refresh rate you were not keeping. The measured table for this panel is now in the README. + This surfaced because **cartoonmonkeystudio (CE), on Discord**, found that the same clock was desensitising their unit's Wi-Fi — 83–95% packet loss on their board — and bisected it properly, down to a display-free sketch behaving normally on the same hardware. Reproduced here on a second board at much lower severity (no packet loss either way, but median latency 15 → 4 ms at 8 MHz), and **not adopted**: every `min_refresh_rate` that preserves colour depth bands on video at 8 MHz. Their patch also raises the Wi-Fi TX power, which cannot be taken — 13 dBm is a conformance setting, not a conservative default. The measurements, the parts that cannot ship, and what to try first if your unit's Wi-Fi is bad are written up in [docs/investigations/2026-08-the-panel-clock-and-the-wifi-radio.md](../../docs/investigations/2026-08-the-panel-clock-and-the-wifi-radio.md). +- **A board that has never been formatted let you try to install patterns anyway, and only said so eight failures later.** Pattern storage is a FAT volume nothing has ever written to on a new device, so it will not mount until somebody formats it once — deliberately, because an earlier revision formatted automatically on a failed mount and one crash mid-write turned that into "the next boot wipes your library". The prompt for that one deliberate act was a grey `Format storage` button sharing a row with `Retry failed` at the foot of the upload column, annotated in the smallest type on the page, and nothing stopped you dropping files past it — so the first thing a new panel did was refuse a whole batch, one `filesystem not mounted` at a time, which reads as "uploading is broken" rather than "press that". An unmountable volume now announces itself above both columns, with the button in the banner and the reason written out; the drop zone goes inert and says why when clicked, instead of accepting work that cannot land; and a `/patterns?src=…` install link from the community is **held rather than spent** — the page waits for the volume to be usable and then runs the install by itself, so arriving from a pattern page on a fresh board is still one click. The format warning is written for the state it actually appears in, too: the device cannot list what is on an unreadable volume, and on a new board the honest answer is that there is nothing there to lose. Reported by akacoda on Discord, from a first install. +- **Sharing a layer stack with a pixel layer on top failed — two ways, and one of them looked like success.** A pixel layer flattened as 44 KB of raw RGBA, so one imported image (or two pixel layers of anything) pushed the published code over the community's 100 KB cap and the modal said "missing or over 100KB" with no hint why. Pixel layers now embed run-length coded, the same `{count, r, g, b, a}` shape the `.h` exporter writes — an empty or flat-filled layer is a few hundred bytes, an imported photo falls back to raw — the modal says what is too big *before* the round trip, and the editable `@stack` line is dropped only when the total would not fit, instead of the whole publish being refused. + The worse one: the publish route strips licence wrapping before storing, and its footer pattern deleted from the **first** "Made with Patternflow" line to the end of the file. Every preset and every AI variant ends with that footer; flattened, it sits mid-file inside the layer's function with the composite `draw()` after it. The upload returned 201 and stored half a pattern — "render error" on the card, a SyntaxError on its page. A footer is now a footer only when nothing but comments follows it. `npm run check:flatten` and `check:license` cover both. +- **Coming back to the wall from a pattern no longer hammers the server.** Three things fired at once on Back: the feed rebuilt its scroll position in viewport-sized batches (sixteen requests for a 300-card feed, each a full-source page with four correlated counts per row), every card entering the viewport prefetched its detail page — a `force-dynamic` route, so each prefetch was a server render of the community layout, and the href changed on every wheel tick over a card, so *each tick* registered another — and each of those ticks also queued a fresh thumbnail render behind one shared iframe at five seconds' timeout apiece, with the queue and its cache unbounded and outliving the page. On a Raspberry Pi with a synchronous SQLite this was a freeze that read as "the server went down". The rebuild now fetches full pages (seven requests at most), card links do not prefetch, a card re-renders its still only when the cursor leaves it, withdrawn renders leave the queue, and the cache forgets past 400 stills. `docs/SERVICES.md` now also says the systemd units need `Restart=always`, which nothing had written down. +- **A repeated `?k=` on a pattern page was a 500**, the detail page queried the pattern twice per view (metadata and body), and a pattern accepted just under the size cap was refused on an unchanged re-save because the cap was measured with the licence wrapping still on. +- **An AI conversion no longer forces a color ramp onto an RGB pattern** — the "does this pattern use the value field" check read the whole source, so a mere mention of `setValue` in a comment recolored a pattern that painted its own RGB. The check now blanks comments and strings first. +- **Fractional Director keyframes survived export but not a reload** — the autosave restore path rounded keyframe times to whole seconds, silently flattening a 0.1 s-grid show back onto the 1 Hz grid. Restores now keep the wire grid. +- **The show list reported v2 lengths in raw ticks** (a 90-second show read as 900) — the header scan now divides by the version's tick rate. diff --git a/docs/releases/v3.7.0.md b/docs/releases/v3.7.0.md new file mode 100644 index 000000000..7ef860662 --- /dev/null +++ b/docs/releases/v3.7.0.md @@ -0,0 +1,25 @@ +## [3.7.0] - 2026-08-27 + +The release where a firmware can have a name. Everything Patternflow does still ships on the board — MQTT, sequences, weather, OSC, audio, all of it — and now the same tree can also publish *named* firmwares for the things the default cannot carry: a microphone that needs four wires soldered on, a radio setting that must not be universal, a build somebody needs pinned so a show behaves the same at the next gig. + +**Nothing left the core, and nothing is going to.** An earlier plan would have moved features out into separately-owned repositories; that was withdrawn before it happened, and the measurement that killed it is in the changelog below. + +### Added +- **A named firmware is two files, and it never edits a core one.** `addons_local.h` says which addons a firmware has, in what order, and may drop or reorder the defaults; `overrides.h` reaches any `#ifndef`-guarded setting — transmit power, panel clock, the firmware's own name — because it is included before any default applies. Both are gitignored here and belong to whoever built the variant, which makes taking a core update a file copy rather than a merge, forever. Measured three ways: default 1,412,457 B, a sound-only build 1,133,925 B, `PF_ADDONS_NONE` 1,094,813 B. +- **A panel says which firmware it is running.** `/api/status` reports `variant` and `variantVersion`, and the console wears the variant's name as a badge next to the wordmark on every page, linking to its entry on the new [variants shelf](https://patternflow.work/variants). Core shows nothing — it is the default and a badge on every panel would say nothing. The core version is now labelled `core v` beside it, because unlabelled it read as the variant's and was wrong. +- **The update banner stays quiet on a variant.** The manifest here describes *core* releases; offering one to a panel running somebody else's firmware would talk a person into flashing away the thing they chose, on a version comparison that means nothing across two release lines. +- **`/api/status` reports knob positions, the parameter bus and transmit power.** Knob state used to be readable only through `GET /api/mqtt` — inside an addon — so a build without MQTT could be written to but not read, and an HTTP-only integration would have lost it the day MQTT moved out. `txDbm` joins it because a variant can now change the radio. +- **A shelf you can install from.** [patternflow.work/variants](https://patternflow.work/variants) lists core and every listed variant as a card, with one-click install for images served from here — the panel's own `/update` page does the fetching, the same trick the core updater already used. Hosted copies are checked against the maintainer's latest GitHub release and stand down when they have drifted. + +### Changed +- **The split was withdrawn before it shipped.** Steps 1–4 of the RFC were right and they are in this release; step 5 — deleting features from the core — is cancelled. A build with three addons removed was measured beside the full one: the ceiling on loadable patterns is **identical** — 73,716 bytes either way — on a board using 45 % of its flash. Stripping all five moves it 18.4 KB, in a build with no shows, no MQTT, no weather, no OSC and no sound that nobody would ship; the largest pattern module anyone has built is 29 KB — the 42-pattern community library tops out at 17.5 KB — against a ceiling already two and a half times that. The case for a slim core rested on headroom that was never scarce. It would also have redefined a product already sold — the funded page lists MQTT and bidirectional OSC as things the device does — and, worse for the stated motive, a separate repository is precisely where trees drift apart. In this tree, widening `observeFrame` for OSC broke MQTT in the compiler a second later and was fixed in the same commit. Elsewhere it would have rotted in silence. +- **The formats are frozen.** The `.pfm` ABI and the `.pfs` show format do not move: fields are appended, never reordered, never reinterpreted; old files play forever; anything else takes a version byte and the old version keeps working. This was already the practice — it is a promise now, which is the guarantee a performer actually needs. Not "the firmware never changes", but "the files keep working". +- **OSC is an addon.** It stayed in the core on the grounds that it "needs no infrastructure", which does not survive being said out loud — a panel running a standalone AP transport needs none either. OSC is how a panel talks to Max, TouchDesigner and Ableton; it is sound integration and it belongs with the rest of it. Nothing is stranded: a build without it still updates, still runs every community pattern, and still takes remote control over HTTP. +- **The console pages are HTML files.** They lived inside `R"HTML(...)"` literals, which is the right way to ship them and the wrong way to work on them. `firmware/patternflow/console/*.html` plus a mock device on `localhost:8322` means save-and-refresh with devtools; a generator splices them back and CI checks the two stay in sync. + +### Fixed +- **Remote pattern picks are remembered again, and show cues still are not.** An addon that *claims* the pattern owns the panel while it runs, so its choices are transient; one that only *asks* is relaying a person, and that should survive a reboot the way turning the knob does. The distinction existed and was simply not reported. +- **Three places a removed feature stayed visible**: a preset hardcoded from another addon's directory, so a firmware with no show player still carried its Black pattern; a pattern counter that counted entries it would never stop on, so a list with one hidden entry read "1 / 3" then "3 / 3"; and a status row reporting MQTT on builds without it. All the same shape — the build stopped carrying something and the interface never found out. +- **The home page ignored `caps`.** The nav learned about capabilities; the page body never did, so a stripped build still advertised four features it did not have. Rows now disappear with the features, groups left empty disappear with them, and what remains is renumbered. +- **Adding a Wi-Fi network could delete the one that was working.** `addNetwork` took its arguments by reference and the reconnect path passed the stored entry itself, so the shift loop overwrote it mid-call. +- Eight console pages carried an orphaned CSS block — the body of a deleted rule — costing a parse error and 492 bytes on every page. diff --git a/docs/releases/v3.7.1.md b/docs/releases/v3.7.1.md new file mode 100644 index 000000000..27f33f787 --- /dev/null +++ b/docs/releases/v3.7.1.md @@ -0,0 +1,12 @@ +## [3.7.1] - 2026-08-28 + +The 3.7.0 seam shipped without the line that runs it. + +### Fixed +- **Every addon's per-frame hook was dead.** `PFAddons::loop()` is not called anywhere in 3.7.0. The addon port moved the per-frame work out of the sketch — correctly, it belongs to the addons — removed the concrete calls that used to do it, and never added the dispatcher that replaced them. Two orphaned comments were left where the calls had been. So the show player never ticked, MQTT never pumped, weather never fetched, and the audio websocket never completed a handshake. **Nothing failed loudly:** `onNetwork` still ran, so routes registered and port 81 opened, TCP connected, and the console's audio page loaded and said DISCONNECTED forever. Every addon was present, initialised, and frozen. On-board audio was the exception — `audio_in` runs on a Core 0 task, not the loop hook — which is why this read as an audio problem rather than a seam one. Verified on hardware: a raw websocket handshake was an 8 s timeout before and `HTTP/1.1 101 Switching Protocols` in 121 ms after; `/api/shows`, `/api/weather` and `/api/mqtt` all answer again. +- **The audio page's file picker worked exactly once per load.** `createMediaElementSource()` can be called once per media element, ever; calling it for a second file throws, so the first track analysed and every one after it did nothing while still playing. +- **One empty FFT bin froze the audio meters for the session.** `getFloatFrequencyData` returns `-Infinity` for a bin with no energy, which makes the band average `-Infinity` and then NaN inside the smoother — and NaN is absorbing, so nothing recovered until a reload. +- **The connected-client count only ever went up.** It was kept by hand and a client whose TCP died without a close frame never produced the event that decremented it, so an idle panel reported one client connected. The websocket library already knows; it is asked now. + +### Added +- **`/api/status` reports `audioRuntime` and `audioClients`.** Whether audio was switched on at all was invisible from outside the device: a browser could hold a socket open, send knob messages, and have every one dropped because the AUD row on the panel was off, with nothing anywhere saying so. diff --git a/docs/releases/v3.8.0.md b/docs/releases/v3.8.0.md new file mode 100644 index 000000000..d448304fe --- /dev/null +++ b/docs/releases/v3.8.0.md @@ -0,0 +1,20 @@ +## [3.8.0] - 2026-08-31 + +One instrument, three editions. The firmware split into **editions**: compositions of features over one unchanged core, installable in one click from [patternflow.work/variants](https://patternflow.work/variants) and switchable any time without losing patterns, networks or settings. + +### Added +- **Editions.** `firmware/patternflow/features//` holds every feature behind one hook struct (`pf_feature.h`); the core names none of them. An edition is two files under `firmware/bundles//` — which features compile in, and what the build calls itself. Three ship: **Patternflow** (core: patterns, four knobs, and the most contiguous memory a pattern can get), **Patternflow Audio** (on-board PDM microphone, OSC, the browser and phone audio paths), **Patternflow Performance** (sequences, MQTT in every role, FlowLocal and the Director, weather — Simone Majocchi's work, running as a composition of this tree). The full reasoning is `docs/EDITIONS.md`. +- **The tree speaks the vocabulary EDITIONS defined.** `addons/` became `features/`, `PFAddon` became `PFFeature`; the old spellings are shimmed for out-of-tree bundles. +- **The boundary grows teeth.** `firmware/toolchain/check_boundaries.py` fails CI when the core references a feature namespace or branches on a feature flag — and caught its first violation on its first day. `check_abi_freeze.py` locks the module ABI; what the core promises modules is now written down under `firmware/patternflow/abi/`. +- **The default build stops claiming features it does not have.** The console's edition card, `/api/status` and the panel's info screens describe the firmware that is actually running. +- **A new board and the shelf install the same thing.** The browser flasher and the one-click edition install draw from one set of images, staged by `firmware/bundles/shelf.sh`. +- **The on-board microphone works in the actual device**, with a gain stage (Espressif's PDM path is quiet), auto-ranged bands over a measured noise gate, and bands that ride their own envelopes. +- **The mapping editor.** Bands are boxes drawn on the live spectrum; response curves — rise, fall, gate, steps, arch, hand-drawn — bake to 33-point tables the firmware only interpolates. Glide ballistics (attack and damping, set separately) and an in-editor preview that runs test signals through the chain. The device's `/audio-in` console page is assembled from the extension's editor, so the two cannot drift. +- **An Android capture app** (`tools/patternflow-audio-android`): what the phone plays drives the panel, with the mapping fetched from the device. + +### Fixed +- **The input frame was never initialised**, and only the edition split made it show: a build without a feature that happened to zero it read garbage on the first frame. +- The console's old `/audio` page, which could never have worked against the port-80 server, is gone; `/audio-in` is the page. + +### Changed +- `PF_IMPROV_FW_VERSION` is `3.8.0`; the Audio edition ships as v0.4.0 and Performance as v0.2.1, on their own version lines. diff --git a/docs/releases/v3.9.0.md b/docs/releases/v3.9.0.md new file mode 100644 index 000000000..5d642d57d --- /dev/null +++ b/docs/releases/v3.9.0.md @@ -0,0 +1,32 @@ +## [3.9.0] - 2026-09-03 + +A tidy-up under the hood. **Nothing changes in how the panel is used**: the same knobs, the same console, the same shelf, the same patterns and `.pfm` modules. The tree was audited end to end after two months at three hundred commits a month, and this release is what that produced — documentation that describes the code as it is, CI that runs every check that existed, firmware settings filed with the feature that reads them, a web `lib/` with a shape, a Pattern Lab whose panels are registered in one place and tested under a DOM, and the Home Assistant integration moved out to its author's own repository. Two things did arrive on the shelf during this cycle — the Audio edition's network MIDI (v0.5.0) and the panel answering to more names — and they are listed below; their proper write-ups, with the walk-throughs they deserve, come with the feature releases to follow. + +### Added — Audio edition v0.5.0 +- **The panel is a MIDI device.** A new `midi` feature (`features/midi/`) makes it an RTP-MIDI port over Wi-Fi — macOS/iOS natively, Windows through rtpMIDI. CC 20–23 pin the four knobs absolutely (the same controllers the Director's `.mid` export writes, so a Live clip made from a show now drives the panel), CC 24–27 turn them relatively, notes 60–63 are the buttons, Program Change picks the pattern; the encoders, buttons and pattern changes go back out the same way. `POST /api/midi?outDiv=N` sets how many detents make one outbound step (1–16, persisted) — one per detent was a lot of parameter per wrist in the first Live session. Contract: `docs/midi-spec.md`. Costs ~6 KB of internal RAM on the audio edition. A `tools/rtpmidi-probe/` script exercises the whole map from a PC with no MIDI driver. +- **A Bluetooth Wi-Fi-setup feature, in the tree but in no edition.** `features/ble/` speaks Improv-BLE (the flasher's Improv-Serial RPC on a GATT service) and its lifecycle works on hardware: advertise only while the panel cannot join, take credentials after a touch on the panel, join, then stop the stack and return the controller's memory. It is not composed anywhere: linking it costs ~12 KB of internal RAM (libbt's IRAM code) and ~36 KB more while advertising, and the one phone it was tried with never listed the panel in Chrome's chooser. Left as an opt-in for whoever wants to finish it; the header records what was measured and the two traps below. +### Added — core +- **The panel answers to more names.** `http://patternflow/` over NetBIOS for Windows machines whose mDNS is broken or blocked, and a per-panel mDNS alias `patternflow-<4 hex of the MAC>.local` (`hostAlias` in `/api/status`) so two panels on one network stop fighting over one name. Both re-announced on every reconnect. And every link from the console to patternflow.work now carries `?device=`, which the site stores where its "Send over Wi-Fi" buttons look — so anyone who has opened the console once, by any route, gets uploads that never depend on `.local` resolving. The Android case, which no firmware can fix at the resolver, is fixed at the link. + +### Notes +- Two traps found on the way, recorded in `features/ble/core_ble_improv.h`: the coexistence module aborts if Bluetooth starts while Wi-Fi power save is off (the core runs `WIFI_PS_NONE`), so modem sleep is switched on for the radio's lifetime; and NimBLE 2.x deletes callback objects it was handed, so they are heap-allocated. +- **Two guides** landed after the release: `AUDIO_GUIDE.md` (the extension, the mapping editor, building the on-board microphone, OSC, MIDI) and `FEATURE_GUIDE.md` (writing a feature or cutting an edition). +- **`shelf.sh` refuses an image that does not carry the version it is being published as** — Audio v0.4.0 had shipped still believing it was v0.3.1. +- **Pattern Lab's Graphic Export renders posters with a renderer of their own** — a print is not an LED panel, so the poster path stops pretending to be one. +- **The shelf is `/editions`.** The page that installs a firmware in one click moved from `/variants` to the word the docs use; the old URL — baked into every 3.8.0 console page — redirects permanently. `docs/EDITIONS.md` now carries the vocabulary table: feature / composition / edition, and which older spellings (`bundles/`, `PF_VARIANT`, the `variant` field in `/api/status`) stay because other software reads them. +- **Feature settings live with their feature.** `PF_OSC_*`, `PF_AUDIO_*`, `PF_MQTT_*`, `PF_WEATHER_*` and `PF_SHOW_HTTP_ENABLED` moved out of the core's `net_config.h` into `features//_config.h`, still `#ifndef`-guarded so a secrets file or an edition's `overrides.h` wins exactly as before. `net_config.h` is the device's network settings and nothing else. The absolute bus scale has a name, `PF_BUS_MAX` (`BUS_WIRE_MAX` on the site), where it used to be the literal 1000 in eleven places. +- **Web and firmware presets are checked as twins.** `firmware/toolchain/check_presets.py` (in CI) fails when a web preset appears without a firmware `.h` or an entry in its web-only list, or a firmware preset has no web source — the web preset is the source of truth and a hand-edited `.h` used to be silently overwritten. +- **`/api/community/*` is closed at the door.** `web/src/proxy.ts` answers 404 for the whole prefix on a deployment without `COMMUNITY_ENABLED`; each route still checks for itself, but a route that forgot is no longer open by default. +- **`web/src/lib` has a shape.** `lib/pattern/` (the runtime, the colour ramp math, the `@matrix` / `@ramp` / `@knobs` annotations, the `.pfs` codec, knob-scale constants), `lib/ai/` (Gemini, with its settings vocabulary in a dependency-free module the lab persists against), `lib/hooks/`. The `@knobs` parser exists once, shared by the lab and the community cards, where each side had its own copy of the regex. +- **The community's server side has a door.** `lib/community/server/` holds everything that touches SQLite, the filesystem or a session (`db`, `schema`, `queries`, `auth`, `attachments`, `retention`, `builds`, `admin`, `deckZip`, `notify`, `serialize`); ESLint refuses a value import of it from a component or a `*Client.tsx`. Type imports still pass — that is all any client file ever took. +- **The lab store is six slices.** `lib/lab/store/` — project, layers, knobs, ramp, director, gallery — composed by `store.ts`, with the helpers they share in `store/shared.ts`. Same actions, same bodies, one file per concern instead of 800 lines in one. The two biggest panels shed what was not the panel: the image-import modal (`ImportImageModal.tsx`) and the pixel undo stacks (`lib/lab/pixelHistory.ts`) out of `PixelPanel`, the lane components and the wire-to-pixel geometry out of `DirectorPanel`. +- **`/editions` no longer mismatches on hydration.** The install links read the remembered device host through `useSyncExternalStore` with a server snapshot, so the first client paint matches the HTML and the saved address arrives on the next render; the same hook served `/update` and the deck panel, which were quietly doing the same thing. +- **The lab's two big panels are compositions, and there is a harness that says so.** `PixelPanel` (957 lines) is 240 lines over six hooks — tool state, the select tool, the viewport, the three canvases, pointer gestures, the keyboard — and two toolbar components; `DirectorPanel` (897) is 230 over the transport view, time zoom, show editing, the file helpers and two bars. Before either was touched, `check:panels` (Vitest + jsdom + Testing Library, `web/test/setup.ts` stubs the canvas) pinned 23 behaviours — pen, eraser, fill, shapes, select/lift/nudge/commit/cancel, undo/redo by key and button, tool keys, the picker, clear, copy-as-code, the import modal; keyframes by double-click, snap grids, the selection editor, messages, length/loop/title, .pfs/.mid export, Space — and they pass unchanged after. It runs in `check:ci`. +- **Each lab panel owns its stylesheet.** `PatternLab.module.css` was 1,618 lines shared by eleven files; the rules only one panel draws with moved beside that panel (`panels/.module.css`), 33 rules nothing used since the v1 lab went, and the shared sheet is what two files or more need. The lab's one door to the community is `pattern-lab/community.ts`, and ESLint keeps it the only one. +- **Community queries, one file per domain.** `server/queries.ts` (1,364 lines) is a barrel over `server/queries/{patterns,decks,workshop,moderation,users,notifications,shared}.ts`; bodies unchanged, importers unchanged. +- **The audio-in console page proves it was rebaked.** `build_audio_in_page.py --check` runs in CI on every change to the extension's editor or the console, so a stale bake fails there instead of shipping. +- **The Wi-Fi setup portal is a record.** The parked branch is written up in `docs/investigations/`, named in `docs/EDITIONS.md` as belonging to no edition and not to be merged as it stands, and pushed so the code is kept. +- **The case folder says what each file is.** The Bambu Studio project sits with the 256 mm kit it slices, the v1-era laser-cut drawings are `legacy_lasercut/`, and the README names both. +- **Pattern Lab panels are a registry.** `panels/registry.tsx` is the one list — id, title, component, where it docks by default — and the shell derives the component map, the Panels menu and the default layout from it; the Pixel, Director, Graphic Export and Gallery editors load on demand instead of riding in the route's first chunk. Everything the lab keeps in `localStorage` is listed in `lib/lab/persist.ts`, which every persistence site now goes through. The pixel editor's undo stacks are dropped when their layer is deleted; they used to live for the tab. +- **The Home Assistant integration has left this repository.** It was a community contribution, and its author now maintains it in a repository of their own; the component (`custom_components/`, `hacs.json`), its tests and scripts (`integrations/homeassistant/`), the dashboard card's source (`web/src/ha-card/`) and the three workflows that built, validated and released it are gone from here. The device's HTTP API it was built against is unchanged (`docs/rest-api.md`). +- **Housekeeping (2026-09-03).** Removed the frozen `firmware/patternflow_stream/` sketch, `firmware/tools/` (a `.pfv` uploader for a Video pattern that no longer exists), retired toolchain scripts, the pre-`features/` agent skills under `.agents/`, dead web components, and every flasher image folder the manifest no longer serves (older images stay on their release tags). Documentation that still described the pre-editions tree — `firmware/README.md`'s layout, library list, OSC default and LED calibration values, `AGENTS.md`'s version and hard rules, `hardware/pcb/README.md`'s USB-C wording — was brought in line with the code. diff --git a/docs/releases/v3.9.1.md b/docs/releases/v3.9.1.md new file mode 100644 index 000000000..bd76ae67a --- /dev/null +++ b/docs/releases/v3.9.1.md @@ -0,0 +1,9 @@ +## [3.9.1] - 2026-09-03 + +### Added — core +- **The network runs on its own core.** Wi-Fi maintenance, the console's HTTP server, Improv-Serial and OTA moved to a FreeRTOS task pinned to Core 0 (`src/core_net_task.h`); `loop()` on Core 1 is input, features, draw and present, and nothing else. A Wi-Fi reconnect or a page load no longer costs the render a frame. Handlers that touch what the frame is using — evicting or restoring the resident module, walking the pattern list, the show engine, the MQTT client, weather's config — are handed to the loop task and run at the frame boundary (`src/core_loop_sync.h`); the rule for feature authors is in `FEATURE_GUIDE.md`. `/api/status` reports `httpCore`, `netStackMin`, `loopSyncServed` and `loopSyncMaxUs`. If the task cannot be created, `loop()` serves the network as it did before. +- **Live knobs on the console home page.** Four sliders under *Now playing* write the absolute bus (`POST /api/params`), one request in flight at a time with the newest value queued behind it. A legacy pattern that reads only `knobDeltas` follows too: the device turns the change in a held value into clicks, so the sliders, OSC and a show all move such a pattern the same way. `d1`..`d4` on `/api/params` add relative clicks for a client that has only those. +- **Stepping through patterns from the home page.** `←` / `→` beside the pattern name call `GET /api/patterns/select?step=+1|-1`, which skips hidden entries and wraps. + +### Added — Audio edition v0.5.1 +- **Audio-React on the console.** The `/audio-in` page (nav label now *Audio*) gains an *Audio-React (AUD)* toggle — the switch that lets the browser extension and the phone capture drive the knobs, which until now lived only on the panel's NETWORK screen — backed by `GET`/`POST /api/audio` (`on=0/1`, persisted), and a link to the extension's guide. diff --git a/docs/releases/v3.9.2.md b/docs/releases/v3.9.2.md new file mode 100644 index 000000000..eb14bd669 --- /dev/null +++ b/docs/releases/v3.9.2.md @@ -0,0 +1,9 @@ +## [3.9.2] - 2026-09-03 + +### Changed — core +- **Pushing a frame to the panel costs 7.0 ms, down from 9.9.** The whole-frame blit in the vendored HUB75 driver used to extract each colour plane's bit from six CIE values with six masked tests per plane — thirty-six per pixel pair, and that loop was 60% of every frame. Two 256-entry tables now spread a channel's plane bits across 6-bit slots, so every plane's word comes from one OR of six reads and each plane is a shift and a mask. Bit-exact with the loop it replaces (the DMA words are identical; 6.2 million plane words were checked), so nothing about the picture changes — only the time. Origin runs at 13.8 ms a frame where it ran at 16.6; a heavy pattern like `0510` drops from 24.8 to 21.8. Every pattern gets it, installed modules included. +- **`PFColor::hsvToRgb` no longer calls `fmodf`.** The sector and its fraction come from one truncation. Mathematically the same colour (a last-bit rounding difference that never reaches an 8-bit channel), but `fmodf` in a module is a call into the host's libm on every pixel; `0510` rebuilt against the new header renders 1.0 ms faster a frame. Reaches a module when it is rebuilt — the site's build service and the toolchain pick it up now; the modules already on a panel, and the prebuilt ones in the shipped Basics pack, keep the code they were built with. +- `PFNoise::perlin2D` takes one floor per axis instead of two. The other two of a four-point list this cycle was handed — a joint `fastSinCos` and a bit-trick `fastHypot` — were declined: the compiler already inlines `sqrtf` and `floorf` to single FPU instructions here, and the trig lookup saves a few cycles nobody would measure. + +### Editions +- Audio v0.5.2 and Performance v0.2.2 are the same firmware on the new core — the network task and the faster frame push — with no feature changes of their own. diff --git a/docs/releases/v3.9.3.md b/docs/releases/v3.9.3.md new file mode 100644 index 000000000..0f2dca818 --- /dev/null +++ b/docs/releases/v3.9.3.md @@ -0,0 +1,10 @@ +## [3.9.3] - 2026-09-03 + +### Changed — core +- **Rebuilding the pattern list no longer stops the render for two seconds.** After an install batch, and on deleting the module that is drawing, the registry rescanned `/patterns` and opened every module's sidecar twice; with forty modules that was 2.4 s with the frame held (3.9.1's `loopSyncMaxUs` put the number on it). The sidecar's two facts are now cached by path and forgotten only by the writer that changed them (upload, delete, format, library pull), and the scan reads the directory with `readdir` instead of opening every entry to build a `File`. Deleting the drawing module answers in 0.29 s where it took 2.4; the render pauses 55 ms where it paused 2,160. Boot still reads every sidecar, once. +- **`PFMath::jsMod` — JavaScript's `%` without `fmodf`.** A pattern ported from the lab reached for `fmodf` on every pixel for a hue wrap like `(h + 0.33) % 1`, and in a module that is a call into the host's libm. `jsMod(x, m)` is one division and one truncation with JS's sign rule; the 22 preset twins that used `fmodf` (52 calls) now use it, and the lab's C++ prompt says to. `0510` rebuilt: 20.8 → 17.9 ms a frame. +- **The frame push writes two columns at a time.** Adjacent columns share one aligned 32-bit word in every plane, so the blit does one read-modify-write per plane for two pixel pairs instead of two; the CIE table it sums on-time from lives in DRAM, and the function in IRAM. Bit-exact, like 3.9.2's tables. 7.0 → 6.9 ms; the DMA engine's own reads of that memory are what remains. + +### Added +- **Firmware compiled in CI.** `.github/workflows/firmware-build.yml` runs `firmware/bundles/build.sh all` — default, audio, performance and the marker scan that proves each image carries exactly its features — on every pull request that touches `firmware/`, with the PlatformIO packages cached. Until now the checks in CI were greps; the compiler ran at a desk. +- **Edition write-ups are releases too.** The firmware-release workflow no longer fails on a tag that is not a core `vX.Y.Z`; an edition's own release (`audio-v0.5.3`) attaches its image by hand from the shelf. The first one is the Audio edition's. diff --git a/docs/releases/v3.9.4.md b/docs/releases/v3.9.4.md new file mode 100644 index 000000000..e8249de70 --- /dev/null +++ b/docs/releases/v3.9.4.md @@ -0,0 +1,10 @@ +## [3.9.4] - 2026-09-04 + +### Changed — web +- **One pattern runtime.** The community's sandbox page (`public/pattern-sandbox.html`, the XSS boundary every preview and thumbnail renders inside) was a hand-kept plain-JS copy of the lab's runtime, with "keep the two in sync" notes on both sides. It is now built from the lab's own code: `scripts/build-sandbox.ts` bundles `src/sandbox/sandbox.ts` — only the canvas, the live loop and the postMessage protocol — with `lib/pattern/harness.ts`, `ramp.ts` and `matrix.ts`, and `check:sandbox-sync` (in `check:ci`) fails when the committed page is not that build. Two drifts came out in the wash: the sandbox turned a knob 20 detents a revolution where the lab and the encoder turn 24, and a pattern that threw reported its message without the line the lab quotes. The default ramp for a `setValue` pattern with no `@ramp` line is kept; every other output was compared pixel for pixel against the old page before it was replaced. + +### Docs +- `docs/SERVICES.md` records the community host as it is: the build worker's systemd sandbox (the drop-in, the three writable paths, what it does not cover — files the worker's own user can read still reach a compile error, and the two steps that close that), and the host update procedure. + +### Added +- **A release is two commands.** `python firmware/toolchain/release.py cut vX.Y.Z [--audio vA] [--performance vB]` bumps every version site, dates the changelog, runs `shelf.sh` for each image, points the flasher manifest and the `/editions` cards at them, runs the web checks, commits and tags; `release.py publish vX.Y.Z --notes FILE` pushes, opens and merges the dev → main pull request, and creates the GitHub release with the edition images attached. `check_versions.py` (in CI) reads the six files a version lives in and the shelf, and fails on any disagreement. `docs/RELEASING.md` keeps the checklist as the description of what the two commands do. diff --git a/docs/releases/v3.9.5.md b/docs/releases/v3.9.5.md new file mode 100644 index 000000000..525d35887 --- /dev/null +++ b/docs/releases/v3.9.5.md @@ -0,0 +1,27 @@ +## [3.9.5] - 2026-09-06 + +### Added +- **Clock edition** (`firmware/bundles/clock/`, v0.1.0) — a panel that tells the time, and carries nothing else. Cut for a request from outside, and shelved on `/editions` under Community rather than Official: good fun, and not what Patternflow is about — Official is the maintainer's own list of what is. +- **`features/clock/`** — the hours and minutes cut out of whatever pattern is running: huge anti-aliased digits, hours over minutes on an upright panel, four across on a wide one. Inside the digits the pattern or a solid colour; outside them the pattern dimmed to taste or a solid colour; between the rows an optional bar (across, the face's own colon), cut from the pattern exactly as the digits are; the gap adjustable; the minute change crossfaded. So one layout is a cut-out, a stencil, solid digits over the pattern, or a plain clock on a coloured ground. Seven faces to choose from — Bebas Neue, Anton, Oswald, Saira Extra Condensed, Barlow Condensed, Six Caps, Squada One, all OFL — rasterised offline into 4-bit alpha cells by `toolchain/build_clock_glyphs.py`, which is also where a face of your own goes (a TTF and one line). Everything is blended into the frame on its way to the panel rather than stamped over it afterwards. `/clock` sets it up with a live preview that draws the same glyphs the panel does and sends every change as it is made — one-tap presets for the four fills, the faces as chips drawing their own digits, and the browser's own colour picker beside the swatches; `GET`/`POST /api/clock` and `GET /clock/glyphs.bin` are the contract (`docs/rest-api.md` 1.3). The time zone is a POSIX TZ string with its DST rule — the weather page's UTC offset cannot say when summer time starts — and the clock owns it: whatever else sets `TZ` is put back within a second. On first boot the offset a panel had already given the weather page is read, never written, as the starting zone. Nothing is drawn until NTP has answered once. Orientation counts quarter turns from the wide way; 1, upright, is the default. +- Core: `PatternflowClock::beginSyncTz()` and `assertTz()` in `src/core_clock.h`, the POSIX-zone entry points beside the minutes-offset one; and **`composeFrame`**, a new hook at the tail of `PFFeature` — the finished canvas on its way to the panel, before the blit, so a feature can blend with real alpha or mask the whole frame. `PFCanvas::present()` asks one function pointer, the sketch wires it to the dispatcher, and the canvas itself is never written (patterns that accumulate across frames own it). +- The tooling knows the fourth composition: `build.sh all` builds and scans `clock` (the feature's marker is `[CLOCK] /clock ready`), `release.py cut --clock vX`, `check_versions.py`, the console page list and the fake device (`/api/clock`, `/clock/glyphs.bin`). + +### Added — panel +- **Browsing with K4 no longer loads every pattern it passes.** A module's cost on the way in is its own `setup()` — 10 ms for a light one, 2.3 s for *Branched flow*, 2.9 s for *Two-stream* — and SELECT mode paid it on every detent: the loop stood still for that long while the encoder kept counting, and the accumulated detents landed all at once when it came back, a panel that froze for two seconds and then jumped five patterns. The knob now moves only the highlight, three detents per pattern (one per detent overshot); the pattern loads once the knob has rested for a third of a second, and until then the panel shows the pattern's **thumbnail** — the 128×64 frame it last drew, taken off the canvas when a pattern is left after running for two seconds, kept in PSRAM and written beside the module as `/patterns/.thumb` (16 KB each; `src/core_thumbs.h`). Captured before gamma and white balance and painted back through the same `present()`, so the picture is what the pattern looked like. A pattern nobody has played yet shows only its name until it has; the first slow pass through the list builds the set. Deleting a module deletes its picture, formatting the volume forgets them all, and `GET /api/patterns/file?ext=thumb` hands one to a console or a site. The load itself runs on a task on Core 0 (`activatePatternAsync`): the frame never stops and the knob keeps answering while a heavy `setup()` runs, the panel shows the thumbnail until the pattern is ready, and where the knob rests last is what loads next. The same path serves switches from the console, OSC, MIDI and a show; `/api/status` says `loading` while one is on its way, and uploads and deletes wait for a load in flight before evicting. + +### Fixed — panel +- **The UPDATE card no longer tears against the pattern while an image is received.** Since the upload handler moved to the network core (3.9.1) its progress callback kept drawing the card and flipping the panel from there, while `loop()` went on drawing the pattern — two tasks flipping one DMA buffer for the length of the upload. The callback now only reports the percentage; `loop()` shows the card, as it does every other one. +- **The name that appears when a pattern is chosen from the console reads upright.** It was drawn along the panel's long axis — sideways on a panel stood on its end — and is now portrait like the SELECT screen, wrapped the same way for long names. + +### Fixed — core +- **The console survives a slow Wi-Fi link.** A panel whose link was moving 2–5 KB/s (round trips of ~0.4 s with retransmissions; a healthy panel on the same network does 3–60 ms) got every console page cut short: the page sender's hard 5-second budget — written for the days when a send ran inside `loop()` and stalled the render — broke off with the header already promising the full `Content-Length`, so `/` arrived as 10–25 KB of 26 KB and the 63 KB audio page never at all, and the browser showed a page whose script ends mid-word. Since 3.9.1 the sender runs on the network core and the render never sees it, so the budget was only ever hurting. It now gives up only when the peer stops accepting bytes for ~20 s (or after a two-minute ceiling), and when it does it closes the connection instead of leaving a short body looking complete; on the single-core fallback the 5 s cap stays. Measured with a client holding a 1 KB window: 17.5 KB delivered whole in 22 s where it used to be cut at 5; a client that reads nothing is dropped at 20 s and the next one is served in 30 ms. +- **Console pages are sent gzip-compressed** — `console_pages.py build` lays a gzip twin under every page literal (and under `pf-console.js` and the unzip library), the handlers send that with `Content-Encoding: gzip`, and `check` proves each twin still decompresses to its page in CI. A third of the bytes cross the air (26.5 → 8.6 KB for `/`, 63 → 17.6 KB for `/audio-in`), which on a bad link is a third of the time; the raw literals stay in the headers for the tooling and drop out of the image, which shrinks by ~140 KB. +- **The network core no longer reboots on a request that stalls.** The vendored `WebServer` read request lines, headers and raw upload bodies through Arduino's `Stream::timedRead()`, a spin loop that never yields. Harmless while the server ran on Core 1; since 3.9.1 it runs on Core 0, whose idle task feeds a 5-second watchdog that panics — so a request that paused for five seconds mid-header, or a `.pfm` upload trickling in over a slow link, took the whole board down. Reproduced on a bench panel (`Task watchdog got triggered: IDLE0`, reset, back in ten seconds) and gone after: the parser now waits with `delay(1)` between bytes, bounded by the same timeouts as before (`src/webserver/VENDORED.md`, fix 2). +- **A big pattern can no longer take the console down with it.** The module loader placed a module's data sections in internal RAM whenever they fit, and internal RAM is what Wi-Fi, lwIP and every HTTP connection are made of. A community pattern whose tables came to 46 KB left 2.7 KB free after boot: the panel drew on, every connection died at the SYN, and nothing anywhere said why. Data sections over 16 KB, or any that would leave less than 24 KB of internal heap, now go to PSRAM first (`PF_MODULE_DATA_INTERNAL_MAX`, `PF_MODULE_INTERNAL_RESERVE`); the same pattern now leaves 42 KB free with 3.9 KB of it internal, and the console answers in 50 ms while it plays. + +### Changed — features +- **MIDI and OSC report hands, not lanes, and MIDI knob output is paced.** On the Audio edition the microphone moves the knobs through lanes, and by the time the frame reached the MIDI and OSC features that motion looked like an encoder — so a quiet room's noise floor went out as 26–43 control changes a second (two datagrams per knob per frame over OSC, once a host is known), filled the Wi-Fi driver's eight transmit buffers, and starved the console's own traffic behind a wall of `could not send data: 12` on the serial log. Both features now skip a knob a lane is moving (`PF_MIDI_OUT_LANE_MOTION` / `PF_OSC_OUT_LANE_MOTION` restore the old behaviour), and MIDI sends at most one CC per knob per 50 ms, trailing-edge, so the last value always lands (`docs/midi-spec.md` 1.1, `docs/osc-spec.md` 1.1). + +### Added — API +- **A brightness slider on the console home page**, under the panel switch — the value K1's long-press session sets, written over `GET /api/display?brightness=5..255` (applied through the power clamp on the next frame, saved to NVS once it has been still for 3 s) and reported in `/api/status` as `brightness`, so the slider follows the knob and the knob follows the slider. +- `GET /api/status` gains `resetReason` (`"poweron"`, `"sw"`, `"panic"`, `"task_wdt"`, `"brownout"`…) next to `uptime` — two reboots went unnoticed in one afternoon because from the network they look exactly like a power cut — and `load.internal` / `load.psram`, where the resident module's sections landed. The same reset reason is printed once at boot. `docs/rest-api.md` 1.4. diff --git a/docs/rest-api-vectors/README.md b/docs/rest-api-vectors/README.md new file mode 100644 index 000000000..d9ee0f458 --- /dev/null +++ b/docs/rest-api-vectors/README.md @@ -0,0 +1,9 @@ +# REST vectors + +Each file here is one sentence of [rest-api.md](../rest-api.md) and the requests that show a panel doing it, written as [contract-vectors.md](../contract-vectors.md) sets out. `npm run check:rest-vectors` in `web/` replays every one against a fresh [virtual panel](../../tools/virtual-panel/README.md) and prints what the vector expects beside what the panel did. CI runs it on every pull request with the other drift checks. When the spec rewords a sentence a vector quotes, that vector fails until someone brings it along. + +A file's name starts with the part of the spec it reads. `state-` is `GET /api/status`, `POST /api/params` and `POST /api/sleep`. `device-` is the rest of device state (`/api/display`, `/api/knobs`, `DELETE /api/crash`) and what every route shares, such as the plain-text 404 and `no-store`. `patterns-` is the pattern list, select and the module files. `absent-` is a route a build leaves out because its feature is not in it. The vectors for a route only a feature serves start with that feature's directory in `firmware/patternflow/features/`, as `mqtt-` does for `/api/mqtt`, and name the feature in `features`. + +Some of the spec has no vectors. The virtual panel has no radio and no flash volume, so Wi-Fi and the hotspot, firmware updates, and installing, deleting or formatting modules are not replayed. It has no microphone and does not run the clock or shows, so `/api/audio-in`, `/api/clock` and `/api/shows` are not replayed either. The console's pages are for a person and have no vectors. + +Two firmware rules are not in rest-api.md, and the virtual panel keeps both. A hand does not take back a channel that `POST /api/params` wrote less than 250 ms before (`ABSOLUTE_RELEASE_GRACE_MS` in `core_bus.h`). A sleeping panel ignores its knobs and buttons for the first 800 ms (`WAKE_GUARD_MS` in `core_sleep.h`). A vector that puts a hand on the panel after either one waits it out: `state-params-hand.json` holds the channel a second before the turn, and `state-sleep-wake.json` lets the panel sleep a second before waking it. diff --git a/docs/rest-api-vectors/absent-caps.json b/docs/rest-api-vectors/absent-caps.json new file mode 100644 index 000000000..3cc4f5d5d --- /dev/null +++ b/docs/rest-api-vectors/absent-caps.json @@ -0,0 +1,6 @@ +{ + "contract": "docs/rest-api.md#get-apistatus", + "says": "The default build reports `[\"patterns\",\"params\",\"sleep\"]` and nothing else; each [edition](EDITIONS.md) adds its own — Audio adds `osc`, `audio`, `audio-in` and `midi`, Performance adds `clock`, `weather`, `mqtt` and `shows`.", + "features": [], + "steps": [{ "send": { "http": "GET /api/status" }, "expect": { "reply": { "status": 200, "body": { "caps": ["patterns", "params", "sleep"] } } } }] +} diff --git a/docs/rest-api-vectors/absent-feature-nav.json b/docs/rest-api-vectors/absent-feature-nav.json new file mode 100644 index 000000000..50a50e9e1 --- /dev/null +++ b/docs/rest-api-vectors/absent-feature-nav.json @@ -0,0 +1,6 @@ +{ + "contract": "docs/rest-api.md#get-apistatus", + "says": "What the console header and home screen build their feature links from; empty on the default build.", + "features": [], + "steps": [{ "send": { "http": "GET /api/status" }, "expect": { "reply": { "status": 200, "body": { "featureNav": [] } } } }] +} diff --git a/docs/rest-api-vectors/absent-routes.json b/docs/rest-api-vectors/absent-routes.json new file mode 100644 index 000000000..ab55dec38 --- /dev/null +++ b/docs/rest-api-vectors/absent-routes.json @@ -0,0 +1,16 @@ +{ + "contract": "docs/rest-api.md#conventions-for-future-additions", + "says": "A feature's endpoints exist only when that feature is composed in — the route itself is absent otherwise, so clients probe `caps` rather than expecting a soft 404.", + "features": [], + "steps": [ + { "send": { "http": "GET /api/mqtt" }, "expect": { "reply": { "status": 404, "headers": { "content-type": "text/plain" } } } }, + { "send": { "http": "POST /api/mqtt?role=subscriber" }, "expect": { "reply": { "status": 404, "headers": { "content-type": "text/plain" } } } }, + { "send": { "http": "GET /api/midi" }, "expect": { "reply": { "status": 404, "headers": { "content-type": "text/plain" } } } }, + { "send": { "http": "POST /api/midi?outMode=rel" }, "expect": { "reply": { "status": 404, "headers": { "content-type": "text/plain" } } } }, + { "send": { "http": "GET /api/audio" }, "expect": { "reply": { "status": 404, "headers": { "content-type": "text/plain" } } } }, + { "send": { "http": "POST /api/audio?on=1" }, "expect": { "reply": { "status": 404, "headers": { "content-type": "text/plain" } } } }, + { "send": { "http": "GET /api/audio-in" }, "expect": { "reply": { "status": 404, "headers": { "content-type": "text/plain" } } } }, + { "send": { "http": "GET /api/clock" }, "expect": { "reply": { "status": 404, "headers": { "content-type": "text/plain" } } } }, + { "send": { "http": "GET /api/shows" }, "expect": { "reply": { "status": 404, "headers": { "content-type": "text/plain" } } } } + ] +} diff --git a/docs/rest-api-vectors/audio-caps.json b/docs/rest-api-vectors/audio-caps.json new file mode 100644 index 000000000..2d0ac0727 --- /dev/null +++ b/docs/rest-api-vectors/audio-caps.json @@ -0,0 +1,9 @@ +{ + "contract": "docs/rest-api.md#conventions-for-future-additions", + "says": "A feature's endpoints exist only when that feature is composed in — the route itself is absent otherwise, so clients probe `caps` rather than expecting a soft 404.", + "features": ["audio"], + "steps": [ + { "send": { "http": "GET /api/status" }, "expect": { "reply": { "body": { "caps": ["patterns", "params", "sleep", "audio"] } } } }, + { "send": { "http": "GET /api/audio" }, "expect": { "reply": { "status": 200, "headers": { "content-type": "application/json" } } } } + ] +} diff --git a/docs/rest-api-vectors/audio-get.json b/docs/rest-api-vectors/audio-get.json new file mode 100644 index 000000000..84e71a92a --- /dev/null +++ b/docs/rest-api-vectors/audio-get.json @@ -0,0 +1,8 @@ +{ + "contract": "docs/rest-api.md#audio-react-audio-edition", + "says": "`audioRuntime` (the switch) and `audioClients` (WebSocket senders connected on :81).", + "features": ["audio"], + "steps": [ + { "send": { "http": "GET /api/audio" }, "expect": { "reply": { "status": 200, "body": { "audioRuntime": "", "audioClients": 0 } } } } + ] +} diff --git a/docs/rest-api-vectors/audio-post.json b/docs/rest-api-vectors/audio-post.json new file mode 100644 index 000000000..80bbec28b --- /dev/null +++ b/docs/rest-api-vectors/audio-post.json @@ -0,0 +1,11 @@ +{ + "contract": "docs/rest-api.md#audio-react-audio-edition", + "says": "`on=0/1`. Persisted in NVS; answers with the new state.", + "features": ["audio"], + "steps": [ + { "send": { "http": "POST /api/audio?on=0" }, "expect": { "reply": { "status": 200, "body": { "ok": true, "audioRuntime": false } } } }, + { "send": { "http": "GET /api/audio" }, "expect": { "reply": { "body": { "audioRuntime": false } } } }, + { "send": { "http": { "request": "POST /api/audio", "form": "on=1" } }, "expect": { "reply": { "status": 200, "body": { "ok": true, "audioRuntime": true } } } }, + { "send": { "http": "GET /api/audio" }, "expect": { "reply": { "body": { "audioRuntime": true } } } } + ] +} diff --git a/docs/rest-api-vectors/device-compressed.json b/docs/rest-api-vectors/device-compressed.json new file mode 100644 index 000000000..c9d82972e --- /dev/null +++ b/docs/rest-api-vectors/device-compressed.json @@ -0,0 +1,10 @@ +{ + "contract": "docs/rest-api.md#transport", + "says": "Nothing under `/api/` is compressed.", + "features": [], + "steps": [ + { "send": { "http": { "request": "GET /api/status", "headers": { "Accept-Encoding": "gzip, deflate" } } }, "expect": { "reply": { "status": 200, "headers": { "content-encoding": "" } } } }, + { "send": { "http": { "request": "GET /api/patterns", "headers": { "Accept-Encoding": "gzip, deflate" } } }, "expect": { "reply": { "status": 200, "headers": { "content-encoding": "" } } } }, + { "send": { "http": { "request": "GET /api/patterns/file?slug=wave_saw", "headers": { "Accept-Encoding": "gzip, deflate" } } }, "expect": { "reply": { "status": 200, "headers": { "content-encoding": "" } } } } + ] +} diff --git a/docs/rest-api-vectors/device-cors.json b/docs/rest-api-vectors/device-cors.json new file mode 100644 index 000000000..16254fc18 --- /dev/null +++ b/docs/rest-api-vectors/device-cors.json @@ -0,0 +1,15 @@ +{ + "contract": "docs/rest-api.md#transport", + "says": "Only `/api/patterns/select` and `/api/display` send `Access-Control-Allow-Origin: *`.", + "features": [], + "steps": [ + { "send": { "http": "GET /api/display" }, "expect": { "reply": { "status": 200, "headers": { "access-control-allow-origin": "*" } } } }, + { "send": { "http": "GET /api/patterns/select?index=0" }, "expect": { "reply": { "status": 200, "headers": { "access-control-allow-origin": "*" } } } }, + { "send": { "http": "GET /api/status" }, "expect": { "reply": { "headers": { "access-control-allow-origin": "" } } } }, + { "send": { "http": "GET /api/patterns" }, "expect": { "reply": { "headers": { "access-control-allow-origin": "" } } } }, + { "send": { "http": "GET /api/knobs" }, "expect": { "reply": { "headers": { "access-control-allow-origin": "" } } } }, + { "send": { "http": "POST /api/params?p1=500" }, "expect": { "reply": { "headers": { "access-control-allow-origin": "" } } } }, + { "send": { "http": "GET /api/patterns/file?slug=wave_saw&ext=json" }, "expect": { "reply": { "headers": { "access-control-allow-origin": "" } } } }, + { "send": { "http": "GET /api/nothing" }, "expect": { "reply": { "headers": { "access-control-allow-origin": "" } } } } + ] +} diff --git a/docs/rest-api-vectors/device-crash.json b/docs/rest-api-vectors/device-crash.json new file mode 100644 index 000000000..5f0039c92 --- /dev/null +++ b/docs/rest-api-vectors/device-crash.json @@ -0,0 +1,9 @@ +{ + "contract": "docs/rest-api.md#delete-apicrash", + "says": "`{\"ok\":true}`, or `404` with `{\"ok\":false,\"error\":\"no crash recorded\"}` when there was nothing to clear.", + "features": [], + "steps": [ + { "send": { "http": "GET /api/status" }, "expect": { "reply": { "body": { "crash": "" } } } }, + { "send": { "http": "DELETE /api/crash" }, "expect": { "reply": { "status": 404, "body": { "ok": false, "error": "no crash recorded" } } } } + ] +} diff --git a/docs/rest-api-vectors/device-display-brightness.json b/docs/rest-api-vectors/device-display-brightness.json new file mode 100644 index 000000000..7de00efc7 --- /dev/null +++ b/docs/rest-api-vectors/device-display-brightness.json @@ -0,0 +1,9 @@ +{ + "contract": "docs/rest-api.md#get-apistatus", + "says": "K1's long-press session and `GET /api/display?brightness=` move the same value; what the power clamp lets through is `power_applied` on `/api/display`.", + "features": [], + "steps": [ + { "send": { "http": "GET /api/display?brightness=77" }, "expect": { "reply": { "body": { "brightness": 77 } }, "status": { "brightness": 77 } } }, + { "send": { "http": "GET /api/display?brightness=255" }, "expect": { "reply": { "body": { "brightness": 255 } }, "status": { "brightness": 255 } } } + ] +} diff --git a/docs/rest-api-vectors/device-display-read-only.json b/docs/rest-api-vectors/device-display-read-only.json new file mode 100644 index 000000000..8e95976ee --- /dev/null +++ b/docs/rest-api-vectors/device-display-read-only.json @@ -0,0 +1,9 @@ +{ + "contract": "docs/rest-api.md#get-apidisplay", + "says": "Read-only in the response: `calib`, `sleep`, `power_ma`, `power_demand`, `power_limiting`, `power_applied`.", + "features": [], + "steps": [ + { "send": { "http": "GET /api/display?sleep=1" }, "expect": { "reply": { "status": 200, "body": { "sleep": "" } } } }, + { "send": { "wait": 20 }, "expect": { "status": { "sleep": false } } } + ] +} diff --git a/docs/rest-api-vectors/device-display-state.json b/docs/rest-api-vectors/device-display-state.json new file mode 100644 index 000000000..f4d580769 --- /dev/null +++ b/docs/rest-api-vectors/device-display-state.json @@ -0,0 +1,15 @@ +{ + "contract": "docs/rest-api.md#get-apidisplay", + "says": "Any subset of these may be set in the query string; the response always reports the full state afterwards.", + "features": [], + "steps": [ + { + "send": { "http": "GET /api/display?sat=2&gamma_g=2.5" }, + "expect": { "reply": { "status": 200, "body": { + "wb_r": "", "wb_g": "", "wb_b": "", "gamma_r": "", "gamma_g": 2.5, "gamma_b": "", "sat": 2, + "brightness": "", "screen": "", "level": "", "power_budget": "", "power_limit": "", + "calib": "", "sleep": "", "power_ma": "", "power_demand": "", "power_limiting": "", "power_applied": "" } } } + }, + { "send": { "http": "GET /api/display?brightness=128" }, "expect": { "reply": { "body": { "gamma_g": 2.5, "sat": 2, "brightness": 128 } } } } + ] +} diff --git a/docs/rest-api-vectors/device-favicon.json b/docs/rest-api-vectors/device-favicon.json new file mode 100644 index 000000000..0b7d2e501 --- /dev/null +++ b/docs/rest-api-vectors/device-favicon.json @@ -0,0 +1,9 @@ +{ + "contract": "docs/rest-api.md#transport", + "says": "`GET /favicon.ico` answers `204`, with `Cache-Control: public, max-age=31536000, immutable` on a panel `Host` and `no-store` on any other (see Host gating); pages carry their icon inline.", + "features": [], + "steps": [ + { "send": { "http": { "request": "GET /favicon.ico", "headers": { "Host": "patternflow.local" } } }, "expect": { "reply": { "status": 204, "headers": { "cache-control": "public, max-age=31536000, immutable" } } } }, + { "send": { "http": { "request": "GET /favicon.ico", "headers": { "Host": "patternflow.fritz.box" } } }, "expect": { "reply": { "status": 204, "headers": { "cache-control": "no-store" } } } } + ] +} diff --git a/docs/rest-api-vectors/device-host.json b/docs/rest-api-vectors/device-host.json new file mode 100644 index 000000000..1d61cd377 --- /dev/null +++ b/docs/rest-api-vectors/device-host.json @@ -0,0 +1,15 @@ +{ + "contract": "docs/rest-api.md#transport", + "says": "Pages, the chrome and the favicon are cacheable only when the request's `Host` can only be the panel: an IPv4 address, a name with no dot (`patternflow`) or a `.local` name, with or without a port.", + "features": [], + "steps": [ + { "send": { "http": { "request": "GET /favicon.ico", "headers": { "Host": "192.168.1.42" } } }, "expect": { "reply": { "headers": { "cache-control": "public, max-age=31536000, immutable" } } } }, + { "send": { "http": { "request": "GET /favicon.ico", "headers": { "Host": "192.168.1.42:80" } } }, "expect": { "reply": { "headers": { "cache-control": "public, max-age=31536000, immutable" } } } }, + { "send": { "http": { "request": "GET /favicon.ico", "headers": { "Host": "patternflow" } } }, "expect": { "reply": { "headers": { "cache-control": "public, max-age=31536000, immutable" } } } }, + { "send": { "http": { "request": "GET /favicon.ico", "headers": { "Host": "patternflow:8080" } } }, "expect": { "reply": { "headers": { "cache-control": "public, max-age=31536000, immutable" } } } }, + { "send": { "http": { "request": "GET /favicon.ico", "headers": { "Host": "patternflow-a1b2.local" } } }, "expect": { "reply": { "headers": { "cache-control": "public, max-age=31536000, immutable" } } } }, + { "send": { "http": { "request": "GET /favicon.ico", "headers": { "Host": "patternflow.local:80" } } }, "expect": { "reply": { "headers": { "cache-control": "public, max-age=31536000, immutable" } } } }, + { "send": { "http": { "request": "GET /favicon.ico", "headers": { "Host": "patternflow.fritz.box" } } }, "expect": { "reply": { "headers": { "cache-control": "no-store" } } } }, + { "send": { "http": { "request": "GET /favicon.ico", "headers": { "Host": "patternflow.work:80" } } }, "expect": { "reply": { "headers": { "cache-control": "no-store" } } } } + ] +} diff --git a/docs/rest-api-vectors/device-knobs-get.json b/docs/rest-api-vectors/device-knobs-get.json new file mode 100644 index 000000000..a59190aec --- /dev/null +++ b/docs/rest-api-vectors/device-knobs-get.json @@ -0,0 +1,16 @@ +{ + "contract": "docs/rest-api.md#get-apiknobs-and-post-apiknobs", + "says": "`GET` returns `inv` (four booleans: reversed or not), `sub` (edges per click per knob: 4, 2 or 1 — most encoders are 4), `clicks` (each knob's click count as the frame sees it) and `raw` (the edge counters underneath, for telling a 2-edge part from a 4-edge one).", + "features": [], + "steps": [ + { "send": { "hand": { "knob": 1, "turn": 2 } } }, + { "send": { "hand": { "knob": 3, "turn": -1 } } }, + { "send": { "wait": 20 }, "expect": { "status": { "knobs": [2, 0, -1, 0] } } }, + { + "send": { "http": "GET /api/knobs" }, + "expect": { "reply": { "status": 200, "body": { + "inv": ["", "", "", ""], "sub": ["", "", "", ""], + "clicks": [2, 0, -1, 0], "raw": ["", "", "", ""] } } } + } + ] +} diff --git a/docs/rest-api-vectors/device-knobs-post.json b/docs/rest-api-vectors/device-knobs-post.json new file mode 100644 index 000000000..8a8a8686e --- /dev/null +++ b/docs/rest-api-vectors/device-knobs-post.json @@ -0,0 +1,15 @@ +{ + "contract": "docs/rest-api.md#get-apiknobs-and-post-apiknobs", + "says": "`POST` takes any subset of `inv0`..`inv3` (`0`/`1`) and `sub0`..`sub3` (`4`/`2`/`1`), or `inv=` / `sub=` for all four, form-encoded; a change is applied at a frame boundary without moving the knob, stored on the device (NVS `pf_knobs`) and answered as `GET`.", + "features": [], + "steps": [ + { "send": { "hand": { "knob": 2, "turn": 3 } } }, + { "send": { "wait": 20 }, "expect": { "status": { "knobs": [0, 3, 0, 0] } } }, + { "send": { "http": { "request": "POST /api/knobs", "form": "inv=0&sub=4" } }, "expect": { "reply": { "body": { "inv": [false, false, false, false], "sub": [4, 4, 4, 4], "clicks": [0, 3, 0, 0], "raw": "" } } } }, + { "send": { "http": { "request": "POST /api/knobs", "form": "inv1=1&sub2=2" } }, "expect": { "reply": { "body": { "inv": [false, true, false, false], "sub": [4, 4, 2, 4], "clicks": [0, 3, 0, 0], "raw": "" } } } }, + { "send": { "wait": 20 }, "expect": { "status": { "knobs": [0, 3, 0, 0] } } }, + { "send": { "hand": { "knob": 2, "turn": 1 } } }, + { "send": { "hand": { "knob": 3, "turn": 1 } } }, + { "send": { "wait": 20 }, "expect": { "status": { "knobs": [0, 2, 2, 0] } } } + ] +} diff --git a/docs/rest-api-vectors/device-no-store.json b/docs/rest-api-vectors/device-no-store.json new file mode 100644 index 000000000..76618bc48 --- /dev/null +++ b/docs/rest-api-vectors/device-no-store.json @@ -0,0 +1,19 @@ +{ + "contract": "docs/rest-api.md#transport", + "says": "Every JSON endpoint sends `Cache-Control: no-store`, and so does the plain-text `404`.", + "features": [], + "steps": [ + { "send": { "http": "GET /api/status" }, "expect": { "reply": { "headers": { "cache-control": "no-store" } } } }, + { "send": { "http": "GET /api/display" }, "expect": { "reply": { "headers": { "cache-control": "no-store" } } } }, + { "send": { "http": "GET /api/knobs" }, "expect": { "reply": { "headers": { "cache-control": "no-store" } } } }, + { "send": { "http": { "request": "POST /api/knobs", "form": "inv=0" } }, "expect": { "reply": { "headers": { "cache-control": "no-store" } } } }, + { "send": { "http": "POST /api/params?p1=500" }, "expect": { "reply": { "headers": { "cache-control": "no-store" } } } }, + { "send": { "http": "POST /api/sleep?on=0" }, "expect": { "reply": { "headers": { "cache-control": "no-store" } } } }, + { "send": { "http": "DELETE /api/crash" }, "expect": { "reply": { "status": 404, "headers": { "cache-control": "no-store" } } } }, + { "send": { "http": "GET /api/patterns" }, "expect": { "reply": { "headers": { "cache-control": "no-store" } } } }, + { "send": { "http": "GET /api/patterns/select?index=0" }, "expect": { "reply": { "headers": { "cache-control": "no-store" } } } }, + { "send": { "http": "GET /api/patterns/select?index=-1" }, "expect": { "reply": { "status": 404, "headers": { "cache-control": "no-store" } } } }, + { "send": { "http": "GET /api/patterns/file?slug=wave_saw&ext=json" }, "expect": { "reply": { "headers": { "cache-control": "no-store" } } } }, + { "send": { "http": "GET /api/nothing" }, "expect": { "reply": { "status": 404, "headers": { "content-type": "text/plain", "cache-control": "no-store" } } } } + ] +} diff --git a/docs/rest-api-vectors/device-removed.json b/docs/rest-api-vectors/device-removed.json new file mode 100644 index 000000000..5bc888d4f --- /dev/null +++ b/docs/rest-api-vectors/device-removed.json @@ -0,0 +1,11 @@ +{ + "contract": "docs/rest-api.md#knobs-and-parameters", + "says": "`/api/knob` and `/remote` existed and were removed as unused alongside the `/api/frame` incident; do not look for them.", + "features": [], + "steps": [ + { "send": { "http": "GET /api/knob" }, "expect": { "reply": { "status": 404 } } }, + { "send": { "http": "POST /api/knob?knob=1&delta=1" }, "expect": { "reply": { "status": 404 } } }, + { "send": { "http": "GET /remote" }, "expect": { "reply": { "status": 404 } } }, + { "send": { "http": "GET /api/frame" }, "expect": { "reply": { "status": 404 } } } + ] +} diff --git a/docs/rest-api-vectors/midi-caps.json b/docs/rest-api-vectors/midi-caps.json new file mode 100644 index 000000000..3809b9f27 --- /dev/null +++ b/docs/rest-api-vectors/midi-caps.json @@ -0,0 +1,9 @@ +{ + "contract": "docs/rest-api.md#conventions-for-future-additions", + "says": "A feature's endpoints exist only when that feature is composed in — the route itself is absent otherwise, so clients probe `caps` rather than expecting a soft 404.", + "features": ["midi"], + "steps": [ + { "send": { "http": "GET /api/status" }, "expect": { "reply": { "body": { "caps": ["patterns", "params", "sleep", "midi"] } } } }, + { "send": { "http": "GET /api/midi" }, "expect": { "reply": { "status": 200, "headers": { "content-type": "application/json" } } } } + ] +} diff --git a/docs/rest-api-vectors/midi-get.json b/docs/rest-api-vectors/midi-get.json new file mode 100644 index 000000000..85f5214f3 --- /dev/null +++ b/docs/rest-api-vectors/midi-get.json @@ -0,0 +1,19 @@ +{ + "contract": "docs/midi-spec.md#settings", + "says": "channel, `outDiv`, `outMul`, `outMode`, `host`, session state, counters", + "features": ["midi"], + "steps": [ + { + "send": { "http": "GET /api/midi" }, + "expect": { + "reply": { + "status": 200, + "body": { + "channel": 1, "outDiv": [1, 1, 1, 1], "outMul": ["", "", "", ""], "outMode": "abs", "host": "", + "rtpPeers": "", "rtpPeer": "", "rx": "", "tx": "" + } + } + } + } + ] +} diff --git a/docs/rest-api-vectors/midi-host.json b/docs/rest-api-vectors/midi-host.json new file mode 100644 index 000000000..a2b1de4cb --- /dev/null +++ b/docs/rest-api-vectors/midi-host.json @@ -0,0 +1,11 @@ +{ + "contract": "docs/midi-spec.md#settings", + "says": "Empty string clears it.", + "features": ["midi"], + "steps": [ + { "send": { "http": "POST /api/midi?host=192.168.0.23" }, "expect": { "reply": { "status": 200 } } }, + { "send": { "http": "GET /api/midi" }, "expect": { "reply": { "body": { "host": "192.168.0.23" } }, "status": { "midi": { "host": "192.168.0.23" } } } }, + { "send": { "http": { "request": "POST /api/midi", "form": "host=" } }, "expect": { "reply": { "status": 200 } } }, + { "send": { "http": "GET /api/midi" }, "expect": { "reply": { "body": { "host": "" } } } } + ] +} diff --git a/docs/rest-api-vectors/midi-knob.json b/docs/rest-api-vectors/midi-knob.json new file mode 100644 index 000000000..11545bf9f --- /dev/null +++ b/docs/rest-api-vectors/midi-knob.json @@ -0,0 +1,12 @@ +{ + "contract": "docs/midi-spec.md#settings", + "says": "Apply `outMul`/`outDiv` to one knob; without it, to all four.", + "features": ["midi"], + "steps": [ + { "send": { "http": "POST /api/midi?outDiv=4&knob=3" }, "expect": { "reply": { "status": 200 } } }, + { "send": { "http": "POST /api/midi?outMul=2&knob=2" }, "expect": { "reply": { "status": 200 } } }, + { "send": { "http": "GET /api/midi" }, "expect": { "reply": { "body": { "outDiv": [1, 1, 4, 1], "outMul": [1, 2, 1, 1] } } } }, + { "send": { "http": "POST /api/midi?outDiv=2" } }, + { "send": { "http": "GET /api/midi" }, "expect": { "reply": { "body": { "outDiv": [2, 2, 2, 2], "outMul": [1, 1, 1, 1] } } } } + ] +} diff --git a/docs/rest-api-vectors/midi-out-div.json b/docs/rest-api-vectors/midi-out-div.json new file mode 100644 index 000000000..8963ab1f3 --- /dev/null +++ b/docs/rest-api-vectors/midi-out-div.json @@ -0,0 +1,11 @@ +{ + "contract": "docs/midi-spec.md#settings", + "says": "outbound sensitivity: detents per outbound step, `1..16`, persisted on the panel.", + "features": ["midi"], + "steps": [ + { "send": { "http": "POST /api/midi?outDiv=16" }, "expect": { "reply": { "status": 200 } } }, + { "send": { "http": "GET /api/midi" }, "expect": { "reply": { "body": { "outDiv": [16, 16, 16, 16] } }, "status": { "midi": { "outDiv": [16, 16, 16, 16] } } } }, + { "send": { "http": "POST /api/midi?outDiv=1" }, "expect": { "reply": { "status": 200 } } }, + { "send": { "http": "GET /api/midi" }, "expect": { "reply": { "body": { "outDiv": [1, 1, 1, 1] } } } } + ] +} diff --git a/docs/rest-api-vectors/midi-out-mode.json b/docs/rest-api-vectors/midi-out-mode.json new file mode 100644 index 000000000..467a3081a --- /dev/null +++ b/docs/rest-api-vectors/midi-out-mode.json @@ -0,0 +1,12 @@ +{ + "contract": "docs/midi-spec.md#settings", + "says": "knobs out as a virtual position (default) or as relative steps.", + "features": ["midi"], + "steps": [ + { "send": { "http": "GET /api/midi" }, "expect": { "reply": { "body": { "outMode": "abs" } } } }, + { "send": { "http": "POST /api/midi?outMode=rel" }, "expect": { "reply": { "status": 200 } } }, + { "send": { "http": "GET /api/midi" }, "expect": { "reply": { "body": { "outMode": "rel" } }, "status": { "midi": { "outMode": "rel" } } } }, + { "send": { "http": { "request": "POST /api/midi", "form": "outMode=abs" } }, "expect": { "reply": { "status": 200 } } }, + { "send": { "http": "GET /api/midi" }, "expect": { "reply": { "body": { "outMode": "abs" } }, "status": { "midi": { "outMode": "abs" } } } } + ] +} diff --git a/docs/rest-api-vectors/midi-out-mul.json b/docs/rest-api-vectors/midi-out-mul.json new file mode 100644 index 000000000..b2e21522d --- /dev/null +++ b/docs/rest-api-vectors/midi-out-mul.json @@ -0,0 +1,13 @@ +{ + "contract": "docs/midi-spec.md#settings", + "says": "Setting one of `outMul`/`outDiv` above 1 resets the other to 1", + "features": ["midi"], + "steps": [ + { "send": { "http": "POST /api/midi?outDiv=4" } }, + { "send": { "http": "GET /api/midi" }, "expect": { "reply": { "body": { "outDiv": [4, 4, 4, 4] } } } }, + { "send": { "http": "POST /api/midi?outMul=8" }, "expect": { "reply": { "status": 200 } } }, + { "send": { "http": "GET /api/midi" }, "expect": { "reply": { "body": { "outDiv": [1, 1, 1, 1], "outMul": [8, 8, 8, 8] } } } }, + { "send": { "http": "POST /api/midi?outDiv=2" } }, + { "send": { "http": "GET /api/midi" }, "expect": { "reply": { "body": { "outDiv": [2, 2, 2, 2], "outMul": [1, 1, 1, 1] } } } } + ] +} diff --git a/docs/rest-api-vectors/midi-rx-detail.json b/docs/rest-api-vectors/midi-rx-detail.json new file mode 100644 index 000000000..835d68aa9 --- /dev/null +++ b/docs/rest-api-vectors/midi-rx-detail.json @@ -0,0 +1,37 @@ +{ + "contract": "docs/midi-spec.md#status", + "says": "Unreleased 1.2 adds `rxDetail` to both `midi` in `/api/status` and the root of `GET /api/midi`.", + "features": ["midi"], + "steps": [ + { + "send": { "http": "GET /api/status" }, + "expect": { + "reply": { + "body": { + "midi": { + "rxDetail": { + "worker": "", "abs": ["", "", "", ""], "rel": ["", "", "", ""], + "queueDropped": "", "txQueueDropped": "", "packetGaps": "", "parseErrors": "", "bufferOverflows": "", + "pollMaxGapUs": "", "pollMaxUs": "", "queueMaxAgeUs": "", "stackMin": "" + } + } + } + } + } + }, + { + "send": { "http": "GET /api/midi" }, + "expect": { + "reply": { + "body": { + "rxDetail": { + "worker": "", "abs": ["", "", "", ""], "rel": ["", "", "", ""], + "queueDropped": "", "txQueueDropped": "", "packetGaps": "", "parseErrors": "", "bufferOverflows": "", + "pollMaxGapUs": "", "pollMaxUs": "", "queueMaxAgeUs": "", "stackMin": "" + } + } + } + } + } + ] +} diff --git a/docs/rest-api-vectors/midi-status.json b/docs/rest-api-vectors/midi-status.json new file mode 100644 index 000000000..5037a54a7 --- /dev/null +++ b/docs/rest-api-vectors/midi-status.json @@ -0,0 +1,24 @@ +{ + "contract": "docs/midi-spec.md#status", + "says": "`GET /api/status` on a build with this feature lists `\"midi\"` in `caps` and carries", + "features": ["midi"], + "steps": [ + { + "send": { "http": "GET /api/status" }, + "expect": { + "reply": { + "status": 200, + "body": { + "caps": ["patterns", "params", "sleep", "midi"], + "midi": { + "runtime": "", "channel": "", "outMode": "", "host": "", + "outDiv": ["", "", "", ""], + "outMul": ["", "", "", ""], + "rtpPeers": "", "rtpPeer": "", "rx": "", "tx": "" + } + } + } + } + } + ] +} diff --git a/docs/rest-api-vectors/mqtt-caps.json b/docs/rest-api-vectors/mqtt-caps.json new file mode 100644 index 000000000..1fd06945b --- /dev/null +++ b/docs/rest-api-vectors/mqtt-caps.json @@ -0,0 +1,9 @@ +{ + "contract": "docs/rest-api.md#conventions-for-future-additions", + "says": "A feature's endpoints exist only when that feature is composed in — the route itself is absent otherwise, so clients probe `caps` rather than expecting a soft 404.", + "features": ["mqtt"], + "steps": [ + { "send": { "http": "GET /api/status" }, "expect": { "reply": { "body": { "caps": ["patterns", "params", "sleep", "mqtt"] } } } }, + { "send": { "http": "GET /api/mqtt" }, "expect": { "reply": { "status": 200, "headers": { "content-type": "application/json" }, "body": { "ok": true } } } } + ] +} diff --git a/docs/rest-api-vectors/mqtt-fields.json b/docs/rest-api-vectors/mqtt-fields.json new file mode 100644 index 000000000..5fa3568c1 --- /dev/null +++ b/docs/rest-api-vectors/mqtt-fields.json @@ -0,0 +1,26 @@ +{ + "contract": "docs/rest-api.md#get-apimqtt", + "says": "Also reported: `normalHost`, `normalPort`, `normalUser`, `normalPrefix`, `normalHasPassword` (the saved Normal-mode broker, kept while Director mode overlays it) and `directorHost`.", + "features": ["mqtt"], + "steps": [ + { + "send": { "http": "GET /api/mqtt" }, + "expect": { + "reply": { + "status": 200, + "body": { + "ok": true, "role": "", "channel": "", "state": "", + "host": "", "port": "", "user": "", "prefix": "", + "pattern": "", "error": "", "mode": "", + "connected": "", "configured": "", "hasPassword": "", "forcesSub": "", + "knobs": ["", "", "", ""], + "params": ["", "", "", ""], + "paramActive": ["", "", "", ""], + "normalHost": "", "normalPort": "", "normalUser": "", "normalPrefix": "", + "normalHasPassword": "", "directorHost": "" + } + } + } + } + ] +} diff --git a/docs/rest-api-vectors/mqtt-forces-sub.json b/docs/rest-api-vectors/mqtt-forces-sub.json new file mode 100644 index 000000000..0820ee270 --- /dev/null +++ b/docs/rest-api-vectors/mqtt-forces-sub.json @@ -0,0 +1,11 @@ +{ + "contract": "docs/rest-api.md#get-apimqtt", + "says": "The selected channel (1–4) pins the role to Subscriber", + "features": ["mqtt"], + "steps": [ + { "send": { "http": "POST /api/mqtt?prefix=patternflow5&role=publisher" }, "expect": { "reply": { "body": { "channel": "live", "role": "publisher", "forcesSub": false } } } }, + { "send": { "http": "POST /api/mqtt?prefix=patternflow3" }, "expect": { "reply": { "body": { "channel": "ch3", "role": "subscriber", "forcesSub": true } } } }, + { "send": { "http": "POST /api/mqtt?role=publisher" }, "expect": { "reply": { "body": { "channel": "ch3", "role": "subscriber", "forcesSub": true } } } }, + { "send": { "http": "GET /api/mqtt" }, "expect": { "reply": { "body": { "channel": "ch3", "role": "subscriber", "forcesSub": true } } } } + ] +} diff --git a/docs/rest-api-vectors/mqtt-knobs.json b/docs/rest-api-vectors/mqtt-knobs.json new file mode 100644 index 000000000..072f5e189 --- /dev/null +++ b/docs/rest-api-vectors/mqtt-knobs.json @@ -0,0 +1,17 @@ +{ + "contract": "docs/rest-api.md#get-apimqtt", + "says": "Absolute accumulated click counts, signed, unbounded. Live in every role.", + "features": ["mqtt"], + "steps": [ + { "send": { "hand": { "knob": 1, "turn": 3 } } }, + { "send": { "hand": { "knob": 1, "turn": 2 } } }, + { "send": { "hand": { "knob": 2, "turn": -4 } } }, + { "send": { "http": "GET /api/mqtt" }, "expect": { "reply": { "body": { "role": "off", "knobs": [5, -4, 0, 0] } } } }, + { "send": { "http": "POST /api/mqtt?role=publisher" } }, + { "send": { "hand": { "knob": 4, "turn": 30 } } }, + { "send": { "http": "GET /api/mqtt" }, "expect": { "reply": { "body": { "role": "publisher", "knobs": [5, -4, 0, 30] } } } }, + { "send": { "http": "POST /api/mqtt?role=subscriber" } }, + { "send": { "hand": { "knob": 2, "turn": -1 } } }, + { "send": { "http": "GET /api/mqtt" }, "expect": { "reply": { "body": { "role": "subscriber", "knobs": [5, -5, 0, 30] } } } } + ] +} diff --git a/docs/rest-api-vectors/mqtt-password.json b/docs/rest-api-vectors/mqtt-password.json new file mode 100644 index 000000000..e0f15339b --- /dev/null +++ b/docs/rest-api-vectors/mqtt-password.json @@ -0,0 +1,16 @@ +{ + "contract": "docs/rest-api.md#get-apimqtt", + "says": "Passwords are never returned — only whether one is set.", + "features": ["mqtt"], + "steps": [ + { "send": { "http": "GET /api/mqtt" }, "expect": { "reply": { "body": { "hasPassword": false, "normalHasPassword": false } } } }, + { + "send": { "http": { "request": "POST /api/mqtt", "form": "host=broker.lan&user=ha&password=hunter2" } }, + "expect": { "reply": { "status": 200, "body": { "host": "broker.lan", "user": "ha", "hasPassword": true, "normalHasPassword": true, "password": "", "pass": "" } } } + }, + { + "send": { "http": "GET /api/mqtt" }, + "expect": { "reply": { "body": { "host": "broker.lan", "user": "ha", "hasPassword": true, "normalHasPassword": true, "password": "", "pass": "" } } } + } + ] +} diff --git a/docs/rest-api-vectors/mqtt-role.json b/docs/rest-api-vectors/mqtt-role.json new file mode 100644 index 000000000..1de3b29ce --- /dev/null +++ b/docs/rest-api-vectors/mqtt-role.json @@ -0,0 +1,11 @@ +{ + "contract": "docs/rest-api.md#get-apistatus", + "says": "`\"off\"`, `\"publisher\"` or `\"subscriber\"`.", + "features": ["mqtt"], + "steps": [ + { "send": { "http": "GET /api/status" }, "expect": { "reply": { "body": { "mqttRole": "off", "mqttState": "", "mqttConnected": false } } } }, + { "send": { "http": "POST /api/mqtt?role=publisher" }, "expect": { "reply": { "status": 200, "body": { "ok": true, "role": "publisher" } }, "status": { "mqttRole": "publisher" } } }, + { "send": { "http": "POST /api/mqtt?role=subscriber" }, "expect": { "reply": { "status": 200, "body": { "ok": true, "role": "subscriber" } }, "status": { "mqttRole": "subscriber" } } }, + { "send": { "http": "POST /api/mqtt?role=off" }, "expect": { "reply": { "status": 200, "body": { "ok": true, "role": "off" } }, "status": { "mqttRole": "off" } } } + ] +} diff --git a/docs/rest-api-vectors/mqtt-same-fields.json b/docs/rest-api-vectors/mqtt-same-fields.json new file mode 100644 index 000000000..9ed8dcb4e --- /dev/null +++ b/docs/rest-api-vectors/mqtt-same-fields.json @@ -0,0 +1,17 @@ +{ + "contract": "docs/rest-api.md#knobs-and-parameters", + "says": "`GET /api/mqtt` reports the same three fields and has since before `/api/status` did — but it lives in the MQTT addon, so a build without MQTT does not serve it.", + "features": ["mqtt"], + "steps": [ + { "send": { "hand": { "knob": 3, "turn": 2 } } }, + { "send": { "http": "POST /api/params?p2=800" } }, + { + "send": { "http": "GET /api/mqtt" }, + "expect": { "reply": { "body": { "knobs": [0, 0, 2, 0], "params": ["", 800, "", ""], "paramActive": [false, true, false, false] } } } + }, + { + "send": { "http": "GET /api/status" }, + "expect": { "reply": { "body": { "knobs": [0, 0, 2, 0], "params": ["", 800, "", ""], "paramActive": [false, true, false, false] } } } + } + ] +} diff --git a/docs/rest-api-vectors/patterns-file-404.json b/docs/rest-api-vectors/patterns-file-404.json new file mode 100644 index 000000000..5ab7153cb --- /dev/null +++ b/docs/rest-api-vectors/patterns-file-404.json @@ -0,0 +1,10 @@ +{ + "contract": "docs/rest-api.md#get-apipatternsfile", + "says": "`404` if the file is not there, `409` if storage is not mounted.", + "features": [], + "steps": [ + { "send": { "http": "GET /api/patterns/file?slug=no_such_module" }, "expect": { "reply": { "status": 404 } } }, + { "send": { "http": "GET /api/patterns/file?slug=no_such_module&ext=json" }, "expect": { "reply": { "status": 404 } } }, + { "send": { "http": "GET /api/patterns/file?slug=no_such_module&ext=thumb" }, "expect": { "reply": { "status": 404 } } } + ] +} diff --git a/docs/rest-api-vectors/patterns-file-download.json b/docs/rest-api-vectors/patterns-file-download.json new file mode 100644 index 000000000..9a6617a78 --- /dev/null +++ b/docs/rest-api-vectors/patterns-file-download.json @@ -0,0 +1,9 @@ +{ + "contract": "docs/rest-api.md#get-apipatternsfile", + "says": "This is a file download (`Content-Disposition: attachment`), one file at a time — the server is single-connection.", + "features": [], + "steps": [ + { "send": { "http": "GET /api/patterns/file?slug=wave_saw" }, "expect": { "reply": { "status": 200, "headers": { "content-disposition": "" } } } }, + { "send": { "http": "GET /api/patterns/file?slug=wave_saw&ext=json" }, "expect": { "reply": { "status": 200, "headers": { "content-disposition": "" } } } } + ] +} diff --git a/docs/rest-api-vectors/patterns-file-preset.json b/docs/rest-api-vectors/patterns-file-preset.json new file mode 100644 index 000000000..18777bd60 --- /dev/null +++ b/docs/rest-api-vectors/patterns-file-preset.json @@ -0,0 +1,6 @@ +{ + "contract": "docs/rest-api.md#get-apipatternsfile", + "says": "Presets have no sidecar.", + "features": [], + "steps": [{ "send": { "http": "GET /api/patterns/file?slug=origin&ext=json" }, "expect": { "reply": { "status": 404 } } }] +} diff --git a/docs/rest-api-vectors/patterns-file-sidecar.json b/docs/rest-api-vectors/patterns-file-sidecar.json new file mode 100644 index 000000000..210c18868 --- /dev/null +++ b/docs/rest-api-vectors/patterns-file-sidecar.json @@ -0,0 +1,9 @@ +{ + "contract": "docs/rest-api.md#get-apipatternsfile", + "says": "`ext=json` returns the module's **sidecar** — the only place per-pattern metadata is exposed over HTTP:", + "features": [], + "steps": [ + { "send": { "http": "GET /api/patterns/file?slug=wave_saw&ext=json" }, "expect": { "reply": { "status": 200, "body": { + "name": "", "abi": "", "knobs": ["", "", "", ""], "slug": "wave_saw" } } } } + ] +} diff --git a/docs/rest-api-vectors/patterns-list.json b/docs/rest-api-vectors/patterns-list.json new file mode 100644 index 000000000..41e80de72 --- /dev/null +++ b/docs/rest-api-vectors/patterns-list.json @@ -0,0 +1,15 @@ +{ + "contract": "docs/rest-api.md#get-apipatterns", + "says": "`active` is an index into `patterns`, or `-1` while nothing is loaded.", + "features": [], + "steps": [ + { + "send": { "http": "GET /api/patterns" }, + "expect": { "reply": { "status": 200, "body": { + "active": "", "presets": 1, "mounted": true, "free": "", "patterns": "", "pendingRev": "", "pending": "" } } } + }, + { "send": { "http": "GET /api/patterns/select?index=2" } }, + { "send": { "wait": 20 } }, + { "send": { "http": "GET /api/patterns" }, "expect": { "reply": { "body": { "active": 2 } } } } + ] +} diff --git a/docs/rest-api-vectors/patterns-select-404.json b/docs/rest-api-vectors/patterns-select-404.json new file mode 100644 index 000000000..5512e0498 --- /dev/null +++ b/docs/rest-api-vectors/patterns-select-404.json @@ -0,0 +1,13 @@ +{ + "contract": "docs/rest-api.md#get-apipatternsselect", + "says": "`404` for an out-of-range index or an unknown name.", + "features": [], + "steps": [ + { "send": { "http": "GET /api/patterns/select?index=0" } }, + { "send": { "wait": 20 } }, + { "send": { "http": "GET /api/patterns/select?index=-1" }, "expect": { "reply": { "status": 404 } } }, + { "send": { "http": "GET /api/patterns/select?index=9999" }, "expect": { "reply": { "status": 404 } } }, + { "send": { "http": "GET /api/patterns/select?name=No%20such%20pattern" }, "expect": { "reply": { "status": 404 } } }, + { "send": { "wait": 20 }, "expect": { "status": { "active": "Origin" } } } + ] +} diff --git a/docs/rest-api-vectors/patterns-select-name.json b/docs/rest-api-vectors/patterns-select-name.json new file mode 100644 index 000000000..3dcbe1a19 --- /dev/null +++ b/docs/rest-api-vectors/patterns-select-name.json @@ -0,0 +1,12 @@ +{ + "contract": "docs/rest-api.md#get-apipatternsselect", + "says": "Display name, exact match. Only if you have no index.", + "features": [], + "steps": [ + { "send": { "http": "GET /api/patterns/select?index=1" } }, + { "send": { "wait": 20 }, "expect": { "status": { "activeIsModule": true } } }, + { "send": { "http": "GET /api/patterns/select?name=origin" }, "expect": { "reply": { "status": 404 } } }, + { "send": { "http": "GET /api/patterns/select?name=Origin" }, "expect": { "reply": { "status": 200, "body": { "ok": true, "index": 0, "name": "Origin" } } } }, + { "send": { "wait": 20 }, "expect": { "status": { "active": "Origin" } } } + ] +} diff --git a/docs/rest-api-vectors/patterns-select-step.json b/docs/rest-api-vectors/patterns-select-step.json new file mode 100644 index 000000000..6c104f822 --- /dev/null +++ b/docs/rest-api-vectors/patterns-select-step.json @@ -0,0 +1,16 @@ +{ + "contract": "docs/rest-api.md#get-apipatternsselect", + "says": "`+1` or `-1`: the next or previous pattern that is not hidden, wrapping, from wherever the panel is now.", + "features": [], + "steps": [ + { "send": { "http": "GET /api/patterns/select?index=0" } }, + { "send": { "wait": 20 } }, + { "send": { "http": "GET /api/patterns/select?step=%2B1" }, "expect": { "reply": { "status": 200, "body": { "index": 1 } } } }, + { "send": { "wait": 20 } }, + { "send": { "http": "GET /api/patterns/select?step=-1" }, "expect": { "reply": { "status": 200, "body": { "index": 0 } } } }, + { "send": { "wait": 20 } }, + { "send": { "http": "GET /api/patterns/select?step=-1" }, "expect": { "reply": { "status": 200, "body": { "index": "" } } } }, + { "send": { "wait": 20 } }, + { "send": { "http": "GET /api/patterns/select?step=%2B1" }, "expect": { "reply": { "status": 200, "body": { "index": 0 } } } } + ] +} diff --git a/docs/rest-api-vectors/patterns-select.json b/docs/rest-api-vectors/patterns-select.json new file mode 100644 index 000000000..0f147d12b --- /dev/null +++ b/docs/rest-api-vectors/patterns-select.json @@ -0,0 +1,13 @@ +{ + "contract": "docs/rest-api.md#get-apipatternsselect", + "says": "Queued and applied in `loop()`, for the same reason as sleep: activating a module reads FATFS and runs the relocator.", + "features": [], + "steps": [ + { "send": { "http": "GET /api/patterns/select?index=0" } }, + { "send": { "wait": 20 }, "expect": { "status": { "active": "Origin", "activeIsModule": false } } }, + { "send": { "http": "GET /api/patterns/select?index=1" }, "expect": { "reply": { "status": 200, "body": { "ok": true, "index": 1, "name": "" } } } }, + { "send": { "http": "GET /api/patterns" }, "expect": { "reply": { "body": { "active": 0 } } } }, + { "send": { "wait": 20 }, "expect": { "status": { "activeIsModule": true } } }, + { "send": { "http": "GET /api/patterns" }, "expect": { "reply": { "body": { "active": 1 } } } } + ] +} diff --git a/docs/rest-api-vectors/patterns-thumb.json b/docs/rest-api-vectors/patterns-thumb.json new file mode 100644 index 000000000..ab8dd6f23 --- /dev/null +++ b/docs/rest-api-vectors/patterns-thumb.json @@ -0,0 +1,14 @@ +{ + "contract": "docs/rest-api.md#get-apipatternsfile", + "says": "`404` until the pattern has been played once; presets (`origin`) get one the same way.", + "features": [], + "steps": [ + { "send": { "http": "GET /api/patterns/select?index=0" } }, + { "send": { "wait": 20 } }, + { "send": { "http": "GET /api/patterns/file?slug=origin&ext=thumb" }, "expect": { "reply": { "status": 404 } } }, + { "send": { "wait": 3000 } }, + { "send": { "http": "GET /api/patterns/select?index=1" } }, + { "send": { "wait": 20 }, "expect": { "status": { "activeIsModule": true } } }, + { "send": { "http": "GET /api/patterns/file?slug=origin&ext=thumb" }, "expect": { "reply": { "status": 200 } } } + ] +} diff --git a/docs/rest-api-vectors/state-params-clicks.json b/docs/rest-api-vectors/state-params-clicks.json new file mode 100644 index 000000000..ef7fd256b --- /dev/null +++ b/docs/rest-api-vectors/state-params-clicks.json @@ -0,0 +1,10 @@ +{ + "contract": "docs/rest-api.md#post-apiparams", + "says": "A legacy pattern — one that reads `knobDeltas` and nothing else — follows a held value too: the device turns the change in it into clicks, ten bus units per click, so `p1=750` moves such a pattern the same way the encoder would.", + "features": [], + "steps": [ + { "send": { "http": "POST /api/params?p1=500" } }, + { "send": { "http": "POST /api/params?p1=750" } }, + { "send": { "wait": 20 }, "expect": { "input": { "given": { "deltas": [25, 0, 0, 0] } } } } + ] +} diff --git a/docs/rest-api-vectors/state-params-hand.json b/docs/rest-api-vectors/state-params-hand.json new file mode 100644 index 000000000..0c64b3abc --- /dev/null +++ b/docs/rest-api-vectors/state-params-hand.json @@ -0,0 +1,13 @@ +{ + "contract": "docs/rest-api.md#post-apiparams", + "says": "A channel is **held** once written and released the moment somebody turns that knob, so an automated source can pin a look and hands always win it back.", + "features": [], + "steps": [ + { "send": { "http": "POST /api/params?p2=300" }, "expect": { "reply": { "body": { "ok": true, "active": [false, true, false, false] } } } }, + { "send": { "wait": 1000 }, "expect": { "status": { "params": ["", 300, "", ""], "paramActive": [false, true, false, false] } } }, + { "send": { "hand": { "knob": 1, "turn": 1 } } }, + { "send": { "wait": 20 }, "expect": { "status": { "paramActive": [false, true, false, false] } } }, + { "send": { "hand": { "knob": 2, "turn": -1 } } }, + { "send": { "wait": 20 }, "expect": { "status": { "paramActive": [false, false, false, false] } } } + ] +} diff --git a/docs/rest-api-vectors/state-params-subset.json b/docs/rest-api-vectors/state-params-subset.json new file mode 100644 index 000000000..88c3abff0 --- /dev/null +++ b/docs/rest-api-vectors/state-params-subset.json @@ -0,0 +1,24 @@ +{ + "contract": "docs/rest-api.md#post-apiparams", + "says": "`p1`..`p4`, each `0..1000`, any subset. A request needs at least one `pN` or `dN`.", + "features": [], + "steps": [ + { + "send": { "http": { "request": "POST /api/params", "form": "p1=750&p3=250" } }, + "expect": { + "reply": { "status": 200, "body": { "ok": true, "params": [750, "", 250, ""], "active": [true, false, true, false] } }, + "status": { "params": [750, "", 250, ""], "paramActive": [true, false, true, false] } + } + }, + { "send": { "http": "POST /api/params" }, "expect": { "reply": { "body": { "ok": false, "error": "" } } } }, + { "send": { "http": "POST /api/params?d2=1" }, "expect": { "reply": { "status": 200, "body": { "ok": true } } } }, + { + "send": { "http": "POST /api/params?p2=1001" }, + "expect": { "reply": { "body": { "ok": false, "error": "" } }, "status": { "paramActive": [true, false, true, false] } } + }, + { + "send": { "http": "POST /api/params?p2=1000&p4=0" }, + "expect": { "reply": { "status": 200, "body": { "ok": true, "params": [750, 1000, 250, 0], "active": [true, true, true, true] } } } + } + ] +} diff --git a/docs/rest-api-vectors/state-sleep-400.json b/docs/rest-api-vectors/state-sleep-400.json new file mode 100644 index 000000000..f4876ca5f --- /dev/null +++ b/docs/rest-api-vectors/state-sleep-400.json @@ -0,0 +1,10 @@ +{ + "contract": "docs/rest-api.md#post-apisleep", + "says": "`400` with `{\"ok\":false,\"error\":\"on must be 1, 0, or toggle\"}` for anything else.", + "features": [], + "steps": [ + { "send": { "http": "POST /api/sleep?on=maybe" }, "expect": { "reply": { "status": 400, "body": { "ok": false, "error": "on must be 1, 0, or toggle" } } } }, + { "send": { "http": "POST /api/sleep?on=2" }, "expect": { "reply": { "status": 400, "body": { "ok": false, "error": "on must be 1, 0, or toggle" } } } }, + { "send": { "wait": 20 }, "expect": { "status": { "sleep": false } } } + ] +} diff --git a/docs/rest-api-vectors/state-sleep-before.json b/docs/rest-api-vectors/state-sleep-before.json new file mode 100644 index 000000000..65f6e997b --- /dev/null +++ b/docs/rest-api-vectors/state-sleep-before.json @@ -0,0 +1,11 @@ +{ + "contract": "docs/rest-api.md#post-apisleep", + "says": "`sleep` is the state **before** the transition — the request is only queued here.", + "features": [], + "steps": [ + { "send": { "http": "POST /api/sleep?on=1" }, "expect": { "reply": { "status": 200, "body": { "ok": true, "requested": true, "sleep": false } } } }, + { "send": { "wait": 20 }, "expect": { "status": { "sleep": true } } }, + { "send": { "http": "POST /api/sleep?on=0" }, "expect": { "reply": { "status": 200, "body": { "ok": true, "requested": false, "sleep": true } } } }, + { "send": { "wait": 20 }, "expect": { "status": { "sleep": false } } } + ] +} diff --git a/docs/rest-api-vectors/state-sleep-form.json b/docs/rest-api-vectors/state-sleep-form.json new file mode 100644 index 000000000..56452b014 --- /dev/null +++ b/docs/rest-api-vectors/state-sleep-form.json @@ -0,0 +1,21 @@ +{ + "contract": "docs/rest-api.md#transport", + "says": "Request parameters are **query-string or form-encoded, never a JSON body** — `POST /api/sleep?on=1` and a form body with `on=1` are the same call.", + "features": [], + "steps": [ + { + "send": { "http": { "request": "POST /api/sleep", "form": "on=1" } }, + "expect": { "reply": { "status": 200, "body": { "ok": true, "requested": true, "sleep": false } } } + }, + { "send": { "wait": 20 }, "expect": { "status": { "sleep": true } } }, + { + "send": { "http": { "request": "POST /api/sleep", "form": "on=0" } }, + "expect": { "reply": { "status": 200, "body": { "ok": true, "requested": false, "sleep": true } } } + }, + { "send": { "wait": 20 }, "expect": { "status": { "sleep": false } } }, + { + "send": { "http": { "request": "POST /api/sleep", "form": "{\"on\":\"0\"}", "headers": { "Content-Type": "application/json" } } }, + "expect": { "reply": { "status": 200, "body": { "ok": true, "requested": true, "sleep": false } } } + } + ] +} diff --git a/docs/rest-api-vectors/state-sleep-on.json b/docs/rest-api-vectors/state-sleep-on.json new file mode 100644 index 000000000..05679d594 --- /dev/null +++ b/docs/rest-api-vectors/state-sleep-on.json @@ -0,0 +1,25 @@ +{ + "contract": "docs/rest-api.md#post-apisleep", + "says": "`1` / `true` / `sleep` · `0` / `false` / `wake` · `toggle`. Default `1` when omitted.", + "features": [], + "steps": [ + { "send": { "http": "POST /api/sleep?on=true" }, "expect": { "reply": { "body": { "ok": true, "requested": true, "sleep": false } } } }, + { "send": { "wait": 20 }, "expect": { "status": { "sleep": true } } }, + { "send": { "http": "POST /api/sleep?on=0" }, "expect": { "reply": { "body": { "ok": true, "requested": false, "sleep": true } } } }, + { "send": { "wait": 20 }, "expect": { "status": { "sleep": false } } }, + { "send": { "http": "POST /api/sleep?on=sleep" }, "expect": { "reply": { "body": { "ok": true, "requested": true, "sleep": false } } } }, + { "send": { "wait": 20 }, "expect": { "status": { "sleep": true } } }, + { "send": { "http": "POST /api/sleep?on=false" }, "expect": { "reply": { "body": { "ok": true, "requested": false, "sleep": true } } } }, + { "send": { "wait": 20 }, "expect": { "status": { "sleep": false } } }, + { "send": { "http": "POST /api/sleep?on=1" }, "expect": { "reply": { "body": { "ok": true, "requested": true, "sleep": false } } } }, + { "send": { "wait": 20 }, "expect": { "status": { "sleep": true } } }, + { "send": { "http": "POST /api/sleep?on=wake" }, "expect": { "reply": { "body": { "ok": true, "requested": false, "sleep": true } } } }, + { "send": { "wait": 20 }, "expect": { "status": { "sleep": false } } }, + { "send": { "http": "POST /api/sleep?on=toggle" }, "expect": { "reply": { "body": { "ok": true, "requested": true, "sleep": false } } } }, + { "send": { "wait": 20 }, "expect": { "status": { "sleep": true } } }, + { "send": { "http": "POST /api/sleep?on=toggle" }, "expect": { "reply": { "body": { "ok": true, "requested": false, "sleep": true } } } }, + { "send": { "wait": 20 }, "expect": { "status": { "sleep": false } } }, + { "send": { "http": "POST /api/sleep" }, "expect": { "reply": { "body": { "ok": true, "requested": true, "sleep": false } } } }, + { "send": { "wait": 20 }, "expect": { "status": { "sleep": true } } } + ] +} diff --git a/docs/rest-api-vectors/state-sleep-wake.json b/docs/rest-api-vectors/state-sleep-wake.json new file mode 100644 index 000000000..1d30f9f58 --- /dev/null +++ b/docs/rest-api-vectors/state-sleep-wake.json @@ -0,0 +1,16 @@ +{ + "contract": "docs/rest-api.md#post-apisleep", + "says": "Any physical knob turn or button press wakes the device, as does an incoming firmware image.", + "features": [], + "steps": [ + { "send": { "http": "POST /api/sleep" }, "expect": { "reply": { "body": { "ok": true, "requested": true } } } }, + { "send": { "wait": 1000 }, "expect": { "status": { "sleep": true } } }, + { "send": { "hand": { "knob": 3, "turn": 1 } } }, + { "send": { "wait": 20 }, "expect": { "status": { "sleep": false } } }, + { "send": { "http": "POST /api/sleep" } }, + { "send": { "wait": 1000 }, "expect": { "status": { "sleep": true } } }, + { "send": { "hand": { "knob": 2, "down": true } } }, + { "send": { "wait": 20 }, "expect": { "status": { "sleep": false } } }, + { "send": { "hand": { "knob": 2, "down": false } } } + ] +} diff --git a/docs/rest-api-vectors/state-status.json b/docs/rest-api-vectors/state-status.json new file mode 100644 index 000000000..5abea2fc8 --- /dev/null +++ b/docs/rest-api-vectors/state-status.json @@ -0,0 +1,52 @@ +{ + "contract": "docs/rest-api.md#get-apistatus", + "says": "The numbers that explain a device when something is off.", + "features": [], + "steps": [ + { + "send": { "http": "GET /api/status" }, + "expect": { + "reply": { + "status": 200, + "body": { + "version": "", "build": "", "uptime": "", "resetReason": "", "panel": "", + "wifi": "", "ssid": "", "ip": "", "rssi": "", + "host": "", "hostAlias": "", "viaHotspot": "", + "network": { "disconnects": "", "retries": "", "reconnectMs": "", "namesReady": "", "announcements": "" }, + "hotspot": { "mode": "", "up": "", "ssid": "", "ip": "", "channel": "", "clients": "", "dns": "" }, + "heapInternal": "", "heapLargest": "", "heapPsram": "", + "fsMounted": "", "fsTotal": "", "fsUsed": "", "fsError": "", "flashId": "", + "patterns": "", "presets": "", "modules": "", + "active": "", "activeIsModule": "", "loading": "", "loadError": "", + "sleep": "", "brightness": "", + "knobs": ["", "", "", ""], + "params": ["", "", "", ""], + "paramActive": ["", "", "", ""], + "lanes": ["", "", "", ""], + "laneActive": ["", "", "", ""], + "consolePaused": "", "busy": "", + "frameUs": "", "presentUs": "", + "runtime": { "loops": "", "lastUs": "", "maxUs": "", "over50ms": "", "housekeepingMaxUs": "", "syncMaxUs": "" }, + "moduleMemory": { + "reserve": "", "serviceFree": "", "execLargest": "", "runtimeBytes": "", "runtimePeakBytes": "", + "runtimeLimit": "", "refusals": "", "loaderRetries": "", "loaderStackBytes": "", "loaderStackMin": "", + "codePolicy": "", + "resident": { "count": "", "bytes": "", "slots": "", "resumes": "", "evictions": "" } + }, + "netMaintenance": { "calls": "", "maxGapMs": "" }, + "loopCore": "", "httpCore": "", "loopStackMin": "", "netStackMin": "", + "loopSyncServed": "", "loopSyncMaxUs": "", + "loopAgeMs": "", "loopStalled": "", "loopSyncGaveUp": "", + "colorBits": "", "refreshHz": "", + "thumbs": { "captures": "", "reads": "", "writes": "", "captureMaxUs": "", "ioMaxUs": "" }, + "load": { + "total": "", "read": "", "relocate": "", "setup": "", "internal": "", "psram": "", + "code": "", "resumed": "", "resumeUs": "" + }, + "variant": "", "caps": "", "featureNav": "" + } + } + } + } + ] +} diff --git a/docs/rest-api.md b/docs/rest-api.md index 9aafd1455..9c2bc8f79 100644 --- a/docs/rest-api.md +++ b/docs/rest-api.md @@ -35,7 +35,7 @@ Three properties of this server are not obvious from the endpoint list, and each Two things stayed true. **Installing patterns still evicts the running module for the duration of the upload batch** — the panel shows PAUSED and restores itself — which is what `status.consolePaused` reports now; treat it as "an install is in progress", not "somebody looked at the console". And a page load still momentarily competes with the render loop on this one-connection server, so sending a person to `http://patternflow.local/` is fine and expected; **polling HTML pages remains a bug** — automated clients keep to `/api/*`. -**Some writes report the state before they take effect.** Stopping a DMA engine, reclocking the CPU, reading FATFS and running the ELF relocator are not things a handler does inside an open HTTP transaction — the firmware queues them and `loop()` performs them. `POST /api/sleep` and `GET /api/patterns/select` therefore return the *previous* state. Set your own state optimistically and let the next poll confirm it; do not parse the reply's state field as the new value. (Since 3.9.1 the same rule holds from the other side: handlers run on the network core, and the few that must touch what the frame is using are executed on the loop task at the frame boundary — `loopSyncServed` in status counts them.) +**Some writes report the state before they take effect.** Stopping a DMA engine, reclocking the CPU, reading FATFS and running the ELF relocator are not things a handler does inside an open HTTP transaction — the firmware queues them and `loop()` performs them. `POST /api/sleep` therefore returns the *previous* state. `GET /api/patterns/select` answers with the pattern it queued, and `/api/patterns` and `/api/status` name the old one until the next frame. Set your own state optimistically and let the next poll confirm it; do not parse the reply's state field as the new value. (Since 3.9.1 the same rule holds from the other side: handlers run on the network core, and the few that must touch what the frame is using are executed on the loop task at the frame boundary — `loopSyncServed` in status counts them.) ## Device state @@ -107,7 +107,7 @@ The numbers that explain a device when something is off. Requires `PF_STATUS_HTT | `thumbs` | Since 1.5: boot counters `captures` (cache refreshed), `reads` (disk image adopted), `writes` (save completed). `captureMaxUs` is the maximum loop-side capture duration; `ioMaxUs` is the maximum background file-operation wall time, including scheduling/filesystem contention. Both are µs. The cache and its temporary I/O snapshot use PSRAM only. | | `load` | The last module load, in µs: `total`, `read` (flash), `relocate`, `setup` (the pattern's own). Since 1.4 also where its sections landed, in bytes: `internal` and `psram`. A big `internal` is the number to look at when the console dies the moment a pattern is chosen — the loader now sends data sections over 16 KB, or any that would leave under 24 KB of internal heap, to PSRAM (`PF_MODULE_DATA_INTERNAL_MAX`, `PF_MODULE_INTERNAL_RESERVE`). Since 1.5 `code` says where the module's code runs from, `"psram"` or `"internal"`: in PSRAM it is counted in `psram` and takes nothing from the internal heap, and `"internal"` on a module means PSRAM could not hold it or `moduleMemory.codePolicy` is `0` - which it becomes, until reboot, if code placed in PSRAM ever fails to read back on this unit. Since 1.5 data sections go to PSRAM first as well, whatever their size, so `internal` is `0` on a module that loaded normally. Since 1.5 `load` describes the module on the panel: `resumed` is `true` when it came back from residency rather than from a load - the timings are then its own, from when it first loaded, and nothing was read or set up this time - and `resumeUs` is what coming back took; `false` and `0` after a fresh load. With no module on the panel (a preset, or a load that failed) `internal` and `psram` are `0`, `code` reads `"internal"`, `resumed` is `false` and `resumeUs` `0`; the timings are left as the last module had them - except after a failed load, where `read`, and `relocate` if the load got that far, describe the attempt that failed. | | `variant` | Which firmware this is: `"core"`, or a variant's own name. What the site's variant list matches, and what stops the update banner offering a core build on top of someone's chosen firmware. | -| `caps` | What this build can do. **Probe this rather than assuming a feature exists.** The default build reports `["patterns","params","sleep"]` and nothing else; each [edition](EDITIONS.md) adds its own — Audio adds `osc` and `audio`, Performance adds `weather`, `mqtt` and `shows`. `patterns` and `params` are on every build. | +| `caps` | What this build can do. **Probe this rather than assuming a feature exists.** The default build reports `["patterns","params","sleep"]` and nothing else; each [edition](EDITIONS.md) adds its own — Audio adds `osc`, `audio`, `audio-in` and `midi`, Performance adds `clock`, `weather`, `mqtt` and `shows`. `patterns` and `params` are on every build. | | `mqttRole` | `"off"`, `"publisher"` or `"subscriber"`. Decides whether the device obeys knob and pattern topics — see [Knobs](#knobs-and-parameters). | | `featureNav` | `[path, label, one-line description]` per console page the loaded features serve, e.g. `[["/audio-in","Audio","The panel hears the room…"]]`. What the console header and home screen build their feature links from; empty on the default build. | @@ -162,7 +162,8 @@ Erases the core dump and drops the record; `crash` is absent from the next statu Write the absolute parameter bus — the four channels a pattern reads as set-points beside the physical encoders. -`p1`..`p4`, each `0..1000`, any subset; at least one required. A channel is +`p1`..`p4`, each `0..1000`, any subset. A request needs at least one `pN` +or `dN`. A channel is **held** once written and released the moment somebody turns that knob, so an automated source can pin a look and hands always win it back. Patterns see it only if they declare `ABSOLUTE_READY` and were built at module ABI 2. @@ -592,8 +593,8 @@ A client that has to key on something stable should use the hostname and require | | HTTP | OSC | MIDI | MQTT | |---|---|---|---|---| | Read device state | **yes**, everything | announce burst + heartbeat | pattern changes only (Program Change) | knob / pattern / sleep topics, in Publisher role | -| Read knob positions | **yes**, `/api/mqtt`, any role | on change | on change, relative (CC 24–27) | in Publisher role | -| Write knobs | **no** | **yes**, deltas | **yes**, absolute (CC 20–23) and deltas (CC 24–27) | **yes**, Subscriber role only | +| Read knob positions | **yes**, `/api/status` | on change | on change, relative (CC 24–27) | in Publisher role | +| Write knobs | **yes**, absolute (`POST /api/params`) and deltas (`d1`..`d4`) | **yes**, deltas | **yes**, absolute (CC 20–23) and deltas (CC 24–27) | **yes**, Subscriber role only | | Switch pattern | **yes** | yes | yes, Program Change | Subscriber role only | | Sleep / wake | **yes** | no | no | **yes**, either role | | Install / delete patterns | **yes** | no | no | no | @@ -601,13 +602,13 @@ A client that has to key on something stable should use the hostname and require | Latency | poll-bound, seconds | sub-frame | sub-frame | broker-bound | | Needs infrastructure | nothing | nothing | an RTP-MIDI driver on the host (built into macOS/iOS; rtpMIDI on Windows) | a broker | -In short: HTTP is the management and state transport, OSC and MIDI are the low-latency performance transports (MIDI is the one a DAW already speaks, and the only one with absolute knob values — [`midi-spec.md`](midi-spec.md)), MQTT is the one that reaches into home automation and mirrors one panel onto another. A home-automation client wants HTTP for everything except knob writes, and MQTT for those. +In short: HTTP is the management and state transport, OSC and MIDI are the low-latency performance transports (MIDI is the one a DAW already speaks, and of the two the one with absolute knob values — [`midi-spec.md`](midi-spec.md)), MQTT is the one that reaches into home automation and mirrors one panel onto another. A home-automation client can do all of it over HTTP, knob writes included. ## Conventions for future additions - JSON lives under `/api/`; anything else on this server is a page for a person. - Parameters are query-string or form-encoded. There is no JSON request body anywhere, and adding one would need a body parser the firmware does not have. -- Errors are `{"ok":false,"error":""}` with a `4xx`/`5xx` status. Success bodies vary; the ones that report a mutation start with `"ok":true`. +- Errors are `{"ok":false,"error":""}` with a `4xx`/`5xx` status. A path no route serves is the one exception: its `404` is plain text. Success bodies vary; the ones that report a mutation start with `"ok":true`. - A route whose handler has to run on the loop task answers `503 {"ok":false,"error":"render loop is not answering"}` when the loop has stopped coming round (`loopStalled` in status) — the pattern list, select, install, delete, format, the knob settings, and a feature's routes that reconfigure what its loop hook is ticking. Nothing was changed. Retrying helps only once `loopStalled` reads `false` again, which a pattern stuck in `draw()` never lets it: that panel has to be restarted (`POST /api/wifi/reboot` still answers). A request made in the first twenty seconds after the loop stops waits out the rest of them for that reply; later ones get it at once. A new handler that uses `PFLoopSync::run()` answers a `false` the same way, with `PatternflowHttp::sendLoopStalled()`. - A handler must never touch FATFS, the ELF loader, the DMA engine or the CPU clock. Queue the work and let `loop()` do it; the reply then reports the pre-transition state, which callers already expect. - A feature's endpoints exist only when that feature is composed in — the route itself is absent otherwise, so clients probe `caps` rather than expecting a soft 404. Core endpoints are gated by a core `PF_*_ENABLED` flag and must leave the sketch compiling at `0`. @@ -615,7 +616,7 @@ In short: HTTP is the management and state transport, OSC and MIDI are the low-l ## Version history -- **1.5** (unreleased) — the hotspot (`hotspot` in status, `GET`/`POST /api/hotspot`); status gains `network` and `thumbs` diagnostics, `fsError` (why storage is not mounted) and `flashId`; a failed `POST /api/patterns/format` returns the reason as its `error`; `POST /api/wifi/reconnect` reconnects without a reboot; core name registration retries partial failures and preserves feature-owned services; the MIDI edition adds a `midiUsb` block to status ([`midi-spec.md`](midi-spec.md)); `GET`/`POST /api/knobs` and the `/knobs` page (encoder direction and edges per click, per knob, persisted); `PUT /update` takes a raw image, and an upload survives a stall of up to two minutes; `GET /api/wifi` gains `join` (what became of the last network asked for) and `GET /api/hotspot` gains `seen` (the names in range); a network added on the hotspot with no station link is tried at once; console pages are cached by build (`?v=` immutable, bare URLs `no-cache` with an `ETag` and `304`, another build's `v` redirected with `302`), the chrome by its CRC (`?h=`), only for a `Host` that can only be the panel; `GET /favicon.ico` answers `204`; status gains `build`, `viaHotspot` and `busy`; `load` gains `code` (where the module's code runs from); pattern names in `GET /api/patterns` and `/api/patterns/select` are JSON-escaped, so a quote or backslash in a title no longer makes the reply unparseable; a multipart request that stops mid-form is dropped - the connection is closed with no reply - instead of spinning the server into its watchdog, and leaves none of its fields behind for later requests; `moduleMemory` gains `codePolicy`; status gains `loopStackMin`; the hotspot password is JSON-escaped in `/api/hotspot`; a `POST` that is not multipart to `/api/patterns` or `/update` is answered instead of crashing the panel, a multipart boundary longer than 70 characters is refused, and a raw `PUT` sees its own query string; status gains `crash` (the core dump's summary, and the pattern and phase the reset interrupted) and `DELETE /api/crash` clears it; status gains `loopAgeMs`, `loopStalled` and `loopSyncGaveUp`, and a route that needs the render loop answers `503` (`render loop is not answering`) when a pattern never returns from `draw()`, where it used to take the whole server down with it; a module that has run stays loaded when another pattern takes over and resumes when picked again - `load` gains `resumed` and `resumeUs`, `moduleMemory` gains `resident`, and a module's data sections go to PSRAM first; `POST /api/audio-in` applies a band when any of its suffixed fields is present (it took one of five before, and answered `ok` to the rest without changing anything), and refuses a bad `band` before applying anything, with `ok:false` in the body; `POST /api/audio-in/reset` takes `band=N` and names the band in its answer, and a full reset now includes `attack`; `hzRange` is printed to two decimals; a microphone that failed to start or stopped answering (`source` beginning `synth`) no longer drives the knobs from the test signal; `/api/audio-in` ignores a value that is not a finite number (`nan`, `inf`) where it used to store it and print `nan` back, which no JSON parser reads, and a band whose low edge is in the top analysis bin reads 0 instead of putting `nan` into the poll and into `audioIn` in status; the microphone section documents what was already there - `smoothing`, `attack`, `meta`, `lutSet`, `lut`, the suffixed field names, `env`, `ext`, the `frame` post, `synth (mic stalled)`, that `spec` has 64 buckets, not 40, and that a body sent without the form `Content-Type` is not read as arguments. +- **1.5** (unreleased) — the hotspot (`hotspot` in status, `GET`/`POST /api/hotspot`); status gains `network` and `thumbs` diagnostics, `fsError` (why storage is not mounted) and `flashId`; a failed `POST /api/patterns/format` returns the reason as its `error`; `POST /api/wifi/reconnect` reconnects without a reboot; core name registration retries partial failures and preserves feature-owned services; the MIDI edition adds a `midiUsb` block to status ([`midi-spec.md`](midi-spec.md)); `GET`/`POST /api/knobs` and the `/knobs` page (encoder direction and edges per click, per knob, persisted); `PUT /update` takes a raw image, and an upload survives a stall of up to two minutes; `GET /api/wifi` gains `join` (what became of the last network asked for) and `GET /api/hotspot` gains `seen` (the names in range); a network added on the hotspot with no station link is tried at once; console pages are cached by build (`?v=` immutable, bare URLs `no-cache` with an `ETag` and `304`, another build's `v` redirected with `302`), the chrome by its CRC (`?h=`), only for a `Host` that can only be the panel; `GET /favicon.ico` answers `204`; status gains `build`, `viaHotspot` and `busy`; `load` gains `code` (where the module's code runs from); pattern names in `GET /api/patterns` and `/api/patterns/select` are JSON-escaped, so a quote or backslash in a title no longer makes the reply unparseable; a multipart request that stops mid-form is dropped - the connection is closed with no reply - instead of spinning the server into its watchdog, and leaves none of its fields behind for later requests; `moduleMemory` gains `codePolicy`; status gains `loopStackMin`; the hotspot password is JSON-escaped in `/api/hotspot`; a `POST` that is not multipart to `/api/patterns` or `/update` is answered instead of crashing the panel, a multipart boundary longer than 70 characters is refused, and a raw `PUT` sees its own query string; status gains `crash` (the core dump's summary, and the pattern and phase the reset interrupted) and `DELETE /api/crash` clears it; status gains `loopAgeMs`, `loopStalled` and `loopSyncGaveUp`, and a route that needs the render loop answers `503` (`render loop is not answering`) when a pattern never returns from `draw()`, where it used to take the whole server down with it; a module that has run stays loaded when another pattern takes over and resumes when picked again - `load` gains `resumed` and `resumeUs`, `moduleMemory` gains `resident`, and a module's data sections go to PSRAM first; `POST /api/audio-in` applies a band when any of its suffixed fields is present (it took one of five before, and answered `ok` to the rest without changing anything), and refuses a bad `band` before applying anything, with `ok:false` in the body; `POST /api/audio-in/reset` takes `band=N` and names the band in its answer, and a full reset now includes `attack`; `hzRange` is printed to two decimals; a microphone that failed to start or stopped answering (`source` beginning `synth`) no longer drives the knobs from the test signal; `/api/audio-in` ignores a value that is not a finite number (`nan`, `inf`) where it used to store it and print `nan` back, which no JSON parser reads, and a band whose low edge is in the top analysis bin reads 0 instead of putting `nan` into the poll and into `audioIn` in status; the microphone section documents what was already there - `smoothing`, `attack`, `meta`, `lutSet`, `lut`, the suffixed field names, `env`, `ext`, the `frame` post, `synth (mic stalled)`, that `spec` has 64 buckets, not 40, and that a body sent without the form `Content-Type` is not read as arguments; where this file said two things, the route's own section now stands: select answers with the pattern it queued, HTTP writes knobs and reads them from `/api/status`, `d1`..`d4` alone are a whole `POST /api/params`, `caps` names what each edition adds, and only a path no route serves gets a plain-text `404`. - **1.4** (2026-09-06) — `GET /api/patterns/file` gains `ext=thumb`; `GET /api/display` takes `brightness` and status reports it; status gains `resetReason` and `load.internal`/`load.psram`; console pages are served gzip-compressed (`Content-Encoding: gzip`); the page sender no longer truncates on a slow link; the server no longer trips the Core-0 watchdog on a request that stalls mid-header. - **1.3** (2026-09-04) — `GET`/`POST /api/clock` (Utility edition) and the `clock` block in status; `caps` gains `"clock"`. - **1.2** (2026-09-03) — the server is serviced on Core 0 (the one-connection rule stands; the render-pays rule is history); status gains `httpCore`, `netStackMin`, `loopSyncServed`/`loopSyncMaxUs`; `POST /api/params` documents `d1`..`d4` and how a held value reaches a legacy pattern; `GET /api/patterns/select` gains `step`; `GET`/`POST /api/audio` (Audio-React) are documented; `featureNav`'s microphone label is *Audio*. diff --git a/docs/rfc-any-resolution.md b/docs/rfc-any-resolution.md new file mode 100644 index 000000000..69453e135 --- /dev/null +++ b/docs/rfc-any-resolution.md @@ -0,0 +1,298 @@ +# RFC: any resolution, the firmware path + +*A panel of another size without a rebuild. Written 2026-10-15.* + +> **Status, 15 October 2026.** Draft. Nothing in it is built. It moves to +> proposed when the cleanup's gate holds +> ([rfc-cleanup.md §2.12](rfc-cleanup.md#212-order-and-what-done-means)). +> Facts are from `dev` at `adbe923`. What a step plan has not merged yet is +> named by its plan and its k. + +This document is two things on purpose. **Part 1 is the maintainer, in his +own words.** **Part 2 is the specification** — precise and mechanical, +written so a contributor (or an AI agent handed this file) can implement +against it without asking what was meant. + +--- + +# Part 1 — Why + +--- + +# Part 2 — The specification + +## 2.1 What changes and what stays + +One ESP32-S3 drives one chain of classic HUB75 modules, as it does today. What +changes is when the geometry is chosen. Today another panel means another +build of `config.h`; here the device reads it from NVS at boot, and a console +page sets it. With nothing saved, the device runs `config.h`'s geometry, one +128×64 module, and draws the same frames. A pattern still declares its own grid +with `setFrame` on the device and `// @matrix` on the site, by the rule the two +share (`core_canvas.h:137-163`, `matrix.ts:127`). + +## 2.2 Where the size lives + +[The panel geometry record](investigations/2026-10-panel-geometry.md) lists +every place with the command behind each count. Of the 36 occurrences of +`PANEL_RES_W` and `PANEL_RES_H` that §2.7 counts: + +| where | uses | after this RFC | +| --- | --- | --- | +| `src/core_canvas.h:38-39` | 2 | read the geometry; the buffer is allocated (§2.3) | +| `src/core_module_loader.h:601-602, 1145` | 4 | read the geometry; `hostAPI` is filled at boot | +| `src/core_status_http.h:282, 284` | 2 | read the geometry | +| `src/core_tables.h:52, 57, 70-76` | 9 | read the geometry | +| `features/clock/core_clock_http.h:116, 118` | 2 | read the geometry | +| `features/weather/preset_weather.h:492-493` | 2 | stay: a layout drawn at the default size | +| `abi/pf_module.h:47, 71-72, 175-176, 281`, `abi/pf_abi.h:116` | 7, two comments | stay: a module's size is its build's | +| `src/core_display.h:18`, `src/core_mem.h:17`, `src/core_tables.h:11, 24-26` | 8, comment | reworded where they call the macro the panel | + +Nineteen read the geometry. Nine stay compile-time, because in the firmware +the macro is now the default and in a module it is the size the module was +built for (`pf_module.h:71-72`). + +## 2.3 The runtime geometry + +`src/core_geometry.h` holds one value, read once at boot: + +| field | compile-time default | NVS key, namespace `pf_panel` | +| --- | --- | --- | +| module width and height | `PANEL_PHYS_W`, `PANEL_PHYS_H` | `mw`, `mh` | +| chain | `PANEL_CHAIN` | `chain` | +| driver profile | `PANEL_PROFILE` | `prof` | +| canvas width and height | module width × chain, module height | none | + +The canvas follows the rule the two `#error`s hold today (`config.h:110-115`). +`PFGeometry::load()` runs before `initDisplay()` (`patternflow.ino:482`), and +a missing key or a value outside §2.8's bound gives the default. `initDisplay()` +passes the module size, the chain (`core_display.h:20`) and the driver the +profile names (`core_display.h:25`). + +What stays compile-time. Both ABI structs carry `panel_w` and `panel_h` +(`pf_abi.h:82-83`, `117-118`), frozen at version 1, and a module takes its size +from its build. The presets that size arrays by the macro (`preset_0629_2.h`, +`preset_0713.h`) are modules too: only Origin and the calibration card compile +in (`pattern_registry.h:44-48`), and the other 33 reach a device in +`basics.zip`. Those two and weather's compact +layout draw at the macro's size, and the core calls `setFrame(PANEL_RES_W, +PANEL_RES_H)` before they draw. It centres them on another panel and is the +straight path on the default one (`core_canvas.h:142`). `env:firmware64x2` +keeps working, as a build whose default is the chain. + +What becomes an allocation. The canvas, a global of W × H × 3 bytes +(`core_canvas.h:40-42`, 24,576 at 128×64), is allocated at boot in internal +RAM, where the blit reads it today. `PFThumbs::W` and `H` +(`core_thumbs.h:44-47`) become values, and a thumbnail of another size is +already refused and taken again (`core_thumbs.h:149-152`). + +Touches `src/core_geometry.h` (new), `core_display.h`, `core_canvas.h`, +`core_thumbs.h`, `core_tables.h`, `core_module_loader.h`, `core_status_http.h`, +`patternflow.ino` and `features/clock/core_clock_http.h`. Five PRs: the +geometry and the driver, the canvas and thumbnails, the tables with the loader +and status, the compiled-in patterns (all core), and the clock (a feature). + +## 2.4 Modules + +A `.pfm` is ESP32-S3 machine code for one size, and it stays one build per +size. `build_module.py` takes `--panel WxH`, default `128x64`, in place of the +pair typed at `:51-52`, for the flags (`:258-259`) and the sidecar +(`:323-324`). `make_pack.py` takes it for the manifest's `panel` (`:199`). +`basics.zip` stays the 128×64 pack. + +The cache key gains the size, as §2.7 asks. `module_cache` gets a `panel` +column in its primary key (`schema.ts:727`), `"128x64"` on every row it holds. +SQLite cannot change a primary key in place, so the migration in `web/drizzle/` +rebuilds the table. Every lookup and upsert in `moduleCache.ts` (lines 102, +117, 159, 198, 217 and 624) and the bake job (`builds`, `schema.ts:619`) carry +the size, and the deck and pack downloads take one, `128x64` by default. No +page reads a device's `/api/status` today, so the size comes from the person. + +The cheaper way, beside it. The web derives the key's second column from the +published `builder_rev` and the size: `builder_rev` itself at 128×64, a digest +of the two at any other, and the worker writes rows under the same value. No +migration and one PR, and `builder_rev` stops meaning the toolchain alone, +which `schema.ts:733-737` says it does. + +What the device shows for a module built at another size. Both sides refuse +(`core_module_loader.h:1143-1150`, `pf_module.h:312`), and the panel shows +PATTERN FAILED with the loader's reason (`patternflow.ino:907-945`), which +`/api/status` carries in `loadError`. After a geometry change every module from +the old size does this. So the device reads `panel_w` and `panel_h` from the +sidecar it already reads for `absoluteReady` (`pattern_registry.h:65-67`), +skips such a module while browsing as it skips a `hidden` one (`:60-64`), and +lists it with its size on the Patterns page. The reason names both sizes. + +Touches `build_module.py`, `make_pack.py`, `pattern_registry.h`, +`core_module_loader.h`, `console/patterns.html`, `schema.ts`, `moduleCache.ts`, +the worker, a migration, the downloads and the pack list (`packs.ts:54`). Six +PRs with the column, five the cheaper way: the toolchain, the skip and reason +(core), the Patterns page (core), the key (site, two), the downloads (site). + +## 2.5 The console page + +The panel is the device, so the page is core (hard rule 7): +`console/panel.html`, spliced into `src/panel_index.h` by `console_pages.py` +(`PAGES`, `:112`), and `src/core_panel_http.h` serving `/panel` and +`/api/panel` the way `/knobs` is served (`core_knobs_http.h:121-123`). Its +values sit in NVS under `pf_panel`, as the knobs' sit under `pf_knobs` +(`core_encoders.h:100-124`). The driver allocates its rows once, in `begin()`, +so a saved geometry takes effect at the next boot. The page offers modules of +64×32, 64×64, 128×32 and 128×64, chains of one to four and the six profiles +(`config.h:161-166`), inside §2.8's bound only, and then the restart. + +The way back, in three cases. When `begin()` fails, the device today stops in +`while (1)` before Wi-Fi comes up (`core_display.h:71-74`). Here it erases +`pf_panel` and restarts, and stops only when the default fails. When the panel +stays dark or scrambled but the device runs, the console still answers and +`/panel` puts the default back. With no network, holding K4's button through +power-on erases `pf_panel`. Nothing reads a button at power-on today. + +On the wire. `/api/status`'s `panel` reads the geometry +(`core_status_http.h:282-285`). `/api/panel` answers with the saved geometry, +the running one and the defaults. `rest-api.md` changes at lines 77, 274, 284 +and 499 and gains a section for `/api/panel`. OSC, MIDI, MQTT and the audio +socket name no size, and they do not change. + +Touches `console/panel.html`, `src/panel_index.h` (generated), +`src/core_panel_http.h`, `console_pages.py`, `patternflow.ino`, +`docs/rest-api.md`, `docs/panel-compatibility.md` and the `config.h` comments +that say to edit `PANEL_PROFILE`. Three PRs: the page and its API (core), the +button at power-on (core), the docs. + +## 2.6 The site + +`PatternRuntime` takes a width and a height (`harness.ts:486`), and +`// @matrix` takes 8 to 512 a side (`matrix.ts:46-47`). The parameterised +harness §2.7 asks for is there. What remains is `DeviceSim`, which builds its +runtime at `PANEL_W` × `PANEL_H` (`deviceSim.ts:167`, `panelScreens.ts:42-43`) +and takes a width and a height, defaulting to those. The guide's stage, the 3D +panel (`LedMatrixTexture.ts:20-21`, `vec2(128.0, 64.0)` five times) and the +guide's preview (`Extras.tsx:185, 248`) draw the stock device and stay at +128×64. Pattern Lab's C++ export writes the macro a module build fills +(`cppPrompt.ts:68`). + +`shared/constants.json` holds the three geometries and the default under +`panel` (step 3, k7). This RFC adds the module sizes the page offers and the +bound beside them. `core_shared.h` and `constants.ts` carry both once step 3's +k8 and k9 have `config.h` and `panelScreens.ts` read them. If those land in +another shape, the page and `DeviceSim` take the two by hand. + +Touches `web/src/lib/guide/deviceSim.ts` and its test, and +`shared/constants.json` with its two generated files. Two PRs, site and shared. + +## 2.7 The virtual panel at other sizes + +Step 4 builds the virtual panel as `DeviceSim` behind sockets, with its width +and height in one place (step 4, k2 and D1). `start()` takes a size and +`main.ts` takes `--size 64x64`, default 128×64. `/frame.png` is that size, +`/api/status` reports it, and the REST transport (`transports/rest-state.ts`) +answers `/api/panel`, keeping a posted value until the next `start()`. REST +comes first (answer 3), and the other transports name no size. + +`check:virtual-panel` (step 4, k8) runs the preset list at 128×64 and again at +64×64. Every JS preset reads `display.width` and `display.height`, so the +second run tests the site's engine at another size, not the device's modules. +It fails on a thrown error, not on a picture. + +Touches `tools/virtual-panel/panel.ts`, `main.ts`, `transports/rest-state.ts`, +`web/scripts/drift/virtual-panel.ts` and a vector in `docs/rest-api-vectors/`. +Two PRs, site, after step 4's k2, k4 and k8, and where its seam (D5) puts them. + +## 2.8 The limits + +Classic HUB75 only, tiers 1 and 2 in +[panel-compatibility.md §9](panel-compatibility.md#9-why-hub75e-guarantees-nothing-and-the-three-tiers). +S-PWM panels (ICN2053, FM6353, MBI5051 and the rest) stay dark at any +geometry. They need a grey-scale clock the driver cannot make (upstream issue +642, closed wontfix; `config.h:153-160`), and they are built for a receiving +card, which is an output of the player in [rfc-linux-player.md §2.3](rfc-linux-player.md#a-receiving-card). + +One chain, in a row, two rows lit at once +(`ESP32-HUB75-MatrixPanel-I2S-DMA.h:67, 750-751`): a module is 32 or 64 tall. +Stacked modules and 1/8 or 1/4 scan modules need the remap in +`ESP32-HUB75-VirtualMatrixPanel_T.hpp`, which nothing includes. + +Memory. The row buffers come from internal DMA RAM (`.h:195`), width × depth × +2 bytes a row (`.h:165-168`), one per row pair per buffer, double-buffered +(`core_display.h:22`): 2 × W × H × depth bytes. With the canvas's 3 × W × H, +worked as arithmetic at depth 8: + +| canvas | modules | row buffers | canvas | against 128×64 | +| --- | --- | --- | --- | --- | +| 64×32 | one 64×32 | 32,768 B | 6,144 B | −116,736 B | +| 64×64, 128×32 | one module, or two 64×32 | 65,536 B | 12,288 B | −77,824 B | +| 128×64, 256×32 | one 128×64, two 64×64, four 64×32 | 131,072 B | 24,576 B | 0 | +| 192×64 | three 64×64 | 196,608 B | 36,864 B | +77,824 B | +| 256×64 | two 128×64 | 262,144 B | 49,152 B | +155,648 B | +| 256×64 at depth 6 | two 128×64 | 196,608 B | 49,152 B | +90,112 B | + +The budget on record is 98,708 bytes of internal heap free after the network +services, measured on the stock panel on 2026-08-18 +([core 2 heap](investigations/2026-08-core2-heap-and-the-5s-stall.md)). Module +code moved to PSRAM after that (`platformio.ini:15-17`), and no later figure is +on record. 192×64 would leave about 20,900 bytes. The core 3 build had 15,320, +and it was the build whose patterns would not install and whose console +stopped answering under a heavy pattern. 256×64 does not fit at depth 8. So the +bound is the stock's own memory, W × H at most 8,192 at depth 8, and anything +larger opens with a heap figure measured on a board. + +The blit and the refresh, also as arithmetic. The blit's time follows the pixel +count, 3,496 µs at 128×64 (`hub75/VENDORED.md:164`), so inside the bound it is +no longer than the stock's. Its fast pass needs depth 8 and an even width +(`ESP32-HUB75-MatrixPanel-I2S-DMA.cpp:979`), and every module width is even. +The scan must stay ahead of the blit, and the line between them is buffers per +row × words per row / pixel clock (`VENDORED.md:172`). Scan and blit both grow +with the row's width, so 109 µs against 96 per row pair at 128 wide keeps its +ratio at 64 or 256. At 12 buffers and 16 MHz the refresh is 16,000,000 / +(12 × W × H / 2): 325 Hz at 8,192 pixels, padded to 300 like the stock's. + +The menus. They draw portrait in a tile 64 wide (`core_ui_text.h:16, 69-76`), +and the info screens type their rows at y 26, 42 and 52 and at h − 28, h − 23 +and h − 13 (`patternflow.ino:924-945`). On a panel 64 wide the portrait screen +is 64 tall and those rows cross. On a panel 32 tall the tile shrinks to 32. +Every geometry but the two 128×64 ones needs the menus laid out from the screen +size, one core PR. + +## 2.9 The order of the work + +| k | what | ground | after | +| --- | --- | --- | --- | +| 1 | the geometry, the driver, the fallback when `begin()` fails | core | the gate | +| 2 | the canvas as an allocation, thumbnails | core | 1 | +| 3 | the tables, the loader's host API, `/api/status` | core | 2 | +| 4 | compiled-in patterns through `setFrame` | core | 2 | +| 5 | the clock's `w` and `h` | feature | 1 | +| 6 | the menus from the screen size | core | 1 | +| 7 | `shared/constants.json`: module sizes and the bound | shared | step 3's k8, k9 | +| 8 | the console page and `/api/panel` | core | 3, 6, 7 | +| 9 | the button at power-on | core | 1 | +| 10 | `build_module.py` and `make_pack.py` take a size | toolchain | — | +| 11 | the sidecar's size: skip and reason | core | 10 | +| 12 | the Patterns page shows a module's size | core | 11 | +| 13, 14 | the cache key, the migration and the worker | site | 10 | +| 15 | downloads and the pack list take a size | site | 14 | +| 16 | `DeviceSim` takes a size | site | 7 | +| 17, 18 | the virtual panel at a size, its check and vector | site | 16, step 4's k8 | +| 19 | `rest-api.md`, `panel-compatibility.md`, the comments in `config.h` | docs | 8 | + +Nineteen PRs, eighteen with the cheaper key. Nine are core, none touches the +frozen ABI, and each core PR runs `build.sh all` and shows the default's frames +unchanged. `builder_rev` hashes `core_module_loader.h` whole +(`builderRev.ts:44`), so k3, k10 and k11 each re-bake every cached module once. + +## 2.10 Open questions for the maintainer + +1. The bound: W × H up to 8,192 at depth 8, the stock's own memory, or 192×64 + measured on a board first? Default: the bound, and a larger size opens with + that measurement. +2. The way back without a network: K4's button held through power-on? + Default: yes. +3. The cache key: the column §2.7 asks for, with its migration, or the size + folded into the `builder_rev` the web derives? Default: the column. +4. Basics at another size: built by the worker when asked, or a zip per size + beside `basics.zip`? Default: the worker. Only 128×64 stays committed. +5. Origin on another panel: through `setFrame` at 128×64, or reading the + geometry? Default: `setFrame`, and Origin moves in a preset PR of its own. +6. The menus: laid out from the screen size before the page offers anything + but 128×64, or offered as they are? Default: the menus first. diff --git a/docs/rfc-cleanup-progress.md b/docs/rfc-cleanup-progress.md new file mode 100644 index 000000000..3cfb00d15 --- /dev/null +++ b/docs/rfc-cleanup-progress.md @@ -0,0 +1,294 @@ +# Cleanup — progress + +Running log of the cleanup proposed in [rfc-cleanup.md](rfc-cleanup.md), +against the step order in §2.12. Updated as PRs land. Entries are added by +whoever merges; the table is the summary, the sections below are the +record. + +**Where it stands: steps 0 to 5 done, and the gate holds.** Baseline SHA `8226f7e`. + +| step | what | state | +| --- | --- | --- | +| 0 | Baseline: CI record, doc map, cleanup-window rule in `AGENTS.md` | done ([#11](https://github.com/Patternflow-lab/Patternflow/pull/11)) | +| 1 | Document structure: root, legacy guide, CHANGELOG split, ko/en parity list | done ([#23](https://github.com/Patternflow-lab/Patternflow/pull/23); plan: [three lanes](investigations/2026-10-cleanup-step1-plan.md)) | +| 2 | Drift checks: links, BOM triad, features, editions, presets, roadmap, changelog; the human-text lock and the voice lint | done ([#37](https://github.com/Patternflow-lab/Patternflow/pull/37); plan: [fourteen PRs](investigations/2026-10-cleanup-step2-plan.md)) | +| 3 | Code hygiene: lint/typecheck zero, legacy named or gone, HUB75 upstream patch, shared constants | done ([#68](https://github.com/Patternflow-lab/Patternflow/pull/68); plan: [twenty-seven PRs](investigations/2026-10-cleanup-step3-plan.md)); k12 has eight upstream commits to go | +| 4 | Test spine: contract vectors ×5, virtual panel | done ([#92](https://github.com/Patternflow-lab/Patternflow/pull/92); plan: [twenty-four PRs](investigations/2026-10-cleanup-step4-plan.md)) | +| 5 | RFC drafts: any-resolution, Linux player | done ([#55](https://github.com/Patternflow-lab/Patternflow/pull/55); plan: [four PRs](investigations/2026-10-cleanup-step5-plan.md)) | + +Gate for any-resolution (§2.12): met at `9dff573` on 16 October 2026. Each +item is checked under [The gate](#the-gate). + +--- + +## Step 0 — baseline + +[#11](https://github.com/Patternflow-lab/Patternflow/pull/11), 14 October +2026. The cleanup starts from `8226f7ea5da15b0e0e55b15fa9ec7af136eef080` on +`dev`. The record is +[investigations/2026-10-cleanup-baseline.md](investigations/2026-10-cleanup-baseline.md). + +At that SHA `check:ci` passes its 25 suites, `check:panels` its 478 tests, +and `typecheck` is clean. `lint` has no errors and 12 warnings, listed in the +record for Step 3. `build.sh all` ran on GitHub Actions, because the container +cannot reach the PlatformIO registry, and all five compositions built. + +`npm run doc-map` in `web/` writes +[investigations/2026-10-doc-map.csv](investigations/2026-10-doc-map.csv). +1007 links out of 193 files, every one resolving, so the known-broken list is +empty. `AGENTS.md` carries the cleanup-window rule as hard rule 8. + +## Step 1 — document structure + +Seven PRs, 14 and 15 October 2026, in the three lanes of +[investigations/2026-10-cleanup-step1-plan.md](investigations/2026-10-cleanup-step1-plan.md): +A1 [#13](https://github.com/Patternflow-lab/Patternflow/pull/13), B1 +[#14](https://github.com/Patternflow-lab/Patternflow/pull/14), C1 +[#15](https://github.com/Patternflow-lab/Patternflow/pull/15), A2 +[#16](https://github.com/Patternflow-lab/Patternflow/pull/16), C2 +[#17](https://github.com/Patternflow-lab/Patternflow/pull/17), A3 +[#22](https://github.com/Patternflow-lab/Patternflow/pull/22), and C3 +[#23](https://github.com/Patternflow-lab/Patternflow/pull/23) to close it. +`docs/voice.md` and `docs/voice-banned.txt` came in beside them +([#18](https://github.com/Patternflow-lab/Patternflow/pull/18)), the step's +first prose PR, as answer 6 asks. + +The root holds the twelve files of answer 1 and the dotfiles. +`BUILD_GUIDE_v2.md` is `docs/legacy/BUILD_GUIDE_v2.md`, with no redirect: the +site's one link to it goes to github.com. `AUDIO_GUIDE.md` is +`docs/AUDIO_GUIDE.md`. `CODE_OF_CONDUCT.md`, `SECURITY.md` and `SUPPORT.md` +went to `.github/` as answer 1 says, where the plan's D5 had put them in +`docs/`. The README's story sections stay where they are (answer 2), so A3 +merged as an empty commit. + +`CHANGELOG.md` keeps the three newest releases in full and links the rest. +Each of the 30 releases back to v1.0.0 has its own file in `docs/releases/`, +and the two long-form notes already there became `v3.0.0-notes.md` and +`v3.1.0-notes.md` (D1). `release.py cut` writes the next file. It also stops +when the section leaving the root differs from its file, a check the plan did +not ask for. + +`npm run doc-map` also writes +[investigations/2026-10-doc-parity.csv](investigations/2026-10-doc-parity.csv). +None of the three pair kinds §2.3 names exists, so the list reads the pairs +the repository does keep: journal posts, the guide's routes and copy, and the +roadmap's and the atlas's fields. The map reads the YAML under `.github/` as +well (C2). Questions 7 to 11 have no answer, and each lane took the plan's +default. + +At [#23](https://github.com/Patternflow-lab/Patternflow/pull/23) the root is +as answer 1 says and `docs-links.yml` is green. The map has 1066 links out of +212 files, every one resolving. The parity list has 1366 pairs: 1340 ok, 18 +ko-short, 7 en-short and one missing-ko. No translation was done; the list is +the deliverable. + +## Step 2 — drift checks + +Thirteen PRs, 15 October 2026: +[#25](https://github.com/Patternflow-lab/Patternflow/pull/25) to +[#36](https://github.com/Patternflow-lab/Patternflow/pull/36) as cut by +[investigations/2026-10-cleanup-step2-plan.md](investigations/2026-10-cleanup-step2-plan.md), +and [#37](https://github.com/Patternflow-lab/Patternflow/pull/37) to close it. +k14 was not opened. It waited on drift in the roadmap check's first CI run, +and [that run](https://github.com/Patternflow-lab/Patternflow/actions/runs/38665590179) +found none. + +`npm run check:drift` in `web/` runs each file in `web/scripts/drift/`. At the +close there were eight: the seven of §2.4 and `check:changelog`, which step 1 +sent here. `check:ci` ends with it. `.github/workflows/drift.yml` runs it on +every pull request with `PF_DRIFT_STRICT=1`, so a side a check cannot read +fails there instead of skipping, and runs `check:human-lock` against the base +commit (§2.9). `check:voice` (§2.10) is run by hand, by question 14's +default. `AGENTS.md` ends with the §2.10 rule and has a line for each command +([#36](https://github.com/Patternflow-lab/Patternflow/pull/36)). + +`check:bom-schematic` finds the BOM's nine designators in the schematic's text +layer, so it was strict from its first day and the warn-only clause of §2.4 +went unused. `check:roadmap` reads the repository from `roadmap-data.ts`, +`engmung/Patternflow`, and its list of issues open on purpose is empty +(question 12). The lock reads Part 1 of every `docs/rfc-*.md` rather than a +fixed list, so step 5's drafts were locked the day they landed. Two handoffs +from the step never landed and are still open at `9dff573`. A +`links-absolute` skip exits 0, so at a desk the runner prints it as passed +(`web/scripts/doc-map.ts` line 260); in CI strict mode fails it, as it should. +And `web/README.md` has no line for `check:voice`. + +At [#37](https://github.com/Patternflow-lab/Patternflow/pull/37) all eight +pass strict in CI ([run 38671531965](https://github.com/Patternflow-lab/Patternflow/actions/runs/38671531965)). +`check:roadmap` read 14 issues, every one closed and every one on a `done` +node. The lock holds 74 journal files, 33 release files, the manifesto, the +README's three story sections, Part 1 of two RFCs and 166 roadmap strings. +`docs/.human-written` lists no path (answer 5). The doc map has 1072 links out +of 214 files, every one resolving, and the parity list is unchanged. + +## Step 3 — code hygiene + +Twenty-seven PRs, 15 October 2026, as cut by +[investigations/2026-10-cleanup-step3-plan.md](investigations/2026-10-cleanup-step3-plan.md): +k1 to k26 between [#38](https://github.com/Patternflow-lab/Patternflow/pull/38) +and [#67](https://github.com/Patternflow-lab/Patternflow/pull/67), and +[#68](https://github.com/Patternflow-lab/Patternflow/pull/68) to close it. +#40, #50, #51 and #55 in that range are step 5's. + +`npm run lint` went from the baseline's 12 warnings to none. Five were fixed +in the code. The seven `` tags each carry a one-line exception with its +reason, and `web-ci.yml` runs lint with `--max-warnings=0`. `typecheck` was +clean and stayed clean. Every legacy path the plan found now says what wrote +it or what links to it, with the date and the commit. Code with no reader +went, `saveDraft` among it. The rest stays until questions 18 and 19 have an +answer. + +`shared/constants.json` holds the values §2.5 named, the 24 detents a turn +among them. `npm run gen:shared` writes `core_shared.h` and `constants.ts` +from it, and the firmware and the site read those. `check:shared` runs in +`check:ci` and in `drift.yml`. The MIDI page counted 20 detents a turn and +counts 24 now ([#46](https://github.com/Patternflow-lab/Patternflow/pull/46)), +a fix reviewed as one. The `markers` check holds first-party code at no +`TODO` or `FIXME`. The five markers §2.5 counts are not in this repository +(question 16). Each web dependency family moved to its newest release inside +its major on the day its PR opened. Next went from 16.3.0 to 16.4.1, which +closed nine security advisories, and React from 19.2.4 to 19.3.1. Majors wait +(answer 4). + +`src/hub75/UPSTREAM.md` names upstream's tag 3.0.13 and its commit `a622186`, +and `patternflow.patch` holds every line Patternflow changed, in 15 hunks +([#47](https://github.com/Patternflow-lab/Patternflow/pull/47)). k12 is where +the step parts from its plan. The plan gives each upstream commit after the +tag that touches no hunk a PR of its own, with a flash-and-look on the +reference panel that the maintainer notes in the PR (§2.5). #47 found nine +such commits. One merged: +[#48](https://github.com/Patternflow-lab/Patternflow/pull/48) takes `7bf3b6c`, +which sets `line_decoder` in the config constructor. At 3.0.13 nothing sets +that field, and `c09362a` reads it to address every row, so `7bf3b6c` went +first, ahead of older commits. Its flash-and-look is not noted on #48 yet. +The other eight have no PR: `c09362a`, `538cb41`, `3ab92ad`, `b40a838`, +`203502b`, `a15a887`, `6a49f09` and `c005c7b`, in that order. Each needs the +maintainer at the panel. §2.5's "done when" asks for `UPSTREAM.md` and the +patch, not for the merges, so #68 closed the step with k12 open, and the gate +does not wait on it. + +At [#68](https://github.com/Patternflow-lab/Patternflow/pull/68) `check:ci` +passes its 26 suites and `check:panels` its 478 tests. Lint and typecheck are +clean. `drift.yml` passes ten checks strict +([run 38734640534](https://github.com/Patternflow-lab/Patternflow/actions/runs/38734640534)). +The doc map has 1095 links out of 217 files, every one resolving. + +## Step 4 — the test spine + +Twenty-four PRs, 15 and 16 October 2026: +[#69](https://github.com/Patternflow-lab/Patternflow/pull/69) to +[#91](https://github.com/Patternflow-lab/Patternflow/pull/91) as cut by +[investigations/2026-10-cleanup-step4-plan.md](investigations/2026-10-cleanup-step4-plan.md), +and [#92](https://github.com/Patternflow-lab/Patternflow/pull/92) to close it. +Every row of the plan has its PR, and each stayed inside its files. k12 +([#82](https://github.com/Patternflow-lab/Patternflow/pull/82)) went past its +row: besides the channels, the snapshot and `/api/mqtt`, its 13 vectors cover +the prefix that names a channel and the Publisher's eight-second heartbeat. + +`tools/virtual-panel/` is a Patternflow with no board. It is the guide's +`DeviceSim` behind the ports the firmware listens on, and patterns run on +`PatternRuntime`, the one the site runs them on. `web/src/lib/harness/`, the +folder §2.6 names, does not exist, and `PatternRuntime` stands in for it. +Eight files in `transports/` speak the five wire contracts, each the subset +its spec names, on Node's own sockets. The lockfile did not move. +`GET /frame.png` is the frame as the LEDs would show it, and `/vp/` is a hand +and the clock. The firmware has neither. + +The five vector folders hold 111 vectors: 53 for REST, 15 for MQTT, 11 for +OSC, 20 for MIDI and 12 for the audio socket. Each is a JSON file that quotes +one sentence of its spec, in the format [contract-vectors.md](contract-vectors.md) +gives. A suite starts a fresh panel for each vector on a clock that stands +still between steps, so a 500 ms release costs no real time. +`check:virtual-panel` runs the 34 presets that have a JS twin for two seconds +of panel time each. `calib` is firmware-only and is left out. The six suites +are files in `web/scripts/drift/`, so `check:ci` and `drift.yml` run them. + +The vectors follow the spec. Where the spec said two things, +[#91](https://github.com/Patternflow-lab/Patternflow/pull/91) made it say one, +in ten lines of `rest-api.md` and `midi-spec.md`, by question 27's default: +the route's own section wins. Nobody has ruled on those lines yet. Where the +firmware disagrees with its spec, the vector keeps the spec and a real panel +would fail it. #92 counts 18 such places, 11 of them in vectors. Each PR lists +its own under "for an issue" with the spec line and the firmware line, and +question 26 leaves opening the issues to the maintainer. Nothing under +`firmware/` changed in this step. Questions 26 to 29 have no answer, and every +PR took the default. Web CI starts only on `web/`, so a PR that edits only +`tools/virtual-panel/` is typechecked at a desk and nowhere else. + +At [#92](https://github.com/Patternflow-lab/Patternflow/pull/92) `check:ci` +passes its 26 suites and `check:panels` its 487 tests. Lint and typecheck are +clean. `drift.yml` passes 16 checks strict +([run 38814004535](https://github.com/Patternflow-lab/Patternflow/actions/runs/38814004535)). +The doc map has 1138 links out of 224 files, every one resolving. + +## Step 5 — RFC drafts + +Four PRs, 15 October 2026, as cut by +[investigations/2026-10-cleanup-step5-plan.md](investigations/2026-10-cleanup-step5-plan.md): +[#40](https://github.com/Patternflow-lab/Patternflow/pull/40), +[#50](https://github.com/Patternflow-lab/Patternflow/pull/50), +[#51](https://github.com/Patternflow-lab/Patternflow/pull/51), and +[#55](https://github.com/Patternflow-lab/Patternflow/pull/55) to close it. The +step ran beside steps 3 and 4, as §2.12 allows once step 2 is merged. + +[rfc-any-resolution.md](rfc-any-resolution.md) and +[rfc-linux-player.md](rfc-linux-player.md) are Part 2 only, 298 lines each, +with facts from `dev` at `adbe923`. Part 1 is an empty heading in both, left +for the maintainer. Each sets out the order of the work as a table of PRs, +then ends with its open questions: six in the any-resolution draft, nine in +the player's. Both have a line in `docs/README.md`, and each links the other. +[investigations/2026-10-panel-geometry.md](investigations/2026-10-panel-geometry.md) +is the record of every place the panel size is set or assumed, by file and +line, and the any-resolution draft carries a table per file from it +(question 31). + +`check:human-lock` reads Part 1 of every `docs/rfc-*.md`, so both drafts were +locked from the start, and the plan's finding 11 needed no fix. Questions 30 +to 34 have no answer. At [#55](https://github.com/Patternflow-lab/Patternflow/pull/55) +the doc map has 1092 links out of 217 files, every one resolving. + +## The gate + +Checked on 16 October 2026 at `9dff573`, the merge of +[#92](https://github.com/Patternflow-lab/Patternflow/pull/92). The web +commands ran in a cloud container on that tree, Node 22.22.0 and npm 10.9.4 +after `npm ci`, as at the baseline. CI ran on `2892326`, the first commit of +the PR that adds this entry. It is `9dff573` plus an empty commit, with the +same tree. + +1. Every command in §2.1(1) passes. `npm run check:ci` passes its 26 suites in + 82 s. Its drift step passes 15 checks and skips `roadmap`, which needs a + `gh` login this container does not have; CI runs it in item 2. + `check:panels` passes 35 test files and 487 tests. `typecheck` prints + nothing, and so does `lint`, with `--max-warnings=0` as Web CI runs it. + `firmware/bundles/build.sh all` ran on GitHub Actions, as at the baseline: + [Firmware builds run 38815552568](https://github.com/Patternflow-lab/Patternflow/actions/runs/38815552568), + dispatched on `2892326`, built default, audio, performance, clock and midi, + scanned each image for its features, and found every edition within + tolerance. The last commit to touch `firmware/` is + [#59](https://github.com/Patternflow-lab/Patternflow/pull/59). +2. Every script in §2.4, §2.5 (`check:shared`), §2.6 and §2.9 runs in CI and + passes. + [Drift checks run 38815274649](https://github.com/Patternflow-lab/Patternflow/actions/runs/38815274649) + on `2892326` passes 16 checks with `PF_DRIFT_STRICT=1`: the seven of §2.4 + (`roadmap` reading `engmung/Patternflow`), `shared`, the five vector suites + and `virtual-panel`, and `changelog` and `markers` besides. Its next step, + `check:human-lock` against `9dff573`, says no locked words changed. +3. The virtual panel speaks REST (`rest-state.ts`, `rest-patterns.ts`) and + MQTT (`mqtt.ts`, `mqtt-http.ts`), and OSC, MIDI and the audio socket + besides. In the same run `check:virtual-panel` finds the 34 presets with a + JS twin on the panel's list and runs each for 2000 ms at 128×64 with no + error. `calib`, the 35th header in `firmware/patternflow/presets/`, is + firmware-only by `check_presets.py`. +4. The doc map has no unresolved row. `npm run doc-map` reads 1138 links out + of 224 files at `9dff573`, and 0 do not resolve. The map committed with + this entry and the new status line in `rfc-cleanup.md` reads 1144 out of + 225, and 0 do not resolve. +5. The root holds `AGENTS.md`, `BUILD_GUIDE.md`, `CHANGELOG.md`, + `CITATION.cff`, `CLAUDE.md`, `CONTRIBUTING.md`, `FEATURE_GUIDE.md`, + `LICENSE`, `LICENSE-CC-BY-SA`, `LICENSE-MIT`, `PATTERN_GUIDE.md`, + `README.md` and the dotfiles, the list in answer 1. +6. `docs/rfc-any-resolution.md` and `docs/rfc-linux-player.md` exist (step 5). + +All six hold. §2.12 lets `rfc-any-resolution.md` move from draft to proposed +now. The move is the maintainer's, and its status line still says "Draft". diff --git a/docs/rfc-cleanup.md b/docs/rfc-cleanup.md new file mode 100644 index 000000000..e787caebd --- /dev/null +++ b/docs/rfc-cleanup.md @@ -0,0 +1,387 @@ +# RFC: cleanup before expansion + +*Tidying the tree before any-resolution work starts. Written 2026-10-14.* + +> **Status, 16 October 2026.** Done. Nothing in this RFC changes what +> the firmware or the site does. It changes where things live, what CI +> checks, and what an agent is allowed to write. The gate at the end (§2.12) +> held at `9dff573`, and [rfc-cleanup-progress.md](rfc-cleanup-progress.md) +> records each step and the check of each item. + +This document is two things on purpose. **Part 1 is the maintainer, in his +own words.** **Part 2 is the specification** — precise and mechanical, +written so a contributor (or an AI agent handed this file) can implement +against it without asking what was meant. + +--- + +# Part 1 — Why + +패턴플로우를 확장하기 위한 밑작업과 다이어트 + +--- + +# Part 2 — The specification + +## 2.1 Rules that hold for the whole cleanup + +1. **No behaviour change.** A cleanup PR may move, rename, split, delete + dead code, add a check, or add a test. It may not change what a build + outputs. Every PR that touches `firmware/` or `web/src/` states in its + description which of the following it ran, and all must pass: + `firmware/bundles/build.sh all`, `npm run check:ci`, `npm run + check:panels`, `npm run typecheck`, `npm run lint`. +2. **Output equality is proven, not asserted.** For firmware, the + `.pfm` module cache keys (header SHA-256 + toolchain revision) must not + change unless the PR is explicitly about the cache. For the site, the + sandbox bundle (`npm run check:sandbox-sync`) and the console pages + (`console-sync.yml`) must stay byte-identical unless the PR is about + them. +3. **Human-written text is locked.** §2.9 lists the files. Agents may fix + markup, layout, CSS and link paths in them. Agents may not change a + word. +4. **Everything an agent writes reads like the maintainer wrote it.** §2.10. +5. **PR size.** Under 300 changed lines. One concern per PR. A PR that + needs more than that is two PRs. +6. **Hard rules in `AGENTS.md` still apply**: founders boards stay private, + the BOM triad stays in sync, the licence split is not touched, naming + is not touched, the power-input description is not touched, nothing + feature-shaped goes into `src/`. + +## 2.2 Step 0 — baseline + +Deliverables: + +- `docs/investigations/2026-10-cleanup-baseline.md`: the commit SHA the + cleanup starts from, and the output of every command in §2.1(1) at that + SHA. If any of them is red before the cleanup starts, that is the first + PR, and it is not a cleanup PR — it is a fix, reviewed as one. +- `scripts/doc-map.ts` (web workspace, reads the whole repo): for every + `.md` and every `.tsx`/`.ts`/`.h` that contains a Markdown or HTTP link + into the repo, emits `docs/investigations/2026-10-doc-map.csv` with + columns `from, to, kind (relative|absolute-blob|absolute-raw|site|console), + anchor, resolves (yes|no)`. The console pages in + `firmware/patternflow/console/` and the site's redirect table are + included as link sources, because those are the links a file move + breaks silently. +- A one-line addition to `AGENTS.md` under its hard rules: *"During a + cleanup window (see docs/rfc-cleanup.md) no PR changes behaviour; moves + and renames carry a redirect or an updated link map."* + +Done when: the baseline file exists, the map CSV has zero rows with +`resolves = no` that are not already listed as known-broken in the +baseline file, and CI is green. + +## 2.3 Step 1 — document structure + +**Root.** The guides that stay at the repository root are decided by the +maintainer before this step starts (§2.13, question 1). The default +proposal: `README.md`, `BUILD_GUIDE.md`, `PATTERN_GUIDE.md`, +`FEATURE_GUIDE.md`, `CONTRIBUTING.md`, `CHANGELOG.md`, `AGENTS.md`, +`LICENSE*`. Everything else at root is a candidate to move under `docs/`. + +**README.** Keep the sections a first-time visitor needs to make, flash, +play, contribute and understand the licence. The narrative sections +("Where it began", "The community responded", "Story so far") are the +maintainer's writing. They are not rewritten. If the maintainer decides +they leave the README, they are moved **verbatim** to `docs/story.md` +with a one-line pointer left in the README. If he decides they stay, they +stay as they are. Agents do not decide this. + +**Legacy build guide.** `BUILD_GUIDE_v2.md` moves to +`docs/legacy/BUILD_GUIDE_v2.md`. Before the move, the doc map (§2.2) +lists every link into it, including the site's anchored links to §1, §2, +§4, §8 and §10. The PR that moves the file updates every one of those +links in the same PR, and adds the old path to `web/next.config` redirects +if any external link is known to point at it. + +**CHANGELOG.** Split by version into `docs/releases/vX.Y.Z.md`, one file +per release, text copied as written. The root `CHANGELOG.md` keeps the +three most recent releases in full and links the rest. `docs/RELEASING.md` is +updated so a release adds a file rather than prepending to the root. + +**Korean/English parity.** `scripts/doc-map.ts` gains a second output, +`docs/investigations/2026-10-doc-parity.csv`: every bilingual pair the +repo maintains (`*.md` / `*.ko.md`, console page pairs, `features-data` +`detail`/`detailKo`), and which side is missing or shorter by more than a +third. No translation is done in this step; the list is the deliverable. + +Done when: root contains only the agreed files, every moved file has +every inbound link updated in the same PR, `docs-links.yml` is green, and +the parity CSV exists. + +## 2.4 Step 2 — drift checks + +Each check is a script under `web/scripts/`, wired into `npm run check:ci`, +and fails CI on drift. Each prints the two sides it compared and the diff, +so a failure is readable without running anything locally. + +| script | left side | right side | what counts as drift | +| --- | --- | --- | --- | +| `check:links-absolute` | every `github.com/engmung/Patternflow/blob/main/...` and `raw.githubusercontent.com/...` URL in the repo | the tree at HEAD | path or anchor that does not exist | +| `check:bom` | `hardware/bom/bom_v3.9.csv` | the parts table in `BUILD_GUIDE.md` | a designator, part or quantity present on one side only | +| `check:bom-schematic` | `hardware/bom/bom_v3.9.csv` | the schematic PDF's text layer (`hardware/pcb/*.pdf`) | a designator in the BOM that the PDF does not mention, or the reverse | +| `check:features` | `web/src/app/features/features-data.ts` ids | directories under `firmware/patternflow/features/` | an id without a directory, or a directory without an id | +| `check:editions` | `web/src/app/editions/editions-data.ts` | `firmware/bundles/*/` and `shelf.sh` output | an edition without a bundle, a bundle without a shelf entry | +| `check:presets` | the preset registry the site renders | `firmware/patternflow/presets/` after the feature cut list | a preset the site lists that no edition can compile, or the reverse | +| `check:roadmap` | `web/src/app/roadmap/roadmap-data.ts` items with an issue link | GitHub issue state via `gh api` (skipped offline) | `done` with an open issue, `planned` with a closed one | + +`check:bom-schematic` may land as warn-only for one release if the PDF +text layer turns out not to carry designators; the PR says so and opens +an issue. + +Done when: all seven scripts exist, run in CI, and pass at HEAD. A check +that fails at HEAD is not disabled; the drift it found is fixed first, in +its own PR. + +## 2.5 Step 3 — code hygiene + +**Web.** +- `npm run lint` and `npm run typecheck` at zero warnings. Rules are not + relaxed to get there. +- Legacy paths and keys (`legacyDraft` keys in Pattern Lab, the `/variants` + redirect, any route kept "for old links") either go, or get a comment + that names the inbound link they exist for. A comment that says "legacy" + without saying what points at it is not enough. +- Dependency updates: minor and patch versions in one PR per package + family, `check:ci` green. Major versions (Next, React, Drizzle) are a + separate PR each, and the maintainer approves before it is opened. + +**Firmware.** +- `firmware/patternflow/src/hub75/` is a vendored fork of + ESP32-HUB75-MatrixPanel-DMA. Produce `src/hub75/UPSTREAM.md` with the + upstream commit it was taken from and `src/hub75/patternflow.patch` + with the exact local changes (`blitRGB888` and whatever else). Then + merge upstream fixes that do not touch the patched regions, one upstream + commit per PR, `build.sh all` green and a flash-and-look on the + reference panel noted in the PR by the maintainer. +- The five remaining `TODO`/`FIXME` markers become issues or get resolved. + No new ones are added during the cleanup. + +**Shared constants.** Values that both the firmware and the web harness +carry by hand — knob scale, detent count, encoder semantics, panel +geometry names — move to one source file, `shared/constants.json`, and +both sides generate from it (`firmware/patternflow/src/core_shared.h` and +`web/src/lib/shared/constants.ts`, both marked generated, both checked by +`check:shared` for staleness). The 24-vs-20 detent incident in the +firmware/web harness is the reason this step exists. + +Done when: lint and typecheck are clean, every legacy path has a named +reason or is gone, `UPSTREAM.md` and the patch file exist, and +`check:shared` is in CI. + +## 2.6 Step 4 — the test spine + +**Contract vectors.** `docs/pfst-v2-vectors/` is the model. Each of the +other contracts gets the same: a directory of inputs and expected outputs, +and a smoke script that replays them. + +| contract | vectors | script | +| --- | --- | --- | +| `docs/rest-api.md` | `docs/rest-api-vectors/` — request, expected status, expected body shape | `check:rest-vectors` | +| `docs/osc-spec.md` | `docs/osc-vectors/` — OSC packet bytes, expected parameter effect | `check:osc-vectors` | +| `docs/midi-spec.md` | `docs/midi-vectors/` — MIDI bytes, expected knob/pattern effect | `check:midi-vectors` | +| `docs/mqtt-spec.md` | `docs/mqtt-vectors/` — topic + payload, expected state | `check:mqtt-vectors` | +| `docs/audio-ws-spec.md` | `docs/audio-ws-vectors/` — frames, expected feature values | `check:audio-vectors` | + +Vectors are derived from the spec text, not from current behaviour. Where +the firmware disagrees with its own spec, the vector encodes the spec and +the PR opens an issue; the firmware is not changed in this step. + +**Virtual panel.** `tools/virtual-panel/`: a Node process that implements +the device side of the five contracts above over the same ports and +paths the firmware uses, renders to an in-memory 128×64 canvas (and, when +the harness is parameterised in §2.7, to any size), and exposes +`GET /frame.png` for assertions. It uses the web harness +(`web/src/lib/harness/`) for pattern execution so the two never drift. It +is a test fixture, not a product: no flashing, no OTA, no hotspot. + +The maintainer decides which contract the virtual panel speaks first +(§2.13, question 3). The default order: REST, then MQTT, then OSC, MIDI, +audio. + +Done when: five vector suites run in CI against the virtual panel and pass +for the contracts it implements, and the virtual panel runs the full +preset list from `firmware/patternflow/presets/` without a thrown error. + +## 2.7 Step 5 — make room for expansion + +No implementation. Two RFC drafts, Part 2 only (Part 1 is the +maintainer's), each in the format of this file: + +- `docs/rfc-any-resolution.md`: the firmware path. Inventory of the 25 + `PANEL_RES_W/H` references in `src/`, the four in `features/`, the seven + in `abi/`; a proposed runtime geometry struct with compile-time + defaults; the module cache key extended with `W×H`; the console page + for choosing geometry without reflashing; the harness matrix + parameterised; what the virtual panel needs to render other sizes. + Includes the limits as limits: classic HUB75 drivers only, S-PWM panels + stay unsupported, the ESP32-S3 DMA framebuffer bound. +- `docs/rfc-linux-player.md`: the player path. The JS harness as the + engine; output adapters for `rpi-rgb-led-matrix`, a Colorlight-class + receiving card over Ethernet, and HDMI; input from USB encoders, MIDI, + OSC and the web knobs; what of the five contracts the player speaks; + what it cannot do (no `.pfm` modules, no browser flash). + +Done when: both files exist, each has every section a reader needs to +estimate the work, and each ends with an open-questions list for the +maintainer. + +## 2.8 Do not touch + +- `web/src/app/compliance/` and the compliance page text — it is printed + on the box. +- Preset file names under `firmware/patternflow/presets/`. +- `hardware/case/encloser.stl` and its name. +- Links baked into console pages, except through the doc map and a + same-PR update. +- `hardware/founders/` (private) and anything that would commit it. +- `LICENSE`, `LICENSE-SUMMARY.md`, and the hardware/firmware/pattern + licence split. +- The power-input description anywhere it appears. +- `.github/workflows/firmware-release.yml` and the release asset names. + +## 2.9 Human-written text lock + +These files and regions hold the maintainer's own writing. Agents may +change markup, layout, CSS, link paths and image paths in them. Agents +may not change, add, remove or reorder a sentence, and do not fix typos — +a typo is reported in the PR description or an issue. + +- `web/content/journal/` — every file, every language. +- `docs/manifesto.md`. +- `web/src/app/roadmap/roadmap-data.ts` — the `detail` and `detailKo` + strings. +- `README.md` — "Where it began", "The community responded", "Story so + far". +- Part 1 of every RFC under `docs/`. +- `docs/releases/*.md` once split — moved verbatim, then locked. +- Any file the maintainer adds to `docs/.human-written` (one path per + line; the lock script reads it). + +`scripts/human-lock.ts` (`check:human-lock`) fails a PR that changes +non-markup characters in any locked region. It compares the plain text of +each locked file or region before and after, with Markdown syntax and +whitespace stripped. + +Moving a locked file is allowed only with the maintainer's approval on the +PR, and only verbatim. + +## 2.10 Writing voice + +Append to `AGENTS.md`: + +> ## Writing voice (hard rule) +> +> Everything an agent writes that a person will read — docs, README +> sections, release notes, console page copy, PR descriptions, comments — +> must read as if the maintainer wrote it. Not "polished". Written. +> +> Voice reference: `docs/voice.md` describes the project voice. Read it before writing anything longer than a sentence. Match its +> register (Korean: 해요체 or 합니다체 as the file already uses; English: +> plain, first person where the project already uses it), its sentence +> length, and its habit of naming the specific thing — the part number, +> the detent count, the date — instead of the category. +> +> Do not: +> - Open with what the document is about ("This document describes…", +> "이 문서는 …을 설명합니다"). Start with the thing. +> - Close with a summary that repeats the body, or with "In conclusion" / +> "결론적으로". +> - Turn prose into bullet lists, or bold phrases for emphasis. Bullets are +> for lists of things. +> - Use these words or their Korean equivalents: seamless, robust, +> leverage, comprehensive, streamline, empower, cutting-edge, ensure, +> utilize, delve, "it's important to note", "here's", 효율적으로, +> 원활하게, 강력한, 최적화된, 혁신적인, 다양한, 전반적으로, 참고로, +> 살펴보겠습니다. +> - Write in threes ("fast, simple, and reliable"). One reason is a +> reason; three is a pattern. +> - Join clauses with em dashes (—). End the sentence and start another. +> - Hedge every claim. Say it, or leave it out. +> - Add emoji, decorative headers, "Note:" callouts, or a "Generated by" +> line. +> - Mention that an AI, an agent or a tool wrote or helped write the text. +> Ever. +> - Reformat an existing human-written paragraph into another structure. +> +> Do: +> - Prefer one specific example over a general statement. +> - Vary sentence length. A short one can stand alone. +> - Keep the project's own terms (core, feature, edition, bundle, preset, +> module) exactly as the existing docs use them. +> - For PR descriptions and commits: imperative first line, one line of +> why, nothing else. No "This PR introduces…". +> - When unsure of tone, write less. +> +> Any new or rewritten text over 20 lines goes through +> `scripts/voice-lint.ts` (banned phrases, triplets, opener and closer +> patterns) before the PR is opened, and a person reads it before merge. + +`docs/voice.md` is written by an agent at the maintainer's request (§2.13, +answer 6), and the maintainer reads it before merge. `scripts/voice-lint.ts` (`check:voice`) reads the banned list from +`docs/voice-banned.txt` so the maintainer can add to it without touching +the script. + +## 2.11 PR protocol + +- Branch names: `cleanup/-`. +- Labels: `cleanup`, plus `step-0` … `step-5`. +- Each PR description has three lines: what moved or changed, which §2.1 + commands ran, and which doc-map rows it touched (or "none"). +- Batching: at most five open cleanup PRs at a time. The maintainer + reviews in one sitting a week; agents do not open new PRs while five are + waiting. +- A PR that fails a check added by an earlier step is fixed, not + rebased around. +- Agents do not merge. Agents do not approve. + +## 2.12 Order, and what "done" means + +Steps run in order 0, 1, 2, 3, 4, with 5 allowed to start once step 2 is +merged. Inside a step, PRs are independent unless the step says otherwise. + +The cleanup is done — and `docs/rfc-any-resolution.md` may move from +draft to proposed — when all of these hold at HEAD: + +1. Every command in §2.1(1) passes. +2. Every script in §2.4, §2.5 (`check:shared`), §2.6 and §2.9 + (`check:human-lock`) runs in CI and passes. +3. The virtual panel speaks at least REST and MQTT and renders the full + preset list. +4. The doc map has zero unresolved rows. +5. Root contains only the files agreed under §2.3. +6. Both RFC drafts from §2.7 exist. + +## 2.13 Open questions for the maintainer + +1. Which files stay at the repository root (§2.3)? Default list given. +2. Do the README's narrative sections stay, or move verbatim to + `docs/story.md`? +3. Which contract does the virtual panel speak first (§2.6)? Default: + REST. +4. Which major dependency updates, if any, are in scope this window + (§2.5)? +5. Any file to add to `docs/.human-written` beyond the list in §2.9? +6. The three to five excerpts for `docs/voice.md`, and additions to + `docs/voice-banned.txt`. + +### Answers (maintainer, 2026-10-14) + +1. The root keeps `README.md`, `BUILD_GUIDE.md`, `PATTERN_GUIDE.md`, + `FEATURE_GUIDE.md`, `CONTRIBUTING.md`, `CHANGELOG.md`, `AGENTS.md`, + `CLAUDE.md`, `CITATION.cff`, `LICENSE`, `LICENSE-MIT`, `LICENSE-CC-BY-SA` + and the dotfiles. `CODE_OF_CONDUCT.md`, `SECURITY.md` and `SUPPORT.md` + move to `.github/`, where GitHub still finds them. `AUDIO_GUIDE.md` moves + to `docs/`. `BUILD_GUIDE_v2.md` moves to `docs/legacy/` as §2.3 says. A + first-time visitor should be able to read the root at a glance. +2. The narrative sections stay in the README as they are. +3. REST first. +4. None. Major updates wait until the cleanup is done. +5. Nothing beyond §2.9. +6. An agent writes `docs/voice.md`. It does not quote the journal. It + describes the voice the README and the build guide already use: short, + specific, finished, nothing that reads as generated. One page at most. + It is the first prose PR of step 1, and no other step 1 PR that writes + prose opens before it is merged. `docs/voice-banned.txt` starts with + the list in §2.10. diff --git a/docs/rfc-linux-player.md b/docs/rfc-linux-player.md new file mode 100644 index 000000000..d0e02a015 --- /dev/null +++ b/docs/rfc-linux-player.md @@ -0,0 +1,298 @@ +# RFC: the Linux player + +*Patterns on a Linux computer, with no ESP32-S3 in between. Written 2026-10-15.* + +> **Status, 15 October 2026.** Draft. Nothing in this RFC changes what the +> firmware or the site does. It sets out a program that does not exist yet, +> the player path of [rfc-cleanup.md](rfc-cleanup.md) §2.7. + +This document is two things on purpose. **Part 1 is the maintainer, in his +own words.** **Part 2 is the specification** — precise and mechanical, +written so a contributor (or an AI agent handed this file) can implement +against it without asking what was meant. + +--- + +# Part 1 — Why + +--- + +# Part 2 — The specification + +## 2.1 What it is + +The player is a Linux process that plays Patternflow patterns with the site's +engine and puts the frames on an LED panel or a screen. A Raspberry Pi with a +HUB75 HAT is the first machine it is written for. It is not firmware and not +an edition: an edition is a firmware build +([EDITIONS.md](EDITIONS.md#the-three-words)), and nothing here compiles for +the ESP32-S3. +The firmware path, any size on one ESP32-S3, is [rfc-any-resolution.md](rfc-any-resolution.md). + +Facts are from `dev` at `adbe923`. Where the player leans on work that is only +planned, the plan and its k are named and the paths are in backticks. "Size" is +the canvas, width × height. A path that starts `player/` is inside the player's +own folder, wherever that lives (§2.7). Each section ends with its rows in +§2.8, the table of files, lines and PRs. + +## 2.2 The engine + +`PatternRuntime` (`web/src/lib/pattern/harness.ts:463`) imports nothing and +touches no DOM. It compiles a pattern with `new Function` (`:443-454`), and +`draw` writes an RGBA buffer of width × height × 4 bytes, sized by the +constructor (`:486`, 128 and 64 by default). It already runs outside a +browser: `check:flatten` renders frames with it under Node 20 in CI +(`web/scripts/lab-flatten-smoke.ts:57-69`). + +Around it, `DeviceSim` (`web/src/lib/guide/deviceSim.ts:163`) plays the +firmware's rules for the guide's 3D board: the parameter bus, the lanes and +their holds, sleep, select, and the device's screens. The player is `DeviceSim` +with an output where the guide has a mesh, calling `tick(dt)` (`:412`) and +handing `frame` (`:165`) on. Step 4 builds its virtual panel the same way, so +that a rule `DeviceSim` has is not written again +([the step 4 plan](investigations/2026-10-cleanup-step4-plan.md), D1). + +The player starts with the 34 patterns a board lists after the Basics pack, +Origin and the 33 slugs of `web/public/packs/basics.json`'s `order`, each run +from its JS twin (`deviceSim.ts:92-104`). The other 14 JS presets and every +community pattern play as JS too. The community keeps a pattern as plain JS +(`web/src/lib/community/server/schema.ts:95`) with an optional C++ port +(`:102`), and the feed's cards carry the JS (`server/serialize.ts:10`), so the +player plays patterns no board runs until someone ports them. + +The size comes from the output (§2.3). At 128×64 nothing in `web/` changes. +At any other size `DeviceSim` is in the way, fixed through `PANEL_W` and +`PANEL_H` (`web/src/lib/guide/panelScreens.ts:42-43`), and so are the typed +sizes the [panel geometry record](investigations/2026-10-panel-geometry.md#the-site) +lists. Step 4 asks for the size in one place (its D1), the firmware path +proposes the change, and the player waits for it. 46 of the 48 JS presets take +the size only from what `draw` is given. + +A pattern can declare its frame with `// @matrix`, 8 to 512 on a side +(`web/src/lib/pattern/matrix.ts:46-47`). `DeviceSim` never reads it, so a +community pattern drawn for 64×128 runs there at 128×64. The device fits a +declared frame in `setFrame` (`firmware/patternflow/src/core_canvas.h:137-163`): +equal draws straight through, swapped rotates, anything else is letterboxed. +`matrixFit` (`matrix.ts:131`) names the same cases and nothing calls it. +`DeviceSim` learns the rule before the player plays community patterns. No JS +preset carries `// @matrix`, so the guide shows nothing new (rows 1, 8 and 9). + +## 2.3 The outputs + +An output is a file in `player/outputs/` with `open(options)`, which returns +its width and height, `write(rgba)` and `close()`. Until `DeviceSim` takes a +size, the player refuses an output that is not 128×64 and says what it got. + +### rpi-rgb-led-matrix + +[rpi-rgb-led-matrix](https://github.com/hzeller/rpi-rgb-led-matrix) drives +HUB75 panels from a Pi's GPIO header, bare or through a HAT. Its README covers +every Pi up to the Pi 4, and says of the Pi 5 both that support has been added +and that it is not official as of November 2025. A program that uses it starts +as root, and the library drops privileges once the hardware is set up. It +exits while the on-board sound module, `snd_bcm2835`, is loaded, so the +install turns sound off. + +The library is GPL-2.0 or later. An addon would link it and bring the GPL into +a tree whose code is MIT (`LICENSE:4`, hard rule 3), and the Node.js bindings +its README lists are addons. The proposal is a second program on a pipe, about +100 lines of C++ kept outside this tree under the GPL (question 2), that reads +raw RGB frames on stdin and shows each one. A 128×64 frame is 24,576 bytes, so +60 frames a second is 1,474,560 bytes a second (arithmetic). The player starts +it with the library's own `--led-rows`, `--led-cols`, `--led-chain` and +`--led-parallel` and takes the size from them. The child starts as root and +drops. The player never runs as root (row 5). + +### A receiving card + +The S-PWM panels Patternflow cannot light (ICN2053, FM6353, MBI5051 and the +rest, `firmware/patternflow/config.h:153-160`) are sold for a receiving card, +and [panel-compatibility.md](panel-compatibility.md) tells a buyer to walk away +from them. A receiving card turns that around: it scans the panel the way an +S-PWM panel expects and takes its pixels over Ethernet. This is the output for +those panels, and for signs larger than one ESP32-S3 drives. + +The reference card is the Colorlight 5A-75B (question 6). Colorlight does not +publish its protocol. People have worked it out from packet captures, and +[haraldkubota/colorlight](https://github.com/haraldkubota/colorlight) (MIT) +sends a 5A-75B plain Ethernet frames, no IP, out of a named interface. Linux +sends such a frame from an `AF_PACKET` socket, which needs `CAP_NET_RAW`. +Node's `dgram` and `net` stop at UDP and TCP, so the output needs an addon or +a helper program given the capability with `setcap`. A protocol learned from +captures can change with a card's firmware (row 11). + +### HDMI + +The screen output writes the Linux framebuffer, `/dev/fb0`, with plain file +writes on a console with no desktop running. Its size and depth are in +`/sys/class/graphics/fb0/virtual_size` and `bits_per_pixel`. The output +reports the size it is given, 128×64 by default, and scales it by a whole +number, nearest pixel, centred: 128×64 on 1920×1080 is ×15, 1920×960 with 60 +rows of black above and below. A browser in kiosk mode would be the site, and +a page cannot open the UDP ports §2.4 needs (row 2). + +## 2.4 The inputs + +Every input ends in a call the guide already makes, so a knob on the player +behaves as it does on a board: a hand is `turn` (`deviceSim.ts:359`), a button +`press` and `release` (`:372`, `:381`), a remote value `applyRemoteParam` +(`:501`). + +USB encoders. The player reads a USB knob box's `/dev/input/by-id/*-event-*` +node as a file: 24-byte events on 64-bit Raspberry Pi OS, a rotation as a +relative step (`EV_REL`) or, on some single knobs, a key per step. +`player/knobs.json` maps a device's axis or key to K1 to K4 and their buttons, +with steps per detent. A board's `PEC11R-4220F-S0024` has 24 detents a turn, +`DETENTS_PER_TURN` in `shared/constants.json`. The player reads the generated +`web/src/lib/shared/constants.ts`, which the site does not import yet +(`harness.ts:11` still types 24). `DeviceSim` takes a direction and an edge +count per knob (`setKnobSettings`, `:522`), as `/api/knobs` does on a board. + +MIDI. A USB controller appears as an ALSA raw device, +`/dev/snd/midiCD`, and reads as a byte stream with no addon. +RTP-MIDI is the player listening on UDP 5004 and 5005 as the panel does +([midi-spec.md](midi-spec.md) line 28), not `rtpmidid`, which hands a session +to the ALSA sequencer, and the sequencer needs an addon. Both go through the +panel's map (`midi-spec.md:35-45`): CC 20 to 23 absolute, CC 24 to 27 +relative, notes 60 to 63 the buttons, Program Change the pattern. Step 4's k15 +writes the receive side (`tools/virtual-panel/transports/midi.ts`). + +OSC. UDP 9001 in, replies to 9000 ([osc-spec.md](osc-spec.md) lines 13-14): +`/patternflow/ping`, `/patternflow/knob/N/delta` and +`/patternflow/pattern/index` (lines 41-48). Step 4's k13 writes it +(`tools/virtual-panel/transports/osc.ts`). + +The web knobs. The console's four sliders, 0 to 100 +(`firmware/patternflow/console/home.html:112-115`), post `pN` as a form body +to `POST /api/params`, one request in flight at a time (`:383-390`, +[rest-api.md](rest-api.md) line 160). The player answers that route and +serves one page with the same four sliders. It does not serve `home.html` +whole: most of what that page reads, Wi-Fi and storage among it, has nothing +behind it on a player (rows 3, 4, 6 and 7). + +## 2.5 The contracts + +The panel speaks five: [rest-api.md](rest-api.md), [osc-spec.md](osc-spec.md), +[midi-spec.md](midi-spec.md), [mqtt-spec.md](mqtt-spec.md) and +[audio-ws-spec.md](audio-ws-spec.md). The player speaks them in this order. + +1. REST first, as for the virtual panel (rfc-cleanup.md §2.13, answer 3). It + carries the web knobs, and a client finds a player with `GET /api/status` + the way it finds a panel. +2. OSC, then MIDI, the inputs rfc-cleanup.md §2.7 names. The virtual panel + puts MQTT here (question 3). +3. MQTT, as a broker's client with the panel's topics and roles + (`mqtt-spec.md:16`), so a player can follow a show channel beside panels. +4. The audio WebSocket on 81, for lanes from the browser extension and the + phone app in `tools/`. + +REST is five routes: `GET /api/status` (`rest-api.md:42`), `POST /api/params` +(`:160`), `POST /api/sleep` (`:189`), `GET /api/patterns` (`:232`) and +`GET /api/patterns/select` (`:251`). Status keeps what a client reads to play +(`active`, `patterns`, `sleep`, `knobs`, `params`, `paramActive`, `lanes`, +`laneActive`, and `panel` as the output's size) and leaves out the fields that +describe an ESP32, such as heap and flash. The spec asks a client to tolerate +fields it does not know (`:5`), not fields that are missing (question 8). HTTP +on 80 and the audio socket on 81 sit below 1024, which Linux keeps from an +unprivileged process (step 4's finding 3). The systemd unit grants +`CAP_NET_BIND_SERVICE` through `AmbientCapabilities=`, and a port flag moves +either one, as in the virtual panel's D3. + +Step 4 plans `tools/virtual-panel/`: `DeviceSim` behind sockets, one transport +per file in `tools/virtual-panel/transports/`, each handed the `DeviceSim`, a +clock, the HTTP routes and its ports (its D5). The player imports those +transports and writes none of its own. It does not take `/vp/` (D6) or the +manual clock and `advance(ms)` (D4): it runs on the wall clock, and the virtual +panel is a test fixture (rfc-cleanup.md §2.6). If step 4 moves the transports +or changes their seam, the player follows. If step 4 does not land, the player +writes each transport itself, and §2.8 grows by one PR for each of step 4's k4, +k5, k9, k10, k13, k15 and k19 (rows 3, 6, 7 and 10). + +## 2.6 What it cannot do + +- Load a `.pfm`. A module is ESP32-S3 machine code for the device's relocator + (`firmware/patternflow/src/core_module_loader.h`). One with no JS twin, like + a module in a pack built outside the repo, does not play (`deviceSim.ts:97`). +- Be flashed from the browser. The Flash Patternflow button installs an + ESP32-S3 image (`web/public/flash/manifest.json`). +- Run a C++ feature. Weather, the clock, shows and the microphone live in + `firmware/patternflow/features/`. The audio socket carries lanes a phone or + a browser worked out, which is not the microphone. +- Do the board's own jobs: Wi-Fi setup, OTA, the crash record, + `/api/display`'s white balance and gamma, the calibration pattern. + +## 2.7 Where it lives and how it ships + +Two builds on the site already run Patternflow on a Raspberry Pi: day's, with +a 64×32 matrix (`web/src/components/sections/InsideGlobe/builds.ts:130`), and +Philipppp's Pi 4 with a HUB75 HAT and firmware of its own (`:333`). The player +starts from neither, but the hardware is already in people's hands. + +Two homes are open (question 1). In this repository it is +`tools/linux-player/`, MIT like the rest of `tools/` (`LICENSE:4`). It imports +`web/src/lib/` by path as step 4's virtual panel does, with a `tsconfig.json` +that extends the site's (step 4's k2), so a change to `DeviceSim` that breaks +the player fails `typecheck` in the same PR. In a repository of its own, as the +Home Assistant integration has been since 2026-09-03, it cannot import +`web/src/lib/` or step 4's transports by path. It needs the engine as a package +or copies of `harness.ts`, `deviceSim.ts` and their imports, and copies drift: +the 24-vs-20 detent incident is why step 3 has a shared constants file at all +(rfc-cleanup.md §2.5). The C++ program for the matrix lives outside this tree +either way. + +It ships as source with a `package.json` of its own, so a Pi never installs the +site's dependencies: `npm ci` on Raspberry Pi OS, Node 20 as CI runs +(`.github/workflows/web-ci.yml:36`), and a systemd unit that starts it at boot. +There is no disk image (row 12). Its own home adds two PRs, the package or the +copies and the check that keeps them in step. + +## 2.8 The order of the work + +| # | what | files | lines | PRs | where | +| --- | --- | --- | --- | --- | --- | +| 1 | the loop, `DeviceSim`, a `null` output for CI | `player/main.ts`, `player/engine.ts`, `player/outputs/null.ts` | 200 | 1 | player | +| 2 | HDMI | `player/outputs/fb.ts` | 100 | 1 | player | +| 3 | REST and the slider page | `player/rest.ts`, `player/knobs.html` | 180 | 1 | player | +| 4 | USB encoders | `player/inputs/evdev.ts`, `player/knobs.json` | 150 | 1 | player | +| 5 | the matrix | `player/outputs/matrix.ts`; the C++ program in its own home | 120 | 1 | player | +| 6 | OSC | `player/transports.ts` | 60 | 1 | player | +| 7 | MIDI | `player/inputs/rawmidi.ts`, `player/transports.ts` | 140 | 1 | player | +| 8 | `// @matrix` in `DeviceSim` | `web/src/lib/guide/deviceSim.ts`, `deviceSim.test.ts` | 80 | 1 | site | +| 9 | community patterns | `player/community.ts` | 120 | 1 | player | +| 10 | MQTT, then the audio socket | `player/transports.ts` | 60 each | 2 | player | +| 11 | the receiving card | `player/outputs/card.ts`, the raw-socket helper | 250 | 2 | player | +| 12 | the systemd unit and the install page | `player/patternflow-player.service`, `player/README.md` | 120 | 1 | player | + +Fourteen PRs, each under rfc-cleanup.md §2.1's 300 lines with one concern. The +`null` output fails on a thrown error, so CI runs the player with no panel. No +row touches the core, the frozen ABI or a feature, so hard rule 7 does not come +into it. After row 4 a Pi with a screen and a USB knob box plays the Basics, +and row 5 puts it on a HAT. Rows 3, 6, 7 and 10 wait for step 4's k2 and the +transport each imports (k4 and k5, k13, k15, then k9, k10 and k19). Row 11 +waits for someone with a card. Any size but 128×64 waits for the site to take +its size from one place, which the firmware path counts instead. + +## 2.9 Open questions for the maintainer + +Each has the default this draft uses until there is an answer. + +1. Where does the player live: here as `tools/linux-player/`, or in a + repository of its own, like the Home Assistant integration (§2.7)? + Default: the draft sets out both and picks neither. +2. Where does the C++ program for rpi-rgb-led-matrix live, and who keeps it? + It is GPL, and this tree's code is MIT. Default: a repository of its own. +3. Is REST, OSC, MIDI, MQTT, audio the right order, when the virtual panel's + is REST, MQTT, OSC, MIDI, audio (§2.5)? Default: yes. +4. Does the player ship at 128×64 only, until the site takes its size from one + place (§2.2)? Default: yes. +5. Where do community patterns come from: the feed on patternflow.work, which + needs the network and a running community, or a deck exported to a file? + Default: the file first, the feed after. +6. Which receiving card is the reference, and who has one on the bench? + Default: the Colorlight 5A-75B, and row 11 waits for someone who has one. +7. Encoders on the Pi's own header, beside a HAT? Philipppp's Pi 4 has four + knobs by its HAT, wired in a way the site does not say. Default: USB only. +8. What does `/api/status` say on a player, whose ESP32 fields have nothing + behind them? Default: those fields are left out, and none is added. +9. Does the player move to proposed with the firmware path at the cleanup's + gate (rfc-cleanup.md §2.12)? Default: no. It stays a draft until you say. diff --git a/docs/voice-banned.txt b/docs/voice-banned.txt new file mode 100644 index 000000000..e0a9a4c62 --- /dev/null +++ b/docs/voice-banned.txt @@ -0,0 +1,34 @@ +# Words and phrases that do not go into anything a person reads here. +# Why: docs/voice.md. The rule: docs/rfc-cleanup.md §2.10. +# +# One entry per line, written as it would appear in a sentence. Case does not +# matter. An English entry matches whole words, so "here's" does not catch +# "there's", and covers the word's other forms ("ensure" catches "ensures" +# and "ensuring"). A Korean entry matches anywhere in the text. A line that +# starts with # is a comment. +# +# check:voice (scripts/voice-lint.ts) reads this file. Adding a word needs no +# change to the script. + +seamless +robust +leverage +comprehensive +streamline +empower +cutting-edge +ensure +utilize +delve +it's important to note +here's + +효율적으로 +원활하게 +강력한 +최적화된 +혁신적인 +다양한 +전반적으로 +참고로 +살펴보겠습니다 diff --git a/docs/voice.md b/docs/voice.md new file mode 100644 index 000000000..b4da5b23f --- /dev/null +++ b/docs/voice.md @@ -0,0 +1,105 @@ +# Writing voice + +The README and the build guide set the voice for English here. Read a page of +[README.md](../README.md) and of [BUILD_GUIDE.md](../BUILD_GUIDE.md) before +writing anything longer than a sentence. Where the two differ, write like the +README: it has no dash between clauses and no emoji callout, and the build +guide still has both. Korean has a standard of its own, at the end. + +## Name the thing + +Not "the power input" but "`J4`, the screw terminal". Not "a lot of LEDs" +but "8,192 points of light". A number carries its unit, and when it helps, +the thing it is measured against: + +> it holds the panel to a 2.4 A budget, the high-power USB-A rating, so a +> white-out frame lands at roughly half brightness instead, while an ordinary +> bright pattern (~1.9 A) never notices. + +A part is named as the BOM names it (`PEC11R-4220F-S0024`), and a knob gets +its place the first time: "K2, the top-left knob". + +## Say what happens + +The guides say what the reader will see, and what it means when it looks +wrong: "The dialog usually ends without asking for it, and nothing failed: +close it." A limit is stated as one, even after a fix: "1/120 and slow motion +still band". "Usually" appears where it is true and the reader can act on it, +not as a cushion under every sentence. + +## Short, and finished + +Sentences carry one point each. A long one is followed by a short one, and a +short one can open a section by itself: "Two licenses, in two separate +files." A step starts with what to do and then says why: "Buy the LED panel +the BOM links. It's verified, and the case is measured around its screw +holes." A paragraph covers one thing and stops when it is said. + +## Who is speaking + +"You" is the reader. "We" is everyone who makes Patternflow: "Every +Patternflow plays every pattern we make." "I" is SeungHun Lee, only on a page +that already speaks as him, and only for something he does: "I'll add your +pin to the build map." New text never gives him a memory or a feeling he did +not write himself. + +## Words with fixed meanings + +[EDITIONS.md](EDITIONS.md#the-three-words) defines the words for firmware +builds, and prose uses them as defined there. A bundle is a composition's +folder and nothing more; "addon" and "variant" stay only where they name the +wire. Brand spelling is hard rule 4 in [AGENTS.md](../AGENTS.md). + +## Side by side + +A sentence that reads as generated, then the one the README has instead: + +> Make sure to select the correct USB port, as using the wrong one may cause +> problems during setup. +> +> Use its left port, labelled `USB` (antenna up, ports toward you): the right +> one flashes too, but the Wi-Fi step never appears there. + +## Korean + +Korean keeps the ending its file already uses (해요체 or 합니다체) and reads +as if it was written in Korean first. Everything above holds in Korean too. +Neither the journal nor the web guide is the model. The journal is a diary, +and the guide's Korean was carried over from its English sentence by +sentence. The marks of carried-over Korean, as two published Korean editing +skills list them (DaleSeo's +[humanizer](https://github.com/DaleSeo/korean-skills/tree/main/skills/humanizer) +and epoko77-ai's [Humanize KR](https://github.com/epoko77-ai/im-not-ai)): + +- English prepositions turned into particles: `~에 대해`, `~를 통해`, + `~에 있어서`, `~에 의해`. The plain particle or the verb says it. +- An English verb on a subject that is not a person. "드라이버 IC가 + 결정해요" is "the driver IC decides". +- `가지고 있다` for have, and `~되어지다` for a passive that `~되다` + already is. +- `~할 수 있다` where the sentence can simply say the thing. +- A noun doing a verb's work: `확인을 진행해요` for `확인해요`. +- Pronouns and plurals the sentence does not need: `그`, `그것`, `이러한`, + `-들`. +- A comma wherever English would put one. Korean uses fewer + ([KatFishNet](https://aclanthology.org/2025.acl-long.1030/), ACL 2025). + +Two of the guide's lines in English and in Korean, then the same thing +written in Korean: + +> Its driver IC decides, not “HUB75E” in the title. +> +> 제목의 ‘HUB75E’가 아니라 드라이버 IC가 결정해요. +> +> 패널이 켜질지는 제목의 ‘HUB75E’가 아니라 드라이버 IC에 달려 있어요. + +> Take $100 as the floor, not the estimate +> +> 100달러는 예상치가 아니라 바닥이에요. +> +> 100달러는 아무 문제 없이 끝났을 때 드는 돈이에요. 보통은 더 들어요. + +Words that never go in are in [voice-banned.txt](voice-banned.txt), one per +line. The maintainer adds to it, and `check:voice` reads it from there once +it lands ([rfc-cleanup.md §2.10](rfc-cleanup.md#210-writing-voice)). A +sentence that needs one of them to stand up is a sentence to cut. diff --git a/firmware/CUSTOM_PATTERNS.md b/firmware/CUSTOM_PATTERNS.md index 02c0ae85d..b4af51ff2 100644 --- a/firmware/CUSTOM_PATTERNS.md +++ b/firmware/CUSTOM_PATTERNS.md @@ -2,16 +2,19 @@ Patternflow includes a workflow for creating patterns without writing low-level rendering code. Two paths converge in the same place — the Live Editor at [patternflow.work](https://patternflow.work) → **Pattern** section → **Live Editor**. -- **AI-assisted** — describe the pattern in plain language, paste the AI's output into the editor, tune knobs, copy to C++, flash. No shader knowledge required. +- **AI-assisted** — describe the pattern in plain language, paste the AI's output into the editor, tune knobs, copy to C++, send it to the board. No shader knowledge required. - **Direct** — write the JavaScript pattern by hand in the editor, then convert to C++. For anyone comfortable with fragment-shader-style code. -> **New: you no longer need the Arduino IDE.** The last step — turning a pattern into firmware and getting it onto the board — now runs in the browser: the server compiles, your browser flashes over USB. See [step 5](#5-convert-to-c-and-flash). A local build is still the route for firmware development itself; that is PlatformIO through `firmware/bundles/build.sh`, not the Arduino IDE (the IDE cannot build the editions' library set). - -> **Newer still: patterns can install without any flashing at all.** On firmware -> with loadable-module support, the same `.h` builds into a tiny `.pfm` module -> that installs over Wi-Fi from the device's `/patterns` page — a few KB, no -> reflash, no reboot, unlimited slots. The community cart does this end to end. -> Details: [firmware/README.md → Loadable pattern modules](README.md#loadable-pattern-modules-pfm). +> **No Arduino IDE, no flashing, no cable.** The last step — getting a pattern +> onto the board — runs in the browser: a server compiles the `.h` into a +> small `.pfm` module (a few KB) and your browser hands it to the board over +> Wi-Fi, where it appears in the list with no reflash and no reboot (up to 128 +> of them). See [step 5](#5-convert-to-c-and-flash); a community pattern's +> **Send to my Patternflow** does the same end to end. Details: +> [firmware/README.md → Loadable pattern modules](README.md#loadable-pattern-modules-pfm). +> A local build is still the route for firmware development itself; that is +> PlatformIO through `firmware/bundles/build.sh`, not the Arduino IDE (the IDE +> cannot build the editions' library set). The 5-step path below is the AI-assisted route. The direct route reuses the same editor; skip to [Direct coding](#direct-coding-no-ai) if you don't need AI help. @@ -119,7 +122,7 @@ Drop the generated header file into the firmware folder: firmware/patternflow/ ├── patternflow.ino ├── config.h ← hardware pins / display / limits / LED calibration -├── net_config.h ← Wi-Fi / OTA defaults (+ feature tuning defaults) +├── net_config.h ← Wi-Fi / Improv / OTA / hotspot defaults (core only) ├── patternflow_secrets.example.h ← copy to patternflow_secrets.h for credentials ├── pattern_registry.h ← edit this ├── _TEMPLATE.h ← starting point for a new preset diff --git a/firmware/README.md b/firmware/README.md index 3b89cced9..6f5e310fe 100644 --- a/firmware/README.md +++ b/firmware/README.md @@ -12,6 +12,7 @@ This file is long because it is the firmware's reference. Most people need one p | you want to | read | | --- | --- | +| put Patternflow on a new board and get it on Wi-Fi | not this file: [`BUILD_GUIDE.md` §8](../BUILD_GUIDE.md#8-firmware), or the Play guide's [Flash chapter](https://patternflow.work/guide/play#flash). The short version is [First flash and Wi-Fi](#first-flash-and-wi-fi) | | build and flash the firmware locally | [Setup](#setup) — the board package, PlatformIO, the libraries | | write a feature or cut an edition | [`docs/EDITIONS.md`](../docs/EDITIONS.md) first, then [Adding features — the constraints that matter](#adding-features--the-constraints-that-matter) here | | write a pattern | [`PATTERN_GUIDE.md`](../PATTERN_GUIDE.md) (nothing here is needed); the hands-on route is [`CUSTOM_PATTERNS.md`](CUSTOM_PATTERNS.md) | @@ -34,6 +35,48 @@ patterns and settings intact. feature is, how to write one, how to cut an edition, and the promises every build keeps so switching stays safe. Read it before writing a feature. +## First flash and Wi-Fi + +Nothing on this page is needed to put the released firmware on a board: the +browser does it. The full walk-through is +[`BUILD_GUIDE.md` §8](../BUILD_GUIDE.md#8-firmware) and, on a board you can +turn, [patternflow.work/guide/play#flash](https://patternflow.work/guide/play#flash). +In order: + +1. The DevKit **out of the board**, on its **left** USB-C port (`USB`, the + native one), with a cable that carries data. Desktop Chrome or Edge. The + port is for flashing and data only — the panel is powered through `J4`, + the screw terminal, and nothing else. +2. **Download mode, every first flash:** hold **BOOT**, tap **RST**, let go + of BOOT. The port then shows as `USB JTAG/serial debug unit`. +3. **Flash Patternflow** (the Pattern tab on patternflow.work, or the guide), + pick that port, *Install Patternflow*, confirm. About two minutes. From + download mode the flasher cannot tell a board already runs Patternflow, + so it erases the whole flash, patterns and saved Wi-Fi included — nothing + to lose on a new build; a running board updates over Wi-Fi instead (see + [Wireless update from the browser](#wireless-update-from-the-browser)). +4. **The Wi-Fi step usually does not appear** after an install from download + mode, because the board does not restart into Patternflow by itself + ([`core_improv.h`](#core_improvh--patternflowimprov) can only answer once it + runs). Expected, not a failure: press **RST** once, press **Flash + Patternflow** again, pick the port, and the dialog opens on Patternflow's + own menu. *Connect to Wi-Fi*; after about 10 s of "Scanning" (the firmware + sends no list) type the network's name exactly and its password. 2.4 GHz + only. +5. **Or skip the flasher for Wi-Fi.** Seat the DevKit and power the panel: + about 15 s after it finds no network it knows (`PF_HOTSPOT_AUTO_AFTER_MS`), + it raises its own, `patternflow-xxxx` (the last two MAC bytes), password + `patternflow` ([`core_hotspot.h`](#core_hotspoth--patternflowhotspot)). + Hold **K2** and the NETWORK screen shows `HOTSPOT` and the name. Not in + the phone's list yet? It only exists from about 15 s after power-on: + wait, refresh the list, and match the exact name on K2. Join it (the phone + says "no internet", which is true; stay on it), open + `http://192.168.4.1/`, and pick your network on the Wi-Fi page. + +Once it is on your network, hold **K2** for its address; the console is at +`patternflow.local` (on Android, type the IP). A new board runs Origin only +— the Basics pack installs from the decks page over Wi-Fi. + ## Setup ### Required board package @@ -113,10 +156,13 @@ Library Manager: > not the plain device. > The HUB75 library is also **vendored** under `patternflow/src/hub75/`, and that -> copy is the one that compiles — it carries Patternflow's additions -> (`resumeDMAoutput()` for sleep, the brightness hook the power clamp uses). The -> installed/cloned copy only needs to satisfy headers, so its exact version does -> not matter. See `src/hub75/VENDORED.md`. +> copy is the one that compiles. [`UPSTREAM.md`](patternflow/src/hub75/UPSTREAM.md) +> there names the upstream tag and commit it was copied from, and each upstream +> commit taken since. Every line Patternflow changed is in `patternflow.patch` +> beside it, and [`VENDORED.md`](patternflow/src/hub75/VENDORED.md) says why. +> After an edit under `src/hub75/`, rebuild the patch with the command in +> `UPSTREAM.md` instead of editing it. The installed/cloned copy only needs to +> satisfy headers, so its exact version does not matter. OSC uses the ESP32 Arduino core's built-in `WiFi` and `WiFiUdp` libraries, so it does not require an extra OSC library. The MQTT client (`pubsubclient`) and the HUB75 driver are vendored under `patternflow/src/`. @@ -152,6 +198,7 @@ firmware/ │ │ ├── core_sleep.h # Sleep mode: panel off, board idle, still on the network │ │ ├── core_wifi.h # Multi-network Wi-Fi (up to 5 saved, tried in order) │ │ ├── core_improv.h # Improv-Serial provisioning from the browser flasher +│ │ ├── core_hotspot.h # The panel's own Wi-Fi network when no saved one answers │ │ ├── core_http.h # The one port-80 WebServer every page registers on │ │ ├── core_net_task.h # The network task on Core 0: Wi-Fi, HTTP, Improv, OTA │ │ ├── core_loop_sync.h # Hands a handler's frame-touching work to the loop task @@ -167,7 +214,7 @@ firmware/ │ │ ├── pf_feature.h # The hook interface a feature implements │ │ ├── pf_features.h # The dispatcher │ │ ├── features.h # The default list (empty) and the escape hatch -│ │ ├── osc/ audio/ audio_in/ midi/ ble/ mqtt/ show/ weather/ +│ │ ├── osc/ audio/ audio_in/ midi/ ble/ mqtt/ show/ weather/ clock/ │ │ └── README.md # What a feature is and the hooks it can answer │ ├── console/ # The device's web pages as plain HTML — edit these │ └── toolchain/ # PlatformIO extra_scripts (must stay sketch-relative) @@ -339,7 +386,7 @@ PatternflowOta::handle(); // first line of loop() — UDP poll, ~free when idle Shares Wi-Fi credentials and connection with OSC (if both are enabled, the connection is reused). When `PF_OTA_ENABLED` is 0 everything compiles to a no-op. See the [OTA Updates](#ota-updates-for-developers) section below for the user-facing workflow. ### `core_improv.h` — PatternflowImprov -Self-contained [Improv-Serial](https://www.improv-wifi.com/serial/) implementation so the **browser flasher** (ESP Web Tools, behind the website's "Flash" button) can set Wi-Fi over USB right after flashing — no rebuild, no `patternflow_secrets.h`. The main sketch only needs: +Self-contained [Improv-Serial](https://www.improv-wifi.com/serial/) implementation so the **browser flasher** (ESP Web Tools, behind the website's **Flash Patternflow** button) can set Wi-Fi over USB once the firmware is running — no rebuild, no `patternflow_secrets.h`. The main sketch only needs: ```cpp PatternflowImprov::begin(); // in setup() — announces it speaks Improv @@ -348,13 +395,19 @@ PatternflowImprov::handle(); // in loop() — drains Serial, drives provisioni The flasher sends the SSID/password over serial; `core_wifi.h` stores them in NVS (separate `pf_wifi` namespace) and uses them in preference to the built-in `PF_WIFI_SSID/PASS` placeholders on every boot. Shares the debug USB Serial — the host parser scans for the `IMPROV` header and ignores `println()` noise. Compiled in only when Wi-Fi is actually used (one of OTA/OSC/audio enabled) and `PF_IMPROV_ENABLED` is 1; otherwise a no-op. Set `#define PF_IMPROV_ENABLED 0` in `patternflow_secrets.h` to drop it. +The flasher can only ask for Wi-Fi once Patternflow is running and answering on that port. After an install from download mode (BOOT + RST) the board usually does not restart into Patternflow by itself, so the flasher's dialog ends at *Installation complete!* with no Wi-Fi step; pressing RST and opening the flasher again brings up Patternflow's own menu with *Connect to Wi-Fi*. The flasher waits up to 20 s for that answer after an install (`new_install_improv_wait_time` in `web/public/flash/manifest.json`), and a join is given up on after 30 s. + +### `core_hotspot.h` — PatternflowHotspot +The panel as a network of its own, so Wi-Fi can be set up without the flasher, and a panel can be used where there is no network at all. Core, not a feature: every image since 3.10.5 has it. In the default `auto` mode, once the station has had no link for `PF_HOTSPOT_AUTO_AFTER_MS` (15 s) the panel raises a WPA2 access point named `patternflow-xxxx` (the hostname plus the last two bytes of its MAC, `patternflow-a1b2`), password `PF_HOTSPOT_PASS` (`patternflow`) until it is changed on `/wifi`, and the whole console answers at `http://192.168.4.1/`. The NETWORK screen (hold K2) shows `HOTSPOT` and the name while it is what there is. On the Wi-Fi page the network-name field offers the networks the hotspot's channel scan saw, so a phone user picks one instead of typing a case-sensitive name; the panel then tries it at once and reports on the page and on its LEDs whether it joined. The hotspot answers every DNS name with the panel and fails the phone's internet probe at once, so the phone says "no internet" — true — and stays connected. Modes `off` / `auto` / `always` are kept in NVS and set on `/wifi` or `POST /api/hotspot` ([`docs/rest-api.md`](../docs/rest-api.md)). The header comment records what the bench decided and why, line by line. + ## Patterns The pattern list has two tiers: -- **Presets** — the curated showcase in `presets/`, compiled into `firmware.bin`. - Pattern 1 is always `Origin`. They work whether or not the filesystem mounts, - and switching between them is instant. +- **Presets** — compiled into `firmware.bin`. Today that is `Origin` alone, + pattern 1, the failsafe; the rest of the curated set in `presets/` ships as + the Basics pack. A preset works whether or not the filesystem mounts, and + switching to one is instant. - **Modules** — `.pfm` files on the FATFS partition, discovered at boot and appended after the presets. This is how new patterns normally arrive now: a ~3–22 KB file installed over Wi-Fi at `http://patternflow.local/patterns`, @@ -438,7 +491,7 @@ surface — the math headers are literally the same files, included with `-DPF_MODULE_BUILD`. **Install paths**, easiest first: -- Community cart → "Send over Wi-Fi": opens `/patterns?src=`, and +- A community deck or pattern page → "Send over Wi-Fi" (or a deck's "Install to my board"): opens `/patterns?src=`, and the page fetches and installs every file itself. Nothing downloaded. - Drag any number of `.pfm`/`.json` files onto `/patterns` — a visible queue uploads them one by one with progress, retries, and per-file results. @@ -785,9 +838,9 @@ For the original defaults: ### Longpress actions - **Encoder 1 longpress (≥1s)** — enter/exit global brightness mode. While active, K1 rotation adjusts panel brightness (5–255, ~5 per detent), the active pattern does not see K1 input, and a "BRIGHTNESS XX%" overlay shows the current level. Exits on a second longpress or after 5 seconds of idle. Value persists across reboots via NVS. -- **Encoder 2 longpress (≥1s)** — enter/exit the NETWORK screen (portrait status view: Wi-Fi state, local IP, OSC on/off and its remote host/port, audio-react on/off). Inside the screen, settings are toggled by **knob rotation, not clicks** — right = ON, left = OFF — so that holding K2 to leave can't flip anything on the way out: - - **turn K2** → OSC send/receive on/off (persists in NVS, so the device reboots into the same state). Wi-Fi stays connected either way; the toggle only enables/disables traffic. If the firmware was built with `PF_OSC_ENABLED 0` the row still shows but the toggle is inert ("REBUILD WITH PF_OSC_ENABLED=1"). - - **turn K3** → audio-react on/off (also persisted). +- **Encoder 2 longpress (≥1s)** — enter/exit the NETWORK screen (portrait status view: the Wi-Fi state and the board's IP address — or, while the panel has no Wi-Fi and its own hotspot is up, `HOTSPOT` and the hotspot's name — plus a row for each of the first two features in the build that can be switched at runtime). The core does not know what those rows are: a feature appears by declaring a short name and a switch (`features/README.md`). On the default build there are none; on the Audio edition they are `OSC` and `AUD` (audio-react). Inside the screen, rows are toggled by **knob rotation, not clicks** — right = ON, left = OFF — so that holding K2 to leave can't flip anything on the way out: + - **turn K2** → the first row on/off (persists in NVS, so the device reboots into the same state). Wi-Fi stays connected either way; the switch only enables/disables that feature's traffic. + - **turn K3** → the second row on/off (also persisted). - **turn K1** → sleep (see [Sleep mode](#sleep-mode) below). Either direction, unlike the toggles above: the screen is about to go dark, so "right = on" has nothing to mean. - **turn K4** → hand off to the UPDATE screen, which arms the `/update` endpoint (the arming *is* the security model — see `core_web_update.h`). @@ -895,21 +948,21 @@ A remembered pattern that is gone, or that no longer loads, falls back to Origin ## Audio-react, OSC, MIDI, MQTT -None of these is in the core. They are features, and each ships in an edition you install from [the shelf](https://patternflow.work/editions): audio-react, OSC and network MIDI in **Audio**, MQTT in **Performance**. What a client sends is a written contract in `docs/` — [`audio-ws-spec.md`](../docs/audio-ws-spec.md), [`osc-spec.md`](../docs/osc-spec.md), [`midi-spec.md`](../docs/midi-spec.md), [`mqtt-spec.md`](../docs/mqtt-spec.md) — and the guides are [`AUDIO_GUIDE.md`](../AUDIO_GUIDE.md) (sound in, MIDI, OSC) and [`docs/midi-ableton.md`](../docs/midi-ableton.md). The ready-made clients are the audio extension and phone app in [`tools/`](../tools/README.md) and the Max for Live bridge in [`integrations/ableton/`](../integrations/ableton/). +None of these is in the core. They are features, and each ships in an edition you install from [the shelf](https://patternflow.work/editions): audio-react, OSC and network MIDI in **Audio**, MQTT in **Performance**. What a client sends is a written contract in `docs/` — [`audio-ws-spec.md`](../docs/audio-ws-spec.md), [`osc-spec.md`](../docs/osc-spec.md), [`midi-spec.md`](../docs/midi-spec.md), [`mqtt-spec.md`](../docs/mqtt-spec.md) — and the guides are [`AUDIO_GUIDE.md`](../docs/AUDIO_GUIDE.md) (sound in, MIDI, OSC) and [`docs/midi-ableton.md`](../docs/midi-ableton.md). The ready-made clients are the audio extension and phone app in [`tools/`](../tools/README.md) and the Max for Live bridge in [`integrations/ableton/`](../integrations/ableton/). From a pattern's point of view every one of them is the user turning the four encoders: audio bands, OSC deltas and MIDI CCs arrive as `knobDeltas` (or pin a lane on the absolute parameter bus), so an encoder-driven pattern reacts with no transport-specific code. Two switches, for a local build: -- **Compile-time** — whether a feature is linked in at all is the bundle: `firmware/bundles//features_local.h` lists the features an edition carries ([`bundles/README.md`](bundles/README.md)). A feature's own tunables live in `features//_config.h` (the audio WebSocket port and knob scale in `features/audio/audio_config.h`, the OSC ports in `features/osc/osc_config.h`, the broker defaults in `features/mqtt/mqtt_config.h`), every one `#ifndef`-guarded so a line in `patternflow_secrets.h` wins. `PF_OSC_ENABLED 0` in the secrets file compiles OSC out of an edition that would otherwise carry it; the K2 info screen then shows `OFF (compile-time)`. -- **Runtime** — once compiled in, OSC and MIDI are switched on and off from the panel's NETWORK screen (hold K2), the state saved in NVS; the K2 screen shows `WAIT HOST` until the first OSC packet teaches the panel your computer's address. +- **Compile-time** — whether a feature is linked in at all is the bundle: `firmware/bundles//features_local.h` lists the features an edition carries ([`bundles/README.md`](bundles/README.md)). A feature's own tunables live in `features//_config.h` (the audio WebSocket port and knob scale in `features/audio/audio_config.h`, the OSC ports in `features/osc/osc_config.h`, the broker defaults in `features/mqtt/mqtt_config.h`), every one `#ifndef`-guarded so a line in `patternflow_secrets.h` wins. `PF_OSC_ENABLED 0` in the secrets file compiles OSC's traffic out of an edition that would otherwise carry it (its NETWORK row stays, and switches nothing). +- **Runtime** — once compiled in, a feature with a runtime switch gets a row on the panel's NETWORK screen (hold K2; turn K2 for the first row, K3 for the second), the state saved in NVS. On the Audio edition those rows are OSC and AUD; MIDI is switched on its console page (`/midi`). OSC learns your computer's address from the first packet it receives — send `/patternflow/ping` (the Max for Live bridge's Connect button does) — see [`docs/osc-spec.md`](../docs/osc-spec.md). ## Wireless update from the browser Once the device is on Wi-Fi, it can flash itself from any browser on the same network — no Arduino IDE, no USB cable, no TLS setup ([#232](https://github.com/engmung/Patternflow/issues/232)). -1. Get a firmware `.bin`: build one on [patternflow.work](https://patternflow.work) and hit *Download .bin*, or export one locally (`arduino-cli compile --output-dir …` — use the app image, not a merged full-flash image). -2. Open `http://patternflow.local` — the device serves a small console (audio sync / firmware update) — and pick **Firmware Update**, or go straight to `/update`. If `.local` doesn't resolve (common on Android), use `http:///update`; the device shows its IP on the NETWORK screen (hold K2) and on the UPDATE screen. +1. Get a firmware `.bin`, or let the site fetch it for you: when a newer release exists the console says so and links to [patternflow.work/update](https://patternflow.work/update), which hands the image to the panel; an edition installs the same way from [the shelf](https://patternflow.work/editions). A file of your own is a release's `patternflow.ino.bin` (or `patternflow-.ino.bin`), or a local build (`./firmware/bundles/build.sh`, or `build.sh flash `, which also sends it) — the app image, not a merged full-flash image. +2. Open `http://patternflow.local` — the device's console — and pick **Update** in its header, or go straight to `/update`. If `.local` doesn't resolve (common on Android), use `http:///update`; the device shows its IP on the NETWORK screen (hold K2) and on the UPDATE screen. 3. Drop the `.bin` on the page. The panel shows flash progress; the device verifies, reboots, and comes back on the new firmware in about ten seconds. Notes: diff --git a/firmware/bundles/README.md b/firmware/bundles/README.md index 73546715b..4741b04a3 100644 --- a/firmware/bundles/README.md +++ b/firmware/bundles/README.md @@ -7,7 +7,8 @@ Named firmwares, built from this repository. Everything Patternflow does lives in `patternflow/` — the panel, the pattern -loader, Wi-Fi, sleep, OSC, MQTT, the show player, weather, audio. **A bundle +loader, Wi-Fi and the hotspot, sleep, and in `patternflow/features/` OSC, +MQTT, the show player, weather, audio, MIDI, the clock. **A bundle does not add code.** It is two files saying which of that gets compiled in, and which settings differ. @@ -26,12 +27,12 @@ lives in an edition built from these two files. ```bash ./firmware/bundles/build.sh # the default: no features ./firmware/bundles/build.sh audio # the audio bundle -./firmware/bundles/build.sh midi # the MIDI edition, in its own env (bundles/midi/env) +./firmware/bundles/build.sh midi # the MIDI bundle, in its own env (bundles/midi/env) ./firmware/bundles/build.sh audio flash patternflow.local ./firmware/bundles/build.sh all # every composition + the marker scan # proving each binary carries exactly # its features -./firmware/bundles/shelf.sh audio v0.5.0 # a publishable image: secrets +./firmware/bundles/shelf.sh audio vA.B.C # a publishable image: secrets # moved aside, scanner proven against a # control build, four files staged for # the shelf diff --git a/firmware/patternflow/README.md b/firmware/patternflow/README.md index a9dd99d96..4f3507ddd 100644 --- a/firmware/patternflow/README.md +++ b/firmware/patternflow/README.md @@ -28,11 +28,10 @@ FATFS partition (on the device, not in this repo) and appended after the presets. No reflash, no reboot, up to 128 of them. Build one with `firmware/toolchain/build_module.py`. -> The old `custom1.h`–`custom3.h` slots in the sketch root are **gone** — -> modules are what they were for, and no longer cost a rebuild. Only `Origin` -> is compiled in today; the rest of the curated set ships as the Basics pack -> (`web/public/packs/basics.zip`, built from `presets/` by -> `firmware/toolchain/make_pack.py`) and installs from the Patterns page. +> Only `Origin` is compiled in today; the rest of the curated set ships as the +> Basics pack (`web/public/packs/basics.zip`, built from `presets/` by +> `firmware/toolchain/make_pack.py`) and installs from the Patterns page. The +> old `custom1.h`–`custom3.h` slots are gone — see [Registry](#registry). ## Source of truth @@ -169,8 +168,9 @@ soon as anything else wanted RAM. One preset plus a pack you choose leaves The preset sources are still in `presets/` as the editable originals — `toolchain/port_preset.py` is what turns one back into a module. -> **The `custom1..3` slots are gone.** They were the hand-edited way to get a -> pattern on a device, and uploading a `.pfm` replaced them. `PF_CUSTOM_SLOT_COUNT` +> **The `custom1.h`–`custom3.h` slots in the sketch root are gone.** They were +> the hand-edited way to get a pattern on a device, and uploading a `.pfm` +> replaced them — modules are what they were for, and no longer cost a rebuild. `PF_CUSTOM_SLOT_COUNT` > is `0`, and the region between the `PF_CUSTOM_SLOTS_BEGIN` / `PF_CUSTOM_SLOTS_END` > markers exists only so the web build service can compile a submitted pattern > into a whole image for devices whose firmware predates the module loader. @@ -184,19 +184,22 @@ The preset sources are still in `presets/` as the editable originals — Two things about that set which the file itself won't tell you: - **Not every pattern made the cut.** 0516, 0517, 0519-2, 0524, 0524-2, 0526, 0529 and 0530 were left out for performance lag or rendering problems on real ESP32 hardware. A pattern that looks fine in the browser can still miss frame budget on the device. 0609, 0614, 0614-2, 0619, 0622 and 0624 were posted after the module loader made the compiled-in showcase unnecessary and were never ported. `firmware/toolchain/check_presets.py` (run in CI) keeps this list honest: a new web preset has to be ported or added there with a reason. -- **Patterns posted to Instagram after 0602 aren't bundled here.** They're on Discord; convert them yourself, or install them as `.pfm` modules. +- **New patterns are not added here.** The set runs from 0510 to 0719 (with the gaps above); patterns since are published from the Pattern Lab to the Community, and install on a board from there as `.pfm` modules. Anything not compiled in is one upload away — build a module and send it to the device over Wi-Fi, no reflash. ## Licensing (read before submitting) -Patterns are licensed **CC-BY-SA-4.0** (same commons as the hardware; see +The patterns in `presets/` — the curated set, and so the Basics pack — are +licensed **CC-BY-SA-4.0** (same commons as the hardware; see `/LICENSE-CC-BY-SA`). Two simple, community-enforceable rules: **give credit, -and share alike.** +and share alike.** A pattern you publish to the Community instead carries the +license you pick there ([`docs/LICENSE-SUMMARY.md`](../../docs/LICENSE-SUMMARY.md)); +publishing is how patterns are shared now, not a pull request +([`CONTRIBUTING.md`](../../CONTRIBUTING.md)). -**Inbound = outbound:** by submitting a pattern (PR, issue, or Discord) you -agree to license it under **CC-BY-SA-4.0**, with attribution kept in the -metadata header. No copyright assignment (no CLA) — you keep authorship; the +**Inbound = outbound:** a pattern that does come into this tree is licensed +under **CC-BY-SA-4.0**, with attribution kept in the metadata header. No copyright assignment (no CLA) — you keep authorship; the project just gets the right to bundle and redistribute it. Authors may set a different license in the metadata header, as long as it diff --git a/firmware/patternflow/config.h b/firmware/patternflow/config.h index c396dce97..af335c10c 100644 --- a/firmware/patternflow/config.h +++ b/firmware/patternflow/config.h @@ -78,33 +78,37 @@ constexpr float EULER = 2.71828182845904523536f; // pattern with PFCanvas::setFrame(). See README.md. // Running a non-stock panel? Please report how it went, working or not: // https://github.com/engmung/Patternflow/issues/224 -#define PANEL_GEOM_128x64_SINGLE 1 // one 128×64 module (stock) -#define PANEL_GEOM_64x64_SINGLE 2 // one 64×64 module -#define PANEL_GEOM_64x64_CHAIN2 3 // two 64×64 modules daisy-chained → 128×64 +// +// The ids and the sizes come from shared/constants.json, which the site +// reads too. +#include "src/core_shared.h" +#define PANEL_GEOM_128x64_SINGLE PF_SHARED_PANEL_GEOM_128x64_SINGLE // one 128×64 module (stock) +#define PANEL_GEOM_64x64_SINGLE PF_SHARED_PANEL_GEOM_64x64_SINGLE // one 64×64 module +#define PANEL_GEOM_64x64_CHAIN2 PF_SHARED_PANEL_GEOM_64x64_CHAIN2 // two 64×64 modules daisy-chained → 128×64 // #define PANEL_GEOMETRY PANEL_GEOM_64x64_CHAIN2 #ifndef PANEL_GEOMETRY -#define PANEL_GEOMETRY PANEL_GEOM_128x64_SINGLE +#define PANEL_GEOMETRY PF_SHARED_PANEL_GEOM_DEFAULT #endif #if PANEL_GEOMETRY == PANEL_GEOM_64x64_SINGLE - #define PANEL_PHYS_W 64 - #define PANEL_PHYS_H 64 - #define PANEL_CHAIN 1 - #define PANEL_RES_W 64 - #define PANEL_RES_H 64 + #define PANEL_PHYS_W PF_SHARED_PANEL_GEOM_64x64_SINGLE_PHYS_W + #define PANEL_PHYS_H PF_SHARED_PANEL_GEOM_64x64_SINGLE_PHYS_H + #define PANEL_CHAIN PF_SHARED_PANEL_GEOM_64x64_SINGLE_CHAIN + #define PANEL_RES_W PF_SHARED_PANEL_GEOM_64x64_SINGLE_RES_W + #define PANEL_RES_H PF_SHARED_PANEL_GEOM_64x64_SINGLE_RES_H #elif PANEL_GEOMETRY == PANEL_GEOM_64x64_CHAIN2 - #define PANEL_PHYS_W 64 - #define PANEL_PHYS_H 64 - #define PANEL_CHAIN 2 - #define PANEL_RES_W 128 - #define PANEL_RES_H 64 + #define PANEL_PHYS_W PF_SHARED_PANEL_GEOM_64x64_CHAIN2_PHYS_W + #define PANEL_PHYS_H PF_SHARED_PANEL_GEOM_64x64_CHAIN2_PHYS_H + #define PANEL_CHAIN PF_SHARED_PANEL_GEOM_64x64_CHAIN2_CHAIN + #define PANEL_RES_W PF_SHARED_PANEL_GEOM_64x64_CHAIN2_RES_W + #define PANEL_RES_H PF_SHARED_PANEL_GEOM_64x64_CHAIN2_RES_H #else - #define PANEL_PHYS_W 128 - #define PANEL_PHYS_H 64 - #define PANEL_CHAIN 1 - #define PANEL_RES_W 128 - #define PANEL_RES_H 64 + #define PANEL_PHYS_W PF_SHARED_PANEL_GEOM_128x64_SINGLE_PHYS_W + #define PANEL_PHYS_H PF_SHARED_PANEL_GEOM_128x64_SINGLE_PHYS_H + #define PANEL_CHAIN PF_SHARED_PANEL_GEOM_128x64_SINGLE_CHAIN + #define PANEL_RES_W PF_SHARED_PANEL_GEOM_128x64_SINGLE_RES_W + #define PANEL_RES_H PF_SHARED_PANEL_GEOM_128x64_SINGLE_RES_H #endif #if PANEL_RES_W != (PANEL_PHYS_W * PANEL_CHAIN) diff --git a/firmware/patternflow/console/README.md b/firmware/patternflow/console/README.md index 2b60139d1..728003a3a 100644 --- a/firmware/patternflow/console/README.md +++ b/firmware/patternflow/console/README.md @@ -1,7 +1,8 @@ # The device console -These are the pages the panel serves over your LAN. They are ordinary HTML -files — open one in a browser, edit it, refresh. +These are the pages the panel serves over your LAN, and on its own hotspot +at `http://192.168.4.1/` when it has no network (`src/core_hotspot.h`). They +are ordinary HTML files — open one in a browser, edit it, refresh. You do not need the firmware, a device, or a toolchain to work on them. diff --git a/firmware/patternflow/console/midi.html b/firmware/patternflow/console/midi.html index 08f51d1b3..b5a249a6c 100644 --- a/firmware/patternflow/console/midi.html +++ b/firmware/patternflow/console/midi.html @@ -42,8 +42,8 @@ .sens .top b{font-size:13px;font-weight:600} .sens .top label{margin-left:auto;font-size:12px;color:var(--muted);display:flex;gap:6px;align-items:center;cursor:pointer} .sens .top label input{accent-color:var(--led)} -/* The readout column is in its own font's ch: the longest reading is 29 - (1/16 at 101.6 turns), plus room for a fallback font's wider × and ·. +/* The readout column is in its own font's ch: the longest reading is 28 + (1/16 at 84.7 turns), plus room for a fallback font's wider × and ·. Narrower, it spilled past the page edge; on a phone it goes under the slider instead. */ .g{display:grid;grid-template-columns:28px minmax(0,1fr) 32ch;align-items:center;gap:12px;padding:5px 0; @@ -201,9 +201,9 @@

The map

return mul>1?0:GAINS.length-1; } function gainText(g){return g[0]>1?('\u00d7'+g[0]):(g[1]>1?('1/'+g[1]):'1 : 1')} -// 20 detents a turn; 127 steps end to end. +// 24 detents a turn (PEC11R-4220F-S0024); 127 steps end to end. function rowText(g,abs){ - var perTurn=20*g[0]/g[1]; + var perTurn=24*g[0]/g[1]; var t=gainText(g)+' \u00b7 '+(Math.round(perTurn*10)/10)+'/turn'; if(abs)t+=' \u00b7 '+(Math.round(127/perTurn*10)/10)+' turns'; return t; diff --git a/firmware/patternflow/features/README.md b/firmware/patternflow/features/README.md index 36126b313..85657fcd8 100644 --- a/firmware/patternflow/features/README.md +++ b/firmware/patternflow/features/README.md @@ -10,13 +10,13 @@ Features that attach to the core without the core knowing they exist. This directory is the answer to one question: *when the core updates, how does somebody else's firmware take the update without pain?* Git merges are only painful when both sides edit the same files — so the rule is that a -variant edits none. +firmware built on the core edits none. -> **A variant adds directories here and two files of its own. It edits +> **A firmware adds directories here and two files of its own. It edits > nothing.** `git merge upstream` has nothing to conflict over, ever. An earlier version of this said "and one line to `features.h`" — which was -one line in a *core* file, so every variant conflicted on it at every core +one line in a *core* file, so every firmware conflicted on it at every core update, forever, on the same line. One line is enough to make taking updates a chore that eventually stops happening, and a firmware that stops taking updates is the fork this directory exists to prevent. @@ -49,14 +49,15 @@ for why any of this exists. | `features.h` | The default list, and the escape hatch. **The core owns it; no firmware edits it.** | | `feature_presets.h` | Patterns contributed by features. Same arrangement: `#ifndef PF_FEATURE_PRESETS`, so a firmware can decline them. | -A variant's `features_local.h` defines `PF_FEATURE_LIST` and `features.h` steps +A firmware's `features_local.h` defines `PF_FEATURE_LIST` and `features.h` steps aside. It may add, drop and reorder; `PF_FEATURES_NONE` builds with none at all: ```c +#include "osc/feature_osc.h" #include "audio_in/feature_audio_in.h" -#define PF_FEATURE_LIST \\ - &PFFeatureOsc::descriptor, \\ +#define PF_FEATURE_LIST \ + &PFFeatureOsc::descriptor, \ &PFFeatureAudioIn::descriptor ``` @@ -67,7 +68,7 @@ it reaches settings the feature list cannot: #define PF_VARIANT "audio" #define PF_VARIANT_VERSION "v0.1.0" // reported in /api/status, worn as // a badge in the console header -#define PF_WIFI_TX_POWER WIFI_POWER_17dBm +#define PF_AUDIO_IN_DRIVES_KNOBS 1 // a feature's own default, changed #define PF_FEATURE_PRESETS // no presets from features you lack ``` @@ -180,7 +181,7 @@ Then one line in the bundle that carries it — `firmware/bundles//feat | `audio/` | the WebSocket on port 81 that a client capturing sound elsewhere (the browser extension, the phone app) writes the four knob lanes through, and the switch that lets it (`/api/audio`) | The one with a server of its own, and a row in the device's own menu. The analysis and the mapping happen in the client; no page is served from here. `docs/audio-ws-spec.md`. | | `audio_in/` | the on-board PDM microphone: FFT on a Core-0 task, four bands onto the knob lanes, `/audio-in` and `/api/audio-in` | The first feature that did not already exist in the core; four hooks, no core edits. Its console page is assembled from the audio extension's mapping editor and the panel's own half in `console/_audio_in_*` (`toolchain/build_audio_in_page.py`, run by `console_pages.py build`). | | `osc/` | Max / TouchDesigner / Ableton, both directions | The fifth, and the first that did not fit — see below. | -| `midi/` | MIDI in and out over RTP-MIDI (Wi-Fi) and, in the MIDI edition, USB: CC 20–23 absolute, 24–27 relative, notes, Program Change | Transport-agnostic mapping in `core_midi.h`; the network transport beside it (`core_midi_rtp.h`), and the USB one (`core_midi_usb.h`) that compiles in only when the build's USB port is the OTG stack — a build flag, which is why the MIDI edition has its own env (`bundles/midi/env`). `docs/midi-spec.md`. | +| `midi/` | MIDI in and out over RTP-MIDI (Wi-Fi) and, in the MIDI bundle, USB: CC 20–23 absolute, 24–27 relative, notes, Program Change | Transport-agnostic mapping in `core_midi.h`; the network transport beside it (`core_midi_rtp.h`), and the USB one (`core_midi_usb.h`) that compiles in only when the build's USB port is the OTG stack — a build flag, which is why the MIDI bundle has its own env (`bundles/midi/env`). `docs/midi-spec.md`. | | `clock/` | the time cut out of the running pattern, `/clock`, `/api/clock` | Settings in its own NVS namespace, a page with a live preview, and the first user of `composeFrame`: huge anti-aliased digits the pattern shows through (typefaces rasterised offline by `toolchain/build_clock_glyphs.py`, several faces), with what fills the digits and what surrounds them chosen separately. Owns the time zone as a POSIX string with its DST rule; the core keeps the time (`src/core_clock.h`), this shows it. | | `ble/` | Wi-Fi provisioning from a phone over Improv-BLE | **In no edition.** The lifecycle works on hardware (advertise only while the panel cannot join, release the controller's memory after) but linking it costs ~12 KB of internal RAM and the one phone it was tried with never listed the panel. Opt-in for whoever finishes it; the header records what was measured. | diff --git a/firmware/patternflow/features/midi/core_midi.h b/firmware/patternflow/features/midi/core_midi.h index c9cf381e5..1efac19b5 100644 --- a/firmware/patternflow/features/midi/core_midi.h +++ b/firmware/patternflow/features/midi/core_midi.h @@ -66,8 +66,8 @@ inline bool runtimeEnabled = true; // Outbound sensitivity: detents per relative-CC step. A DAW moves a mapped // parameter a fixed amount per step, and one detent per step turned out to be // a lot of parameter per wrist on the first Live session - the panel's -// encoders have 20 detents a turn, so at 1 a full turn is 20 steps and at 4 -// it is 5. Remainders accumulate, so slow turns still arrive. Runtime, via +// encoders have 24 detents a turn, so at 1 a full turn is 24 steps and at 4 +// it is 6. Remainders accumulate, so slow turns still arrive. Runtime, via // POST /api/midi?outDiv=N, persisted; PF_MIDI_OUT_DIVISOR is the default. #ifndef PF_MIDI_OUT_DIVISOR #define PF_MIDI_OUT_DIVISOR 1 diff --git a/firmware/patternflow/features/midi/midi_index.h b/firmware/patternflow/features/midi/midi_index.h index 2e3961dcf..b49c28fae 100644 --- a/firmware/patternflow/features/midi/midi_index.h +++ b/firmware/patternflow/features/midi/midi_index.h @@ -52,8 +52,8 @@ cursor:pointer;text-align:left;font-family:inherit;color:inherit} .sens .top b{font-size:13px;font-weight:600} .sens .top label{margin-left:auto;font-size:12px;color:var(--muted);display:flex;gap:6px;align-items:center;cursor:pointer} .sens .top label input{accent-color:var(--led)} -/* The readout column is in its own font's ch: the longest reading is 29 - (1/16 at 101.6 turns), plus room for a fallback font's wider × and ·. +/* The readout column is in its own font's ch: the longest reading is 28 + (1/16 at 84.7 turns), plus room for a fallback font's wider × and ·. Narrower, it spilled past the page edge; on a phone it goes under the slider instead. */ .g{display:grid;grid-template-columns:28px minmax(0,1fr) 32ch;align-items:center;gap:12px;padding:5px 0; @@ -211,9 +211,9 @@ function gainIndex(mul,div){ return mul>1?0:GAINS.length-1; } function gainText(g){return g[0]>1?('\u00d7'+g[0]):(g[1]>1?('1/'+g[1]):'1 : 1')} -// 20 detents a turn; 127 steps end to end. +// 24 detents a turn (PEC11R-4220F-S0024); 127 steps end to end. function rowText(g,abs){ - var perTurn=20*g[0]/g[1]; + var perTurn=24*g[0]/g[1]; var t=gainText(g)+' \u00b7 '+(Math.round(perTurn*10)/10)+'/turn'; if(abs)t+=' \u00b7 '+(Math.round(127/perTurn*10)/10)+' turns'; return t; @@ -297,448 +297,449 @@ PF.status(function(s){ )HTML"; // GENERATED by firmware/toolchain/console_pages.py build — do not edit. -// gzip -9 of MIDI_INDEX_HTML (18973 bytes → 7026 bytes), served with Content-Encoding: gzip. +// gzip -9 of MIDI_INDEX_HTML (18993 bytes → 7047 bytes), served with Content-Encoding: gzip. static const uint8_t MIDI_INDEX_HTML_GZ[] PROGMEM = { - 0x1f,0x8b,0x08,0x00,0x00,0x00,0x00,0x00,0x02,0xff,0xc5,0x5c,0xcd,0x72,0xdb,0xc8, - 0x76,0xde,0xeb,0x29,0xda,0xf4,0x1d,0x81,0x18,0x83,0x20,0x41,0x4b,0xb2,0x0c,0x0a, - 0x72,0x64,0xc9,0x9e,0x38,0x1e,0xcf,0xb8,0x2c,0x4f,0x4d,0xd5,0x38,0x5e,0x80,0x44, - 0x93,0xc4,0x15,0x08,0x20,0x40,0x53,0xb2,0x2e,0xcd,0xaa,0xbb,0x4a,0x65,0x9d,0xa4, - 0x2a,0x6f,0x90,0x7d,0x56,0xd9,0xdf,0xbc,0xc9,0x3c,0x49,0xbe,0x73,0xba,0x01,0x82, - 0x3f,0xb2,0x35,0x93,0xa9,0x4a,0xcd,0x98,0x22,0x1a,0xdd,0xa7,0x4f,0x9f,0xdf,0xaf, - 0x4f,0x03,0x3c,0x79,0x10,0x65,0x23,0x75,0x9b,0x4b,0x31,0x55,0xb3,0xe4,0x74,0xef, - 0x84,0xfe,0x88,0x24,0x4c,0x27,0x41,0x4b,0xa6,0x2d,0x6a,0x90,0x61,0x74,0x7a,0x32, - 0x93,0x2a,0x14,0xa3,0x69,0x58,0x94,0x52,0x05,0xad,0xb9,0x1a,0x77,0x8e,0xe9,0x66, - 0x39,0x2a,0xe2,0x5c,0x89,0xb2,0x18,0x05,0xad,0x6e,0x3e,0xee,0x8c,0xb2,0xb4,0xcc, - 0x12,0xe9,0xfe,0xb9,0x7c,0x36,0x0d,0xe4,0x78,0x3c,0x8e,0x0e,0x7b,0x87,0xad,0xd3, - 0x93,0xae,0xee,0x78,0x6a,0x06,0x9c,0xc6,0xe3,0xf6,0x83,0x9b,0x38,0x8d,0xb2,0x1b, - 0xf7,0xed,0x4b,0xbb,0x3d,0x9e,0xa7,0x23,0x15,0x67,0x69,0xdb,0x5e,0x5c,0x87,0x85, - 0xf8,0x39,0xd0,0xf7,0x9c,0x9f,0x02,0xab,0x1b,0xe6,0x71,0xb7,0x54,0xa1,0x9a,0x97, - 0x96,0xf3,0x7d,0xf0,0xe1,0xa3,0xf3,0x26,0xe8,0xcb,0xa7,0xce,0xa5,0xf3,0x8b,0xf3, - 0x36,0xf8,0x19,0xc3,0x83,0x05,0xdd,0x96,0xbe,0x95,0xc4,0xd7,0xd2,0x72,0xa2,0xb8, - 0x50,0xb7,0xfe,0x03,0xcf,0xb9,0xf6,0xd3,0x79,0x92,0x38,0x13,0xa9,0xfc,0xef,0x9c, - 0x38,0x1d,0x27,0xf1,0x64,0xaa,0xfc,0x1f,0xe6,0xb3,0xa1,0x2c,0x9c,0x69,0x96,0x44, - 0xfe,0x8f,0xc3,0x3f,0xcb,0x91,0x72,0xca,0xf0,0xd6,0xaf,0x39,0x50,0xce,0x95,0x23, - 0xed,0x85,0xdc,0xdf,0x6f,0x4b,0x57,0xc9,0x4f,0xea,0x3c,0x4b,0x95,0x4c,0x55,0xa0, - 0xec,0xa5,0x33,0x9c,0x97,0x8d,0xae,0x43,0x27,0xb7,0x17,0xd5,0x95,0x18,0x83,0xf9, - 0xa1,0x1b,0xc5,0x65,0x38,0x4c,0x64,0x14,0x3c,0xf0,0x96,0xcd,0xab,0xde,0x20,0x77, - 0xd5,0x54,0xa6,0xed,0xb1,0x33,0xb6,0x07,0x85,0x54,0xf3,0x22,0x15,0xf9,0xd2,0xc9, - 0xb3,0x24,0x59,0x51,0x2c,0x9d,0x99,0x33,0x72,0x32,0x2d,0x05,0xe5,0x8c,0x9d,0x49, - 0xe0,0x0d,0xea,0x19,0xde,0x61,0x86,0xc9,0xfe,0xfe,0x83,0x31,0x78,0x1b,0x07,0x9e, - 0x33,0x4a,0x64,0x58,0xbc,0x8f,0x67,0x32,0x9b,0xab,0xb6,0xb2,0x9d,0x76,0xe9,0x8e, - 0xc2,0x24,0x79,0x56,0xb6,0x6d,0xff,0xbb,0x76,0x69,0xdb,0x66,0xc6,0x8a,0x7a,0x64, - 0x2f,0x46,0xed,0xc8,0x21,0xa9,0x60,0x2d,0x75,0xb3,0xa4,0x66,0x16,0x95,0xb4,0x97, - 0x66,0xcc,0x6b,0xe7,0xb5,0x6d,0x2f,0xeb,0x99,0x5f,0x63,0xe6,0x71,0xd0,0x1b,0x6c, - 0xcc,0x38,0x00,0x37,0x6d,0x15,0xc0,0x24,0xaa,0xb6,0x77,0xce,0x0c,0xe3,0xb2,0xfd, - 0xfd,0xcc,0x1d,0xc7,0x45,0xa9,0x82,0x00,0x82,0x78,0x86,0xe1,0x3e,0x98,0x37,0xcb, - 0x86,0xba,0xb2,0xdc,0x6f,0xa8,0x7c,0xb2,0x4d,0x79,0xe9,0x80,0xe8,0xaa,0x4f,0x6a, - 0x2f,0x66,0x41,0x3a,0x18,0x7f,0xfe,0x0c,0x52,0x4b,0x27,0xcd,0x6e,0xfc,0x77,0x4b, - 0xf4,0x61,0xb3,0x58,0x75,0x1b,0x61,0x25,0xfb,0xfb,0xdf,0xbb,0xf9,0xbc,0x9c,0xe2, - 0x62,0x70,0xf9,0x0c,0x97,0xa3,0xf6,0xa5,0xed,0xff,0xf2,0xec,0x17,0x17,0xa3,0xc0, - 0xc7,0x2f,0xc1,0x5b,0x97,0x84,0xde,0xfe,0xc9,0x79,0xe3,0x5c,0x80,0xd8,0x4d,0xa8, - 0x46,0xd3,0xcb,0x0d,0x4a,0x33,0x7b,0xf1,0xd6,0xd5,0xe4,0xc1,0xf7,0x2f,0x2e,0xb8, - 0x69,0xbf,0x09,0xde,0x84,0x6a,0xea,0xce,0x62,0xdc,0x76,0xde,0xd8,0x3c,0x34,0x56, - 0x2f,0xb3,0xe2,0x42,0x5e,0xc7,0x23,0xb9,0x1a,0x0c,0xfd,0x65,0x41,0xf6,0xf9,0xf3, - 0x62,0x59,0x29,0x3a,0x95,0x37,0xe2,0x6d,0x91,0xcd,0xe2,0x52,0xae,0xd4,0x71,0xeb, - 0xa4,0x5a,0xd1,0xd3,0x15,0x4b,0x7d,0xf9,0x78,0xa5,0x98,0xd2,0x5e,0x94,0x50,0x77, - 0x3b,0x73,0x87,0xf3,0x38,0x89,0xf6,0xf7,0x4b,0xfd,0x25,0x08,0x4c,0x8b,0x0d,0xf1, - 0x4f,0x5d,0x12,0x67,0xdb,0x76,0x6e,0x49,0xe3,0x4b,0x7b,0xd0,0xd0,0x46,0x43,0xc6, - 0x55,0xb7,0x41,0xda,0x7e,0x51,0x14,0x59,0xd1,0xb6,0x94,0xee,0x64,0xd1,0x42,0x32, - 0xd7,0x5c,0x7d,0xfe,0xfc,0x54,0x1e,0x80,0xca,0x72,0xb9,0x32,0xbb,0xef,0xda,0x73, - 0x32,0x49,0xb3,0x94,0xb1,0x84,0xb8,0xb8,0x65,0xc3,0xba,0x0a,0x7b,0x41,0x5e,0x5d, - 0xb8,0xd9,0x95,0xad,0xa6,0x45,0x76,0x23,0xf4,0x44,0x85,0x91,0x63,0x6d,0xf5,0x6c, - 0x1d,0xe4,0x59,0xcf,0x0a,0xfe,0x03,0xa5,0x14,0x08,0x18,0xc4,0xe6,0xb2,0x61,0x73, - 0x17,0xb4,0x7e,0x50,0xc4,0xe7,0x25,0xfc,0x3c,0x1f,0x6b,0x1d,0x05,0xe5,0xe0,0x7b, - 0x77,0x9c,0x15,0x2f,0x42,0xb0,0x51,0x4f,0x3e,0x86,0x7d,0xa2,0x27,0x31,0xbe,0xb4, - 0xdb,0x76,0x1d,0x6f,0xf6,0x74,0xe0,0x4a,0xc3,0x99,0x0c,0x5a,0xd7,0xb1,0xbc,0xc9, - 0xb3,0x42,0xb5,0xc4,0xc8,0xf8,0x74,0xeb,0x26,0x8e,0xd4,0x34,0x88,0x58,0x81,0x1d, - 0xbe,0x40,0xa4,0x88,0x55,0x1c,0x26,0x9d,0x12,0xbe,0x24,0x03,0x8f,0xa2,0x9c,0x8a, - 0x55,0x22,0x4f,0xdf,0x86,0x4a,0xc9,0x02,0x71,0x04,0x4b,0xeb,0x88,0x37,0xaf,0x2e, - 0x5e,0x9d,0x74,0xf5,0x1d,0xc4,0x41,0x75,0x4b,0x7f,0xfd,0x22,0xcb,0xd4,0xa2,0xd3, - 0x19,0x15,0x32,0x9c,0xf9,0x0f,0x7b,0xe7,0xbd,0xe7,0xbd,0xa7,0x83,0x4e,0x27,0x4e, - 0xaf,0xfc,0x87,0x2f,0x2e,0x5e,0x3c,0xb9,0x78,0x8e,0xab,0xd9,0x5c,0xc9,0xc8,0x7f, - 0x78,0x7c,0x76,0xdc,0x7f,0xd2,0xc7,0xf5,0x38,0x8c,0x53,0xe5,0x3f,0x3c,0x3c,0x3b, - 0x3c,0x3c,0x38,0x1a,0xec,0x75,0x3a,0xc5,0x3c,0x91,0xfe,0xc3,0xfe,0x41,0xdf,0xf3, - 0x8e,0x07,0xfa,0xb2,0x53,0x66,0x63,0xf4,0xf1,0x9e,0x7b,0x4f,0xbd,0x03,0xb4,0x25, - 0x44,0xe1,0xe5,0xcb,0xc3,0xf3,0xfe,0x0b,0x5c,0x65,0x20,0x7f,0xf8,0xe4,0xf9,0xf1, - 0x93,0x97,0xb8,0xb8,0x09,0x8b,0xd4,0x7f,0x78,0xf1,0xf4,0xac,0xf7,0xf8,0x25,0x51, - 0xcb,0xc3,0x54,0x26,0x18,0xfa,0xd8,0xf3,0xbc,0x1e,0x35,0x94,0x61,0x5a,0xfa,0xd6, - 0x2b,0x88,0xa0,0xb0,0x9c,0x79,0xcc,0xd7,0x9d,0x52,0x16,0xf1,0xd8,0x29,0x6f,0x4b, - 0x25,0x67,0x9d,0x79,0xec,0xac,0x1a,0x69,0xc8,0x2c,0x4b,0x33,0xdf,0xfa,0x07,0xa9, - 0x9e,0x17,0x60,0xb6,0x14,0x6f,0x70,0xcd,0x63,0xe9,0x46,0x99,0x87,0x23,0xe9,0x5c, - 0xbe,0xa4,0xc6,0xce,0x3b,0x39,0x99,0x27,0x61,0xe1,0xbc,0x91,0x69,0x92,0x39,0xf5, - 0xed,0xe5,0xde,0xb7,0x8b,0x61,0xf6,0xa9,0x53,0xc6,0x7f,0x89,0xd3,0x89,0x3f,0xcc, - 0x8a,0x48,0x16,0x1d,0xb4,0x2c,0xf7,0x86,0x59,0x74,0xbb,0x98,0x85,0xc5,0x24,0x4e, - 0xfd,0xde,0x60,0x18,0x8e,0xae,0x26,0x45,0x36,0x4f,0x23,0x1f,0xbe,0xd1,0x36,0xb2, - 0xb4,0x07,0xa3,0x2c,0xc9,0x0a,0xd3,0x04,0x71,0xda,0x83,0x31,0x74,0x08,0xd1,0xcd, - 0xe2,0xe4,0xd6,0x34,0x13,0xcb,0xf6,0x60,0x2f,0x89,0x53,0xd9,0x99,0x4a,0x8e,0xf6, - 0x9e,0x7b,0x38,0xe8,0xdc,0xc8,0xe1,0x55,0x8c,0xbe,0x34,0xa0,0x9c,0x41,0x43,0x53, - 0x62,0x21,0x4c,0x49,0xcd,0x71,0x58,0xca,0x68,0xb9,0xe7,0xde,0x14,0x61,0x0e,0x26, - 0x3e,0x69,0x23,0xf0,0x8f,0x8e,0x7b,0xf9,0xa7,0x41,0xc5,0x94,0x08,0xe7,0x2a,0x1b, - 0xe4,0x61,0x14,0xd1,0xc0,0xc7,0xfd,0xfc,0x93,0xe8,0xe3,0xbe,0x38,0x3a,0xc8,0xc1, - 0x3f,0x25,0x48,0x59,0x2c,0x10,0xeb,0xf3,0x84,0x32,0x49,0x22,0x3f,0x0d,0x40,0x78, - 0x92,0x76,0x62,0xc8,0xb2,0xf4,0x47,0x92,0x24,0x3d,0x98,0x84,0xb9,0x7f,0x0c,0xa2, - 0x86,0x0c,0xd6,0xae,0x54,0x36,0xf3,0x3d,0x50,0x1b,0xec,0xd5,0xf2,0xd0,0x6d,0xa0, - 0x8d,0x34,0x1a,0x47,0x42,0x2f,0x8c,0x0c,0xc0,0x06,0x93,0x11,0x8c,0x4b,0xf3,0xf7, - 0x04,0x83,0xcc,0x12,0xe9,0xeb,0x96,0xd0,0x60,0x1e,0xf6,0xc0,0x10,0x2d,0xc2,0x28, - 0x46,0x6c,0xf3,0x98,0x57,0x6f,0xa1,0xc5,0x10,0xff,0x45,0xfa,0xde,0x21,0x86,0xf2, - 0xe5,0x8d,0x26,0x75,0xd4,0xeb,0xd5,0x6b,0x1e,0xd0,0x3a,0x7c,0x0f,0xb3,0x96,0xf3, - 0xe1,0x62,0x5b,0xd8,0xa4,0x59,0xa3,0x04,0x4d,0x0c,0xf4,0xd7,0x94,0xc4,0x56,0x0d, - 0xae,0x4b,0xc9,0x2e,0x6a,0x34,0xdc,0xa1,0xd8,0xdf,0x3f,0x62,0x5e,0xfa,0x8b,0x8d, - 0xe1,0x9b,0xbc,0x24,0x92,0x3c,0xae,0x43,0x06,0x44,0x82,0x77,0x7b,0x4f,0xe5,0x6c, - 0x40,0x21,0xa3,0xa3,0x0a,0xe8,0x1a,0x51,0x60,0xe6,0xcf,0xf3,0x5c,0x16,0x23,0x68, - 0x71,0xb0,0xd7,0x9c,0x9d,0x7d,0xcc,0x5e,0x69,0xb0,0x27,0xbc,0x1e,0x4d,0xea,0x22, - 0xcf,0x44,0x0b,0x2d,0x99,0xdd,0x72,0xde,0x16,0x26,0x3b,0x90,0x5d,0xeb,0xdf,0x83, - 0xd6,0x85,0x77,0x54,0x53,0x13,0xc3,0x5a,0xf7,0xc3,0x24,0x1b,0x5d,0x35,0x85,0x72, - 0xb0,0xbd,0xaa,0x6a,0x54,0x5e,0xd9,0x3c,0x91,0x03,0x83,0xcd,0x61,0xfd,0x0d,0x59, - 0x9a,0xd5,0xac,0x5b,0xf6,0xc1,0xa1,0x21,0xe5,0x42,0xba,0x46,0xd7,0xcd,0x41,0x88, - 0xc3,0x75,0x87,0xf1,0x78,0x57,0x0f,0xbd,0xde,0x8c,0xc4,0x0b,0xc8,0xe4,0x1e,0xa1, - 0xfb,0xbc,0x1c,0xae,0x5b,0x32,0x99,0xad,0xb7,0x72,0x06,0xd6,0x1f,0x5f,0x57,0x76, - 0x4c,0x0d,0x64,0xd7,0x86,0x3e,0xdf,0xdf,0x21,0x57,0x0e,0x60,0xf0,0xce,0xdf,0x61, - 0x49,0x7a,0xf5,0x9a,0x39,0x01,0x63,0x48,0x7d,0xc6,0x16,0x9d,0xd1,0x14,0x39,0x70, - 0xb1,0xcb,0xe6,0xdc,0xd1,0x34,0x43,0x44,0xaf,0x57,0x32,0x29,0xe2,0x68,0xb5,0x12, - 0xba,0xea,0xc0,0x35,0x71,0x4b,0x49,0x12,0xc8,0x7c,0x86,0x80,0xe8,0x8d,0x0b,0x81, - 0x7f,0x3c,0x78,0x43,0xa1,0xff,0x37,0x73,0xa1,0x70,0xa1,0xbd,0x7c,0x34,0x2f,0x4a, - 0xf0,0x9a,0x67,0x31,0x47,0x04,0xb6,0x64,0x8e,0x14,0x7e,0x22,0xc7,0x6a,0x2d,0xa2, - 0xc5,0xe9,0x14,0xb1,0x57,0x19,0x39,0x98,0x2b,0xe6,0xcd,0x9f,0x66,0xd7,0x08,0x37, - 0x3b,0xd4,0x59,0xcb,0x69,0x34,0xbd,0xc3,0x24,0x4c,0x58,0x40,0x18,0x9e,0x86,0xc0, - 0xd7,0xa0,0x0b,0xb4,0x40,0xc6,0x47,0xfe,0x01,0x36,0x57,0xbd,0x98,0xca,0x97,0x2c, - 0xfb,0xf1,0x9d,0xb1,0xa3,0x8a,0x62,0x7d,0xf6,0x10,0x50,0xc9,0x57,0xd1,0xfd,0x37, - 0xdb,0x38,0xc5,0x1f,0x99,0x96,0xcd,0xe8,0xe1,0x71,0xd4,0xe5,0x66,0xe1,0xa2,0xe1, - 0xee,0xc8,0x3b,0x44,0x58,0x20,0x7a,0xb5,0xea,0x9b,0xc3,0xc4,0x70,0xf1,0xe5,0xe5, - 0xac,0x75,0x4e,0xc2,0xa1,0x4c,0x2a,0x2e,0x48,0x5b,0x3e,0xe7,0x84,0xaf,0xaf,0x67, - 0xcb,0x99,0x10,0x38,0x76,0xa5,0x87,0x75,0xdb,0xd8,0x9e,0x5b,0xc4,0x69,0x3e,0x57, - 0x8b,0x70,0x44,0xfd,0xb7,0x94,0xba,0xdc,0xeb,0x7e,0x2b,0xde,0x4f,0xa5,0x40,0xb2, - 0x8c,0x80,0xe3,0x84,0xb6,0x6a,0x11,0x97,0x18,0x27,0x62,0x55,0x8a,0xec,0x06,0xd8, - 0x0d,0xdc,0x5a,0x25,0xb6,0x71,0xbe,0x00,0x76,0x13,0x49,0x96,0x4e,0x64,0xa9,0x78, - 0x0c,0x2c,0x95,0x3a,0xf7,0x9f,0xee,0x09,0x21,0xda,0x5e,0xd7,0x3b,0x12,0xa1,0x42, - 0xcc,0xf4,0xdc,0x23,0x41,0xa8,0xad,0xb4,0x1d,0x91,0x27,0xf3,0x52,0x00,0xe1,0xcc, - 0x40,0xa8,0x10,0xa1,0x18,0x63,0xb3,0x41,0xd6,0x5f,0xd1,0x45,0x6a,0x92,0x85,0xf8, - 0x9f,0xff,0x10,0x61,0x1a,0x89,0xbf,0xfd,0xb7,0x4b,0xa4,0x7e,0x08,0x01,0x02,0x6f, - 0xb0,0xe1,0x02,0x13,0xf0,0xdd,0x38,0x01,0xb7,0x22,0x0f,0x31,0x2b,0x71,0x90,0x87, - 0x13,0x29,0x64,0x34,0x91,0x03,0x01,0xc4,0x17,0x8a,0x7c,0x9a,0xa5,0x92,0x7a,0x4e, - 0x32,0x59,0x0a,0xb8,0x14,0xe8,0xa1,0x1f,0x11,0x2a,0x13,0xa6,0x0e,0x8b,0x55,0x14, - 0xd1,0xc4,0xb7,0xdd,0x3d,0x77,0xb2,0xe1,0xe0,0x3b,0xfd,0xba,0x8f,0xe0,0x24,0x00, - 0xda,0x91,0xdc,0xdb,0x3d,0x07,0x1e,0x6e,0x8b,0xc7,0xfd,0xd1,0xf4,0xae,0x14,0xcd, - 0x7a,0xac,0x7c,0xf7,0x90,0x42,0xf3,0x7d,0x63,0x16,0x74,0x36,0x11,0xee,0xd5,0xee, - 0x80,0x34,0xd1,0xfa,0xfb,0x40,0x5b,0xed,0x00,0xc9,0x6b,0x22,0x3f,0x9a,0x54,0xee, - 0xf5,0x7a,0xdf,0xac,0xb2,0xee,0x1d,0xfa,0x1d,0xa8,0x6c,0x3e,0x9a,0x76,0x42,0x4e, - 0xa5,0x3e,0x22,0x4c,0xe7,0x56,0x4f,0x57,0x2c,0x76,0xd8,0x5c,0x23,0xb0,0x14,0x64, - 0xcd,0x83,0x9b,0x29,0xd6,0xc9,0x79,0x54,0xfa,0xd8,0x06,0x01,0xeb,0x90,0x81,0x11, - 0xc0,0x5d,0x77,0x9e,0x3f,0xcf,0x4b,0x15,0x8f,0x6f,0x3b,0x06,0x1f,0xfb,0x3c,0xa2, - 0x33,0x94,0xea,0x46,0xca,0xb4,0x46,0x30,0xf7,0x10,0x47,0x6f,0x27,0x18,0xa8,0x96, - 0xd9,0xd7,0x19,0x4f,0x1c,0xb0,0x4b,0xfe,0xdd,0x4c,0x46,0x71,0xd8,0x5e,0x81,0xaf, - 0x03,0x02,0x5f,0xf6,0x02,0xea,0xbd,0xaf,0x46,0x07,0xb0,0xb1,0x0e,0xb1,0xd7,0x5b, - 0x6a,0xa1,0xf0,0x40,0xdd,0xdf,0xef,0x6f,0x06,0xda,0x25,0x16,0x7f,0x95,0x66,0xc3, - 0xf2,0x3e,0xe6,0x53,0xc8,0x5c,0x86,0xaa,0x7d,0xa0,0xe7,0xd9,0x99,0x0d,0x75,0x3c, - 0x22,0x8a,0x5f,0x02,0x16,0x26,0x01,0x7e,0x2d,0x5d,0xd0,0xda,0xe8,0x9f,0x09,0x57, - 0x44,0x55,0xb8,0xe9,0xe2,0xf7,0xcb,0xfc,0xfe,0x0a,0xae,0xa7,0x1b,0x86,0xc5,0xc2, - 0x84,0xe0,0x83,0x5d,0xe8,0x52,0x27,0xbe,0x86,0x08,0x18,0xd7,0x66,0x65,0xcc,0xd6, - 0x59,0x48,0x08,0x30,0xbe,0x96,0x4d,0x72,0x22,0x5e,0xd4,0xf7,0xc3,0x21,0x44,0x03, - 0x43,0x1d,0x70,0x18,0xed,0x0d,0x88,0x00,0x70,0xbf,0x4e,0x1a,0xbd,0x3b,0xb0,0xac, - 0xb6,0x8c,0xc3,0xde,0x37,0x35,0xd5,0xeb,0xfb,0xc8,0xe4,0xf1,0xba,0xaa,0x34,0x68, - 0x1b,0xc7,0x12,0xb8,0xe1,0x3e,0x78,0x7d,0x2d,0x18,0x1c,0x91,0xd5,0x56,0xc3,0x4d, - 0x42,0x60,0x90,0xcc,0xd9,0xd3,0xab,0x3d,0x63,0x35,0xf3,0xce,0xe4,0xac,0x87,0xeb, - 0x68,0xae,0x31,0xf6,0x00,0xb6,0x6c,0x4c,0x5f,0x67,0xc8,0x3a,0xfb,0xdf,0x63,0x85, - 0x0c,0x2c,0x2a,0x16,0xb1,0x17,0x10,0x4f,0x77,0x69,0xcc,0x18,0xd9,0xd6,0x26,0x6a, - 0xef,0xcb,0xd0,0x66,0x6d,0x03,0xd1,0x5f,0x09,0x4f,0xb3,0xef,0x8f,0xb3,0xd1,0xbc, - 0x5c,0x20,0xdd,0x50,0x92,0x45,0x60,0x41,0xa6,0xbd,0x03,0x74,0x6c,0x0c,0xf4,0x21, - 0xf9,0x91,0xa4,0x1a,0x9c,0x2c,0x76,0xc7,0x4b,0x1d,0xe9,0xca,0x75,0x2d,0xd1,0x47, - 0x87,0xa2,0x97,0x4f,0x1f,0x77,0x2a,0x6d,0xc3,0x3f,0x8f,0x75,0xde,0x0f,0xaf,0xa5, - 0x43,0x85,0x83,0x89,0x54,0x8e,0x3b,0x99,0x66,0xa5,0x5a,0x6c,0xcb,0x7a,0x25,0xd3, - 0xa6,0xd6,0xb9,0x61,0x4b,0x18,0x3b,0x52,0x36,0xe6,0xb8,0x2b,0x04,0x68,0xe8,0xb5, - 0xd3,0xb6,0x9b,0x02,0x30,0x8a,0xda,0x86,0x22,0x9a,0xe3,0xfb,0x22,0x51,0xd6,0xc5, - 0xa6,0xb6,0x49,0x09,0xbc,0xfe,0xdf,0x4f,0x66,0x65,0xc5,0x4c,0x48,0x63,0x51,0x23, - 0xce,0xbb,0x81,0xe9,0xd6,0x2a,0x6b,0x84,0x29,0x47,0x57,0xdb,0xdb,0x8d,0xe3,0xdd, - 0x08,0xe9,0xab,0xce,0xb5,0xe5,0xa9,0x4c,0xff,0x6b,0xc0,0xc9,0x4d,0x33,0x25,0x17, - 0x5f,0x00,0x72,0xeb,0xb9,0xeb,0x98,0x73,0x17,0x6f,0xec,0x76,0x6c,0xc7,0x88,0x16, - 0xb0,0x57,0x24,0x9d,0x28,0xe2,0xbf,0xbf,0x67,0xe7,0x6c,0x74,0x35,0x2b,0x27,0x8b, - 0xcd,0x4d,0xd7,0x3d,0xa9,0x51,0x40,0xa9,0x18,0x3b,0xba,0x2b,0x10,0x81,0xbe,0x9b, - 0x8f,0x3b,0xb2,0x58,0x77,0x41,0x96,0x4a,0x75,0x33,0x5b,0x87,0x33,0xbc,0xa5,0xfc, - 0x30,0x8d,0xa3,0x48,0xa6,0x1f,0x6b,0xc5,0x91,0x95,0x3c,0x88,0x67,0x54,0x5a,0x0b, - 0x53,0x55,0x43,0xd1,0xd1,0xb4,0xc8,0x66,0x92,0xf1,0x66,0x9c,0x8b,0x32,0xbc,0x05, - 0x42,0x9c,0xca,0xd4,0x00,0x3f,0x98,0xb9,0xa0,0x92,0x64,0x09,0xb4,0x58,0x02,0x20, - 0x42,0x6f,0x03,0x8d,0x4a,0x09,0x1c,0x5e,0x87,0xc9,0x1c,0x08,0x30,0x8a,0x67,0x8c, - 0xf4,0xe8,0x50,0x82,0xd9,0x19,0x8f,0x49,0xe8,0xe2,0x61,0xa9,0x9c,0xcd,0x36,0xde, - 0xe9,0x6e,0xb5,0x46,0xc9,0x76,0x47,0x9d,0xfb,0xeb,0x6d,0x2f,0x29,0x2e,0x4a,0x1a, - 0x15,0xa8,0xfb,0x6c,0x64,0x21,0x3f,0x60,0x8e,0xfb,0x6e,0x3d,0x9a,0x01,0x85,0x62, - 0xf4,0x3d,0x6b,0x3e,0xd5,0x54,0x51,0x9d,0x29,0xbe,0xee,0x03,0xd5,0x32,0x30,0x2a, - 0xda,0xd8,0x77,0x7d,0x3d,0x91,0x6c,0x02,0x47,0xac,0x72,0x97,0x05,0x28,0x3a,0xd4, - 0x68,0xc2,0xd7,0x95,0xcf,0x27,0x61,0x5e,0x4a,0xbf,0xfa,0xb2,0x41,0x1f,0x23,0xa7, - 0x8b,0x9d,0xbb,0xde,0xc6,0x26,0x72,0x03,0xd1,0x6c,0xd5,0x80,0x8e,0xef,0x5d,0x03, - 0x32,0x8e,0x5b,0x09,0xfe,0xc0,0x08,0xfe,0xfe,0x72,0x57,0xd1,0xa2,0x19,0x52,0x7e, - 0xcb,0xe0,0x01,0x42,0xa1,0x8a,0x81,0xb0,0xcd,0x52,0x61,0x4d,0x3b,0xbd,0x50,0x45, - 0x6b,0xc5,0x8c,0xdf,0x19,0x2d,0x76,0x00,0x7c,0x83,0x98,0xbc,0x6f,0xea,0x42,0x4d, - 0xa1,0xe3,0x01,0xa3,0xd5,0x71,0x86,0x40,0x55,0x34,0xc3,0xcb,0xe3,0xfe,0x46,0x4d, - 0xa7,0x99,0xf4,0xee,0xf2,0x85,0xdf,0x57,0xcf,0xa9,0x92,0x7c,0xb8,0xd8,0x25,0x11, - 0x72,0xd7,0x0f,0x51,0xa8,0xc2,0x0e,0xa2,0xc1,0x4c,0x06,0x7c,0x6c,0xf7,0x71,0x55, - 0x54,0x7f,0x79,0xf0,0xe2,0xe5,0x8b,0xa3,0x81,0xb9,0xee,0x53,0x15,0xfc,0xe5,0xf9, - 0xcb,0x33,0x34,0x0c,0x27,0x8d,0xbb,0xa6,0xea,0x5d,0xdf,0xe4,0x12,0xbc,0x77,0xe6, - 0x1d,0x73,0xf9,0xdc,0x94,0xe0,0x8f,0x9e,0x1f,0x1d,0x1e,0x1e,0xaf,0x4a,0xf0,0x4f, - 0xcf,0x9e,0x1e,0x1c,0xd3,0x68,0x9d,0xd0,0x1e,0xbe,0xe8,0x5d,0x3c,0x3d,0x3f,0x1f, - 0x54,0x15,0xf9,0x8b,0xa7,0x17,0xde,0x79,0x7f,0xbd,0x22,0xff,0xe2,0xf8,0x45,0xff, - 0xe2,0x68,0x67,0x45,0xbe,0xff,0xf2,0xf8,0xec,0xf0,0xb0,0xae,0xc8,0x3f,0x3f,0x3e, - 0xf6,0xfa,0x3d,0x62,0x3c,0x2c,0xa2,0x06,0x67,0xe3,0x49,0xc5,0xd8,0x17,0xf6,0x42, - 0xa6,0xa0,0x75,0x67,0xe9,0x6a,0x69,0xa2,0xda,0x17,0x37,0x31,0x7d,0xde,0xc4,0x6c, - 0x21,0x60,0xae,0xed,0x2c,0xd7,0x70,0xed,0x7a,0xd9,0x67,0xbd,0xae,0x43,0x06,0xb4, - 0x86,0x62,0x57,0x81,0x00,0x3b,0xab,0x93,0xae,0x3e,0x0b,0x39,0xe9,0xea,0x83,0x63, - 0xaa,0xee,0x9f,0x9e,0x44,0xf1,0xb5,0x18,0x21,0xb2,0x97,0x41,0x8b,0x8c,0xb3,0x3a, - 0x57,0x96,0xc5,0xe9,0x09,0x15,0xf4,0xaa,0x7b,0x51,0xa6,0xf8,0xb0,0x18,0x4d,0xa7, - 0x27,0x53,0xef,0x54,0x1f,0xb2,0xe0,0xcb,0x5a,0xaf,0x72,0x3e,0x6c,0x89,0x38,0xc2, - 0x17,0xf4,0xee,0x54,0xdd,0xbb,0x86,0xe0,0xde,0xde,0x89,0x29,0x39,0x9f,0xee,0x09, - 0x71,0x32,0xed,0x9f,0x5e,0xca,0xb2,0xc4,0x25,0x7a,0xf4,0xb9,0xa9,0xc1,0x0c,0xe5, - 0x0c,0x4d,0x8b,0xbf,0x81,0xdd,0xfa,0xa2,0xa3,0x89,0x0f,0x4f,0x4f,0xf2,0x55,0x5b, - 0x4e,0xec,0xe5,0x44,0x65,0x9d,0xce,0xbc,0x34,0x2c,0xf1,0x17,0x9d,0x20,0x35,0xd3, - 0xa7,0x3f,0x5d,0x3e,0xaf,0x58,0xe4,0x45,0x98,0x5e,0x9d,0x35,0xde,0x41,0xca,0x7c, - 0x12,0x83,0x14,0xd4,0xb8,0x1f,0x59,0xe1,0xb8,0xc5,0xc7,0x0f,0xa3,0x0c,0x3a,0x95, - 0x4a,0x06,0x2d,0xe4,0xb1,0xd6,0x36,0x07,0xac,0x10,0x70,0xa7,0x8b,0x45,0xa0,0x80, - 0xa6,0x0e,0x8d,0x6f,0x9d,0x9e,0x63,0x28,0xec,0xbd,0x10,0x2a,0x83,0xc6,0xae,0x11, - 0x29,0x4e,0xba,0xdc,0x4d,0x53,0x01,0x1d,0xd6,0x23,0xcf,0x68,0xc6,0x88,0x06,0x40, - 0x0f,0x5a,0xd2,0x9d,0xb8,0xc2,0x7b,0xda,0x77,0xbd,0xa3,0x63,0xb7,0xe7,0xf6,0x1f, - 0x8b,0x5f,0xff,0xfa,0xef,0x02,0x46,0x86,0xf4,0x29,0xe8,0x10,0x94,0x28,0x0f,0x09, - 0xf8,0xa4,0x29,0x44,0x2f,0x49,0xa4,0x44,0x71,0x06,0x00,0x04,0xa5,0xca,0x51,0x3c, - 0x0b,0x93,0x96,0x80,0x55,0x27,0x32,0x9d,0xa8,0x69,0xd0,0xf2,0xf8,0xa1,0x00,0xb3, - 0xde,0xf5,0x85,0x18,0xe4,0xdf,0xaa,0x99,0x1b,0xce,0x61,0x76,0x80,0x0d,0x54,0x39, - 0x21,0xdd,0xcf,0x62,0x3a,0xc1,0x33,0xa6,0x00,0xc4,0xdd,0x3a,0xbd,0xc4,0x27,0x34, - 0xc5,0xfd,0x76,0x0f,0xd3,0x17,0xf5,0xb0,0x89,0x5e,0xa4,0x56,0x85,0x54,0xd3,0x18, - 0xd3,0xfd,0x54,0x4a,0x41,0xdf,0xc4,0xc8,0x88,0xeb,0x37,0x51,0xd4,0x78,0x58,0x93, - 0xe4,0xb3,0x6c,0x23,0x7a,0xfa,0xba,0x4e,0x68,0xa5,0xe6,0x2e,0xe9,0x79,0xd3,0x22, - 0x01,0xbc,0x34,0x15,0xfe,0x12,0x16,0x71,0xd8,0xa1,0xe7,0x1a,0x82,0x56,0x8e,0xd8, - 0xab,0x64,0x43,0x6c,0xb0,0x4b,0x33,0x86,0x50,0x67,0xeb,0xf4,0xe7,0x58,0x4d,0x45, - 0x58,0xb3,0x2f,0x48,0x36,0x91,0xd3,0x80,0x5b,0x59,0x4e,0x25,0x45,0xba,0x2e,0xb5, - 0x47,0x50,0x61,0x50,0x26,0x63,0xaa,0xbd,0x49,0xe4,0xab,0x5b,0x31,0x44,0x5a,0xe0, - 0xea,0x1d,0x01,0x35,0x6a,0x62,0x8e,0xa1,0xdf,0xa8,0x20,0xa4,0x46,0x5a,0x4f,0x33, - 0x51,0x48,0xa3,0x68,0x2e,0x1a,0xa6,0xa2,0x50,0x39,0x39,0xaa,0xc8,0x0a,0x71,0x36, - 0x8f,0xe2,0x8c,0xcf,0x46,0xc5,0xa5,0x54,0xf3,0xdc,0xd5,0x48,0xb0,0x62,0x69,0x1a, - 0x96,0x64,0x2a,0x84,0xc5,0x73,0xa5,0x6d,0x31,0x64,0x6d,0xf3,0x3c,0x6d,0x43,0xc8, - 0x17,0x27,0x72,0x76,0xfa,0xf3,0x34,0x83,0xc5,0xdc,0x56,0x46,0x45,0xe3,0x66,0xd2, - 0x17,0x67,0xe9,0x2d,0xe0,0xe6,0x49,0x17,0x3d,0x6c,0x57,0xd0,0x9a,0xa9,0xdc,0x89, - 0x26,0xa7,0xee,0x39,0x06,0xee,0xe4,0x65,0x56,0xf3,0xfa,0x2b,0x21,0xe8,0x05,0x95, - 0x22,0x89,0x4b,0xd8,0xa9,0x00,0x3f,0x27,0x04,0xd3,0x9b,0x87,0xbb,0x27,0x5d,0x6e, - 0x71,0x68,0x3d,0xc8,0x87,0xb4,0xfc,0xe1,0x2d,0x7d,0x2b,0x20,0x35,0x98,0x39,0xba, - 0xb8,0x26,0x08,0x9c,0x44,0xc9,0xb6,0x01,0x03,0x17,0x42,0x4b,0x91,0x3a,0x3d,0xd3, - 0x43,0xa0,0x30,0x85,0xeb,0x88,0xb5,0x4a,0x64,0xd8,0xf1,0xa3,0xe8,0x2e,0x07,0xa8, - 0xc7,0x9f,0x4f,0x43,0x2c,0x28,0x59,0x1b,0x3f,0x42,0xdb,0x7d,0xc7,0xbf,0x93,0x23, - 0x09,0xd3,0xa1,0x5a,0x2c,0x34,0x9e,0xaa,0x75,0x42,0xa9,0xda,0xa2,0x83,0x2f,0xf4, - 0x08,0x50,0xb7,0x8e,0x9f,0x5b,0xa1,0xf4,0x35,0x25,0x19,0xf1,0xeb,0x3f,0xff,0xab, - 0x39,0xff,0xde,0x8e,0xa8,0x3a,0x51,0x55,0x01,0xca,0xb8,0x4c,0x7d,0xcf,0x98,0x76, - 0x27,0x1c,0x96,0xad,0x75,0x47,0x42,0xe8,0x65,0xea,0x1a,0xfa,0x53,0xdc,0xad,0xdc, - 0x2e,0x3f,0x7d,0x5f,0x9b,0xf0,0x95,0x94,0xb4,0x63,0x10,0xbd,0x5f,0xff,0xfa,0x6f, - 0x5e,0xff,0x89,0xa8,0x8a,0x4a,0x02,0x00,0x50,0x70,0x5d,0x88,0xac,0x17,0x8b,0x8d, - 0x4a,0xe8,0xcd,0x25,0x6b,0x11,0x17,0x67,0x3f,0x73,0x89,0x9b,0x5a,0x48,0xe1,0x88, - 0xc1,0x40,0x35,0x71,0x1a,0xc2,0xe0,0x79,0x88,0x36,0x6b,0x3e,0x15,0x26,0x2b,0x8b, - 0x10,0x65,0x47,0x18,0x5b,0x07,0x04,0xaa,0x76,0x9f,0x01,0xf1,0x82,0x4d,0x56,0xfc, - 0x86,0x3f,0xdf,0xb5,0xc6,0x42,0x26,0x3b,0xd6,0xf8,0xce,0x54,0xc7,0xb0,0xf9,0xc1, - 0x52,0xd6,0xd7,0x79,0xc9,0x7c,0x3f,0xf2,0x44,0x57,0xfc,0xfa,0x2f,0xff,0xea,0xf1, - 0xa2,0xa8,0x9b,0x68,0x1f,0x1d,0x88,0xbf,0xfd,0x97,0x48,0x61,0xf1,0x2f,0xc1,0x0c, - 0xc5,0x16,0x72,0xe4,0x50,0xc1,0x43,0x72,0x81,0x51,0x09,0x99,0xa6,0x4c,0xc9,0x70, - 0x0b,0x7c,0xf9,0x94,0x27,0x31,0x36,0x37,0x80,0x64,0xe2,0x4d,0xf8,0xc9,0x11,0xef, - 0xa9,0x7a,0x7c,0x21,0x4b,0x20,0x51,0x2a,0xc5,0x8f,0xe6,0xd8,0x79,0xcd,0x78,0x7f, - 0xba,0xb5,0xa0,0x95,0x31,0x34,0x94,0x4a,0x67,0x11,0x3b,0x72,0x0e,0x60,0x21,0xaf, - 0x09,0x7c,0x93,0x1a,0xe0,0xcd,0xb7,0x9c,0x30,0x75,0x72,0x31,0x59,0x45,0x4b,0x80, - 0xb7,0xe2,0xc3,0xec,0x93,0x49,0xb6,0x40,0x63,0x08,0x9d,0xd4,0x26,0xa3,0x53,0x31, - 0xcb,0x20,0x8f,0x30,0xa1,0xb4,0x35,0xa7,0x2c,0x85,0x48,0x3a,0xa5,0xf8,0x6b,0xe8, - 0xec,0x36,0xf3,0x49,0x6b,0x1d,0x15,0x5c,0xb5,0x4e,0x5f,0x7b,0x55,0x42,0x6d,0x4e, - 0xcd,0xd5,0xf6,0x16,0x15,0x8c,0x83,0x56,0x8f,0xb3,0x10,0xf2,0x8f,0xd7,0x62,0xc9, - 0xe2,0x5b,0x4b,0x5b,0x5c,0xd0,0x3a,0x68,0x09,0x86,0x9d,0x57,0xd4,0x6d,0x9d,0x76, - 0xb1,0x99,0xab,0xef,0xc5,0x4d,0xff,0x0f,0xe2,0xc6,0xfb,0x43,0xb8,0x79,0xfc,0x07, - 0x71,0xd3,0xff,0x43,0xb8,0x39,0xf8,0x83,0xb8,0x79,0xfc,0x9b,0xb9,0xe1,0x63,0x0f, - 0x33,0xea,0x74,0x4c,0xc5,0x06,0x84,0xc7,0x63,0xed,0x8e,0x70,0x3c,0xf2,0x7e,0x0a, - 0x95,0x2b,0xc4,0x76,0xea,0x09,0x5f,0x78,0x6b,0x2d,0x63,0xaa,0x23,0x60,0x98,0x77, - 0x64,0xfa,0xd3,0x48,0xa2,0xb0,0x39,0xf5,0x4e,0x6f,0xaa,0x53,0xfc,0xd5,0xd7,0x73, - 0x7c,0x63,0x18,0x03,0x7c,0x33,0x90,0xbf,0x6e,0xaf,0x8d,0xda,0x5b,0x6b,0x68,0x3b, - 0xad,0x56,0xfa,0x7a,0x7d,0x05,0xe7,0xe7,0xa2,0x7f,0xb0,0x0e,0x3f,0x1b,0xa3,0x86, - 0x21,0xe4,0x78,0x12,0xe3,0x4e,0xbc,0xe3,0xee,0xb5,0x4e,0x1c,0x4d,0xc8,0x7a,0x7f, - 0x36,0xfa,0x5b,0x6c,0x1c,0xfe,0x7f,0xb0,0xf1,0x78,0x8b,0x8d,0xa3,0xff,0x0f,0x36, - 0x0e,0xb6,0xd8,0x78,0xf2,0xc7,0xb1,0x71,0x17,0x52,0xac,0x4d,0x48,0xe3,0xc6,0xef, - 0x29,0x1b,0x55,0x89,0xb4,0xa4,0x0c,0x49,0x68,0x89,0xb2,0x66,0x29,0x25,0x5f,0xcc, - 0x18,0xc6,0xdd,0xc2,0xc2,0xc3,0x42,0xd1,0xb9,0x30,0xd2,0x11,0x32,0x5e,0x8d,0x16, - 0xa9,0x8e,0x47,0xcd,0x34,0x8e,0x52,0xd8,0xa0,0xa2,0x60,0x61,0x78,0x78,0x25,0xa9, - 0x2e,0x2c,0x68,0x33,0x20,0xda,0x34,0x99,0x4f,0xd8,0x90,0xc0,0xa3,0x46,0x12,0xdf, - 0x23,0x19,0x20,0x45,0x61,0x3b,0x91,0x89,0xfd,0x70,0x96,0x0f,0x34,0x7e,0xa4,0x5b, - 0xef,0xab,0xc1,0x6f,0x30,0xd8,0x16,0xfa,0xe1,0x2d,0xcd,0x5f,0x1e,0x8f,0xae,0x80, - 0x2f,0x19,0x88,0x7d,0x09,0xb1,0x5c,0xde,0xc4,0x6a,0x34,0xad,0x91,0x8a,0xde,0x1e, - 0xd5,0xb9,0x1a,0x09,0xa8,0xf5,0xa5,0x44,0x45,0x49,0x5b,0xb3,0x43,0xc0,0x9f,0xc0, - 0x00,0xe3,0x90,0xc6,0xfe,0x69,0xa7,0x63,0x67,0xbf,0x19,0xbc,0xff,0x38,0x1e,0x1b, - 0x70,0xc3,0x8b,0xcb,0x20,0x67,0x82,0xeb,0x2c,0x5e,0x0d,0xbe,0x19,0xa1,0x33,0x48, - 0xf9,0xfa,0xa2,0x09,0x30,0x01,0x19,0xd4,0xab,0xe6,0x82,0x9d,0xb1,0x4c,0x85,0x3d, - 0xb7,0x9a,0x9e,0xbe,0x01,0x62,0x08,0x27,0x00,0x5a,0xf8,0x4e,0xd7,0xaf,0x52,0xd1, - 0x26,0x5c,0xc1,0x72,0xd7,0xa7,0x10,0xf5,0xbd,0x1f,0x21,0x9d,0xb6,0x06,0x60,0x74, - 0x97,0xba,0x99,0x9b,0x5d,0x50,0x6b,0x90,0x8d,0xd8,0x84,0x09,0x9c,0xf5,0xe1,0x60, - 0xb8,0xa4,0x26,0x46,0x75,0x1e,0xda,0x0e,0x1c,0x51,0x9d,0xfd,0xd5,0x00,0x8e,0x67, - 0x8b,0x01,0x73,0x23,0x31,0x4f,0x55,0x9c,0x00,0xdb,0x4d,0x69,0xcd,0xfc,0xcc,0x81, - 0x86,0x38,0x64,0xa9,0x7a,0x1f,0x71,0x11,0x63,0xeb,0xa1,0xb2,0xc2,0xaa,0x50,0xbb, - 0x3b,0x8b,0x23,0x03,0xd7,0xc5,0x4d,0x01,0x09,0xb3,0xf8,0x4a,0x42,0x35,0x66,0x72, - 0x60,0x3b,0xfd,0x7d,0x27,0xa7,0x07,0xc4,0xe9,0x93,0x9d,0x9c,0x56,0xa7,0x98,0x2b, - 0xec,0xb5,0xa3,0x1b,0xb3,0x49,0x9b,0xac,0x58,0x97,0xb5,0xd9,0xc6,0x81,0x80,0x4b, - 0xd2,0xdc,0x30,0xa3,0x5d,0xe9,0xce,0xb9,0x7f,0xa0,0x63,0x82,0x23,0x92,0xc1,0xd1, - 0x4a,0x4e,0xcf,0x35,0x8e,0xd4,0x84,0xdb,0xf8,0x16,0x88,0x9c,0xf6,0x0f,0x8e,0x98, - 0x52,0xad,0xe5,0x66,0x1a,0x27,0x92,0xbf,0xda,0xbb,0x87,0x70,0x67,0xc8,0xb1,0x4b, - 0xbc,0x4b,0x7a,0xae,0xf1,0x8e,0xd9,0xdf,0x16,0xd9,0x44,0xd0,0xe6,0x62,0x22,0x6b, - 0x4a,0x97,0x18,0x82,0xad,0x53,0xae,0xb7,0x41,0xb4,0xe1,0x89,0xd3,0x48,0x7e,0xaa, - 0xef,0x9b,0xfd,0x11,0xbd,0x02,0x80,0x61,0xeb,0x94,0xf1,0xb7,0xb2,0xaf,0x4d,0xb3, - 0x36,0xb0,0xd9,0x27,0x01,0xdd,0xc3,0xe9,0x1d,0x51,0xb2,0xbb,0x02,0x54,0x9f,0xbe, - 0x2f,0xc2,0xd1,0x15,0xc1,0x49,0xf6,0x01,0x46,0xce,0xb3,0x4c,0xf1,0xce,0x80,0x9c, - 0x91,0x40,0x79,0xe5,0x2a,0xb0,0x87,0x57,0xec,0xc2,0xa6,0xe7,0x6a,0xa8,0xe9,0x48, - 0x0f,0xc0,0xc0,0x88,0xd1,0x85,0x0d,0x29,0x15,0xe7,0xaa,0x48,0xba,0xe7,0xb3,0xa8, - 0xf3,0x06,0x68,0x18,0x58,0xf9,0x8a,0x1e,0x3e,0x09,0x8b,0x70,0x86,0x6c,0x0e,0x80, - 0xcc,0xcf,0x29,0x87,0xda,0xf2,0xb6,0x9d,0x4d,0x17,0x4f,0x4f,0xdf,0xbd,0x7f,0xdb, - 0xe1,0xd0,0xd0,0x3e,0xcb,0xf3,0x44,0xd2,0x57,0x9b,0x40,0xc1,0x09,0x4c,0xb8,0x90, - 0xe3,0xa0,0x35,0x55,0x2a,0x2f,0xfd,0x6e,0x77,0x82,0x4d,0xea,0x7c,0xe8,0x62,0x37, - 0xda,0x95,0xe9,0x64,0x36,0x4f,0x27,0xdd,0xc6,0x76,0xb3,0x3b,0x4c,0xb2,0x61,0x77, - 0x16,0xc6,0x69,0x37,0xca,0x46,0x65,0x17,0x26,0x1d,0x77,0x42,0xb3,0xdd,0x98,0x45, - 0xad,0xd3,0x92,0x36,0xd2,0x8e,0xde,0x08,0x0c,0x6f,0x0d,0xcc,0x08,0x4f,0xff,0xa8, - 0xa9,0xca,0x5c,0x8e,0x78,0x1e,0xbd,0x63,0x4e,0x15,0x84,0xa7,0x68,0x02,0xaa,0x4f, - 0xf0,0x42,0xf7,0x4c,0xd4,0xaa,0x5e,0xde,0xd8,0xab,0x9f,0xd1,0xfe,0x53,0x3b,0xae, - 0x1f,0x0c,0x07,0xc5,0xf9,0x0c,0x50,0xc8,0x05,0x7a,0x7f,0x91,0x48,0xfa,0xfa,0xfc, - 0xf6,0x55,0x84,0x1e,0xcb,0x3d,0x7a,0xca,0x9d,0xce,0x75,0x02,0x7e,0xcd,0x80,0xe2, - 0xc7,0x05,0xbd,0xa5,0x11,0x8c,0xc3,0xa4,0x94,0x4e,0x54,0x84,0x93,0x09,0x2c,0xc3, - 0x5c,0xd2,0xe6,0x80,0x37,0x2c,0x32,0x32,0x2d,0xf3,0x72,0xf8,0x33,0x3d,0xa5,0x5f, - 0x35,0x0c,0xf6,0xba,0x5d,0x0e,0x08,0x1c,0xb0,0x4c,0x29,0xb2,0xac,0x9f,0x28,0x82, - 0x39,0xd0,0x63,0x44,0xf3,0x94,0x2b,0x20,0x42,0x46,0xb1,0xd2,0x19,0x49,0x9f,0x48, - 0x89,0x9b,0x18,0x5b,0x0d,0xd8,0x27,0xf9,0x49,0x16,0x46,0x44,0xac,0x7e,0x18,0x09, - 0xeb,0xcf,0xf8,0x71,0xfd,0x71,0x5c,0xcc,0x6e,0xc2,0x42,0x1a,0xbf,0xc3,0xd6,0x91, - 0xaa,0x61,0x4c,0xd9,0x5d,0x09,0x00,0xba,0xe1,0x95,0xb4,0xaf,0xed,0xc5,0x6a,0x59, - 0x6f,0x5f,0xba,0xfc,0x16,0x4a,0x70,0xbd,0x24,0xe2,0x67,0x98,0xa8,0x9c,0x27,0x8a, - 0x78,0x2c,0xc3,0x38,0x12,0x43,0xec,0xc4,0x22,0x29,0x6a,0x81,0x67,0x89,0x8e,0x74, - 0xa3,0x70,0x4e,0x0e,0x1c,0x2b,0x5f,0x94,0x09,0xf8,0x43,0x16,0x11,0x6d,0x53,0xab, - 0xb1,0x1d,0x22,0x75,0xc5,0x4d,0x0c,0xff,0x6c,0xaa,0x4f,0x64,0xba,0x0b,0x3b,0x0d, - 0x36,0x87,0xef,0x24,0xc2,0xeb,0xb5,0xa4,0x47,0xb1,0xd4,0x94,0xe9,0xeb,0xc3,0x35, - 0xae,0x65,0xd0,0xe3,0x54,0x04,0x5c,0xa3,0x06,0xff,0x48,0xfb,0xaa,0xcd,0x86,0x5f, - 0x3a,0xd9,0xd5,0x7b,0xf9,0x49,0x39,0x34,0xb3,0xbd,0x80,0x2f,0x93,0xd2,0xa8,0x7a, - 0x1b,0x90,0x38,0x7e,0x7a,0xf7,0xfd,0xa5,0x0c,0x8b,0xd1,0xf4,0x2d,0x77,0x6e,0xdb, - 0x8e,0x4c,0x82,0x3f,0xb5,0xa9,0xf3,0xe7,0xcf,0x16,0xb8,0xb0,0xec,0x01,0xc6,0xc0, - 0xd9,0xda,0x34,0x8e,0x4e,0x5d,0xb5,0x43,0x95,0x36,0xd1,0xe0,0x17,0x28,0xae,0x1c, - 0xdd,0xf2,0xe1,0xea,0x23,0x77,0x5e,0x7b,0xa3,0x40,0xbf,0xe8,0x43,0x16,0x69,0x39, - 0x0b,0xb8,0xe1,0x34,0x8b,0x7c,0xeb,0xed,0x8f,0x97,0xef,0x2d,0x87,0x08,0xf8,0xf4, - 0xb1,0xdc,0xf1,0xce,0x81,0x21,0xb2,0x7a,0x83,0x60,0xfb,0xa5,0x17,0x5d,0xf3,0x19, - 0xb7,0x23,0x37,0xbb,0x0a,0x02,0x6d,0x42,0xf6,0x02,0x4a,0x2a,0xc3,0x5b,0x34,0x4a, - 0x7a,0x4d,0x01,0x8b,0x18,0x87,0x50,0x74,0x64,0x39,0x16,0x1a,0x2c,0x87,0x4e,0xe1, - 0x2b,0xcb,0x5e,0x32,0x85,0x9c,0xce,0x08,0x40,0x6f,0x00,0x52,0x5a,0x56,0xb6,0xa1, - 0x61,0x24,0x67,0x65,0x57,0xeb,0xe3,0x68,0x91,0x60,0x68,0x44,0xb6,0xdb,0x7c,0x19, - 0xc3,0x0c,0xb3,0xd2,0xcc,0xe8,0x67,0x55,0xb2,0xe2,0x2c,0xdb,0xe0,0x61,0x09,0x49, - 0x2d,0x1b,0xf6,0x06,0x8b,0xfe,0x7b,0xd2,0xd9,0x50,0xa5,0xec,0x47,0x46,0x6b,0xbc, - 0xc8,0x3f,0xb5,0x2d,0x5d,0xbe,0xb5,0x6c,0x57,0xef,0x94,0xe8,0x82,0x98,0xc0,0x84, - 0xf4,0xea,0x12,0x8f,0x62,0x95,0x2f,0xcc,0x81,0x7d,0xa9,0x96,0x15,0x85,0x1d,0x82, - 0x23,0x99,0xed,0xef,0x93,0xd8,0x1e,0x54,0x62,0xab,0x0d,0x5e,0x5f,0x6a,0xf6,0x60, - 0x98,0x3f,0xc2,0xe1,0x78,0x67,0xe5,0x9b,0x9d,0x14,0xd5,0x2e,0xf4,0xde,0x48,0x43, - 0x27,0x29,0xe8,0xb4,0xcf,0xa9,0xf7,0x4b,0x75,0x6d,0xc3,0xdc,0xe5,0xd3,0x29,0xb6, - 0x71,0xcf,0xf7,0xea,0x7c,0x1a,0x47,0x51,0x22,0x5d,0x41,0x2f,0x7b,0x00,0x59,0xaa, - 0xe2,0x96,0x7c,0xe8,0xc3,0x0c,0xce,0x14,0xe7,0x49,0x4c,0x86,0x8d,0xc8,0x14,0x97, - 0x59,0xf1,0x91,0x9e,0x20,0x4c,0x6e,0xd9,0xf1,0x63,0x0d,0x96,0xc4,0xa9,0xf0,0x5c, - 0x0e,0x3d,0xdf,0x9d,0xbd,0xfa,0xe1,0x32,0xf8,0xf0,0xe1,0xd8,0xf1,0x3e,0x3a,0x1f, - 0x0e,0xf8,0xf3,0x31,0x7f,0xf6,0xf9,0xd3,0x33,0x9f,0x7d,0xfe,0x7c,0xcc,0x9f,0x07, - 0xfc,0x79,0xc4,0x9f,0xc7,0xba,0x8f,0xbe,0xed,0x1d,0x7d,0xfc,0x38,0x58,0x69,0x64, - 0x02,0xab,0x78,0x45,0xa9,0xb2,0x0d,0xae,0x1c,0x70,0xc3,0xaa,0xa8,0xfc,0x20,0x0e, - 0x7a,0x83,0xf8,0x84,0xe7,0x77,0x75,0xed,0x7b,0x10,0x3f,0x7a,0x64,0x43,0xb0,0xdc, - 0xf6,0x21,0xfe,0xf8,0xa1,0xf7,0x11,0x36,0x89,0xb1,0xfb,0xfb,0x75,0x93,0x47,0x4d, - 0x44,0xca,0x18,0x52,0xdc,0xf0,0x16,0xf4,0x3c,0xf5,0x9e,0xf5,0xfc,0x26,0xcd,0x8e, - 0xb7,0x66,0x24,0xc4,0x12,0xa9,0xb4,0x3d,0xa9,0xdd,0x63,0x82,0x69,0x30,0xac,0x6d, - 0xfd,0xe3,0xbc,0xd7,0x8b,0x9e,0x58,0x8f,0xa8,0xc1,0xf6,0xdb,0x13,0xcc,0xc5,0xed, - 0x5e,0x97,0xda,0x3c,0xb4,0x59,0xbc,0xdd,0xb5,0x6c,0x56,0x6b,0xbf,0x57,0x2b,0x2c, - 0xe4,0xac,0x38,0x10,0x84,0xda,0xb4,0x8a,0x25,0xe1,0xb4,0x8c,0xfe,0x34,0x22,0x4a, - 0x91,0xdd,0xe8,0xb9,0x1d,0x60,0xbd,0x3a,0x8e,0x40,0xdb,0xef,0x31,0x3a,0xe8,0xf7, - 0xbe,0xa5,0x99,0xbb,0x34,0xd5,0xc0,0xdc,0x53,0x41,0x83,0xe1,0x47,0x96,0x20,0x16, - 0x87,0x4f,0x84,0xf5,0xa8,0xcd,0xef,0x5b,0xf1,0x93,0x28,0x6d,0x43,0xe0,0x5b,0xaf, - 0x67,0x77,0xf1,0xef,0x91,0xd5,0x25,0x6e,0x2c,0xa2,0x01,0x61,0xd2,0x54,0xea,0x51, - 0x70,0xc7,0x58,0x70,0xdc,0xdd,0x1a,0xaf,0x11,0xa6,0xd5,0x90,0xac,0x5a,0xf7,0x34, - 0x7e,0xde,0x14,0x81,0x6e,0x2b,0xbf,0xfd,0xd3,0x1c,0x40,0x5c,0xe3,0xa5,0xac,0x38, - 0x4b,0x92,0xb6,0xb5,0xeb,0xe9,0x4e,0x12,0x60,0x4d,0x2c,0x2c,0x0a,0xca,0x10,0x86, - 0xd2,0x59,0x51,0x84,0xb7,0x6e,0x5c,0xf2,0x5f,0xb4,0x3f,0xbb,0xf6,0x3f,0x5c,0x3b, - 0xfc,0xdf,0xc7,0xc6,0xa0,0x2a,0xde,0x90,0x0c,0x39,0x79,0x46,0x95,0xc4,0x90,0x34, - 0x82,0xc8,0x2d,0x54,0xfe,0x56,0x82,0xc5,0xd3,0xde,0x40,0x3b,0x3f,0x3b,0x7e,0xf3, - 0x95,0xc5,0x07,0xe8,0x44,0xa8,0x7a,0x26,0x9f,0x59,0xd9,0x78,0x6c,0xf9,0x6d,0x8c, - 0x84,0xb2,0xeb,0x73,0x1a,0xc2,0x10,0x90,0x55,0x4d,0x0b,0x51,0xd0,0xb2,0x6d,0x7a, - 0x93,0x12,0x1a,0x4e,0x91,0x92,0x75,0x54,0xaf,0x68,0x33,0xb4,0xfb,0x81,0xde,0xa5, - 0xb2,0xca,0xf9,0x90,0x46,0x82,0x12,0x05,0x88,0xd5,0x2c,0x57,0x96,0x6f,0xe9,0x41, - 0x9c,0xf3,0x65,0x18,0x21,0x4b,0x58,0xf4,0x57,0xb7,0xd2,0x2b,0x62,0x75,0x7f,0x7b, - 0xc1,0x6f,0x27,0x34,0xc8,0xf2,0x9b,0x11,0xc4,0xea,0xc0,0x8c,0xea,0x6c,0xae,0xc9, - 0x62,0xc8,0x45,0x99,0x94,0x93,0x9e,0xdc,0xe8,0x9e,0x6f,0x76,0x7f,0xcf,0x0e,0xc4, - 0x41,0x48,0x97,0xee,0xad,0x01,0x85,0x73,0x89,0xe8,0x45,0xcc,0x60,0x01,0x77,0x71, - 0x91,0x7e,0x81,0x89,0xf3,0x5a,0x82,0x70,0x80,0x0d,0x09,0xae,0x8e,0x60,0xf4,0x92, - 0x59,0x7e,0x77,0x30,0x57,0x17,0xb6,0x63,0x72,0x30,0x5e,0x1a,0xef,0xfd,0xa8,0xfe, - 0x29,0x81,0x51,0xc0,0x2e,0x11,0xa7,0x40,0xfd,0xcc,0x12,0xaf,0x94,0xc6,0x2e,0xf5, - 0x01,0x8c,0xc8,0xea,0x87,0xbb,0x5d,0x2d,0xf8,0x6a,0x6d,0x3b,0xd7,0x74,0xf7,0x82, - 0xcc,0x14,0x6d,0xeb,0x3c,0x4c,0x12,0xaa,0x83,0x5b,0x8f,0x74,0xd3,0x23,0xeb,0xd7, - 0xbf,0xfe,0xa7,0x05,0x93,0xf8,0xbe,0x36,0x89,0x2f,0xaf,0xa9,0x62,0xf6,0x15,0x1d, - 0xee,0x10,0x25,0x7d,0xb2,0x84,0x40,0x52,0xba,0xd8,0x74,0xd0,0x03,0x59,0x8c,0x75, - 0xaa,0x43,0xa3,0xae,0xf8,0x41,0xaa,0x9b,0xac,0xb8,0x12,0x95,0x62,0xeb,0x7d,0xaf, - 0x3e,0x26,0x2a,0x9b,0xe7,0x44,0xb4,0xca,0xf3,0xe6,0x31,0xcf,0x2d,0x55,0x8c,0x2b, - 0x79,0x33,0xcc,0xa1,0x14,0xc9,0x42,0x59,0x3b,0xb1,0x61,0x82,0xab,0xa3,0x30,0x16, - 0x23,0xbd,0x2c,0x4b,0xa7,0x04,0xda,0x22,0xc8,0x32,0x6b,0x08,0x67,0x6f,0xe7,0x52, - 0xbd,0x30,0xf2,0x10,0xe3,0x12,0xe6,0xe4,0x70,0x53,0xa7,0x5b,0xe7,0x88,0xac,0x41, - 0xf0,0x09,0x05,0xb6,0x49,0xac,0xf8,0xfa,0xc8,0xb2,0x6b,0x3f,0x59,0xa3,0xc4,0x67, - 0xe7,0xae,0x39,0x7d,0x0f,0xcc,0x30,0x74,0xa5,0x47,0xac,0xaa,0x79,0x69,0x5d,0x1b, - 0x93,0x62,0x82,0x38,0x07,0x6b,0x1d,0x0b,0x91,0xcd,0xe7,0x09,0xc9,0x8c,0x3e,0x7f, - 0x3e,0xec,0xf5,0x0e,0x28,0xd8,0x91,0xa3,0x8b,0xc6,0x4e,0xa0,0xa2,0x45,0xdb,0xb9, - 0x2d,0x05,0x8e,0xf4,0xb9,0x13,0x72,0x50,0xef,0x99,0x05,0x19,0x21,0x6e,0xa0,0x23, - 0x9b,0x84,0xb9,0x55,0x31,0x3e,0x4a,0xd5,0x36,0x27,0xc5,0xa7,0xcf,0x9f,0x39,0xc2, - 0x56,0xc1,0x45,0x71,0x43,0x15,0x13,0x10,0xab,0x31,0x47,0x36,0x57,0x54,0xd3,0x01, - 0xae,0xb0,0x80,0xc2,0x2b,0x76,0xf8,0x60,0x68,0x3d,0xd0,0x8c,0xa6,0xa0,0x81,0x56, - 0x48,0x0f,0x1e,0xd9,0x10,0x1a,0x1f,0xb0,0xdc,0xd5,0x17,0x1d,0xa9,0x7b,0x3d,0x29, - 0xb2,0x66,0x19,0x50,0x14,0xd6,0x33,0xcf,0x93,0xcf,0x9f,0x3d,0x9b,0xb2,0x75,0xa3, - 0xf5,0x22,0xbe,0xa6,0xd6,0x6a,0x48,0x99,0x04,0x75,0x0e,0x68,0x42,0xdb,0x12,0x29, - 0xbd,0x3c,0x39,0x18,0x94,0xc8,0xe3,0x35,0xb4,0x7c,0x50,0xed,0x61,0xec,0x32,0xf9, - 0x50,0x7e,0x34,0x26,0xb3,0x86,0x0d,0x4a,0xb4,0xf3,0x8c,0xf8,0x6b,0xe2,0x02,0x91, - 0x9b,0x04,0x3a,0xed,0xeb,0xf7,0xd2,0xdb,0x8d,0xe1,0xf6,0x47,0xdd,0x4b,0x37,0x01, - 0x3a,0x43,0xbe,0x3f,0xd0,0x91,0xcd,0x5a,0x02,0x42,0xf6,0xd9,0xb4,0x86,0xf5,0x14, - 0xcc,0x20,0x74,0x6f,0x9d,0xcb,0xfd,0xfd,0x07,0x8d,0x5d,0x96,0x59,0x06,0x2f,0x8e, - 0xe4,0x48,0xcc,0xba,0xec,0xb6,0x2b,0x34,0x38,0x73,0x56,0x3b,0xbd,0x99,0x06,0x2c, - 0x25,0x12,0x39,0x82,0x3f,0xad,0x28,0x36,0x80,0x85,0x9a,0x96,0x8d,0xa0,0x87,0x39, - 0x48,0x43,0xfa,0x9c,0x27,0x20,0xe2,0x15,0x37,0xbc,0x49,0x80,0x21,0xdc,0x9d,0x54, - 0x1f,0xf2,0x2e,0x47,0x3f,0xad,0xb7,0xbe,0xb9,0xd0,0xa0,0xea,0x80,0x91,0xd4,0x8a, - 0xf5,0xeb,0x40,0xab,0xf1,0x6d,0x56,0x7e,0xfe,0xfc,0xe1,0xe8,0xc0,0xa9,0xfe,0xff, - 0x68,0x83,0x3f,0xcd,0xd3,0x15,0xb1,0xba,0x29,0xbf,0xb8,0xf6,0x3b,0xfd,0x36,0x30, - 0x19,0x50,0xbb,0x7d,0xdd,0x05,0x6c,0x00,0x5c,0x20,0x4b,0xfe,0x86,0x02,0x60,0xef, - 0x1b,0xeb,0x0b,0x44,0xdc,0xeb,0x0d,0x25,0x10,0x95,0x6b,0xdf,0xfa,0xdb,0x7f,0x69, - 0x90,0xb2,0xd4,0x46,0x5b,0x55,0x5d,0xb7,0x5c,0xbd,0xb2,0xda,0xa6,0xa3,0x93,0x01, - 0xd7,0xc2,0x7b,0xb0,0x4a,0x9b,0x84,0x53,0x1a,0xce,0x92,0xa5,0x5c,0xc1,0x08,0xd6, - 0x37,0x18,0x8c,0xf7,0x29,0xaa,0x18,0xc0,0x6f,0x1c,0xce,0xb7,0x68,0xd0,0xd2,0xb1, - 0xb4,0x74,0x4b,0xa9,0xc3,0x62,0x5c,0xe8,0xb3,0x15,0xec,0x3c,0xae,0x78,0x33,0x67, - 0x2f,0x07,0x7b,0x0d,0x27,0xfb,0x8d,0x73,0xd0,0xa0,0xf5,0x39,0x8a,0xb5,0x63,0xcf, - 0xcd,0x69,0x8c,0xa1,0x60,0x16,0x2e,0x2f,0x35,0xa7,0x69,0x56,0x03,0x54,0x31,0x97, - 0xb4,0xfb,0xa2,0x29,0x2b,0xc1,0xe8,0x57,0xe8,0xaf,0x57,0xbe,0x0a,0x13,0xd4,0xfe, - 0x33,0xd8,0xe1,0x5e,0xd7,0x70,0x2a,0xe6,0xd5,0x00,0xe2,0x85,0x8e,0x06,0x3e,0x5d, - 0x2e,0xfd,0x85,0x8e,0x02,0x3e,0xa1,0x53,0xb0,0x5f,0x9f,0x48,0xd2,0x4b,0x72,0x9c, - 0xe7,0x1b,0x40,0xb5,0x5a,0xc1,0x12,0x2b,0xd0,0x80,0x2e,0x2f,0x32,0x95,0x11,0x02, - 0xac,0x5e,0x4f,0xe7,0x5f,0x63,0x68,0xd7,0x8c,0x35,0x7e,0x71,0x21,0x61,0xbb,0x95, - 0x09,0x56,0xcc,0xc0,0xb1,0xb9,0xe0,0xba,0x1a,0xc2,0xab,0xad,0x02,0xcc,0x96,0x37, - 0x55,0xeb,0x66,0x59,0xe8,0xf5,0xfe,0x56,0x36,0xe8,0x77,0x0a,0x4c,0xa4,0xba,0x5e, - 0xda,0xcb,0x6a,0x2a,0xc2,0x9a,0xb6,0x06,0x9f,0xfc,0x75,0x39,0xa8,0x78,0xdd,0xd6, - 0xce,0x7a,0xe9,0x66,0x97,0xc4,0x57,0xfc,0x55,0xf1,0x8c,0xf7,0x03,0xc1,0xd7,0x15, - 0x50,0xaf,0xfd,0xc1,0xf6,0xe2,0x73,0x0e,0x0b,0x41,0x73,0x8e,0x89,0x54,0x67,0x4a, - 0x15,0xf1,0x10,0x39,0xb7,0x6d,0xe9,0xf3,0x41,0x18,0xd8,0x23,0x4f,0xd3,0xd1,0x95, - 0x0e,0x67,0x5b,0x90,0xcf,0x6a,0x3d,0xc3,0xfd,0x5e,0x5b,0x8f,0x34,0x69,0xbd,0x11, - 0xe9,0x7b,0x4f,0xfb,0x77,0xa8,0x1d,0x4e,0x68,0xb3,0xed,0xb2,0x97,0xee,0x92,0x8d, - 0xf1,0x89,0xd4,0x6f,0x9a,0xeb,0x33,0xcb,0xc3,0x34,0x3d,0x78,0xc7,0x7a,0xab,0x39, - 0x63,0xc0,0x3d,0xfd,0x0d,0x98,0xd6,0xb1,0xb2,0x6a,0xaa,0x26,0x1a,0xd9,0x61,0x32, - 0xf5,0xa6,0x9c,0x4c,0xc6,0xf4,0xe7,0x47,0xc7,0xb8,0xbb,0x7e,0x64,0x2a,0x68,0xfe, - 0xde,0x87,0x84,0x95,0x20,0xc0,0xa7,0xea,0x42,0x8e,0x43,0x6c,0xa6,0x91,0xe1,0xf4, - 0x0f,0x51,0x6c,0xc1,0x1e,0x17,0x12,0x9d,0xe9,0x04,0x58,0xd7,0x1e,0x98,0xf3,0xcd, - 0xf8,0x47,0x77,0x2d,0xdb,0x99,0x3a,0xd3,0x67,0x96,0xae,0xbb,0xd1,0x63,0x15,0xb1, - 0x41,0x81,0x58,0x17,0x3f,0x15,0x25,0xa3,0x6a,0x3d,0x2b,0xcc,0xb3,0x23,0xb6,0x18, - 0x2b,0xdc,0xdf,0xa7,0x4f,0x02,0x41,0xf6,0xda,0xe4,0x4e,0xd5,0xec,0xec,0x9a,0xca, - 0x4c,0x50,0x3f,0x85,0xb5,0x7b,0x8a,0x75,0x82,0x16,0xa4,0x6d,0x18,0x64,0x62,0xf4, - 0x50,0x9b,0x79,0x1c,0xa4,0xf9,0x5c,0x1b,0xd3,0xe6,0x6a,0x47,0xa3,0x9e,0x68,0x95, - 0xf4,0x7b,0x39,0xd2,0xa7,0xda,0x78,0xcc,0xa7,0x01,0xb9,0xae,0xe3,0xe9,0xca,0x21, - 0x75,0x54,0xe1,0x90,0x90,0xad,0x7e,0x2e,0xd0,0xa9,0xb0,0x28,0x51,0xaa,0x9e,0xa4, - 0xa2,0x07,0x48,0xa6,0x71,0xee,0x70,0x5d,0xd2,0x1c,0x34,0x4d,0xa4,0x73,0x9f,0xc7, - 0xe6,0xdd,0x3d,0x84,0x62,0xfe,0xfd,0x90,0x66,0x0d,0xcd,0x03,0xf4,0x73,0x36,0x8b, - 0x39,0x76,0xb5,0xa7,0x24,0xdb,0xc5,0xec,0x3f,0x5d,0x3e,0xd7,0x25,0x6c,0xf9,0x29, - 0xa6,0x47,0x4a,0xb8,0x84,0x12,0x53,0xe9,0x9b,0x7f,0x5f,0x04,0x13,0x67,0x40,0xb3, - 0xe8,0xa5,0x77,0x25,0xa6,0xc6,0xfa,0xe3,0xfb,0xef,0xe8,0x28,0x70,0x74,0xe5,0x98, - 0xd3,0x3f,0xfa,0x5d,0x0e,0x22,0xc7,0xcc,0x96,0x99,0xae,0x3e,0x86,0x54,0xbd,0x89, - 0x7f,0x2a,0x87,0x82,0x1f,0x22,0x75,0xc5,0x6b,0x29,0x73,0xd3,0x19,0xd8,0x5d,0x96, - 0x53,0x3d,0x1d,0x55,0x9d,0x20,0x3b,0xaa,0x78,0x33,0x66,0x96,0x4c,0x74,0x92,0xf1, - 0xb2,0xcc,0x4f,0xb0,0x34,0x7f,0x12,0xc5,0x80,0x87,0x79,0x50,0xba,0x66,0x82,0x1a, - 0x45,0x03,0x27,0xb8,0x5a,0xc6,0xc1,0x83,0x79,0xb5,0xdb,0x9c,0x9b,0x2a,0x4a,0x7d, - 0x5d,0x17,0x93,0xed,0x45,0xa3,0xb0,0xcc,0xd1,0x16,0x33,0x36,0x7e,0x0e,0xa6,0x0d, - 0x11,0xf6,0xec,0x65,0x5d,0xa6,0x98,0xbb,0xb3,0x0c,0x19,0x98,0xdc,0x75,0x73,0x07, - 0x3d,0x5f,0xa1,0x5e,0x88,0xaf,0x6a,0x53,0x55,0x1b,0xfd,0xce,0x0a,0xe5,0xf6,0xd5, - 0x93,0x70,0xa6,0xea,0xc5,0xcb,0xae,0xaa,0x18,0x34,0xe0,0xa2,0xc8,0xf2,0x1c,0xac, - 0x71,0x35,0x83,0xe9,0x34,0x5a,0x41,0x29,0xd2,0xdf,0x56,0x7b,0x90,0xe1,0xd6,0x5e, - 0x4e,0x6d,0xdc,0x5b,0x01,0xe4,0xc6,0x02,0xf4,0x46,0x5d,0x07,0xb1,0xc6,0xaf,0xa5, - 0x74,0xf5,0xe3,0xba,0x5d,0xfd,0x63,0x50,0xff,0x0b,0x75,0x03,0x36,0xe6,0x1d,0x4a, - 0x00,0x00, + 0x1f,0x8b,0x08,0x00,0x00,0x00,0x00,0x00,0x02,0xff,0xc5,0x5c,0xdd,0x72,0xdb,0x46, + 0x96,0xbe,0xd7,0x53,0xb4,0xe9,0x89,0x40,0xc4,0x20,0x48,0xd0,0x94,0x2c,0x83,0x02, + 0x3d,0xb2,0x64,0x65,0xbd,0xb6,0x13,0x97,0xe5,0x54,0xaa,0xe2,0xd5,0x05,0x48,0x34, + 0x49,0x8c,0x40,0x00,0x0b,0x80,0x92,0x35,0x14,0xab,0xe6,0x6a,0x6b,0xaf,0x67,0xb7, + 0x6a,0xdf,0x60,0xef,0xe7,0x6a,0xef,0x67,0xdf,0x24,0x4f,0xb2,0xdf,0x39,0xdd,0x00, + 0xc1,0x1f,0xd9,0x4a,0x36,0x55,0x53,0x89,0x29,0xa2,0xd1,0x7d,0xfa,0xf4,0xf9,0xfd, + 0xfa,0x34,0xc0,0xe3,0x47,0x41,0x32,0x2a,0x6e,0x53,0x29,0xa6,0xc5,0x2c,0x1a,0xec, + 0x1d,0xd3,0x1f,0x11,0xf9,0xf1,0xc4,0x6b,0xc8,0xb8,0x41,0x0d,0xd2,0x0f,0x06,0xc7, + 0x33,0x59,0xf8,0x62,0x34,0xf5,0xb3,0x5c,0x16,0x5e,0x63,0x5e,0x8c,0x5b,0x47,0x74, + 0x33,0x1f,0x65,0x61,0x5a,0x88,0x3c,0x1b,0x79,0x8d,0x76,0x3a,0x6e,0x8d,0x92,0x38, + 0x4f,0x22,0x69,0xff,0x29,0x7f,0x31,0xf5,0xe4,0x78,0x3c,0x0e,0x0e,0x3a,0x07,0x8d, + 0xc1,0x71,0x5b,0x75,0x1c,0xe8,0x01,0x83,0x70,0xdc,0x7c,0x74,0x13,0xc6,0x41,0x72, + 0x63,0xbf,0x3f,0x37,0x9b,0xe3,0x79,0x3c,0x2a,0xc2,0x24,0x6e,0x9a,0x8b,0x6b,0x3f, + 0x13,0x3f,0x79,0xea,0x9e,0xf5,0xa3,0x67,0xb4,0xfd,0x34,0x6c,0xe7,0x85,0x5f,0xcc, + 0x73,0xc3,0x7a,0xeb,0x7d,0xba,0xb4,0xde,0x79,0x5d,0xf9,0xdc,0xba,0xb0,0x7e,0xb6, + 0xde,0x7b,0x3f,0x61,0xb8,0xb7,0xa0,0xdb,0xd2,0x35,0xa2,0xf0,0x5a,0x1a,0x56,0x10, + 0x66,0xc5,0xad,0xfb,0xc8,0xb1,0xae,0xdd,0x78,0x1e,0x45,0xd6,0x44,0x16,0xee,0x77, + 0x56,0x18,0x8f,0xa3,0x70,0x32,0x2d,0xdc,0xef,0xe7,0xb3,0xa1,0xcc,0xac,0x69,0x12, + 0x05,0xee,0x0f,0xc3,0x3f,0xc9,0x51,0x61,0xe5,0xfe,0xad,0x5b,0x71,0x50,0x58,0x57, + 0x96,0x34,0x17,0x72,0x7f,0xbf,0x29,0xed,0x42,0x7e,0x2e,0x4e,0x93,0xb8,0x90,0x71, + 0xe1,0x15,0xe6,0xd2,0x1a,0xce,0xf3,0x5a,0xd7,0xa1,0x95,0x9a,0x8b,0xf2,0x4a,0x8c, + 0xc1,0xfc,0xd0,0x0e,0xc2,0xdc,0x1f,0x46,0x32,0xf0,0x1e,0x39,0xcb,0xfa,0x55,0xa7, + 0x9f,0xda,0xc5,0x54,0xc6,0xcd,0xb1,0x35,0x36,0xfb,0x99,0x2c,0xe6,0x59,0x2c,0xd2, + 0xa5,0x95,0x26,0x51,0xb4,0xa2,0x98,0x5b,0x33,0x6b,0x64,0x25,0x4a,0x0a,0x85,0x35, + 0xb6,0x26,0x9e,0xd3,0xaf,0x66,0xf8,0x80,0x19,0x26,0xfb,0xfb,0x8f,0xc6,0xe0,0x6d, + 0xec,0x39,0xd6,0x28,0x92,0x7e,0xf6,0x31,0x9c,0xc9,0x64,0x5e,0x34,0x0b,0xd3,0x6a, + 0xe6,0xf6,0xc8,0x8f,0xa2,0x17,0x79,0xd3,0x74,0xbf,0x6b,0xe6,0xa6,0xa9,0x67,0x2c, + 0xa9,0x07,0xe6,0x62,0xd4,0x0c,0x2c,0x92,0x0a,0xd6,0x52,0x35,0x4b,0x6a,0x66,0x51, + 0x49,0x73,0xa9,0xc7,0xbc,0xb1,0xde,0x98,0xe6,0xb2,0x9a,0xf9,0x0d,0x66,0x1e,0x7b, + 0x9d,0xfe,0xc6,0x8c,0x7d,0x70,0xd3,0x2c,0x3c,0x98,0x44,0xd9,0xf6,0xc1,0x9a,0x61, + 0x5c,0xb2,0xbf,0x9f,0xd8,0xe3,0x30,0xcb,0x0b,0xcf,0x83,0x20,0x5e,0x60,0xb8,0x0b, + 0xe6,0xf5,0xb2,0xa1,0xae,0x24,0x75,0x6b,0x2a,0x9f,0x6c,0x53,0x5e,0x5a,0x20,0xba, + 0xea,0x13,0x9b,0x8b,0x99,0x17,0xf7,0xc7,0x77,0x77,0x20,0xb5,0xb4,0xe2,0xe4,0xc6, + 0xfd,0xb0,0x44,0x1f,0x36,0x8b,0x55,0xb7,0x11,0x56,0xb2,0xbf,0xff,0xd6,0x4e,0xe7, + 0xf9,0x14,0x17,0xfd,0x8b,0x17,0xb8,0x1c,0x35,0x2f,0x4c,0xf7,0xe7,0x17,0x3f,0xdb, + 0x18,0x05,0x3e,0x7e,0xf6,0xde,0xdb,0x24,0xf4,0xe6,0x8f,0xd6,0x3b,0xeb,0x0c,0xc4, + 0x6e,0xfc,0x62,0x34,0xbd,0xd8,0xa0,0x34,0x33,0x17,0xef,0x6d,0x45,0x1e,0x7c,0xff, + 0x6c,0x83,0x9b,0xe6,0x3b,0xef,0x9d,0x5f,0x4c,0xed,0x59,0x88,0xdb,0xd6,0x3b,0x93, + 0x87,0x86,0xc5,0x79,0x92,0x9d,0xc9,0xeb,0x70,0x24,0x57,0x83,0xa1,0xbf,0xc4,0x4b, + 0xee,0xee,0x16,0xcb,0x52,0xd1,0xb1,0xbc,0x11,0xef,0xb3,0x64,0x16,0xe6,0x72,0xa5, + 0x8e,0x5b,0x2b,0x56,0x8a,0x9e,0xae,0x58,0xea,0xca,0xa7,0x2b,0xc5,0xe4,0xe6,0x22, + 0x87,0xba,0x9b,0x89,0x3d,0x9c,0x87,0x51,0xb0,0xbf,0x9f,0xab,0x2f,0x9e,0xa7,0x5b, + 0x4c,0x88,0x7f,0x6a,0x93,0x38,0x9b,0xa6,0x75,0x4b,0x1a,0x5f,0x9a,0xfd,0x9a,0x36, + 0x6a,0x32,0x2e,0xbb,0xf5,0xe3,0xe6,0xab,0x2c,0x4b,0xb2,0xa6,0x51,0xa8,0x4e,0x06, + 0x2d,0x24,0xb1,0xf5,0xd5,0xdd,0xdd,0x73,0xd9,0x03,0x95,0xe5,0x72,0x65,0x76,0xdf, + 0x35,0xe7,0x64,0x92,0x7a,0x29,0x63,0x09,0x71,0x71,0xcb,0x86,0x75,0x65,0xe6,0x82, + 0xbc,0x3a,0xb3,0x93,0x2b,0xb3,0x98,0x66,0xc9,0x8d,0x50,0x13,0x65,0x5a,0x8e,0x95, + 0xd5,0xb3,0x75,0x90,0x67,0xbd,0xc8,0xf8,0x0f,0x94,0x92,0x21,0x60,0x10,0x9b,0xcb, + 0x9a,0xcd,0x9d,0xd1,0xfa,0x41,0x11,0x9f,0x17,0xf0,0xf3,0x74,0xac,0x74,0xe4,0xe5, + 0xfd,0xb7,0xf6,0x38,0xc9,0x5e,0xf9,0x60,0xa3,0x9a,0x7c,0x0c,0xfb,0x44,0x4f,0x62, + 0x7c,0x69,0x36,0xcd,0x2a,0xde,0xec,0xa9,0xc0,0x15,0xfb,0x33,0xe9,0x35,0xae,0x43, + 0x79,0x93,0x26,0x59,0xd1,0x10,0x23,0xed,0xd3,0x8d,0x9b,0x30,0x28,0xa6,0x5e,0xc0, + 0x0a,0x6c,0xf1,0x05,0x22,0x45,0x58,0x84,0x7e,0xd4,0xca,0xe1,0x4b,0xd2,0x73,0x28, + 0xca,0x15,0x61,0x11,0xc9,0xc1,0x7b,0xbf,0x28,0x64,0x86,0x38,0x82,0xa5,0xb5,0xc4, + 0xbb,0xd7,0x67,0xaf,0x8f,0xdb,0xea,0x0e,0xe2,0x60,0x71,0x4b,0x7f,0xdd,0x2c,0x49, + 0x8a,0x45,0xab,0x35,0xca,0xa4,0x3f,0x73,0x1f,0x77,0x4e,0x3b,0x2f,0x3b,0xcf,0xfb, + 0xad,0x56,0x18,0x5f,0xb9,0x8f,0x5f,0x9d,0xbd,0x7a,0x76,0xf6,0x12,0x57,0xb3,0x79, + 0x21,0x03,0xf7,0xf1,0xd1,0xc9,0x51,0xf7,0x59,0x17,0xd7,0x63,0x3f,0x8c,0x0b,0xf7, + 0xf1,0xc1,0xc9,0xc1,0x41,0xef,0xb0,0xbf,0xd7,0x6a,0x65,0xf3,0x48,0xba,0x8f,0xbb, + 0xbd,0xae,0xe3,0x1c,0xf5,0xd5,0x65,0x2b,0x4f,0xc6,0xe8,0xe3,0xbc,0x74,0x9e,0x3b, + 0x3d,0xb4,0x45,0x44,0xe1,0xfc,0xfc,0xe0,0xb4,0xfb,0x0a,0x57,0x09,0xc8,0x1f,0x3c, + 0x7b,0x79,0xf4,0xec,0x1c,0x17,0x37,0x7e,0x16,0xbb,0x8f,0xcf,0x9e,0x9f,0x74,0x9e, + 0x9e,0x13,0xb5,0xd4,0x8f,0x65,0x84,0xa1,0x4f,0x1d,0xc7,0xe9,0x50,0x43,0xee,0xc7, + 0xb9,0x6b,0xbc,0x86,0x08,0x32,0xc3,0x9a,0x87,0x7c,0xdd,0xca,0x65,0x16,0x8e,0xad, + 0xfc,0x36,0x2f,0xe4,0xac,0x35,0x0f,0xad,0x55,0x23,0x0d,0x99,0x25,0x71,0xe2,0x1a, + 0xff,0x2c,0x8b,0x97,0x19,0x98,0xcd,0xc5,0x3b,0x5c,0xf3,0x58,0xba,0x91,0xa7,0xfe, + 0x48,0x5a,0x17,0xe7,0xd4,0xd8,0xfa,0x20,0x27,0xf3,0xc8,0xcf,0xac,0x77,0x32,0x8e, + 0x12,0xab,0xba,0xbd,0xdc,0xfb,0x76,0x31,0x4c,0x3e,0xb7,0xf2,0xf0,0xcf,0x61,0x3c, + 0x71,0x87,0x49,0x16,0xc8,0xac,0x85,0x96,0xe5,0xde,0x30,0x09,0x6e,0x17,0x33,0x3f, + 0x9b,0x84,0xb1,0xdb,0xe9,0x0f,0xfd,0xd1,0xd5,0x24,0x4b,0xe6,0x71,0xe0,0xc2,0x37, + 0x9a,0x5a,0x96,0x66,0x7f,0x94,0x44,0x49,0xa6,0x9b,0x20,0x4e,0xb3,0x3f,0x86,0x0e, + 0x21,0xba,0x59,0x18,0xdd,0xea,0x66,0x62,0xd9,0xec,0xef,0x45,0x61,0x2c,0x5b,0x53, + 0xc9,0xd1,0xde,0xb1,0x0f,0xfa,0xad,0x1b,0x39,0xbc,0x0a,0xd1,0x97,0x06,0xe4,0x33, + 0x68,0x68,0x4a,0x2c,0xf8,0x31,0xa9,0x39,0xf4,0x73,0x19,0x2c,0xf7,0xec,0x9b,0xcc, + 0x4f,0xc1,0xc4,0x67,0x65,0x04,0xee,0xe1,0x51,0x27,0xfd,0xdc,0x2f,0x99,0x12,0xfe, + 0xbc,0x48,0xfa,0xa9,0x1f,0x04,0x34,0xf0,0x69,0x37,0xfd,0x2c,0xba,0xb8,0x2f,0x0e, + 0x7b,0x29,0xf8,0xa7,0x04,0x29,0xb3,0x05,0x62,0x7d,0x1a,0x51,0x26,0x89,0xe4,0xe7, + 0x3e,0x08,0x4f,0xe2,0x56,0x08,0x59,0xe6,0xee,0x48,0x92,0xa4,0xfb,0x13,0x3f,0x75, + 0x8f,0x40,0x54,0x93,0xc1,0xda,0x8b,0x22,0x99,0xb9,0x0e,0xa8,0xf5,0xf7,0x2a,0x79, + 0xa8,0x36,0xd0,0x46,0x1a,0x0d,0x03,0xa1,0x16,0x46,0x06,0x60,0x82,0xc9,0x00,0xc6, + 0xa5,0xf8,0x7b,0x86,0x41,0x7a,0x89,0xf4,0x75,0x4b,0x68,0x30,0x0f,0xb3,0xaf,0x89, + 0x66,0x7e,0x10,0x22,0xb6,0x39,0xcc,0xab,0xb3,0x50,0x62,0x08,0xff,0x2c,0x5d,0xe7, + 0x00,0x43,0xf9,0xf2,0x46,0x91,0x3a,0xec,0x74,0xaa,0x35,0xf7,0x69,0x1d,0xae,0x83, + 0x59,0xf3,0xf9,0x70,0xb1,0x2d,0x6c,0xd2,0xac,0x56,0x82,0x22,0x06,0xfa,0x6b,0x4a, + 0x62,0xab,0x06,0xd7,0xb9,0x64,0x17,0xd5,0x1a,0x6e,0x51,0xec,0xef,0x1e,0x32,0x2f, + 0xdd,0xc5,0xc6,0xf0,0x4d,0x5e,0x22,0x49,0x1e,0xd7,0x22,0x03,0x22,0xc1,0xdb,0x9d, + 0xe7,0x72,0xd6,0xa7,0x90,0xd1,0x2a,0x32,0xe8,0x1a,0x51,0x60,0xe6,0xce,0xd3,0x54, + 0x66,0x23,0x68,0xb1,0xbf,0x57,0x9f,0x9d,0x7d,0xcc,0x5c,0x69,0xb0,0x23,0x9c,0x0e, + 0x4d,0x6a,0x23,0xcf,0x04,0x0b,0x25,0x99,0xdd,0x72,0xde,0x16,0x26,0x3b,0x90,0x59, + 0xe9,0xdf,0x81,0xd6,0x85,0x73,0x58,0x51,0x13,0xc3,0x4a,0xf7,0xc3,0x28,0x19,0x5d, + 0xd5,0x85,0xd2,0xdb,0x5e,0x55,0x39,0x2a,0x2d,0x6d,0x9e,0xc8,0x81,0xc1,0xfa,0xb0, + 0xee,0x86,0x2c,0xf5,0x6a,0xd6,0x2d,0xbb,0x77,0xa0,0x49,0xd9,0x90,0xae,0xd6,0x75, + 0x7d,0x10,0xe2,0x70,0xd5,0x61,0x3c,0xde,0xd5,0x43,0xad,0x37,0x21,0xf1,0x02,0x32, + 0xd9,0x87,0xe8,0x3e,0xcf,0x87,0xeb,0x96,0x4c,0x66,0xeb,0xac,0x9c,0x81,0xf5,0xc7, + 0xd7,0xa5,0x1d,0x53,0x03,0xd9,0xb5,0xa6,0xcf,0xf7,0x77,0xc8,0x95,0x03,0x18,0xbc, + 0xf3,0x37,0x58,0x92,0x5a,0xbd,0x62,0x4e,0xc0,0x18,0x62,0x97,0xb1,0x45,0x6b,0x34, + 0x45,0x0e,0x5c,0xec,0xb2,0x39,0x7b,0x34,0x4d,0x10,0xd1,0xab,0x95,0x4c,0xb2,0x30, + 0x58,0xad,0x84,0xae,0x5a,0x70,0x4d,0xdc,0x2a,0x24,0x09,0x64,0x3e,0x43,0x40,0x74, + 0xc6,0x99,0xc0,0x3f,0x1e,0xbc,0xa1,0xd0,0xff,0x9f,0xb9,0x50,0xb8,0x50,0x5e,0x3e, + 0x9a,0x67,0x39,0x78,0x4d,0x93,0x90,0x23,0x02,0x5b,0x32,0x47,0x0a,0x37,0x92,0xe3, + 0x62,0x2d,0xa2,0x85,0xf1,0x14,0xb1,0xb7,0xd0,0x72,0xd0,0x57,0xcc,0x9b,0x3b,0x4d, + 0xae,0x11,0x6e,0x76,0xa8,0xb3,0x92,0xd3,0x68,0x7a,0x8f,0x49,0xe8,0xb0,0x80,0x30, + 0x3c,0xf5,0x81,0xaf,0x41,0x17,0x68,0x81,0x8c,0x8f,0xfc,0x03,0x6c,0xae,0x7a,0x31, + 0x95,0x2f,0x59,0xf6,0xd3,0x7b,0x63,0x47,0x19,0xc5,0xba,0xec,0x21,0xa0,0x92,0xae, + 0xa2,0xfb,0xaf,0xb6,0x71,0x8a,0x3f,0x32,0xce,0xeb,0xd1,0xc3,0xe1,0xa8,0xcb,0xcd, + 0xc2,0x46,0xc3,0xfd,0x91,0x77,0x88,0xb0,0x40,0xf4,0x2a,0xd5,0xd7,0x87,0x89,0xe1, + 0xe2,0xcb,0xcb,0x59,0xeb,0x1c,0xf9,0x43,0x19,0x95,0x5c,0x90,0xb6,0x5c,0xce,0x09, + 0x5f,0x5f,0xcf,0x96,0x33,0x21,0x70,0xec,0x4a,0x0f,0xeb,0xb6,0xb1,0x3d,0xb7,0x08, + 0xe3,0x74,0x5e,0x2c,0xfc,0x11,0xf5,0xdf,0x52,0xea,0x72,0xaf,0xfd,0xad,0xf8,0x38, + 0x95,0x02,0xc9,0x32,0x00,0x8e,0x13,0xca,0xaa,0x45,0x98,0x63,0x9c,0x08,0x8b,0x5c, + 0x24,0x37,0xc0,0x6e,0xe0,0xd6,0xc8,0xb1,0x8d,0x73,0x05,0xb0,0x9b,0x88,0x92,0x78, + 0x22,0xf3,0x82,0xc7,0xc0,0x52,0xa9,0x73,0xf7,0x68,0x4f,0x08,0xd1,0x74,0xda,0xce, + 0xa1,0xf0,0x0b,0x71,0xd4,0xb3,0x9f,0x09,0x02,0x6d,0xb9,0x69,0x89,0x34,0x9a,0xe7, + 0x02,0x00,0x67,0x06,0x3a,0x99,0xf0,0xc5,0x18,0x7b,0x0d,0x32,0xfe,0x92,0x2c,0x32, + 0x93,0xcc,0xc4,0xff,0xfe,0x97,0xf0,0xe3,0x40,0xfc,0xfd,0x7f,0x6c,0xa2,0xf4,0xbd, + 0x0f,0x0c,0x78,0x83,0xfd,0x16,0x78,0x80,0xeb,0x86,0x11,0x98,0x15,0xa9,0x8f,0x49, + 0x89,0x81,0xd4,0x9f,0x48,0x21,0x83,0x89,0xec,0x0b,0x00,0x3e,0x5f,0xa4,0xd3,0x24, + 0x96,0xd4,0x73,0x92,0xc8,0x5c,0xc0,0xa3,0x40,0x0f,0xfd,0x88,0x50,0x1e,0x31,0x75, + 0x18,0x6c,0x41,0x01,0x4d,0x7c,0xdb,0xde,0xb3,0x27,0x1b,0xfe,0xbd,0xd3,0xad,0xbb, + 0x88,0x4d,0x02,0x98,0x1d,0xb9,0xbd,0xd9,0xb1,0xe0,0xe0,0xa6,0x78,0xda,0x1d,0x4d, + 0xef,0xcb,0xd0,0xac,0xc6,0xd2,0x75,0x0f,0x28,0x32,0x3f,0x34,0x64,0x41,0x65,0x13, + 0x61,0x5f,0xed,0x8e,0x47,0x13,0xa5,0xbe,0x4f,0xb4,0xd3,0xf6,0x90,0xbb,0x26,0xf2, + 0x52,0x67,0x72,0xa7,0xd3,0xf9,0x66,0x95,0x74,0xef,0x51,0x6f,0xbf,0x48,0xe6,0xa3, + 0x69,0xcb,0xe7,0x4c,0xea,0x22,0xc0,0xb4,0x6e,0xd5,0x74,0xd9,0x62,0x87,0xc9,0xd5, + 0xe2,0x4a,0x46,0xc6,0xdc,0xbf,0x99,0x62,0x9d,0x9c,0x46,0xa5,0x8b,0x5d,0x10,0xa0, + 0x0e,0xd9,0x17,0xe1,0xdb,0x75,0xdf,0xf9,0xd3,0x3c,0x2f,0xc2,0xf1,0x6d,0x4b,0xc3, + 0x63,0x97,0x47,0xb4,0x86,0xb2,0xb8,0x91,0x32,0xae,0x00,0xcc,0x03,0xc4,0xd1,0xd9, + 0x89,0x05,0xca,0x65,0x76,0x55,0xc2,0x13,0x3d,0xf6,0xc8,0x3f,0xce,0x64,0x10,0xfa, + 0xcd,0x15,0xf6,0xea,0x11,0xf6,0x32,0x17,0x50,0xef,0x43,0x35,0xda,0x87,0x8d,0xb5, + 0x88,0xbd,0xce,0x52,0x09,0x85,0x07,0xaa,0xfe,0x6e,0x77,0x33,0xce,0x2e,0xb1,0xf8, + 0xab,0x38,0x19,0xe6,0x0f,0x31,0x9f,0x4c,0xa6,0xd2,0x2f,0x9a,0x3d,0x35,0xcf,0xce, + 0x64,0xa8,0xc2,0x11,0x51,0xfc,0x12,0xae,0xd0,0xf9,0xef,0x6b,0xd9,0x82,0xd6,0x46, + 0xff,0x74,0xb4,0x22,0xaa,0xc2,0x8e,0x17,0xbf,0x5d,0xe6,0x0f,0x57,0x70,0x35,0xdd, + 0xd0,0xcf,0x16,0x3a,0x02,0xf7,0x76,0x81,0x4b,0x95,0xf7,0x6a,0x22,0x60,0x58,0x9b, + 0xe4,0x21,0x5b,0x67,0x26,0x21,0xc0,0xf0,0x5a,0xd6,0xc9,0x89,0x70,0x51,0xdd,0xf7, + 0x87,0x10,0x0d,0x0c,0xb5,0xcf,0x51,0xb4,0xd3,0x27,0x02,0x80,0xfd,0x2a,0x67,0x74, + 0xee,0x81,0xb2,0xca,0x32,0x0e,0x3a,0xdf,0x54,0x54,0xaf,0x1f,0x22,0x93,0xa7,0xeb, + 0xaa,0x52,0x98,0x6d,0x1c,0x4a,0xc0,0x86,0x87,0xc0,0xf5,0xb5,0x60,0x70,0x48,0x56, + 0x5b,0x0e,0xd7,0xf9,0x80,0x31,0x32,0x27,0x4f,0xa7,0xf2,0x8c,0xd5,0xcc,0x3b,0x73, + 0xb3,0x1a,0xae,0x82,0xb9,0x82,0xd8,0x7d,0xd8,0xb2,0x36,0x7d,0x95,0x20,0xab,0xe4, + 0xff,0x80,0x15,0x32,0xae,0x28,0x59,0xc4,0x56,0x40,0x3c,0xdf,0xa5,0x31,0x6d,0x64, + 0x5b,0x7b,0xa8,0xbd,0x2f,0x23,0x9b,0xb5,0xfd,0x43,0x77,0x25,0x3c,0xc5,0xbe,0x3b, + 0x4e,0x46,0xf3,0x7c,0x81,0x6c,0x43,0x39,0x16,0x81,0x05,0x89,0xf6,0x1e,0xcc,0xb1, + 0x31,0xd0,0x85,0xe4,0x47,0x92,0x4a,0x70,0x32,0xdb,0x1d,0x2f,0x55,0xa4,0xcb,0xd7, + 0xb5,0x44,0x1f,0x2d,0x8a,0x5e,0x2e,0x7d,0xdc,0xab,0xb4,0x0d,0xff,0x3c,0x52,0x69, + 0xdf,0xbf,0x96,0x16,0xd5,0x0d,0x26,0xb2,0xb0,0xec,0xc9,0x34,0xc9,0x8b,0xc5,0xb6, + 0xac,0x57,0x32,0xad,0x6b,0x9d,0x1b,0xb6,0x84,0xb1,0x23,0x63,0x63,0x8e,0xfb,0x42, + 0x80,0x42,0x5e,0x3b,0x6d,0xbb,0x2e,0x00,0xad,0xa8,0x6d,0x24,0xa2,0x38,0x7e,0x28, + 0x10,0x65,0x5d,0x6c,0x6a,0x9b,0x94,0xc0,0xeb,0xff,0xed,0x64,0x56,0x56,0xcc,0x84, + 0x14,0x14,0xd5,0xe2,0xbc,0x1f,0x97,0x6e,0xad,0xb2,0x02,0x98,0x72,0x74,0xb5,0xbd, + 0xdb,0x38,0xda,0x0d,0x90,0xbe,0xea,0x5c,0x5b,0x9e,0xca,0xf4,0xbf,0x86,0x9b,0xec, + 0x38,0x29,0xe4,0xe2,0x0b,0x38,0x6e,0x3d,0x77,0x1d,0x71,0xee,0xe2,0x7d,0xdd,0x8e, + 0xdd,0x18,0xd1,0x02,0xf4,0x0a,0xa4,0x15,0x04,0xfc,0xf7,0xb7,0x6c,0x9c,0xb5,0xae, + 0x66,0xf9,0x64,0xb1,0xb9,0xe7,0x7a,0x20,0x35,0x0a,0x28,0x25,0x63,0x87,0xf7,0x05, + 0x22,0xd0,0xb7,0xd3,0x71,0x4b,0x66,0xeb,0x2e,0xc8,0x52,0x29,0x6f,0x26,0xeb,0x70, + 0x86,0x77,0x94,0x9f,0xa6,0x61,0x10,0xc8,0xf8,0xb2,0x52,0x1c,0x59,0xc9,0xa3,0x70, + 0x46,0x95,0x35,0x3f,0x2e,0x2a,0x24,0x3a,0x9a,0x66,0xc9,0x4c,0x32,0xdc,0x0c,0x53, + 0x91,0xfb,0xb7,0x40,0x88,0x53,0x19,0x6b,0xe0,0x07,0x33,0x17,0x54,0x91,0xcc,0x81, + 0x16,0x73,0x00,0x44,0xe8,0xad,0xaf,0x40,0x29,0x81,0xc3,0x6b,0x3f,0x9a,0x03,0x01, + 0x06,0xe1,0x8c,0x91,0x1e,0x9d,0x49,0x30,0x3b,0xe3,0x31,0x09,0x5d,0x3c,0xce,0x0b, + 0x6b,0xb3,0x8d,0x37,0xba,0x5b,0xad,0x41,0xb4,0xdd,0x51,0xe5,0xfe,0x6a,0xd7,0x4b, + 0x8a,0x0b,0xa2,0x5a,0x01,0xea,0x21,0xfb,0x58,0xc8,0x0f,0x98,0xe3,0xa1,0x3b,0x8f, + 0x7a,0x40,0xa1,0x18,0xfd,0xc0,0x92,0x4f,0x39,0x55,0x50,0x65,0x8a,0xaf,0xfb,0x40, + 0xb9,0x0c,0x8c,0x0a,0x36,0xb6,0x5d,0x5f,0x4f,0x24,0x9b,0xc0,0x11,0xab,0xdc,0x65, + 0x01,0x05,0x9d,0x69,0xd4,0xe1,0xeb,0xca,0xe7,0x23,0x3f,0xcd,0xa5,0x5b,0x7e,0xd9, + 0xa0,0x8f,0x91,0xd3,0xc5,0xce,0x4d,0x6f,0x6d,0x0f,0xb9,0x81,0x68,0xb6,0x4a,0x40, + 0x47,0x0f,0x2e,0x01,0x69,0xc7,0x2d,0x05,0xdf,0xd3,0x82,0x7f,0xb8,0xdc,0x8b,0x60, + 0x51,0x0f,0x29,0xbf,0x66,0x70,0x1f,0xa1,0xb0,0x08,0x81,0xb0,0xf5,0x52,0x61,0x4d, + 0x3b,0xbd,0xb0,0x08,0xd6,0x6a,0x19,0xbf,0x31,0x5a,0xec,0x00,0xf8,0x1a,0x31,0x39, + 0xdf,0x54,0x75,0x9a,0x4c,0xc5,0x03,0x46,0xab,0xe3,0x04,0x81,0x2a,0xab,0x87,0x97, + 0xa7,0xdd,0x8d,0x92,0x4e,0x3d,0xe9,0xdd,0xe7,0x0b,0xbf,0xad,0x9c,0x53,0x26,0x79, + 0x7f,0xb1,0x4b,0x22,0xe4,0xae,0x9f,0x02,0xbf,0xf0,0x5b,0x88,0x06,0x33,0xe9,0xf1, + 0xa9,0xdd,0xe5,0xaa,0xa6,0x7e,0xde,0x7b,0x75,0xfe,0xea,0xb0,0xaf,0xaf,0xbb,0x54, + 0x04,0x3f,0x3f,0x3d,0x3f,0x41,0xc3,0x70,0x52,0xbb,0xab,0x8b,0xde,0xd5,0x4d,0xae, + 0xc0,0x3b,0x27,0xce,0x11,0x57,0xcf,0x75,0x05,0xfe,0xf0,0xe5,0xe1,0xc1,0xc1,0xd1, + 0xaa,0x02,0xff,0xfc,0xe4,0x79,0xef,0x88,0x46,0xab,0x84,0xf6,0xf8,0x55,0xe7,0xec, + 0xf9,0xe9,0x69,0xbf,0x2c,0xc8,0x9f,0x3d,0x3f,0x73,0x4e,0xbb,0xeb,0x05,0xf9,0x57, + 0x47,0xaf,0xba,0x67,0x87,0x3b,0x0b,0xf2,0xdd,0xf3,0xa3,0x93,0x83,0x83,0xaa,0x20, + 0xff,0xf2,0xe8,0xc8,0xe9,0x76,0x88,0x71,0x3f,0x0b,0x6a,0x9c,0x8d,0x27,0x25,0x63, + 0x5f,0xd8,0x0b,0xe9,0x7a,0xd6,0xbd,0x95,0xab,0xa5,0x8e,0x6a,0x5f,0xdc,0xc4,0x74, + 0x79,0x13,0xb3,0x85,0x80,0xb9,0xb4,0xb3,0x5c,0xc3,0xb5,0xeb,0x55,0x9f,0xf5,0xb2, + 0x0e,0x19,0xd0,0x1a,0x8a,0x5d,0x05,0x02,0xec,0xac,0x8e,0xdb,0xea,0x28,0xe4,0xb8, + 0xad,0xce,0x8d,0xa9,0xb8,0x3f,0x38,0x0e,0xc2,0x6b,0x31,0x42,0x64,0xcf,0xbd,0x06, + 0x19,0x67,0x79,0xac,0x2c,0xb3,0xc1,0x31,0xd5,0xf3,0xca,0x7b,0x41,0x52,0xf0,0x59, + 0x31,0x9a,0x06,0xc7,0x53,0x67,0xa0,0xce,0x58,0xf0,0x65,0xad,0x57,0x3e,0x1f,0x36, + 0x44,0x18,0xe0,0x0b,0x7a,0xb7,0xca,0xee,0x6d,0x4d,0x70,0x6f,0xef,0x58,0x57,0x9c, + 0x07,0x7b,0x42,0x1c,0x4f,0xbb,0x83,0x0b,0x99,0xe7,0xb8,0x44,0x8f,0x2e,0x37,0xd5, + 0x98,0xa1,0x9c,0xa1,0x68,0xf1,0x37,0xb0,0x5b,0x5d,0xb4,0x14,0xf1,0xe1,0xe0,0x38, + 0x5d,0xb5,0xa5,0xc4,0x5e,0x4a,0x54,0xd6,0xe9,0xcc,0x73,0xcd,0x12,0x7f,0x51,0x09, + 0x52,0x31,0x3d,0xf8,0xf1,0xe2,0x65,0xc9,0x22,0x2f,0x42,0xf7,0x6a,0xad,0xf1,0x0e, + 0x52,0xfa,0x93,0x18,0xa4,0xa0,0xc6,0xfd,0xc8,0x0a,0xc7,0x0d,0x3e,0x7d,0x18,0x25, + 0xd0,0xa9,0x2c,0xa4,0xd7,0x40,0x1e,0x6b,0x6c,0x73,0xc0,0x0a,0x01,0x77,0xaa,0x56, + 0x04,0x0a,0x68,0x6a,0xd1,0xf8,0xc6,0xe0,0x14,0x43,0x61,0xef,0x99,0x28,0x12,0x68, + 0xec,0x1a,0x91,0xe2,0xb8,0xcd,0xdd,0x14,0x15,0xd0,0x61,0x3d,0xf2,0x8c,0x7a,0x8c, + 0xa8,0x01,0x74,0xaf,0x21,0xed,0x89,0x2d,0x9c,0xe7,0x5d,0xdb,0x39,0x3c,0xb2,0x3b, + 0x76,0xf7,0xa9,0xf8,0xe5,0x2f,0xff,0x29,0x60,0x64,0x48,0x9f,0x82,0xce,0x40,0x89, + 0xf2,0x90,0x80,0x4f,0x1c,0x43,0xf4,0x92,0x44,0x4a,0x14,0x67,0x00,0x40,0x50,0xaa, + 0x1c,0x85,0x33,0x3f,0x6a,0x08,0x58,0x75,0x24,0xe3,0x49,0x31,0xf5,0x1a,0x0e,0x3f, + 0x13,0xa0,0xd7,0xbb,0xbe,0x10,0x8d,0xfc,0x1b,0x15,0x73,0xc3,0x39,0xcc,0x0e,0xb0, + 0x81,0x2a,0x27,0xa4,0xfb,0x59,0x48,0x07,0x78,0xda,0x14,0x80,0xb8,0x1b,0x83,0x0b, + 0x7c,0x42,0x53,0xdc,0x6f,0xf7,0x30,0x75,0x51,0x0d,0x9b,0xa8,0x45,0x2a,0x55,0xc8, + 0x62,0x1a,0x62,0xba,0x1f,0x73,0x29,0xe8,0x9b,0x18,0x69,0x71,0xfd,0x2a,0x8a,0x0a, + 0x0f,0x2b,0x92,0x7c,0x94,0xad,0x45,0x4f,0x5f,0xd7,0x09,0xad,0xd4,0xdc,0x26,0x3d, + 0x6f,0x5a,0x24,0x80,0x97,0xa2,0xc2,0x5f,0xfc,0x2c,0xf4,0x5b,0xf4,0x58,0x83,0xd7, + 0x48,0x11,0x7b,0x0b,0x59,0x13,0x1b,0xec,0x52,0x8f,0x21,0xd4,0xd9,0x18,0xfc,0x14, + 0x16,0x53,0xe1,0x57,0xec,0x0b,0x92,0x4d,0x60,0xd5,0xe0,0x56,0x92,0x52,0x45,0x91, + 0xae,0x73,0xe5,0x11,0x54,0x17,0x94,0xd1,0x98,0x6a,0x6f,0x12,0xf9,0xea,0x56,0x0c, + 0x91,0x16,0xb8,0x7a,0x47,0x40,0x8d,0x9a,0x98,0x63,0xe8,0x37,0xc8,0x08,0xa9,0x91, + 0xd6,0xe3,0x44,0x64,0x52,0x2b,0x9a,0x6b,0x86,0xb1,0xc8,0x8a,0x94,0x1c,0x55,0x24, + 0x99,0x38,0x99,0x07,0x61,0xc2,0x47,0xa3,0xe2,0x42,0x16,0xf3,0xd4,0x56,0x48,0xb0, + 0x64,0x69,0xea,0xe7,0x64,0x2a,0x84,0xc5,0xd3,0x42,0xd9,0xa2,0xcf,0xda,0xe6,0x79, + 0x9a,0x9a,0x90,0x2b,0x8e,0xe5,0x6c,0xf0,0xd3,0x34,0x81,0xc5,0xdc,0x96,0x46,0x45, + 0xe3,0x66,0xd2,0x15,0x27,0xf1,0x2d,0xe0,0xe6,0x71,0x1b,0x3d,0x4c,0x5b,0xd0,0x9a, + 0xa9,0xda,0x89,0x26,0xab,0xea,0x39,0x06,0xee,0xe4,0x65,0x96,0xf3,0xba,0x2b,0x21, + 0xa8,0x05,0xe5,0x22,0x0a,0x73,0xd8,0xa9,0x00,0x3f,0xc7,0x04,0xd3,0xeb,0x67,0xbb, + 0xc7,0x6d,0x6e,0xb1,0x68,0x3d,0xc8,0x87,0xb4,0xfc,0xe1,0x2d,0x7d,0xcb,0x20,0x35, + 0x98,0x39,0xba,0xd8,0x3a,0x08,0x1c,0x07,0xd1,0xb6,0x01,0x03,0x17,0x42,0x4b,0x41, + 0x31,0x38,0x51,0x43,0xa0,0xb0,0x02,0xd7,0x01,0x6b,0x95,0xc8,0xb0,0xe3,0x07,0xc1, + 0x7d,0x0e,0x50,0x8d,0x3f,0x9d,0xfa,0x58,0x50,0xb4,0x36,0x7e,0x84,0xb6,0x87,0x8e, + 0xff,0x20,0x47,0x12,0xa6,0x43,0xb5,0x58,0x68,0x3c,0x2e,0xd6,0x09,0xc5,0xc5,0x16, + 0x1d,0x7c,0xa1,0x27,0x80,0xda,0x55,0xfc,0xdc,0x0a,0xa5,0x6f,0x28,0xc9,0x88,0x5f, + 0xfe,0xed,0xaf,0xfa,0xf8,0x7b,0x3b,0xa2,0xaa,0x44,0x55,0x06,0x28,0xed,0x32,0xd5, + 0x3d,0x6d,0xda,0x2d,0x7f,0x98,0x37,0xd6,0x1d,0x09,0xa1,0x97,0xa9,0x2b,0xe8,0x4f, + 0x71,0xb7,0x74,0xbb,0x74,0xf0,0xb1,0x32,0xe1,0x2b,0x29,0x69,0xc7,0x20,0x3a,0xbf, + 0xfc,0xe5,0x3f,0x9c,0xee,0x33,0x51,0x16,0x95,0x04,0x00,0xa0,0xe0,0xba,0x10,0x59, + 0x2f,0x16,0x1b,0xe4,0xd0,0x9b,0x4d,0xd6,0x22,0xce,0x4e,0x7e,0xe2,0x0a,0x37,0xb5, + 0x90,0xc2,0x11,0x83,0x81,0x6a,0xc2,0xd8,0x87,0xc1,0xf3,0x10,0x65,0xd6,0x7c,0x28, + 0x4c,0x56,0x16,0x20,0xca,0x8e,0x30,0xb6,0x0a,0x08,0x54,0xed,0x3e,0x01,0xe2,0x05, + 0x9b,0xac,0xf8,0x0d,0x7f,0xbe,0x6f,0x8d,0x99,0x8c,0x76,0xac,0xf1,0x83,0xae,0x8e, + 0x61,0xf3,0x83,0xa5,0xac,0xaf,0xf3,0x82,0xf9,0x7e,0xe2,0x88,0xb6,0xf8,0xe5,0xdf, + 0xff,0xea,0xf0,0xa2,0xa8,0x9b,0x68,0x1e,0xf6,0xc4,0xdf,0xff,0x26,0x62,0x58,0xfc, + 0x39,0x98,0xa1,0xd8,0x42,0x8e,0xec,0x17,0xf0,0x90,0x54,0x60,0x54,0x44,0xa6,0x29, + 0x63,0x32,0xdc,0x0c,0x5f,0x3e,0xa7,0x51,0x88,0xcd,0x0d,0x20,0x99,0x78,0xe7,0x7f, + 0xb6,0xc4,0x47,0xaa,0x1e,0x9f,0xc9,0x1c,0x48,0x94,0x4a,0xf1,0xa3,0x39,0x76,0x5e, + 0x33,0xde,0x9f,0x6e,0x2d,0x68,0x65,0x0c,0x35,0xa5,0xd2,0x51,0xc4,0x8e,0x9c,0x03, + 0x58,0xc8,0x6b,0x02,0xdf,0xa4,0x06,0x78,0xf3,0x2d,0x27,0x4c,0x95,0x5c,0x74,0x56, + 0x51,0x12,0xe0,0xad,0xf8,0x30,0xf9,0xac,0x93,0x2d,0xd0,0x18,0x42,0x27,0xb5,0xc9, + 0x60,0x20,0x66,0x09,0xe4,0xe1,0x47,0x94,0xb6,0xe6,0x94,0xa5,0x10,0x49,0xa7,0x14, + 0x7f,0x35,0x9d,0xdd,0x66,0x3e,0x69,0xac,0xa3,0x82,0xab,0xc6,0xe0,0x8d,0x53,0x26, + 0xd4,0xfa,0xd4,0x5c,0x6d,0x6f,0x50,0xc1,0xd8,0x6b,0x74,0x38,0x0b,0x21,0xff,0x38, + 0x0d,0x96,0x2c,0xbe,0x35,0x94,0xc5,0x79,0x8d,0x5e,0x43,0x30,0xec,0xbc,0xa2,0x6e, + 0xeb,0xb4,0xb3,0xcd,0x5c,0xfd,0x20,0x6e,0xba,0xbf,0x13,0x37,0xce,0xef,0xc2,0xcd, + 0xd3,0xdf,0x89,0x9b,0xee,0xef,0xc2,0x4d,0xef,0x77,0xe2,0xe6,0xe9,0xaf,0xe6,0x86, + 0x8f,0x3d,0xf4,0xa8,0xc1,0x98,0x8a,0x0d,0x08,0x8f,0x47,0xca,0x1d,0xe1,0x78,0xe4, + 0xfd,0x14,0x2a,0x57,0x88,0x6d,0xe0,0x08,0x57,0x38,0x6b,0x2d,0x63,0xaa,0x23,0x60, + 0x98,0x73,0xa8,0xfb,0xd3,0x48,0xa2,0xb0,0x39,0xf5,0x4e,0x6f,0xaa,0x52,0xfc,0xd5, + 0xd7,0x73,0x7c,0x6d,0x18,0x03,0x7c,0x3d,0x90,0xbf,0x6e,0xaf,0x8d,0xda,0x1b,0x6b, + 0x68,0x3b,0x2e,0x57,0xfa,0x66,0x7d,0x05,0xa7,0xa7,0xa2,0xdb,0x5b,0x87,0x9f,0xb5, + 0x51,0x43,0x1f,0x72,0x3c,0x0e,0x71,0x27,0xdc,0x71,0xf7,0x5a,0x25,0x8e,0x3a,0x64, + 0x7d,0x38,0x1b,0xdd,0x2d,0x36,0x0e,0xfe,0x11,0x6c,0x3c,0xdd,0x62,0xe3,0xf0,0x1f, + 0xc1,0x46,0x6f,0x8b,0x8d,0x67,0xbf,0x1f,0x1b,0xf7,0x21,0xc5,0xca,0x84,0x14,0x6e, + 0x7c,0x4b,0xd9,0xa8,0x4c,0xa4,0x39,0x65,0x48,0x42,0x4b,0x94,0x35,0x73,0x29,0xf9, + 0x62,0xc6,0x30,0xee,0x16,0x16,0xee,0x67,0x05,0x1d,0x0b,0x23,0x1d,0x21,0xe3,0x55, + 0x68,0x91,0xea,0x78,0xd4,0x4c,0xe3,0x28,0x85,0xf5,0x4b,0x0a,0x06,0x86,0xfb,0x57, + 0x92,0xea,0xc2,0x82,0x36,0x03,0xa2,0x49,0x93,0xb9,0x84,0x0d,0x09,0x3c,0x2a,0x24, + 0xf1,0x16,0xc9,0x00,0x29,0x0a,0xdb,0x89,0x44,0xec,0xfb,0xb3,0xb4,0xaf,0xf0,0x23, + 0xdd,0xfa,0x58,0x0e,0x7e,0x87,0xc1,0xa6,0x50,0xcf,0x6e,0x29,0xfe,0xd2,0x70,0x74, + 0x05,0x7c,0xc9,0x40,0xec,0x4b,0x88,0xe5,0xe2,0x26,0x2c,0x46,0xd3,0x0a,0xa9,0xa8, + 0xed,0x51,0x95,0xab,0x91,0x80,0x1a,0x5f,0x4a,0x54,0x94,0xb4,0x15,0x3b,0x04,0xfc, + 0x09,0x0c,0x30,0x0e,0xa9,0xed,0x9f,0x76,0x3a,0x76,0xf2,0xab,0xc1,0xfb,0x0f,0xe3, + 0xb1,0x06,0x37,0xbc,0xb8,0x04,0x72,0x26,0xb8,0xce,0xe2,0x55,0xe0,0x9b,0x11,0x3a, + 0x83,0x94,0xaf,0x2f,0x9a,0x00,0x13,0x90,0x41,0xb5,0x6a,0x2e,0xd8,0x69,0xcb,0x2c, + 0xb0,0xe7,0x2e,0xa6,0x83,0x77,0x40,0x0c,0xfe,0x04,0x40,0x0b,0xdf,0xe9,0xfa,0x75, + 0x2c,0x9a,0x84,0x2b,0x58,0xee,0xea,0x14,0xa2,0xba,0xf7,0x03,0xa4,0xd3,0x54,0x00, + 0x8c,0xee,0x52,0x37,0x7d,0xb3,0x0d,0x6a,0x35,0xb2,0x01,0x9b,0x30,0x81,0xb3,0x2e, + 0x1c,0x0c,0x97,0xd4,0xc4,0xa8,0xce,0x41,0x5b,0xcf,0x12,0xe5,0xd9,0x5f,0x05,0xe0, + 0x78,0xb6,0x10,0x30,0x37,0x10,0xf3,0xb8,0x08,0x23,0x60,0xbb,0x29,0xad,0x99,0x9f, + 0x39,0x50,0x10,0x87,0x2c,0x55,0xed,0x23,0xce,0x42,0x6c,0x3d,0x8a,0x24,0x33,0x4a, + 0xd4,0x6e,0xcf,0xc2,0x40,0xc3,0x75,0x71,0x93,0x41,0xc2,0x2c,0xbe,0x9c,0x50,0x8d, + 0x9e,0x1c,0xd8,0x4e,0x7d,0xdf,0xc9,0x69,0x8f,0x38,0x7d,0xb6,0x93,0xd3,0xf2,0x14, + 0x73,0x85,0xbd,0x76,0x74,0x63,0x36,0x69,0x93,0x15,0xaa,0xb2,0x36,0xdb,0x38,0x10, + 0x70,0x4e,0x9a,0x1b,0x26,0xb4,0x2b,0xdd,0x39,0xf7,0xf7,0x74,0x4c,0x70,0x48,0x32, + 0x38,0x5c,0xc9,0xe9,0xa5,0xc2,0x91,0x8a,0x70,0x13,0xdf,0x3c,0x91,0xd2,0xfe,0xc1, + 0x12,0x53,0xaa,0xb5,0xdc,0x4c,0xc3,0x48,0xf2,0x57,0x73,0xf7,0x10,0xee,0x0c,0x39, + 0xb6,0x89,0x77,0x49,0x8f,0x35,0xde,0x33,0xfb,0xfb,0x2c,0x99,0x08,0xda,0x5c,0x4c, + 0x64,0x45,0xe9,0x02,0x43,0xb0,0x75,0x4a,0xd5,0x36,0x88,0x36,0x3c,0x61,0x1c,0xc8, + 0xcf,0xd5,0x7d,0xbd,0x3f,0xa2,0x37,0x00,0x30,0x6c,0x9d,0x32,0xfe,0x96,0xf6,0xb5, + 0x69,0xd6,0x1a,0x36,0xbb,0x24,0xa0,0x07,0x38,0xbd,0x25,0x72,0x76,0x57,0x80,0xea, + 0xc1,0xc7,0xcc,0x1f,0x5d,0x11,0x9c,0x64,0x1f,0x60,0xe4,0x3c,0x4b,0x0a,0xde,0x19, + 0x90,0x33,0x12,0x28,0x2f,0x5d,0x05,0xf6,0xf0,0x9a,0x5d,0x58,0xf7,0x5c,0x0d,0xd5, + 0x1d,0xe9,0xf9,0x17,0x18,0x31,0xba,0xb0,0x21,0xc5,0xe2,0xb4,0xc8,0xa2,0xf6,0xe9, + 0x2c,0x68,0xbd,0x03,0x1a,0x06,0x56,0xbe,0xa2,0x87,0x4f,0xfc,0xcc,0x9f,0x21,0x9b, + 0x03,0x20,0xf3,0x63,0xca,0xbe,0xb2,0xbc,0x6d,0x67,0x53,0xc5,0xd3,0xc1,0x87,0x8f, + 0xef,0x5b,0x1c,0x1a,0x9a,0x27,0x69,0x1a,0x49,0xfa,0x6a,0x12,0x28,0x38,0x86,0x09, + 0x67,0x72,0xec,0x35,0xa6,0x45,0x91,0xe6,0x6e,0xbb,0x3d,0xc1,0x26,0x75,0x3e,0xb4, + 0xb1,0x1b,0x6d,0xcb,0x78,0x32,0x9b,0xc7,0x93,0x76,0x6d,0xbb,0xd9,0x1e,0x46,0xc9, + 0xb0,0x3d,0xf3,0xc3,0xb8,0x1d,0x24,0xa3,0xbc,0x0d,0x93,0x0e,0x5b,0xbe,0xde,0x6e, + 0xcc,0x82,0xc6,0x20,0xa7,0x8d,0xb4,0xa5,0x36,0x02,0xc3,0x5b,0x0d,0x33,0xfc,0xc1, + 0xef,0x35,0x55,0x9e,0xca,0x11,0xcf,0xa3,0x76,0xcc,0x71,0x01,0xe1,0x15,0x34,0x01, + 0xd5,0x27,0x78,0xa1,0x7b,0x3a,0x6a,0x95,0xef,0x6e,0xec,0x55,0x8f,0x68,0xff,0xa1, + 0x19,0x56,0xcf,0x85,0x83,0xe2,0x7c,0x06,0x28,0x64,0x03,0xbd,0xbf,0x8a,0x24,0x7d, + 0x7d,0x79,0xfb,0x3a,0x40,0x8f,0xe5,0x1e,0x3d,0xe4,0x4e,0xe7,0x3a,0x1e,0xbf,0x65, + 0x40,0xf1,0xe3,0x8c,0x5e,0xd2,0xf0,0xc6,0x7e,0x94,0x4b,0x2b,0xc8,0xfc,0xc9,0x04, + 0x96,0xa1,0x2f,0x69,0x73,0xc0,0x1b,0x16,0x19,0xe8,0x96,0x79,0x3e,0xfc,0x89,0x1e, + 0xd2,0x2f,0x1b,0xfa,0x7b,0xed,0x36,0x07,0x04,0x0e,0x58,0xba,0x14,0x99,0x57,0x4f, + 0x14,0xc1,0x1c,0xe8,0x31,0xa2,0x79,0xcc,0x15,0x10,0x21,0x83,0xb0,0x50,0x19,0x49, + 0x9d,0x48,0x89,0x9b,0x10,0x5b,0x0d,0xd8,0x27,0xf9,0x49,0xe2,0x07,0x44,0xac,0x7a, + 0x18,0x09,0xeb,0x4f,0xf8,0x69,0xfd,0x71,0x98,0xcd,0x6e,0xfc,0x4c,0x6a,0xbf,0xc3, + 0xd6,0x91,0xaa,0x61,0x4c,0xd9,0x5e,0x09,0x00,0xba,0xe1,0x95,0x34,0xaf,0xcd,0xc5, + 0x6a,0x59,0xef,0xcf,0x6d,0x7e,0x09,0xc5,0xbb,0x5e,0x12,0xf1,0x13,0x4c,0x94,0xcf, + 0xa3,0x82,0x78,0xcc,0xfd,0x30,0x10,0x43,0xec,0xc4,0x02,0x29,0x2a,0x81,0x27,0x91, + 0x8a,0x74,0x23,0x7f,0x4e,0x0e,0x1c,0x16,0xae,0xc8,0x23,0xf0,0x87,0x2c,0x22,0x9a, + 0xba,0x56,0x63,0x5a,0x44,0xea,0x8a,0x9b,0x18,0xfe,0x99,0x54,0x9f,0x48,0x54,0x17, + 0x76,0x1a,0x6c,0x0e,0x3f,0x48,0x84,0xd7,0x6b,0x49,0x8f,0x62,0x15,0x53,0xa6,0xaf, + 0x0e,0xd7,0xb8,0x96,0x41,0x8f,0x53,0x11,0x70,0x0d,0x6a,0xfc,0x23,0xed,0x17,0x4d, + 0x36,0xfc,0xdc,0x4a,0xae,0x3e,0xca,0xcf,0x85,0x45,0x33,0x9b,0x0b,0xf8,0x32,0x29, + 0x8d,0xaa,0xb7,0x1e,0x89,0xe3,0xc7,0x0f,0x6f,0x2f,0xa4,0x9f,0x8d,0xa6,0xef,0xb9, + 0x73,0xd3,0xb4,0x64,0xe4,0xfd,0xa1,0x49,0x9d,0xef,0xee,0x0c,0x70,0x61,0x98,0x7d, + 0x8c,0x81,0xb3,0x35,0x69,0x1c,0x9d,0xba,0x2a,0x87,0xca,0x4d,0xa2,0xc1,0xef,0x4f, + 0x5c,0x59,0xaa,0xe5,0xd3,0xd5,0x25,0x77,0x5e,0x7b,0xa1,0x40,0xbd,0xe7,0x43,0x16, + 0x69,0x58,0x0b,0xb8,0xe1,0x34,0x09,0x5c,0xe3,0xfd,0x0f,0x17,0x1f,0x0d,0x8b,0x08, + 0xb8,0xf4,0xb1,0xdc,0xf1,0xca,0x81,0x26,0xb2,0x7a,0x81,0x60,0xfb,0x9d,0x17,0x55, + 0xf3,0x19,0x37,0x03,0x3b,0xb9,0xf2,0x3c,0x65,0x42,0xe6,0x02,0x4a,0xca,0xfd,0x5b, + 0x34,0x4a,0x7a,0x4b,0x01,0x8b,0x18,0xfb,0x50,0x74,0x60,0x58,0x06,0x1a,0x0c,0x8b, + 0x4e,0xe1,0x4b,0xcb,0x5e,0x32,0x85,0x94,0xce,0x08,0x40,0xaf,0x0f,0x52,0x4a,0x56, + 0xa6,0xa6,0xa1,0x25,0x67,0x24,0x57,0xeb,0xe3,0x68,0x91,0x60,0x68,0x44,0xb6,0x5b, + 0x7f,0x17,0x43,0x0f,0x33,0xe2,0x44,0xeb,0x67,0x55,0xb2,0xe2,0x2c,0x5b,0xe3,0x61, + 0x09,0x49,0x2d,0x6b,0xf6,0x06,0x8b,0xfe,0x27,0xd2,0xd9,0xb0,0x88,0xd9,0x8f,0xb4, + 0xd6,0x78,0x91,0x7f,0x68,0x1a,0xaa,0x7c,0x6b,0x98,0xb6,0xda,0x29,0xd1,0x05,0x31, + 0x81,0x09,0xe9,0xcd,0x25,0x1e,0xc5,0x2a,0x5f,0xe8,0x03,0xfb,0xbc,0x58,0x96,0x14, + 0x76,0x08,0x8e,0x64,0xb6,0xbf,0x4f,0x62,0x7b,0x54,0x8a,0xad,0x32,0x78,0x75,0xa9, + 0xd8,0x83,0x61,0xfe,0x00,0x87,0xe3,0x9d,0x95,0xab,0x77,0x52,0x54,0xbb,0x50,0x7b, + 0x23,0x05,0x9d,0xa4,0xa0,0xd3,0x3e,0xab,0xda,0x2f,0x55,0xb5,0x0d,0x7d,0x97,0x4f, + 0xa7,0xd8,0xc6,0x1d,0xd7,0xa9,0xf2,0x69,0x18,0x04,0x91,0xb4,0x05,0xbd,0xeb,0x01, + 0x64,0x59,0x64,0xb7,0xe4,0x43,0x9f,0x66,0x70,0xa6,0x30,0x8d,0x42,0x32,0x6c,0x44, + 0xa6,0x30,0x4f,0xb2,0x4b,0x7a,0x82,0x30,0xba,0x65,0xc7,0x0f,0x15,0x58,0x12,0x03, + 0xe1,0xd8,0x1c,0x7a,0xbe,0x3b,0x79,0xfd,0xfd,0x85,0xf7,0xe9,0xd3,0x91,0xe5,0x5c, + 0x5a,0x9f,0x7a,0xfc,0xf9,0x94,0x3f,0xbb,0xfc,0xe9,0xe8,0xcf,0x2e,0x7f,0x3e,0xe5, + 0xcf,0x1e,0x7f,0x1e,0xf2,0xe7,0x91,0xea,0xa3,0x6e,0x3b,0x87,0x97,0x97,0xfd,0x95, + 0x46,0x26,0xb0,0x8a,0xd7,0x94,0x2a,0x9b,0xe0,0xca,0x02,0x37,0xac,0x8a,0xd2,0x0f, + 0x42,0xaf,0xd3,0x0f,0x8f,0x79,0x7e,0x5b,0xd5,0xbe,0xfb,0xe1,0x93,0x27,0x26,0x04, + 0xcb,0x6d,0x9f,0xc2,0xcb,0x4f,0x9d,0x4b,0xd8,0x24,0xc6,0xee,0xef,0x57,0x4d,0x0e, + 0x35,0x11,0x29,0x6d,0x48,0x61,0xcd,0x5b,0xd0,0x73,0xe0,0xbc,0xe8,0xb8,0x75,0x9a, + 0x2d,0x67,0xcd,0x48,0x88,0x25,0x52,0x69,0x73,0x52,0xb9,0xc7,0x04,0xd3,0x60,0x58, + 0xd3,0xf8,0x97,0x79,0xa7,0x13,0x3c,0x33,0x9e,0x50,0x83,0xe9,0x36,0x27,0x98,0x8b, + 0xdb,0x9d,0x36,0xb5,0x39,0x68,0x33,0x78,0xbb,0x6b,0x98,0xac,0xd6,0x6e,0xaf,0x52, + 0x98,0xaf,0xb2,0x62,0xf3,0xfd,0xab,0x53,0xc7,0xf9,0xd0,0xea,0x75,0xbb,0x9d,0xf3, + 0xd6,0x45,0xa7,0xd3,0xed,0x99,0x7d,0x41,0x50,0x4e,0xe9,0x5d,0x12,0x78,0x4b,0xe8, + 0x4f,0x2d,0xcc,0x64,0xc9,0x8d,0x62,0xc8,0x02,0x00,0xac,0x82,0x0b,0x4c,0xe0,0x23, + 0x48,0x7a,0xdd,0xde,0xb7,0xc4,0x4e,0x9b,0xe6,0xef,0xeb,0x7b,0x85,0x57,0x5b,0xc5, + 0x13,0x43,0x10,0xdf,0xc3,0x67,0xc2,0x78,0xd2,0xe4,0x77,0xb0,0xf8,0xf1,0x94,0xa6, + 0x26,0xf0,0xad,0xd3,0x31,0xdb,0xf8,0xf7,0xc4,0x68,0x13,0x8b,0x06,0xd1,0x80,0x84, + 0x69,0xaa,0xe2,0x89,0x77,0xcf,0x58,0x70,0xdc,0xde,0x1a,0xaf,0x60,0xa7,0x51,0x13, + 0x77,0xb1,0xee,0x7e,0xfc,0x10,0x2a,0xa2,0xdf,0x56,0xd2,0xfb,0xd7,0x39,0xd0,0xb9, + 0x02,0x51,0x49,0x76,0x12,0x45,0x4d,0x63,0xd7,0x23,0x9f,0x24,0xd5,0x8a,0x98,0x9f, + 0x65,0x94,0x36,0x34,0xa5,0x93,0x2c,0xf3,0x6f,0xed,0x30,0xe7,0xbf,0x68,0x7f,0x71, + 0xed,0x7e,0xba,0xb6,0xf8,0xbf,0xcb,0xda,0xa0,0x32,0x08,0x91,0x0c,0x39,0xa3,0x06, + 0xa5,0xc4,0x90,0x49,0xbc,0xc0,0xce,0x8a,0xf4,0xbd,0x04,0x8b,0x83,0x4e,0x5f,0x45, + 0x04,0x8e,0x06,0xf5,0xd7,0x18,0x1f,0xa1,0x13,0x41,0xed,0x99,0x7c,0x61,0x24,0xe3, + 0xb1,0xe1,0x36,0x31,0x12,0x16,0x50,0x1d,0xde,0x10,0xb0,0x80,0xac,0x2a,0x5a,0x08, + 0x8d,0x86,0x69,0xd2,0xdb,0x95,0xd0,0x70,0x8c,0x3c,0xad,0x42,0x7d,0x49,0x9b,0xf1, + 0xde,0xf7,0xf4,0x7e,0x95,0x91,0xcf,0x87,0x34,0x12,0x94,0x28,0x6a,0xac,0x66,0xb9, + 0x32,0x5c,0x43,0x0d,0x62,0x20,0x20,0xfd,0x00,0xa9,0xc3,0xa0,0xbf,0xaa,0x95,0x5e, + 0x1b,0xab,0xfa,0x9b,0x0b,0x7e,0x63,0xa1,0x46,0x96,0xdf,0x96,0x20,0x56,0xfb,0x7a, + 0x54,0x6b,0x73,0x4d,0x06,0xe3,0x30,0x4a,0xaf,0x9c,0x09,0xe5,0x46,0xf7,0x74,0xb3, + 0xfb,0x47,0xf6,0x2a,0x8e,0x4c,0xaa,0x9e,0x6f,0xf4,0x29,0xc6,0x4b,0x84,0x34,0x62, + 0x06,0x0b,0xb8,0x8f,0x8b,0xf8,0x0b,0x4c,0x9c,0x56,0x12,0x84,0x03,0x6c,0x48,0x70, + 0x75,0x2e,0xa3,0x96,0xcc,0xf2,0xbb,0x87,0xb9,0xaa,0xda,0x1d,0x92,0xd7,0xf1,0xd2, + 0x78,0x43,0x48,0x45,0x51,0x09,0xe0,0x02,0x76,0x89,0x38,0x45,0xef,0x17,0x86,0x78, + 0x5d,0x28,0x40,0x53,0x9d,0xca,0x88,0xa4,0x7a,0xe0,0xdb,0x56,0x82,0x2f,0xd7,0xb6, + 0x73,0x4d,0xf7,0x2f,0x48,0x4f,0xd1,0x34,0x4e,0xfd,0x28,0xa2,0xe2,0xb8,0xf1,0x44, + 0x35,0x3d,0x31,0x7e,0xf9,0xcb,0x7f,0x1b,0x30,0x89,0xb7,0x95,0x49,0x7c,0x79,0x4d, + 0x25,0xb3,0xaf,0xe9,0xc4,0x87,0x28,0xa9,0xe3,0xa6,0x6e,0x47,0xe4,0x36,0x76,0x22, + 0xf4,0x94,0x16,0x03,0xa0,0xf2,0x24,0xa9,0x2d,0xbe,0x97,0xc5,0x4d,0x92,0x5d,0x89, + 0x52,0xb1,0xd5,0x66,0x58,0x9d,0x1d,0xe5,0xf5,0xc3,0x23,0x5a,0xe5,0x69,0xfd,0xec, + 0xe7,0x96,0xca,0xc8,0xa5,0xbc,0x19,0xfb,0x50,0xde,0x64,0xa1,0xac,0x1d,0xe3,0x30, + 0xc1,0xd5,0xf9,0x18,0x8b,0x91,0x5e,0xa0,0xa5,0xa3,0x03,0x65,0x11,0x64,0x99,0x15, + 0xae,0x33,0xb7,0x13,0xac,0x5a,0x18,0x79,0x88,0x76,0x09,0x7d,0x9c,0xb8,0xa9,0xd3, + 0xad,0xc3,0x45,0xd6,0x20,0xf8,0x84,0x02,0x9b,0x24,0x56,0x7c,0x7d,0x62,0x98,0x95, + 0x9f,0xac,0x51,0xe2,0x03,0x75,0x5b,0x1f,0xc9,0x7b,0x7a,0x18,0xba,0xd2,0x73,0x57, + 0xe5,0xbc,0xb4,0xae,0x8d,0x49,0x31,0x41,0x98,0x82,0xb5,0x96,0x81,0xc8,0xe6,0xf2, + 0x84,0x64,0x46,0x77,0x77,0x07,0x9d,0x4e,0x8f,0x82,0x1d,0x39,0xba,0xa8,0x6d,0x0f, + 0x4a,0x5a,0xb4,0xc7,0xdb,0x52,0xe0,0x48,0x1d,0x46,0x21,0x31,0x75,0x5e,0x18,0x90, + 0x11,0xe2,0x06,0x3a,0xb2,0x49,0xe8,0x5b,0x25,0xe3,0xa3,0xb8,0xd8,0xe6,0x24,0xfb, + 0x7c,0x77,0xc7,0x11,0xb6,0x0c,0x2e,0x05,0x37,0x94,0x31,0x01,0xb1,0x1a,0x73,0x24, + 0xf3,0x82,0x0a,0x3d,0x00,0x1b,0x06,0xa0,0x79,0xc9,0x0e,0x9f,0x16,0xad,0x07,0x9a, + 0xd1,0x14,0x34,0xd0,0x0a,0xe9,0xc1,0x23,0x6b,0x42,0xe3,0x53,0x97,0xfb,0xfa,0xa2, + 0x23,0x75,0xaf,0x26,0x45,0x2a,0xcd,0x3d,0x8a,0xc2,0x6a,0xe6,0x79,0x74,0x77,0xe7, + 0x98,0x94,0xc2,0x6b,0xad,0x67,0xe1,0x35,0xb5,0x96,0x43,0xf2,0xc8,0xab,0x72,0x40, + 0x1d,0xef,0xe6,0xc8,0xf3,0xf9,0x71,0xaf,0x9f,0x23,0xb9,0x57,0x78,0xf3,0x51,0xb9, + 0xb1,0x31,0xf3,0xe8,0x53,0x7e,0xa9,0x4d,0x66,0x0d,0x30,0xe4,0x68,0xe7,0x19,0xf1, + 0x57,0xc7,0x05,0x22,0x37,0xf1,0x14,0x16,0x50,0xef,0xaa,0x37,0x6b,0xc3,0xcd,0x4b, + 0xd5,0x4b,0x35,0x01,0x4f,0x43,0xbe,0xdf,0xd3,0x39,0xce,0x5a,0x02,0x42,0xf6,0xd9, + 0xb4,0x86,0xf5,0x14,0xcc,0xc8,0x74,0x6f,0x9d,0xcb,0xfd,0xfd,0x47,0xb5,0xad,0x97, + 0x5e,0x06,0x2f,0x8e,0xe4,0x48,0xcc,0xda,0xec,0xb6,0x2b,0x88,0x38,0xb3,0x56,0xdb, + 0xbf,0x99,0x42,0x31,0x39,0x12,0x39,0x82,0x3f,0xad,0x28,0xd4,0x28,0x86,0x9a,0x96, + 0xb5,0xa0,0x87,0x39,0x48,0x43,0xea,0xf0,0xc7,0x23,0xe2,0x25,0x37,0xbc,0x73,0x80, + 0x21,0xdc,0x9f,0x54,0x1f,0xf3,0xd6,0x47,0x3d,0xc2,0xb7,0xbe,0xe3,0x50,0x48,0xab, + 0xc7,0xf0,0x6a,0xc5,0xfa,0xb5,0xa7,0xd4,0xf8,0x3e,0xc9,0xef,0xee,0x3e,0x1d,0xf6, + 0xac,0xf2,0xff,0x4b,0x13,0xfc,0x29,0x9e,0xae,0x88,0xd5,0x4d,0xf9,0x85,0x95,0xdf, + 0xa9,0x37,0x84,0xc9,0x80,0x9a,0xcd,0xeb,0x36,0x60,0x03,0xe0,0x02,0x59,0xf2,0x37, + 0x14,0x00,0x3b,0xdf,0x18,0x5f,0x20,0x62,0x5f,0x6f,0x28,0x81,0xa8,0x5c,0xbb,0xc6, + 0xdf,0xff,0xa6,0x40,0xca,0x52,0x19,0x6d,0x59,0x8a,0xdd,0x72,0xf5,0xd2,0x6a,0xeb, + 0x8e,0x4e,0x06,0x5c,0x09,0xef,0xd1,0x2a,0x6d,0x12,0x4e,0xa9,0x39,0x4b,0x12,0x73, + 0x59,0xc3,0x5b,0xdf,0x75,0xf0,0x26,0x80,0xa2,0x8a,0xde,0x05,0x68,0x87,0x73,0x0d, + 0x1a,0xb4,0xb4,0x0c,0x25,0xdd,0x5c,0xaa,0xb0,0x18,0x66,0xea,0xc0,0x05,0xdb,0x91, + 0x2b,0xde,0xe1,0x99,0xcb,0xfe,0x5e,0xcd,0xc9,0x7e,0xe5,0x1c,0x34,0x68,0x7d,0x8e, + 0x6c,0xed,0x2c,0x74,0x73,0x1a,0x6d,0x28,0x98,0x85,0x6b,0x4e,0xf5,0x69,0xea,0x25, + 0x82,0x22,0x9b,0x4b,0xda,0x92,0xd1,0x94,0xa5,0x60,0xd4,0x6b,0xf5,0xd7,0x2b,0x5f, + 0x85,0x09,0x2a,0xff,0xe9,0xef,0x70,0xaf,0x6b,0x38,0x15,0xf3,0xaa,0x51,0xf2,0x42, + 0x45,0x03,0x97,0x2e,0x97,0xee,0x42,0x45,0x01,0x97,0xd0,0x29,0xd8,0xaf,0x8e,0x29, + 0xe9,0xc5,0x39,0xce,0xf3,0x35,0xa0,0x5a,0xae,0x60,0x89,0x15,0x28,0x40,0x97,0x66, + 0x49,0x91,0x10,0x02,0x2c,0x5f,0x59,0xe7,0x5f,0x68,0x68,0x56,0x8c,0xd5,0x7e,0x85, + 0x21,0x62,0xbb,0x95,0x11,0x56,0xcc,0xc0,0xb1,0xbe,0xe0,0xaa,0x44,0xc2,0xab,0x2d, + 0x03,0xcc,0x96,0x37,0x95,0xeb,0x66,0x59,0xa8,0xf5,0xfe,0x5a,0x36,0xe8,0xb7,0x0b, + 0x74,0xa4,0xba,0x5e,0x9a,0xcb,0x72,0x2a,0xc2,0x9a,0xa6,0x02,0x9f,0xfc,0x75,0xd9, + 0x2f,0x79,0xdd,0xd6,0xce,0x7a,0x3d,0x67,0x97,0xc4,0x57,0xfc,0x95,0xf1,0x8c,0xf7, + 0x03,0xde,0xd7,0x15,0x50,0xad,0xfd,0xd1,0xf6,0xe2,0x53,0x0e,0x0b,0x5e,0x7d,0x8e, + 0x89,0x2c,0x4e,0x8a,0x22,0x0b,0x87,0xc8,0xb9,0x4d,0x43,0x1d,0x1a,0xc2,0xc0,0x9e, + 0x38,0x8a,0x8e,0x2a,0x7f,0x58,0xdb,0x82,0x7c,0x51,0xe9,0x19,0xee,0xf7,0xc6,0x78, + 0xa2,0x48,0xab,0x8d,0x48,0xd7,0x79,0xde,0xbd,0x47,0xed,0x70,0x42,0x93,0x6d,0x97, + 0xbd,0x74,0x97,0x6c,0xb4,0x4f,0xc4,0x6e,0xdd,0x5c,0x5f,0x18,0x0e,0xa6,0xe9,0xc0, + 0x3b,0xd6,0x5b,0xf5,0xc1,0x03,0xee,0xa9,0x6f,0xc0,0xb4,0x96,0x91,0x94,0x53,0xd5, + 0xd1,0xc8,0x0e,0x93,0xa9,0x76,0xea,0x64,0x32,0xba,0x3f,0x3f,0x4f,0xc6,0xdd,0xd5, + 0x73,0x54,0x5e,0xfd,0x37,0x40,0x24,0xac,0x04,0x01,0x3e,0x2e,0xce,0xe4,0xd8,0xc7, + 0x0e,0x1b,0x19,0x4e,0xfd,0x38,0xc5,0x16,0xec,0xb1,0x21,0xd1,0x99,0x4a,0x80,0x55, + 0x41,0x82,0x39,0xdf,0x8c,0x7f,0x74,0xd7,0x30,0xad,0xa9,0x35,0x7d,0x61,0xa8,0x62, + 0x1c,0x3d,0x6b,0x11,0x6a,0x14,0x88,0x75,0xf1,0xa3,0x52,0x32,0x28,0xd7,0xb3,0xc2, + 0x3c,0x3b,0x62,0x8b,0xb6,0xc2,0xfd,0x7d,0xfa,0x24,0x10,0x64,0xae,0x4d,0x6e,0x95, + 0xcd,0xd6,0xae,0xa9,0xf4,0x04,0xd5,0xa3,0x59,0xbb,0xa7,0x58,0x27,0x68,0x40,0xda, + 0x9a,0x41,0x26,0x46,0x4f,0xba,0xe9,0x67,0x44,0xea,0x0f,0xbb,0x31,0x6d,0x2e,0x81, + 0xd4,0x8a,0x8c,0x46,0x4e,0xbf,0xa1,0x23,0x5d,0x2a,0x98,0x87,0x7c,0x44,0x90,0xaa, + 0xe2,0x9e,0x2a,0x27,0x52,0xc7,0xc2,0x1f,0x12,0xb2,0x55,0x0f,0x0b,0x5a,0x25,0x16, + 0x25,0x4a,0xe5,0xe3,0x55,0xf4,0x54,0xc9,0x34,0x4c,0x2d,0x2e,0x56,0xea,0xd3,0xa7, + 0x89,0xb4,0x1e,0xf2,0x2c,0xbd,0xbd,0x87,0x50,0xcc,0xbf,0x29,0x52,0x2f,0xac,0x39, + 0x80,0x7e,0xd6,0x66,0x85,0xc7,0x2c,0xf7,0x94,0x64,0xbb,0x98,0xfd,0xc7,0x8b,0x97, + 0xaa,0xae,0x2d,0x3f,0x87,0xf4,0x9c,0x09,0xd7,0x55,0x42,0xaa,0x87,0xf3,0x6f,0x8e, + 0x60,0xe2,0x04,0x68,0x16,0xbd,0xd4,0xae,0x44,0x17,0x5e,0x7f,0xf8,0xf8,0x1d,0x9d, + 0x0f,0x8e,0xae,0x2c,0x7d,0x24,0x48,0xbf,0xd5,0x41,0xe4,0x98,0xd9,0x3c,0x51,0x25, + 0x49,0x9f,0x4a,0x3a,0xe1,0x8f,0xf9,0x50,0xf0,0x93,0xa5,0xb6,0x78,0x23,0x65,0xaa, + 0x3b,0x03,0xbb,0xcb,0x7c,0xaa,0xa6,0xa3,0x52,0x14,0x64,0x47,0x65,0x70,0xc6,0xcc, + 0x92,0x89,0x4e,0x12,0x5e,0x96,0xfe,0x59,0x96,0xfa,0xcf,0xa4,0x68,0xf0,0x30,0xf7, + 0x72,0x5b,0x4f,0x50,0xa1,0x68,0xe0,0x04,0x5b,0xc9,0xd8,0x7b,0x34,0x2f,0x77,0x9b, + 0x73,0x5d,0x5a,0xa9,0xae,0xab,0x0a,0xb3,0xb9,0xa8,0x55,0x9b,0x39,0xda,0x62,0xc6, + 0xda,0x4f,0xc4,0x34,0x21,0xc2,0x8e,0xb9,0xac,0xca,0x14,0x73,0x7b,0x96,0x20,0x03, + 0x93,0xbb,0x6e,0xee,0xa0,0xe7,0x2b,0xd4,0x0b,0xf1,0x95,0x6d,0x45,0xd9,0x46,0xbf, + 0xbd,0x42,0xb9,0x7d,0xf5,0x78,0x9c,0x2e,0x85,0xf1,0xb2,0xcb,0x2a,0x06,0x0d,0x38, + 0xcb,0x92,0x34,0x05,0x6b,0x5c,0xcd,0x60,0x3a,0xb5,0x56,0x50,0x0a,0xd4,0xb7,0xd5, + 0x1e,0x64,0xb8,0xb5,0x97,0x2b,0x36,0xee,0xad,0x00,0x72,0x6d,0x01,0x6a,0xa3,0xae, + 0x82,0x58,0xed,0x17,0x54,0xda,0xea,0x19,0xde,0xb6,0xfa,0x81,0xa8,0xff,0x03,0xa5, + 0xa7,0xe7,0x67,0x31,0x4a,0x00,0x00, }; constexpr size_t MIDI_INDEX_HTML_GZ_LEN = sizeof(MIDI_INDEX_HTML_GZ); // diff --git a/firmware/patternflow/net_config.h b/firmware/patternflow/net_config.h index b5134678f..2ebb142d8 100644 --- a/firmware/patternflow/net_config.h +++ b/firmware/patternflow/net_config.h @@ -32,6 +32,8 @@ #include "patternflow_secrets.h" #endif +#include "src/core_shared.h" + // ── Wi-Fi ──────────────────────────────────────────────────────────── // Whether the panel has a radio at all. 0 makes a USB-only instrument: // everything network-side (console, OTA, every feature that dials out) @@ -190,7 +192,8 @@ // it into virtual knob deltas (applyLaneMotion), so EVERY encoder-driven // pattern reacts with no per-pattern code. This is how many knob clicks a // full 0..1 swing maps to. Higher = stronger response. No per-frame clamp, -// so the value tracks without lag. +// so the value tracks without lag. The default is a parameter's whole range, +// two turns of 24 detents (src/core_shared.h). // // The old name is honoured below because a firmware built elsewhere may // already set it in its overrides.h, and a rename that silently ignores @@ -199,6 +202,6 @@ #ifdef PF_AUDIO_VIRTUAL_KNOB_SCALE #define PF_LANE_MOTION_SCALE PF_AUDIO_VIRTUAL_KNOB_SCALE #else -#define PF_LANE_MOTION_SCALE 48.0f +#define PF_LANE_MOTION_SCALE ((float)(PF_SHARED_DETENTS_PER_TURN * PF_SHARED_TURNS_PER_FULL_RANGE)) #endif #endif diff --git a/firmware/patternflow/patternflow.ino b/firmware/patternflow/patternflow.ino index cb4094307..f451f8ebf 100644 --- a/firmware/patternflow/patternflow.ino +++ b/firmware/patternflow/patternflow.ino @@ -270,7 +270,7 @@ bool updateDirty = false; // loop() draws it. -1 when nothing is being received. volatile int uploadPctShown = -1; -const uint32_t MODE_HOLD_MS = 1000; +const uint32_t MODE_HOLD_MS = PF_SHARED_LONG_PRESS_MS; const uint32_t BRIGHTNESS_IDLE_MS = 5000; // How long the value has to be still before it is written to NVS - the // console's slider sends a step for every pixel of a drag. diff --git a/firmware/patternflow/presets/README.md b/firmware/patternflow/presets/README.md index eb5553710..649962bbc 100644 --- a/firmware/patternflow/presets/README.md +++ b/firmware/patternflow/presets/README.md @@ -1,12 +1,12 @@ # presets/ -The curated patterns, as C++ headers. `preset_origin.h` is the one compiled into every firmware image — the failsafe a board can always boot into. The rest are built into `.pfm` modules by `firmware/toolchain/make_pack.py` and ship as the **Basics pack** (`web/public/packs/`), which a new board installs in one click. `presetPatterns[]` in [`../pattern_registry.h`](../pattern_registry.h) decides what is compiled in; [`../README.md`](../README.md) explains the registry. +The curated patterns, as C++ headers. `preset_origin.h` is the one compiled into every firmware image — the failsafe a board can always boot into. The rest are ported to module sources by `firmware/toolchain/port_preset.py`, built into `.pfm` modules by `build_module.py`, bundled by `make_pack.py`, and ship as the **Basics pack** (`web/public/packs/`), which a new board installs in one click. `presetPatterns[]` in [`../pattern_registry.h`](../pattern_registry.h) decides what is compiled in; [`../README.md`](../README.md) explains the registry. **This set is curated by the maintainer.** Patterns are not contributed here: they are made in the [Pattern Lab](https://patternflow.work/pattern-lab) and published to the Community, where every Patternflow can play them. [`../../CUSTOM_PATTERNS.md`](../../CUSTOM_PATTERNS.md) is the hands-on route for your own board. ## Twins -Every header here has a JavaScript twin in `web/src/lib/presets/`, and **the JavaScript is the source of truth**: the header is generated from it (the Pattern Lab's *Copy C++ prompt* flow, or `firmware/toolchain/port_preset.py` in the other direction). `firmware/toolchain/check_presets.py` runs in CI and fails when the two sets drift. Its `WEB_ONLY` list names the web presets that deliberately have no header — the ones that missed frame budget on the real ESP32, and the ones posted after the module loader made a compiled-in showcase unnecessary; delete an entry there the day its header lands. `preset_calib.h` is the display-calibration pattern and has no web twin on purpose. +Every header here has a JavaScript twin in `web/src/lib/presets/`, and **the JavaScript is the source of truth**: the header is generated from it (the Pattern Lab's *Copy C++ prompt* flow), and `firmware/toolchain/port_preset.py` takes the header on from there, into a module. `firmware/toolchain/check_presets.py` runs in CI and fails when the two sets drift. Its `WEB_ONLY` list names the web presets that deliberately have no header — the ones that missed frame budget on the real ESP32, and the ones posted after the module loader made a compiled-in showcase unnecessary; delete an entry there the day its header lands. `preset_calib.h` is the display-calibration pattern and has no web twin on purpose. ## Names diff --git a/firmware/patternflow/src/core_bus.h b/firmware/patternflow/src/core_bus.h index 29dfedcfc..d423228e1 100644 --- a/firmware/patternflow/src/core_bus.h +++ b/firmware/patternflow/src/core_bus.h @@ -23,13 +23,16 @@ #include #include "core_encoders.h" // InputFrame +#include "core_shared.h" // The wire scale of an absolute channel: 0..PF_BUS_MAX, integer. Everything // that writes the bus — MQTT param/1..4, POST /api/params, a .pfs cue, a MIDI // CC — speaks this scale, and the site's Director bakes to it // (BUS_WIRE_MAX in web/src/lib/pattern/controls.ts). abi/pf_params.h clamps // to the same literal and cannot be changed to this name: the ABI is frozen. -#define PF_BUS_MAX 1000 +// The number is BUS_MAX in shared/constants.json, and check:shared holds the +// ABI's literal to it. +#define PF_BUS_MAX PF_SHARED_BUS_MAX namespace PatternflowBus { @@ -87,9 +90,9 @@ inline void applyRemoteParam(int index, long value) { if (diff != 0) { const int32_t acc = __atomic_add_fetch(&clickResidual[index], (int32_t)diff, __ATOMIC_RELAXED); - const int32_t d = acc / 10; // truncates toward zero, both signs + const int32_t d = acc / PF_SHARED_BUS_UNITS_PER_CLICK; // truncates toward zero, both signs if (d != 0) { - __atomic_fetch_sub(&clickResidual[index], d * 10, __ATOMIC_RELAXED); + __atomic_fetch_sub(&clickResidual[index], d * PF_SHARED_BUS_UNITS_PER_CLICK, __ATOMIC_RELAXED); __atomic_fetch_add(&pendingDelta[index], d, __ATOMIC_RELAXED); } } diff --git a/firmware/patternflow/src/core_canvas.h b/firmware/patternflow/src/core_canvas.h index 0a1dfbb0a..3ecf687d3 100644 --- a/firmware/patternflow/src/core_canvas.h +++ b/firmware/patternflow/src/core_canvas.h @@ -25,9 +25,9 @@ #include #include "core_loop_sync.h" // onLoopTask(): present() belongs to the frame -// Vendored HUB75 driver (src/hub75/), not the Library Manager copy: it -// carries one addition, blitRGB888(), that upstream has no equivalent for. -// See src/hub75/VENDORED.md. +// Vendored HUB75 driver (src/hub75/), not the Library Manager copy. Every +// change from upstream is in src/hub75/patternflow.patch, and +// src/hub75/VENDORED.md says why. #include "hub75/ESP32-HUB75-MatrixPanel-I2S-DMA.h" #include "config.h" diff --git a/firmware/patternflow/src/core_display.h b/firmware/patternflow/src/core_display.h index d471cb7d4..53e8d0041 100644 --- a/firmware/patternflow/src/core_display.h +++ b/firmware/patternflow/src/core_display.h @@ -1,7 +1,7 @@ #pragma once -// Vendored HUB75 driver (src/hub75/), not the Library Manager copy: it -// carries one addition, blitRGB888(), that upstream has no equivalent for. -// See src/hub75/VENDORED.md. +// Vendored HUB75 driver (src/hub75/), not the Library Manager copy. Every +// change from upstream is in src/hub75/patternflow.patch, and +// src/hub75/VENDORED.md says why. #include "hub75/ESP32-HUB75-MatrixPanel-I2S-DMA.h" #include "config.h" diff --git a/firmware/patternflow/src/core_encoders.h b/firmware/patternflow/src/core_encoders.h index 84cd93e81..b0d61299a 100644 --- a/firmware/patternflow/src/core_encoders.h +++ b/firmware/patternflow/src/core_encoders.h @@ -2,6 +2,7 @@ #include #include #include "config.h" +#include "core_shared.h" volatile long encPos[4] = {0, 0, 0, 0}; // sub-steps; 4 per detent volatile uint8_t encState[4] = {0, 0, 0, 0}; // last ACCEPTED (A<<1)|B @@ -73,7 +74,9 @@ void IRAM_ATTR isr4() { handleEncoder(3, ENC4_A, ENC4_B); } // (namespace "pf_knobs"), set from the /knobs page (core_knobs_http.h). inline bool knobInvert[4] = {INVERT_ENCODER != 0, INVERT_ENCODER != 0, INVERT_ENCODER != 0, INVERT_ENCODER != 0}; -inline int knobSubSteps[4] = {4, 4, 4, 4}; // edges per click: 4, 2 or 1 +// Edges per click: 4, 2 or 1. +inline int knobSubSteps[4] = {PF_SHARED_EDGES_PER_DETENT, PF_SHARED_EDGES_PER_DETENT, + PF_SHARED_EDGES_PER_DETENT, PF_SHARED_EDGES_PER_DETENT}; // A settings change must not move a knob. The click count carries on from // where it was, counting the edges since the change in the new direction // and at the new size, so the frame's delta stays zero across a save. @@ -86,7 +89,9 @@ inline long getClicks(int idx) { return knobClickBase[idx] + rel / knobSubSteps[idx]; } -inline int clampSubSteps(int v) { return (v == 1 || v == 2 || v == 4) ? v : 4; } +inline int clampSubSteps(int v) { + return (v == 1 || v == 2 || v == 4) ? v : PF_SHARED_EDGES_PER_DETENT; +} // Call on the frame task (the frame reads the same counters). inline void setKnobSettings(int idx, bool invert, int subSteps) { @@ -140,7 +145,7 @@ struct Button { bool pressed() { bool cur = digitalRead(pin); uint32_t now = millis(); - if (cur != lastState && (now - lastChangeMs) > 50) { + if (cur != lastState && (now - lastChangeMs) > PF_SHARED_BUTTON_DEBOUNCE_MS) { lastState = cur; lastChangeMs = now; if (cur == LOW) { @@ -166,7 +171,7 @@ struct Button { return c; } - bool longPressed(uint32_t threshold = 1000) { + bool longPressed(uint32_t threshold = PF_SHARED_LONG_PRESS_MS) { if (!isDown()) return false; uint32_t now = millis(); if (!longPressFired && (now - pressStartMs) > threshold) { diff --git a/firmware/patternflow/src/core_shared.h b/firmware/patternflow/src/core_shared.h new file mode 100644 index 000000000..4a7f65ae8 --- /dev/null +++ b/firmware/patternflow/src/core_shared.h @@ -0,0 +1,45 @@ +// GENERATED by web/scripts/gen-shared.ts from shared/constants.json. Do not edit. +// Change the JSON and run `npm run gen:shared` in web/. check:shared fails +// while this file says anything else. +// +// License: MIT +#pragma once + +// The geometries PANEL_GEOMETRY picks from in config.h. Two 64x64 modules in a +// chain make the same 128x64 canvas as one 128x64 module. +#define PF_SHARED_PANEL_GEOM_128x64_SINGLE 1 +#define PF_SHARED_PANEL_GEOM_128x64_SINGLE_PHYS_W 128 +#define PF_SHARED_PANEL_GEOM_128x64_SINGLE_PHYS_H 64 +#define PF_SHARED_PANEL_GEOM_128x64_SINGLE_CHAIN 1 +#define PF_SHARED_PANEL_GEOM_128x64_SINGLE_RES_W 128 +#define PF_SHARED_PANEL_GEOM_128x64_SINGLE_RES_H 64 +#define PF_SHARED_PANEL_GEOM_64x64_SINGLE 2 +#define PF_SHARED_PANEL_GEOM_64x64_SINGLE_PHYS_W 64 +#define PF_SHARED_PANEL_GEOM_64x64_SINGLE_PHYS_H 64 +#define PF_SHARED_PANEL_GEOM_64x64_SINGLE_CHAIN 1 +#define PF_SHARED_PANEL_GEOM_64x64_SINGLE_RES_W 64 +#define PF_SHARED_PANEL_GEOM_64x64_SINGLE_RES_H 64 +#define PF_SHARED_PANEL_GEOM_64x64_CHAIN2 3 +#define PF_SHARED_PANEL_GEOM_64x64_CHAIN2_PHYS_W 64 +#define PF_SHARED_PANEL_GEOM_64x64_CHAIN2_PHYS_H 64 +#define PF_SHARED_PANEL_GEOM_64x64_CHAIN2_CHAIN 2 +#define PF_SHARED_PANEL_GEOM_64x64_CHAIN2_RES_W 128 +#define PF_SHARED_PANEL_GEOM_64x64_CHAIN2_RES_H 64 +#define PF_SHARED_PANEL_GEOM_DEFAULT PF_SHARED_PANEL_GEOM_128x64_SINGLE + +// The PEC11R-4220F-S0024 has 24 detents a turn. Two turns cross a parameter's +// whole range, so 48 detents do. +#define PF_SHARED_KNOB_COUNT 4 +#define PF_SHARED_DETENTS_PER_TURN 24 +#define PF_SHARED_TURNS_PER_FULL_RANGE 2 + +// Quadrature edges in one detent. A press held past LONG_PRESS_MS is a long +// press, and button edges closer than BUTTON_DEBOUNCE_MS are ignored. +#define PF_SHARED_EDGES_PER_DETENT 4 +#define PF_SHARED_LONG_PRESS_MS 1000 +#define PF_SHARED_BUTTON_DEBOUNCE_MS 50 + +// The absolute bus speaks integers 0 to BUS_MAX. A pattern that reads only knob +// deltas gets one click for every BUS_UNITS_PER_CLICK a held channel moves. +#define PF_SHARED_BUS_MAX 1000 +#define PF_SHARED_BUS_UNITS_PER_CLICK 10 diff --git a/firmware/patternflow/src/fonts/README.md b/firmware/patternflow/src/fonts/README.md index 77324ebab..1689e3756 100644 --- a/firmware/patternflow/src/fonts/README.md +++ b/firmware/patternflow/src/fonts/README.md @@ -2,18 +2,13 @@ From [trip5/Matrix-Fonts](https://github.com/trip5/Matrix-Fonts) (CC-BY). -| File | Use | -|------|-----| -| `MatrixLight8X.h` | SELECT titles, MQTT message banners | -| `MatrixLight6.h` | NETWORK / OSC / AUD / KNOB MAP / chrome hints | +**Optional — not the fonts the panel uses by default.** The panel's own screens draw with the Adafruit GFX built-in 5×7 (pattern names on SELECT), TomThumb (NETWORK rows, hints, status lines) and Org_01 (banners and titles); `../core_ui_fonts.h` binds those three roles. This pair is kept here for anyone who prefers it — the fonts Simone Majocchi's tree uses — and is switched in from `patternflow_secrets.h` or an edition's `overrides.h`, with the block at the bottom of `../core_ui_fonts.h`. -Regenerate after updating a `.bdf`: +| File | Role it can take | +|------|-----| +| `MatrixLight8X.h` | SELECT titles, banners (`PF_UI_FONT_SELECT`, `PF_UI_FONT_TITLE`) | +| `MatrixLight6.h` | NETWORK / KNOB MAP / chrome hints (`PF_UI_FONT_CHROME`) | -```bash -python toolchain/bdf_to_gfxfont.py src/fonts/MatrixLight8X.bdf \ - -o src/fonts/MatrixLight8X.h --name MatrixLight8X -python toolchain/bdf_to_gfxfont.py src/fonts/MatrixLight6.bdf \ - -o src/fonts/MatrixLight6.h --name MatrixLight6 -``` +The `.bdf` sources sit beside the headers. The `toolchain/bdf_to_gfxfont.py` this page used to name for regenerating them is not in this repository, so regenerating a header after editing a `.bdf` needs a BDF → Adafruit GFXfont converter of your own. ASCII `0x20`–`0x7E` only (overlays run text through `asciiFold`). diff --git a/firmware/patternflow/src/hub75/ESP32-HUB75-MatrixPanel-I2S-DMA.h b/firmware/patternflow/src/hub75/ESP32-HUB75-MatrixPanel-I2S-DMA.h index 05a3c56af..354f18761 100644 --- a/firmware/patternflow/src/hub75/ESP32-HUB75-MatrixPanel-I2S-DMA.h +++ b/firmware/patternflow/src/hub75/ESP32-HUB75-MatrixPanel-I2S-DMA.h @@ -347,7 +347,7 @@ struct HUB75_I2S_CFG bool _clockphase = true, uint16_t _min_refresh_rate = 60, uint8_t _pixel_color_depth_bits = PIXEL_COLOR_DEPTH_BITS_DEFAULT) - : mx_width(_w), mx_height(_h), chain_length(_chain), gpio(_pinmap), driver(_drv), double_buff(_dbuff), i2sspeed(_i2sspeed), latch_blanking(_latblk), clkphase(_clockphase), min_refresh_rate(_min_refresh_rate) + : mx_width(_w), mx_height(_h), chain_length(_chain), gpio(_pinmap), driver(_drv), line_decoder(_line_drv), double_buff(_dbuff), i2sspeed(_i2sspeed), latch_blanking(_latblk), clkphase(_clockphase), min_refresh_rate(_min_refresh_rate) { setPixelColorDepthBits(_pixel_color_depth_bits); } diff --git a/firmware/patternflow/src/hub75/UPSTREAM.md b/firmware/patternflow/src/hub75/UPSTREAM.md new file mode 100644 index 000000000..28abd48eb --- /dev/null +++ b/firmware/patternflow/src/hub75/UPSTREAM.md @@ -0,0 +1,80 @@ +# Upstream + +This folder is [ESP32-HUB75-MatrixPanel-DMA](https://github.com/mrcodetastic/ESP32-HUB75-MatrixPanel-DMA) +at tag `3.0.13`, commit `a6221865c71fd5aeba885c31b81fe41bd36c5705` (25 October +2025). It was copied in on 31 July 2026, in `42bfc10`. Upstream's `master` was +already past the tag that day, but the copy is the tag: every upstream file here +that [patternflow.patch](patternflow.patch) does not name is byte for byte the +one at `a622186`, plus the commits under +[Taken from upstream since](#taken-from-upstream-since). + +The patch is every change Patternflow made since. It is `diff -ruN` of +upstream's `src/`, with those commits on it, against this folder, over the +files both carry, and [VENDORED.md](VENDORED.md) says why each change is there. + +## What was not copied + +Only upstream's `src/` is here, and without its three images: + +- `platforms/esp32/RGB_HUB75_PINS.png` +- `platforms/esp32s3/ESP32-S3-DevKitC-1-pin-layout.png` +- `platforms/esp32s3/ReservedPinsForPSRAM.PNG` + +Everything at upstream's root stayed behind: `examples/`, `doc/`, `testing/`, +`LICENSE.txt` and the build manifests. One manifest came along under another +name. `library.properties` is `UPSTREAM_library.properties` here, kept for its +version line. + +## Upstream's TODO + +`setupDMA()` in `ESP32-HUB75-MatrixPanel-I2S-DMA.cpp` has +`// TODO: should we release all previous rowBitStructs here???`. It is +upstream's, and it stays as it is. Editing it would add a hunk to the patch. + +## Rebuilding the patch + +From the repository root, with GNU diff: + +```sh +U=$(mktemp -d) +git clone -q https://github.com/mrcodetastic/ESP32-HUB75-MatrixPanel-DMA "$U/upstream" +git -C "$U/upstream" checkout -q a6221865c71fd5aeba885c31b81fe41bd36c5705 +git -C "$U/upstream" cherry-pick -n 7bf3b6c1dfb407f02ff6f3394b56d6f813048f5c +ln -s "$U/upstream/src" "$U/a" +ln -s "$PWD/firmware/patternflow/src/hub75" "$U/b" +(cd "$U" && diff -ruN -x VENDORED.md -x UPSTREAM.md -x UPSTREAM_library.properties \ + -x patternflow.patch -x '*.png' -x '*.PNG' a b) | + sed -E -e '/^diff /d' -e 's/^(---|\+\+\+) ([ab]\/[^[:space:]]+).*/\1 \2/' \ + > firmware/patternflow/src/hub75/patternflow.patch +``` + +The `sed` drops the timestamps and the command lines diff prints, so the same +tree always gives the same file. Rebuild the patch instead of editing it. +Upstream ends some lines in spaces, the patch carries them as context, and an +editor that trims trailing whitespace (`.editorconfig` asks for that) breaks +those hunks. + +To check it, apply it to a clean copy of the tag with the taken commits on it, +which is what the rebuild left in `$U/upstream`. The last `diff -r` prints +nothing: + +```sh +cp -R "$U/upstream/src" "$U/check" +patch -p1 --fuzz=0 -d "$U/check" < firmware/patternflow/src/hub75/patternflow.patch +diff -r -x VENDORED.md -x UPSTREAM.md -x UPSTREAM_library.properties \ + -x patternflow.patch -x '*.png' -x '*.PNG' "$U/check" firmware/patternflow/src/hub75 +``` + +## Taken from upstream since + +A commit taken from upstream gets a line here, in the order it was taken, with +its hash and title. Its full hash goes on the end of the `cherry-pick -n` line +in the rebuild above, in the same order, so the patch stays Patternflow's +changes and nothing upstream wrote. + +- `7bf3b6c` "Fix: Properly set line_decoder in HUB75_I2S_CFG struct + constructor" (Christopher King, 3 April 2026). At 3.0.13 the constructor + never sets `line_decoder`, and `initDisplay()` in `core_display.h` builds its + config on the stack, so the field held whatever the stack held. It was taken + ahead of older commits because `c09362a`, the oldest one left, reads the + field to address every row. diff --git a/firmware/patternflow/src/hub75/VENDORED.md b/firmware/patternflow/src/hub75/VENDORED.md index d457c7a3c..0ea88367b 100644 --- a/firmware/patternflow/src/hub75/VENDORED.md +++ b/firmware/patternflow/src/hub75/VENDORED.md @@ -354,12 +354,13 @@ which is what makes comparing tens of millions of DMA words worth doing. ## What this costs -- Upstream updates are manual. Diff a new release against this tree and re-apply - the additions. -- The additions are marked `PATTERNFLOW ADDITION (not upstream)`, and the two - in-place changes above are marked `PATTERNFLOW FIX` — - `blitRGB888()` in `ESP32-HUB75-MatrixPanel-I2S-DMA.h` and `.cpp`, - `resumeDMAoutput()` in the `.h` only. Nothing else is modified. +- Upstream updates are manual. [UPSTREAM.md](UPSTREAM.md) names the commit this + copy was taken from and the command that rebuilds the patch. +- [patternflow.patch](patternflow.patch) is every change from upstream. A + `PATTERNFLOW ADDITION (not upstream)` or `PATTERNFLOW FIX` marker heads the + larger changes in the source, but not every change has one: the two fields + behind `lastFrameOnTime()`, which `../core_power.h` reads, carry neither. The + patch is the list. - The build server compiles the sketch folder, so it picks this up with no separate configuration. - If the Library Manager copy is still installed it is simply unused: the sketch diff --git a/firmware/patternflow/src/hub75/patternflow.patch b/firmware/patternflow/src/hub75/patternflow.patch new file mode 100644 index 000000000..d4c00813d --- /dev/null +++ b/firmware/patternflow/src/hub75/patternflow.patch @@ -0,0 +1,948 @@ +--- a/ESP32-HUB75-MatrixPanel-I2S-DMA.cpp ++++ b/ESP32-HUB75-MatrixPanel-I2S-DMA.cpp +@@ -1,3 +1,4 @@ ++#include + #include "ESP32-HUB75-MatrixPanel-I2S-DMA.h" + + #if defined(SPIRAM_DMA_BUFFER) +@@ -32,6 +33,68 @@ + */ + #define PIXEL_COLOR_MASK_BIT(color_depth_index, mask_offset) (1 << (color_depth_index + mask_offset)) + ++// PATTERNFLOW: how many EXTRA times one row sends plane `plane`, after the pass ++// that sends every plane once. This replaces upstream's 2^(i - L - 1) rule, and ++// pfOEWindowPixels() further down is its other half - read that comment first. ++// ++// Upstream's rule cannot be made binary without throwing light away. A plane is ++// on the LEDs while the NEXT buffer clocks in, a window belongs to a buffer and ++// not to a pass, and under the 2^k rule the windows that the top plane needs ++// wide are the same ones the planes below it need narrow. Solved exactly, that ++// chain (15 buffers a row on the shipped panel) keeps 42% of the row lit. ++// ++// Searching every grouped order of up to 16 buffers a row for the exactly-binary ++// solution that keeps the most of the row lit found this one, and nothing close: ++// ++// scheme 0 all planes, then D-2 once, then D-1 three times D + 4 buffers ++// depth 8: 0 1 2 3 4 5 6 7 6 7 7 7 -> 66% of the row lit ++// scheme 1 all planes, then D-1 once D + 1 buffers ++// scheme 2 all planes D buffers ++// ++// Fewer buffers is also a faster refresh: 12 against 15 is 325 Hz against 260 on ++// the shipped panel. Schemes 1 and 2 are the fallbacks for a row too long to ++// reach min_refresh_rate with scheme 0; they are dimmer, and still binary. ++// PATTERNFLOW: pad every frame out to an exact refresh rate, for cameras. ++// ++// A row is lit once per refresh, for 1/32 of it - a short pulse, not a glow. A ++// phone's rolling shutter therefore collects a whole number of pulses per sensor ++// line, and unless the exposure is an exact multiple of the refresh period some ++// lines get N and some N + 1: bands. Exposure times are not arbitrary - mains ++// flicker avoidance pins them to 1/50, 1/100, 1/25 or 1/60, 1/30 - and the ++// lowest rate all of those are whole multiples of is 300 Hz. The 12-buffer chain ++// runs at 325.5, which is close to the worst case for 1/50 (6.51 periods) and ++// 1/60 (5.43). So: a run of blank words, OE off, at the end of every frame, ++// bringing it to 16 MHz / 300 = 53,333 clocks. About 8% of the light, no CPU at ++// all - the DMA engine walks the chain by itself - and 1/120 and faster still ++// band at any rate this clock can reach (it would take 600 Hz). ++// 0 turns it off. Never pads below min_refresh_rate. ++#ifndef PF_TARGET_REFRESH_HZ ++#define PF_TARGET_REFRESH_HZ 300 ++#endif ++static constexpr int PF_PAD_CHUNK_WORDS = 256; // one small blank buffer, linked as often as needed ++static ESP32_I2S_DMA_STORAGE_TYPE *pfPadBuffer = nullptr; ++ ++// How many blank clocks one frame needs. Pure, so check_oe.py lifts it out and ++// tests it on a host. 0 means "leave the frame alone": padding is off, the target ++// is not above the refresh floor, the frame is already longer than the target ++// (a slower clock - there is nothing to pad toward), or the gap is more than a ++// quarter of the frame, which would be a different chain's job and not padding's. ++long pfFramePadWords(long clockHz, long frameWords, long targetHz, long minRefreshHz) ++{ ++ if (targetHz <= 0 || targetHz <= minRefreshHz || clockHz <= 0 || frameWords <= 0) return 0; ++ const long targetWords = (clockHz + targetHz / 2) / targetHz; ++ if (targetWords <= frameWords) return 0; ++ if (targetWords - frameWords >= frameWords / 4) return 0; ++ return targetWords - frameWords; ++} ++ ++static inline int pfChainExtraPasses(int plane, int depth, int scheme) ++{ ++ if (scheme == 0 && depth >= 3) return (plane == depth - 1) ? 3 : (plane == depth - 2) ? 1 : 0; ++ if (scheme <= 1 && depth >= 2) return (plane == depth - 1) ? 1 : 0; ++ return 0; ++} ++ + bool MatrixPanel_I2S_DMA::setupDMA(const HUB75_I2S_CFG &_cfg) + { + +@@ -84,39 +147,56 @@ + + ESP_LOGI("I2S-DMA", "Minimum visual refresh rate (scan rate from panel top to bottom) requested: %d Hz", m_cfg.min_refresh_rate); + +- while (1) ++ // PATTERNFLOW: pick the brightest chain scheme that still clears the refresh ++ // floor (see pfChainExtraPasses). lsbMsbTransitionBit is no longer used. ++ for (pfChainScheme = 0; ; pfChainScheme++) + { + int psPerClock = 1000000000000UL / m_cfg.i2sspeed; + int nsPerLatch = ((PIXELS_PER_ROW + CLKS_DURING_LATCH) * psPerClock) / 1000; // time per row + +- // add time to shift out LSBs + LSB-MSB transition bit - this ignores fractions... +- int nsPerRow = m_cfg.getPixelColorDepthBits() * nsPerLatch; ++ int buffersPerRow = m_cfg.getPixelColorDepthBits(); ++ for (int i = 0; i < m_cfg.getPixelColorDepthBits(); i++) ++ buffersPerRow += pfChainExtraPasses(i, m_cfg.getPixelColorDepthBits(), pfChainScheme); + +- // Now add the time for the remaining bit depths +- for (int i = lsbMsbTransitionBit + 1; i < m_cfg.getPixelColorDepthBits(); i++) { +- //nsPerRow += (1 << (i - lsbMsbTransitionBit - 1)) * (m_cfg.getPixelColorDepthBits() - i) * nsPerLatch; +- nsPerRow += (1 << (i - lsbMsbTransitionBit - 1)) * nsPerLatch; +- } +- +- int nsPerFrame = nsPerRow * ROWS_PER_FRAME; ++ int nsPerFrame = buffersPerRow * nsPerLatch * ROWS_PER_FRAME; + int actualRefreshRate = 1000000000UL / (nsPerFrame); + calculated_refresh_rate = actualRefreshRate; + +- ESP_LOGW("I2S-DMA", "lsbMsbTransitionBit of %d gives %d Hz refresh rate.", lsbMsbTransitionBit, actualRefreshRate); +- +- if (actualRefreshRate > m_cfg.min_refresh_rate) +- break; ++ ESP_LOGW("I2S-DMA", "chain scheme %d: %d buffers per row, %d Hz refresh rate.", pfChainScheme, buffersPerRow, actualRefreshRate); + +- if (lsbMsbTransitionBit < m_cfg.getPixelColorDepthBits() - 1) +- lsbMsbTransitionBit++; +- else ++ if (actualRefreshRate > m_cfg.min_refresh_rate || pfChainScheme >= 2) + break; + } + +- if (lsbMsbTransitionBit > 0) ++ // PATTERNFLOW: frame padding to PF_TARGET_REFRESH_HZ (see above). ++ int pfPadWords = 0; ++ if (m_cfg.line_decoder != HUB75_I2S_CFG::SM5266P && m_cfg.line_decoder != HUB75_I2S_CFG::SM5368) + { +- ESP_LOGW("I2S-DMA", "lsbMsbTransitionBit of %d used to achieve refresh rate of %d Hz. Percieved colour depth to the eye may be reduced.", lsbMsbTransitionBit, m_cfg.min_refresh_rate); ++ int buffersPerRow = m_cfg.getPixelColorDepthBits(); ++ for (int i = 0; i < m_cfg.getPixelColorDepthBits(); i++) ++ buffersPerRow += pfChainExtraPasses(i, m_cfg.getPixelColorDepthBits(), pfChainScheme); ++ const long frameWords = (long)buffersPerRow * (PIXELS_PER_ROW + CLKS_DURING_LATCH) * ROWS_PER_FRAME; ++ const long wanted = pfFramePadWords((long)m_cfg.i2sspeed, frameWords, PF_TARGET_REFRESH_HZ, m_cfg.min_refresh_rate); ++ if (wanted > 0) ++ { ++ if (!pfPadBuffer) ++ pfPadBuffer = (ESP32_I2S_DMA_STORAGE_TYPE *)heap_caps_malloc( ++ PF_PAD_CHUNK_WORDS * sizeof(ESP32_I2S_DMA_STORAGE_TYPE), MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA); ++ if (pfPadBuffer) ++ { ++ // Output off, no latch, and the address left on the last row - whose top ++ // plane is still waiting for the next frame's buffer 0 to light it. ++ const ESP32_I2S_DMA_STORAGE_TYPE blank = ++ (ESP32_I2S_DMA_STORAGE_TYPE)(BIT_OE | ((ROWS_PER_FRAME - 1) << BITS_ADDR_OFFSET)); ++ for (int i = 0; i < PF_PAD_CHUNK_WORDS; i++) pfPadBuffer[i] = blank; ++ pfPadWords = (int)wanted; ++ const long total = frameWords + wanted; ++ calculated_refresh_rate = (int)(((long)m_cfg.i2sspeed + total / 2) / total); ++ ESP_LOGW("I2S-DMA", "padding each frame with %d blank clocks: %d Hz refresh rate.", pfPadWords, calculated_refresh_rate); ++ } ++ } + } ++ const int pfPadDescriptors = (pfPadWords + PF_PAD_CHUNK_WORDS - 1) / PF_PAD_CHUNK_WORDS; + + ESP_LOGI("I2S-DMA", "DMA frame buffer color depths: %d", m_cfg.getPixelColorDepthBits()); + +@@ -146,14 +226,14 @@ + int dma_descriptors_per_row = dma_descs_per_row_all_cdepths; + + // Add descriptors for MSB bits after transition +- for (int i = lsbMsbTransitionBit + 1; i < m_cfg.getPixelColorDepthBits(); i++) { +- dma_descriptors_per_row += (1 << (i - lsbMsbTransitionBit - 1)) * dma_descs_per_row_1cdepth; ++ for (int i = 0; i < m_cfg.getPixelColorDepthBits(); i++) { ++ dma_descriptors_per_row += pfChainExtraPasses(i, m_cfg.getPixelColorDepthBits(), pfChainScheme) * dma_descs_per_row_1cdepth; + } + + //dma_descriptors_per_row = 1; + + // Allocate DMA descriptors +- int dma_descriptions_required = dma_descriptors_per_row * ROWS_PER_FRAME; ++ int dma_descriptions_required = dma_descriptors_per_row * ROWS_PER_FRAME + pfPadDescriptors; + + ESP_LOGV("I2S-DMA", "DMA descriptors per row: %d", dma_descriptors_per_row); + ESP_LOGV("I2S-DMA", "DMA descriptors required per buffer: %d", dma_descriptions_required); +@@ -202,13 +282,13 @@ + } + + // Step 2: Handle additional descriptors for bits beyond the lsbMsbTransitionBit +- for (int i = lsbMsbTransitionBit + 1; i < m_cfg.getPixelColorDepthBits(); i++) ++ for (int i = 0; i < m_cfg.getPixelColorDepthBits(); i++) + { + // binary time division setup: we need 2 of bit (LSBMSB_TRANSITION_BIT + 1) four of (LSBMSB_TRANSITION_BIT + 2), etc + // because we sweep through to MSB each time, it divides the number of times we have to sweep in half (saving linked list RAM) + // we need 2^(i - LSBMSB_TRANSITION_BIT - 1) == 1 << (i - LSBMSB_TRANSITION_BIT - 1) passes from i to MSB + +- for (int k = 0; k < (1 << (i - lsbMsbTransitionBit - 1)); k++) ++ for (int k = 0; k < pfChainExtraPasses(i, m_cfg.getPixelColorDepthBits(), pfChainScheme); k++) + { + // Link and send all colour data, all passes of everything in one hit. + for (int dma_desc_1cdepth = 0; dma_desc_1cdepth < dma_descs_per_row_1cdepth; dma_desc_1cdepth++) +@@ -230,6 +310,14 @@ + + + } // end all rows ++ ++ // PATTERNFLOW: the blank run that brings the frame to its target length. ++ for (int left = pfPadWords; left > 0; left -= PF_PAD_CHUNK_WORDS) ++ { ++ const int words = (left < PF_PAD_CHUNK_WORDS) ? left : PF_PAD_CHUNK_WORDS; ++ dma_bus.create_dma_desc_link(pfPadBuffer, words * sizeof(ESP32_I2S_DMA_STORAGE_TYPE), (fb==1)); ++ _dmadescriptor_count++; ++ } + + ESP_LOGI("I2S-DMA", "Created %d DMA descriptors for buffer %d.", _dmadescriptor_count, fb); + +@@ -414,6 +502,530 @@ + } // updateMatrixDMABuffer (specific co-ords change) + + ++/* PATTERNFLOW ADDITION (not upstream) — see the header for why this exists. */ ++ ++// The bitplane transpose, done once per channel value instead of once per ++// plane per channel. A plane wants bit (d + MASK_OFFSET) of each of the six ++// CIE values; the loop used to extract it with six masked tests per plane — ++// about thirty-six per pixel pair, and that loop was 60% of every frame ++// (presentUs 9.9 ms of 16.5 on the 128x64 panel at 6 bits). These tables ++// spread the top `depth` bits of a post-LUT byte's CIE value across 6-bit ++// slots, slot d holding bit d, so one OR of six table reads yields every ++// plane's R1 G1 B1 R2 G2 B2 at once and each plane is then a shift and a ++// mask. Planes 0-4 live in the low word (30 bits), 5-7 in the high one. ++// Bit-exact with the loop it replaces: the DMA words are identical. ++static uint32_t pfSpreadLo[256]; ++static uint32_t pfSpreadHi[256]; ++// lumConvTab is const, so it lives in flash and reaches the loop through the ++// cache; the on-time sum reads it six times a pixel pair. A copy in DRAM. ++static uint16_t pfCie[256]; ++static uint8_t pfSpreadDepth = 0; ++ ++// PATTERNFLOW FIX: the ceiling this encoding actually has. ++// ++// One 6-bit field per plane - three colours x two panel halves - packed into two ++// uint32: planes 0-4 in lo at 6*d, planes 5-9 in hi at 6*(d-5). Plane 9 ends at ++// bit 29 and there is no room for a tenth field. Past that it degrades quietly ++// and then illegally: depth 11 needs bits 30-35 of hi and loses the top four, ++// and depth 12 shifts a uint32 by 36, which is undefined behaviour in both the ++// builder above and the reader in blitRGB888. ++// ++// HUB75_I2S_CFG clamps a requested depth to PIXEL_COLOR_DEPTH_BITS_MAX, which is ++// 12, so the configuration permits what this path cannot represent. The upstream ++// updateMatrixDMABuffer path does not use these tables and is unaffected, which ++// is why the fix is a guard here rather than a lower MAX - fillScreen() still ++// goes through it. Nothing in this firmware asks for more than 8. ++static constexpr uint8_t PF_SPREAD_MAX_DEPTH = 10; ++ ++ ++// Both halves of the panel for one column: the post-processed bytes, their ++// on-time, and every plane's bits spread into (lo, hi). ++#define PF_PLANES_FOR(srcT, srcB, lo, hi, onTime) \ ++ { \ ++ uint8_t _t[3], _m[3]; \ ++ PF_POST_PIXEL((srcT), _t); \ ++ PF_POST_PIXEL((srcB), _m); \ ++ (onTime) += (uint32_t)pfCie[_t[0]] + pfCie[_t[1]] + pfCie[_t[2]] + \ ++ (uint32_t)pfCie[_m[0]] + pfCie[_m[1]] + pfCie[_m[2]]; \ ++ (lo) = pfSpreadLo[_t[0]] | (pfSpreadLo[_t[1]] << 1) | (pfSpreadLo[_t[2]] << 2) | \ ++ (pfSpreadLo[_m[0]] << 3) | (pfSpreadLo[_m[1]] << 4) | (pfSpreadLo[_m[2]] << 5); \ ++ (hi) = pfSpreadHi[_t[0]] | (pfSpreadHi[_t[1]] << 1) | (pfSpreadHi[_t[2]] << 2) | \ ++ (pfSpreadHi[_m[0]] << 3) | (pfSpreadHi[_m[1]] << 4) | (pfSpreadHi[_m[2]] << 5); \ ++ } ++ ++// Two adjacent columns share one aligned 32-bit word in every plane. ++#define PF_CLEAR32 (((uint32_t)BITMASK_RGB12_CLEAR << 16) | (uint32_t)BITMASK_RGB12_CLEAR) ++ ++// An empty asm statement that claims to rewrite its operand. The value is ++// untouched; the optimiser just stops knowing where it came from. Each use ++// says what this GCC does with that knowledge. MSVC, which the host test may ++// be built with, has no such statement and needs none. ++#if defined(__GNUC__) ++#define PF_OPAQUE(v) __asm__("" : "+r"(v)) ++#define PF_NOINLINE __attribute__((noinline)) ++#else ++#define PF_OPAQUE(v) ((void)0) ++#define PF_NOINLINE ++#endif ++// PF_NOINLINE keeps a function out of blitRGB888. For the passes further down ++// that is the whole idea: folded back in, they would share one set of registers ++// again. For the rest it is about where the code lives - whatever is inlined ++// into an IRAM function is in IRAM, and that is internal RAM. ++ ++// Not inlined: this runs once per depth change, and inlined into blitRGB888 it ++// sat in IRAM for nothing. ++static PF_NOINLINE void pfBuildSpread(uint8_t depth) ++{ ++ const uint8_t maskOffset = 16 - depth; ++ for (int v = 0; v < 256; v++) ++ { ++#ifdef NO_CIE1931 ++ const uint16_t cie = (uint16_t)v; ++#else ++ const uint16_t cie = lumConvTab[v]; ++#endif ++ pfCie[v] = cie; ++ // PATTERNFLOW FIX: round to nearest, not toward zero. ++ // ++ // Only the top `depth` bits of the 16-bit CIE value reach a plane; the low ++ // byte was simply dropped. Truncation is biased downward by up to half a ++ // plane step everywhere, and at the bottom of the range that is the whole ++ // signal: measured over the shipped white balance, eight input levels emitted ++ // nothing at all, and neighbouring neutral greys came out as opposite colour ++ // casts because each channel crossed its threshold at a different code. On the ++ // panel, level 9 read blue, level 17 cyan and level 18 red - all three from a ++ // neutral input. Adding half a step before the test costs nothing at runtime: ++ // this loop runs once per depth change, not per pixel. ++ // ++ // Computed over all 256 levels x 3 channels: dead levels 8 -> 4, level 9 ++ // becomes (1,1,1), levels 17 and 18 both become (2,2,2), and full-scale white ++ // moves from (212,255,240) to (212,255,241) - so the hand-converged LED_WB_* ++ // constants keep their meaning. tests/blit_test.cpp carries the same rounding ++ // in its independent reference; the two must move together or 30M compared ++ // DMA words disagree. ++#ifdef NO_CIE1931 ++ const uint32_t thresholdSrc = cie; ++#else ++ uint32_t thresholdSrc = (uint32_t)cie + (maskOffset ? (1u << (maskOffset - 1)) : 0u); ++ if (thresholdSrc > 0xFFFFu) thresholdSrc = 0xFFFFu; ++#endif ++ uint32_t lo = 0, hi = 0; ++ for (uint8_t d = 0; d < depth; d++) ++ { ++#ifdef NO_CIE1931 ++ const uint16_t mask = d; ++#else ++ const uint16_t mask = PIXEL_COLOR_MASK_BIT(d, maskOffset); ++#endif ++ const uint32_t bit = (thresholdSrc & mask) ? 1u : 0u; ++ ++ if (d < 5) lo |= bit << (6 * d); ++ else hi |= bit << (6 * (d - 5)); ++ } ++ pfSpreadLo[v] = lo; ++ pfSpreadHi[v] = hi; ++ } ++ pfSpreadDepth = depth; ++} ++ ++// Rec.601 luma pivot for the saturation boost, then the caller's gamma/WB LUT. ++// Kept as a macro so it stays inside the pixel loop with no call overhead. ++#define PF_POST_PIXEL(src, dst) { int _r = (src)[0], _g = (src)[1], _b = (src)[2]; int _y = (_r * 77 + _g * 150 + _b * 29) >> 8; _r = _y + (((_r - _y) * satBoostQ8) >> 8); _g = _y + (((_g - _y) * satBoostQ8) >> 8); _b = _y + (((_b - _y) * satBoostQ8) >> 8); if (_r < 0) _r = 0; else if (_r > 255) _r = 255; if (_g < 0) _g = 0; else if (_g > 255) _g = 255; if (_b < 0) _b = 0; else if (_b > 255) _b = 255; (dst)[0] = lutR[_r]; (dst)[1] = lutG[_g]; (dst)[2] = lutB[_b]; } ++ ++// --------------------------------------------------------------------------- ++// The kernel as three short passes (2026-10). ++// ++// Counted in the linked image, the one long loop this replaces - it is still ++// here, as pfBlitRowPair - ran 635 instructions per column pair in 691 ++// cycles: compute-bound, not waiting on the DMA engine as its own comment ++// said. Three things in the disassembly made up the excess, and none of them ++// was the arithmetic: ++// ++// - depth was a run-time value, so every plane word paid for a multiply by ++// six, two variable shifts and a plane pointer reloaded from a stack ++// array - 25 instructions where 10 do; ++// - this compiler (GCC 8.4 at -Os) synthesises x77, x150 and x29 as ++// shift/add chains, about 20 instructions a pixel where three multiplies ++// do; ++// - one 400-instruction body keeps more values alive than the 14 registers ++// a windowed call leaves it, so every column pair made 48 reloads from the ++// stack and 12 literal loads. ++// ++// Each pass below is short enough to keep its state in registers, and the ++// plane pass has the depth written into it: ++// ++// 1a pfPostRow saturation + gamma/WB LUT RGB888 -> 3 bytes a pixel ++// pfPostRowRaw the same, when the LUTs are the identity ++// 1b pfSpreadRow on-time + plane spread 6 bytes -> (lo, hi) a column ++// 2 pfStoreRow8 plane words 2 x (lo, hi) -> 8 plane words ++// ++// 426 instructions per column pair, counted the same way - 390 when the LUTs ++// are the identity, which is what config.h ships. Same tables, same DMA ++// words, same on-time sum: check_blit.py holds this to its scalar reference ++// exactly as it held the loop. What the split costs is a store and a reload ++// of every intermediate, through scratch on the caller's stack. ++// ++// THE NEXT SPEED-UP HAS A FLOOR THAT IS NOT THE CPU. flipDMABuffer() returns ++// before the swap happens: the retired buffer stays on the panel until the DMA ++// finishes its pass, up to 3.3 ms, and the next blit writes into that very ++// buffer. Nothing shows, for one reason - the scan takes 96 us a row pair ++// (12 buffers of 128 words at 16 MHz), it is already ahead when the blit ++// starts, and a writer slower than the reader never catches it. At 390 ++// instructions a column pair this blit cannot go under 104 us a row pair. A ++// kernel that does get under 96 - 3.07 ms a frame - or one split across the ++// two cores, overtakes the scan and tears; it has to wait for the swap first. ++ ++// GCC's count-register pass rewrites `while (p != end)` as a down-counter, ++// meant for Xtensa's zero-overhead loop instruction. In passes 1a and 1b it ++// then finds no register left for the counter and keeps it on the stack - ++// load, decrement, store, branch, every time round. With the pass off the test ++// is a compare against a pointer. ++#if defined(__XTENSA__) ++#define PF_NO_COUNT_REG __attribute__((optimize("no-branch-count-reg"))) ++#else ++#define PF_NO_COUNT_REG ++#endif ++ ++// Columns per trip through the passes. The scratch between them is 192 + 256 ++// bytes at this size, and it lives on the stack of whoever calls blitRGB888 - ++// not in static storage, so a blit split by row pair across the two cores ++// would have nothing shared to trip over. ++#define PF_BLIT_CHUNK 32 ++ ++// What pass 1b hands pass 2 for one column: every plane's R1 G1 B1 R2 G2 B2. ++struct PfPlanes { uint32_t lo, hi; }; ++ ++// One pixel of pass 1a as far as the clamp, written once for its two forms. ++// ++// (c*q + y*(256-q)) >> 8 is PF_POST_PIXEL's y + (((c-y)*q) >> 8) with the y*256 ++// carried inside the shift - exact for any q, because the shift is arithmetic. ++// It costs one multiply shared by the three channels where the other form ++// costs a subtract in each. ++#define PF_SATURATE(src) \ ++ const int r = (src)[0], g = (src)[1], b = (src)[2]; \ ++ const int k = ((r * wr + g * wg + b * wb) >> 8) * kq; \ ++ int R = (r * q + k) >> 8, G = (g * q + k) >> 8, B = (b * q + k) >> 8; \ ++ if (R < 0) R = 0; else if (R > 255) R = 255; \ ++ if (G < 0) G = 0; else if (G > 255) G = 255; \ ++ if (B < 0) B = 0; else if (B > 255) B = 255; ++// What it reads besides the pixel. The weights are hidden, or the three ++// multiplies come back as the shift/add chains. ++#define PF_SATURATE_CONSTANTS(q) \ ++ const int kq = 256 - (q); \ ++ int wr = 77, wg = 150, wb = 29; \ ++ PF_OPAQUE(wr); PF_OPAQUE(wg); PF_OPAQUE(wb); ++ ++// Pass 1a. `n` pixels of one canvas row -> the three bytes the tables are ++// indexed by, written six apart: the other row of the pair fills the gaps, and ++// pass 1b walks one pointer instead of two. ++static PF_NOINLINE PF_NO_COUNT_REG void IRAM_ATTR ++pfPostRow(const uint8_t *src, unsigned n, uint8_t *out, ++ const uint8_t *lutR, const uint8_t *lutG, const uint8_t *lutB, int q) ++{ ++ PF_SATURATE_CONSTANTS(q) ++ const uint8_t *const end = src + (size_t)n * 3; ++ do ++ { ++ PF_SATURATE(src) ++ out[0] = lutR[R]; out[1] = lutG[G]; out[2] = lutB[B]; ++ src += 3; out += 6; ++ } while (src != end); ++} ++ ++// Pass 1a when all three LUTs are the identity - gamma 1.0 and white balance ++// 1/1/1, which is what config.h ships. The lookups would hand each index back ++// unchanged, at two instructions apiece and three table pointers this loop has ++// no registers for: 41 instructions a pixel with them, 32 without. ++static PF_NOINLINE PF_NO_COUNT_REG void IRAM_ATTR ++pfPostRowRaw(const uint8_t *src, unsigned n, uint8_t *out, int q) ++{ ++ PF_SATURATE_CONSTANTS(q) ++ const uint8_t *const end = src + (size_t)n * 3; ++ do ++ { ++ PF_SATURATE(src) ++ out[0] = (uint8_t)R; out[1] = (uint8_t)G; out[2] = (uint8_t)B; ++ src += 3; out += 6; ++ } while (src != end); ++} ++ ++static bool IRAM_ATTR pfLutIsIdentity(const uint8_t *lut) ++{ ++ for (unsigned v = 0; v < 256; v++) ++ if (lut[v] != v) return false; ++ return true; ++} ++ ++// Pass 1b. Six index bytes per column - the top row's three, then the bottom ++// row's - become that column's planes, and their on-time is returned. ++static PF_NOINLINE PF_NO_COUNT_REG uint32_t IRAM_ATTR ++pfSpreadRow(const uint8_t *idx, unsigned n, PfPlanes *out) ++{ ++ const uint32_t *const spreadLo = pfSpreadLo, *const spreadHi = pfSpreadHi; ++ const uint16_t *const cie = pfCie; ++ uint32_t on = 0; ++ const PfPlanes *const end = out + n; ++ do ++ { ++ const unsigned t0 = idx[0], t1 = idx[1], t2 = idx[2], m0 = idx[3], m1 = idx[4], m2 = idx[5]; ++ on += (uint32_t)cie[t0] + cie[t1] + cie[t2] + cie[m0] + cie[m1] + cie[m2]; ++ out->lo = spreadLo[t0] | (spreadLo[t1] << 1) | (spreadLo[t2] << 2) | ++ (spreadLo[m0] << 3) | (spreadLo[m1] << 4) | (spreadLo[m2] << 5); ++ out->hi = spreadHi[t0] | (spreadHi[t1] << 1) | (spreadHi[t2] << 2) | ++ (spreadHi[m0] << 3) | (spreadHi[m1] << 4) | (spreadHi[m2] << 5); ++ idx += 6; out++; ++ } while (out != end); ++ return on; ++} ++ ++static inline uint32_t pfSwapHalves(uint32_t v) { return (v << 16) | (v >> 16); } ++ ++#if defined(SPIRAM_DMA_BUFFER) ++#define PF_PLANE_WRITEBACK(p) Cache_WriteBack_Addr((uint32_t)(p), sizeof(uint32_t)) ++#else ++#define PF_PLANE_WRITEBACK(p) ((void)0) ++#endif ++ ++// One plane's word for two adjacent columns: the ten control bits of each stay, ++// the six colour bits of each are replaced. `fieldA` is already in bits 0-5 and ++// `fieldB` in bits 16-21; which column the DMA sends first is the FIFO order's ++// business, as in the loop. The word access is spelled the way the loop spells ++// it, for the reason given there. ++#define PF_STORE_PLANE(fieldA, fieldB) \ ++ { \ ++ void *aligned = __builtin_assume_aligned(p, sizeof(uint32_t)); \ ++ uint32_t word; \ ++ memcpy(&word, aligned, sizeof(word)); \ ++ word = (word & PF_CLEAR32) | \ ++ (aLow ? ((fieldA) | (fieldB)) : pfSwapHalves((fieldA) | (fieldB))); \ ++ memcpy(aligned, &word, sizeof(word)); \ ++ PF_PLANE_WRITEBACK(p); \ ++ p = (ESP32_I2S_DMA_STORAGE_TYPE *)((uint8_t *)p + stride); \ ++ } ++ ++// Pass 2, for a depth of exactly 8. `pairs` column pairs of (lo, hi) go into ++// all eight planes of the row pair; `col` is plane 0 at the first column and ++// `stride` the bytes from one plane to the next. With the depth known every ++// field's position is a constant - an extract and a shift - where the loop ++// computes 6 * d and shifts by a register. ++static PF_NOINLINE void IRAM_ATTR ++pfStoreRow8(const PfPlanes *in, unsigned pairs, ESP32_I2S_DMA_STORAGE_TYPE *col, size_t stride) ++{ ++ const bool aLow = (ESP32_TX_FIFO_POSITION_ADJUST(0) == 0); ++ const uint32_t fieldBMask = 0x003F0000u; ++ const PfPlanes *const end = in + (size_t)pairs * 2; ++ do ++ { ++ const uint32_t loA = in[0].lo, hiA = in[0].hi, loB = in[1].lo, hiB = in[1].hi; ++ // Hidden: shown that p starts at col, GCC walks a pointer of its own for ++ // half the planes and keeps stride multiples on the stack for the rest, ++ // three instructions a column pair more than stepping p by the stride. ++ ESP32_I2S_DMA_STORAGE_TYPE *p = col; ++ PF_OPAQUE(p); ++ PF_STORE_PLANE(loA & 0x3Fu, (loB << 16) & fieldBMask); ++ PF_STORE_PLANE((loA >> 6) & 0x3Fu, (loB << 10) & fieldBMask); ++ PF_STORE_PLANE((loA >> 12) & 0x3Fu, (loB << 4) & fieldBMask); ++ PF_STORE_PLANE((loA >> 18) & 0x3Fu, (loB >> 2) & fieldBMask); ++ PF_STORE_PLANE((loA >> 24) & 0x3Fu, (loB >> 8) & fieldBMask); ++ PF_STORE_PLANE(hiA & 0x3Fu, (hiB << 16) & fieldBMask); ++ PF_STORE_PLANE((hiA >> 6) & 0x3Fu, (hiB << 10) & fieldBMask); ++ PF_STORE_PLANE((hiA >> 12) & 0x3Fu, (hiB << 4) & fieldBMask); ++ in += 2; col += 2; ++ } while (in != end); ++} ++ ++// The loop the passes replaced, kept whole for what they do not cover: an odd ++// width, where alternate planes are not word-aligned, and any depth but 8. ++// One row pair per call; returns its on-time. Nothing this firmware ships ++// reaches it, so it is the one part of the blit that is not in IRAM - and not ++// inlined, or it would follow blitRGB888 back there. ++static PF_NOINLINE uint64_t ++pfBlitRowPair(const uint8_t *top, const uint8_t *bot, ++ ESP32_I2S_DMA_STORAGE_TYPE *plane0, uint16_t w, uint8_t depth, ++ const uint8_t *lutR, const uint8_t *lutG, const uint8_t *lutB, int satBoostQ8) ++{ ++ uint64_t onTime = 0; ++ ++ // Row pointers do not change across the pixels of a row — hoist all of ++ // them once instead of recomputing per pixel per plane. A row's planes ++ // are one allocation, `w` words apart (rowBitStruct::getDataPtr). ++ ESP32_I2S_DMA_STORAGE_TYPE *plane[16]; ++ for (uint8_t d = 0; d < depth; d++) ++ plane[d] = plane0 + (size_t)d * w; ++ ++ // Two columns per step. x and x + 1 are adjacent uint16_t words in every ++ // plane, so both halves of the panel for both columns — two pixel pairs — ++ // are one aligned 32-bit read-modify-write per plane instead of two. ++ // (This used to add that every access arbitrates with the DMA engine and ++ // that this is the cost. Counted, it is not: see the passes above.) ++ // Which column takes the low half is the FIFO order's business, decided ++ // once by the macro at compile time. ++ const bool aLow = (ESP32_TX_FIFO_POSITION_ADJUST(0) == 0); ++ uint16_t x = 0; ++ // DMA allocation is word-aligned. With an even width, every plane and ++ // every two-column offset is too. Tell the compiler that fact below: ++ // memcpy on a uint16_t* otherwise becomes four byte loads, a stack ++ // spill, and four byte stores on Xtensa, not the intended word access. ++ // Odd widths use the scalar path since alternate planes are unaligned. ++ const uint16_t pairedWidth = (w & 1) ? 0 : w; ++ for (; x + 1 < pairedWidth; x += 2) ++ { ++ uint32_t loA, hiA, loB, hiB; ++ PF_PLANES_FOR(top + (size_t)x * 3, bot + (size_t)x * 3, loA, hiA, onTime); ++ PF_PLANES_FOR(top + (size_t)(x + 1) * 3, bot + (size_t)(x + 1) * 3, loB, hiB, onTime); ++ ++ uint8_t d = depth; ++ while (d > 5) ++ { ++ --d; ++ const uint32_t bA = (hiA >> (6 * (d - 5))) & 0x3Fu; ++ const uint32_t bB = (hiB >> (6 * (d - 5))) & 0x3Fu; ++ const uint32_t both = aLow ? (bA | (bB << 16)) : (bB | (bA << 16)); ++ ESP32_I2S_DMA_STORAGE_TYPE *p = plane[d] + x; ++ void *aligned = __builtin_assume_aligned(p, sizeof(uint32_t)); ++ uint32_t word; ++ memcpy(&word, aligned, sizeof(word)); ++ word = (word & PF_CLEAR32) | both; ++ memcpy(aligned, &word, sizeof(word)); ++#if defined(SPIRAM_DMA_BUFFER) ++ Cache_WriteBack_Addr((uint32_t)p, sizeof(word)); ++#endif ++ } ++ while (d) ++ { ++ --d; ++ const uint32_t bA = (loA >> (6 * d)) & 0x3Fu; ++ const uint32_t bB = (loB >> (6 * d)) & 0x3Fu; ++ const uint32_t both = aLow ? (bA | (bB << 16)) : (bB | (bA << 16)); ++ ESP32_I2S_DMA_STORAGE_TYPE *p = plane[d] + x; ++ void *aligned = __builtin_assume_aligned(p, sizeof(uint32_t)); ++ uint32_t word; ++ memcpy(&word, aligned, sizeof(word)); ++ word = (word & PF_CLEAR32) | both; ++ memcpy(aligned, &word, sizeof(word)); ++#if defined(SPIRAM_DMA_BUFFER) ++ Cache_WriteBack_Addr((uint32_t)p, sizeof(word)); ++#endif ++ } ++ } ++ ++ // Scalar fallback for odd widths, one column at a time. ++ for (; x < w; ++x) ++ { ++ uint32_t lo, hi; ++ PF_PLANES_FOR(top + (size_t)x * 3, bot + (size_t)x * 3, lo, hi, onTime); ++ const uint16_t idx = ESP32_TX_FIFO_POSITION_ADJUST(x); ++ uint8_t d = depth; ++ while (d > 5) ++ { ++ --d; ++ const uint16_t bits = (uint16_t)((hi >> (6 * (d - 5))) & 0x3Fu); ++ ESP32_I2S_DMA_STORAGE_TYPE *p = plane[d] + idx; ++ *p = (*p & BITMASK_RGB12_CLEAR) | bits; ++#if defined(SPIRAM_DMA_BUFFER) ++ Cache_WriteBack_Addr((uint32_t)p, sizeof(ESP32_I2S_DMA_STORAGE_TYPE)); ++#endif ++ } ++ while (d) ++ { ++ --d; ++ const uint16_t bits = (uint16_t)((lo >> (6 * d)) & 0x3Fu); ++ ESP32_I2S_DMA_STORAGE_TYPE *p = plane[d] + idx; ++ *p = (*p & BITMASK_RGB12_CLEAR) | bits; ++#if defined(SPIRAM_DMA_BUFFER) ++ Cache_WriteBack_Addr((uint32_t)p, sizeof(ESP32_I2S_DMA_STORAGE_TYPE)); ++#endif ++ } ++ } ++ ++ return onTime; ++} ++ ++void IRAM_ATTR MatrixPanel_I2S_DMA::blitRGB888(const uint8_t *rgb, ++ const uint8_t *lutR, const uint8_t *lutG, const uint8_t *lutB, ++ int satBoostQ8) ++{ ++ if (!initialized || rgb == nullptr) ++ return; ++ ++ const uint16_t w = PIXELS_PER_ROW; ++ const uint8_t depth = m_cfg.getPixelColorDepthBits(); ++ const uint8_t rows = ROWS_PER_FRAME; ++ // Refuse rather than corrupt. Above PF_SPREAD_MAX_DEPTH the plane fields do not ++ // fit and the shifts are undefined; leaving the previous frame up and saying so ++ // once is diagnosable, where writing garbage into the DMA buffer is not. Cannot ++ // fire on any configuration this firmware ships. ++ if (depth > PF_SPREAD_MAX_DEPTH) ++ { ++ static bool warned = false; ++ if (!warned) ++ { ++ warned = true; ++ ESP_LOGE("I2S-DMA", "blitRGB888: colour depth %d exceeds %d, the most the " ++ "plane encoding can hold - frame not written", depth, PF_SPREAD_MAX_DEPTH); ++ } ++ return; ++ } ++ if (pfSpreadDepth != depth) pfBuildSpread(depth); ++ ++ // Total LED on-time this frame asks for, summed as we go. The per-pixel ++ // linear values are computed below anyway and live in registers when we add ++ // them, so this rides along for six adds per pixel pair — nothing next to ++ // the bitplane writes underneath it. See lastFrameOnTime(). ++ uint64_t onTime = 0; ++ ++ // The passes need every plane word-aligned (an even width) and the depth ++ // pass 2 is written for. Everything else goes to the loop they replaced. ++ const bool passes = (depth == 8) && !(w & 1); ++ // Asked of the tables on every frame, not remembered: they are the caller's, ++ // and it rebuilds them in place whenever gamma or white balance is tuned. ++ // 768 compares a frame, against 36 instructions on each of its column pairs. ++ const bool rawLut = passes && pfLutIsIdentity(lutR) && pfLutIsIdentity(lutG) && pfLutIsIdentity(lutB); ++ // A row's planes are one allocation, `w` words apart ++ // (rowBitStruct::getDataPtr), so plane 0 and a stride reach all of them. ++ const size_t stride = (size_t)w * sizeof(ESP32_I2S_DMA_STORAGE_TYPE); ++ uint8_t idx[PF_BLIT_CHUNK * 6]; ++ PfPlanes planes[PF_BLIT_CHUNK]; ++ ++ // A two-scan panel lights row `r` and row `r + ROWS_PER_FRAME` together, and ++ // both live in the same uint16_t. Walk the pairs, not the rows. ++ for (uint8_t row = 0; row < rows; row++) ++ { ++ const uint8_t *top = rgb + (size_t)row * w * 3; ++ const uint8_t *bot = rgb + ((size_t)row + rows) * w * 3; ++ ESP32_I2S_DMA_STORAGE_TYPE *plane0 = getRowDataPtr(row, 0); ++ ++ if (!passes) ++ { ++ onTime += pfBlitRowPair(top, bot, plane0, w, depth, lutR, lutG, lutB, satBoostQ8); ++ continue; ++ } ++ for (unsigned x = 0; x < w; x += PF_BLIT_CHUNK) ++ { ++ const unsigned n = (w - x < PF_BLIT_CHUNK) ? (w - x) : PF_BLIT_CHUNK; ++ if (rawLut) ++ { ++ pfPostRowRaw(top + (size_t)x * 3, n, idx, satBoostQ8); ++ pfPostRowRaw(bot + (size_t)x * 3, n, idx + 3, satBoostQ8); ++ } ++ else ++ { ++ pfPostRow(top + (size_t)x * 3, n, idx, lutR, lutG, lutB, satBoostQ8); ++ pfPostRow(bot + (size_t)x * 3, n, idx + 3, lutR, lutG, lutB, satBoostQ8); ++ } ++ onTime += pfSpreadRow(idx, n, planes); ++ pfStoreRow8(planes, n / 2, plane0 + x, stride); ++ } ++ } ++ ++ _lastFrameOnTime = onTime; ++ // What an all-white frame would have summed to: every pixel, every channel, ++ // at the top of the CIE table. Recomputed rather than cached because panel ++ // geometry and colour depth are both configurable. ++ _maxFrameOnTime = (uint64_t)w * rows * 2ULL * 3ULL * lumConvTab[255]; ++} // blitRGB888 ++ ++ + /* Update the entire buffer with a single specific colour - quicker */ + void MatrixPanel_I2S_DMA::updateMatrixDMABuffer(uint8_t red, uint8_t green, uint8_t blue) + { +@@ -630,6 +1242,78 @@ + } while (row_idx); + } + ++// PATTERNFLOW FIX: how wide the OE window written into one plane's buffer is. ++// Pure, so toolchain/check_oe.py can lift it out verbatim and test it on a host. ++// ++// THE FACT EVERYTHING HERE RESTS ON. A plane's bits are latched at the END of ++// its buffer, so they are on the LEDs while the NEXT buffer in the descriptor ++// chain clocks in - and it is that next buffer's OE bits that gate them. ++// clearFrameBuffer() already knows this: it marks buffer 0 with the PREVIOUS ++// row's address "because it is used to display previous row while we pump in ++// MSBs for the next row". So the window written into buffer b does not light ++// plane b. It lights whatever was latched before it, and buffer 0's window is ++// the top plane's - which is what upstream's (2 * depth - colouridx) % depth was ++// reaching for when it gave "the LSB" a full window. ++// ++// With the chain from pfChainExtraPasses(), at depth 8, scheme 0: ++// ++// sent 0 1 2 3 4 5 6 7 6 7 7 7 | next row's 0 ++// lights 7' 0 1 2 3 4 5 6 7 6 7 7 (7' = previous row's) ++// ++// light[p] = W[p+1] p <= 4 1/32 .. 1/2 of a row ++// light[5] = W[6] 1 ++// light[6] = W[7] + W[7] 2 ++// light[7] = W[6] + W[7] + W[7] + W[0] 4 ++// ++// so W = [1, 1/32, 1/16, 1/8, 1/4, 1/2, 1, 1] is exactly binary, every window is ++// the full one shifted right, and floors keep it strictly ordered at every ++// brightness (sum of floor(n / 2^j) over j >= 1 is n - popcount(n) < n). Schemes ++// 1 and 2 are the same shape with one and no extra pass. ++// ++// TWO EARLIER VERSIONS OF THIS FUNCTION WERE WRONG, and the record matters more ++// than the embarrassment. Both kept upstream's chain (at depth 8, L = 4: ++// 0 1 2 3 4 5 6 7 5 6 6 7 7 7 7). 2026-09-10 read the full window on buffer 0 ++// as an LSB bug and shrank it, which took a fifth of the top plane's light away. ++// The morning of 2026-09-17 corrected the repeat counts and kept the same ++// misreading - window b lights plane b - and was worse. Both were "verified" by ++// a test that modelled delivered light the same wrong way, so tens of thousands ++// of assertions agreed with them. Light per plane 0-7 at full brightness: ++// ++// upstream 7 15 31 63 125 250 375 625 3 codes where light goes DOWN ++// 09-10 7 15 31 63 125 250 375 503 3, and 127 -> 128 loses 42% ++// 09-17 a.m. 9 19 39 78 78 210 355 458 15 such codes ++// this 3 7 15 31 62 125 250 500 none ++// ++// What it looked like: a pattern whose background is a near-white (214,211,204) ++// and whose edges fade to dark. Each channel crosses code 128 at a different ++// point along the fade, and the one that has crossed is suddenly far brighter ++// than the ones that have not - blue first, a purple fringe; then green, a ++// sky-blue one; then red, and it is neutral again. Fringes round every star and ++// petal. A grey test card at identity white balance cannot show this: all three ++// channels cross together. ++// ++// Solving upstream's chain exactly was the first thing tried and was confirmed ++// fringe-free on the panel, but it keeps only 42% of the row lit and was too ++// dark to ship. This chain keeps 66% (upstream's, not binary: 80%). ++int pfOEWindowPixels(int buffer, int depth, int scheme, ++ int width, int blank, int brt) ++{ ++ if (buffer < 0 || buffer >= depth) return 0; ++ int full = (int)(((uint32_t)(width - blank) * (uint32_t)brt) >> 8); ++ if (full > width) full = width; ++ if (full < 0) full = 0; ++ ++ // The buffers whose window is the full one: buffer 0 always (the top plane's ++ // last pass), and every buffer that is sent again. ++ int firstFull = depth; // lowest buffer index >= 1 at full width ++ if (scheme == 0 && depth >= 3) firstFull = depth - 2; ++ else if (scheme <= 1 && depth >= 2) firstFull = depth - 1; ++ ++ if (buffer == 0 || buffer >= firstFull) return full; ++ const int shift = firstFull - buffer; // 1 for the buffer just below, and so on down ++ return (shift < 31) ? (full >> shift) : 0; ++} ++ + void MatrixPanel_I2S_DMA::setBrightnessOE(uint8_t brt, const int _buff_id) + { + +@@ -642,6 +1326,12 @@ + uint8_t _depth = fb->rowBits[0]->colour_depth; + uint16_t _width = fb->rowBits[0]->width; + ++ // One window per buffer, the same for every row - see pfOEWindowPixels above ++ // for which plane each one actually lights. ++ int windows[16] = {0}; ++ for (int b = 0; b < _depth && b < 16; b++) ++ windows[b] = pfOEWindowPixels(b, _depth, pfChainScheme, _width, _blank, brt); ++ + // start with iterating all rows in dma_buff structure + int row_idx = fb->rowBits.size(); + do +@@ -654,13 +1344,7 @@ + { + --colouridx; + +- char bitplane = (2 * _depth - colouridx) % _depth; +- char bitshift = (_depth - lsbMsbTransitionBit - 1) >> 1; +- +- char rightshift = std::max(bitplane - bitshift - 2, 0); +- // calculate the OE disable period by brightness, and also blanking +- int brightness_in_x_pixels = ((_width - _blank) * brt) >> (7 + rightshift); +- brightness_in_x_pixels = (brightness_in_x_pixels >> 1) | (brightness_in_x_pixels & 1); ++ int brightness_in_x_pixels = windows[colouridx]; + + // switch pointer to a row for a specific color index + ESP32_I2S_DMA_STORAGE_TYPE *row = fb->rowBits[row_idx]->getDataPtr(colouridx); +--- a/ESP32-HUB75-MatrixPanel-I2S-DMA.h ++++ b/ESP32-HUB75-MatrixPanel-I2S-DMA.h +@@ -607,6 +607,45 @@ + void fillScreenRGB888(uint8_t r, uint8_t g, uint8_t b); + void drawPixelRGB888(int16_t x, int16_t y, uint8_t r, uint8_t g, uint8_t b); + ++ /* PATTERNFLOW ADDITION (not upstream) — blit a whole RGB888 frame. ++ * ++ * drawPixelRGB888() is built to change one pixel: for every call it bounds- ++ * checks, works out which half of the panel the row is in, and then walks the ++ * colour-depth planes recomputing the row pointer and doing a read-modify-write ++ * per plane. Painting a full frame that way costs one call per pixel and two ++ * RMWs per plane per word (the top and bottom halves arrive as separate calls ++ * even though they share the same uint16_t). ++ * ++ * A full-frame repaint knows more than that. Both halves of a word are known at ++ * once, the row pointers are the same for every pixel in a row, and nothing ++ * needs bounds-checking. Measured on a 128x64 P2.5 at 8-bit depth this took the ++ * frame's push cost from 12.3 ms to a fraction of it — see PFCanvas::present(). ++ * ++ * `rgb` is W*H*3 bytes, row-major, 8 bits per channel, sized to the panel. ++ * Colour output is identical to calling drawPixelRGB888() for every pixel: the ++ * same lumConvTab/CIE1931 curve and the same bit masks are used. ++ */ ++ void blitRGB888(const uint8_t *rgb, ++ const uint8_t *lutR, const uint8_t *lutG, const uint8_t *lutB, ++ int satBoostQ8); ++ ++ /** ++ * Total LED on-time the last blit asked for, and the largest value that ++ * total could have been (an all-white frame). ++ * ++ * Summed from the post-CIE linear values — the numbers that actually become ++ * PWM duty — so the ratio between them is the fraction of full-white current ++ * the frame draws. Accumulated inside the blit's existing pixel loop from ++ * values already in registers, which is why it costs nothing worth ++ * measuring; see core_power.h for what reads it. ++ * ++ * Brightness is applied later, in OE timing, so this is deliberately ++ * independent of it: the limiter measures demand and sets brightness, and ++ * cannot end up chasing its own tail. ++ */ ++ uint64_t lastFrameOnTime() const { return _lastFrameOnTime; } ++ uint64_t maxFrameOnTime() const { return _maxFrameOnTime; } ++ + #ifdef USE_GFX_LITE + // 24bpp FASTLED CRGB colour struct support + void fillScreen(CRGB color); +@@ -717,7 +756,8 @@ + } + + /** +- * Stop the ESP32 DMA Engine. Screen will forever be black until next ESP reboot. ++ * Stop the ESP32 DMA Engine. Upstream says "forever ... until next ESP ++ * reboot" — see resumeDMAoutput() below, which is Patternflow's way back. + */ + void stopDMAoutput() + { +@@ -726,6 +766,31 @@ + dma_bus.dma_transfer_stop(); + } + ++ /** ++ * PATTERNFLOW ADDITION (not upstream) ++ * ++ * Restart the DMA engine after stopDMAoutput(), so a stop is a sleep rather ++ * than a one-way trip. This is what makes the firmware's sleep mode possible: ++ * with the transfer stopped the panel's driver ICs stop being clocked at ++ * 15 MHz, which is the part of the idle draw that blanking the framebuffer ++ * cannot touch. ++ * ++ * The pair is symmetric on both supported platforms — dma_transfer_stop() ++ * resets the LCD peripheral and halts the GDMA channel, and this starts the ++ * same channel again from the head of descriptor chain A. It has to live in ++ * the driver because dma_bus is protected. ++ * ++ * Note the restart always resumes at chain A while back_buffer_id is ++ * wherever it was left, so the caller should blank BOTH buffers before ++ * resuming or a stale frame can show for one flip. core_sleep.h does that. ++ */ ++ void resumeDMAoutput() ++ { ++ if (!initialized) ++ return; ++ dma_bus.dma_transfer_start(); ++ } ++ + // ------- PROTECTED ------- + // those might be useful for child classes, like VirtualMatrixPanel + protected: +@@ -893,6 +958,7 @@ + volatile int back_buffer_id = 0; // If using double buffer, which one is NOT active (ie. being displayed) to write too? + int brightness = 128; // If you get ghosting... reduce brightness level. ((60/64)*255) seems to be the limit before ghosting on a 64 pixel wide physical panel for some panels. + int lsbMsbTransitionBit = 0; // For colour depth calculations ++ int pfChainScheme = 0; // PATTERNFLOW: which chain begin() linked - see pfChainExtraPasses in the .cpp + + /* ESP32-HUB75-MatrixPanel-I2S-DMA functioning constants + * we should not those once object instance initialized it's DMA structs +@@ -907,6 +973,10 @@ + bool initialized = false; + bool config_set = false; + ++ // Power demand of the last blitted frame — see lastFrameOnTime(). ++ uint64_t _lastFrameOnTime = 0; ++ uint64_t _maxFrameOnTime = 0; ++ + }; // end Class header + + /***************************************************************************************/ diff --git a/firmware/toolchain/README.md b/firmware/toolchain/README.md index 8ca9933ac..729aeced4 100644 --- a/firmware/toolchain/README.md +++ b/firmware/toolchain/README.md @@ -2,10 +2,12 @@ The repository-level scripts around the firmware: what builds a module or a pack, what assembles a console page, and the `check_*.py` that CI runs. Each script's docstring is its manual (`python firmware/toolchain/ diff --git a/web/scripts/cut-led-panel.ts b/web/scripts/cut-led-panel.ts new file mode 100644 index 000000000..9714844ce --- /dev/null +++ b/web/scripts/cut-led-panel.ts @@ -0,0 +1,141 @@ +/** + * Cuts the LED panel out of the official case model for the guide: + * public/cases/official/model.glb → public/guide/led-panel.glb. + * + * npx tsx scripts/cut-led-panel.ts (inside web/) + * + * The guide's stage takes one mesh of that file, the LED panel "l" (its case, + * board and DevKit are files of the guide's own, stage/parts.ts). The file is + * 317 KB and the panel is 10 KB of it, so a phone opening the Build guide on + * a slow connection spent a second and a half on a board and a DevKit it + * never drew. The cut keeps the node as the file has it, its place, its mesh + * and its material, byte for byte, and nothing else. + * + * Run it again whenever tools/case-models writes model.glb anew. + * stage/ledPanel.test.ts cuts the file in memory and fails while the copy in + * public/guide is not what this writes. + */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const WEB = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +export const SOURCE = path.join(WEB, "public/cases/official/model.glb"); +export const TARGET = path.join(WEB, "public/guide/led-panel.glb"); +export const NODE = "l"; + +type Json = Record & { + nodes: { name?: string; mesh?: number; children?: number[] }[]; + meshes: { primitives: Primitive[] }[]; + accessors: { bufferView?: number }[]; + bufferViews: { buffer: number; byteOffset?: number; byteLength: number }[]; + materials?: Record[]; +}; +type Primitive = { + attributes: Record; + indices?: number; + material?: number; + extensions?: { KHR_draco_mesh_compression?: { bufferView: number } }; +}; + +const pad4 = (n: number) => (n + 3) & ~3; + +function chunks(glb: Uint8Array): { json: Json; bin: Uint8Array } { + const view = new DataView(glb.buffer, glb.byteOffset, glb.byteLength); + if (view.getUint32(0, true) !== 0x46546c67) throw new Error("not a GLB"); + const jsonLength = view.getUint32(12, true); + const json = JSON.parse(new TextDecoder().decode(glb.subarray(20, 20 + jsonLength))) as Json; + const binAt = 20 + jsonLength; + const binLength = view.getUint32(binAt, true); + return { json, bin: glb.subarray(binAt + 8, binAt + 8 + binLength) }; +} + +/** The GLB holding one root node of `glb`, by name: its mesh, accessors, material and the bytes they use. */ +export function cutNode(glb: Uint8Array, name: string, generator: string): Uint8Array { + const { json, bin } = chunks(glb); + const node = json.nodes.find((n) => n.name === name); + if (!node || node.mesh === undefined) throw new Error(`no mesh node "${name}"`); + if (node.children?.length) throw new Error(`"${name}" has children; this cuts a single mesh`); + + const views: number[] = []; + const view = (i: number) => (views.includes(i) ? views.indexOf(i) : views.push(i) - 1); + const accessors: Json["accessors"] = []; + const accessor = (i: number) => { + const a = { ...json.accessors[i] }; + if (a.bufferView !== undefined) a.bufferView = view(a.bufferView); + return accessors.push(a) - 1; + }; + const materials: Record[] = []; + const material = (i: number) => { + const m = json.materials![i]; + if (JSON.stringify(m).includes('"index"')) throw new Error(`material ${i} has textures; this cuts none`); + return materials.push(m) - 1; + }; + const mesh = { + ...json.meshes[node.mesh], + primitives: json.meshes[node.mesh].primitives.map((p) => { + const out: Primitive = { ...p, attributes: Object.fromEntries(Object.entries(p.attributes).map(([k, a]) => [k, accessor(a)])) }; + if (p.indices !== undefined) out.indices = accessor(p.indices); + if (p.material !== undefined) out.material = material(p.material); + const draco = p.extensions?.KHR_draco_mesh_compression; + if (draco) out.extensions = { ...p.extensions, KHR_draco_mesh_compression: { ...draco, bufferView: view(draco.bufferView) } }; + return out; + }), + }; + + // The bytes: each view that is kept, in the order first used, on a 4-byte boundary. + const bufferViews: Json["bufferViews"] = []; + let length = 0; + for (const i of views) { + const v = json.bufferViews[i]; + bufferViews.push({ ...v, buffer: 0, byteOffset: length }); + length = pad4(length + v.byteLength); + } + const outBin = new Uint8Array(length); + views.forEach((i, k) => { + const v = json.bufferViews[i]; + const at = v.byteOffset ?? 0; + outBin.set(bin.subarray(at, at + v.byteLength), bufferViews[k].byteOffset); + }); + + const out: Record = { + asset: { ...(json.asset as object), generator }, + ...(json.extensionsUsed ? { extensionsUsed: json.extensionsUsed } : {}), + ...(json.extensionsRequired ? { extensionsRequired: json.extensionsRequired } : {}), + scene: 0, + scenes: [{ name: "Scene", nodes: [0] }], + nodes: [{ ...node, mesh: 0 }], + meshes: [mesh], + accessors, + ...(materials.length ? { materials } : {}), + bufferViews, + buffers: [{ byteLength: length }], + }; + const text = new TextEncoder().encode(JSON.stringify(out)); + const jsonLength = pad4(text.length); + const total = 12 + 8 + jsonLength + 8 + length; + const glbOut = new Uint8Array(total); + const dv = new DataView(glbOut.buffer); + dv.setUint32(0, 0x46546c67, true); + dv.setUint32(4, 2, true); + dv.setUint32(8, total, true); + dv.setUint32(12, jsonLength, true); + dv.setUint32(16, 0x4e4f534a, true); + glbOut.fill(0x20, 20, 20 + jsonLength); + glbOut.set(text, 20); + dv.setUint32(20 + jsonLength, length, true); + dv.setUint32(24 + jsonLength, 0x004e4942, true); + glbOut.set(outBin, 28 + jsonLength); + return glbOut; +} + +/** What this script writes, from the model on disk. */ +export function render(): Uint8Array { + return cutNode(new Uint8Array(fs.readFileSync(SOURCE)), NODE, "patternflow web/scripts/cut-led-panel.ts, node \"l\" of public/cases/official/model.glb"); +} + +if (path.resolve(process.argv[1] ?? "") === fileURLToPath(import.meta.url)) { + const out = render(); + fs.writeFileSync(TARGET, out); + console.log(`wrote ${path.relative(WEB, TARGET)} (${out.length} bytes, from ${fs.statSync(SOURCE).size})`); +} diff --git a/web/scripts/doc-map.ts b/web/scripts/doc-map.ts new file mode 100644 index 000000000..a785d1417 --- /dev/null +++ b/web/scripts/doc-map.ts @@ -0,0 +1,459 @@ +/** + * The doc map: every link into this repository, and whether it lands. + * `npm run doc-map` in web/ writes docs/investigations/2026-10-doc-map.csv, + * and beside it the parity list, docs/investigations/2026-10-doc-parity.csv. + * + * Moving a file breaks the links that point at it, and only one kind of link + * is watched: the relative ones in Markdown (.github/scripts/check_links.py). + * The site points at the guides by absolute GitHub URL. The console pages the + * device serves point at docs and at the site. The redirect table sends old + * URLs to new ones. None of those fail a build when the file they name moves, + * so the cleanup (docs/rfc-cleanup.md §2.2) maps them before it moves anything. + * + * Sources are the tracked .md, .mdx, .ts, .tsx and .h files, the console + * pages, the .yml and .yaml files under .github/ (the issue forms and their + * config.yml link the repository by absolute URL), and the redirect table in + * next.config.ts. Vendored upstream code is skipped, as check_links.py skips + * it, and so are the *_index.h headers, which are the console pages again. + * Kinds: + * + * relative a path in Markdown, resolved against the file it is in + * absolute-blob github.com//Patternflow/blob|tree// + * absolute-raw raw.githubusercontent.com/..., or .../raw// + * site patternflow.work/, a root-relative Markdown link, or + * a redirect destination; checked against web/src/app, + * web/public and the redirect and rewrite sources + * console a root-relative link in a console page; checked against + * the paths the firmware registers with .on("/path") + * + * An anchor into a Markdown file has to match one of its headings, slugged + * the way GitHub slugs them. A ref other than main is read with git, and a + * shallow clone has no tags: run `git fetch --tags` first or those rows say no. + * + * `--check absolute` is check:links-absolute (docs/rfc-cleanup.md §2.4). It + * reads the same rows and writes nothing. It fails on an absolute row that does + * not resolve, and on a tracked file outside the sources above that carries a + * repository URL, since the map never reads that file. A row on a ref the clone + * lacks is skipped, and the check says which refs and exits with SKIPPED from + * scripts/drift/_lib.ts, so scripts/drift.ts prints it as skipped and not as + * passed. With PF_DRIFT_STRICT=1 a skip fails as well. + * + * The parity list (docs/rfc-cleanup.md §2.3) is every text the repository + * keeps in both Korean and English, one row per pair, and whether a side is + * missing or short. Kinds: + * + * file a file beside its twin named for the other language: x.ko.md + * beside x.md, a journal post's x.en.mdx beside x.mdx + * route a page under web/src/app beside its ko/ or en/ twin; the row only + * says both exist, since the words are the copy rows + * copy an object in web/src with `en` and `ko`, walked side by side + * field a field in web/src beside its Ko or En twin: title/titleKo + * (bare English), nm/nmEn (bare Korean) + * + * The three kinds §2.3 names (x.md beside x.ko.md, console page pairs, Ko + * fields in features-data.ts) do not exist yet; when one lands it is a file or + * a field row and needs no change here. Copy a component picks with + * a ternary on the language is not read. en_chars and ko_chars count the + * characters that are not whitespace once markup is stripped; how state + * weighs them is at PER_SYLLABLE below. Nothing is translated: the list is + * the deliverable. + */ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import ts from "typescript"; + +import { SKIPPED } from "./drift/_lib"; + +const ROOT = execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" }).trim(); +const OUT = "docs/investigations/2026-10-doc-map.csv"; +const PARITY = "docs/investigations/2026-10-doc-parity.csv"; +const CONSOLE = "firmware/patternflow/console/"; +const REDIRECTS = "web/next.config.ts"; +const SKIP = /(^|\/)(hub75|webserver|pubsubclient)\//; +const CHECK = process.argv.includes("--check") ? (process.argv[process.argv.indexOf("--check") + 1] ?? "") : null; +if (CHECK !== null && CHECK !== "absolute") { + console.error(`doc-map: --check takes "absolute", not "${CHECK}"`); + process.exit(2); +} + +const git = (args: string[]) => + execFileSync("git", args, { cwd: ROOT, encoding: "utf8", maxBuffer: 1 << 28, stdio: ["ignore", "pipe", "ignore"] }); +const tracked = git(["ls-files", "-z"]).split("\0").filter(Boolean); +const files = new Set(tracked); +const dirs = new Set([""]); +for (const f of tracked) for (let d = path.posix.dirname(f); d !== "."; d = path.posix.dirname(d)) dirs.add(d); + +// ── What a link can land on ── + +const read = (f: string) => fs.readFileSync(path.join(ROOT, f), "utf8"); +const attempt = (f: () => T, fallback: T): T => { + try { + return f(); + } catch { + return fallback; + } +}; +const decode = (p: string) => attempt(() => decodeURIComponent(p), p); +const trim = (p: string) => (p.length > 1 ? p.replace(/\/+$/, "") : p); + +const atRef = new Map(); +function contentAt(ref: string, p: string): string | null { + if (ref === "main" || ref === "HEAD") return files.has(p) ? read(p) : dirs.has(p) ? "" : null; + const key = `${ref}:${p}`; + if (!atRef.has(key)) atRef.set(key, attempt(() => git(["cat-file", "-p", key]), null)); + return atRef.get(key) ?? null; +} + +// GitHub's heading ids: rendered text, lower-cased, punctuation dropped, +// spaces to hyphens, a repeat numbered -1, -2. Plus any explicit id= or name=. +function anchorsOf(md: string): Set { + const out = new Set(); + const seen = new Map(); + for (const line of md.replace(/```[\s\S]*?```/g, "").split("\n")) { + const h = /^ {0,3}#{1,6}\s+(.*?)\s*#*\s*$/.exec(line); + if (!h) continue; + const text = h[1].replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/<[^>]+>/g, "").replace(/[*`~]/g, ""); + const base = text.toLowerCase().replace(/[^\p{L}\p{M}\p{N}\p{Pc} -]/gu, "").replace(/ /g, "-"); + const n = seen.get(base) ?? 0; + seen.set(base, n + 1); + out.add(n ? `${base}-${n}` : base); + } + for (const m of md.matchAll(/\b(?:id|name)=["']([^"']+)["']/g)) out.add(m[1].toLowerCase()); + return out; +} + +// A route or redirect source as a regex; [[...x]], [...x], [x] and :x are its parameters. +const pattern = (route: string) => { + const marked = route.replace(/\[\[\.\.\.\w+\]\]/g, "\u0001").replace(/\[\.\.\.\w+\]/g, "\u0002").replace(/\[\w+\]|:\w+\*?/g, "\u0003"); + const escaped = marked.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\/?\u0001/g, "(?:/.*)?"); + return new RegExp(`^${escaped.replace(/\u0002/g, ".+").replace(/\u0003/g, "[^/]+")}$`); +}; + +const publicFiles = new Set(tracked.filter((f) => f.startsWith("web/public/")).map((f) => f.slice("web/public".length))); +const siteRoutes = [pattern("/sitemap.xml"), pattern("/robots.txt")]; +for (const f of tracked) { + const app = /^web\/src\/app(\/.*)?\/(?:page|route)\.(?:tsx?|jsx?|mdx?)$/.exec(f); + if (app) siteRoutes.push(pattern((app[1] ?? "").replace(/\/\([^/]+\)/g, "") || "/")); +} +// Redirect sources only: the headers() block has sources too, and they are not pages. +const redirects = read(REDIRECTS).split("async redirects()")[1]?.split("async rewrites()")[0] ?? ""; +for (const m of redirects.matchAll(/\bsource: "(\/[^"]*)"/g)) siteRoutes.push(pattern(m[1])); +for (const m of read("web/src/lib/campaignRoutes.ts").matchAll(/\bpath: "(\/[^"]*)"/g)) siteRoutes.push(pattern(m[1])); + +const deviceRoutes = new Set(); +for (const f of tracked.filter((f) => /^firmware\/patternflow\/.*\.(h|ino|cpp)$/.test(f))) + for (const m of read(f).matchAll(/\.on\(\s*"(\/[^"]*)"/g)) deviceRoutes.add(m[1]); + +// ── Reading links out of a file ── + +// ref: the branch or tag a file row was read at, which the CSV folds into `to`. +type Row = { from: string; to: string; kind: string; anchor: string; resolves: "yes" | "no"; ref?: string }; + +const REPO_URL = + /^https?:\/\/(?:www\.)?(?:github\.com\/(?:engmung|patternflow-lab)\/patternflow\/(blob|tree|raw|edit)|raw\.githubusercontent\.com\/(?:engmung|patternflow-lab)\/patternflow)\/([^/?#]+)\/?([^?#]*)/i; +const SITE_URL = /^https?:\/\/(?:www\.)?patternflow\.work(\/[^?#]*)?/i; +const URL_IN_TEXT = /https?:\/\/(?:www\.)?(?:github\.com|raw\.githubusercontent\.com|patternflow\.work)[^\s"'`<>)\]]*/gi; + +function classify(from: string, raw: string, markdown: boolean): Row | null { + const target = raw.replace(/^<|>$/g, "").replace(/[.,;:]+$/, ""); + if (target.includes("${")) return null; // a template the constants did not fill + const anchor = target.includes("#") ? decode(target.slice(target.indexOf("#") + 1)) : ""; + const row = (to: string, kind: string, ok: boolean): Row => ({ from, to, kind, anchor, resolves: ok ? "yes" : "no" }); + const fileRow = (ref: string, p: string, kind: string) => { + const body = contentAt(ref, p); + const ok = body !== null && (!anchor || !/\.mdx?$/.test(p) || /^L\d+/.test(anchor) || anchorsOf(body).has(anchor.toLowerCase())); + return { ...row(ref === "main" ? p : `${ref}:${p}`, kind, ok), ref }; + }; + const site = (p: string) => row(trim(p), "site", publicFiles.has(trim(p)) || siteRoutes.some((r) => r.test(trim(p)))); + + const repo = REPO_URL.exec(target); + if (repo) { + const kind = repo[1] && repo[1].toLowerCase() !== "raw" ? "absolute-blob" : "absolute-raw"; + const p = decode(repo[3]).replace(/\/$/, ""); + return p ? fileRow(repo[2], p, kind) : null; // the repository's front page, which no move breaks + } + const web = SITE_URL.exec(target); + if (web) return site(decode(web[1] ?? "/")); + if (/^[a-z][a-z0-9+.-]*:|^\/\/|^#/i.test(target)) return null; + const p = decode(target.split("#")[0].split("?")[0]); + if (from.startsWith(CONSOLE)) return p.startsWith("/") ? row(trim(p), "console", deviceRoutes.has(trim(p))) : null; + if (!markdown || !p) return null; + if (p.startsWith("/")) return site(p); + const local = path.posix.normalize(path.posix.join(path.posix.dirname(from), p)).replace(/\/$/, ""); + return local.startsWith("../") ? null : fileRow("main", local, "relative"); // ../../releases is GitHub's page, not a file +} + +// `const REPO = "https://github.com/…"`, `${REPO}/blob/main/x` and one-line +// helpers like `blob("x")` are how the site writes its GitHub links. +function expandConstants(text: string): string { + const consts = new Map(); + for (let pass = 0; pass < 3; pass++) { + for (const m of text.matchAll(/\bconst (\w+) = (["'`])(https?:\/\/[^"'`$]*)\2/g)) consts.set(m[1], m[3]); + text = text.replace(/\$\{(\w+)\}/g, (s, n: string) => consts.get(n) ?? s); + } + for (const m of text.matchAll(/\bconst (\w+) = \((\w+)(?:: string)?\) =>\s*`(https?:\/\/[^`$]*)\$\{\2\}`/g)) + text = text.replace(new RegExp(`\\b${m[1]}\\((["'])([^"']*)\\1\\)`, "g"), (_s, _q, arg: string) => m[3] + arg); + return text; +} + +function linksIn(f: string): Row[] { + const markdown = /\.mdx?$/.test(f); + let text = read(f); + const found: [string, boolean][] = []; + if (markdown) text = text.replace(/```[\s\S]*?```/g, "").replace(/`[^`\n]*`/g, ""); + if (/\.tsx?$/.test(f)) text = expandConstants(text); + // In code, `a[i](x)` looks like a Markdown link; only a path that starts like one counts. + for (const m of text.matchAll(/\]\(\s*(<[^>]+>|[^)\s]+)/g)) found.push([m[1], markdown || /^\.{0,2}\//.test(m[1])]); + if (markdown) for (const m of text.matchAll(/^\s*\[(?!\^)[^\]]+\]:\s*(\S+)/gm)) found.push([m[1], true]); + for (const m of text.matchAll(/\b(?:href|src)=["']([^"']+)["']/g)) found.push([m[1], markdown]); + for (const m of text.matchAll(URL_IN_TEXT)) found.push([m[0], false]); + if (f === REDIRECTS) for (const m of text.matchAll(/\bdestination: "(\/[^"]*)"/g)) found.push([m[1], true]); + return found.map(([raw, md]) => classify(f, raw, md)).filter((r): r is Row => r !== null); +} + +// ── The map ── + +const sources = tracked.filter((f) => !SKIP.test(f) && (/\.(mdx?|tsx?)$/.test(f) || + (f.endsWith(".h") && !f.endsWith("_index.h")) || (f.startsWith(CONSOLE) && f.endsWith(".html")) || + (f.startsWith(".github/") && /\.ya?ml$/.test(f)))); +const key = (r: Row) => [r.from, r.to, r.anchor, r.kind].join("\0"); +const rows = new Map(); +for (const f of sources) for (const r of linksIn(f)) rows.set(key(r), r); + +const cell = (v: string) => (/[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v); +// Code-point order, not the locale's, so the file is the same on every machine. +const sorted = [...rows.values()].sort((a, b) => (key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0)); +const show = (r: Row) => ` ${r.from} → ${r.to}${r.anchor ? "#" + r.anchor : ""} (${r.kind})`; + +// ── The check ── + +if (CHECK) { + const strict = process.env.PF_DRIFT_STRICT === "1"; + const absolute = sorted.filter((r) => r.kind.startsWith("absolute-")); + const refOf = (r: Row) => r.ref ?? "main"; + const perRef = new Map(); + for (const r of absolute) perRef.set(refOf(r), (perRef.get(refOf(r)) ?? 0) + 1); + const lacking = new Set([...perRef.keys()].filter((ref) => + ref !== "main" && ref !== "HEAD" && !attempt(() => !!git(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]), false))); + const unread = absolute.filter((r) => lacking.has(refOf(r))); + const broken = absolute.filter((r) => r.resolves === "no" && !lacking.has(refOf(r))); + + // A repository URL in a file the map does not read. The *_index.h headers + // are the console pages again, and vendored code is upstream's. + const mapped = new Set(sources); + const hits = attempt(() => git(["grep", "-z", "-l", "-I", "-i", "-E", + "github\\.com/(engmung|patternflow-lab)/patternflow/(blob|tree|raw|edit)/|raw\\.githubusercontent\\.com/(engmung|patternflow-lab)/patternflow/"]), ""); + const strays = hits.split("\0").filter((f) => f && !mapped.has(f) && !SKIP.test(f) && !f.endsWith("_index.h")) + .flatMap((f) => [...read(f).matchAll(URL_IN_TEXT)].map((m) => classify(f, m[0], false))) + .filter((r): r is Row => !!r?.kind.startsWith("absolute-")); + + const at = [...perRef].sort().map(([ref, n]) => `${ref === "main" ? "main at HEAD" : ref} (${n})`).join(", "); + console.log(`${absolute.length} absolute links from ${new Set(absolute.map((r) => r.from)).size} files, against ${at}`); + console.log(`${broken.length} do not resolve`); + for (const r of broken) console.log(show(r)); + console.log(`${strays.length} in files the map does not read`); + for (const r of strays) console.log(show(r)); + if (unread.length) { + console.log(`${unread.length} not read: this clone lacks ${[...lacking].sort().join(", ")}; run git fetch --tags`); + if (strict) console.log("PF_DRIFT_STRICT=1, so a link not read fails"); + } + const failed = broken.length > 0 || strays.length > 0 || (unread.length > 0 && strict); + console.log(`links-absolute: ${failed ? "failed" : unread.length ? "skipped" : "passed"}`); + process.exit(failed ? 1 : unread.length ? SKIPPED : 0); +} + +const csv = ["from,to,kind,anchor,resolves", ...sorted.map((r) => [r.from, r.to, r.kind, r.anchor, r.resolves].map(cell).join(","))]; +fs.writeFileSync(path.join(ROOT, OUT), csv.join("\n") + "\n"); + +const broken = sorted.filter((r) => r.resolves === "no"); +console.log(`${sorted.length} links from ${new Set(sorted.map((r) => r.from)).size} files → ${OUT}`); +console.log(`${broken.length} do not resolve`); +for (const r of broken) console.log(show(r)); + +// ── The parity list ── + +type Side = string | null | undefined; // the side's prose; null: it does not exist; undefined: not measured +type Pair = { kind: string; en: string; ko: string; enText: Side; koText: Side }; +const pairs: Pair[] = []; + +// Prose with the markup gone; a path or a URL on its own is not prose. +const prose = (s: string) => + /^\s*(?:https?:\/\/|\/)\S*\s*$/.test(s) ? "" : s + .replace(/^---\n[\s\S]*?\n---\n/, "").replace(/```[\s\S]*?```/g, "").replace(//g, "") + .replace(/^(?:import|export) .*$/gm, "").replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/<[^>]+>/g, "") + .replace(/https?:\/\/\S+/g, "").replace(/[*_~`#>|]/g, ""); +const chars = (s: Side) => (typeof s === "string" ? [...s.replace(/\s/g, "")].length : null); + +// Files and routes: the twin carries the language it adds, the bare one is the other. +const TWIN = /^(.*?)([./])(en|ko)\2(page\.tsx|[^./]+)$/; +const ROUTE = /^web\/src\/app\/([^/]+)\/.*page\.tsx$/; +// The journal is written post by post in Korean (x.mdx) and English (x.en.mdx): +// a post with one side is missing the other. +const PAIRED_FOLDERS = ["web/content/journal/"]; +const routeTwins = new Map(); // a route folder → the language its twins add +const twinned = new Set(); +for (const f of tracked) { + const m = TWIN.exec(f); + if (!m || (m[2] === "/" && m[4] !== "page.tsx")) continue; + const bare = `${m[1]}${m[2] === "/" ? "/" : "."}${m[4]}`; + const route = m[2] === "/"; + const [en, ko] = m[3] === "ko" ? [bare, f] : [f, bare]; + const text = (p: string) => (!files.has(p) ? null : route ? undefined : prose(read(p))); + pairs.push({ kind: route ? "route" : "file", en, ko, enText: text(en), koText: text(ko) }); + twinned.add(bare); + if (route) routeTwins.set(ROUTE.exec(f)?.[1] ?? "", m[3]); +} +for (const f of tracked) { + if (twinned.has(f) || TWIN.test(f)) continue; + const top = ROUTE.exec(f)?.[1]; + if (PAIRED_FOLDERS.some((d) => f.startsWith(d)) && /\.mdx?$/.test(f)) pairs.push({ kind: "file", en: f.replace(/(\.mdx?)$/, ".en$1"), ko: f, enText: null, koText: prose(read(f)) }); + else if (top && routeTwins.has(top) && !/\/(en|ko)\//.test(f)) { + const twin = f.replace(/page\.tsx$/, `${routeTwins.get(top)}/page.tsx`); + pairs.push(routeTwins.get(top) === "ko" + ? { kind: "route", en: f, ko: twin, enText: undefined, koText: null } + : { kind: "route", en: twin, ko: f, enText: null, koText: undefined }); + } +} + +// Code: an object with `en` and `ko` (walked side by side when both are +// objects), and a field beside its `Ko` or `En` twin (bare English beside Ko, +// bare Korean beside En). A list where some items have the twin and some +// do not has a missing side. +const unwrap = (e: ts.Expression): ts.Expression => + ts.isAsExpression(e) || ts.isSatisfiesExpression(e) || ts.isParenthesizedExpression(e) ? unwrap(e.expression) : e; +const nameOf = (n: ts.PropertyName) => (ts.isIdentifier(n) || ts.isStringLiteral(n) ? n.text : null); +function propsOf(o: ts.ObjectLiteralExpression): Map { + const out = new Map(); + for (const p of o.properties) { + const n = p.name && nameOf(p.name); + if (!n) continue; + out.set(n, ts.isPropertyAssignment(p) ? unwrap(p.initializer) : ts.isShorthandPropertyAssignment(p) ? p.name : p); + } + return out; +} +// The strings in a value: a literal, a template's fixed parts, literals joined +// with +, or every literal in a function. +function textOf(n: ts.Node | undefined): Side { + if (!n) return null; + if (ts.isStringLiteral(n) || ts.isNoSubstitutionTemplateLiteral(n)) return prose(n.text); + if (ts.isTemplateExpression(n)) return prose([n.head.text, ...n.templateSpans.map((s) => s.literal.text)].join("")); + if (ts.isBinaryExpression(n) && n.operatorToken.kind === ts.SyntaxKind.PlusToken) { + const [l, r] = [textOf(unwrap(n.left)), textOf(unwrap(n.right))]; + return l === undefined && r === undefined ? undefined : (l ?? "") + (r ?? ""); + } + if (!ts.isFunctionLike(n)) return undefined; + const parts: string[] = []; + const walk = (c: ts.Node): void => { + if (ts.isStringLiteral(c) || ts.isNoSubstitutionTemplateLiteral(c) || ts.isTemplateLiteralToken(c)) parts.push(c.text); + ts.forEachChild(c, walk); + }; + walk(n); + return prose(parts.join(" ")); +} + +function codePairs(f: string) { + const sf = ts.createSourceFile(f, read(f), ts.ScriptTarget.Latest, true, f.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS); + const consts = new Map(); + for (const s of sf.statements) + if (ts.isVariableStatement(s)) + for (const d of s.declarationList.declarations) if (ts.isIdentifier(d.name) && d.initializer) consts.set(d.name.text, d.initializer); + const deref = (n: ts.Node | undefined) => { + const e = n && ts.isIdentifier(n) ? (consts.get(n.text) ?? n) : n; + return e && ts.isExpression(e) ? unwrap(e) : e; + }; + const push = (kind: string, en: string, ko: string, enText: Side, koText: Side) => { + if (enText === undefined && koText === undefined) return; + if (!chars(enText) && !chars(koText)) return; // a path, a URL or an empty string on both sides + pairs.push({ kind, en: `${f}#${en}`, ko: `${f}#${ko}`, enText, koText }); + }; + + function sideBySide(base: string, rest: string, en: ts.Node | undefined, ko: ts.Node | undefined) { + const [a, b] = [deref(en), deref(ko)]; + if (a && b && ts.isObjectLiteralExpression(a) && ts.isObjectLiteralExpression(b)) { + const [pa, pb] = [propsOf(a), propsOf(b)]; + for (const k of new Set([...pa.keys(), ...pb.keys()])) sideBySide(base, `${rest}.${k}`, pa.get(k), pb.get(k)); + } else if (a && b && ts.isArrayLiteralExpression(a) && ts.isArrayLiteralExpression(b)) { + for (let i = 0; i < Math.max(a.elements.length, b.elements.length); i++) + sideBySide(base, `${rest}[${i}]`, a.elements[i], b.elements[i]); + } else push("copy", `${base}.en${rest}`, `${base}.ko${rest}`, textOf(a), textOf(b)); + } + + function fields(at: string, props: Map, twins: Set) { + for (const k of twins) { + const [bare, lang] = [k.slice(0, -2), k.slice(-2)]; + if (!props.has(bare) && !props.has(k)) continue; + const [en, ko] = lang === "Ko" ? [bare, k] : [k, bare]; + push("field", `${at}.${en}`, `${at}.${ko}`, textOf(props.get(en)), textOf(props.get(ko))); + } + } + const twinsIn = (props: Map) => + [...props.keys()].filter((k) => /[a-z](Ko|En)$/.test(k) && props.has(k.slice(0, -2))); + + function visit(n: ts.Node, at: string): void { + if ((ts.isVariableDeclaration(n) || ts.isFunctionDeclaration(n)) && n.name && ts.isIdentifier(n.name)) { + const here = at ? `${at}.${n.name.text}` : n.name.text; + ts.forEachChild(n, (c) => visit(c, here)); + } else if (ts.isObjectLiteralExpression(n)) { + const props = propsOf(n); + const paired = props.has("en") && props.has("ko"); + if (paired) sideBySide(at, "", props.get("en"), props.get("ko")); + if (!ts.isArrayLiteralExpression(n.parent)) fields(at, props, new Set(twinsIn(props))); + for (const [k, v] of props) if (!(paired && (k === "en" || k === "ko"))) visit(v, `${at}.${k}`); + } else if (ts.isArrayLiteralExpression(n)) { + const items = n.elements.map((e) => (ts.isObjectLiteralExpression(unwrap(e)) ? propsOf(unwrap(e) as ts.ObjectLiteralExpression) : null)); + const twins = new Set(items.flatMap((p) => (p ? twinsIn(p) : []))); + n.elements.forEach((e, i) => { + const id = items[i]?.get("id"); + const here = `${at}[${id && ts.isStringLiteral(id) ? id.text : i}]`; + if (items[i]) fields(here, items[i]!, twins); + visit(e, here); + }); + } else ts.forEachChild(n, (c) => visit(c, at)); + } + visit(sf, ""); +} +for (const f of tracked) if (/^web\/src\/.*\.tsx?$/.test(f) && !/\.(test|d)\.tsx?$/.test(f)) codePairs(f); + +// Korean says in one syllable what English says in about two and a half +// letters: over the journal's 37 pairs (2026-10-14) a Hangul syllable stands +// for 2.4 English characters, the other characters for one. Sides are compared +// at that size. A side under two thirds of the other is short; a line whose +// longer side is under a sentence (SENTENCE) is a label or a title, and the +// comparison means nothing there. The same sentence on both sides is a +// translation not made yet; the same short value is an id, a file name or a +// brand, not a translation, and is left out. +const HANGUL = /[\u1100-\u11ff\u3130-\u318f\uac00-\ud7a3]/; +const PER_SYLLABLE = 2.4; +const SENTENCE = 40; +const size = (s: string) => { + const hangul = [...s].filter((c) => HANGUL.test(c)).length; + return (chars(s) ?? 0) - hangul + PER_SYLLABLE * hangul; +}; +function state(p: Pair): string | null { + if (p.enText === null) return "missing-en"; + if (p.koText === null) return "missing-ko"; + if (p.enText === undefined || p.koText === undefined) return "ok"; + const [en, ko] = [size(p.enText), size(p.koText)]; + if (p.enText.trim() === p.koText.trim()) + return Math.max(en, ko) < SENTENCE || !/\s/.test(p.enText.trim()) ? null : HANGUL.test(p.enText) ? "missing-en" : "missing-ko"; + if (Math.max(en, ko) < SENTENCE) return "ok"; + if (ko < (2 / 3) * en) return "ko-short"; + if (en < (2 / 3) * ko) return "en-short"; + return "ok"; +} +const pkey = (p: Pair) => [p.kind, p.en, p.ko].join("\0"); +const listed = [...new Map(pairs.map((p) => [pkey(p), p])).values()].sort((a, b) => (pkey(a) < pkey(b) ? -1 : pkey(a) > pkey(b) ? 1 : 0)); +const num = (n: number | null) => (n === null ? "" : String(n)); +const parity = listed.flatMap((p) => { + const s = state(p); + return s ? [{ ...p, state: s }] : []; +}); +fs.writeFileSync( + path.join(ROOT, PARITY), + ["kind,en,ko,en_chars,ko_chars,state", ...parity.map((p) => [p.kind, p.en, p.ko, num(chars(p.enText)), num(chars(p.koText)), p.state].map(cell).join(","))].join("\n") + "\n", +); +const tally = new Map(); +for (const p of parity) tally.set(p.state, (tally.get(p.state) ?? 0) + 1); +console.log(`${parity.length} Korean/English pairs → ${PARITY}`); +console.log([...tally].sort().map(([s, n]) => `${n} ${s}`).join(", ")); diff --git a/web/scripts/drift.ts b/web/scripts/drift.ts new file mode 100644 index 000000000..5f8df4db2 --- /dev/null +++ b/web/scripts/drift.ts @@ -0,0 +1,39 @@ +/** + * The drift checks: `npm run check:drift` in web/, which check:ci runs, or + * `npm run check:` for one (docs/rfc-cleanup.md §2.4). + * + * A check is a file in scripts/drift/. It compares two things the tree keeps + * by hand, the BOM and the build guide's parts table for one, and fails when + * they disagree. This runs every file there whose name does not start with _ + * (those are shared code), each in a process of its own, so a check that + * throws stops no other. A name given here with no file behind it fails: + * package.json names every check step 2 plans + * (docs/investigations/2026-10-cleanup-step2-plan.md), and each file lands in + * a PR of its own. + */ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +import { ROOT, SKIPPED } from "./drift/_lib"; + +const DIR = path.join(ROOT, "web/scripts/drift"); +const checks = fs.readdirSync(DIR).filter((f) => f.endsWith(".ts") && !f.startsWith("_")).map((f) => f.slice(0, -3)).sort(); +const asked = process.argv.slice(2); + +const results: [name: string, state: string][] = []; +for (const name of asked.length ? asked : checks) { + console.log(`── ${name}`); + if (!checks.includes(name)) { + console.log(`scripts/drift/${name}.ts is not written yet`); + results.push([name, "failed"]); + continue; + } + // process.execArgv carries tsx's loader, so the child reads TypeScript too. + const run = spawnSync(process.execPath, [...process.execArgv, path.join(DIR, `${name}.ts`)], { stdio: "inherit" }); + results.push([name, run.status === 0 ? "passed" : run.status === SKIPPED ? "skipped" : "failed"]); +} + +console.log(""); +for (const [name, state] of results) console.log(`${state.padEnd(8)} ${name}`); +if (results.some(([, state]) => state === "failed")) process.exitCode = 1; diff --git a/web/scripts/drift/_bom.ts b/web/scripts/drift/_bom.ts new file mode 100644 index 000000000..629a1b2a0 --- /dev/null +++ b/web/scripts/drift/_bom.ts @@ -0,0 +1,56 @@ +/** + * The BOM as the drift checks read it. hardware/bom/bom_v3.9.csv is the + * source of truth (hard rule 2 in AGENTS.md): check:bom holds it against the + * build guide's parts tables, check:bom-schematic against the schematic PDF. + */ +import { read } from "./_lib"; + +export const BOM = "hardware/bom/bom_v3.9.csv"; + +export type Row = Record; + +// RFC 4180: a quoted cell may hold commas, line breaks and "" for a quote. +function parse(text: string): string[][] { + const rows: string[][] = []; + let row: string[] = []; + let cell = ""; + let quoted = false; + for (let i = 0; i < text.length; i++) { + const c = text[i]; + if (quoted) { + if (c === '"' && text[i + 1] === '"') cell += text[++i]; + else if (c === '"') quoted = false; + else cell += c; + } else if (c === '"') quoted = true; + else if (c === "," || c === "\n") { + row.push(cell); + cell = ""; + if (c === "\n") { + rows.push(row); + row = []; + } + } else if (c !== "\r") cell += c; + } + if (cell || row.length) rows.push([...row, cell]); + return rows; +} + +/** The BOM's rows, each keyed by the CSV's header line. */ +export function bom(): Row[] { + const [head, ...rows] = parse(read(BOM)); + return rows.filter((r) => r.some(Boolean)).map((r) => Object.fromEntries(head.map((h, i) => [h, r[i] ?? ""]))); +} + +/** + * The designators a ref cell names. "J1" is one, and "SW1-SW4" is four, with + * a hyphen or an en dash. Any other cell names none: "-" off the board, the + * guide's "—", and "U1 (sockets)", whose notes say it is not a refdes. + */ +export function designators(ref: string): string[] { + const m = ref.trim().match(/^([A-Z]+)(\d+)(?:\s*[-–]\s*([A-Z]+)(\d+))?$/); + if (!m) return []; + const [, prefix, from, prefixTo, to] = m; + if (!to) return [prefix + from]; + if (prefixTo !== prefix || +to <= +from) throw new Error(`${ref}: a range keeps one prefix and counts up`); + return Array.from({ length: +to - +from + 1 }, (_, i) => `${prefix}${+from + i}`); +} diff --git a/web/scripts/drift/_broker.ts b/web/scripts/drift/_broker.ts new file mode 100644 index 000000000..48a29948f --- /dev/null +++ b/web/scripts/drift/_broker.ts @@ -0,0 +1,130 @@ +/** + * check:mqtt-vectors' broker, and the Sender it hands _vectors.ts (docs/contract-vectors.md). A Patternflow + * is a broker's client, so each vector gets a broker of its own on a free port: MQTT 3.1.1 at QoS 0 on + * node:net, enough to connect, subscribe, publish and retain. A topic matches only itself, since a panel + * subscribes with no wildcards (docs/mqtt-spec.md line 12). open() points the panel at the broker with + * POST /api/mqtt and leaves the role off for the vector to pick. A step's mqtt goes to each connection + * subscribed to its topic, and heard is each PUBLISH the panel sent. + */ +import net, { type AddressInfo } from "node:net"; + +import type { Panel, Sender } from "./_vectors"; + +/** A step's mqtt, and what heard holds. A send keeps it on the broker with retain: true. */ +type Message = { topic: string; payload: string; retain?: boolean }; + +// The framing: a type byte, the length in groups of seven bits, the body. A string is two length bytes and UTF-8. +const u16 = (n: number) => Buffer.from([n >> 8, n & 255]); +const str = (s: string) => Buffer.concat([u16(Buffer.byteLength(s)), Buffer.from(s)]); +function packet(type: number, ...parts: Buffer[]) { + const [body, head] = [Buffer.concat(parts), [type]]; + let n = body.length; + do head.push((n % 128) | (n >= 128 ? 128 : 0)); + while ((n = Math.floor(n / 128))); + return Buffer.concat([Buffer.from(head), body]); +} +const [CONNECT, PUBLISH, SUBSCRIBE, PINGREQ, DISCONNECT] = [1, 3, 8, 12, 14]; +const SETTLE_MS = 2000; // the longest a dial may take before settle() gives up on it + +const isMessage = (m: unknown): m is Message => + typeof m === "object" && m !== null && Object.keys(m).every((k) => ["topic", "payload", "retain"].includes(k)) && + typeof (m as Message).topic === "string" && typeof (m as Message).payload === "string" && [undefined, true, false].includes((m as Message).retain); + +export const mqtt: Sender = async () => { + // Every connection, and once it has sent CONNECT, the topics it subscribed to. + const clients = new Map | null>(); + const retained = new Map(); + const heard: Message[] = []; + let [moved, panel] = [0, undefined as Panel | undefined]; // bytes in and out, which settle() watches + + const write = (s: net.Socket, b: Buffer) => ((moved += b.length), s.write(b)); + // To each subscriber with the retain flag clear, and kept for later ones when it is set. An empty one clears what was kept. + function publish(topic: string, payload: Buffer, retain: boolean) { + if (retain && payload.length) retained.set(topic, payload); + else if (retain) retained.delete(topic); + for (const [s, topics] of clients) if (topics?.has(topic)) write(s, packet(0x30, str(topic), payload)); + } + + function received(s: net.Socket, type: number, flags: number, body: Buffer) { + const topics = clients.get(s); + if (type === CONNECT) return void (clients.set(s, new Set()), write(s, packet(0x20, Buffer.from([0, 0])))); + if (!topics) return void s.destroy(); // anything before CONNECT + if (type === PUBLISH) { + const at = 2 + body.readUInt16BE(0); + const [topic, payload, retain] = [body.toString("utf8", 2, at), body.subarray(at + (flags & 6 ? 2 : 0)), (flags & 1) === 1]; + heard.push({ topic, payload: payload.toString(), retain }); + publish(topic, payload, retain); + // QoS 0 has no answer, and a PINGRESP is one a client takes for nothing. Bytes going back carry the TCP ACK + // at once, where the kernel would hold it 40 ms and the panel's next write behind it, by Nagle's rule. + return void write(s, packet(0xd0)); + } + if (type === SUBSCRIBE) { + // Each filter is granted QoS 0, then gets what was kept on its topic, with the retain flag set. + const filters: string[] = []; + for (let at = 2; at < body.length; at += 3 + body.readUInt16BE(at)) filters.push(body.toString("utf8", at + 2, at + 2 + body.readUInt16BE(at))); + filters.forEach((f) => topics.add(f)); + write(s, packet(0x90, body.subarray(0, 2), Buffer.alloc(filters.length))); + for (const f of filters) if (retained.has(f)) write(s, packet(0x31, str(f), retained.get(f)!)); + } else if (type === PINGREQ) write(s, packet(0xd0)); + else if (type === DISCONNECT) s.end(); + } + + const server = net.createServer((s) => { + clients.set(s, null); + s.setNoDelay(true); // with Nagle on, a write behind one not yet acknowledged waits out a 40 ms delayed ACK + let inbox: Buffer = Buffer.alloc(0); + s.on("error", () => s.destroy()); + s.on("close", () => clients.delete(s)); + s.on("data", (chunk: Buffer) => { + moved += chunk.length; + inbox = Buffer.concat([inbox, chunk]); + for (;;) { + let [length, i, scale] = [0, 1, 1]; + for (; i < inbox.length && i < 5; i++, scale *= 128) { + length += (inbox[i] & 127) * scale; + if (inbox[i] < 128) break; + } + if (i === 5) return void s.destroy(); // a length past four bytes is not MQTT + if (i >= inbox.length || inbox.length < i + 1 + length) return; + const [type, flags, body] = [inbox[0] >> 4, inbox[0] & 15, inbox.subarray(i + 1, i + 1 + length)]; + inbox = inbox.subarray(i + 1 + length); + try { + received(s, type, flags, body); + } catch { + return void s.destroy(); // a body shorter than its own fields say + } + } + }); + }); + await new Promise((done, fail) => server.once("error", fail).listen(0, "127.0.0.1", () => done())); + const port = (server.address() as AddressInfo).port; + + return { + heard, + async open(p) { + panel = p; + const r = await p.http("POST /api/mqtt", `host=127.0.0.1&port=${port}`); + if (r.status !== 200) throw new Error(`POST /api/mqtt answered ${r.status}, so the panel has no mqtt feature to point at the broker`); + }, + async send(what) { + if (!isMessage(what)) throw new Error(`mqtt sends { "topic", "payload" }, and "retain": true to keep it, not ${JSON.stringify(what)}`); + publish(what.topic, Buffer.from(what.payload), what.retain === true); + return undefined; + }, + // A dial, its CONNECT and CONNACK and the SUBSCRIBEs after them take real time, the panel's clock or not. Settled + // once the panel is not mid-dial and a round trip over HTTP moved nothing on the broker. + async settle() { + for (const from = Date.now(); ; ) { + const before = moved; + for (let i = 0; i < 3; i++) await new Promise((done) => setImmediate(done)); + const { state } = (await panel!.http("GET /api/mqtt")).body as { state?: string }; + if (state !== "connecting" && moved === before) return; + if (Date.now() - from > SETTLE_MS) throw new Error(`the panel's MQTT is still ${state} after ${SETTLE_MS} ms`); + } + }, + async close() { + for (const s of clients.keys()) s.destroy(); + await new Promise((done) => server.close(() => done())); + }, + }; +}; diff --git a/web/scripts/drift/_lib.ts b/web/scripts/drift/_lib.ts new file mode 100644 index 000000000..f4379364a --- /dev/null +++ b/web/scripts/drift/_lib.ts @@ -0,0 +1,74 @@ +/** + * What the drift checks share. A check is a file beside this one: it reads + * two lists the tree keeps by hand, gives them to compare(), and ends with + * done(). scripts/drift.ts runs them (docs/rfc-cleanup.md §2.4). + * + * compare() prints both sides and what is on one side only, so a red run in + * CI says what drifted without anyone checking the branch out. A side a check + * cannot read where it runs (a tag the clone lacks, no gh login, no pdftotext) + * goes to skip(), which says why. The check then exits SKIPPED, which fails + * nothing and which scripts/drift.ts prints as skipped. Under + * PF_DRIFT_STRICT=1, which the CI workflow sets, a skip fails. + */ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +export const ROOT = execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" }).trim(); + +/** How a check that skipped and found no drift exits. scripts/drift.ts reads it. */ +export const SKIPPED = 75; + +let files: string[] | undefined; +/** Tracked files, relative to the repository root, all of them or those under one folder. */ +export function tracked(under = ""): string[] { + files ??= execFileSync("git", ["ls-files", "-z"], { cwd: ROOT, encoding: "utf8", maxBuffer: 1 << 28 }) + .split("\0") + .filter(Boolean); + return files.filter((f) => f.startsWith(under)); +} + +export const read = (file: string) => fs.readFileSync(path.join(ROOT, file), "utf8"); + +export type Side = { label: string; items: Iterable }; + +let failed = 0; +let skipped = 0; + +const counts = (items: Iterable) => { + const out = new Map(); + for (const x of items) out.set(x, (out.get(x) ?? 0) + 1); + return out; +}; +// What a has more times than b: an item listed twice against once is on one side once. +const extra = (a: Map, b: Map) => + [...a].flatMap(([x, n]) => Array(Math.max(0, n - (b.get(x) ?? 0))).fill(x)).sort(); +const list = (xs: string[]) => (xs.length ? xs.join(", ") : "(none)"); + +/** Prints both sides and what is on one side only. True when the two hold the same items. */ +export function compare(name: string, left: Side, right: Side): boolean { + const [l, r] = [counts(left.items), counts(right.items)]; + const [onlyLeft, onlyRight] = [extra(l, r), extra(r, l)]; + const all = (m: Map) => [...m].flatMap(([x, n]) => Array(n).fill(x)).sort(); + const ok = !onlyLeft.length && !onlyRight.length; + console.log(`${name}: ${ok ? "same" : "drift"}`); + console.log(` ${left.label} (${all(l).length}): ${list(all(l))}`); + console.log(` ${right.label} (${all(r).length}): ${list(all(r))}`); + if (onlyLeft.length) console.log(` only in ${left.label}: ${list(onlyLeft)}`); + if (onlyRight.length) console.log(` only in ${right.label}: ${list(onlyRight)}`); + if (!ok) failed++; + return ok; +} + +/** A side this check cannot read here. Says why; done() then exits SKIPPED, or 1 under PF_DRIFT_STRICT=1. */ +export function skip(name: string, why: string): void { + console.log(`${name}: skipped, ${why}`); + skipped++; +} + +/** The check's result as its exit code: 1 on drift or a strict skip, SKIPPED on a skip, 0 otherwise. */ +export function done(): void { + const strict = process.env.PF_DRIFT_STRICT === "1"; + if (!failed && skipped && strict) console.log("PF_DRIFT_STRICT=1, so a skip fails"); + process.exitCode = failed || (skipped && strict) ? 1 : skipped ? SKIPPED : 0; +} diff --git a/web/scripts/drift/_osc.ts b/web/scripts/drift/_osc.ts new file mode 100644 index 000000000..91aa7bfce --- /dev/null +++ b/web/scripts/drift/_osc.ts @@ -0,0 +1,91 @@ +/** + * The OSC sender check:osc-vectors gives _vectors.ts. A vector writes a message as its address and then its + * arguments (docs/osc-vectors/README.md): a whole number goes as an int32, any other number as a float32, a + * string as a string, and ["#bundle", message, …] wraps the messages in a bundle. One UDP socket a vector sends to + * the panel's osc port, and the panel, started with oscReply 0, answers to the port it was sent from. + */ +import dgram from "node:dgram"; + +import type { Sender } from "./_vectors"; + +// A string ends in a zero and is padded to four bytes. +function pad(s: string) { + const b = Buffer.from(`${s}\0`); + return Buffer.concat([b, Buffer.alloc((4 - (b.length % 4)) % 4)]); +} +function word(n: number, float = false) { + const b = Buffer.alloc(4); + if (float) b.writeFloatBE(n); + else b.writeInt32BE(n); + return b; +} + +function pack(m: unknown): Buffer { + if (!Array.isArray(m) || typeof m[0] !== "string") throw new Error(`osc sends ["/address", ...arguments], not ${JSON.stringify(m)}`); + const [address, ...args] = m as [string, ...unknown[]]; + // A time tag of 1 is "immediately". Each element is its size and then the message. + if (address === "#bundle") return Buffer.concat([pad(address), word(0), word(1), ...args.map(pack).flatMap((b) => [word(b.length), b])]); + const tags = args.map((a) => (typeof a === "string" ? "s" : Number.isInteger(a) ? "i" : typeof a === "number" ? "f" : "?")); + if (tags.includes("?")) throw new Error(`an OSC argument is a number or a string, not in ${JSON.stringify(m)}`); + const values = args.map((a, i) => (tags[i] === "s" ? pad(a as string) : word(a as number, tags[i] === "f"))); + return Buffer.concat([pad(address), pad(`,${tags.join("")}`), ...values]); +} + +/** A message as a vector writes it, or the bytes in hex when they are not one. */ +function read(b: Buffer): unknown { + const string = (at: number): [string, number] => { + const end = b.indexOf(0, at); + if (end < 0) throw new Error("no terminating zero"); + return [b.toString("utf8", at, end), (end + 4) & ~3]; + }; + try { + const [address, at] = string(0); + const [tags, start] = string(at); + const m: unknown[] = [address]; + let i = start; + for (const t of tags.slice(1)) { + if (t === "s") { + const [s, next] = string(i); + m.push(s); + i = next; + } else if (t === "i" || t === "f") { + m.push(t === "i" ? b.readInt32BE(i) : b.readFloatBE(i)); + i += 4; + } else throw new Error(`type tag ${t}`); + } + return m; + } catch { + return `not OSC: ${b.toString("hex")}`; + } +} + +const MARK = Buffer.from("settle"); + +export const osc: Sender = async () => { + const socket = dgram.createSocket("udp4"); + await new Promise((done, fail) => void socket.once("error", fail).bind(0, "127.0.0.1", () => done())); + const me = socket.address().port; + const send = (b: Buffer, port: number) => new Promise((done, fail) => socket.send(b, port, "127.0.0.1", (e) => (e ? fail(e) : done()))); + const [heard, marks] = [[] as unknown[], [] as (() => void)[]]; + socket.on("message", (b, from) => { + if (from.port === me) marks.shift()?.(); + else heard.push(read(b)); + }); + let panel = 0; + return { + open: async ({ ports }) => { + panel = ports.osc; + }, + send: async (what) => { + await send(pack(what), panel); + }, + heard, + // A datagram to itself. The socket reads in order, so once it is back, all the panel sent before it is in heard. + settle: () => + new Promise((done, fail) => { + marks.push(done); + send(MARK, me).catch(fail); + }), + close: () => new Promise((done) => socket.close(() => done())), + }; +}; diff --git a/web/scripts/drift/_rtpmidi.ts b/web/scripts/drift/_rtpmidi.ts new file mode 100644 index 000000000..61dde6a52 --- /dev/null +++ b/web/scripts/drift/_rtpmidi.ts @@ -0,0 +1,130 @@ +/** + * The RTP-MIDI sender check:midi-vectors gives _vectors.ts, an initiator shaped like + * tools/rtpmidi-probe/rtpmidi_probe.py. It invites the panel on its midi port from a control socket and + * on the next port from a data socket, then syncs clocks once. Each step's message goes in an RTP-MIDI + * packet of its own. A vector writes a message as its bytes (docs/midi-vectors/README.md), [176, 24, 65] + * for CC 24 at 65 on channel 1, and heard is each message the panel sent, read back into the same form. + */ +import { randomBytes } from "node:crypto"; +import dgram from "node:dgram"; + +import type { Sender } from "./_vectors"; + +const NAME = "check:midi-vectors"; +const ANSWER_MS = 2000; // the longest an invitation or a clock sync waits for the panel's answer +// Data octets after a channel status, by its high nibble. +const DATA: Record = { 0x8: 2, 0x9: 2, 0xa: 2, 0xb: 2, 0xc: 1, 0xd: 1, 0xe: 2 }; + +// A session packet is two 0xFF octets and a two-letter command (AppleMIDI). Its clock counts 100 µs. +const isSession = (b: Buffer) => b.length >= 4 && b.readUInt16BE(0) === 0xffff; +const ticks = () => Math.round(performance.now() * 10); +function session(command: string, ...words: number[]) { + const b = Buffer.alloc(4 + 4 * words.length); + b.writeUInt16BE(0xffff, 0); + b.write(command, 2, "latin1"); + words.forEach((w, i) => b.writeUInt32BE(w, 4 + 4 * i)); + return b; +} +// A clock sync: the initiator's SSRC, the count, three pad octets and three times. +function sync(ssrc: number, count: number, times: (number | bigint)[]) { + const b = Buffer.concat([session("CK", ssrc, count << 24), Buffer.alloc(24)]); + times.forEach((t, i) => b.writeBigUInt64BE(BigInt(t), 12 + 8 * i)); + return b; +} + +const isMessage = (m: unknown): m is number[] => + Array.isArray(m) && m.every((b, i) => Number.isInteger(b) && b >= (i ? 0 : 0x80) && b < (i ? 0x80 : 0xf0)) && m.length === 1 + DATA[m[0] >> 4]; + +/** Each channel message in an RTP-MIDI packet (RFC 6295), running status filled in. Anything else comes back in hex. */ +function read(b: Buffer): unknown[] { + const long = (b[12] & 0x80) !== 0; // B: a 12-bit length + let at = long ? 14 : 13; + const end = at + (long ? ((b[12] & 0x0f) << 8) | b[13] : b[12] & 0x0f); + if (b.length < at || b[0] >> 6 !== 2 || (b[1] & 0x7f) !== 0x61 || end > b.length) return [`not RTP-MIDI: ${b.toString("hex")}`]; + let [delta, running] = [(b[12] & 0x20) !== 0, 0]; // Z: the first message has a delta time too + const out: unknown[] = []; + while (at < end) { + if (delta) while (at < end && b[at++] & 0x80); // one to four octets, the last under 128 + delta = true; + const status = b[at] & 0x80 ? b[at++] : running; + const m = [status, ...b.subarray(at, (at += DATA[status >> 4] ?? end))]; + if (!isMessage(m)) return [...out, `not a channel message: ${b.toString("hex", 12, end)}`]; + out.push(m); + running = status; + } + return out; +} + +/** Sends a session packet and resolves with the answer of that command on the same socket. */ +function ask(socket: dgram.Socket, port: number, b: Buffer, command: string) { + const what = `${b.toString("latin1", 2, 4)} to port ${port}`; + return new Promise((done, fail) => { + const timer = setTimeout(() => (socket.off("message", hear), fail(new Error(`${what}: no answer in ${ANSWER_MS} ms`))), ANSWER_MS); + function hear(r: Buffer) { + if (!isSession(r) || ![command, "NO"].includes(r.toString("latin1", 2, 4))) return; + clearTimeout(timer); + socket.off("message", hear); + if (r.toString("latin1", 2, 4) === "NO") fail(new Error(`${what}: the panel said NO`)); + else done(r); + } + socket.on("message", hear); + socket.send(b, port, "127.0.0.1", (e) => e && fail(e)); + }); +} + +const MARK = Buffer.from("settle"); + +async function socket() { + const s = dgram.createSocket("udp4"); + await new Promise((done, fail) => void s.once("error", fail).bind(0, "127.0.0.1", () => done())); + return s; +} + +export const midi: Sender = async () => { + const [control, data] = [await socket(), await socket()]; + const me = data.address().port; + const [ssrc, token] = [randomBytes(4).readUInt32BE(0), randomBytes(4).readUInt32BE(0)]; + const [heard, marks] = [[] as unknown[], [] as (() => void)[]]; + data.on("message", (b, from) => { + if (from.port === me) marks.shift()?.(); + else if (!isSession(b)) heard.push(...read(b)); // session packets are open()'s + }); + const send = (b: Buffer, port: number) => new Promise((done, fail) => data.send(b, port, "127.0.0.1", (e) => (e ? fail(e) : done()))); + + let [panel, seq] = [0, randomBytes(2).readUInt16BE(0)]; + return { + // An invitation on control and then on data, which the panel keeps on the port after it, and one clock sync. + open: async ({ ports }) => { + const invite = Buffer.concat([session("IN", 2, token, ssrc), Buffer.from(`${NAME}\0`)]); + await ask(control, ports.midi, invite, "OK"); + await ask(data, ports.midi + 1, invite, "OK"); + const answer = await ask(data, ports.midi + 1, sync(ssrc, 0, [ticks(), 0, 0]), "CK"); + await send(sync(ssrc, 2, [answer.readBigUInt64BE(12), answer.readBigUInt64BE(20), ticks()]), ports.midi + 1); + panel = ports.midi; + }, + send: async (what) => { + if (!isMessage(what)) throw new Error(`midi sends one channel message, [status, ...data], not ${JSON.stringify(what)}`); + const b = Buffer.alloc(13); + b[0] = 0x80; // version 2 + b[1] = 0x61; // payload type 97 + b.writeUInt16BE(seq, 2); + b.writeUInt32BE(ticks() % 2 ** 32, 4); + b.writeUInt32BE(ssrc, 8); + b[12] = what.length; // a list under 16 octets has the short header + seq = (seq + 1) & 0xffff; + await send(Buffer.concat([b, Buffer.from(what)]), panel + 1); + }, + heard, + // A datagram to itself. The socket reads in order, so once it is back, all the panel sent before it is in heard. + settle: () => + new Promise((done, fail) => { + marks.push(done); + send(MARK, me).catch(fail); + }), + // BY, as the probe ends, since a device keeps two participants at most. + close: async () => { + if (panel) await new Promise((done) => control.send(session("BY", 2, token, ssrc), panel, "127.0.0.1", () => done())); + await Promise.all([control, data].map((s) => new Promise((done) => s.close(() => done())))); + }, + }; +}; diff --git a/web/scripts/drift/_vectors.ts b/web/scripts/drift/_vectors.ts new file mode 100644 index 000000000..e3af41304 --- /dev/null +++ b/web/scripts/drift/_vectors.ts @@ -0,0 +1,215 @@ +/** + * The replay the contract-vector suites share (docs/contract-vectors.md). replay() starts a fresh + * virtual panel (tools/virtual-panel/) for each .json in a vector folder, on a manual clock and free + * ports, runs its steps and gives compare() what the vector expects beside what the panel did. It + * reaches the panel only through its ports: http, hand and wait go over HTTP, and a suite passes in + * a Sender for each other kind, kept in a _ file of its own. A vector whose `says` is gone from its + * spec fails, so a spec edit brings its vectors along. + */ +import fs from "node:fs"; +import http from "node:http"; + +import { start } from "../../../tools/virtual-panel/panel"; + +import { compare, read, ROOT } from "./_lib"; + +/** What a suite's Sender opens for one vector. */ +export type Channel = { + /** Ports the panel starts with, over "*": 0, such as where its replies go. */ + ports?: Record; + /** Runs once the panel is up, before the first step. */ + open?(panel: Panel): Promise; + /** One step's send. What it returns is the step's reply. */ + send(what: unknown): Promise; + /** What the panel has sent on this kind, oldest first. The channel adds to it, and replay() times each. */ + heard: unknown[]; + /** Resolves once nothing is in flight. replay() waits on it after each send and each move of the clock. */ + settle?(): Promise; + close(): Promise; +}; +export type Sender = () => Promise; +export type Panel = { ports: Record; http(request: string, form?: string): Promise }; +export type Reply = { status: number; headers: Record; body: unknown }; + +type Out = Record & { after?: number; within?: number }; +type Step = { send: Record; expect?: { reply?: unknown; status?: unknown; input?: unknown; out?: Out[] } }; +type Vector = { contract: string; says: string; features: string[]; steps: Step[] }; + +const SENT = ["osc", "midi", "mqtt", "ws"]; // the kinds a suite brings, and the ones the panel sends on +const KINDS = ["http", "hand", "wait", ...SENT]; +const EXPECTS = ["reply", "status", "input", "out"]; + +const isObject = (x: unknown): x is Record => typeof x === "object" && x !== null && !Array.isArray(x); +const TYPES: Record boolean> = { + "": (x) => x === undefined, + "": (x) => x !== undefined, + "": (x) => typeof x === "number", + "": (x) => Number.isInteger(x), + "": (x) => typeof x === "string", + "": (x) => typeof x === "boolean", + "": (x) => Array.isArray(x), + "": isObject, +}; +const isType = (x: unknown): x is string => typeof x === "string" && Object.hasOwn(TYPES, x); +const show = (x: unknown) => (x === undefined ? "" : isType(x) ? x : JSON.stringify(x)); + +/** + * Each place a shape names, as "where shape", and the value there: the same text where it fits, the + * value where not. True when every place fits. + */ +function match(where: string, shape: unknown, value: unknown, want: string[] = [], got: string[] = []): boolean { + if (Array.isArray(shape) && Array.isArray(value) && shape.length === value.length) { + return shape.map((s, i) => match(`${where}[${i}]`, s, value[i], want, got)).every(Boolean); + } + if (isObject(shape) && isObject(value)) return Object.entries(shape).map(([k, s]) => match(`${where}.${k}`, s, value[k], want, got)).every(Boolean); + const fits = isType(shape) ? TYPES[shape](value) : shape === value; + want.push(`${where} ${show(shape)}`); + got.push(`${where} ${show(fits ? shape : value)}`); + return fits; +} + +// GitHub's heading ids, slugged the way scripts/doc-map.ts slugs them. +function anchors(md: string) { + const seen = new Map(); + return new Set( + [...md.replace(/```[\s\S]*?```/g, "").matchAll(/^ {0,3}#{1,6}[ \t]+(.*?)[ \t]*#*[ \t]*$/gm)].map(([, h]) => { + const text = h.replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/<[^>]+>/g, "").replace(/[*`~]/g, ""); + const base = text.toLowerCase().replace(/[^\p{L}\p{M}\p{N}\p{Pc} -]/gu, "").replace(/ /g, "-"); + seen.set(base, (seen.get(base) ?? -1) + 1); + return seen.get(base) ? `${base}-${seen.get(base)}` : base; + }), + ); +} +/** Markdown and line breaks taken out, so a sentence is found however the spec wraps or marks it. */ +const plain = (md: string) => md.replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/[*_`|\\]/g, "").replace(/\s+/g, " ").trim(); + +/** Throws on a vector that checks less than it seems to: a misspelled key would pass, checking nothing. */ +function check(v: unknown): asserts v is Vector { + const only = (o: unknown, keys: string[]) => isObject(o) && Object.keys(o).every((k) => keys.includes(k)); + const ms = (x: unknown) => x === undefined || (typeof x === "number" && x >= 0); + const one = (o: Record, kinds: string[]) => Object.keys(o).filter((k) => kinds.includes(k)).length === 1; + const step = (s: Step) => + only(s, ["send", "expect"]) && only(s.send, KINDS) && one(s.send, KINDS) && only(s.expect ?? {}, EXPECTS) && ms(s.send.wait) && + [s.expect?.out ?? []].every(Array.isArray) && (s.expect?.out ?? []).every((o) => only(o, ["after", "within", ...SENT]) && one(o, SENT) && ms(o.after) && ms(o.within)); + const { contract, says, features, steps } = (isObject(v) ? v : {}) as Partial; + const why = [ + !only(v, ["contract", "says", "features", "steps"]) && "a vector has contract, says, features and steps", + !/^docs\/[^#]+\.md#.+$/.test(String(contract)) && "contract is a spec in docs/ and one of its headings, as docs/x.md#anchor", + !(typeof says === "string" && says.trim()) && "says is a sentence from that spec", + !Array.isArray(features) && "features lists what the panel is built with, [] for the default build", + !(Array.isArray(steps) && steps.length) && "steps lists one step or more", + ...(Array.isArray(steps) ? steps : []).map((s, i) => !step(s) && `step ${i + 1} is not a send of one kind and an expect of ${EXPECTS.join(", ")}`), + ].filter(Boolean); + if (why.length) throw new Error(why.join("; ")); +} + +/** One request, on a connection of its own. A JSON body comes back parsed. */ +async function request(port: number, line: string, form?: string, headers: Record = {}): Promise { + const [method, target] = String(line).split(" "); + if (!target?.startsWith("/")) throw new Error(`http sends "METHOD /path?query", not ${JSON.stringify(line)}`); + if (form !== undefined) headers = { "Content-Type": "application/x-www-form-urlencoded", ...headers }; + const res = await new Promise((done, fail) => { + http.request({ host: "127.0.0.1", port, method, path: target, headers, agent: false }, done).once("error", fail).end(form); + }); + let text = ""; + for await (const part of res) text += part; + const flat = Object.entries(res.headers).map(([k, v]) => [k, Array.isArray(v) ? v.join(", ") : String(v)]); + const json = res.headers["content-type"]?.startsWith("application/json"); + return { status: res.statusCode ?? 0, headers: Object.fromEntries(flat), body: json ? JSON.parse(text) : text }; +} + +async function run(file: string, senders: Record, want: string[], got: string[]) { + const v: unknown = JSON.parse(read(file)); + check(v); + const [spec, anchor] = v.contract.split("#"); + const text = read(spec); + want.push(`contract ${v.contract}`, `says ${spec}`); + got.push(anchors(text).has(anchor) ? `contract ${v.contract}` : `contract ${spec} has no #${anchor}`); + got.push(plain(text).includes(plain(v.says)) ? `says ${spec}` : `says not in ${spec}`); + + const kindOf = (o: Out) => Object.keys(o).find((k) => SENT.includes(k)) as string; + const kinds = new Set(v.steps.flatMap((s) => [...Object.keys(s.send), ...(s.expect?.out ?? []).map(kindOf)]).filter((k) => SENT.includes(k))); + const channels = new Map(); + let panel: Awaited> | undefined; + try { + for (const kind of kinds) { + if (!senders[kind]) throw new Error(`this suite brings no sender for ${kind}`); + channels.set(kind, await senders[kind]()); + } + panel = await start({ features: v.features, clock: "manual", ports: Object.assign({ "*": 0 }, ...[...channels.values()].map((c) => c.ports)) }); + const port = panel.ports.http; + for (const c of channels.values()) await c.open?.({ ports: panel.ports, http: (line, form) => request(port, line, form) }); + + // A message is timed by the panel's clock when replay() first sees it, after a send or a move of the clock. + let now = 0; + const times = new Map([...channels.keys()].map((k) => [k, [] as number[]])); + const drain = async () => { + for (let i = 0; i < 3; i++) await new Promise((done) => setImmediate(done)); + for (const c of channels.values()) await c.settle?.(); + for (const [k, c] of channels) while (times.get(k)!.length < c.heard.length) times.get(k)!.push(now); + }; + await drain(); + + for (const [i, s] of v.steps.entries()) { + const at = `step ${String(i + 1).padStart(2, "0")}`; + const [[kind, what]] = Object.entries(s.send); + const [t0, next] = [now, new Map([...channels].map(([k, c]) => [k, c.heard.length]))]; + let reply: unknown; + if (kind === "http") { + const h = (typeof what === "string" ? { request: what } : what) as { request: string; form?: string; headers?: Record }; + reply = await request(port, h.request, h.form, h.headers); + } else if (kind === "hand") { + const h = what as { knob: number; turn?: number; down?: boolean }; + const r = await request(port, "turn" in h ? `GET /vp/turn?knob=${h.knob}&detents=${h.turn}` : `GET /vp/button?knob=${h.knob}&down=${Number(h.down)}`); + if (r.status !== 200) throw new Error(`${at} hand: ${r.body}`); + } else if (kind !== "wait") reply = await channels.get(kind)!.send(what); + await drain(); + + // The clock runs to the end of a wait and on to the last edge an out names, stopping at each + // edge, so a message falls wholly inside or outside each window. + const out = s.expect?.out ?? []; + const span = kind === "wait" ? (what as number) : 0; + for (const ms of [span, ...out.flatMap((o) => [o.after ?? 0, o.within ?? span])].sort((a, b) => a - b)) { + if (t0 + ms <= now) continue; + const r = await request(port, `GET /vp/advance?ms=${t0 + ms - now}`); + if (r.status !== 200) throw new Error(`the clock: ${r.body}`); + now = (r.body as { now: number }).now; + await drain(); + } + + const e = s.expect ?? {}; + if ("reply" in e) match(`${at} reply`, e.reply, reply, want, got); + if ("status" in e) match(`${at} status`, e.status, (await request(port, "GET /api/status")).body, want, got); + if ("input" in e) match(`${at} input`, e.input, (await request(port, "GET /vp/state")).body, want, got); + // In order: each out is a message later than the one matched before it on its kind. + for (const o of out) { + const [k, after, within] = [kindOf(o), o.after ?? -1, o.within ?? span]; + const [heard, time, from] = [channels.get(k)!.heard, times.get(k)!, next.get(k)!]; + const window = heard.map((_, j) => j).filter((j) => j >= from && time[j] - t0 > after && time[j] - t0 <= within); + const hit = o[k] === "" ? (window.length ? undefined : from - 1) : window.find((j) => match("", o[k], heard[j])); + const say = (message: string) => `${at} out ${k} ${message} ${o.after === undefined ? "" : `after ${after} ms, `}by ${within} ms`; + want.push(say(show(o[k]))); + got.push(say(hit !== undefined ? show(o[k]) : window.map((j) => `${show(heard[j])} at ${time[j] - t0} ms`).join(", ") || "nothing")); + if (hit !== undefined) next.set(k, hit + 1); + } + } + } finally { + const closed = await Promise.allSettled([panel?.close(), ...[...channels.values()].map((c) => c.close())]); + for (const c of closed) if (c.status === "rejected") got.push(`close threw ${c.reason}`); + } +} + +/** Replays each vector in a folder, named from the repository root, with a suite's senders: one compare() a vector. */ +export async function replay(folder: string, senders: Record = {}) { + const files = fs.readdirSync(`${ROOT}/${folder}`).filter((f) => f.endsWith(".json")).sort(); + if (!files.length) throw new Error(`${folder} has no vectors`); + for (const f of files) { + const [want, got]: string[][] = [[], []]; + try { + await run(`${folder}/${f}`, senders, want, got); + } catch (e) { + got.push(`threw ${(e as Error).message}`); + } + compare(`${folder}/${f}`, { label: "the vector", items: want }, { label: "the panel", items: got }); + } +} diff --git a/web/scripts/drift/_ws.ts b/web/scripts/drift/_ws.ts new file mode 100644 index 000000000..ad501f264 --- /dev/null +++ b/web/scripts/drift/_ws.ts @@ -0,0 +1,83 @@ +/** + * The WebSocket sender check:audio-vectors gives _vectors.ts. Node 20, which CI runs, has no WebSocket client, so + * this is RFC 6455 on node:http's upgrade, as much as a client that only sends needs. One socket a vector, opened on + * the panel's ws port before the first step. A step's ws is the text of one frame, or { "close": 1000 } for a close + * frame with that code (docs/audio-ws-vectors/README.md). heard is each text frame the panel sent, and + * docs/audio-ws-spec.md says it sends none. + */ +import { createHash, randomBytes } from "node:crypto"; +import http from "node:http"; +import type { Socket } from "node:net"; + +import type { Sender } from "./_vectors"; + +const GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; // RFC 6455's, for Sec-WebSocket-Accept +const [TEXT, CLOSE, PING, PONG] = [0x1, 0x8, 0x9, 0xa]; +const SETTLE_MS = 2000; // the longest the panel may take to answer a ping or a close + +/** One piece, masked as a client masks every frame, its length in the seven bits that hold up to 125 bytes. */ +function frame(op: number, payload: Buffer) { + if (payload.length > 125) throw new Error(`a ws message is 125 bytes at most, not ${payload.length}`); + const mask = randomBytes(4); + return Buffer.concat([Buffer.from([0x80 | op, 0x80 | payload.length]), mask, payload.map((x, i) => x ^ mask[i % 4])]); +} + +export const ws: Sender = async () => { + const [heard, waits] = [[] as string[], [] as (() => void)[]]; + let [socket, closed, buf] = [undefined as Socket | undefined, false, Buffer.alloc(0)]; + const gone = () => { + closed = true; + waits.splice(0).forEach((done) => done()); + }; + // The panel's frames are unmasked and short. A pong ends the oldest wait, and a close ends them all. + function read(more: Buffer) { + buf = Buffer.concat([buf, more]); + while (buf.length >= 2 && buf.length >= 2 + (buf[1] & 0x7f)) { + const [op, payload] = [buf[0] & 0x0f, buf.subarray(2, 2 + (buf[1] & 0x7f))]; + buf = buf.subarray(2 + payload.length); + if (op === TEXT) heard.push(payload.toString("utf8")); + else if (op === PONG) waits.shift()?.(); + else if (op === CLOSE) return void (gone(), socket?.end()); + } + } + // Writes a frame and resolves on the panel's answer, or once the socket closes. + const answered = (b: Buffer) => + new Promise((done, fail) => { + if (closed) return done(); + const timer = setTimeout(() => fail(new Error(`the panel did not answer in ${SETTLE_MS} ms`)), SETTLE_MS); + waits.push(() => (clearTimeout(timer), done())); + socket!.write(b); + }); + + return { + open: async ({ ports }) => { + const key = randomBytes(16).toString("base64"); + const headers = { Connection: "Upgrade", Upgrade: "websocket", "Sec-WebSocket-Key": key, "Sec-WebSocket-Version": "13" }; + socket = await new Promise((done, fail) => { + const req = http.request({ host: "127.0.0.1", port: ports.ws, path: "/", headers, agent: false }); + req.once("response", (res) => fail(new Error(`the panel answered the handshake with ${res.statusCode}, not 101`))); + req.once("upgrade", (res, s: Socket, head: Buffer) => { + if (res.headers["sec-websocket-accept"] !== createHash("sha1").update(key + GUID).digest("base64")) return fail(new Error("Sec-WebSocket-Accept does not match the key")); + s.setNoDelay(true); // a ping behind a message not yet acknowledged would wait out a 40 ms delayed ACK + s.on("data", read).on("close", gone).on("error", () => s.destroy()); + done(s); + read(head); + }); + req.once("error", fail).end(); + }); + }, + send: async (what) => { + if (closed) throw new Error("the socket is closed"); + if (typeof what === "string") return void socket!.write(frame(TEXT, Buffer.from(what))); + const code = (what as { close?: unknown } | null)?.close; + if (!Number.isInteger(code) || Object.keys(what as object).length !== 1) throw new Error(`ws sends a frame's text or { "close": code }, not ${JSON.stringify(what)}`); + const b = Buffer.alloc(2); + b.writeUInt16BE(code as number); + await answered(frame(CLOSE, b)); // the panel's close frame back says it has read this one + }, + heard, + // A ping. The panel reads frames in order, so once its pong is back it holds every message sent before it. + settle: () => answered(frame(PING, Buffer.alloc(0))), + close: () => new Promise((done) => (socket && !socket.destroyed ? socket.once("close", () => done()).destroy() : done())), + }; +}; diff --git a/web/scripts/drift/audio-vectors.ts b/web/scripts/drift/audio-vectors.ts new file mode 100644 index 000000000..ae8dd4e6c --- /dev/null +++ b/web/scripts/drift/audio-vectors.ts @@ -0,0 +1,10 @@ +/** + * check:audio-vectors: docs/audio-ws-vectors/ against a fresh virtual panel each + * (docs/contract-vectors.md), built with the audio feature. The sender is + * _ws.ts, one WebSocket a vector. + */ +import { done } from "./_lib"; +import { replay } from "./_vectors"; +import { ws } from "./_ws"; + +replay("docs/audio-ws-vectors", { ws }).then(done); diff --git a/web/scripts/drift/bom-schematic.ts b/web/scripts/drift/bom-schematic.ts new file mode 100644 index 000000000..0e75e6c5d --- /dev/null +++ b/web/scripts/drift/bom-schematic.ts @@ -0,0 +1,37 @@ +/** + * check:bom-schematic: the BOM's designators against the schematic's. + * hardware/pcb/schematic.pdf is KiCad output in stroke fonts, and pdftotext + * (poppler) reads every designator out of its text layer all the same, so + * this is strict from its first day. + * + * A token in the PDF is a designator when it has a designator's prefix: U, + * SW, J and C are on the board, R, D, Q, L and F are what a part added later + * would carry, and USB is what the v3.0 board's USB-C footprint was (hard + * rule 6). Pin names (S1, IO4) and ESP32 carry none of them. Without + * pdftotext this skips. + */ +import { execFileSync } from "node:child_process"; +import path from "node:path"; + +import { BOM, bom, designators } from "./_bom"; +import { ROOT, compare, done, skip } from "./_lib"; + +const PDF = "hardware/pcb/schematic.pdf"; +const PREFIXES = ["U", "SW", "J", "C", "R", "D", "Q", "L", "F", "USB"]; + +let text: string | undefined; +try { + text = execFileSync("pdftotext", ["-layout", path.join(ROOT, PDF), "-"], { encoding: "utf8" }); +} catch (e) { + if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e; + skip("designators", "pdftotext is not installed (poppler-utils)"); +} +if (text !== undefined) { + const token = new RegExp(`\\b(?:${PREFIXES.join("|")})\\d+\\b`, "g"); + compare( + "designators", + { label: BOM, items: bom().flatMap((r) => designators(r.ref)) }, + { label: PDF, items: new Set(text.match(token)) }, + ); +} +done(); diff --git a/web/scripts/drift/bom.ts b/web/scripts/drift/bom.ts new file mode 100644 index 000000000..64d4e8afe --- /dev/null +++ b/web/scripts/drift/bom.ts @@ -0,0 +1,78 @@ +/** + * check:bom: the BOM against the two parts tables in section 1 of + * BUILD_GUIDE.md. Hard rule 2 derives the guide's tables from the BOM, so a + * designator, a part or a quantity on one side only is drift. + * + * On the board a row is its designators, its quantity and its MPN. The names + * are worded apart ("Rotary encoder w/ switch" for "Rotary encoder with push + * switch") and are not compared. The sockets row has no designator: the BOM + * writes "U1 (sockets)" and the guide "—". Off the board there is neither, so + * the rows pair in order, and the guide's part keeps every word of the BOM's: + * "Sacrificial USB cable (it gets cut)" for "USB cable (sacrificial)". A + * hyphen and an en dash are the same in a quantity, so 6-12 is 6–12. + */ +import { BOM, bom, designators, type Row } from "./_bom"; +import { compare, done, read } from "./_lib"; + +const GUIDE = "BUILD_GUIDE.md"; + +// Bold, code, emphasis and link targets out; a link keeps its text. +const plain = (s: string) => + s + .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") + .replace(/[*`]/g, "") + .trim(); +const dashes = (s: string) => s.replace(/[–—]/g, "-"); + +/** The first table under a heading of the guide, each row keyed by the table's header line. */ +function table(heading: string, columns: string[]): Row[] { + const lines = read(GUIDE).split("\n"); + const at = lines.findIndex((l) => l.trim() === heading); + if (at < 0) throw new Error(`${GUIDE} has no "${heading}"`); + const rows: string[][] = []; + for (const line of lines.slice(at + 1)) { + if (line.startsWith("|")) rows.push(line.trim().replace(/^\||\|$/g, "").split("|").map(plain)); + else if (rows.length || line.startsWith("#")) break; + } + const [head, ...body] = rows.filter((r) => !r.every((c) => /^:?-+:?$/.test(c))); + for (const c of columns) if (!head?.includes(c)) throw new Error(`${GUIDE}, ${heading}: no "${c}" column`); + return body.map((r) => Object.fromEntries(head.map((h, i) => [h, r[i] ?? ""]))); +} + +const rows = bom(); + +// On the board: designators, quantity, MPN. The guide's MPN cell ends in the maker, "(Bourns)". +const ref = (cell: string) => { + const d = designators(dashes(cell)); + return d.length > 1 ? `${d[0]}-${d[d.length - 1]}` : (d[0] ?? "—"); +}; +const part = (r: string, qty: string, mpn: string) => `${ref(r)}: ${dashes(qty)} × ${mpn}`; +compare( + "on the board", + { label: BOM, items: rows.filter((r) => r.category === "pcb").map((r) => part(r.ref, r.qty, r.mpn)) }, + { + label: GUIDE, + items: table("### On the board", ["Ref", "Qty", "MPN (Manufacturer)"]).map((g) => + part(g.Ref, g.Qty, g["MPN (Manufacturer)"].replace(/\s*\([^)]*\)$/, "")), + ), + }, +); + +// Off the board: quantity and part, in order. A guide row is named as the BOM's +// row in its place when it holds every word of that row's part. +const words = (s: string) => s.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? []; +const off = rows.filter((r) => r.category === "off-board"); +console.log("off the board, rows pair in order, and a guide row with every word of its BOM row's part takes that name"); +compare( + "off the board", + { label: BOM, items: off.map((r) => `${dashes(r.qty)} × ${r.part}`) }, + { + label: GUIDE, + items: table("### Off the board", ["Qty", "Part"]).map((g, i) => { + const has = new Set(words(g.Part)); + const same = off[i] && words(off[i].part).every((w) => has.has(w)); + return `${dashes(g.Qty)} × ${same ? off[i].part : g.Part}`; + }), + }, +); +done(); diff --git a/web/scripts/drift/changelog.ts b/web/scripts/drift/changelog.ts new file mode 100644 index 000000000..894986f0f --- /dev/null +++ b/web/scripts/drift/changelog.ts @@ -0,0 +1,92 @@ +/** + * check:changelog: CHANGELOG.md against docs/releases/. Every release has a + * file there. The root keeps the three newest in full and one line for each + * older release, and release.py cut writes both (docs/RELEASING.md). So the + * newest three are written twice, and an edit to one copy alone is drift. + * + * A section in full equals its file once its relative links point two folders + * up. That is release.py's as_filed, and it is the only rewrite: a link with a + * scheme, an anchor or a leading slash stays as it is. A line under "Older + * releases" is what release.py's older_line makes of its file's heading, so + * the version, the file name and the date have to agree. Every vX.Y.Z.md is in + * the root one way or the other. The -notes and -launch files are not + * changelog sections and are not counted. + */ +import { compare, done, read, tracked } from "./_lib"; + +const CHANGELOG = "CHANGELOG.md"; +const RELEASES = "docs/releases/"; +const OLDER = "## Older releases"; + +// release.py reads \r\n as \n, so a checkout with CRLF compares the same. +const text = (file: string) => read(file).replace(/\r\n/g, "\n"); + +// A release section runs from its "## [X.Y.Z] - date" line to the next "## ", as SECTION in release.py. +const SECTION = /^## \[(?!Unreleased\])([^\]]+)\][^\n]*\n[\s\S]*?(?=^## |(?![\s\S]))/gm; + +// [3.11.0] and [v1.1.0] alike: the file name always carries the v. +const fileOf = (label: string) => `${RELEASES}v${label.replace(/^v+/, "")}.md`; + +// $& is the "](" itself, so this file holds no link for doc-map.ts to follow. +const asFiled = (section: string) => + section.replace(/\n+$/, "").replace(/\]\((?![a-z][a-z0-9+.-]*:|[#/])/g, "$&../../") + "\n"; + +function olderLine(file: string): string { + const m = /^## \[([^\]]+)\]([^\n]*)/.exec(text(file)); + if (!m) return `${file} has no "## [X.Y.Z]" heading`; + return `- [${m[1]}](${fileOf(m[1])})${m[2]}`; +} + +const root = text(CHANGELOG); +const rootLines = root.split("\n"); +const sections = [...root.matchAll(SECTION)]; +const releases = new Set(tracked(RELEASES).filter((f) => /^v\d+\.\d+\.\d+\.md$/.test(f.slice(RELEASES.length)))); + +const same: string[] = []; +for (const s of sections) { + const [label, file] = [s[1], fileOf(s[1])]; + if (!releases.has(file)) { + console.log(`[${label}]: ${file} does not exist`); + continue; + } + const want = asFiled(s[0]).split("\n"); + const have = text(file).split("\n"); + const at = Array.from({ length: Math.max(want.length, have.length) }, (_, i) => i).find((i) => want[i] !== have[i]); + if (at === undefined) { + same.push(label); + continue; + } + // The section's line numbers in the root, counted from 1. as_filed keeps every line where it was. + const start = root.slice(0, s.index).split("\n").length; + // An entry is one long line, so the run shows where in it the two part. + const [a, b] = [want[at], have[at]]; + let col = 0; + while (a !== undefined && b !== undefined && col < a.length && a[col] === b[col]) col++; + const from = Math.max(0, col - 30); + const show = (line: string | undefined) => + line === undefined + ? "(ends before this line)" + : (from ? "…" : "") + JSON.stringify(line.slice(from, from + 80)) + (line.length > from + 80 ? "…" : ""); + console.log(`[${label}]: CHANGELOG.md line ${start + at} and ${file} line ${at + 1} differ from column ${col + 1}`); + console.log(` CHANGELOG.md, links as filed: ${show(a)}`); + console.log(` ${file}: ${show(b)}`); +} +compare( + "sections in full against their files", + { label: CHANGELOG, items: sections.map((s) => s[1]) }, + { label: `${RELEASES}, word for word`, items: same }, +); + +const heading = rootLines.indexOf(OLDER); +const listed = []; +for (const line of heading < 0 ? [] : rootLines.slice(heading + 1)) { + if (line.startsWith("## ")) break; + if (line.startsWith("- ")) listed.push(line); +} +const inFull = new Set(sections.map((s) => fileOf(s[1]))); +compare( + "older releases against their files", + { label: `lines under "${OLDER.slice(3)}"`, items: listed }, + { label: `the other files in ${RELEASES}, by their headings`, items: [...releases].filter((f) => !inFull.has(f)).map(olderLine) }, +); +done(); diff --git a/web/scripts/drift/editions.ts b/web/scripts/drift/editions.ts new file mode 100644 index 000000000..5298ff335 --- /dev/null +++ b/web/scripts/drift/editions.ts @@ -0,0 +1,82 @@ +/** + * check:editions: the cards on /editions against the folders under + * firmware/bundles/. It checks names. Versions, and that each named image has + * its files, are check_versions.py's. + * + * Every card has a bundle, and every bundle is a card or a try-out on + * /features. Two kinds of card have no bundle. The default is the card whose + * id is the variant the core reports with no bundle, PF_VARIANT in + * net_config.h. A card with a github field is released from that repository + * (EditionCard.tsx reads its latest tag there), so it is built there too. + * Both are read from the tree and printed. + * + * The folders in web/public/flash/bin/ are the images the cards, the try-outs + * and the flasher's manifest.json name: shelf.sh stages one per name and + * version, and nothing else writes there. The EDITIONS tuples in release.py + * and check_versions.py are the cards built here bar the default, the list + * both scripts keep by hand. + */ +import path from "node:path"; + +import { EDITIONS } from "../../src/app/editions/editions-data"; +import { FEATURES } from "../../src/app/features/features-data"; +import { compare, done, read, tracked } from "./_lib"; + +const NET_CONFIG = "firmware/patternflow/net_config.h"; +const BUNDLES = "firmware/bundles/"; +const BIN = "web/public/flash/bin/"; +const MANIFEST = "web/public/flash/manifest.json"; +const SITE = new URL("https://patternflow.work/"); +const SCRIPTS = ["firmware/toolchain/release.py", "firmware/toolchain/check_versions.py"]; + +/** The folder under /flash/bin/ a URL on the site points into. Undefined for a URL hosted elsewhere. */ +function binFolder(url: string): string | undefined { + const u = new URL(url, SITE); + return u.origin === SITE.origin ? /^\/flash\/bin\/([^/]+)\//.exec(u.pathname)?.[1] : undefined; +} + +/** The first folder of each tracked path under dir. */ +const folders = (dir: string) => + new Set(tracked(dir).map((f) => f.slice(dir.length).split("/")).filter((parts) => parts.length > 1).map((parts) => parts[0])); + +const core = /^#define PF_VARIANT "([^"]+)"/m.exec(read(NET_CONFIG))?.[1]; +if (!core) throw new Error(`${NET_CONFIG}: no #define PF_VARIANT "..." line`); +const elsewhere = EDITIONS.filter((e) => e.github); +const built = EDITIONS.filter((e) => e.id !== core && !e.github).map((e) => e.id); +const tryOuts = FEATURES.flatMap((f) => (f.tryOut ? [f.tryOut.url] : [])); +// shelf.sh names a folder -. A try-out URL of another shape stays whole, so the run shows it. +const tryOutNames = tryOuts.map((url) => binFolder(url)?.replace(/-v\d+\.\d+\.\d+$/, "") ?? url); +console.log(`the default, by PF_VARIANT in net_config.h: ${core}`); +console.log(`cards released from another repository, by their github field: ${elsewhere.map((e) => e.id).join(", ") || "(none)"}`); +console.log(`try-outs, by tryOut in features-data.ts: ${tryOutNames.join(", ") || "(none)"}`); + +compare( + "cards and try-outs against bundles", + { label: "cards built here, and try-outs", items: new Set([...built, ...tryOutNames]) }, + { label: BUNDLES, items: folders(BUNDLES) }, +); + +const manifest = JSON.parse(read(MANIFEST)) as { builds: { parts: { path: string }[] }[] }; +// A part's path is relative to the manifest, which the site serves at /flash/manifest.json. +const manifestUrl = new URL("flash/manifest.json", SITE); +const named = [ + ...EDITIONS.flatMap((e) => (e.hosted ? [e.hosted.url] : [])), + ...tryOuts, + ...manifest.builds.flatMap((b) => b.parts.map((p) => new URL(p.path, manifestUrl).href)), +].flatMap((url) => binFolder(url) ?? []); +compare( + "images against what names them", + { label: BIN, items: folders(BIN) }, + { label: "cards, try-outs and manifest.json", items: new Set(named) }, +); + +for (const file of SCRIPTS) { + const tuple = /^EDITIONS = \(([^)]*)\)/m.exec(read(file))?.[1]; + if (tuple === undefined) throw new Error(`${file}: no EDITIONS = (...) line`); + compare( + `EDITIONS in ${path.basename(file)} against the cards`, + { label: file, items: [...tuple.matchAll(/["']([^"']+)["']/g)].map((m) => m[1]) }, + { label: "cards built here", items: built }, + ); +} +done(); diff --git a/web/scripts/drift/features.ts b/web/scripts/drift/features.ts new file mode 100644 index 000000000..6c3147341 --- /dev/null +++ b/web/scripts/drift/features.ts @@ -0,0 +1,29 @@ +/** + * check:features: the rows of /features against the feature folders. Every + * id in features-data.ts is a directory under firmware/patternflow/features/, + * and every directory there has a row. The ids that are not folders are the + * ones the comment on Feature.id names in quotes, today "core", the panel + * itself. They are read from there, not copied here. + */ +import ts from "typescript"; + +import { FEATURES } from "../../src/app/features/features-data"; +import { compare, done, read, tracked } from "./_lib"; + +const DATA = "web/src/app/features/features-data.ts"; +const DIR = "firmware/patternflow/features/"; + +const source = ts.createSourceFile(DATA, read(DATA), ts.ScriptTarget.Latest, true); +const type = source.statements.find((s): s is ts.TypeAliasDeclaration => ts.isTypeAliasDeclaration(s) && s.name.text === "Feature"); +const id = type && ts.isTypeLiteralNode(type.type) ? type.type.members.find((m) => m.name?.getText() === "id") : undefined; +const comment = id ? ts.getJSDocCommentsAndTags(id).map((d) => ts.getTextOfJSDocComment(d.comment) ?? "").join(" ") : ""; +const notFolders = new Set([...comment.matchAll(/"([^"]+)"/g)].map((m) => m[1])); +console.log(`ids that are not folders, by the comment on Feature.id: ${[...notFolders].join(", ") || "(none)"}`); + +const folders = tracked(DIR).map((f) => f.slice(DIR.length).split("/")).filter((parts) => parts.length > 1).map((parts) => parts[0]); +compare( + "ids against folders", + { label: DATA, items: FEATURES.map((f) => f.id).filter((x) => !notFolders.has(x)) }, + { label: DIR, items: new Set(folders) }, +); +done(); diff --git a/web/scripts/drift/links-absolute.ts b/web/scripts/drift/links-absolute.ts new file mode 100644 index 000000000..d55f65f49 --- /dev/null +++ b/web/scripts/drift/links-absolute.ts @@ -0,0 +1,15 @@ +/** + * check:links-absolute (docs/rfc-cleanup.md §2.4): every GitHub URL into a + * file of this repository, against the tree at HEAD, or against the tag it + * names. The doc map already finds and resolves each of them, so this runs + * its check mode, which writes nothing; doc-map.ts says what fails and what + * is skipped. + */ +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const docMap = fileURLToPath(new URL("../doc-map.ts", import.meta.url)); +// execArgv carries tsx's loader, so doc-map.ts runs the way this file does. +const run = spawnSync(process.execPath, [...process.execArgv, docMap, "--check", "absolute"], { stdio: "inherit" }); +if (run.error) console.error(`links-absolute: ${run.error.message}`); +process.exit(run.status ?? 1); diff --git a/web/scripts/drift/markers.ts b/web/scripts/drift/markers.ts new file mode 100644 index 000000000..24cf696d8 --- /dev/null +++ b/web/scripts/drift/markers.ts @@ -0,0 +1,44 @@ +/** + * The markers check (docs/rfc-cleanup.md §2.5): no TODO or FIXME is added + * during the cleanup. First-party code had none when step 3 was planned, and + * none at seven commits sampled on dev from May to October (finding 11 of + * docs/investigations/2026-10-cleanup-step3-plan.md). So no marker is allowed, + * and one found is drift. `npm run check:drift` runs it with the others, and + * `npx tsx scripts/drift.ts markers` alone. + * + * It reads every tracked text file under firmware/, web/src/, web/scripts/, + * tools/ and integrations/. The two words count in capitals, at the start of a + * word: the guide's data-s="todo" names a step not reached yet, and MASTODON + * holds no marker. Vendored code is upstream's, and doc-map.ts skips the same + * three folders. hub75/ keeps upstream's one TODO, which its UPSTREAM.md + * records, because editing it would add a hunk to patternflow.patch. This file + * names both words, so it skips itself as well. + */ +import { compare, done, read, tracked } from "./_lib"; + +const UNDER = ["firmware/", "web/src/", "web/scripts/", "tools/", "integrations/"]; +const VENDORED = /(^|\/)(hub75|webserver|pubsubclient)\//; +const SELF = "web/scripts/drift/markers.ts"; +const MARKER = /(^|[^A-Za-z])(TODO|FIXME)/; + +const files = UNDER.flatMap((under) => tracked(under)).filter((f) => f !== SELF); +const vendored = files.filter((f) => VENDORED.test(f)); +let binary = 0; +const found: string[] = []; +for (const f of files) { + if (VENDORED.test(f)) continue; + const text = read(f); + // A NUL is how git tells a binary file from text, and a PNG has one. + if (text.includes("\0")) { + binary++; + continue; + } + text.split("\n").forEach((line, i) => { + if (MARKER.test(line)) found.push(`${f}:${i + 1}`); + }); +} + +console.log(`read ${files.length - vendored.length - binary} files under ${UNDER.join(", ")}`); +console.log(`not read: ${vendored.length} vendored, ${binary} binary, and this file`); +compare("TODO and FIXME", { label: "markers found", items: found }, { label: "markers allowed", items: [] }); +done(); diff --git a/web/scripts/drift/midi-vectors.ts b/web/scripts/drift/midi-vectors.ts new file mode 100644 index 000000000..fd1c38cca --- /dev/null +++ b/web/scripts/drift/midi-vectors.ts @@ -0,0 +1,10 @@ +/** + * check:midi-vectors: docs/midi-vectors/ against a fresh virtual panel each + * (docs/contract-vectors.md), built with the midi feature. The sender is + * _rtpmidi.ts, one RTP-MIDI session a vector. + */ +import { done } from "./_lib"; +import { midi } from "./_rtpmidi"; +import { replay } from "./_vectors"; + +replay("docs/midi-vectors", { midi }).then(done); diff --git a/web/scripts/drift/mqtt-vectors.ts b/web/scripts/drift/mqtt-vectors.ts new file mode 100644 index 000000000..25d304498 --- /dev/null +++ b/web/scripts/drift/mqtt-vectors.ts @@ -0,0 +1,10 @@ +/** + * check:mqtt-vectors: docs/mqtt-vectors/ against a fresh virtual panel each + * (docs/contract-vectors.md), and a broker of its own for each (_broker.ts). + * HTTP, a hand and the clock are _vectors.ts's own. + */ +import { mqtt } from "./_broker"; +import { done } from "./_lib"; +import { replay } from "./_vectors"; + +replay("docs/mqtt-vectors", { mqtt }).then(done); diff --git a/web/scripts/drift/osc-vectors.ts b/web/scripts/drift/osc-vectors.ts new file mode 100644 index 000000000..4f05c3002 --- /dev/null +++ b/web/scripts/drift/osc-vectors.ts @@ -0,0 +1,10 @@ +/** + * check:osc-vectors: docs/osc-vectors/ against a fresh virtual panel each + * (docs/contract-vectors.md), built with the osc feature. The sender is + * _osc.ts, one UDP socket a vector. + */ +import { done } from "./_lib"; +import { osc } from "./_osc"; +import { replay } from "./_vectors"; + +replay("docs/osc-vectors", { osc }).then(done); diff --git a/web/scripts/drift/presets.ts b/web/scripts/drift/presets.ts new file mode 100644 index 000000000..de7e77748 --- /dev/null +++ b/web/scripts/drift/presets.ts @@ -0,0 +1,69 @@ +/** + * check:presets: how a web preset reaches a panel. Each pattern is a file in + * web/src/lib/presets/ that index.ts imports, so a file it does not import + * never shows on the site, and an import with no file is a preset that is + * gone. A preset with a firmware twin then gets onto a board one of two ways: + * pattern_registry.h compiles it in (Origin, the failsafe), or it ships in the + * Basics pack, whose basics.json keys each module on its twin's pattern + * number, the num make_pack.py reads out of the web file. A twin in neither, + * or a pack entry whose number no web preset has, is drift. A twin compiled + * in and packed too is on the panel twice, and shows as one extra. + * + * Which presets have no twin on purpose is check_presets.py's to say, in + * WEB_ONLY and FIRMWARE_ONLY, with a reason for each. The two sets are read + * out of that file, not copied here. Whether the twins are in step with each + * other stays its job; firmware-checks.yml runs it. + */ +import ts from "typescript"; + +import { compare, done, read, tracked } from "./_lib"; + +const WEB = "web/src/lib/presets/"; +const INDEX = `${WEB}index.ts`; +const TWINS = "firmware/toolchain/check_presets.py"; +const REGISTRY = "firmware/patternflow/pattern_registry.h"; +const PACK = "web/public/packs/basics.json"; + +// The web spells a slug with dashes and firmware with underscores. Both are +// compared in the firmware spelling, the one check_presets.py lists. +const slug = (s: string) => s.replace(/-/g, "_"); + +/** One of check_presets.py's set literals, NAME = {"0516", ...}. */ +function pySet(name: string): Set { + const m = read(TWINS).match(new RegExp(`^${name}\\s*=\\s*\\{([^}]*)\\}`, "m")); + if (!m) throw new Error(`${TWINS} has no ${name} = {...}`); + return new Set([...m[1].matchAll(/"([^"]+)"/g)].map((x) => x[1])); +} +const webOnly = pySet("WEB_ONLY"); +const firmwareOnly = pySet("FIRMWARE_ONLY"); +console.log(`web-only, by ${TWINS}: ${[...webOnly].sort().join(", ")}`); +console.log(`firmware-only, by ${TWINS}: ${[...firmwareOnly].sort().join(", ")}`); + +const files = tracked(WEB) + .map((f) => f.slice(WEB.length).match(/^pattern-([^/]+)\.ts$/)?.[1]) + .filter((x): x is string => x !== undefined); +const index = ts.createSourceFile(INDEX, read(INDEX), ts.ScriptTarget.Latest); +const imports = index.statements + .filter(ts.isImportDeclaration) + .map((s) => (ts.isStringLiteral(s.moduleSpecifier) ? s.moduleSpecifier.text.match(/^\.\/pattern-(.+)$/)?.[1] : undefined)) + .filter((x): x is string => x !== undefined); +compare("files against index.ts", { label: `${WEB}pattern-*.ts`, items: files.map(slug) }, { label: `${INDEX} imports`, items: imports.map(slug) }); + +// The pack's link back to a web preset, read the way make_pack.py reads it. +const byNum = new Map(); +for (const f of files) { + const num = read(`${WEB}pattern-${f}.ts`).match(/^\s*num:\s*([0-9.]+)/m)?.[1]; + if (num) byNum.set(Number(num), slug(f)); +} +const compiled = [...read(REGISTRY).matchAll(/^\s*#include\s+"presets\/preset_([^"]+)\.h"/gm)].map((m) => m[1]); +const { presets } = JSON.parse(read(PACK)) as { presets?: Record }; +if (!presets) throw new Error(`${PACK} has no "presets"`); +const packed = Object.entries(presets).map(([module, num]) => byNum.get(num) ?? `${module} (num ${num}, no web preset)`); +console.log(`compiled in by ${REGISTRY}: ${compiled.join(", ")}`); +console.log(`in ${PACK} by number: ${packed.length}`); +compare( + "twins against what reaches a panel", + { label: "web presets with a firmware twin", items: files.map(slug).filter((x) => !webOnly.has(x)) }, + { label: `${REGISTRY} and ${PACK}`, items: [...compiled.filter((x) => !firmwareOnly.has(x)), ...packed] }, +); +done(); diff --git a/web/scripts/drift/rest-vectors.ts b/web/scripts/drift/rest-vectors.ts new file mode 100644 index 000000000..1c7aca6cd --- /dev/null +++ b/web/scripts/drift/rest-vectors.ts @@ -0,0 +1,9 @@ +/** + * check:rest-vectors: docs/rest-api-vectors/ against a fresh virtual panel each + * (docs/contract-vectors.md). REST needs no sender of its own: _vectors.ts + * speaks HTTP, and a hand and the clock are HTTP too. + */ +import { done } from "./_lib"; +import { replay } from "./_vectors"; + +replay("docs/rest-api-vectors").then(done); diff --git a/web/scripts/drift/roadmap.ts b/web/scripts/drift/roadmap.ts new file mode 100644 index 000000000..3557e0905 --- /dev/null +++ b/web/scripts/drift/roadmap.ts @@ -0,0 +1,78 @@ +/** + * check:roadmap: the status of each /roadmap node against the state of the + * issues it names. A done node with an open issue is drift, and so is a + * planned node with a closed one. The issues are in the repository the page's + * own links point at, REPO in roadmap-data.ts, and gh reads each state. An + * issue GitHub answers 404 or 410 for is drift too: the page names an issue + * nobody can open. + * + * An issue can stay open after its node is done, when the work shipped and the + * issue holds something back for later. Such an issue goes in OPEN_ON_PURPOSE + * below, with the maintainer's reason, and an entry whose issue has closed is + * drift as well. A gh that cannot read the repository here (not installed, no + * login, no network, no access) skips. + */ +import { execFileSync } from "node:child_process"; + +import { NODES } from "../../src/app/roadmap/roadmap-data"; +import { compare, done, read, skip } from "./_lib"; + +const DATA = "web/src/app/roadmap/roadmap-data.ts"; + +/** Issues on done nodes that stay open on purpose, each with the maintainer's reason. */ +const OPEN_ON_PURPOSE: Record = {}; + +const href = /^const REPO = '([^']+)'/m.exec(read(DATA))?.[1]; +if (!href) throw new Error(`${DATA}: no const REPO = '...' line`); +const repo = new URL(href).pathname.replace(/^\/|\/$/g, ""); + +/** gh's own words when it fails: the first line of what it printed. */ +const why = (e: unknown) => + String((e as { stderr?: string }).stderr ?? (e as Error).message) + .split("\n") + .find((line) => line.trim()) ?? "gh failed"; + +const numbers = [...new Set(NODES.flatMap((n) => n.issues ?? []))].sort((a, b) => a - b); +const state = new Map(); +const ask = (path: string, ...args: string[]) => + execFileSync("gh", ["api", path, ...args], { stdio: ["ignore", "pipe", "pipe"], encoding: "utf8" }).trim(); +let readable = true; +try { + ask(`repos/${repo}`, "--silent"); + for (const n of numbers) { + try { + state.set(n, ask(`repos/${repo}/issues/${n}`, "--jq", ".state")); + } catch (e) { + // A 404 or a 410 says the issue is not there, which is the page's to fix. Any other failure is gh's. + if (!/\(HTTP (404|410)\)/.test(why(e))) throw e; + state.set(n, why(e)); + } + } +} catch (e) { + readable = false; + skip("issues", (e as NodeJS.ErrnoException).code === "ENOENT" ? "gh is not installed" : `gh cannot read ${repo}: ${why(e)}`); +} + +if (readable) { + console.log(`issues on ${repo}, by gh:`); + for (const n of numbers) { + const nodes = NODES.filter((node) => node.issues?.includes(n)).map((node) => `${node.id} (${node.status})`); + console.log(` #${n} ${state.get(n)}: ${nodes.join(", ")}`); + } + + const kept = Object.entries(OPEN_ON_PURPOSE).map(([n, reason]) => `#${n} (${reason})`); + console.log(`open on purpose, by OPEN_ON_PURPOSE: ${kept.join(", ") || "(none)"}`); + + const on = (status: string) => [...new Set(NODES.filter((n) => n.status === status).flatMap((n) => n.issues ?? []))]; + compare( + "done nodes with an open issue", + { label: "issues on done nodes, not closed", items: on("done").filter((n) => state.get(n) !== "closed").map((n) => `#${n}`) }, + { label: "OPEN_ON_PURPOSE in roadmap.ts", items: Object.keys(OPEN_ON_PURPOSE).map((n) => `#${n}`) }, + ); + compare( + "planned nodes with a closed issue", + { label: "issues on planned nodes", items: on("planned").map((n) => `#${n}`) }, + { label: "of those, open", items: on("planned").filter((n) => state.get(n) === "open").map((n) => `#${n}`) }, + ); +} +done(); diff --git a/web/scripts/drift/shared.ts b/web/scripts/drift/shared.ts new file mode 100644 index 000000000..c8b32504e --- /dev/null +++ b/web/scripts/drift/shared.ts @@ -0,0 +1,58 @@ +/** + * check:shared: the two files gen:shared writes from shared/constants.json, + * against what it writes now. A hand edit to core_shared.h or constants.ts + * fails here, and so does a change to the JSON nobody regenerated. + * + * Two copies cannot be generated, so this reads them. abi/pf_params.h clamps + * the bus to a literal 1000 and is frozen: installed modules read it without + * recompiling. build_module.py names the panel's 128 and 64 itself, and the + * module cache hashes it. The copies written for other readers, the REST + * contract's 24 and the Ableton bridge's 48, stay by hand and are not read. + */ +import fs from "node:fs"; +import path from "node:path"; + +import { render, SOURCE } from "../gen-shared"; +import { compare, done, read, ROOT } from "./_lib"; + +let stale = 0; +for (const [file, want] of Object.entries(render())) { + const have = fs.existsSync(path.join(ROOT, file)) ? read(file) : ""; + if (have === want) { + console.log(`${file}: same as gen:shared writes`); + continue; + } + stale++; + console.log(`${file}: stale, run npm run gen:shared in web/`); + const [a, b] = [want.split("\n"), have.split("\n")]; + const lines = [...Array(Math.max(a.length, b.length)).keys()].filter((i) => a[i] !== b[i]); + for (const i of lines.slice(0, 10)) { + console.log(` line ${i + 1}, gen:shared writes: ${a[i] ?? "(no line)"}`); + console.log(` line ${i + 1}, the file has: ${b[i] ?? "(no line)"}`); + } + if (lines.length > 10) console.log(` and ${lines.length - 10} more lines`); +} + +const data = JSON.parse(read(SOURCE)); + +const ABI = "firmware/patternflow/abi/pf_params.h"; +const body = read(ABI) + .replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, "") + .match(/\bunit1000\s*\([^)]*\)\s*\{([^}]*)\}/)?.[1]; +if (body === undefined) console.log(`no unit1000() in ${ABI}`); +compare( + "the bus maximum", + { label: `${SOURCE} BUS_MAX`, items: [String(data.bus.BUS_MAX)] }, + { label: `the literals in unit1000(), ${ABI}`, items: new Set(body?.match(/\b\d+(\.\d+)?f?\b/g)?.map((n) => String(parseFloat(n)))) }, +); + +const BUILD = "firmware/toolchain/build_module.py"; +const panel = data.panel.PANEL_GEOMETRIES[data.panel.PANEL_GEOM_DEFAULT]; +compare( + "the default panel's canvas", + { label: `${SOURCE} ${data.panel.PANEL_GEOM_DEFAULT}`, items: [`PANEL_W ${panel.physW * panel.chain}`, `PANEL_H ${panel.physH}`] }, + { label: BUILD, items: [...read(BUILD).matchAll(/^(PANEL_[WH]) = (\d+)/gm)].map((m) => `${m[1]} ${m[2]}`) }, +); + +done(); +if (stale) process.exitCode = 1; diff --git a/web/scripts/drift/virtual-panel.ts b/web/scripts/drift/virtual-panel.ts new file mode 100644 index 000000000..9a45888f1 --- /dev/null +++ b/web/scripts/drift/virtual-panel.ts @@ -0,0 +1,115 @@ +/** + * check:virtual-panel: the virtual panel runs every preset in + * firmware/patternflow/presets/ without a thrown error, the half of step 4's + * "done when" that no vector suite covers (docs/rfc-cleanup.md §2.6). + * + * The presets are the headers there, less the FIRMWARE_ONLY check_presets.py + * names, and each has to be on the list GET /api/patterns gives on the + * default build with the Basics pack. A preset compiled in is listed by the + * NAME in its header. A module is listed by its slug, which basics.json keys + * on its web twin's num, the link check:presets reads. Each listed preset then + * gets a panel of its own: select it over REST, two seconds of panel time, a + * 128×64 PNG from /frame.png and no error in /vp/state. Everything goes + * through the panel's HTTP port. + */ +import zlib from "node:zlib"; + +import { PANEL_GEOM_DEFAULT, PANEL_GEOMETRIES } from "@/lib/shared/constants"; + +import { start } from "../../../tools/virtual-panel/panel"; +import { compare, done, read, tracked } from "./_lib"; + +const HEADERS = "firmware/patternflow/presets/"; +const WEB = "web/src/lib/presets/"; +const TWINS = "firmware/toolchain/check_presets.py"; +const PACK = "web/public/packs/basics.json"; +const { resW: WIDTH, resH: HEIGHT } = PANEL_GEOMETRIES[PANEL_GEOM_DEFAULT]; +const RUN_MS = 2000; + +type Listed = { index: number; name: string; module: string | null }; +type State = { mode: string; pattern: { index: number }; error: string | null }; + +const literal = read(TWINS).match(/^FIRMWARE_ONLY\s*=\s*\{([^}]*)\}/m)?.[1]; +if (literal === undefined) throw new Error(`${TWINS} has no FIRMWARE_ONLY = {...}`); +const firmwareOnly = new Set([...literal.matchAll(/"([^"]+)"/g)].map((m) => m[1])); +console.log(`firmware-only, by ${TWINS}: ${[...firmwareOnly].sort().join(", ")}`); +const headers = tracked(HEADERS) + .map((f) => f.slice(HEADERS.length).match(/^preset_([^/]+)\.h$/)?.[1]) + .filter((x): x is string => x !== undefined && !firmwareOnly.has(x)); + +// What the panel lists a header under: its NAME for a preset, its num for a module. +const byName = new Map(); +const byNum = new Map(); +for (const h of headers) { + const name = read(`${HEADERS}preset_${h}.h`).match(/\bNAME\s*=\s*"([^"]*)"/)?.[1]; + if (name !== undefined) byName.set(name, h); + const twin = `${WEB}pattern-${h.replace(/_/g, "-")}.ts`; + const num = tracked(twin).length ? read(twin).match(/^\s*num:\s*([0-9.]+)/m)?.[1] : undefined; + if (num) byNum.set(Number(num), h); +} +const { presets: nums } = JSON.parse(read(PACK)) as { presets: Record }; +const header = ({ name, module }: Listed) => + module === null ? (byName.get(name) ?? `${name} (no header has this NAME)`) : (byNum.get(nums[module]) ?? `${module} (no header by num)`); + +/** A fresh panel on the manual clock, any free port, closed once talk() is done with it. */ +async function panel(talk: (get: (path: string) => Promise) => Promise): Promise { + const { ports, close } = await start({ clock: "manual", ports: { "*": 0 } }); + try { + return await talk((path) => fetch(`http://127.0.0.1:${ports.http}${path}`)); + } finally { + await close(); + } +} +const json = async (reply: Promise) => (await reply).json() as Promise; + +/** Width, height and lit pixels of an 8-bit RGB PNG with unfiltered rows, the one tools/virtual-panel/png.ts writes. */ +function readPng(file: Buffer) { + if (file.toString("latin1", 1, 4) !== "PNG" || file.toString("latin1", 12, 16) !== "IHDR") throw new Error("not a PNG"); + const [width, height] = [file.readUInt32BE(16), file.readUInt32BE(20)]; + if (file[24] !== 8 || file[25] !== 2) throw new Error(`bit depth ${file[24]}, colour type ${file[25]}, not 8-bit RGB`); + const idat: Buffer[] = []; + for (let at = 8; at < file.length; at += 12 + file.readUInt32BE(at)) + if (file.toString("latin1", at + 4, at + 8) === "IDAT") idat.push(file.subarray(at + 8, at + 8 + file.readUInt32BE(at))); + const rows = zlib.inflateSync(Buffer.concat(idat)); + const stride = width * 3 + 1; + if (rows.length !== stride * height) throw new Error(`${rows.length} bytes of rows for ${width}×${height}`); + let lit = 0; + for (let y = 0; y < height; y++) { + if (rows[y * stride]) throw new Error(`row ${y} is filtered`); + for (let x = 0; x < width; x++) lit += Number(rows.subarray(y * stride + 1 + x * 3, y * stride + 4 + x * 3).some(Boolean)); + } + return { width, height, lit }; +} + +async function main() { + const listed = await panel(async (get) => (await json<{ patterns: Listed[] }>(get("/api/patterns"))).patterns); + compare("headers against the panel's list", { label: HEADERS, items: headers }, { label: "GET /api/patterns", items: listed.map(header) }); + + const ran: string[] = []; + for (const p of listed) { + const h = header(p); + const why = await panel(async (get) => { + const picked = await get(`/api/patterns/select?index=${p.index}`); + if (!picked.ok) return `select answered ${picked.status}`; + const state = await json(get(`/vp/advance?ms=${RUN_MS}`)); + if (state.pattern.index !== p.index) return `running index ${state.pattern.index}, not ${p.index}`; + if (state.mode !== "run") return `the panel is in ${state.mode}`; + if (state.error !== null) return `threw: ${state.error.split("\n")[0]}`; + const frame = await get("/frame.png"); + if (frame.headers.get("content-type") !== "image/png") return `/frame.png is ${frame.headers.get("content-type")}`; + const png = readPng(Buffer.from(await frame.arrayBuffer())); + if (png.width !== WIDTH || png.height !== HEIGHT) return `/frame.png is ${png.width}×${png.height}`; + console.log(` ${p.index} ${h}: ${RUN_MS} ms, ${png.width}×${png.height}, ${png.lit} lit`); + return null; + }).catch((e: Error) => e.message); + if (why) console.log(` ${p.index} ${h}: ${why}`); + else ran.push(h); + } + compare( + "presets that run", + { label: "on the panel's list", items: listed.map(header) }, + { label: `${RUN_MS} ms, ${WIDTH}×${HEIGHT}, no error`, items: ran }, + ); + done(); +} +void main(); diff --git a/web/scripts/enqueue-test-build.ts b/web/scripts/enqueue-test-build.ts index ccdd01ac2..7e78fc8ce 100644 --- a/web/scripts/enqueue-test-build.ts +++ b/web/scripts/enqueue-test-build.ts @@ -1,9 +1,13 @@ /** * Put one build on the queue, for testing the worker without the web UI. * - * npm run build:enqueue # whole image from the template - * npm run build:enqueue -- path/to.h # or a header of your choosing - * npm run build:enqueue -- --pfm path/to.h # loadable modules instead + * npm run build:enqueue # the pattern template + * npm run build:enqueue -- path/to.h # or a header of your choosing + * + * The worker compiles the header into a .pfm module and zips it, as it does + * for a pattern sent from the site. Whole-image builds ended on 12 August + * (4c13474). --pfm, which used to pick modules over an image, is still + * accepted and changes nothing. * * Prints the build id, then poll it with: npm run build:status -- */ @@ -18,9 +22,7 @@ async function main() { const { getDb } = await import("../src/lib/community/server/db"); const { user } = await import("../src/lib/community/server/schema"); - const args = process.argv.slice(2); - const pfm = args.includes("--pfm"); - const headerArg = args.find((a) => a !== "--pfm"); + const headerArg = process.argv.slice(2).find((a) => a !== "--pfm"); const headerPath = headerArg ? path.resolve(process.cwd(), headerArg) : path.resolve(process.cwd(), "../firmware/patternflow/_TEMPLATE.h"); @@ -38,13 +40,9 @@ async function main() { } const code = fs.readFileSync(headerPath, "utf8"); - const id = await enqueueBuild( - accounts[0].id, - [{ label: path.basename(headerPath), code }], - pfm ? "pfm" : "bin", - ); + const id = await enqueueBuild(accounts[0].id, [{ label: path.basename(headerPath), code }], "pfm"); - console.log(`queued ${pfm ? "module" : "image"} build ${id}`); + console.log(`queued module build ${id}`); console.log(` header : ${headerPath} (${(code.length / 1024).toFixed(1)} KB)`); console.log(` as : ${accounts[0].name}`); console.log(`\nnpm run build:status -- ${id}`); diff --git a/web/scripts/gen-shared.ts b/web/scripts/gen-shared.ts new file mode 100644 index 000000000..e28df5170 --- /dev/null +++ b/web/scripts/gen-shared.ts @@ -0,0 +1,106 @@ +/** + * gen:shared: writes shared/constants.json out as the two files that read it, + * firmware/patternflow/src/core_shared.h and web/src/lib/shared/constants.ts + * (docs/rfc-cleanup.md §2.5). The lab counted 20 detents a turn until 31 July + * 2026, against the encoder's 24, and the community sandbox until 4 September. + * Each copy was found and fixed by hand. + * + * A key in the JSON is the constant's name on both sides: DETENTS_PER_TURN is + * PF_SHARED_DETENTS_PER_TURN in the header and DETENTS_PER_TURN in the module. + * The prefix keeps the macros clear of the names config.h already defines, and + * of every feature's PF__ flags, which check_boundaries.py refuses in + * src/. A key "//" is a note, and each group's note goes over its lines. + * + * check:shared (scripts/drift/shared.ts) imports render() and compares, so the + * files on disk must be exactly what this writes. + */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +export const SOURCE = "shared/constants.json"; +export const HEADER = "firmware/patternflow/src/core_shared.h"; +export const MODULE = "web/src/lib/shared/constants.ts"; + +type Group = Record; + +const fail = (what: string): never => { + throw new Error(`${SOURCE}: ${what}`); +}; +const whole = (v: unknown, at: string) => + Number.isInteger(v) && (v as number) > 0 ? (v as number) : fail(`${at} is ${JSON.stringify(v)}, not a whole number above 0`); + +/** A note as comment lines, wrapped at 80 columns. */ +function comment(note: unknown): string[] { + if (typeof note !== "string") return []; + const lines: string[] = []; + let line = "//"; + for (const word of note.split(/\s+/)) { + if (line !== "//" && line.length + 1 + word.length > 80) { + lines.push(line); + line = "//"; + } + line += ` ${word}`; + } + return [...lines, line]; +} + +const BANNER = [ + `// GENERATED by web/scripts/gen-shared.ts from ${SOURCE}. Do not edit.`, + ...comment("Change the JSON and run `npm run gen:shared` in web/. check:shared fails while this file says anything else."), +]; + +/** Both files as gen:shared writes them, keyed by their path from the repository root. */ +export function render(json = fs.readFileSync(path.join(ROOT, SOURCE), "utf8")): Record { + const data = JSON.parse(json) as Record; + const h = [...BANNER, "//", "// License: MIT", "#pragma once"]; + const ts = [...BANNER]; + const names = new Set(["PANEL_GEOMETRIES", "PANEL_GEOM_DEFAULT"]); + + const panel = data.panel ?? fail("no panel group"); + const geometries = Object.entries((panel.PANEL_GEOMETRIES ?? fail("no panel.PANEL_GEOMETRIES")) as Record); + const fallback = panel.PANEL_GEOM_DEFAULT; + if (!geometries.some(([name]) => name === fallback)) fail(`panel.PANEL_GEOM_DEFAULT ${JSON.stringify(fallback)} is not in PANEL_GEOMETRIES`); + const ids = new Set(); + h.push("", ...comment(panel["//"])); + ts.push("", ...comment(panel["//"]), "export const PANEL_GEOMETRIES = {"); + for (const [name, g] of geometries) { + if (!/^\w+$/.test(name)) fail(`the geometry ${JSON.stringify(name)} is not a C name`); + const at = `panel.PANEL_GEOMETRIES.${name}`; + const [id, physW, physH, chain] = (["id", "physW", "physH", "chain"] as const).map((k) => whole(g[k], `${at}.${k}`)); + if (ids.has(id)) fail(`${at}.id ${id} is taken by another geometry`); + ids.add(id); + // config.h refuses a build where these two disagree with the physical size. + const fields = { physW, physH, chain, resW: physW * chain, resH: physH }; + h.push(`#define PF_SHARED_PANEL_GEOM_${name} ${id}`); + for (const [k, v] of Object.entries(fields)) h.push(`#define PF_SHARED_PANEL_GEOM_${name}_${k.replace(/[A-Z]/g, "_$&").toUpperCase()} ${v}`); + ts.push(` "${name}": { id: ${id}, ${Object.entries(fields).map(([k, v]) => `${k}: ${v}`).join(", ")} },`); + } + h.push(`#define PF_SHARED_PANEL_GEOM_DEFAULT PF_SHARED_PANEL_GEOM_${fallback}`); + ts.push("} as const;", `export const PANEL_GEOM_DEFAULT = "${fallback}";`); + + for (const [group, values] of Object.entries(data)) { + if (group === "//" || group === "panel") continue; + h.push("", ...comment(values["//"])); + ts.push("", ...comment(values["//"])); + for (const [name, v] of Object.entries(values)) { + if (name === "//") continue; + if (!/^[A-Z][A-Z0-9_]*$/.test(name)) fail(`${group}.${name} is not a constant's name`); + if (names.has(name)) fail(`${group}.${name} is named twice`); + names.add(name); + const n = whole(v, `${group}.${name}`); + h.push(`#define PF_SHARED_${name} ${n}`); + ts.push(`export const ${name} = ${n};`); + } + } + return { [HEADER]: `${h.join("\n")}\n`, [MODULE]: `${ts.join("\n")}\n` }; +} + +if (path.resolve(process.argv[1] ?? "") === fileURLToPath(import.meta.url)) { + for (const [file, text] of Object.entries(render())) { + fs.mkdirSync(path.dirname(path.join(ROOT, file)), { recursive: true }); + fs.writeFileSync(path.join(ROOT, file), text); + console.log(`wrote ${file}`); + } +} diff --git a/web/scripts/human-lock.ts b/web/scripts/human-lock.ts new file mode 100644 index 000000000..bfd412a22 --- /dev/null +++ b/web/scripts/human-lock.ts @@ -0,0 +1,245 @@ +/** + * The lock on the maintainer's own writing: `npm run check:human-lock` in web/ + * (docs/rfc-cleanup.md §2.9). A pull request may change the markup, layout and + * link and image paths in locked text, not a word of it. A typo is reported in + * the PR description, not fixed. + * + * Locked: web/content/journal/, docs/manifesto.md, docs/releases/*.md, three + * README sections (README_SECTIONS, each to the next ## heading), Part 1 of + * each docs/rfc-*.md, each roadmap node's detail and detailKo (keyed by its id), + * and each path in docs/.human-written, a directory locking what is under it. + * + * Each is compared as plain text, the base's (scripts/lib/pr-base.ts) against + * the working tree's, with the Markdown and MDX syntax and all whitespace gone: + * marks, fences (not the code in them), pipes, rules, comments, entities, tags, + * link targets, URLs and front matter keys. A tag's alt, title and aria-label + * stay, and so do front matter values that are not a path or a URL. + * + * A locked file the base lacks is new, and passes. Text that leaves its place + * passes only if it turns up word for word in a file this diff adds or changes: + * a move, which needs the maintainer's approval on the PR. Taking a path off + * docs/.human-written fails, since only the maintainer does that. + */ +import fs from "node:fs"; +import path from "node:path"; +import ts from "typescript"; + +import { ROOT, changedSince, findBase, git, readAt, readNow } from "./lib/pr-base"; + +const README_SECTIONS = ["Where it began", "The community responded", "Story so far"]; +const ROADMAP = "web/src/app/roadmap/roadmap-data.ts"; +const LIST = "docs/.human-written"; + +// ── Plain text ── +// The characters kept, and the offset of each in the text they came from. +// Syntax is blanked to spaces rather than cut, so every offset stays where it +// was and a changed word can be shown in its line. +type Plain = { text: string; at: number[] }; +const blank = (s: string) => s.replace(/[^\n]/g, " "); +const keep = (_m: string, open: string, text: string, close: string) => blank(open) + text + blank(close); +// A tag goes, but the words in its alt, title and aria-label stay. +const tag = (m: string) => + m.replace(/\b(alt|title|aria-label)=(["'])([^"']*)\2|[^\n]/g, (_x, a?: string, q?: string, w?: string) => (a ? blank(`${a}=${q}`) + w + blank(q!) : " ")); +function frontMatter(m: string, body: string): string { + const values = body.split("\n").map((line) => { + const kv = /^(\s*[\w-]+:\s*)(["']?)(.*?)(\2\s*)$/.exec(line); + return !kv ? line : blank(kv[1] + kv[2]) + (/^(?:\.{0,2}\/|https?:)/.test(kv[3]) ? blank(kv[3]) : kv[3]) + blank(kv[4]); + }); + return blank(m.slice(0, m.indexOf("\n") + 1)) + values.join("\n") + blank(m.slice(m.lastIndexOf("\n"))); +} + +const SYNTAX: [RegExp, (m: string, ...g: string[]) => string][] = [ + [/^\uFEFF?---[ \t]*\n([\s\S]*?)\n---(?=[ \t]*(?:\n|$))/, frontMatter], + [//g, blank], + [/^[ \t]*(?:```|~~~).*$/gm, blank], // the fences; the code between them is text + [/^[ \t]*\[[^\]\n]+\]:[ \t]*\S.*$/gm, blank], // reference definitions + [/<\/?[A-Za-z][^>]*>/g, tag], // tags and autolinks + [/(!\[)([^\]]*)(\]\([^)]*\))/g, keep], // images, then the links that may hold them + [/(\[)([^\]]*)(\](?:\([^)]*\)|\[[^\]]*\]))/g, keep], + [/https?:\/\/[^\s)\]>]+/g, blank], + [/^ {0,3}#{1,6}(?=\s)/gm, blank], + [/^[ \t]*(?:>[ \t]?)+/gm, blank], + [/^[ \t]*(?:[-*+]|\d+[.)])(?=[ \t])/gm, blank], + [/^[ \t|:=*_-]*$/gm, blank], // rules, setext underlines, table delimiter rows + [/&(?:#x[\da-f]+|#\d+|[a-z]+);/gi, blank], + [/\\(?=[!-/:-@[-`{-~])/g, blank], + [/[*~`|]/g, blank], + [/(? string] = [/^(?:import|export)\s.*$/gm, blank]; + +function plain(src: string, mdx: boolean): Plain { + let s = src.replace(/\r/g, " "); + for (const [re, f] of mdx ? [MDX, ...SYNTAX] : SYNTAX) s = s.replace(re, f as (m: string, ...g: string[]) => string); + const at: number[] = []; + for (let i = 0; i < s.length; i++) if (!/\s/.test(s[i])) at.push(i); + return { text: at.map((i) => s[i]).join(""), at }; +} +function slice(p: Plain, from: number, to: number): Plain { + const [i, j] = [from, to].map((x) => p.at.findIndex((a) => a >= x)).map((k) => (k < 0 ? p.at.length : k)); + return { text: p.text.slice(i, j), at: p.at.slice(i, j) }; +} + +// ── What is locked ── +type Unit = { key: string; file: string; src: string; line0: number; plain: Plain }; + +const listOf = (text: string | null) => + (text ?? "").split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#")).map((l) => l.replace(/^\.?\//, "")); + +function kindOf(f: string, listed: string[]): string | null { + if (listed.some((p) => f === p || f.startsWith(p.replace(/\/*$/, "/")))) return "listed"; + if (f.startsWith("web/content/journal/")) return "journal"; + if (/^docs\/releases\/[^/]+\.md$/.test(f)) return "release"; + if (f === "docs/manifesto.md") return "manifesto"; + if (f === "README.md") return "readme"; + if (/^docs\/(?:[^/]+\/)*rfc-[^/]+\.md$/.test(f)) return "rfc"; + return f === ROADMAP ? "roadmap" : null; +} + +// The headings outside code fences that `wanted` picks, each to the next heading of its level or higher. +function sections(src: string, wanted: (level: number, title: string) => boolean) { + const out: { title: string; from: number; to: number }[] = []; + let open: { level: number; title: string; from: number } | null = null; + let [fence, at] = [false, 0]; + for (const line of src.split("\n")) { + if (/^ {0,3}(?:```|~~~)/.test(line)) fence = !fence; + const h = fence ? null : /^ {0,3}(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line); + if (h && open && h[1].length <= open.level) { + out.push({ ...open, to: at }); + open = null; + } + if (h && !open && wanted(h[1].length, h[2])) open = { level: h[1].length, title: h[2], from: at }; + at += line.length + 1; + } + if (open) out.push({ ...open, to: src.length }); + return out; +} + +// Each node's detail and detailKo, read with the TypeScript parser. A string +// that is not a plain literal is not read, and so is reported removed. +function roadmapStrings(src: string): Unit[] { + const sf = ts.createSourceFile(ROADMAP, src, ts.ScriptTarget.Latest, true); + const literal = (e?: ts.Expression) => (e && (ts.isStringLiteral(e) || ts.isNoSubstitutionTemplateLiteral(e)) ? e.text : null); + const out: Unit[] = []; + const visit = (n: ts.Node): void => { + if (ts.isObjectLiteralExpression(n)) { + const props = n.properties.filter(ts.isPropertyAssignment); + const named = (k: string) => props.find((p) => (ts.isIdentifier(p.name) || ts.isStringLiteral(p.name)) && p.name.text === k)?.initializer; + for (const field of ["detail", "detailKo"]) { + const [text, e] = [literal(named(field)), named(field)]; + const line = e ? sf.getLineAndCharacterOfPosition(e.getStart()).line + 1 : 0; + const key = `${ROADMAP} › ${literal(named("id")) ?? `line ${line}`}.${field}`; + if (text !== null) out.push({ key, file: ROADMAP, src: text, line0: line, plain: plain(text, false) }); + } + } + ts.forEachChild(n, visit); + }; + visit(sf); + return out; +} + +function unitsOf(f: string, src: string, kind: string): Unit[] { + if (kind === "roadmap") return roadmapStrings(src); + const whole = plain(src, f.endsWith(".mdx")); + const cut = (key: string, s: { from: number; to: number }): Unit => ({ key, file: f, src, line0: 1, plain: slice(whole, s.from, s.to) }); + if (kind === "readme") return sections(src, (l, t) => l === 2 && README_SECTIONS.includes(t)).map((s) => cut(`${f} › ${s.title}`, s)); + if (kind === "rfc") return sections(src, (_, t) => /^Part 1\b/.test(t)).map((s) => cut(`${f} › Part 1`, s)); + return [{ key: f, file: f, src, line0: 1, plain: whole }]; +} + +// ── Showing a change ── +// The source around plain characters [p, q), 30 of them each side, with the stretch marked «…». +function excerpt(u: Unit, p: number, q: number): string { + const { src, plain: { at } } = u; + const markFrom = p < q ? at[p] : p > 0 ? at[p - 1] + 1 : (at[0] ?? 0); + const markTo = p < q ? at[q - 1] + 1 : markFrom; + const from = Math.min(markFrom, at[Math.max(0, p - 30)] ?? markFrom); + const to = Math.max(markTo, (at[Math.min(at.length, q + 30) - 1] ?? markTo - 1) + 1); + const flat = (s: string) => s.replace(/\s+/g, " "); + const marked = flat(src.slice(markFrom, markTo)); + const line = u.line0 + (src.slice(0, markFrom).match(/\n/g)?.length ?? 0); + return `${u.file}:${line} …${flat(src.slice(from, markFrom))}«${marked.length > 200 ? `${marked.slice(0, 200)}…` : marked}»${flat(src.slice(markTo, to))}…`; +} +function difference(a: Unit, b: Unit): [string, string] { + const [x, y] = [a.plain.text, b.plain.text]; + let [p, s] = [0, 0]; + while (p < x.length && p < y.length && x[p] === y[p]) p++; + while (s < x.length - p && s < y.length - p && x[x.length - 1 - s] === y[y.length - 1 - s]) s++; + return [excerpt(a, p, x.length - s), excerpt(b, p, y.length - s)]; +} + +// ── The check ── +const strict = process.env.PF_DRIFT_STRICT === "1"; +const base = findBase(); +if (base.sha === null) { + console.log(`check:human-lock: ${base.fatal ? base.why : `skipped, ${base.why}${strict ? "\nPF_DRIFT_STRICT=1, so a skip fails" : ""}`}`); + process.exit(base.fatal || strict ? 1 : 0); +} +const BASE = base.sha; +const plural = (k: number, one: string) => `${k} ${one}${k === 1 ? "" : "s"}`; + +const [listedBefore, listedNow] = [listOf(readAt(BASE, LIST)), listOf(readNow(LIST))]; +const listed = [...new Set([...listedBefore, ...listedNow])]; +const unlisted = listedBefore.filter((p) => !listedNow.includes(p)); + +// What is locked at HEAD, counted, so a run says what it looked at. +const count = new Map(); +for (const f of git(["ls-files", "-z"]).split("\0")) { + const kind = kindOf(f, listedNow); + if (kind && kind !== "listed") count.set(kind, (count.get(kind) ?? 0) + (["readme", "rfc", "roadmap"].includes(kind) ? unitsOf(f, readNow(f) ?? "", kind).length : 1)); +} +const n = (kind: string) => count.get(kind) ?? 0; +console.log(`base: ${BASE.slice(0, 7)} (${base.from})`); +console.log(`locked: ${n("journal")} journal files, ${n("release")} release files, docs/manifesto.md, ${n("readme")} README sections, Part 1 of ${n("rfc")} RFCs, ${n("roadmap")} roadmap strings, ${plural(listedNow.length, "path")} in ${LIST}`); +const changes = changedSince(BASE); +const touched = changes.filter((c) => kindOf(c.path, listed)); +console.log(`this diff touches ${plural(touched.length, "locked file")}`); +if (touched.length || unlisted.length) console.log(""); +for (const p of unlisted) console.log(`unlisted ${LIST} no longer names ${p}; only the maintainer takes a path off it`); + +// Where vanished text may have gone: every file this diff adds or changes. +let corpus: { file: string; text: string }[] | undefined; +function whereIs(text: string): string | null { + corpus ??= changes.flatMap(({ path: f, status }) => { + const raw = status !== "deleted" && fs.existsSync(path.join(ROOT, f)) ? fs.readFileSync(path.join(ROOT, f)) : null; + if (!raw || raw.includes(0)) return []; + const parts = kindOf(f, listed) === "roadmap" ? roadmapStrings(raw.toString("utf8")).map((u) => u.plain.text) : []; + return [plain(raw.toString("utf8"), f.endsWith(".mdx")).text, ...parts].map((t) => ({ file: f, text: t })); + }); + return corpus.find((c) => c.text.includes(text))?.file ?? null; +} + +let [failed, moved] = [0, 0]; +for (const c of touched) { + const kind = kindOf(c.path, listed)!; + const units = (src: string | null) => new Map((src === null ? [] : unitsOf(c.path, src, kind)).map((u) => [u.key, u])); + const [old, cur] = [units(c.status === "added" ? null : readAt(BASE, c.path)), units(c.status === "deleted" ? null : readNow(c.path))]; + let same = 0; + for (const [key, a] of old) { + const b = cur.get(key); + if (b ? a.plain.text === b.plain.text : !a.plain.text) same++; + else if (b) { + const [was, is] = difference(a, b); + console.log(`changed ${key}\n base ${was}\n head ${is}`); + failed++; + } else { + const to = whereIs(a.plain.text); + console.log(to ? `moved ${key} → ${to}, word for word` : `removed ${key}: its words are in no file this diff adds or changes`); + if (to) moved++; + else failed++; + } + } + const fresh = [...cur.keys()].filter((k) => !old.has(k)).length; + const what = kind === "roadmap" ? "strings" : "sections"; + if (same) console.log(`same ${c.path}${old.size > 1 ? ` (${same} of ${old.size} locked ${what})` : ""}`); + if (fresh) console.log(`new ${c.path}${cur.size > 1 ? ` (${fresh} locked ${what})` : ""}`); +} + +console.log(""); +if (moved) console.log("Moving locked text needs the maintainer's approval on the PR (docs/rfc-cleanup.md §2.9)."); +const wrong = [failed && `${plural(failed, "locked text")} changed`, unlisted.length && `${plural(unlisted.length, "path")} taken off ${LIST}`]; +if (failed || unlisted.length) { + console.log(`check:human-lock: ${wrong.filter(Boolean).join(", ")}`); + if (failed) console.log("Markup, layout and link and image paths may change in locked text; words may not. A typo is reported in the PR description, not fixed."); + process.exitCode = 1; +} else console.log("check:human-lock: no locked words changed"); diff --git a/web/scripts/lab-director-smoke.ts b/web/scripts/lab-director-smoke.ts index 18342cf78..28dd89a58 100644 --- a/web/scripts/lab-director-smoke.ts +++ b/web/scripts/lab-director-smoke.ts @@ -169,7 +169,7 @@ function key(t: number, v: number, mode: "hold" | "curve" = "hold"): DirectorKey knobLabels: ["A", "B", "C", "D"], forkOf: null, editOf: null, - gen: { count: 5, thinking: "LOW" as const, refs: 6, colorMode: "vfield" as const }, + gen: { colorMode: "vfield" as const }, director: show, name: "Smoke Piece", }; diff --git a/web/scripts/lab-edit-smoke.ts b/web/scripts/lab-edit-smoke.ts index a93a0bd2d..7208a19f1 100644 --- a/web/scripts/lab-edit-smoke.ts +++ b/web/scripts/lab-edit-smoke.ts @@ -130,7 +130,7 @@ async function main() { knobLabels: ["A", "B", "C", "D"], forkOf: null, editOf: EDIT, - gen: { count: 5, thinking: "LOW" as const, refs: 6, colorMode: "vfield" as const }, + gen: { colorMode: "vfield" as const }, director: emptyShow(), name: "Edit Smoke", }; diff --git a/web/scripts/lab-flatten-smoke.ts b/web/scripts/lab-flatten-smoke.ts index f98a05a4f..2b9409f47 100644 --- a/web/scripts/lab-flatten-smoke.ts +++ b/web/scripts/lab-flatten-smoke.ts @@ -254,7 +254,7 @@ async function stackSmoke() { knobLabels: ["Hue", "Speed", "Freq", "Mix"], forkOf: null, editOf: null, - gen: { count: 5, thinking: "LOW" as const, refs: 6, colorMode: "vfield" as const }, + gen: { colorMode: "vfield" as const }, director: emptyShow(), name: "Flatten Smoke", }; diff --git a/web/scripts/lib/pr-base.ts b/web/scripts/lib/pr-base.ts new file mode 100644 index 000000000..26b14b562 --- /dev/null +++ b/web/scripts/lib/pr-base.ts @@ -0,0 +1,47 @@ +/** + * The commit a pull request starts from, and the files it changes since, for + * scripts/human-lock.ts and scripts/voice-lint.ts. PF_LOCK_BASE names it (CI + * passes the pull request's base SHA); at a desk it is the merge-base with + * origin/dev, then origin/main. With none of the three, the check skips + * (docs/investigations/2026-10-cleanup-step2-plan.md, D5). + */ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +export const ROOT = execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" }).trim(); +export const git = (args: string[]) => + execFileSync("git", args, { cwd: ROOT, encoding: "utf8", maxBuffer: 1 << 28, stdio: ["ignore", "pipe", "ignore"] }); +const attempt = (f: () => T, fallback: T): T => { + try { return f(); } catch { return fallback; } +}; +const commit = (ref: string) => attempt(() => git(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]).trim(), ""); + +export type Base = { sha: string; from: string } | { sha: null; why: string; fatal: boolean }; + +/** The base and where it came from, or why there is none: fatal when PF_LOCK_BASE names a commit this clone lacks. */ +export function findBase(): Base { + const named = process.env.PF_LOCK_BASE?.trim(); + if (named && !commit(named)) return { sha: null, why: `PF_LOCK_BASE is ${named}, and this clone has no such commit (check out with fetch-depth: 0)`, fatal: true }; + if (named) return { sha: commit(named), from: "PF_LOCK_BASE" }; + for (const ref of ["origin/dev", "origin/main"]) { + const sha = commit(ref) && attempt(() => git(["merge-base", "HEAD", ref]).trim(), ""); + if (sha) return { sha, from: `the merge-base with ${ref}` }; + } + return { sha: null, why: "no PF_LOCK_BASE, and no merge-base with origin/dev or origin/main", fatal: false }; +} + +export type Change = { path: string; status: "added" | "modified" | "deleted" }; + +/** What the working tree changes since base, untracked files included. A move is a deletion and an addition. */ +export function changedSince(base: string): Change[] { + const out = new Map(); + const fields = git(["diff", "--name-status", "--no-renames", "-z", base]).split("\0").filter(Boolean); + for (let i = 0; i + 1 < fields.length; i += 2) out.set(fields[i + 1], fields[i] === "A" ? "added" : fields[i] === "D" ? "deleted" : "modified"); + for (const f of git(["ls-files", "--others", "--exclude-standard", "-z"]).split("\0").filter(Boolean)) out.set(f, "added"); + return [...out].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([p, status]) => ({ path: p, status })); +} + +/** A file as it was at a commit, or as it is in the working tree; null if it is not there. */ +export const readAt = (ref: string, file: string) => attempt(() => git(["cat-file", "-p", `${ref}:${file}`]), null); +export const readNow = (file: string) => attempt(() => fs.readFileSync(path.join(ROOT, file), "utf8"), null); diff --git a/web/scripts/license-smoke.ts b/web/scripts/license-smoke.ts index a6f8d89b7..6e2c3add0 100644 --- a/web/scripts/license-smoke.ts +++ b/web/scripts/license-smoke.ts @@ -142,15 +142,15 @@ const communityFooter = [ "// please keep this notice and the author credit above when you reuse,", "// remix, or redistribute this pattern. Do not delete it.", ].join("\n"); -const geminiFooter = [ +const labAiFooter = [ "// ---", "// Made with Patternflow Pattern Lab — https://patternflow.work/pattern-lab", "// Licensed CC-BY-SA-4.0. Keep this notice if you share or remix.", ].join("\n"); -const stack = `(function () {\n function draw() {}\n${communityFooter}\n})();\n(function () {\n function draw() {}\n${geminiFooter}\n})();\nexport function draw(display) {}`; +const stack = `(function () {\n function draw() {}\n${communityFooter}\n})();\n(function () {\n function draw() {}\n${labAiFooter}\n})();\nexport function draw(display) {}`; check("embedded community footer leaves the code after it alone", stripShareWrapping(stack), stack); check("trailing community footer still comes off", stripShareWrapping(`${BODY}\n\n${communityFooter}\n`), BODY); -check("trailing gemini footer still comes off", stripShareWrapping(`${BODY}\n\n${geminiFooter}\n`), BODY); +check("trailing Lab AI footer still comes off", stripShareWrapping(`${BODY}\n\n${labAiFooter}\n`), BODY); check( "a @stack line after the body survives a trailing footer", stripShareWrapping(`${BODY}\n\n// @stack v1 d:AAAA\n\n${communityFooter}\n`), diff --git a/web/scripts/performance-smoke.ts b/web/scripts/performance-smoke.ts index 217a89c04..1caf63b21 100644 --- a/web/scripts/performance-smoke.ts +++ b/web/scripts/performance-smoke.ts @@ -13,7 +13,6 @@ import { PFST_CUE_BYTES, decodePfst, encodePfst, - normalizePerformance, pfsFilename, serializePerformance, validatePerformance, diff --git a/web/scripts/voice-lint.ts b/web/scripts/voice-lint.ts new file mode 100644 index 000000000..2dddda200 --- /dev/null +++ b/web/scripts/voice-lint.ts @@ -0,0 +1,271 @@ +/** + * The voice lint: `npm run check:voice` in web/ (docs/rfc-cleanup.md §2.10). + * New or rewritten text over 20 lines goes through it before the pull request + * opens, and a person still reads the text before merge. CI does not run it. + * + * With files as arguments it reads all of each (`-` reads Markdown from + * stdin, for a PR description). With none it reads the lines this branch adds + * since the base the lock uses (scripts/lib/pr-base.ts), untracked files + * included. Each finding is printed as file:line, the rule and the words. + * + * banned a word or phrase in docs/voice-banned.txt. An English entry + * matches whole words and their endings; a Korean one, anywhere. + * threes three adjectives or clauses in a row, joined by commas. + * opener a sentence that says what the text is about. + * closer a closing summary, the kind that starts "In conclusion". + * + * Only the prose of a file is read: Markdown without code, tags, link targets + * or URLs; an HTML page's text; a script's comments, strings and JSX text; the + * comments of C, Python, shell and YAML. Text in double quotes or 「」 is + * someone's words being quoted, and is skipped. So is a section under a + * "Writing voice" heading, which has to quote what it forbids. + */ +import fs from "node:fs"; +import path from "node:path"; +import ts from "typescript"; + +import { ROOT, changedSince, findBase, git } from "./lib/pr-base"; + +const BANNED = "docs/voice-banned.txt"; +// Files the rules would read as their own examples, each with the reason. +const EXEMPT: Record = { + [BANNED]: "it is the list the banned rule reads", +}; +const VOICE_HEADING = /^ {0,3}(#{1,6})\s+(?:[\d.]+\s+)?Writing voice\b/i; + +// ── Prose ── +// Each reader returns its file with everything but the prose blanked to +// spaces, newlines kept, so a match's offset still names its line. +const blank = (s: string) => s.replace(/[^\n]/g, " "); +type Reader = (src: string, file: string) => string; +type Range = [from: number, to: number]; + +function only(src: string, keep: Range[]): string { + const out = blank(src).split(""); + for (const [a, b] of keep) for (let i = a; i < b; i++) out[i] = src[i]; + return out.join(""); +} +// The matches of re that keep() accepts, trimmed by the delimiters it returns. +const pick = (re: RegExp, keep: (m: string) => number | null): Reader => (src) => + only(src, [...src.matchAll(re)].flatMap((m): Range[] => { + const cut = keep(m[0]); + return cut === null ? [] : [[m.index + cut, m.index + m[0].length - cut]]; + })); + +function markdown(src: string, mdx: boolean): string { + let [fence, quiet, front] = [false, 0, src.startsWith("---\n")]; + const lines = src.split("\n").map((line, i) => { + if (front) { + if (i > 0 && /^---\s*$/.test(line)) front = false; + return i > 0 && front ? line.replace(/^\s*[\w-]+:/, blank) : ""; // front matter: the values are text + } + const fenced = /^ {0,3}(?:```|~~~)/.test(line); + if (fenced) fence = !fence; + if (fence || fenced) return ""; + const h = /^ {0,3}(#{1,6})\s/.exec(line); + if (h && quiet && h[1].length <= quiet) quiet = 0; + quiet ||= VOICE_HEADING.exec(line)?.[1].length ?? 0; + return quiet || (mdx && /^(?:import|export)\s/.test(line)) ? "" : line; + }); + return lines.join("\n") + .replace(/^ {0,3}\[[^\]\n]+\]:.*$/gm, blank) + .replace(/`[^`\n]*`|\]\([^)\n]*\)|\]\[[^\]\n]*\]|<\/?[A-Za-z][^>\n]*>||&(?:#x[\da-f]+|#\d+|[a-z]+);/gi, blank); +} + +const html: Reader = (src) => + src.replace(/<(script|style)\b[\s\S]*?<\/\1\s*>|<[^>]*>|&(?:#x[\da-f]+|#\d+|[a-z]+);/gi, blank); + +// A script's comments, strings and JSX text, found by the TypeScript parser so +// a regular expression or a template cannot pass for a comment. +function script(src: string, file: string): string { + const kind = /x$/.test(file) ? ts.ScriptKind.TSX : /\.[cm]?js$/.test(file) ? ts.ScriptKind.JS : ts.ScriptKind.TS; + const sf = ts.createSourceFile(file, src, ts.ScriptTarget.Latest, true, kind); + const keep: Range[] = []; + const seen = new Set(); + const comment = (pos: number, end: number) => { + if (!seen.has(pos)) keep.push([pos, end]); + seen.add(pos); + }; + const visit = (n: ts.Node): void => { + if (n.kind >= ts.SyntaxKind.FirstJSDocNode && n.kind <= ts.SyntaxKind.LastJSDocNode) return; + if (ts.isJsxText(n)) { + keep.push([n.pos, n.end]); + return; + } + ts.forEachLeadingCommentRange(src, n.pos, comment); + ts.forEachTrailingCommentRange(src, n.end, comment); + if (ts.isTaggedTemplateExpression(n) && n.tag.getText(sf) === "String.raw") return; // a pattern or a path + if (ts.isStringLiteral(n)) keep.push([n.getStart(sf) + 1, n.end - 1]); + else if (ts.isNoSubstitutionTemplateLiteral(n) || ts.isTemplateHead(n) || ts.isTemplateMiddle(n) || ts.isTemplateTail(n)) keep.push([n.getStart(sf), n.end]); + n.getChildren(sf).forEach(visit); + }; + visit(sf); + return only(src, keep); +} + +const STRINGS = String.raw`"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'`; +const cLike = pick(new RegExp(String.raw`\/\*[\s\S]*?\*\/|\/\/[^\n]*|${STRINGS}`, "g"), (m) => (m[0] === "/" ? 0 : null)); +const hash = pick(new RegExp(String.raw`${STRINGS}|#[^\n]*`, "g"), (m) => (m[0] === "#" ? 0 : null)); +const python = pick(new RegExp(String.raw`"""[\s\S]*?"""|'''[\s\S]*?'''|${STRINGS}|#[^\n]*`, "g"), (m) => (m[0] === "#" ? 0 : /^("""|''')/.test(m) ? 3 : null)); +const asMarkdown: Reader = (src, file) => markdown(src, file.endsWith(".mdx")); + +const plain: Reader = (s) => s; +const READERS: Record = { + ".md": asMarkdown, ".mdx": asMarkdown, ".txt": plain, ".html": html, + ".ts": script, ".tsx": script, ".js": script, ".jsx": script, ".mjs": script, ".cjs": script, + ".h": cLike, ".hpp": cLike, ".c": cLike, ".cpp": cLike, ".ino": cLike, ".kt": cLike, ".java": cLike, + ".py": python, ".sh": hash, ".yml": hash, ".yaml": hash, ".toml": hash, +}; + +// Not prose: URLs, paths, and quoted words (double quotes on one paragraph, +// or 「」 and 『』). Runs of blanks shrink to one space; the newlines stay. +const prose = (src: string, read: Reader, file: string) => + read(src.replace(/\r/g, ""), file) + .replace(/https?:\/\/[^\s)\]>"'`]+|[\w.@~-]*\/[\w./@~-]+/g, blank) + .replace(/"(?:[^"\n]|\n(?![ \t]*\n)){1,300}?"|“[^”]{1,300}”|「[^」]{1,300}」|『[^』]{1,300}』/g, blank) + .replace(/[^\S\n]+/g, " "); + +// ── The rules ── +const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +// "ensure" catches "ensures", "ensured" and "ensuring"; "robust", "robustly". +function forms(w: string): string { + if (!/^[a-z]+$/.test(w)) return escape(w); + if (/[^aeiou]y$/.test(w)) return `${w.slice(0, -1)}(?:y|ies|ied|ying|ily|iness)`; + if (w.endsWith("e")) return `${w.slice(0, -1)}(?:e|es|ed|ing|ely|eness|ation|ations)`; + return `${w}(?:s|es|ed|ing|ly|ness|ment|ments)?`; +} +function bannedRule(): RegExp { + const entries = fs.readFileSync(path.join(ROOT, BANNED), "utf8").split("\n") + .map((l) => l.trim().toLowerCase().replace(/’/g, "'")).filter((l) => l && !l.startsWith("#")); + const korean = entries.filter((e) => /\p{Script=Hangul}/u.test(e)).map(escape); + const english = entries.filter((e) => !/\p{Script=Hangul}/u.test(e)).map((e) => { + const words = e.split(/\s+/); + return [...words.slice(0, -1).map(escape), forms(words[words.length - 1])].join(String.raw`\s+`).replace(/'/g, "['’]").replace(/-/g, String.raw`[-\s]`); + }); + const alt = [english.length && String.raw`(? String.raw`${EN_DET}${first}[a-z'’-]*(?:\s${EN_WORD}){${n - 1}}`; +const EN_THREE = (n: number) => String.raw`${EN_ITEM(n, "[A-Za-z]")},\s${EN_ITEM(n)},\s(?:and|or)\s${EN_ITEM(n)}`; +const KO_WORD = String.raw`[\p{Script=Hangul}]+`; +const KO_JOIN = String.raw`${KO_WORD}(?:고|며|하게|롭게|한|적인|로운|있는|없는|된)`; +const KO_ITEM = (last: string) => String.raw`(?:${KO_WORD}\s){0,2}${last}`; +// Patterns are regex literals, so a run on this file does not read them as prose. +const OPENERS = [ + /(?:This|The following)\s(?:document|doc|file|page|guide|section|chapter|post|article|note|README|PR|pull request|commit|change|RFC|plan|report|tutorial|module|script|spec|specification|repository|repo)\s(?:describes|explains|covers|introduces|provides|contains|outlines|presents|documents|details|lists|summari[sz]es|discusses|defines|walks|aims|is about|will)\b/u, + /In\sthis\s(?:document|doc|file|page|guide|section|chapter|post|article|README|PR|pull request|RFC|plan|report|tutorial),?\s(?:we|you|I)\b/u, + /(?:이|본|이번)\s(?:문서|글|페이지|파일|가이드|안내서|절|장|섹션|PR|커밋|스크립트|모듈|계획서|보고서|RFC)\s?(?:은|는|에서는|에서)\s[^.!?\n]{0,60}?(?:설명|소개|다루|다룹|다룬|안내|살펴|담고|담았)/u, +]; +const CLOSERS = /(?:In conclusion|In summary|To summari[sz]e|To sum up|All in all|In closing|Overall,|TL;\s?DR)/u; +const CLOSERS_KO = /결론적으로|요약하면|요약하자면|정리하자면|종합하면|종합하자면|요컨대|마치며/u; +const RULES: [name: string, re: RegExp, why: string][] = [ + ["banned", bannedRule(), `a word ${BANNED} lists. A sentence that needs one to stand up is a sentence to cut`], + ["threes", new RegExp([ + String.raw`(? r.source).join("|")})`, "gmu"), "says what the text is about. Start with the thing"], + ["closer", new RegExp(`${START}${CLOSERS.source}|${CLOSERS_KO.source}`, "gmu"), "a closing summary. A paragraph stops when it is said"], +]; + +// ── What is read ── +type Target = { file: string; src: string; read: Reader; lines: Set | null }; +const plural = (k: number, one: string) => `${k} ${one}${k === 1 ? "" : "s"}`; +const strict = process.env.PF_DRIFT_STRICT === "1"; +const args = process.argv.slice(2); +const targets: Target[] = []; +const skipped: string[] = []; + +function add(file: string, lines: Set | null, explicit: boolean, abs = path.join(ROOT, file)) { + const read = READERS[path.extname(file).toLowerCase()] ?? (explicit ? plain : undefined); + const raw = read && fs.existsSync(abs) && fs.statSync(abs).isFile() ? fs.readFileSync(abs) : null; + if (explicit && !raw && !EXEMPT[file]) { + console.log(`check:voice: no such file: ${file}`); + process.exit(1); + } + if (EXEMPT[file]) skipped.push(`${file}: ${EXEMPT[file]}`); + else if (read && raw && !raw.includes(0)) targets.push({ file, src: raw.toString("utf8"), read, lines }); +} + +if (args.length) { + const cwd = process.env.INIT_CWD ?? process.cwd(); + for (const a of args) { + if (a === "-") targets.push({ file: "(stdin)", src: fs.readFileSync(0, "utf8"), read: asMarkdown, lines: null }); + else { + const abs = path.resolve(cwd, a); + const rel = path.relative(ROOT, abs); + add(rel.startsWith("..") ? a : rel.split(path.sep).join("/"), null, true, abs); + } + } +} else { + const base = findBase(); + if (base.sha === null) { + console.log(`check:voice: ${base.fatal ? base.why : `skipped, ${base.why}${strict ? "\nPF_DRIFT_STRICT=1, so a skip fails" : ""}`}`); + process.exit(base.fatal || strict ? 1 : 0); + } + console.log(`base: ${base.sha.slice(0, 7)} (${base.from})`); + // Each file's added lines: all of a new file, the + side of each hunk otherwise. + const added = new Map | null>(); + for (const c of changedSince(base.sha)) if (c.status === "added") added.set(c.path, null); + let file = ""; + for (const line of git(["-c", "core.quotePath=false", "diff", "-U0", "--no-renames", "--no-color", "--no-ext-diff", base.sha]).split("\n")) { + const f = /^\+\+\+ (?:b\/(.*?)\t?|\/dev\/null)$/.exec(line); + const h = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(line); + if (f) file = f[1] ?? ""; + else if (h && file && added.get(file) !== null) { + const set = added.get(file) ?? new Set(); + for (let i = 0; i < Number(h[2] ?? 1); i++) set.add(Number(h[1]) + i); + added.set(file, set); + } + } + for (const [f, lines] of added) if (lines === null || lines.size) add(f, lines, false); +} + +// ── The lint ── +type Finding = { at: string; rule: string; words: string }; +const findings: Finding[] = []; +let count = 0; +for (const t of targets) { + const lines = prose(t.src, t.read, t.file).replace(/\n$/, "").split("\n"); + const wanted = (i: number) => t.lines === null || t.lines.has(i + 1); + count += lines.filter((_, i) => wanted(i)).length; + // Runs of wanted lines, matched as one text so a phrase may wrap. + for (let i = 0; i < lines.length; i++) { + if (!wanted(i)) continue; + let j = i; + while (j + 1 < lines.length && wanted(j + 1)) j++; + const text = lines.slice(i, j + 1).join("\n"); + for (const [rule, re] of RULES) + for (const m of text.matchAll(re)) { + const line = i + 1 + (text.slice(0, m.index).match(/\n/g)?.length ?? 0); + const words = m[0].replace(/\s+/g, " ").trim(); + findings.push({ at: `${t.file}:${line}`, rule, words: words.length > 70 ? `${words.slice(0, 70)}…` : words }); + } + i = j; + } +} +findings.sort((a, b) => a.at.localeCompare(b.at, undefined, { numeric: true })); + +console.log(`read: ${plural(count, args.length ? "line" : "added line")} in ${plural(targets.length, "file")}`); +for (const s of skipped) console.log(`skipped ${s}`); +if (findings.length) console.log(""); +const width = Math.max(0, ...findings.map((f) => f.at.length)); +for (const f of findings) console.log(`${f.at.padEnd(width)} ${f.rule} ${f.words}`); +console.log(""); +if (!findings.length) console.log("check:voice: nothing found"); +else { + const files = new Set(findings.map((f) => f.at.replace(/:\d+$/, ""))).size; + console.log(`check:voice: ${plural(findings.length, "finding")} in ${plural(files, "file")}`); + for (const [rule, , why] of RULES) if (findings.some((f) => f.rule === rule)) console.log(` ${rule} ${why}`); + console.log("The rules are docs/rfc-cleanup.md §2.10; the voice is docs/voice.md. A person reads the text before merge either way."); + process.exitCode = 1; +} diff --git a/web/src/app/api/community/atlas/route.ts b/web/src/app/api/community/atlas/route.ts index 2cd779fa1..f13c690a3 100644 --- a/web/src/app/api/community/atlas/route.ts +++ b/web/src/app/api/community/atlas/route.ts @@ -3,6 +3,7 @@ import { ENTRY_BY_ID } from "@/lib/atlas/data"; import { isAdminSession } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { listAtlasPins } from "@/lib/community/server/queries"; import { rateLimit } from "@/lib/community/ratelimit"; @@ -84,12 +85,8 @@ async function handleWrite(request: Request, mode: "place" | "remove") { return Response.json({ error: "Too many moves — let the pin settle a moment." }, { status: 429 }); } - let payload: Record; - try { - payload = (await request.json()) as Record; - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } + const payload = await readJsonObject(request); + if (payload instanceof Response) return payload; const patternId = typeof payload.patternId === "string" ? payload.patternId : ""; if (!patternId) { diff --git a/web/src/app/api/community/builds/route.ts b/web/src/app/api/community/builds/route.ts index 57ec45231..e8aa6f459 100644 --- a/web/src/app/api/community/builds/route.ts +++ b/web/src/app/api/community/builds/route.ts @@ -8,6 +8,7 @@ import { type BuildPatternInput, } from "@/lib/community/server/builds"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled } from "@/lib/community/server/db"; import { rateLimit } from "@/lib/community/ratelimit"; import { CPP_MAX } from "@/lib/community/validate"; @@ -70,16 +71,17 @@ async function handlePost(request: Request) { ); } - let body: unknown; - try { - body = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } + const body = await readJsonObject(request); + if (body instanceof Response) return body; - // "bin" is still accepted as a word so an older page gets an explanation - // rather than a validation error it cannot act on. - const rawFormat = (body as Record).format ?? "pfm"; + // "bin" asked for a whole firmware image. The community built those from 25 + // July 2026 (c5e0907) until 4c13474 removed them on 12 August, and since + // then the word gets a 410 that says where to go instead of the 400 below. + // No page here ever sent it. BuildFirmwareModal, the one dialog that asked + // for an image, sent no format, and a missing format meant "bin" until 12 + // August. The 410 is for a caller outside this repository that names the + // format, as this route's header told it to from 27 July (d7cd193). + const rawFormat = body.format ?? "pfm"; if (rawFormat === "bin") { return Response.json( { @@ -95,7 +97,7 @@ async function handlePost(request: Request) { } const format: BuildFormat = rawFormat; - const raw = (body as Record).patterns; + const raw = body.patterns; if (!Array.isArray(raw) || raw.length === 0) { return Response.json({ error: "Send at least one pattern to build." }, { status: 400 }); } diff --git a/web/src/app/api/community/comments/[id]/route.ts b/web/src/app/api/community/comments/[id]/route.ts index 8a9307705..cf3b030bb 100644 --- a/web/src/app/api/community/comments/[id]/route.ts +++ b/web/src/app/api/community/comments/[id]/route.ts @@ -2,6 +2,7 @@ import { eq } from "drizzle-orm"; import { isAdminSession } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { clearNotificationsFor } from "@/lib/community/server/notify"; import { getCommentStub } from "@/lib/community/server/queries"; @@ -64,14 +65,10 @@ async function handlePatch(request: Request, context: { params: Promise<{ id: st return Response.json({ error: "You can only edit your own comments." }, { status: 403 }); } - let body: unknown; - try { - body = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } + const body = await readJsonObject(request); + if (body instanceof Response) return body; - const next = cleanComment((body as Record).body); + const next = cleanComment(body.body); if (!next) { return Response.json( { error: `A comment cannot be empty, and must be under ${COMMENT_MAX} characters.` }, diff --git a/web/src/app/api/community/decks/[id]/route.test.ts b/web/src/app/api/community/decks/[id]/route.test.ts new file mode 100644 index 000000000..08662a42d --- /dev/null +++ b/web/src/app/api/community/decks/[id]/route.test.ts @@ -0,0 +1,133 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, beforeAll, expect, it } from "vitest"; + +// Sharing a deck and editing it used to count against the same five a minute. +// The visibility select sends a PATCH on every change, so somebody who shared +// a deck and then flipped it public and back a couple of times was refused on +// the fifth edit. Driven through the real routes with real sessions, on a +// database of its own. + +const ENV_KEYS = ["COMMUNITY_DB_PATH", "COMMUNITY_ENABLED", "BETTER_AUTH_SECRET"] as const; +const savedEnv = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]])); +let tmp = ""; + +/** Signs somebody up with one public pattern and returns their Cookie header. */ +let enrol: (username: string) => Promise; +/** Shares a private deck holding that person's pattern. */ +let share: (cookie: string, username: string) => Promise<{ status: number; id?: string }>; +let edit: (cookie: string, deckId: string, body: Record) => Promise; + +beforeAll(async () => { + // The database is a lazy singleton keyed off these variables, so they are + // set before anything imports it. + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "pf-deck-rate-")); + process.env.COMMUNITY_DB_PATH = path.join(tmp, "test.db"); + process.env.COMMUNITY_ENABLED = "1"; + process.env.BETTER_AUTH_SECRET = "deck-rate-test-secret-not-a-real-one"; + + const { eq } = await import("drizzle-orm"); + const { getAuth } = await import("@/lib/community/server/auth"); + const { getDb } = await import("@/lib/community/server/db"); + const schema = await import("@/lib/community/server/schema"); + const createRoute = await import("../route"); + const deckRoute = await import("./route"); + + const db = getDb(); + const auth = getAuth(); + + enrol = async (username) => { + const response = await auth.api.signUpEmail({ + body: { + email: `${username}@patternflow.local`, + password: "deck-rate-test-password", + name: username, + username, + }, + asResponse: true, + }); + const setCookie = response.headers.get("set-cookie"); + if (!setCookie) throw new Error(`no session cookie for ${username}`); + const [author] = await db + .select({ id: schema.user.id }) + .from(schema.user) + .where(eq(schema.user.username, username)) + .limit(1); + const now = new Date(); + await db.insert(schema.patterns).values({ + id: `p-${username}`, + userId: author!.id, + title: `${username}'s pattern`, + code: "// js", + license: "CC-BY-SA-4.0", + visibility: "public", + createdAt: now, + updatedAt: now, + }); + return setCookie.split(";")[0]; + }; + + // No Origin header below, which is the same-origin case originBlocked() + // lets through. The session is the gate under test. + share = async (cookie, username) => { + const response = await createRoute.POST( + new Request("http://localhost:3000/api/community/decks", { + method: "POST", + headers: { "Content-Type": "application/json", cookie }, + body: JSON.stringify({ + title: `${username}'s running order`, + visibility: "private", + patternIds: [`p-${username}`], + }), + }), + ); + const payload = (await response.json()) as { id?: string }; + return { status: response.status, id: payload.id }; + }; + + edit = async (cookie, deckId, body) => { + const response = await deckRoute.PATCH( + new Request(`http://localhost:3000/api/community/decks/${deckId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json", cookie }, + body: JSON.stringify(body), + }), + { params: Promise.resolve({ id: deckId }) }, + ); + return response.status; + }; +}, 30_000); + +afterAll(() => { + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[key]; + } + if (tmp) fs.rmSync(tmp, { recursive: true, force: true }); +}); + +it("lets the owner edit a deck twenty times in the minute they shared it", async () => { + const ada = await enrol("ada"); + const shared = await share(ada, "ada"); + expect(shared.status).toBe(201); + const deckId = shared.id!; + + const statuses: number[] = []; + for (let n = 0; n < 20; n += 1) { + statuses.push(await edit(ada, deckId, { visibility: n % 2 === 0 ? "public" : "private" })); + } + expect(statuses).toEqual(Array(20).fill(200)); + + // Edits still have a ceiling, the same twenty a minute a pattern's have. + expect(await edit(ada, deckId, { title: "One too many" })).toBe(429); + // And they never ate into sharing: a new deck still goes through. + expect((await share(ada, "ada")).status).toBe(201); +}, 30_000); + +it("keeps sharing new decks at five a minute", async () => { + const bea = await enrol("bea"); + const statuses: number[] = []; + for (let n = 0; n < 6; n += 1) statuses.push((await share(bea, "bea")).status); + expect(statuses).toEqual([201, 201, 201, 201, 201, 429]); +}, 30_000); diff --git a/web/src/app/api/community/decks/[id]/route.ts b/web/src/app/api/community/decks/[id]/route.ts index 9cad0bdcb..992ef7ddc 100644 --- a/web/src/app/api/community/decks/[id]/route.ts +++ b/web/src/app/api/community/decks/[id]/route.ts @@ -7,6 +7,7 @@ import { } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { DECK_MAX, PUBLIC_DECKS_MAX } from "@/lib/community/deck"; import { checkDeckPattern, cleanPatternIds } from "@/lib/community/deckShare"; @@ -84,7 +85,11 @@ async function handlePatch(request: Request, context: { params: Promise<{ id: st return Response.json({ error: "Sign in to edit your deck." }, { status: 401 }); } - if (!rateLimit(`deck:${session.user.id}`, 5, 60_000)) { + // Edits count on a key of their own. The visibility select sends a PATCH on + // every change, and while edits shared `deck:` with sharing a new deck, the + // fifth edit within a minute of the share was refused. Twenty, the same as + // a pattern's or a post's edits. + if (!rateLimit(`deckpatch:${session.user.id}`, 20, 60_000)) { return Response.json({ error: "Too many deck updates — wait a minute and try again." }, { status: 429 }); } @@ -97,13 +102,8 @@ async function handlePatch(request: Request, context: { params: Promise<{ id: st return Response.json({ error: "You can only edit your own decks." }, { status: 403 }); } - let body: unknown; - try { - body = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } - const raw = body as Record; + const raw = await readJsonObject(request); + if (raw instanceof Response) return raw; if (moderating) { if (!moderatorVisibilityPatchOnly(raw)) { diff --git a/web/src/app/api/community/decks/route.ts b/web/src/app/api/community/decks/route.ts index 3aa0ab1fb..99b6f6c40 100644 --- a/web/src/app/api/community/decks/route.ts +++ b/web/src/app/api/community/decks/route.ts @@ -1,6 +1,7 @@ import { isAdminSession } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { DECK_MAX, PUBLIC_DECKS_MAX } from "@/lib/community/deck"; import { checkDeckPattern, cleanPatternIds } from "@/lib/community/deckShare"; @@ -42,13 +43,8 @@ async function handlePost(request: Request) { return Response.json({ error: "Too many deck updates — wait a minute and try again." }, { status: 429 }); } - let body: unknown; - try { - body = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } - const raw = body as Record; + const raw = await readJsonObject(request); + if (raw instanceof Response) return raw; const title = cleanTitle(raw.title); if (!title) return Response.json({ error: "Title is required (max 80 chars)." }, { status: 400 }); diff --git a/web/src/app/api/community/featured/route.ts b/web/src/app/api/community/featured/route.ts index e80d3a825..4ef3bc9c4 100644 --- a/web/src/app/api/community/featured/route.ts +++ b/web/src/app/api/community/featured/route.ts @@ -2,6 +2,7 @@ import { inArray } from "drizzle-orm"; import { isAdminSession } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { featuredPatterns, patterns } from "@/lib/community/server/schema"; @@ -35,14 +36,10 @@ async function handlePut(request: Request) { return Response.json({ error: "Not found." }, { status: 404 }); } - let payload: unknown; - try { - payload = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } + const payload = await readJsonObject(request); + if (payload instanceof Response) return payload; - const raw = (payload as { patternIds?: unknown }).patternIds; + const raw = payload.patternIds; if (!Array.isArray(raw) || raw.some((id) => typeof id !== "string")) { return Response.json({ error: "Expected patternIds: string[]." }, { status: 400 }); } diff --git a/web/src/app/api/community/nullBody.test.ts b/web/src/app/api/community/nullBody.test.ts new file mode 100644 index 000000000..0c4a4c807 --- /dev/null +++ b/web/src/app/api/community/nullBody.test.ts @@ -0,0 +1,133 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, beforeAll, expect, it } from "vitest"; + +// `null` is valid JSON, so it got past the `catch` around every write route's +// `request.json()` and threw a TypeError on the first field read. Next.js +// answered that with a bare 500: no JSON saying why, and no CORS headers, so a +// Pattern Lab on another origin could not read even the status. Every route +// below is driven with a signed-in moderator and the Lab's Origin, on a +// database of its own, with something for each id in the path to find. +// +// territories/[code]/pin is not here: its body is optional, and a `null` one +// already pins with no note. + +const LAB_ORIGIN = "https://lab.patternflow.test"; +const ENV_KEYS = [ + "COMMUNITY_DB_PATH", + "COMMUNITY_ENABLED", + "BETTER_AUTH_SECRET", + "COMMUNITY_ADMIN_USERNAMES", + "COMMUNITY_ALLOWED_ORIGINS", + "BUILD_ENABLED", +] as const; +const savedEnv = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]])); +let tmp = ""; +let cookie = ""; + +type Handler = (request: Request, context: { params: Promise> }) => Promise; +type RouteModule = Record; + +// [method, path, module loader, path params] +const ROUTES: [string, string, () => Promise, Record][] = [ + ["POST", "atlas", () => import("./atlas/route"), {}], + ["DELETE", "atlas", () => import("./atlas/route"), {}], + ["POST", "builds", () => import("./builds/route"), {}], + ["PATCH", "comments/c1?on=pattern", () => import("./comments/[id]/route"), { id: "c1" }], + ["POST", "decks", () => import("./decks/route"), {}], + ["PATCH", "decks/d1", () => import("./decks/[id]/route"), { id: "d1" }], + ["PUT", "featured", () => import("./featured/route"), {}], + ["POST", "patterns", () => import("./patterns/route"), {}], + ["PATCH", "patterns/p1", () => import("./patterns/[id]/route"), { id: "p1" }], + ["POST", "patterns/p1/comments", () => import("./patterns/[id]/comments/route"), { id: "p1" }], + ["POST", "patterns/p1/header", () => import("./patterns/[id]/header/route"), { id: "p1" }], + ["POST", "patterns/p1/performance", () => import("./patterns/[id]/performance/route"), { id: "p1" }], + ["PATCH", "ports/h1", () => import("./ports/[id]/route"), { id: "h1" }], + ["POST", "posts", () => import("./posts/route"), {}], + ["PATCH", "posts/s1", () => import("./posts/[id]/route"), { id: "s1" }], + ["POST", "posts/s1/comments", () => import("./posts/[id]/comments/route"), { id: "s1" }], + ["POST", "posts/s1/pin", () => import("./posts/[id]/pin/route"), { id: "s1" }], + ["POST", "presence", () => import("./presence/route"), {}], + ["POST", "reports", () => import("./reports/route"), {}], + ["PATCH", "reports/r1", () => import("./reports/[id]/route"), { id: "r1" }], + ["POST", "territories", () => import("./territories/route"), {}], + ["PATCH", "territories/A1", () => import("./territories/[code]/route"), { code: "A1" }], +]; + +beforeAll(async () => { + // The database is a lazy singleton keyed off these variables, so they are + // set before anything imports it. + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "pf-null-body-")); + process.env.COMMUNITY_DB_PATH = path.join(tmp, "test.db"); + process.env.COMMUNITY_ENABLED = "1"; + process.env.BETTER_AUTH_SECRET = "null-body-test-secret-not-a-real-one"; + process.env.COMMUNITY_ADMIN_USERNAMES = "ada"; + process.env.COMMUNITY_ALLOWED_ORIGINS = LAB_ORIGIN; + process.env.BUILD_ENABLED = "1"; + + const { eq } = await import("drizzle-orm"); + const { getAuth } = await import("@/lib/community/server/auth"); + const { getDb } = await import("@/lib/community/server/db"); + const schema = await import("@/lib/community/server/schema"); + + const response = await getAuth().api.signUpEmail({ + body: { email: "ada@patternflow.local", password: "null-body-test-password", name: "ada", username: "ada" }, + asResponse: true, + }); + const setCookie = response.headers.get("set-cookie"); + if (!setCookie) throw new Error("no session cookie for ada"); + cookie = setCookie.split(";")[0]; + + const db = getDb(); + const [ada] = await db + .select({ id: schema.user.id }) + .from(schema.user) + .where(eq(schema.user.username, "ada")) + .limit(1); + const userId = ada!.id; + const now = new Date(); + await db.insert(schema.patterns).values({ + id: "p1", userId, title: "ada's pattern", code: "// js", visibility: "public", createdAt: now, updatedAt: now, + }); + await db.insert(schema.decks).values({ id: "d1", userId, title: "ada's deck", createdAt: now, updatedAt: now }); + await db.insert(schema.comments).values({ id: "c1", patternId: "p1", userId, body: "first", createdAt: now }); + await db.insert(schema.patternHeaders).values({ + id: "h1", patternId: "p1", userId, codeCpp: "#pragma once\n", createdAt: now, + }); + await db.insert(schema.territories).values({ id: "t1", code: "A1", title: "Workshop", createdAt: now }); + await db.insert(schema.posts).values({ + id: "s1", territoryId: "t1", userId, title: "ada's post", body: "hello", createdAt: now, updatedAt: now, + }); + await db.insert(schema.reports).values({ + id: "r1", targetType: "pattern", targetId: "p1", reporterId: userId, reason: "other", createdAt: now, + }); +}, 30_000); + +afterAll(() => { + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[key]; + } + if (tmp) fs.rmSync(tmp, { recursive: true, force: true }); +}); + +it.each(ROUTES)("%s /api/community/%s answers a null body with a 400 the Lab can read", async ( + method, + route, + load, + params, +) => { + const handler = (await load())[method] as Handler; + const response = await handler( + new Request(`http://localhost:3000/api/community/${route}`, { + method, + headers: { "Content-Type": "application/json", Origin: LAB_ORIGIN, cookie }, + body: "null", + }), + { params: Promise.resolve(params) }, + ); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "Expected a JSON object." }); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe(LAB_ORIGIN); +}); diff --git a/web/src/app/api/community/patterns/[id]/comments/route.ts b/web/src/app/api/community/patterns/[id]/comments/route.ts index cf794febe..16bf38756 100644 --- a/web/src/app/api/community/patterns/[id]/comments/route.ts +++ b/web/src/app/api/community/patterns/[id]/comments/route.ts @@ -1,6 +1,7 @@ import { isAdminSession } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { notifyCommentAdded } from "@/lib/community/server/notify"; import { getPatternStub, newId } from "@/lib/community/server/queries"; @@ -46,14 +47,10 @@ async function handlePost(request: Request, context: { params: Promise<{ id: str return Response.json({ error: "Pattern not found." }, { status: 404 }); } - let body: unknown; - try { - body = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } + const body = await readJsonObject(request); + if (body instanceof Response) return body; - const text = cleanComment((body as Record).body); + const text = cleanComment(body.body); if (!text) { return Response.json({ error: "Comment is empty or over 2000 chars." }, { status: 400 }); } diff --git a/web/src/app/api/community/patterns/[id]/header/route.ts b/web/src/app/api/community/patterns/[id]/header/route.ts index 026df279d..ffc6b3a47 100644 --- a/web/src/app/api/community/patterns/[id]/header/route.ts +++ b/web/src/app/api/community/patterns/[id]/header/route.ts @@ -3,6 +3,7 @@ import { eq } from "drizzle-orm"; import { isAdminSession } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { bakePatternHeader } from "@/lib/community/server/moduleCache"; import { notifyPortAdded } from "@/lib/community/server/notify"; @@ -106,13 +107,8 @@ async function handlePost(request: Request, context: { params: Promise<{ id: str return Response.json({ error: "No such pattern." }, { status: 404 }); } - let body: unknown; - try { - body = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } - const raw = body as Record; + const raw = await readJsonObject(request); + if (raw instanceof Response) return raw; const codeCpp = cleanCpp(raw.codeCpp); if (!codeCpp) { diff --git a/web/src/app/api/community/patterns/[id]/performance/route.ts b/web/src/app/api/community/patterns/[id]/performance/route.ts index d903fa64d..c14c712bf 100644 --- a/web/src/app/api/community/patterns/[id]/performance/route.ts +++ b/web/src/app/api/community/patterns/[id]/performance/route.ts @@ -3,6 +3,7 @@ import { eq } from "drizzle-orm"; import { isAdminSession } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { notifyPerformanceAdded } from "@/lib/community/server/notify"; import { @@ -129,13 +130,8 @@ async function handlePost(request: Request, context: { params: Promise<{ id: str return Response.json({ error: "No such pattern." }, { status: 404 }); } - let body: unknown; - try { - body = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } - const raw = body as Record; + const raw = await readJsonObject(request); + if (raw instanceof Response) return raw; if (typeof raw.performanceJson !== "string" || raw.performanceJson.length === 0) { return Response.json({ error: "performanceJson (a Director show, as its JSON string) is required." }, { status: 400 }); diff --git a/web/src/app/api/community/patterns/[id]/route.test.ts b/web/src/app/api/community/patterns/[id]/route.test.ts new file mode 100644 index 000000000..70413c546 --- /dev/null +++ b/web/src/app/api/community/patterns/[id]/route.test.ts @@ -0,0 +1,179 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, beforeAll, expect, it } from "vitest"; + +// MIT and CC0 left the licence picker, and a pattern published under one keeps +// it. The edit route said it would refuse a change TO one of them, then looked +// the new value up in the list that still holds both, so a direct PATCH moved +// a CC BY-SA original to CC0 and rewrote the SPDX line in its source. Driven +// through the real route with real sessions, on a database of its own. + +const ENV_KEYS = ["COMMUNITY_DB_PATH", "COMMUNITY_ENABLED", "BETTER_AUTH_SECRET"] as const; +const savedEnv = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]])); +let tmp = ""; + +/** Signs somebody up and returns their user id and Cookie header. */ +let enrol: (username: string) => Promise<{ id: string; cookie: string }>; +/** Puts a pattern straight into the table, as a past publish left it. */ +let seed: (row: { id: string; userId: string; license: string; parentId?: string }) => Promise; +let edit: ( + cookie: string, + patternId: string, + body: Record, +) => Promise<{ status: number; error?: string }>; +/** The licence column and the SPDX line baked into the stored source. */ +let stored: (patternId: string) => Promise<{ license: string; spdx: string | undefined }>; + +beforeAll(async () => { + // The database is a lazy singleton keyed off these variables, so they are + // set before anything imports it. + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "pf-pattern-licence-")); + process.env.COMMUNITY_DB_PATH = path.join(tmp, "test.db"); + process.env.COMMUNITY_ENABLED = "1"; + process.env.BETTER_AUTH_SECRET = "pattern-licence-test-secret-not-a-real-one"; + + const { eq } = await import("drizzle-orm"); + const { getAuth } = await import("@/lib/community/server/auth"); + const { getDb } = await import("@/lib/community/server/db"); + const schema = await import("@/lib/community/server/schema"); + const patternRoute = await import("./route"); + + const db = getDb(); + const auth = getAuth(); + + enrol = async (username) => { + const response = await auth.api.signUpEmail({ + body: { + email: `${username}@patternflow.local`, + password: "pattern-licence-test-password", + name: username, + username, + }, + asResponse: true, + }); + const setCookie = response.headers.get("set-cookie"); + if (!setCookie) throw new Error(`no session cookie for ${username}`); + const [author] = await db + .select({ id: schema.user.id }) + .from(schema.user) + .where(eq(schema.user.username, username)) + .limit(1); + return { id: author!.id, cookie: setCookie.split(";")[0] }; + }; + + seed = async ({ id, userId, license, parentId }) => { + const now = new Date(); + await db.insert(schema.patterns).values({ + id, + userId, + title: id, + code: "function draw() {}", + license, + parentId: parentId ?? null, + visibility: "public", + createdAt: now, + updatedAt: now, + }); + }; + + // No Origin header below, which is the same-origin case originBlocked() + // lets through. + edit = async (cookie, patternId, body) => { + const response = await patternRoute.PATCH( + new Request(`http://localhost:3000/api/community/patterns/${patternId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json", cookie }, + body: JSON.stringify(body), + }), + { params: Promise.resolve({ id: patternId }) }, + ); + const payload = (await response.json()) as { error?: string }; + return { status: response.status, error: payload.error }; + }; + + stored = async (patternId) => { + const [row] = await db + .select({ license: schema.patterns.license, code: schema.patterns.code }) + .from(schema.patterns) + .where(eq(schema.patterns.id, patternId)) + .limit(1); + return { + license: row!.license, + spdx: /SPDX-License-Identifier: (\S+)/.exec(row!.code)?.[1], + }; + }; +}, 30_000); + +afterAll(() => { + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[key]; + } + if (tmp) fs.rmSync(tmp, { recursive: true, force: true }); +}); + +it("refuses to move an original pattern to MIT or CC0", async () => { + const ada = await enrol("ada"); + await seed({ id: "p-ada", userId: ada.id, license: "CC-BY-SA-4.0" }); + // One edit first, so the source carries a baked header to compare against. + expect((await edit(ada.cookie, "p-ada", { title: "Tide" })).status).toBe(200); + + for (const license of ["CC0-1.0", "MIT"]) { + const refused = await edit(ada.cookie, "p-ada", { license }); + expect(refused.status).toBe(400); + expect(refused.error).toBe( + `${license} is retired. A pattern published under it keeps it, but no pattern can change to it.`, + ); + } + expect(await stored("p-ada")).toEqual({ license: "CC-BY-SA-4.0", spdx: "CC-BY-SA-4.0" }); +}, 30_000); + +it("still moves an original between the two licences the picker offers", async () => { + const bea = await enrol("bea"); + await seed({ id: "p-bea", userId: bea.id, license: "CC-BY-SA-4.0" }); + + expect((await edit(bea.cookie, "p-bea", { license: "CC-BY-4.0" })).status).toBe(200); + expect(await stored("p-bea")).toEqual({ license: "CC-BY-4.0", spdx: "CC-BY-4.0" }); + expect((await edit(bea.cookie, "p-bea", { license: "CC-BY-SA-4.0" })).status).toBe(200); + expect(await stored("p-bea")).toEqual({ license: "CC-BY-SA-4.0", spdx: "CC-BY-SA-4.0" }); +}, 30_000); + +it("lets a pattern published under a retired licence keep it, or leave it for an offered one", async () => { + const cal = await enrol("cal"); + await seed({ id: "p-cal", userId: cal.id, license: "MIT" }); + + // The edit modal sends the current licence back with every save. + expect((await edit(cal.cookie, "p-cal", { title: "Typo fixed", license: "MIT" })).status).toBe(200); + expect(await stored("p-cal")).toEqual({ license: "MIT", spdx: "MIT" }); + + // Retired to retired is still a change to a retired licence. + expect((await edit(cal.cookie, "p-cal", { license: "CC0-1.0" })).status).toBe(400); + expect(await stored("p-cal")).toEqual({ license: "MIT", spdx: "MIT" }); + + expect((await edit(cal.cookie, "p-cal", { license: "CC-BY-SA-4.0" })).status).toBe(200); + expect(await stored("p-cal")).toEqual({ license: "CC-BY-SA-4.0", spdx: "CC-BY-SA-4.0" }); +}, 30_000); + +it("refuses CC0 on a fork whose parent puts no limit on it", async () => { + // An MIT parent lets a fork take any licence (forkLicenseAllowed), so the + // fork check alone let this one through. + const dee = await enrol("dee"); + const eli = await enrol("eli"); + await seed({ id: "p-dee", userId: dee.id, license: "MIT" }); + await seed({ id: "p-eli", userId: eli.id, license: "CC-BY-4.0", parentId: "p-dee" }); + + expect((await edit(eli.cookie, "p-eli", { license: "CC0-1.0" })).status).toBe(400); + expect((await edit(eli.cookie, "p-eli", { license: "CC-BY-SA-4.0" })).status).toBe(200); + expect((await stored("p-eli")).license).toBe("CC-BY-SA-4.0"); +}, 30_000); + +it("refuses a licence it has never offered", async () => { + const fay = await enrol("fay"); + await seed({ id: "p-fay", userId: fay.id, license: "CC-BY-SA-4.0" }); + + expect(await edit(fay.cookie, "p-fay", { license: "GPL-3.0-only" })).toEqual({ + status: 400, + error: "Unknown licence.", + }); +}, 30_000); diff --git a/web/src/app/api/community/patterns/[id]/route.ts b/web/src/app/api/community/patterns/[id]/route.ts index 482dff73c..afa177c08 100644 --- a/web/src/app/api/community/patterns/[id]/route.ts +++ b/web/src/app/api/community/patterns/[id]/route.ts @@ -8,6 +8,7 @@ import { } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { bakePatternHeader } from "@/lib/community/server/moduleCache"; import { @@ -39,7 +40,13 @@ import { cleanTitle, } from "@/lib/community/validate"; import { cleanVisibility } from "@/lib/community/visibility"; -import { KNOWN_LICENSES, forkLicenseAllowed, stripShareWrapping } from "@/lib/pattern/share"; +import { + KNOWN_LICENSES, + LICENSE_OPTIONS, + RETIRED_LICENSE_OPTIONS, + forkLicenseAllowed, + stripShareWrapping, +} from "@/lib/pattern/share"; // PATCH /api/community/patterns/[id] — the author edits their own pattern. // @@ -128,13 +135,8 @@ async function handlePatch(request: Request, context: { params: Promise<{ id: st return Response.json({ error: "You can only edit your own patterns." }, { status: 403 }); } - let body: unknown; - try { - body = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } - const raw = body as Record; + const raw = await readJsonObject(request); + if (raw instanceof Response) return raw; // A moderator's reach into someone else's pattern is its visibility or its // .h, never both at once. Every other field below simply never runs for @@ -182,11 +184,25 @@ async function handlePatch(request: Request, context: { params: Promise<{ id: st let license = pattern.license; if (raw.license !== undefined) { - // KNOWN_LICENSES, not LICENSE_OPTIONS: a pattern published under a licence - // that has since been retired must stay editable by its author. Keeping the - // stored value is always allowed; only *changing* it is restricted. - const match = KNOWN_LICENSES.find((option) => option.spdx === raw.license); - if (!match) return Response.json({ error: "Unknown licence." }, { status: 400 }); + // Keeping the stored value is always allowed, so it is looked up in + // KNOWN_LICENSES: a pattern published under a licence that has since been + // retired must stay editable by its author. Changing it picks from + // LICENSE_OPTIONS, the two the publish route and both pickers offer. A + // retired licence stays on the work that already has it, and no pattern + // moves to one. + const choosable = raw.license === pattern.license ? KNOWN_LICENSES : LICENSE_OPTIONS; + const match = choosable.find((option) => option.spdx === raw.license); + if (!match) { + const retired = RETIRED_LICENSE_OPTIONS.find((option) => option.spdx === raw.license); + return Response.json( + { + error: retired + ? `${retired.spdx} is retired. A pattern published under it keeps it, but no pattern can change to it.` + : "Unknown licence.", + }, + { status: 400 }, + ); + } if (match.spdx !== pattern.license && parent && !forkLicenseAllowed(parent.license, match.spdx)) { return Response.json( { diff --git a/web/src/app/api/community/patterns/route.ts b/web/src/app/api/community/patterns/route.ts index 4ab900e46..f66f1e60c 100644 --- a/web/src/app/api/community/patterns/route.ts +++ b/web/src/app/api/community/patterns/route.ts @@ -1,6 +1,7 @@ import { and, eq, gte } from "drizzle-orm"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { MAX_FEED_PAGE_SIZE } from "@/lib/community/feedView"; import { notifyForkPublished } from "@/lib/community/server/notify"; @@ -124,13 +125,8 @@ async function handlePost(request: Request) { return Response.json({ error: "Too many uploads — wait a minute and try again." }, { status: 429 }); } - let body: unknown; - try { - body = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } - const raw = body as Record; + const raw = await readJsonObject(request); + if (raw instanceof Response) return raw; const title = cleanTitle(raw.title); if (!title) return Response.json({ error: "Title is required (max 80 chars)." }, { status: 400 }); @@ -149,8 +145,8 @@ async function handlePost(request: Request) { if (!code) return Response.json({ error: "Pattern code is missing or over 100KB." }, { status: 400 }); // Any licence block the code arrived with belongs to someone else (a lab - // preset's header, a Gemini stamp, a previously exported file), so drop it - // before we write our own. + // preset's header, the Lab's old AI gallery stamp, a previously exported + // file), so drop it before we write our own. if (stripShareWrapping(code).length === 0) { return Response.json({ error: "Pattern code is empty once the licence header is removed." }, { status: 400 }); } diff --git a/web/src/app/api/community/ports/[id]/route.ts b/web/src/app/api/community/ports/[id]/route.ts index 63d15bc4c..a20b1727b 100644 --- a/web/src/app/api/community/ports/[id]/route.ts +++ b/web/src/app/api/community/ports/[id]/route.ts @@ -2,6 +2,7 @@ import { eq } from "drizzle-orm"; import { isAdminSession } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { bakePatternHeader } from "@/lib/community/server/moduleCache"; import { clearNotificationsFor, notifyHeaderModerated } from "@/lib/community/server/notify"; @@ -96,13 +97,8 @@ async function handlePatch(request: Request, context: { params: Promise<{ id: st const port = await getPortStub(id); if (!port) return Response.json({ error: "Port not found." }, { status: 404 }); - let body: unknown; - try { - body = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } - const raw = body as Record; + const raw = await readJsonObject(request); + if (raw instanceof Response) return raw; const codeCpp = cleanCpp(raw.codeCpp); if (codeCpp === null) { diff --git a/web/src/app/api/community/posts/[id]/comments/route.ts b/web/src/app/api/community/posts/[id]/comments/route.ts index 3c2e45ac7..e38d30af3 100644 --- a/web/src/app/api/community/posts/[id]/comments/route.ts +++ b/web/src/app/api/community/posts/[id]/comments/route.ts @@ -1,5 +1,6 @@ import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { notifyCommentAdded } from "@/lib/community/server/notify"; import { getPostStub, newId } from "@/lib/community/server/queries"; @@ -40,14 +41,10 @@ async function handlePost(request: Request, context: { params: Promise<{ id: str return Response.json({ error: "Post not found." }, { status: 404 }); } - let payload: unknown; - try { - payload = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } + const payload = await readJsonObject(request); + if (payload instanceof Response) return payload; - const text = cleanComment((payload as Record).body); + const text = cleanComment(payload.body); if (!text) { return Response.json({ error: "Comment is empty or over 2000 chars." }, { status: 400 }); } diff --git a/web/src/app/api/community/posts/[id]/pin/route.ts b/web/src/app/api/community/posts/[id]/pin/route.ts index b39f0ee18..10c216b56 100644 --- a/web/src/app/api/community/posts/[id]/pin/route.ts +++ b/web/src/app/api/community/posts/[id]/pin/route.ts @@ -2,6 +2,7 @@ import { eq, isNotNull } from "drizzle-orm"; import { isAdminSession } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { getPostStub } from "@/lib/community/server/queries"; import { posts } from "@/lib/community/server/schema"; @@ -36,13 +37,9 @@ async function handlePost(request: Request, context: { params: Promise<{ id: str const post = await getPostStub(id); if (!post) return Response.json({ error: "Post not found." }, { status: 404 }); - let body: unknown; - try { - body = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } - const pinned = (body as Record).pinned; + const body = await readJsonObject(request); + if (body instanceof Response) return body; + const pinned = body.pinned; if (typeof pinned !== "boolean") { return Response.json({ error: "Expected { pinned: true | false }." }, { status: 400 }); } diff --git a/web/src/app/api/community/posts/[id]/route.ts b/web/src/app/api/community/posts/[id]/route.ts index 5a607e70c..36ee7b0eb 100644 --- a/web/src/app/api/community/posts/[id]/route.ts +++ b/web/src/app/api/community/posts/[id]/route.ts @@ -2,6 +2,7 @@ import { eq } from "drizzle-orm"; import { isAdminSession } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { clearNotificationsFor } from "@/lib/community/server/notify"; import { getPostStub } from "@/lib/community/server/queries"; @@ -79,13 +80,8 @@ async function handlePatch(request: Request, context: { params: Promise<{ id: st return Response.json({ error: "Too many edits — wait a minute and try again." }, { status: 429 }); } - let payload: unknown; - try { - payload = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } - const raw = payload as Record; + const raw = await readJsonObject(request); + if (raw instanceof Response) return raw; // Partial update: only the fields present in the body change. const patch: { title?: string; body?: string; updatedAt: Date } = { updatedAt: new Date() }; diff --git a/web/src/app/api/community/posts/route.ts b/web/src/app/api/community/posts/route.ts index 9ce0d9f9e..45f3d970b 100644 --- a/web/src/app/api/community/posts/route.ts +++ b/web/src/app/api/community/posts/route.ts @@ -1,5 +1,6 @@ import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { cleanPinNote, cleanTerritoryCode } from "@/lib/community/workshop"; import { notifyNewThread } from "@/lib/community/server/notify"; @@ -43,14 +44,8 @@ async function handlePost(request: Request) { return Response.json({ error: "Too many posts — wait a minute and try again." }, { status: 429 }); } - let payload: unknown; - try { - payload = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } - - const raw = payload as Record; + const raw = await readJsonObject(request); + if (raw instanceof Response) return raw; const title = cleanTitle(raw.title); if (!title) { return Response.json({ error: "Title is empty or too long." }, { status: 400 }); diff --git a/web/src/app/api/community/presence/route.ts b/web/src/app/api/community/presence/route.ts index f5c26ae0c..0385ef7df 100644 --- a/web/src/app/api/community/presence/route.ts +++ b/web/src/app/api/community/presence/route.ts @@ -1,5 +1,6 @@ import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { STAGE_HEIGHT, @@ -69,12 +70,8 @@ async function handlePost(request: Request) { return Response.json({ error: "Too many moves — stand still a moment." }, { status: 429 }); } - let payload: Record; - try { - payload = (await request.json()) as Record; - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } + const payload = await readJsonObject(request); + if (payload instanceof Response) return payload; const hasPosition = payload.x !== undefined || payload.y !== undefined; const hasStatus = payload.status !== undefined; diff --git a/web/src/app/api/community/reports/[id]/route.ts b/web/src/app/api/community/reports/[id]/route.ts index b747578a4..d1d9940af 100644 --- a/web/src/app/api/community/reports/[id]/route.ts +++ b/web/src/app/api/community/reports/[id]/route.ts @@ -2,6 +2,7 @@ import { eq } from "drizzle-orm"; import { isAdminSession } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { reports } from "@/lib/community/server/schema"; @@ -34,13 +35,9 @@ async function handlePatch(request: Request, context: { params: Promise<{ id: st return Response.json({ error: "Not found." }, { status: 404 }); } - let body: unknown; - try { - body = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } - const status = (body as Record).status; + const body = await readJsonObject(request); + if (body instanceof Response) return body; + const status = body.status; if (typeof status !== "string" || !STATUSES.includes(status as never)) { return Response.json({ error: "Unknown status." }, { status: 400 }); } diff --git a/web/src/app/api/community/reports/route.ts b/web/src/app/api/community/reports/route.ts index dd2c24d0b..7f80c5666 100644 --- a/web/src/app/api/community/reports/route.ts +++ b/web/src/app/api/community/reports/route.ts @@ -1,5 +1,6 @@ import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { newId, reportTarget } from "@/lib/community/server/queries"; import { rateLimit } from "@/lib/community/ratelimit"; @@ -35,13 +36,8 @@ async function handlePost(request: Request) { return Response.json({ error: "Too many reports — wait a minute and try again." }, { status: 429 }); } - let body: unknown; - try { - body = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } - const raw = body as Record; + const raw = await readJsonObject(request); + if (raw instanceof Response) return raw; if (!isReportTargetType(raw.targetType)) { return Response.json({ error: "Unknown report target." }, { status: 400 }); diff --git a/web/src/app/api/community/territories/[code]/route.ts b/web/src/app/api/community/territories/[code]/route.ts index cec3a0083..7e554b835 100644 --- a/web/src/app/api/community/territories/[code]/route.ts +++ b/web/src/app/api/community/territories/[code]/route.ts @@ -2,6 +2,7 @@ import { eq } from "drizzle-orm"; import { isAdminSession } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { QUESTION_MAX, @@ -78,13 +79,8 @@ async function handlePatch(request: Request, context: Context) { if (resolved.error) return resolved.error; const { territory } = resolved; - let payload: unknown; - try { - payload = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } - const raw = payload as Record; + const raw = await readJsonObject(request); + if (raw instanceof Response) return raw; // Every field is optional — the editor sends what changed, and archiving // arrives as its own one-key body. diff --git a/web/src/app/api/community/territories/route.ts b/web/src/app/api/community/territories/route.ts index 231bd7b46..eb5e3b08a 100644 --- a/web/src/app/api/community/territories/route.ts +++ b/web/src/app/api/community/territories/route.ts @@ -1,6 +1,7 @@ import { isAdminSession } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { cleanQuestions, @@ -39,13 +40,8 @@ async function handlePost(request: Request) { return Response.json({ error: "Not found." }, { status: 404 }); } - let payload: unknown; - try { - payload = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } - const raw = payload as Record; + const raw = await readJsonObject(request); + if (raw instanceof Response) return raw; const code = cleanTerritoryCode(raw.code); if (!code) { diff --git a/web/src/app/build/breadboard/breadboard.module.css b/web/src/app/build/breadboard/breadboard.module.css new file mode 100644 index 000000000..e46b71b93 --- /dev/null +++ b/web/src/app/build/breadboard/breadboard.module.css @@ -0,0 +1,81 @@ +/* A step photo sits under its diagram, and on a wide screen beside the step, + in the space between the 772px column and the window's edge. */ +.photo { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; + margin-top: 16px; +} + +.photo img { + width: 100%; + height: auto; + display: block; + border-radius: 8px; + border: 1px solid var(--pf-rule); +} + +/* The photo is centred in that space and at most 360px wide. 50vw - 394px is + the space with room left for an 8px half of a desktop scrollbar. The photo + used to sit 120px out at a fixed 360px, which needs a 1,732px window: at + 1440px the page scrolled 146px sideways and the left-hand photos were cut + off where no scroll reaches. */ +@media (min-width: 1280px) { + .photoRight, + .photoLeft { + position: absolute; + top: 50%; + transform: translateY(-50%); + width: calc(50vw - 394px); + margin-top: 0; + padding: 0 32px; + align-items: center; + } + + .photoRight { + left: 100%; + } + + .photoLeft { + right: 100%; + } + + .photoRight > *, + .photoLeft > * { + width: 100%; + max-width: 360px; + } +} + +@media print { + .page { + background: #ffffff !important; + color: #000000 !important; + } + + .page main { + padding: 0 !important; + max-width: none !important; + } + + .page section { + break-inside: avoid; + page-break-inside: avoid; + } + + .page header { + display: none !important; + } + + .photoRight, + .photoLeft { + position: static; + margin-top: 16px; + transform: none; + width: 100%; + max-width: 440px; + padding: 0; + align-items: stretch; + } +} diff --git a/web/src/app/build/breadboard/layout.tsx b/web/src/app/build/breadboard/layout.tsx new file mode 100644 index 000000000..d1e867d88 --- /dev/null +++ b/web/src/app/build/breadboard/layout.tsx @@ -0,0 +1,17 @@ +import type { Metadata } from "next"; +import { SITE_OPEN_GRAPH } from "@/lib/siteMetadata"; + +// The metadata lives here from when page.tsx was a client component, which +// cannot export it. Without it the guide's browser tab and its link preview +// were the home page's. +export const metadata: Metadata = { + title: "Breadboard Build Guide / Patternflow", + description: + "Build Patternflow with no custom PCB: one snapped-off breadboard power strip and a handful of jumper wires. The guide leads with the picture, because the wiring is the hard part.", + alternates: { canonical: "/build/breadboard" }, + openGraph: { ...SITE_OPEN_GRAPH, url: "/build/breadboard" }, +}; + +export default function BreadboardLayout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/web/src/app/build/breadboard/page.tsx b/web/src/app/build/breadboard/page.tsx index bdb2b9b42..9435eb456 100644 --- a/web/src/app/build/breadboard/page.tsx +++ b/web/src/app/build/breadboard/page.tsx @@ -1,12 +1,10 @@ -'use client'; - -import React from 'react'; import Link from 'next/link'; import BreadboardDiagram from '@/components/docs/BreadboardDiagram'; +import styles from './breadboard.module.css'; export default function BreadboardBuildPage() { return ( -
+
{/* Navigation Header */}
⚡ Flash the firmware first

- Flashing is fiddly once everything is wired into a tangle of jumpers. Connect the bare ESP32-S3 to your computer by its left USB-C port (the native USB one, or the Wi-Fi step never appears), open the{' '} - Pattern tab and click “Flash Patternflow” under Got the hardware? (Chrome / Edge desktop). Confirm it boots — then start the build. + Flashing is fiddly once everything is wired into a tangle of jumpers, so do it on the bare ESP32-S3. Connect it to your computer by its left USB-C port (the native USB one, with a cable that carries data), then put it in download mode: hold BOOT, tap RST, let go of BOOT. Now open the{' '} + Pattern tab, click “Flash Patternflow” under Got the hardware? (Chrome / Edge desktop) and pick the port, listed as USB JTAG/serial debug unit. +

+

+ The Wi-Fi step usually doesn’t appear after the install, and that’s expected: press RST, click Flash Patternflow again, pick the port and choose Connect to Wi-Fi (2.4 GHz only). The dialog opening on Patternflow’s own menu is your sign it boots — then start the build.{' '} + The Play guide’s 01 Flash shows every screen, and the panel’s own network for when the Wi-Fi won’t take.

Prefer Arduino IDE? See{' '} @@ -386,11 +388,15 @@ export default function BreadboardBuildPage() {

↑ Ground (GND) pins on both sides of all four rotary encoders land on the Ground (−) rail of the breadboard.

-
+
+ {/* eslint-disable-next-line @next/next/no-img-element -- a fixed build photo, shown as is */} Encoders mounted with GND wires connected to the breadboard rail

↑ Encoders fixed, GND wires on the − rail. @@ -427,11 +433,15 @@ export default function BreadboardBuildPage() {

↑ U1 (ESP32-S3) on the left, and J1 (HUB75E) on the right. Wire the pins that share the same color-coded labels (e.g. R1, B1, A, B...).

-
+
+ {/* eslint-disable-next-line @next/next/no-img-element -- a fixed build photo, shown as is */} ESP32-S3 connected to the HUB75E LED matrix via ribbon cable

↑ ESP32-S3 with HUB75E jumpers connected. @@ -468,11 +478,15 @@ export default function BreadboardBuildPage() {

↑ Four rotary encoders on the left, and ESP32 on the right. Wire the signal channels (A, B, SW) to their matching colored tags (e.g. 1A, 2A...).

-
+
+ {/* eslint-disable-next-line @next/next/no-img-element -- a fixed build photo, shown as is */} All encoder signal wires routed routed into the case alongside the LED panel

↑ Encoder A, B, SW wires all connected. @@ -506,11 +520,15 @@ export default function BreadboardBuildPage() {

-
+
+ {/* eslint-disable-next-line @next/next/no-img-element -- a fixed build photo, shown as is */} Stripped USB and LED panel power wires being twisted onto jumpers

↑ Power leads stripped and twisted onto jumpers. @@ -549,10 +567,15 @@ export default function BreadboardBuildPage() { { src: '/builds/breadboard/step8.jpg', label: "It's alive! 🎉" } ].map((img, idx) => (

+ {/* eslint-disable-next-line @next/next/no-img-element -- a fixed build photo, shown as is */} {img.label}

{img.label} @@ -608,57 +631,6 @@ export default function BreadboardBuildPage() {

- - {/* Scoped style to support printing and avoiding split sections */} -
); } diff --git a/web/src/app/build/page.tsx b/web/src/app/build/page.tsx index 145bd2060..be5bbfe0c 100644 --- a/web/src/app/build/page.tsx +++ b/web/src/app/build/page.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import HomeView from "@/components/HomeView"; +import { SITE_OPEN_GRAPH } from "@/lib/siteMetadata"; export const metadata: Metadata = { title: "Build Your Own — Patternflow", @@ -7,6 +8,7 @@ export const metadata: Metadata = { "Build the open-source Patternflow LED synthesizer from scratch: the hand-soldered v3.9 board, a printed or laser-cut case, and firmware flashed from the browser. Every file and the full build guide.", alternates: { canonical: "/build" }, openGraph: { + ...SITE_OPEN_GRAPH, title: "Build Your Own — Patternflow", description: "Build the open-source Patternflow LED synthesizer from scratch — firmware, PCB, a printed or laser-cut case, and the browser flasher.", diff --git a/web/src/app/business/page.tsx b/web/src/app/business/page.tsx index f89de0e26..e553eb18c 100644 --- a/web/src/app/business/page.tsx +++ b/web/src/app/business/page.tsx @@ -1,5 +1,9 @@ import { redirect } from "next/navigation"; +// /business was never a page. It has sent people to /contact since the contact +// page arrived on 12 May 2026 (bbb6a21a), and that page still wears the +// business-* classes in globals.css. Nothing in the repository links /business. + export default function BusinessPage() { redirect("/contact"); } diff --git a/web/src/app/community/layout.tsx b/web/src/app/community/layout.tsx index 6e7b86002..978a12ce1 100644 --- a/web/src/app/community/layout.tsx +++ b/web/src/app/community/layout.tsx @@ -22,9 +22,16 @@ export const metadata: Metadata = { export default async function CommunityLayout({ children }: { children: React.ReactNode }) { // Community pages only run where the SQLite database lives (the self-hosted - // deployment). Everywhere else — e.g. the Vercel site — this path is just an - // old or mistyped URL, so send the visitor straight to the community host - // rather than parking them on a page whose only content is another link. + // deployment). Everywhere else, e.g. the Vercel site, this path is reached + // through links that name patternflow.work instead of the community host, + // and the community writes one into every pattern: since 22 July 2026 + // (edaa07da) each published pattern's licence footer has carried + // patternflow.work/community, and a fork's credit line links + // /community/p/ (SHARE_TOOLS in lib/pattern/share.ts). PATTERN_GUIDE.md + // and the roadmap print the address too. Send the visitor straight to the + // community host rather than parking them on a page whose only content is + // another link. Every path lands on the community's front page, a fork + // credit's included. if (!communityEnabled()) { const target = communityHomeUrl(); let targetHost: string | null = null; @@ -73,7 +80,7 @@ export default async function CommunityLayout({ children }: { children: React.Re
- + Patternflow diff --git a/web/src/app/contact/page.tsx b/web/src/app/contact/page.tsx index 7edfc9998..eada130df 100644 --- a/web/src/app/contact/page.tsx +++ b/web/src/app/contact/page.tsx @@ -1,10 +1,12 @@ import type { Metadata } from "next"; +import { SITE_OPEN_GRAPH } from "@/lib/siteMetadata"; import ContactPage from "@/components/contact/ContactPage"; export const metadata: Metadata = { title: "Contact / Patternflow", description: "Partnership, installation, distribution, and custom AV inquiries for Patternflow.", + openGraph: { ...SITE_OPEN_GRAPH, url: "/contact" }, }; export default function ContactRoute() { diff --git a/web/src/app/editions/EditionCard.tsx b/web/src/app/editions/EditionCard.tsx index a8e451191..ad450d9fb 100644 --- a/web/src/app/editions/EditionCard.tsx +++ b/web/src/app/editions/EditionCard.tsx @@ -29,15 +29,6 @@ import type { Edition } from "./editions-data"; // Core is the cheapest entry on the shelf: its images already sit under // /flash/bin with CORS headers, so its install button needs nothing new. -// Why this firmware is not simply the default. Three different answers, and -// somebody choosing needs to know which — picking up something unfinished is -// not the same as picking up something deliberately still. -const REASON_LABEL: Record, string> = { - "not-ready": "not ready for everyone", - "not-universal": "not right as a default", - frozen: "pinned and not moving", -}; - const STATUS_LABEL: Record = { available: "available", building: "in progress", diff --git a/web/src/app/features/TryInstall.tsx b/web/src/app/features/TryInstall.tsx index 60f1281c6..787c3f2cb 100644 --- a/web/src/app/features/TryInstall.tsx +++ b/web/src/app/features/TryInstall.tsx @@ -20,7 +20,7 @@ import type { TryOut } from "./features-data"; const SITE = "https://patternflow.work"; export default function TryInstall({ t }: { t: TryOut }) { - const { deviceHost, changeDeviceHost } = useDeviceHost(); + const { deviceHost, host, changeDeviceHost } = useDeviceHost(); // The panel's page fetches the image, so the URL has to be absolute. The // production origin is the server-side answer, so the first paint matches; // a preview or a dev server reads its own in on the client. A store that @@ -32,7 +32,6 @@ export default function TryInstall({ t }: { t: TryOut }) { () => SITE, ); - const host = deviceHost.trim() || "patternflow.local"; const src = new URL(t.url, origin).toString(); const href = `http://${host}/update?src=${encodeURIComponent(src)}`; diff --git a/web/src/app/features/features-data.ts b/web/src/app/features/features-data.ts index bfd7a9cdd..f618868ad 100644 --- a/web/src/app/features/features-data.ts +++ b/web/src/app/features/features-data.ts @@ -126,7 +126,7 @@ export const FEATURES: Feature[] = [ where: "In the Audio firmware.", whereHref: "/editions#audio", links: [ - { label: "Audio guide", href: blob("AUDIO_GUIDE.md") }, + { label: "Audio guide", href: blob("docs/AUDIO_GUIDE.md") }, { label: "Source", href: tree("firmware/patternflow/features/audio_in") }, ], reel: { diff --git a/web/src/app/features/page.tsx b/web/src/app/features/page.tsx index e1c54df02..ca1e00e07 100644 --- a/web/src/app/features/page.tsx +++ b/web/src/app/features/page.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import Link from "next/link"; +import { SITE_OPEN_GRAPH } from "@/lib/siteMetadata"; import shelf from "../editions/Editions.module.css"; import ShelfTabs from "../editions/ShelfTabs"; import styles from "./Features.module.css"; @@ -26,6 +27,7 @@ export const metadata: Metadata = { description: "What a Patternflow panel can do, one feature at a time: a microphone, audio from a browser or phone, OSC, MIDI, a clock, MQTT, sequences, the weather. What each needs, which firmware carries it, and a reel of it running.", alternates: { canonical: "/features" }, + openGraph: { ...SITE_OPEN_GRAPH, url: "/features" }, }; export const dynamic = "force-static"; @@ -102,7 +104,7 @@ export default function FeaturesPage() {
- + Patternflow diff --git a/web/src/app/fonts.test.ts b/web/src/app/fonts.test.ts new file mode 100644 index 000000000..ad9f50b08 --- /dev/null +++ b/web/src/app/fonts.test.ts @@ -0,0 +1,223 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { TextDecoder } from 'node:util'; +import { brotliDecompressSync } from 'node:zlib'; +import { describe, expect, it, vi } from 'vitest'; + +// Pretendard's parts against the @font-face rules in globals.css: each +// weight's ranges still cover all of Unicode, the Hangul is the 2,350 +// syllables of KS X 1001 in one file and the other 8,822 in another, and the +// common file is the one the browser tries first. Reads each WOFF2's cmap. +// Then the Google fonts the root layout loads, and which of them every page +// preloads. + +const FONTS = path.resolve(__dirname, '../../public/fonts'); +const CSS = fs.readFileSync(path.resolve(__dirname, 'globals.css'), 'utf8'); + +/** The one Hangul file a Korean page fetched before the split: Regular 593,848 bytes, Bold 617,400. */ +const COMMON_BUDGET = 170_000; + +type Range = [number, number]; + +interface Face { + weight: number; + file: string; + ranges: Range[]; +} + +function parseRanges(value: string): Range[] { + return value.split(',').map((part) => { + const [from, to] = part.trim().replace(/^U\+/i, '').split('-'); + return [parseInt(from, 16), parseInt(to ?? from, 16)]; + }); +} + +const faces: Face[] = [...CSS.matchAll(/@font-face\s*{([^}]*)}/g)] + .map((m) => m[1]) + .filter((body) => /font-family:\s*'Pretendard'/.test(body)) + .map((body) => ({ + weight: Number(/font-weight:\s*(\d+)/.exec(body)![1]), + file: /url\('\/fonts\/([^']+)'\)/.exec(body)![1], + ranges: parseRanges(/unicode-range:\s*([^;]+);/.exec(body)![1]), + })); + +const inRanges = (ranges: Range[], cp: number) => ranges.some(([a, b]) => cp >= a && cp <= b); + +/** UIntBase128 from the WOFF2 table directory. */ +function base128(buf: Buffer, at: { pos: number }): number { + let value = 0; + for (let i = 0; i < 5; i++) { + const byte = buf[at.pos++]; + value = value * 128 + (byte & 0x7f); + if (!(byte & 0x80)) return value; + } + throw new Error('UIntBase128 longer than five bytes'); +} + +/** The code points a WOFF2 maps to a glyph other than .notdef. */ +function woff2Cmap(file: string): Set { + const buf = fs.readFileSync(path.join(FONTS, file)); + expect(buf.toString('latin1', 0, 4), `${file} is not WOFF2`).toBe('wOF2'); + const numTables = buf.readUInt16BE(12); + const compressedSize = buf.readUInt32BE(20); + const at = { pos: 48 }; + let offset = 0; + let cmap: { offset: number; length: number } | null = null; + for (let i = 0; i < numTables; i++) { + const flags = buf[at.pos++]; + const known = flags & 0x3f; + const tag = known === 0x3f ? buf.toString('latin1', at.pos, (at.pos += 4)) : known; + const origLength = base128(buf, at); + const version = flags >> 6; + // glyf (10) and loca (11) are transformed at version 0; every other table at any other version. + const transformed = tag === 10 || tag === 11 ? version === 0 : version !== 0; + const length = transformed ? base128(buf, at) : origLength; + if (tag === 0) cmap = { offset, length }; + offset += length; + } + expect(cmap, `${file} has no cmap`).not.toBeNull(); + const data = brotliDecompressSync(buf.subarray(at.pos, at.pos + compressedSize)); + const table = data.subarray(cmap!.offset, cmap!.offset + cmap!.length); + + const subtables = new Map(); + for (let i = 0; i < table.readUInt16BE(2); i++) { + const rec = 4 + i * 8; + subtables.set(`${table.readUInt16BE(rec)},${table.readUInt16BE(rec + 2)}`, table.readUInt32BE(rec + 4)); + } + const cps = new Set(); + const full = subtables.get('3,10') ?? subtables.get('0,4'); + if (full !== undefined) { + expect(table.readUInt16BE(full), `${file}: full-repertoire cmap is not format 12`).toBe(12); + const groups = table.readUInt32BE(full + 12); + for (let g = 0; g < groups; g++) { + const rec = full + 16 + g * 12; + const start = table.readUInt32BE(rec); + const end = table.readUInt32BE(rec + 4); + const glyph = table.readUInt32BE(rec + 8); + for (let cp = start; cp <= end; cp++) if (glyph + cp - start !== 0) cps.add(cp); + } + return cps; + } + const bmp = subtables.get('3,1') ?? subtables.get('0,3'); + expect(bmp, `${file} has no Unicode cmap`).toBeDefined(); + expect(table.readUInt16BE(bmp!), `${file}: BMP cmap is not format 4`).toBe(4); + const segs = table.readUInt16BE(bmp! + 6) / 2; + const ends = bmp! + 14; + const starts = ends + segs * 2 + 2; + const deltas = starts + segs * 2; + const rangeOffsets = deltas + segs * 2; + for (let s = 0; s < segs; s++) { + const end = table.readUInt16BE(ends + s * 2); + const start = table.readUInt16BE(starts + s * 2); + const delta = table.readInt16BE(deltas + s * 2); + const rangeOffset = table.readUInt16BE(rangeOffsets + s * 2); + for (let cp = start; cp <= end; cp++) { + let glyph: number; + if (rangeOffset === 0) glyph = (cp + delta) & 0xffff; + else { + glyph = table.readUInt16BE(rangeOffsets + s * 2 + rangeOffset + (cp - start) * 2); + if (glyph !== 0) glyph = (glyph + delta) & 0xffff; + } + if (glyph !== 0) cps.add(cp); + } + } + return cps; +} + +/** The 2,350 Hangul syllables of KS X 1001: EUC-KR lead bytes B0–C8, trail bytes A1–FE. */ +function ksx1001Syllables(): Set { + const decoder = new TextDecoder('euc-kr'); + const out = new Set(); + for (let lead = 0xb0; lead <= 0xc8; lead++) { + for (let trail = 0xa1; trail <= 0xfe; trail++) { + const cp = decoder.decode(Uint8Array.of(lead, trail)).codePointAt(0)!; + if (cp >= 0xac00 && cp <= 0xd7a3) out.add(cp); + } + } + return out; +} + +const isSyllable = (cp: number) => cp >= 0xac00 && cp <= 0xd7a3; + +describe.each([ + [400, 'Regular'], + [700, 'Bold'], +] as const)('Pretendard %i', (weight, name) => { + const parts = faces.filter((face) => face.weight === weight); + const common = `Pretendard-${name}.hangul.woff2`; + const rare = `Pretendard-${name}.hangul-rare.woff2`; + + it('covers all of Unicode between its parts, as the whole file did', () => { + const ranges = parts.flatMap((face) => face.ranges).sort((x, y) => x[0] - y[0]); + let next = 0; + for (const [a, b] of ranges) { + expect(a, `nothing covers U+${next.toString(16).toUpperCase()}`).toBeLessThanOrEqual(next); + next = Math.max(next, b + 1); + } + expect(next).toBe(0x110000); + }); + + it('writes hangul-rare before hangul, so the browser tries hangul first', () => { + const files = parts.map((face) => face.file); + expect(files).toContain(common); + expect(files).toContain(rare); + expect(files.indexOf(rare)).toBeLessThan(files.indexOf(common)); + for (const file of [common, rare]) { + const face = parts.find((p) => p.file === file)!; + expect(inRanges(face.ranges, 0xac00) && inRanges(face.ranges, 0xd7a3), `${file} claims the syllable block`).toBe(true); + } + }); + + it('keeps each glyph of each part inside that part\'s unicode-range', () => { + for (const face of parts) { + const outside = [...woff2Cmap(face.file)].filter((cp) => !inRanges(face.ranges, cp)); + expect(outside.map((cp) => `U+${cp.toString(16).toUpperCase()}`), face.file).toEqual([]); + } + }); + + it('puts the jamo and the 2,350 syllables of KS X 1001 in hangul, and the other 8,822 in hangul-rare', () => { + const ks = ksx1001Syllables(); + expect(ks.size).toBe(2350); + const commonCps = woff2Cmap(common); + const rareCps = woff2Cmap(rare); + expect([...commonCps].filter(isSyllable).sort()).toEqual([...ks].sort()); + // Hangul Compatibility Jamo, ㄱ to ㅣ, as typed alone. + for (let cp = 0x3131; cp <= 0x3163; cp++) expect(commonCps.has(cp)).toBe(true); + expect([...rareCps].every(isSyllable)).toBe(true); + expect(rareCps.size).toBe(11172 - 2350); + expect([...rareCps].filter((cp) => ks.has(cp))).toEqual([]); + }); + + it('keeps a Korean page\'s Hangul under a quarter of the file it replaced', () => { + expect(fs.statSync(path.join(FONTS, common)).size).toBeLessThan(COMMON_BUDGET); + }); +}); + +// Each loader call the root layout makes, with its options. A font added there +// without a line here fails the import: the mock has no export for it. +const googleFonts = vi.hoisted(() => [] as { name: string; preload?: boolean }[]); +vi.mock('next/font/google', () => { + const loader = (name: string) => (options: { preload?: boolean }) => { + googleFonts.push({ name, preload: options.preload }); + return { variable: '', className: '' }; + }; + return { + Inter: loader('Inter'), + JetBrains_Mono: loader('JetBrains_Mono'), + Silkscreen: loader('Silkscreen'), + Newsreader: loader('Newsreader'), + }; +}); + +describe('the root layout\'s Google fonts', () => { + // Next puts a preload link for each font the root layout loads in the head + // of every page. On 22 October DM Sans, which no text used, and Newsreader + // and Silkscreen, which only the journal, /terms and the guide use, were + // 166,368 bytes on / and on /pattern-lab that no letter there was set in. + it('preloads only Inter and JetBrains Mono, the two every page sets text in', async () => { + await import('@/app/layout'); + const preloaded = googleFonts.filter((font) => font.preload !== false).map((font) => font.name); + expect(preloaded.sort()).toEqual(['Inter', 'JetBrains_Mono']); + expect(googleFonts.map((font) => font.name).sort()).toEqual(['Inter', 'JetBrains_Mono', 'Newsreader', 'Silkscreen']); + }); +}); diff --git a/web/src/app/globals.css b/web/src/app/globals.css index a1360c8c2..9c2339379 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -1,13 +1,32 @@ @import "tailwindcss"; - /* Pretendard, each weight in four parts. The whole file is 770–790 KB, most + /* Pretendard, each weight in five parts. The whole file is 770–790 KB, most of it Hangul, and a page fetched all of it for one glyph Inter lacks — an English page for the arrow in a link. The parts are the same glyphs, outlines and metrics, cut by script (pyftsubset on the files beside them); their ranges cover all of Unicode between them, so whatever the whole file drew, one part still draws. A page now fetches only the parts it has characters for: an arrow costs 38 KB, and the Hangul comes with - the first Korean word. */ + the first Korean word. + + The Hangul is two parts. hangul holds the jamo and the 2,350 syllables + of KS X 1001 in 158–161 KB; on 21 October 2026 those were every + syllable in web/src and web/content. hangul-rare holds the other 8,822 + in 463–484 KB. Both claim the syllable block, and hangul-rare is + written first on purpose: where two faces' ranges overlap, the browser + tries the one written last and moves to the earlier one only for a + syllable it has no glyph for. A Korean page fetches hangul alone, and + hangul-rare waits for a name like 똠 or 뷁. Listing the 2,350 syllables + in unicode-range instead takes 1,613 runs, 29 KB of CSS on every page, + English ones included. + + Cut with fonttools 4.67 from the files beside them: + pyftsubset Pretendard-Regular.woff2 --unicodes-file= + --layout-features='*' --name-IDs='*' --flavor=woff2 + where the hangul list is U+1100-11FF, U+3130-318F, U+A960-A97F, + U+D7B0-D7FF and every syllable Python's euc_kr codec decodes from + lead bytes B0-C8, trail bytes A1-FE; the hangul-rare list is the rest + of U+AC00-D7A3. src/app/fonts.test.ts holds the files to that split. */ @font-face { font-family: 'Pretendard'; font-weight: 400; @@ -16,6 +35,14 @@ src: url('/fonts/Pretendard-Regular.latin.woff2') format('woff2'); unicode-range: U+0000-052F, U+1E00-1EFF; } + @font-face { + font-family: 'Pretendard'; + font-weight: 400; + font-style: normal; + font-display: swap; + src: url('/fonts/Pretendard-Regular.hangul-rare.woff2') format('woff2'); + unicode-range: U+AC00-D7A3; + } @font-face { font-family: 'Pretendard'; font-weight: 400; @@ -48,6 +75,14 @@ src: url('/fonts/Pretendard-Bold.latin.woff2') format('woff2'); unicode-range: U+0000-052F, U+1E00-1EFF; } + @font-face { + font-family: 'Pretendard'; + font-weight: 700; + font-style: normal; + font-display: swap; + src: url('/fonts/Pretendard-Bold.hangul-rare.woff2') format('woff2'); + unicode-range: U+AC00-D7A3; + } @font-face { font-family: 'Pretendard'; font-weight: 700; @@ -203,6 +238,16 @@ .viewer-hint { top: 40px; } + /* Where the 3D view would be, in a browser that cannot draw it (ViewerPanel). */ + .viewer-off { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + text-align: center; + } @media (max-width: 900px) { .app-layout { flex-direction: column; } @@ -369,6 +414,31 @@ border-color: var(--ink); text-decoration: none; } + /* Stop and Play for the hero video, in its bottom-right corner. Dark with a + light rule, so it shows on the poster and on the video. 40px square, for a + finger on a phone. The focus ring stays inside the frame, which clips + anything past its edge. */ + .hero-video-toggle { + position: absolute; + right: 10px; + bottom: 10px; + width: 40px; + height: 40px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid rgba(244, 239, 230, 0.55); + background: rgba(20, 20, 20, 0.62); + color: var(--cream); + cursor: pointer; + transition: background-color 0.15s ease; + } + .hero-video-toggle:hover { background: rgba(20, 20, 20, 0.85); } + .hero-video-toggle:focus-visible { + outline: 2px solid var(--pf-led); + outline-offset: 2px; + } /* At laptop height the column — wordmark, video, the two facts, the buttons — can be taller than the window with the full 80px above it, and the buttons' note falls below the fold. Take the difference from the top. The nav is out @@ -580,7 +650,7 @@ .panel-header { margin-bottom: 40px; } - .panel-header h2 { + .panel-header h1 { font-weight: 500; font-size: 64px; line-height: 1; @@ -793,7 +863,7 @@ @media (max-width: 900px) { .tab-bar { padding: 20px 24px 16px; gap: 20px; } .deck-content { padding: 0 24px 60px; } - .panel-header h2 { font-size: 42px; } + .panel-header h1 { font-size: 42px; } .panel-header .sub { font-size: 16px; } .panel-body p { font-size: 17px; } .panel-content { padding: 40px 0; min-height: auto; } diff --git a/web/src/app/homeLinkPrefetch.test.ts b/web/src/app/homeLinkPrefetch.test.ts new file mode 100644 index 000000000..754175524 --- /dev/null +++ b/web/src/app/homeLinkPrefetch.test.ts @@ -0,0 +1,69 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +// A in view prefetches the home page, and the home page's +// client chunks come with it: three.js (237,537 bytes gzip) and the +// post-processing chunk (212,365). At 390px in Chromium on 22 October, +// /terms, /contact and a 404 fetched 767,286 bytes of script with the +// prefetch and 148,456 without, and /community 987,765 and 408,515. With +// prefetch={false} the home page loads when the link is followed. Pattern +// Lab (579,250 bytes) and the guide (107,109) still prefetch it, as do the +// breadboard guide, journal, editions and compliance. They are left out +// below until they are done the same way. + +const SRC = path.resolve(__dirname, '..'); +const LEFT_OUT = [ + 'components/guide', + 'app/build/breadboard', + 'app/pattern-lab', + 'components/journal', + 'app/editions', + 'app/compliance', +]; + +function sources(dir: string): string[] { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) return sources(full); + return entry.name.endsWith('.tsx') && !entry.name.includes('.test.') ? [full] : []; + }); +} + +/** Each `` opening tag, read to the `>` outside any braces. */ +function linkTags(source: string): string[] { + const tags: string[] = []; + for (const m of source.matchAll(/' && depth === 0) break; + } + tags.push(source.slice(m.index, end + 1)); + } + return tags; +} + +const homeLinks = sources(SRC).flatMap((file) => { + const rel = path.relative(SRC, file).split(path.sep).join('/'); + return linkTags(fs.readFileSync(file, 'utf8')) + .filter((tag) => /\bhref="\/"/.test(tag)) + .map((tag) => ({ rel, tag })); +}); + +describe('links to the home page', () => { + it('are still found', () => { + expect(homeLinks.map((l) => l.rel)).toContain('app/not-found.tsx'); + }); + + it('skip the prefetch outside the pages left out', () => { + const prefetching = homeLinks + .filter(({ rel }) => !LEFT_OUT.some((dir) => rel.startsWith(`${dir}/`))) + .filter(({ tag }) => !/\bprefetch=\{false\}/.test(tag)) + .map(({ rel }) => rel); + expect(prefetching).toEqual([]); + }); +}); diff --git a/web/src/app/hoverUnderline.test.ts b/web/src/app/hoverUnderline.test.ts new file mode 100644 index 000000000..d227d6a25 --- /dev/null +++ b/web/src/app/hoverUnderline.test.ts @@ -0,0 +1,75 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +// globals.css underlines every link on hover with `a:hover`, which is (0,1,1). +// A CSS module class that says `text-decoration: none` is (0,1,0) and loses +// to it, so a link drawn as a button or a card grew an underline when pointed +// at: 453 links on 16 pages in Chromium on 21 October, and the guide's +// "Stuck? Tell us" drew one under the line it already draws. Each such class +// says it again in a `:hover` rule. Community (31 classes), editions and +// compliance still lose. They are left out below until they are done the +// same way. + +const SRC = path.resolve(__dirname, '..'); +const LEFT_OUT = ['components/community', 'app/community', 'app/editions', 'app/compliance', 'app/journal']; + +function modules(dir: string): string[] { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) return modules(full); + return entry.name.endsWith('.module.css') ? [full] : []; + }); +} + +interface Rule { + selectors: string[]; + body: string; +} + +/** Every rule with no braces inside it; an @media block's rules come out on their own. */ +function rules(css: string): Rule[] { + const bare = css.replace(/\/\*[\s\S]*?\*\//g, ''); + return [...bare.matchAll(/([^{}]+)\{([^{}]*)\}/g)].map((m) => ({ + selectors: m[1].split(',').map((s) => s.trim()), + body: m[2], + })); +} + +const NO_LINE = /text-decoration(-line)?:\s*none/; + +/** Classes that turn the line off on their own, with no :hover rule saying anything about it. */ +function losing(css: string): string[] { + const all = rules(css); + const off = new Set( + all.flatMap((r) => (NO_LINE.test(r.body) ? r.selectors.filter((s) => /^\.[\w-]+$/.test(s)) : [])), + ); + return [...off].filter((cls) => { + const own = all.filter((r) => r.selectors.includes(cls)); + // A class that takes no pointer is never hovered (update's .goDisabled). + if (own.some((r) => /pointer-events:\s*none/.test(r.body))) return false; + return !all.some((r) => r.selectors.includes(`${cls}:hover`) && /text-decoration/.test(r.body)); + }); +} + +describe('hover underline', () => { + it('is still the global rule the classes have to beat', () => { + const globals = fs.readFileSync(path.join(SRC, 'app/globals.css'), 'utf8'); + expect(globals).toMatch(/\n\s*a:hover \{ text-decoration: underline;/); + }); + + it('stays off on hover for every class that turns it off', () => { + const files = modules(SRC).filter((f) => !LEFT_OUT.some((dir) => f.includes(`${path.sep}${dir}${path.sep}`))); + const found = files.flatMap((f) => + losing(fs.readFileSync(f, 'utf8')).map((cls) => `${path.relative(SRC, f)} ${cls}`), + ); + expect(found).toEqual([]); + }); + + it('finds the class the rule was written for', () => { + expect(losing('.card { text-decoration: none; }\n.card:hover { color: red; }')).toEqual(['.card']); + expect(losing('.card { text-decoration: none; }\n.card:hover { text-decoration: none; }')).toEqual([]); + expect(losing('@media (x) {\n .card { text-decoration: none; }\n}')).toEqual(['.card']); + expect(losing('.row a { text-decoration: none; }')).toEqual([]); + }); +}); diff --git a/web/src/app/inside/[build]/page.tsx b/web/src/app/inside/[build]/page.tsx index 1e35b4074..1159c7634 100644 --- a/web/src/app/inside/[build]/page.tsx +++ b/web/src/app/inside/[build]/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; import HomeView from "@/components/HomeView"; import { builds, buildBySlug } from "@/components/sections/InsideGlobe/builds"; +import { SITE_OPEN_GRAPH } from "@/lib/siteMetadata"; const SITE_URL = "https://patternflow.work"; @@ -26,10 +27,12 @@ export async function generateMetadata({ params }: RouteParams): Promise {children} diff --git a/web/src/app/metadata.test.ts b/web/src/app/metadata.test.ts new file mode 100644 index 000000000..6fcba2c2c --- /dev/null +++ b/web/src/app/metadata.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Metadata } from "next"; +import { SITE_OPEN_GRAPH } from "@/lib/siteMetadata"; +import { guideMetadata } from "@/components/guide/meta"; +import { PAGES, pagePath } from "@/components/guide/pages"; +import { builds } from "@/components/sections/InsideGlobe/builds"; +import type { GuideScreen } from "@/components/guide/store"; + +// A link pasted into a chat shows the page's own title, address and a +// picture. Next merges metadata one key deep, so the root layout's openGraph +// and twitter decide what every page without its own carries, and a page that +// sets openGraph keeps the picture only by spreading SITE_OPEN_GRAPH in. + +// The pages render nothing here: only their metadata is read. +vi.mock("@/components/HomeView", () => ({ default: () => null })); +vi.mock("next/font/google", () => { + const font = () => ({ variable: "", className: "" }); + return { Inter: font, JetBrains_Mono: font, Silkscreen: font, Newsreader: font }; +}); + +type OpenGraph = NonNullable; + +function urlOf(og: OpenGraph): string { + return String(og.url ?? ""); +} + +describe("site metadata", () => { + it("leaves each page's title and address to the page, in the root layout", async () => { + const { metadata } = await import("@/app/layout"); + const og = metadata.openGraph as OpenGraph; + expect(og).toEqual(SITE_OPEN_GRAPH); + expect(og).not.toHaveProperty("title"); + expect(og).not.toHaveProperty("url"); + // Next copies a page's og:title into twitter:title only when no layout + // set one. + expect(metadata.twitter).toEqual({ card: "summary_large_image" }); + }); + + it.each([ + ["/", () => import("@/app/page")], + ["/pattern", () => import("@/app/pattern/page")], + ["/build", () => import("@/app/build/page")], + ["/build/breadboard", () => import("@/app/build/breadboard/layout")], + ["/inside", () => import("@/app/inside/page")], + ["/features", () => import("@/app/features/page")], + ["/contact", () => import("@/app/contact/page")], + ["/terms", () => import("@/app/terms/page")], + ["/update", () => import("@/app/update/page")], + ] as const)("%s previews with its own address and the site's picture", async (path, load) => { + const { metadata } = (await load()) as { metadata: Metadata }; + const og = metadata.openGraph as OpenGraph; + expect(og.images).toEqual(SITE_OPEN_GRAPH.images); + expect(urlOf(og).replace("https://patternflow.work", "") || "/").toBe(path); + }); + + it("gives /build/breadboard a title of its own", async () => { + const { metadata } = await import("@/app/build/breadboard/layout"); + expect(metadata.title).toBe("Breadboard Build Guide / Patternflow"); + }); + + it("gives every guide page, in both languages, the site's picture", () => { + const screens: GuideScreen[] = ["hub", ...(Object.keys(PAGES) as GuideScreen[])]; + for (const page of screens) { + for (const lang of ["en", "ko"] as const) { + const og = guideMetadata(page, lang).openGraph as OpenGraph; + expect(og.images).toEqual(SITE_OPEN_GRAPH.images); + expect(urlOf(og)).toBe(pagePath(page, lang)); + } + } + }); + + it("previews a build on the map with its photo, or the site's picture without one", async () => { + const { generateMetadata } = await import("@/app/inside/[build]/page"); + const ogOf = async (slug: string) => + (await generateMetadata({ params: Promise.resolve({ build: slug }) })).openGraph as OpenGraph; + + const withPhoto = builds.find((b) => b.images?.length); + expect(withPhoto).toBeDefined(); + expect(await ogOf(withPhoto!.slug)).toMatchObject({ + images: [{ url: `https://patternflow.work${withPhoto!.images![0].src}` }], + url: `https://patternflow.work/inside/${withPhoto!.slug}`, + }); + + const withoutPhoto = builds.find((b) => !b.images?.length); + if (withoutPhoto) { + expect((await ogOf(withoutPhoto.slug)).images).toEqual(SITE_OPEN_GRAPH.images); + } + }); +}); diff --git a/web/src/app/not-found.tsx b/web/src/app/not-found.tsx index 58245a15d..98b580300 100644 --- a/web/src/app/not-found.tsx +++ b/web/src/app/not-found.tsx @@ -4,7 +4,7 @@ export default function NotFound() { return (

404

- Return to main + Return to main
); } diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index 717bc65f0..d27199bd8 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -1,4 +1,23 @@ +import type { Metadata } from "next"; import HomeView from "@/components/HomeView"; +import { SITE_OPEN_GRAPH } from "@/lib/siteMetadata"; + +// The and description are the root layout's. The preview is here, +// not there, so that a page without one does not borrow the home page's. +export const metadata: Metadata = { + openGraph: { + ...SITE_OPEN_GRAPH, + url: "https://patternflow.work", + title: "Patternflow — An LED synthesizer", + description: + "An open-source LED synthesizer. Play light patterns with your fingertips — and every Patternflow plays every pattern we make.", + }, + twitter: { + card: "summary_large_image", + description: + "Patternflow is an open-source LED synthesizer. Play light with your fingertips.", + }, +}; export default function Home() { return <HomeView initialTab="hero" />; diff --git a/web/src/app/pattern-lab/HardwareModal.test.tsx b/web/src/app/pattern-lab/HardwareModal.test.tsx new file mode 100644 index 000000000..eb4db6989 --- /dev/null +++ b/web/src/app/pattern-lab/HardwareModal.test.tsx @@ -0,0 +1,169 @@ +// The To hardware modal opens Send and Publish inside its own backdrop. A +// press on their backdrop closes them, and before the fix the same click went +// on up to this backdrop and closed To hardware as well. The header the model +// translated lives only in this modal's state, so it went with it. +// +// Esc goes the same way. Send and Publish listen for no keys, so this modal +// hears the Esc meant for them, and it closes the one on top. +// +// The modal is mounted the way the lab mounts it, behind an open flag that +// onClose clears, so a stray close really unmounts it and the pasted header +// is really gone. + +import { useState } from "react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import HardwareModal from "./HardwareModal"; +import { useLabStore } from "@/lib/lab/store"; + +vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }) })); +vi.mock("@/lib/community/auth-client", () => ({ + authClient: { + useSession: () => ({ data: { user: { id: "u1", username: "ada" } }, isPending: false }), + }, +})); + +const HEADER = "#pragma once\nnamespace Lab { void draw() {} }\n"; +const CODE = "export function draw(display) { display.setPixel(0, 0, 255, 0, 0); }"; + +function Lab() { + const [open, setOpen] = useState(true); + return open ? ( + <HardwareModal onClose={() => setOpen(false)} exportCode={async () => CODE} /> + ) : ( + <p>closed</p> + ); +} + +function hardware() { + return screen.getByRole("dialog", { name: "Hardware" }); +} + +/** Paste a header, press Next, and land on the "On to hardware" stage. */ +function pasteHeaderAndContinue() { + fireEvent.change(screen.getByPlaceholderText(/Paste the \.h the model gives you/), { + target: { value: HEADER }, + }); + fireEvent.click(screen.getByText("Next →")); + expect(screen.getByText("On to hardware")).toBeTruthy(); +} + +/** The header is still in the modal: back on the first stage, in the box. */ +function expectHeaderKept() { + expect(hardware()).toBeTruthy(); + fireEvent.click(screen.getByText("← Back to the header")); + expect(screen.getByPlaceholderText(/Paste the \.h the model gives you/)).toHaveValue(HEADER); +} + +beforeEach(() => { + useLabStore.getState().discardProject(); + vi.stubEnv("NEXT_PUBLIC_BUILD_ENABLED", "1"); + vi.stubEnv("NEXT_PUBLIC_COMMUNITY_URL", "https://patternflow.work"); + // Nothing here should reach the network; a request that does stays unanswered. + vi.stubGlobal("fetch", vi.fn(() => new Promise<Response>(() => {}))); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); +}); + +describe("HardwareModal", () => { + it("a press on the Send modal's backdrop closes Send and keeps the header", () => { + render(<Lab />); + pasteHeaderAndContinue(); + fireEvent.click(screen.getByText("↗ Apply to my Patternflow")); + const send = screen.getByText("Send to my Patternflow").closest('[role="dialog"]')!; + + fireEvent.click(send); + + expect(screen.queryByText("Send to my Patternflow")).toBeNull(); + expectHeaderKept(); + }); + + it("a press on the Publish modal's backdrop closes Publish and keeps the header", async () => { + render(<Lab />); + pasteHeaderAndContinue(); + fireEvent.click(screen.getByText("Upload to the community")); + const title = await screen.findByText("Share to Community"); + const publish = title.closest('[role="dialog"]')!; + + fireEvent.click(publish); + + await waitFor(() => expect(screen.queryByText("Share to Community")).toBeNull()); + expectHeaderKept(); + }); + + it("a press inside the card leaves the modal open", () => { + render(<Lab />); + fireEvent.click(screen.getByText("Convert to a firmware header")); + expect(hardware()).toBeTruthy(); + }); + + it("a press on its own backdrop still closes it", () => { + render(<Lab />); + fireEvent.click(hardware()); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(screen.getByText("closed")).toBeTruthy(); + }); + + it("opens with the focus inside it", () => { + render(<Lab />); + expect(hardware().contains(document.activeElement)).toBe(true); + }); + + it("Esc closes it", () => { + render(<Lab />); + fireEvent.keyDown(document.activeElement!, { key: "Escape" }); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(screen.getByText("closed")).toBeTruthy(); + }); + + it("Esc over the Send modal closes Send alone and keeps the header", () => { + render(<Lab />); + pasteHeaderAndContinue(); + fireEvent.click(screen.getByText("↗ Apply to my Patternflow")); + expect(screen.getByText("Send to my Patternflow")).toBeTruthy(); + + fireEvent.keyDown(document.activeElement!, { key: "Escape" }); + + expect(screen.queryByText("Send to my Patternflow")).toBeNull(); + expect(hardware().contains(document.activeElement)).toBe(true); + expectHeaderKept(); + }); + + // The prompt goes to a model by paste. When the browser refuses the write, + // the label used to stay as it was, and the paste carried whatever the + // clipboard held before. + it("says Copy failed on the button whose copy the browser refused", async () => { + const writeText = vi.fn<(text: string) => Promise<void>>(() => + Promise.reject(new DOMException("Write permission denied.", "NotAllowedError")), + ); + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } }); + try { + render(<Lab />); + fireEvent.click(screen.getByText("Copy the conversion prompt")); + expect(await screen.findByText("Copy failed")).toBeTruthy(); + expect(screen.getByText("Copy .h")).toBeTruthy(); + + writeText.mockImplementation(() => Promise.resolve()); + fireEvent.click(screen.getByText("Copy failed")); + expect(await screen.findByText("Copied ✓")).toBeTruthy(); + expect(writeText).toHaveBeenCalledTimes(2); + } finally { + delete (navigator as { clipboard?: unknown }).clipboard; + } + }); + + it("Esc over the Publish modal closes Publish alone and keeps the header", async () => { + render(<Lab />); + pasteHeaderAndContinue(); + fireEvent.click(screen.getByText("Upload to the community")); + await screen.findByText("Share to Community"); + + fireEvent.keyDown(document.activeElement!, { key: "Escape" }); + + await waitFor(() => expect(screen.queryByText("Share to Community")).toBeNull()); + expectHeaderKept(); + }); +}); diff --git a/web/src/app/pattern-lab/HardwareModal.tsx b/web/src/app/pattern-lab/HardwareModal.tsx index a2c35af07..dda5ada6a 100644 --- a/web/src/app/pattern-lab/HardwareModal.tsx +++ b/web/src/app/pattern-lab/HardwareModal.tsx @@ -1,7 +1,9 @@ "use client"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState, type MouseEvent } from "react"; import { PublishModal, SendModuleModal, buildsConfigured, communityConfigured } from "./community"; +import { useDialog } from "@/lib/hooks/useDialog"; +import { useResettingState } from "@/lib/hooks/useResettingState"; import { assembleH, buildHExport, cleanPastedUnit, enforcePatternName } from "@/lib/lab/hExport"; import { buildCppPrompt } from "@/lib/lab/cppPrompt"; import { currentPerformanceJson } from "@/lib/lab/director/publish"; @@ -47,7 +49,7 @@ export default function HardwareModal({ const [name, setName] = useState(() => labPatternName(useLabStore.getState())); const [pastes, setPastes] = useState<Record<number, string>>({}); const [single, setSingle] = useState(""); - const [copiedKey, setCopiedKey] = useState<string | null>(null); + const [copied, flashCopied] = useResettingState<{ key: string; ok: boolean } | null>(null, 1200); const [stage, setStage] = useState<Stage>("convert"); const [sendOpen, setSendOpen] = useState(false); @@ -105,19 +107,20 @@ export default function HardwareModal({ return cleanPastedUnit(pasted, index) !== null; }; - const flashCopied = (key: string) => { - setCopiedKey(key); - window.setTimeout(() => setCopiedKey((current) => (current === key ? null : current)), 1200); - }; const copyText = async (key: string, text: string) => { try { await navigator.clipboard.writeText(text); - flashCopied(key); + flashCopied({ key, ok: true }); } catch { - /* clipboard may be blocked */ + // The browser refused the write. Saying nothing left the label as it + // was, and whatever the clipboard held before went to the model. + flashCopied({ key, ok: false }); } }; + const copyLabel = (key: string, idle: string) => + copied?.key === key ? (copied.ok ? "Copied ✓" : "Copy failed") : idle; + const downloadH = () => { const blob = new Blob([assembled], { type: "text/plain" }); const url = URL.createObjectURL(blob); @@ -132,15 +135,44 @@ export default function HardwareModal({ const label = stacked ? name : codeLayers[0]?.name ?? "Pattern"; + // Only a press on this backdrop itself closes. Send and Publish open inside + // it, so a press on their backdrop bubbles up here too, and the header + // pasted above lives in this component's state and would go with it. + const closeOnBackdrop = (event: MouseEvent<HTMLDivElement>) => { + if (event.target === event.currentTarget) onClose(); + }; + + // Esc closes the window on top. Send and Publish open over this one and + // hear no keys of their own, so the first Esc is theirs and the header + // stays. + const cardRef = useDialog<HTMLDivElement>(() => { + if (sendOpen) setSendOpen(false); + else if (publishCode !== null) setPublishCode(null); + else onClose(); + }); + + // Their buttons go with them, and the focus would fall to the page behind. + const childOpen = sendOpen || publishCode !== null; + useEffect(() => { + if (!childOpen && !cardRef.current?.contains(document.activeElement)) { + cardRef.current?.focus({ preventScroll: true }); + } + }, [childOpen, cardRef]); + return ( <div className={styles.modalOverlay} role="dialog" aria-modal="true" aria-label="Hardware" - onClick={onClose} + onClick={closeOnBackdrop} > - <div className={styles.modalCard} onClick={(event) => event.stopPropagation()}> + <div + ref={cardRef} + tabIndex={-1} + className={styles.modalCard} + onClick={(event) => event.stopPropagation()} + > <div className={styles.modalHeader}> <strong>{stage === "convert" ? "Convert to a firmware header" : "On to hardware"}</strong> <button type="button" onClick={onClose} aria-label="Close"> @@ -190,7 +222,7 @@ export default function HardwareModal({ type="button" onClick={() => void copyText(`u${unit.index}`, unit.prompt)} > - {copiedKey === `u${unit.index}` ? "Copied ✓" : "Copy prompt"} + {copyLabel(`u${unit.index}`, "Copy prompt")} </button> </div> <textarea @@ -210,7 +242,7 @@ export default function HardwareModal({ <> <div className={styles.variantActions}> <button type="button" onClick={() => void copyText("single", singlePrompt)}> - {copiedKey === "single" ? "Copied ✓" : "Copy the conversion prompt"} + {copyLabel("single", "Copy the conversion prompt")} </button> </div> <textarea @@ -233,7 +265,7 @@ export default function HardwareModal({ <div className={styles.variantActions} style={{ marginTop: 10, flexWrap: "wrap" }}> <button type="button" onClick={() => void copyText("h", assembled)}> - {copiedKey === "h" ? "Copied ✓" : "Copy .h"} + {copyLabel("h", "Copy .h")} </button> <button type="button" onClick={downloadH} disabled={!headerLooksReal}> Download .h diff --git a/web/src/app/pattern-lab/LabPanels.module.css b/web/src/app/pattern-lab/LabPanels.module.css index ee8db2bd1..c3564186e 100644 --- a/web/src/app/pattern-lab/LabPanels.module.css +++ b/web/src/app/pattern-lab/LabPanels.module.css @@ -117,6 +117,35 @@ color: rgba(23, 21, 18, 0.4); } +/* Below 800px the action row starts at the left edge. A menu hung from its + button's right edge then opens off the left of the screen, and one hung + from the button's left edge runs off the right once Share and To hardware + sit in front of Panels. Both menus take their left edge from the row + instead, at most 360px wide, which covers every button in it. `top: auto` + keeps each one just under its own button when Community wraps to a second + line. */ +@media (max-width: 800px) { + .dockHeaderGroup:last-child { + position: relative; + } + + .menuWrap { + position: static; + } + + .menuPop { + top: auto; + left: 0; + right: 0; + max-width: 360px; + margin-top: 6px; + } + + .menuPop button { + min-height: 44px; + } +} + /* ── Generic panel chrome ── */ .panel { diff --git a/web/src/app/pattern-lab/PatternLab.module.css b/web/src/app/pattern-lab/PatternLab.module.css index 46c3c2faf..d7c66f102 100644 --- a/web/src/app/pattern-lab/PatternLab.module.css +++ b/web/src/app/pattern-lab/PatternLab.module.css @@ -12,6 +12,7 @@ .labBrand:hover { opacity: 0.8; + text-decoration: none; } .labTitle { @@ -106,8 +107,7 @@ } .headerMeta, -.stats, -.emptyState { +.stats { color: rgba(23, 21, 18, 0.62); font-family: var(--font-jetbrains), monospace; font-size: 12px; @@ -327,11 +327,6 @@ font-weight: 700; } -.emptyState { - margin: 0; - padding: 18px 14px; -} - .editorHeader button:disabled { opacity: 0.5; cursor: default; @@ -363,62 +358,6 @@ color: #f7f3ea !important; } -.editorHeader .keyButton { - background: transparent !important; - color: rgba(23, 21, 18, 0.62) !important; - border-color: rgba(23, 21, 18, 0.22) !important; - padding: 0 10px; - font-size: var(--pf-fs-mono); -} - -.editorHeader .keyButton:hover { - color: #171512 !important; - border-color: #171512 !important; -} - -.editorHeader .genField { - display: flex; - align-items: center; - gap: 6px; - color: rgba(23, 21, 18, 0.62); - font-family: var(--font-jetbrains), monospace; - font-size: var(--pf-fs-mono); - text-transform: uppercase; -} - -.editorHeader .genField input { - width: 56px; - min-height: 38px; - border: 1px solid rgba(23, 21, 18, 0.3); - background: #ffffff; - color: #171512; - padding: 0 8px; - font-family: var(--font-jetbrains), monospace; - font-size: 12px; -} - -.editorHeader .genThinking { - min-height: 38px; - border: 1px solid rgba(23, 21, 18, 0.3); - background: #ffffff; - color: #171512; - padding: 0 8px; - font-family: var(--font-jetbrains), monospace; - font-size: 12px; - text-transform: uppercase; - cursor: pointer; -} - -@keyframes jobPulse { - 0%, - 100% { - opacity: 1; - } - 50% { - opacity: 0.3; - } -} - .variantActions { display: flex; gap: 8px; @@ -465,6 +404,12 @@ color: #171512; } +/* The card takes the focus when it opens, so the keyboard starts inside it. + It is not a control, and a ring around the whole window says nothing. */ +.modalCard:focus { + outline: none; +} + .modalHeader { display: flex; align-items: center; @@ -495,6 +440,17 @@ border-color: #171512; } +/* On a phone the × box grows from 30px to 44px, the size of the community's + close buttons. The negative margin gives the extra 14px back to the + header's padding, so the header keeps its height and the × its place. */ +@media (max-width: 800px) { + .modalHeader button { + min-width: 44px; + min-height: 44px; + margin: -7px; + } +} + .modalBody { padding: 16px; font-size: 14px; diff --git a/web/src/app/pattern-lab/PatternLabClient.tsx b/web/src/app/pattern-lab/PatternLabClient.tsx index ebbd83eea..3a534b4ff 100644 --- a/web/src/app/pattern-lab/PatternLabClient.tsx +++ b/web/src/app/pattern-lab/PatternLabClient.tsx @@ -3,11 +3,11 @@ // Pattern Lab v2 — a layered, dockable pattern editing workspace. // // The old lab was one pattern + one fixed two-column layout. This shell hosts -// nine dockable panels (dockview) over a layer-stack project (zustand): -// Preview, Layers, Code, Pixel, Gallery, Knobs, Color Ramp, Graphic Export, -// Director — the list is panels/registry.tsx. Panels rearrange -// Photoshop-style — drag tabs, split groups, float — and the arrangement -// persists to localStorage alongside the project itself. +// eight dockable panels (dockview) over a layer-stack project (zustand): +// Preview, Layers, Code, Pixel, Knobs, Color Ramp, Graphic Export, Director — +// the list is panels/registry.tsx. Panels rearrange Photoshop-style — drag +// tabs, split groups, float — and the arrangement persists to localStorage +// alongside the project itself. // // Exports (community publish, firmware build) flatten the visible stack into // one standalone JS pattern (see lib/lab/flatten.ts), so every downstream @@ -20,6 +20,7 @@ import type { DockviewTheme } from "dockview"; import "dockview/dist/styles/dockview.css"; import "./lab-dock.css"; +import { useDismiss } from "@/lib/hooks/useDismiss"; import { withMatrixAnnotation } from "@/lib/pattern/matrix"; import { codeUsesValueField, withRampAnnotation } from "@/lib/pattern/ramp"; import { @@ -32,15 +33,15 @@ import { withKnobsAnnotation } from "@/lib/lab/annotations"; import { currentPerformanceJson } from "@/lib/lab/director/publish"; import { onEditorReveal } from "@/lib/lab/editorReveal"; import { flattenLayers, needsFlatten } from "@/lib/lab/flatten"; -import { readStorage, removeStorage, writeStorage } from "@/lib/lab/persist"; -import { LAYOUT_STORAGE, layoutViewCount } from "@/lib/lab/serialize"; +import { readStorage, removeStorage, writeStorage, type LabStorageKey } from "@/lib/lab/persist"; +import { LAYOUT_NARROW_STORAGE, LAYOUT_STORAGE, dropRetiredPanels, layoutViewCount } from "@/lib/lab/serialize"; import { listSessions, type SessionMeta } from "@/lib/lab/sessions"; import { buildStackAnnotation, stripStackAnnotation } from "@/lib/lab/stackShare"; import { flushLabProject, saveLabProjectNow, useLabStore } from "@/lib/lab/store"; import type { CodeLayer } from "@/lib/lab/types"; import HardwareModal from "./HardwareModal"; import { openIncomingPattern } from "./openIncomingPattern"; -import { PANELS, buildDefaultLayout, panelComponents, panelDef, type PanelId } from "./panels/registry"; +import { PANELS, buildDefaultLayout, isNarrowDock, panelComponents, panelDef, type PanelId } from "./panels/registry"; import styles from "./PatternLab.module.css"; import dock from "./LabPanels.module.css"; @@ -141,8 +142,17 @@ export default function PatternLabClient() { const [sharePerfJson, setSharePerfJson] = useState<string | null>(null); const [hardwareOpen, setHardwareOpen] = useState(false); const [workError, setWorkError] = useState<string | null>(null); + // A header menu closes when the mouse leaves it, which a finger never does + // and a keyboard cannot. So a press outside closes it too, and so do the + // keys (useDismiss). + const recentRef = useDismiss<HTMLDivElement>(recentOpen, () => setRecentOpen(false)); + const panelsMenuRef = useDismiss<HTMLDivElement>(panelsMenuOpen, () => setPanelsMenuOpen(false)); const apiRef = useRef<DockviewApi | null>(null); const layoutSaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null); + // Where this dock's layout is saved, chosen with the layout itself: a dock + // built narrow keeps saving as narrow after the window widens, until Reset + // layout builds it again for the width it has then. + const layoutKey = useRef<LabStorageKey>(LAYOUT_STORAGE); const hydrate = useLabStore((state) => state.hydrate); const restoredAt = useLabStore((state) => state.restoredAt); @@ -234,12 +244,16 @@ export default function PatternLabClient() { (event: DockviewReadyEvent) => { const api = event.api; apiRef.current = api; + const narrow = isNarrowDock(); + layoutKey.current = narrow ? LAYOUT_NARROW_STORAGE : LAYOUT_STORAGE; let restored = false; try { - const raw = readStorage(LAYOUT_STORAGE); + const raw = readStorage(layoutKey.current); if (raw) { - const parsed = JSON.parse(raw); + // A panel the registry no longer has (Gallery, 23 October 2026) + // comes out first; fromJSON would throw on it. + const parsed = dropRetiredPanels(JSON.parse(raw), new Set(Object.keys(panelComponents))); // Restoring a layout that places nothing gives you an empty // workspace: every panel gone, and Reset layout the only way out. // dockview will happily do it, so the check has to happen here. @@ -255,12 +269,12 @@ export default function PatternLabClient() { // Drop the unusable one rather than leaving it to be rejected again on // every future load — onDidLayoutChange is subscribed below, so // nothing overwrites it until the first panel is moved. - removeStorage(LAYOUT_STORAGE); + removeStorage(layoutKey.current); try { // Whatever a failed fromJSON managed to add is still in there, and // building the default on top of it collides on panel ids. api.clear(); - buildDefaultLayout(api); + buildDefaultLayout(api, narrow); } catch { // A half-built layout is still usable; panels can be opened manually. } @@ -287,7 +301,7 @@ export default function PatternLabClient() { // taken then is a layout that restores as nothing at all. It is // never a state worth remembering, so it is never written. if (layoutViewCount(next) === 0) return; - writeStorage(LAYOUT_STORAGE, JSON.stringify(next)); + writeStorage(layoutKey.current, JSON.stringify(next)); } catch { // Quota/private mode — layout just won't persist. } @@ -312,9 +326,11 @@ export default function PatternLabClient() { const resetLayout = () => { const api = apiRef.current; if (!api) return; - removeStorage(LAYOUT_STORAGE); + const narrow = isNarrowDock(); + layoutKey.current = narrow ? LAYOUT_NARROW_STORAGE : LAYOUT_STORAGE; + removeStorage(layoutKey.current); api.clear(); - buildDefaultLayout(api); + buildDefaultLayout(api, narrow); syncOpenPanels(api); }; @@ -407,7 +423,7 @@ export default function PatternLabClient() { )} {/* Recent works. The list is only read when the menu opens, so nothing polls localStorage while you are editing. */} - <div className={dock.menuWrap}> + <div className={dock.menuWrap} ref={recentRef}> <button type="button" className={styles.headerToggle} @@ -467,7 +483,7 @@ export default function PatternLabClient() { </div> )} </div> - <div className={dock.menuWrap}> + <div className={dock.menuWrap} ref={panelsMenuRef}> <button type="button" className={styles.headerToggle} @@ -482,15 +498,16 @@ export default function PatternLabClient() { <button key={def.id} type="button" + aria-pressed={openPanels.has(def.id)} onClick={() => togglePanel(def.id, def.title)} > {def.title} - <em>{openPanels.has(def.id) ? "●" : "○"}</em> + <em aria-hidden="true">{openPanels.has(def.id) ? "●" : "○"}</em> </button> ))} <button type="button" onClick={resetLayout}> Reset layout - <em>⟲</em> + <em aria-hidden="true">⟲</em> </button> </div> )} diff --git a/web/src/app/pattern-lab/labCss.test.ts b/web/src/app/pattern-lab/labCss.test.ts new file mode 100644 index 000000000..59681be70 --- /dev/null +++ b/web/src/app/pattern-lab/labCss.test.ts @@ -0,0 +1,81 @@ +// jsdom applies none of the CSS modules, so the narrow rules below are read +// from the files. In Chromium, with Share and To hardware in the header, the +// Panels menu opened at x −106~84 on a 320px screen. Hung from its button's +// left edge instead, it ran to x 449 on a 390px one. Hung from the action row +// it stays inside, 14~374 at 390px. The Code guide × measured 30×30px before +// and 44×44px after, in a header 59px tall both times. + +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { NARROW_DOCK_QUERY } from "./panels/registry"; + +const css = (name: string) => readFileSync(path.resolve(__dirname, name), "utf8"); + +/** The bodies of every `@media <query>` block in the file, braces matched. */ +function mediaBlocks(source: string, query: string): string[] { + const blocks: string[] = []; + const head = `@media ${query} {`; + let at = source.indexOf(head); + while (at > -1) { + const start = at + head.length; + let depth = 1; + let i = start; + while (depth > 0 && i < source.length) { + if (source[i] === "{") depth += 1; + else if (source[i] === "}") depth -= 1; + i += 1; + } + blocks.push(source.slice(start, i - 1)); + at = source.indexOf(head, i); + } + return blocks; +} + +/** The declarations of every `selector` rule inside a narrow-dock block. */ +function narrowRule(source: string, selector: string): string { + const found: string[] = []; + for (const block of mediaBlocks(source, NARROW_DOCK_QUERY)) { + const at = block.indexOf(`\n ${selector} {`); + if (at > -1) found.push(block.slice(block.indexOf("{", at) + 1, block.indexOf("}", at))); + } + return found.join(""); +} + +describe("Lab header menus below 800px", () => { + const dock = css("LabPanels.module.css"); + + it("hang from the action row, not from their button", () => { + expect(narrowRule(dock, ".dockHeaderGroup:last-child")).toMatch(/position: relative;/); + expect(narrowRule(dock, ".menuWrap")).toMatch(/position: static;/); + const pop = narrowRule(dock, ".menuPop"); + expect(pop).toMatch(/top: auto;/); + expect(pop).toMatch(/left: 0;/); + expect(pop).toMatch(/max-width: 360px;/); + }); + + it("come after the rules they override", () => { + // Same specificity, so the later rule wins: a narrow block above + // `.menuWrap { position: relative }` would change nothing. + const base = dock.indexOf("\n.menuWrap {"); + const narrow = dock.lastIndexOf(`@media ${NARROW_DOCK_QUERY} {`); + expect(base).toBeGreaterThan(-1); + expect(narrow).toBeGreaterThan(base); + }); + + it("give each row a 44px height", () => { + expect(narrowRule(dock, ".menuPop button")).toMatch(/min-height: 44px;/); + }); +}); + +describe("Lab dialogs below 800px", () => { + it("give the header's close button a 44px box and keep the header's height", () => { + const lab = css("PatternLab.module.css"); + const button = narrowRule(lab, ".modalHeader button"); + expect(button).toMatch(/min-width: 44px;/); + expect(button).toMatch(/min-height: 44px;/); + // 44 - 30 = 14px, half of it on each side. + expect(button).toMatch(/margin: -7px;/); + expect(lab.lastIndexOf(`@media ${NARROW_DOCK_QUERY} {`)).toBeGreaterThan(lab.indexOf("\n.modalHeader button {")); + }); +}); diff --git a/web/src/app/pattern-lab/panels/CapturePanel.tsx b/web/src/app/pattern-lab/panels/CapturePanel.tsx index a38f416cf..a9cacab1a 100644 --- a/web/src/app/pattern-lab/panels/CapturePanel.tsx +++ b/web/src/app/pattern-lab/panels/CapturePanel.tsx @@ -15,6 +15,7 @@ // take, warmed a couple of seconds in so patterns never export cold. import { useCallback, useEffect, useRef, useState } from "react"; +import { useResettingState } from "@/lib/hooks/useResettingState"; import { useLabStore } from "@/lib/lab/store"; import { isCodeLayer } from "@/lib/lab/types"; import { buildAnySizePrompt } from "@/lib/lab/capture/anySizePrompt"; @@ -111,8 +112,8 @@ export default function CapturePanel() { const [exporting, setExporting] = useState<ExportState | null>(null); const [message, setMessage] = useState<{ text: string; error: boolean } | null>(null); const [fatal, setFatal] = useState<string | null>(null); - const [promptCopied, setPromptCopied] = useState(false); - const [shaderPromptCopied, setShaderPromptCopied] = useState(false); + const [promptCopied, setPromptCopied] = useResettingState(false, 1200); + const [shaderPromptCopied, setShaderPromptCopied] = useResettingState(false, 1200); // ── the shader twin ── // Source lives per code layer in the capture module's own storage; the panel @@ -323,7 +324,6 @@ export default function CapturePanel() { try { await navigator.clipboard.writeText(buildAnySizePrompt(layer.code, matrix)); setPromptCopied(true); - window.setTimeout(() => setPromptCopied(false), 1200); setMessage({ text: `Prompt for "${layer.name}" copied — paste it into any AI, then paste the code it returns into the Code panel. Auto re-checks the new code by itself.`, error: false, @@ -345,7 +345,6 @@ export default function CapturePanel() { buildShaderPrompt(layer.code, matrix, layer.ramp, state.ranges), ); setShaderPromptCopied(true); - window.setTimeout(() => setShaderPromptCopied(false), 1200); setMessage({ text: `GLSL prompt for "${layer.name}" copied — paste it into any AI, then paste the shader it returns into the box below. The pattern itself is left alone.`, error: false, diff --git a/web/src/app/pattern-lab/panels/CodeGuideModal.tsx b/web/src/app/pattern-lab/panels/CodeGuideModal.tsx index b27baada8..3bf5aaa87 100644 --- a/web/src/app/pattern-lab/panels/CodeGuideModal.tsx +++ b/web/src/app/pattern-lab/panels/CodeGuideModal.tsx @@ -2,9 +2,11 @@ // The pattern-code reference modal, unchanged from the single-pattern lab. +import { useDialog } from "@/lib/hooks/useDialog"; import styles from "../PatternLab.module.css"; export default function CodeGuideModal({ onClose }: { onClose: () => void }) { + const cardRef = useDialog<HTMLDivElement>(onClose); return ( <div className={styles.modalOverlay} @@ -13,7 +15,12 @@ export default function CodeGuideModal({ onClose }: { onClose: () => void }) { aria-label="Code guide" onClick={onClose} > - <div className={styles.modalCard} onClick={(event) => event.stopPropagation()}> + <div + ref={cardRef} + tabIndex={-1} + className={styles.modalCard} + onClick={(event) => event.stopPropagation()} + > <div className={styles.modalHeader}> <span>Code guide — encoder buttons</span> <button type="button" onClick={onClose} aria-label="Close"> diff --git a/web/src/app/pattern-lab/panels/CodePanel.module.css b/web/src/app/pattern-lab/panels/CodePanel.module.css index 3fe1d50b2..ec8deb95d 100644 --- a/web/src/app/pattern-lab/panels/CodePanel.module.css +++ b/web/src/app/pattern-lab/panels/CodePanel.module.css @@ -19,3 +19,11 @@ button.promptButton { background: #eee7d8; font-weight: 600; } + +/* The colour mode is what the copied prompt asks for, so on a narrow bar the + two wrap to the next row together. */ +.promptGroup { + display: inline-flex; + align-items: center; + gap: 6px; +} diff --git a/web/src/app/pattern-lab/panels/CodePanel.test.tsx b/web/src/app/pattern-lab/panels/CodePanel.test.tsx new file mode 100644 index 000000000..30931f673 --- /dev/null +++ b/web/src/app/pattern-lab/panels/CodePanel.test.tsx @@ -0,0 +1,100 @@ +// "Copy prompt" in the Code panel. A browser can refuse the clipboard write +// (no permission, a page served over plain http). The button used to keep its +// label and leave an unhandled rejection behind, so the next paste into the +// model carried whatever the clipboard held before. + +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import CodePanel from "./CodePanel"; +import { useLabStore } from "@/lib/lab/store"; + +vi.mock("@monaco-editor/react", () => ({ default: () => <textarea aria-label="Code" /> })); + +function setClipboard(value: unknown) { + Object.defineProperty(navigator, "clipboard", { configurable: true, value }); +} + +beforeEach(() => { + useLabStore.getState().discardProject(); +}); + +afterEach(() => { + delete (navigator as { clipboard?: unknown }).clipboard; +}); + +describe("CodePanel copy prompt", () => { + it("says Copied once the prompt is on the clipboard", async () => { + const writeText = vi.fn<(text: string) => Promise<void>>(() => Promise.resolve()); + setClipboard({ writeText }); + render(<CodePanel />); + + fireEvent.click(screen.getByText("Copy prompt")); + await act(async () => {}); + + expect(writeText).toHaveBeenCalledTimes(1); + expect(screen.getByText("Copied")).toBeTruthy(); + }); + + it("says Copy failed when the browser refuses the write", async () => { + const writeText = vi.fn<(text: string) => Promise<void>>(() => + Promise.reject(new DOMException("Write permission denied.", "NotAllowedError")), + ); + setClipboard({ writeText }); + render(<CodePanel />); + + fireEvent.click(screen.getByText("Copy prompt")); + await act(async () => {}); + + expect(screen.getByText("Copy failed")).toBeTruthy(); + expect(screen.queryByText("Copied")).toBeNull(); + }); + + it("says Copy failed on a page with no clipboard at all", async () => { + render(<CodePanel />); + + fireEvent.click(screen.getByText("Copy prompt")); + await act(async () => {}); + + expect(screen.getByText("Copy failed")).toBeTruthy(); + }); + + it("goes back to Copy prompt after a moment", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + try { + setClipboard({ writeText: () => Promise.reject(new Error("denied")) }); + render(<CodePanel />); + + fireEvent.click(screen.getByText("Copy prompt")); + await act(async () => {}); + expect(screen.getByText("Copy failed")).toBeTruthy(); + + act(() => vi.advanceTimersByTime(1200)); + expect(screen.getByText("Copy prompt")).toBeTruthy(); + } finally { + vi.useRealTimers(); + } + }); +}); + +// The colour mode used to be picked in the Gallery tab, which went on +// 23 October 2026. It sits beside Copy prompt now, and the prompt follows it. +describe("CodePanel colour mode", () => { + it("copies a v-field prompt until rgb is picked, then an rgb one", async () => { + const writeText = vi.fn<(text: string) => Promise<void>>(() => Promise.resolve()); + setClipboard({ writeText }); + render(<CodePanel />); + + const mode = screen.getByRole("combobox", { name: "Color mode" }) as HTMLSelectElement; + expect(mode.value).toBe("vfield"); + fireEvent.click(screen.getByText("Copy prompt")); + await act(async () => {}); + expect(writeText.mock.calls[0][0]).toContain("display.setValue(x, y, v)"); + + fireEvent.change(mode, { target: { value: "rgb" } }); + expect(useLabStore.getState().gen).toEqual({ colorMode: "rgb" }); + fireEvent.click(screen.getByText("Copied")); + await act(async () => {}); + expect(writeText.mock.calls[1][0]).toContain("display.setPixel(x, y, r, g, b)"); + expect(writeText.mock.calls[1][0]).not.toContain("Do NOT call display.setPixel"); + }); +}); diff --git a/web/src/app/pattern-lab/panels/CodePanel.tsx b/web/src/app/pattern-lab/panels/CodePanel.tsx index 10f5b8887..695a4e314 100644 --- a/web/src/app/pattern-lab/panels/CodePanel.tsx +++ b/web/src/app/pattern-lab/panels/CodePanel.tsx @@ -2,12 +2,14 @@ // Monaco editor bound to the ACTIVE code layer. The toolbar keeps the // single-pattern workflow: preset stepper, paste/clear, the AI variation -// prompt, and the code guide. +// prompt with the colour model it asks for, and the code guide. import Editor from "@monaco-editor/react"; import { useState } from "react"; import { livePresets } from "@/lib/presets"; -import { buildVariantCopyPrompt } from "@/lib/ai/gemini"; +import { COLOR_MODES, type ColorMode } from "@/lib/ai/settings"; +import { buildVariantCopyPrompt } from "@/lib/ai/variantPrompt"; +import { useResettingState } from "@/lib/hooks/useResettingState"; import { hasStackAnnotation, importCodeIntoLab } from "@/lib/lab/stackShare"; import { useActiveLayer, useLabStore } from "@/lib/lab/store"; import { isCodeLayer } from "@/lib/lab/types"; @@ -24,6 +26,7 @@ export default function CodePanel() { const knobs = useLabStore((state) => state.knobs); const ranges = useLabStore((state) => state.ranges); const gen = useLabStore((state) => state.gen); + const setGen = useLabStore((state) => state.setGen); const updateLayerCode = useLabStore((state) => state.updateLayerCode); const applyCodeToActive = useLabStore((state) => state.applyCodeToActive); const addCodeLayer = useLabStore((state) => state.addCodeLayer); @@ -31,9 +34,9 @@ export default function CodePanel() { layer ? (state.layerErrors[layer.id] ?? null) : null, ); - const [promptCopied, setPromptCopied] = useState(false); - const [pasted, setPasted] = useState(false); - const [cleared, setCleared] = useState(false); + const [promptCopy, flashPromptCopy] = useResettingState<"idle" | "copied" | "failed">("idle", 1200); + const [pasted, setPasted] = useResettingState(false, 1200); + const [cleared, setCleared] = useResettingState(false, 1200); const [guideOpen, setGuideOpen] = useState(false); if (!isCodeLayer(layer)) { @@ -60,12 +63,17 @@ export default function CodePanel() { applyCodeToActive(labPresets[next].code); }; + // A browser can refuse the write (no permission, a page served over plain + // http). The button says so, the way Pixel's "Copy as code" does. const copyVariantPrompt = async () => { - await navigator.clipboard.writeText( - buildVariantCopyPrompt(layer.code, knobs, ranges, gen.colorMode, matrix), - ); - setPromptCopied(true); - window.setTimeout(() => setPromptCopied(false), 1200); + try { + await navigator.clipboard.writeText( + buildVariantCopyPrompt(layer.code, knobs, ranges, gen.colorMode, matrix), + ); + flashPromptCopy("copied"); + } catch { + flashPromptCopy("failed"); + } }; // Pasted code carrying a @stack line is a whole shared composition — @@ -77,7 +85,6 @@ export default function CodePanel() { applyCodeToActive(text); } setPasted(true); - window.setTimeout(() => setPasted(false), 1200); }; const pasteFromClipboard = async () => { @@ -129,20 +136,33 @@ export default function CodePanel() { onClick={() => { updateLayerCode(layer.id, ""); setCleared(true); - window.setTimeout(() => setCleared(false), 1200); }} title="Clear this layer's code" > {cleared ? "Cleared ✓" : "Clear"} </button> - <button - type="button" - className={local.promptButton} - onClick={copyVariantPrompt} - title="Copy the AI variation prompt for this layer (ChatGPT / Claude / Gemini)" - > - {promptCopied ? "Copied" : "Copy prompt"} - </button> + <span className={local.promptGroup}> + <select + value={gen.colorMode} + aria-label="Color mode" + title="What Copy prompt asks for. v-field: the AI writes a 0..1 value field and the layer's Color Ramp does the coloring. rgb: the AI colors pixels itself." + onChange={(event) => setGen({ colorMode: event.target.value as ColorMode })} + > + {COLOR_MODES.map((option) => ( + <option key={option} value={option}> + {option === "vfield" ? "v-field" : "rgb"} + </option> + ))} + </select> + <button + type="button" + className={local.promptButton} + onClick={copyVariantPrompt} + title="Copy the AI variation prompt for this layer (ChatGPT / Claude / Gemini)" + > + {promptCopy === "copied" ? "Copied" : promptCopy === "failed" ? "Copy failed" : "Copy prompt"} + </button> + </span> <button type="button" onClick={() => setGuideOpen(true)}> Code guide </button> diff --git a/web/src/app/pattern-lab/panels/GalleryPanel.module.css b/web/src/app/pattern-lab/panels/GalleryPanel.module.css deleted file mode 100644 index 8ad0f2623..000000000 --- a/web/src/app/pattern-lab/panels/GalleryPanel.module.css +++ /dev/null @@ -1,314 +0,0 @@ -/* ── GalleryPanel: its own classes ───────────────────────────────────────── - Moved out of PatternLab.module.css on 2026-09; only this file uses them. */ - -.galleryPane { - height: 100%; - overflow-y: auto; -} - -.queue { - border-bottom: 1px solid rgba(23, 21, 18, 0.18); -} - -.queueHeader { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - padding: 10px 14px; - color: rgba(23, 21, 18, 0.62); - font-family: var(--font-jetbrains), monospace; - font-size: 12px; - text-transform: uppercase; -} - -.queueHeader button { - min-height: 28px; - border: 1px solid rgba(23, 21, 18, 0.22); - background: #ffffff; - color: #171512; - padding: 0 10px; - font: inherit; - font-family: var(--font-jetbrains), monospace; - font-size: var(--pf-fs-mono); - text-transform: uppercase; - cursor: pointer; -} - -.queueHeader button:hover { - border-color: #171512; -} - -.queueList { - margin: 0; - padding: 0; - list-style: none; -} - -.jobRow { - display: flex; - align-items: center; - gap: 10px; - padding: 8px 14px; - border-top: 1px solid rgba(23, 21, 18, 0.1); - font-family: var(--font-jetbrains), monospace; - font-size: 12px; -} - -.jobRow input[type="checkbox"] { - flex: 0 0 auto; - accent-color: #171512; -} - -.jobDot { - flex: 0 0 auto; - width: 8px; - height: 8px; - border-radius: 50%; - background: rgba(23, 21, 18, 0.3); -} - -.jobRow[data-status="running"] .jobDot { - background: #8a5d00; - animation: jobPulse 1s ease-in-out infinite; -} - -.jobRow[data-status="done"] .jobDot { - background: #177246; -} - -.jobRow[data-status="error"] .jobDot { - background: #b42318; -} - -.jobLabel { - display: flex; - align-items: center; - gap: 8px; - white-space: nowrap; -} - -.jobLabel em { - font-style: normal; - color: rgba(23, 21, 18, 0.5); - font-size: 10px; - text-transform: uppercase; -} - -.jobError { - flex: 1 1 auto; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - color: #b42318; - font-size: var(--pf-fs-mono); -} - -.jobTime { - margin-left: auto; - color: rgba(23, 21, 18, 0.55); - font-variant-numeric: tabular-nums; -} - -.galleryToolbar { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 8px; - padding: 8px 14px; - border-bottom: 1px solid rgba(23, 21, 18, 0.14); -} - -.galleryToolbar button { - min-height: 28px; - border: 1px solid rgba(23, 21, 18, 0.22); - background: #ffffff; - color: #171512; - padding: 0 10px; - font: inherit; - font-family: var(--font-jetbrains), monospace; - font-size: var(--pf-fs-mono); - text-transform: uppercase; - cursor: pointer; -} - -.galleryToolbar button:hover { - border-color: #171512; -} - -.galleryToolbar button:disabled { - opacity: 0.4; - cursor: default; -} - -.galleryToolbar .toolbarActive { - background: #171512; - color: #f7f3ea; - border-color: #171512; -} - -.toolbarHint { - margin-left: auto; - color: rgba(23, 21, 18, 0.55); - font-family: var(--font-jetbrains), monospace; - font-size: var(--pf-fs-mono); - text-transform: uppercase; -} - -.deleteButton { - border-color: rgba(180, 35, 24, 0.5) !important; - color: #b42318 !important; -} - -.deleteButton:hover:not(:disabled) { - border-color: #b42318 !important; - background: #fff4f2 !important; -} - -.galleryGrid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); - gap: 0; - margin: 0; - padding: 0; - list-style: none; -} - -.galleryGrid li { - border-right: 1px solid rgba(23, 21, 18, 0.14); - border-bottom: 1px solid rgba(23, 21, 18, 0.14); -} - -.variantCardSelected { - background: #e8dcc2; -} - -.variantCardSelected:hover { - background: #e1d2b2; -} - -.variantCardSelected .variantFrame { - outline: 2px solid #171512; - outline-offset: 1px; -} - -.selectBadge { - position: absolute; - top: 6px; - right: 6px; - z-index: 2; - display: flex; - align-items: center; - justify-content: center; - width: 18px; - height: 18px; - background: #171512; - color: #f7f3ea; - font-size: var(--pf-fs-mono); - line-height: 1; -} - -.pinBadge { - position: absolute; - top: 6px; - left: 6px; - z-index: 2; - padding: 2px 5px; - background: #8a5d00; - color: #fffdf8; - font-family: var(--font-jetbrains), monospace; - font-size: 9px; - letter-spacing: 0.06em; - line-height: 1; -} - -.variantCard { - display: grid; - gap: 8px; - width: 100%; - border: none; - background: transparent; - padding: 10px; - font: inherit; - color: inherit; - text-align: left; - cursor: pointer; -} - -.variantCard:hover { - background: #f4eede; -} - -.variantCardActive { - background: #e8dcc2; -} - -.variantCardActive:hover { - background: #e1d2b2; -} - -.variantFrame { - position: relative; - aspect-ratio: 1 / 2; - width: 100%; - background: #09090b; - border-radius: 6px; - overflow: hidden; - border: 1px solid rgba(23, 21, 18, 0.2); -} - -.variantFrame canvas { - display: block; - width: 100%; - height: 100%; - image-rendering: pixelated; -} - -.variantError { - position: absolute; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - overflow: auto; - padding: 6px; - background: rgba(180, 35, 24, 0.92); - color: #fff; - font-family: var(--font-jetbrains), monospace; - font-size: 10px; - line-height: 1.4; - text-align: center; -} - -.variantMeta { - display: grid; - gap: 2px; -} - -.variantMeta strong { - font-size: 12px; -} - -.variantMeta span { - color: rgba(23, 21, 18, 0.62); - font-family: var(--font-jetbrains), monospace; - font-size: 10px; - line-height: 1.4; -} - -.keyField { - width: 100%; - min-height: 38px; - border: 1px solid rgba(23, 21, 18, 0.3); - background: #ffffff; - color: #171512; - padding: 0 10px; - font-family: var(--font-jetbrains), monospace; - font-size: 12px; -} - -.keyField:focus { - outline: 1px solid #171512; - outline-offset: 1px; -} diff --git a/web/src/app/pattern-lab/panels/GalleryPanel.tsx b/web/src/app/pattern-lab/panels/GalleryPanel.tsx deleted file mode 100644 index ed7437571..000000000 --- a/web/src/app/pattern-lab/panels/GalleryPanel.tsx +++ /dev/null @@ -1,697 +0,0 @@ -"use client"; - -// AI generation gallery. Generations seed from the focused code layer; cards -// render live previews driven by that layer's current knobs and ramp. Clicking -// a card loads it into the active code layer; the ⧉ button stacks it as a new -// layer instead — the layered-lab way to build up a composition. - -import { useEffect, useRef, useState } from "react"; -import { - PatternRuntime, - createIdleInput, - knobTargetToDelta, -} from "@/lib/pattern/harness"; -import { - knobUnitsPerTurn, - LOGICAL_KNOB_WRAP, -} from "@/lib/pattern/controls"; -import { DEFAULT_MATRIX, parseMatrixAnnotation } from "@/lib/pattern/matrix"; -import { - COLOR_MODES, - GEMINI_MODEL, - THINKING_LEVELS, - generatePatternVariants, - loadGeminiKey, - saveGeminiKey, - type ColorMode, - type ThinkingLevelKey, -} from "@/lib/ai/gemini"; -import { captureEvent } from "@/lib/posthogEvents"; -import { livePresets } from "@/lib/presets"; -import { rampStateToHarness } from "@/lib/lab/engine"; -import { useFocusCodeLayer, useLabStore } from "@/lib/lab/store"; -import { isCodeLayer, type GalleryItem, type GenJob, type KnobRange } from "@/lib/lab/types"; -import styles from "../PatternLab.module.css"; -import local from "./GalleryPanel.module.css"; -import dock from "../LabPanels.module.css"; - -const MAX_GALLERY = 48; -const MAX_CONCURRENT_JOBS = 6; -const GEN_COUNT_MIN = 1; -const GEN_COUNT_MAX = 20; -const REF_OPTIONS = [0, 3, 6, 10]; - -function capGallery(items: GalleryItem[]): GalleryItem[] { - if (items.length <= MAX_GALLERY) return items; - const pinnedCount = items.reduce((total, item) => total + (item.pinned ? 1 : 0), 0); - const unpinnedBudget = Math.max(0, MAX_GALLERY - pinnedCount); - let unpinnedKept = 0; - const result: GalleryItem[] = []; - for (const item of items) { - if (item.pinned) { - result.push(item); - } else if (unpinnedKept < unpinnedBudget) { - result.push(item); - unpinnedKept += 1; - } - } - return result; -} - -function sampleExamples(currentCode: string, count: number) { - if (count <= 0) return []; - const pool = livePresets.filter((preset) => preset.code !== currentCode); - for (let i = pool.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [pool[i], pool[j]] = [pool[j], pool[i]]; - } - return pool.slice(0, count).map((preset) => ({ name: preset.name, code: preset.code })); -} - -function formatDuration(ms: number) { - const total = Math.max(0, Math.floor(ms / 1000)); - const minutes = Math.floor(total / 60); - const seconds = total % 60; - return `${minutes}:${String(seconds).padStart(2, "0")}`; -} - -// A live gallery card: runs its own runtime, reading the FOCUS code layer's -// current knobs/ranges/ramp from the store each frame, so turning a knob -// updates every preview at once. -function VariantPreview({ - code, - name, - active, - selected, - selectMode, - pinned, - onSelect, - onAddAsLayer, -}: { - code: string; - name: string; - active: boolean; - selected: boolean; - selectMode: boolean; - pinned: boolean; - onSelect: () => void; - onAddAsLayer: () => void; -}) { - const canvasRef = useRef<HTMLCanvasElement | null>(null); - const [error, setError] = useState<string | null>(null); - const cardMatrix = parseMatrixAnnotation(code) ?? DEFAULT_MATRIX; - - useEffect(() => { - const matrix = parseMatrixAnnotation(code) ?? DEFAULT_MATRIX; - const runtime = new PatternRuntime(matrix.width, matrix.height); - const load = runtime.loadCode(code); - let frameId = 0; - if (!load.ok) { - frameId = requestAnimationFrame(() => setError(load.error ?? "Pattern failed to load.")); - return () => cancelAnimationFrame(frameId); - } - - const paint = () => { - const canvas = canvasRef.current; - const context = canvas?.getContext("2d"); - if (!canvas || !context) return; - const imageData = context.createImageData(runtime.width, runtime.height); - const minLength = Math.min(runtime.data.length, imageData.data.length); - imageData.data.set(runtime.data.subarray(0, minLength)); - context.putImageData(imageData, 0, 0); - }; - paint(); - - let previousKnobs: number[] | null = null; - let cachedRampState: unknown = null; - let lastNow = performance.now(); - let simTime = 0; - - const tick = (now: number) => { - const dt = Math.min(Math.max(0, (now - lastNow) / 1000), 0.05); - lastNow = now; - simTime += dt; - - // Follow the SHARED knobs live; ramp/recolor come from the focus layer. - const state = useLabStore.getState(); - const activeLayer = state.layers.find((layer) => layer.id === state.activeLayerId); - const focus = isCodeLayer(activeLayer) ? activeLayer : state.layers.find(isCodeLayer); - - if (previousKnobs === null) previousKnobs = [...state.knobs]; - const knobDeltas = state.knobs.map((value, index) => - knobTargetToDelta( - previousKnobs![index] ?? value, - value, - LOGICAL_KNOB_WRAP[index], - knobUnitsPerTurn(state.ranges[index] ?? [0, 1]), - ), - ); - const knobNormalized = state.knobs.map((value, index) => { - const range: KnobRange = state.ranges[index] ?? [0, 1]; - const span = Math.max(0.0001, range[1] - range[0]); - return (value - range[0]) / span; - }); - previousKnobs = [...state.knobs]; - - if (focus && cachedRampState !== focus.ramp) { - cachedRampState = focus.ramp; - runtime.setRamp(rampStateToHarness(focus.ramp)); - } - runtime.recolor = focus?.recolor ?? false; - const input = createIdleInput(knobDeltas, { - knobValues: state.knobs, - knobNormalized, - knobRanges: state.ranges, - }); - - const result = runtime.renderFrame(dt, simTime, input); - if (!result.ok) { - setError(result.error ?? "Runtime error."); - return; - } - paint(); - frameId = requestAnimationFrame(tick); - }; - - frameId = requestAnimationFrame(tick); - return () => cancelAnimationFrame(frameId); - }, [code]); - - return ( - <button - type="button" - className={`${local.variantCard}${active && !selectMode ? ` ${local.variantCardActive}` : ""}${selected ? ` ${local.variantCardSelected}` : ""}`} - onClick={onSelect} - aria-pressed={selectMode ? selected : undefined} - title={selectMode ? "Click to select" : "Click to load into the active code layer"} - > - <div className={local.variantFrame}> - <canvas - ref={canvasRef} - width={cardMatrix.width} - height={cardMatrix.height} - style={{ aspectRatio: `${cardMatrix.width} / ${cardMatrix.height}` }} - aria-label={`${name} preview`} - /> - {error && <div className={local.variantError}>{error}</div>} - {pinned && ( - <span className={local.pinBadge} aria-hidden="true"> - PIN - </span> - )} - {selected && ( - <span className={local.selectBadge} aria-hidden="true"> - ✓ - </span> - )} - {!selectMode && ( - <span - role="button" - tabIndex={0} - className={local.pinBadge} - style={{ left: "auto", right: 4, cursor: "pointer" }} - title="Add as a new layer on top of the stack" - aria-label="Add as new layer" - onClick={(event) => { - event.stopPropagation(); - onAddAsLayer(); - }} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - event.stopPropagation(); - onAddAsLayer(); - } - }} - > - +⧉ - </span> - )} - </div> - <div className={local.variantMeta}> - <strong>{name}</strong> - </div> - </button> - ); -} - -export default function GalleryPanel() { - const focus = useFocusCodeLayer(); - const matrix = useLabStore((state) => state.matrix); - const knobs = useLabStore((state) => state.knobs); - const ranges = useLabStore((state) => state.ranges); - const gen = useLabStore((state) => state.gen); - const setGen = useLabStore((state) => state.setGen); - const gallery = useLabStore((state) => state.gallery); - const jobs = useLabStore((state) => state.jobs); - const setGallery = useLabStore((state) => state.setGallery); - const setJobs = useLabStore((state) => state.setJobs); - const applyCodeToActive = useLabStore((state) => state.applyCodeToActive); - const addCodeLayerFromCode = useLabStore((state) => state.addCodeLayerFromCode); - const activeCode = useLabStore((state) => { - const layer = state.layers.find((entry) => entry.id === state.activeLayerId); - return isCodeLayer(layer) ? layer.code : null; - }); - - const [geminiKey, setGeminiKey] = useState(""); - const [keyModalOpen, setKeyModalOpen] = useState(false); - const [keyDraft, setKeyDraft] = useState(""); - const [selected, setSelected] = useState<Set<string>>(() => new Set()); - const [selectMode, setSelectMode] = useState(false); - const [now, setNow] = useState(0); - const removedJobsRef = useRef<Set<string>>(new Set()); - - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect -- browser-only localStorage read after mount - setGeminiKey(loadGeminiKey()); - }, []); - - useEffect(() => { - if (!jobs.some((job) => job.status === "running")) return; - const timer = window.setInterval(() => setNow(Date.now()), 1000); - return () => window.clearInterval(timer); - }, [jobs]); - - const runningJobs = jobs.filter((job) => job.status === "running").length; - - const openKeyModal = () => { - setKeyDraft(geminiKey); - setKeyModalOpen(true); - }; - - const saveKey = () => { - const next = keyDraft.trim(); - saveGeminiKey(next); - setGeminiKey(next); - setKeyModalOpen(false); - }; - - const fireGeneration = () => { - if (!focus) return; - if (!geminiKey) { - openKeyModal(); - return; - } - if (runningJobs >= MAX_CONCURRENT_JOBS) return; - - const count = Math.min(GEN_COUNT_MAX, Math.max(GEN_COUNT_MIN, Math.round(gen.count) || 1)); - const thinkingLevel = gen.thinking; - const jobId = - typeof crypto !== "undefined" && crypto.randomUUID - ? crypto.randomUUID() - : `job-${Date.now()}-${Math.random().toString(36).slice(2)}`; - - const job: GenJob = { - id: jobId, - count, - thinkingLevel, - status: "running", - startedAt: Date.now(), - }; - setJobs((current) => [job, ...current]); - - const seedCode = focus.code; - const seedKnobs = [...knobs]; - const seedRanges = ranges.map((range): KnobRange => [...range]); - const examples = sampleExamples(seedCode, gen.refs); - const seedWithCurrent = gen.refs > 0; - - generatePatternVariants({ - apiKey: geminiKey, - code: seedCode, - knobs: seedKnobs, - ranges: seedRanges, - count, - thinkingLevel, - examples, - matrix, - seedWithCurrent, - colorMode: gen.colorMode, - }) - .then((items) => { - if (removedJobsRef.current.has(jobId)) return; - const stamped: GalleryItem[] = items.map((item, index) => ({ - ...item, - id: `${jobId}-${index}`, - })); - setGallery((current) => capGallery([...stamped, ...current])); - setJobs((current) => - current.map((entry) => - entry.id === jobId - ? { ...entry, status: "done", finishedAt: Date.now(), resultCount: items.length } - : entry, - ), - ); - captureEvent("pattern_lab_generate_variants", { - model: GEMINI_MODEL, - requested: count, - count: items.length, - thinking: thinkingLevel, - color_mode: gen.colorMode, - ms: Date.now() - job.startedAt, - }); - }) - .catch((error) => { - if (removedJobsRef.current.has(jobId)) return; - const message = error instanceof Error ? error.message : "Generation failed."; - setJobs((current) => - current.map((entry) => - entry.id === jobId - ? { ...entry, status: "error", finishedAt: Date.now(), error: message } - : entry, - ), - ); - captureEvent("pattern_lab_generate_variants_error", { - model: GEMINI_MODEL, - requested: count, - thinking: thinkingLevel, - message, - }); - }); - }; - - const toggleSelected = (id: string) => { - setSelected((current) => { - const next = new Set(current); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); - }; - - const exitSelectMode = () => { - setSelectMode(false); - setSelected(new Set()); - }; - - const deleteSelected = () => { - if (selected.size === 0) return; - selected.forEach((id) => removedJobsRef.current.add(id)); - setJobs((current) => current.filter((job) => !selected.has(job.id))); - setGallery((current) => current.filter((item) => !selected.has(item.id))); - setSelected(new Set()); - }; - - const selectedGalleryItems = gallery.filter((item) => selected.has(item.id)); - const allSelectedPinned = - selectedGalleryItems.length > 0 && selectedGalleryItems.every((item) => item.pinned); - const pinnedCount = gallery.reduce((total, item) => total + (item.pinned ? 1 : 0), 0); - - const togglePinSelected = () => { - if (selectedGalleryItems.length === 0) return; - const pinnedTarget = !allSelectedPinned; - setGallery((current) => - current.map((item) => (selected.has(item.id) ? { ...item, pinned: pinnedTarget } : item)), - ); - }; - - return ( - <div className={dock.panel}> - <div className={dock.panelBar}> - <label className={styles.genField} title="How many variations per run (1–20)"> - <span>n</span> - <input - type="number" - min={GEN_COUNT_MIN} - max={GEN_COUNT_MAX} - value={gen.count} - aria-label="Variations per run" - onChange={(event) => - setGen({ - count: Math.min( - GEN_COUNT_MAX, - Math.max(GEN_COUNT_MIN, Math.round(Number(event.target.value)) || GEN_COUNT_MIN), - ), - }) - } - /> - </label> - <select - value={gen.thinking} - aria-label="Thinking level" - title="Reasoning depth — higher is slower but more varied" - onChange={(event) => setGen({ thinking: event.target.value as ThinkingLevelKey })} - > - {THINKING_LEVELS.map((level) => ( - <option key={level} value={level}> - {level.toLowerCase()} - </option> - ))} - </select> - <select - value={gen.refs} - aria-label="Reference examples" - title="How many existing patterns to show the model as references. No refs = rules only, most creative." - onChange={(event) => setGen({ refs: Number(event.target.value) })} - > - {REF_OPTIONS.map((option) => ( - <option key={option} value={option}> - {option === 0 ? "no refs" : `${option} refs`} - </option> - ))} - </select> - <select - value={gen.colorMode} - aria-label="Color mode" - title="v-field: the model outputs a 0..1 value field and the layer's Color Ramp does the coloring. rgb: the model colors pixels itself." - onChange={(event) => setGen({ colorMode: event.target.value as ColorMode })} - > - {COLOR_MODES.map((option) => ( - <option key={option} value={option}> - {option === "vfield" ? "v-field" : "rgb"} - </option> - ))} - </select> - <button - type="button" - onClick={fireGeneration} - disabled={!focus || runningJobs >= MAX_CONCURRENT_JOBS} - title={ - !focus - ? "Add a code layer first — generations seed from it" - : runningJobs >= MAX_CONCURRENT_JOBS - ? `Max ${MAX_CONCURRENT_JOBS} runs at once` - : "Queue a generation run seeded from the focused code layer" - } - > - Generate - </button> - <button - type="button" - onClick={openKeyModal} - title={geminiKey ? "Gemini key set — click to change" : "Set Gemini API key"} - aria-label="Gemini API key" - > - {geminiKey ? "Key ✓" : "Key"} - </button> - </div> - - <div className={dock.panelBody}> - <div className={local.galleryPane}> - {jobs.length > 0 && ( - <div className={local.queue}> - <div className={local.queueHeader}> - <span>Queue</span> - {jobs.some((job) => job.status !== "running") && ( - <button - type="button" - onClick={() => { - const finishedIds = new Set( - jobs.filter((job) => job.status !== "running").map((job) => job.id), - ); - setJobs((current) => current.filter((job) => job.status === "running")); - setSelected( - (current) => new Set([...current].filter((id) => !finishedIds.has(id))), - ); - }} - > - Clear finished - </button> - )} - </div> - <ul className={local.queueList}> - {jobs.map((job) => { - const elapsed = (job.finishedAt ?? Math.max(now, job.startedAt)) - job.startedAt; - return ( - <li key={job.id} className={local.jobRow} data-status={job.status}> - {selectMode && ( - <input - type="checkbox" - checked={selected.has(job.id)} - onChange={() => toggleSelected(job.id)} - aria-label="Select job" - /> - )} - <span className={local.jobDot} aria-hidden /> - <span className={local.jobLabel}> - {job.status === "running" - ? `Generating ${job.count}…` - : job.status === "done" - ? `Done · ${job.resultCount ?? job.count} pattern${(job.resultCount ?? job.count) === 1 ? "" : "s"}` - : "Failed"} - <em>{job.thinkingLevel.toLowerCase()}</em> - </span> - {job.status === "error" && job.error && ( - <span className={local.jobError} title={job.error}> - {job.error} - </span> - )} - <span className={local.jobTime}>{formatDuration(elapsed)}</span> - </li> - ); - })} - </ul> - </div> - )} - - {(gallery.length > 0 || jobs.length > 0) && ( - <div className={local.galleryToolbar}> - {selectMode ? ( - <> - <button type="button" className={local.toolbarActive} onClick={exitSelectMode}> - Done - </button> - <button - type="button" - onClick={() => - setSelected( - new Set([...jobs.map((job) => job.id), ...gallery.map((item) => item.id)]), - ) - } - > - Select all - </button> - <button - type="button" - onClick={togglePinSelected} - disabled={selectedGalleryItems.length === 0} - > - {allSelectedPinned ? "Unpin" : "Pin"} - {selectedGalleryItems.length > 0 ? ` (${selectedGalleryItems.length})` : ""} - </button> - <button - type="button" - className={local.deleteButton} - onClick={deleteSelected} - disabled={selected.size === 0} - > - Delete{selected.size > 0 ? ` (${selected.size})` : ""} - </button> - <span className={local.toolbarHint}> - {selected.size} selected — click items to toggle - </span> - </> - ) : ( - <> - <button type="button" onClick={() => setSelectMode(true)}> - Select - </button> - {pinnedCount > 0 && pinnedCount < gallery.length && ( - <button - type="button" - className={local.deleteButton} - onClick={() => setGallery((current) => current.filter((item) => item.pinned))} - > - Delete unpinned ({gallery.length - pinnedCount}) - </button> - )} - </> - )} - </div> - )} - - {gallery.length === 0 ? ( - jobs.length === 0 && ( - <p className={styles.emptyState}> - No variants yet — set a count and hit Generate. Click a card to load it into the - active code layer, or +⧉ to stack it as a new layer. - </p> - ) - ) : ( - <ol className={local.galleryGrid}> - {[...gallery] - .sort((a, b) => (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0)) - .map((item) => ( - <li key={item.id}> - <VariantPreview - code={item.code} - name={item.name} - active={item.code === activeCode} - selected={selected.has(item.id)} - selectMode={selectMode} - pinned={Boolean(item.pinned)} - onSelect={() => { - if (selectMode) toggleSelected(item.id); - else applyCodeToActive(item.code); - }} - onAddAsLayer={() => addCodeLayerFromCode(item.code, item.name)} - /> - </li> - ))} - </ol> - )} - </div> - </div> - - {keyModalOpen && ( - <div - className={styles.modalOverlay} - role="dialog" - aria-modal="true" - aria-label="Gemini API key" - onClick={() => setKeyModalOpen(false)} - > - <div className={styles.modalCard} onClick={(event) => event.stopPropagation()}> - <div className={styles.modalHeader}> - <span>Gemini API key</span> - <button type="button" onClick={() => setKeyModalOpen(false)} aria-label="Close"> - × - </button> - </div> - <div className={styles.modalBody}> - <p> - Bring your own Google AI Studio key to generate variations in-app. It is stored only - in this browser (localStorage) and sent directly to Google — never to our servers. - </p> - <input - className={`${local.keyField} ph-no-capture`} - type="password" - autoComplete="off" - spellCheck={false} - placeholder="AIza…" - value={keyDraft} - aria-label="Gemini API key" - onChange={(event) => setKeyDraft(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") saveKey(); - if (event.key === "Escape") setKeyModalOpen(false); - }} - /> - <p className={styles.modalNote}> - Get a free key at aistudio.google.com/apikey. The key never leaves your browser - except to call Google directly. - </p> - <div className={styles.variantActions} style={{ marginTop: 12 }}> - <button type="button" onClick={saveKey}> - Save - </button> - {geminiKey && ( - <button - type="button" - onClick={() => { - saveGeminiKey(""); - setGeminiKey(""); - setKeyDraft(""); - }} - > - Clear - </button> - )} - </div> - </div> - </div> - </div> - )} - </div> - ); -} diff --git a/web/src/app/pattern-lab/panels/ImportImageModal.test.tsx b/web/src/app/pattern-lab/panels/ImportImageModal.test.tsx new file mode 100644 index 000000000..b5727524d --- /dev/null +++ b/web/src/app/pattern-lab/panels/ImportImageModal.test.tsx @@ -0,0 +1,128 @@ +// The import modal's wheel: over the preview it scales the image, and the +// card behind it stays where it is. jsdom loads no images, so a stand-in +// Image reports itself loaded as soon as it is given a source. + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createEvent, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import ImportImageModal from "./ImportImageModal"; +import { rasterizeImage } from "@/lib/lab/pixelTools"; +import { useLabStore } from "@/lib/lab/store"; + +// The placement the preview was last drawn with. +vi.mock("@/lib/lab/pixelTools", async (importOriginal) => { + const actual = await importOriginal<typeof import("@/lib/lab/pixelTools")>(); + return { ...actual, rasterizeImage: vi.fn(actual.rasterizeImage) }; +}); +function lastPlacement() { + const calls = vi.mocked(rasterizeImage).mock.calls; + const transform = calls[calls.length - 1][5]!; + return { scale: transform.scale!, x: transform.offsetX!, y: transform.offsetY! }; +} + +class LoadedImage { + width = 32; + height = 16; + onload: (() => void) | null = null; + set src(_url: string) { + queueMicrotask(() => this.onload?.()); + } +} + +function mount() { + useLabStore.getState().discardProject(); + useLabStore.getState().addPixelLayer(); + const layer = useLabStore.getState().layers.find((entry) => entry.type === "pixel")!; + render( + <ImportImageModal width={128} height={64} layerId={layer.id} onApply={() => {}} onClose={() => {}} />, + ); + return screen.getByLabelText(/^Import preview/); +} + +async function loadImage() { + const file = new File(["x"], "pic.png", { type: "image/png" }); + fireEvent.change(screen.getByLabelText("Choose image"), { target: { files: [file] } }); + await waitFor(() => expect(screen.getByText("Apply to layer")).toBeEnabled()); +} + +beforeEach(() => { + vi.stubGlobal("Image", LoadedImage); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("ImportImageModal", () => { + it("leaves the wheel alone until there is an image to scale", () => { + const preview = mount(); + const up = createEvent.wheel(preview, { deltaY: -100 }); + fireEvent(preview, up); + expect(up.defaultPrevented).toBe(false); + expect(screen.getByText(/100%/)).toBeTruthy(); + }); + + it("the wheel scales the image from the moment Apply turns on", async () => { + const preview = mount(); + const file = new File(["x"], "pic.png", { type: "image/png" }); + fireEvent.change(screen.getByLabelText("Choose image"), { target: { files: [file] } }); + // waitFor looks again on every DOM change, so the first look that finds + // Apply enabled is right after React wrote it, before anything later. + let prevented: boolean | null = null; + await waitFor(() => { + expect(screen.getByText("Apply to layer")).toBeEnabled(); + if (prevented === null) { + const up = createEvent.wheel(preview, { deltaY: -100 }); + preview.dispatchEvent(up); + prevented = up.defaultPrevented; + } + }); + expect(prevented).toBe(true); + }); + + it("the wheel scales the image and does not scroll the card", async () => { + const preview = mount(); + await loadImage(); + const up = createEvent.wheel(preview, { deltaY: -100 }); + fireEvent(preview, up); + expect(up.defaultPrevented).toBe(true); + expect(screen.getByText(/108%/)).toBeTruthy(); + const down = createEvent.wheel(preview, { deltaY: 100 }); + fireEvent(preview, down); + expect(down.defaultPrevented).toBe(true); + expect(screen.getByText(/100%/)).toBeTruthy(); + const sideways = createEvent.wheel(preview, { deltaX: 100, deltaY: 0 }); + fireEvent(preview, sideways); + expect(sideways.defaultPrevented).toBe(false); + expect(screen.getByText(/100%/)).toBeTruthy(); + }); + + it("a trackpad's small wheel events add up to one notch's 8%", async () => { + const preview = mount(); + await loadImage(); + for (let i = 0; i < 10; i++) fireEvent(preview, createEvent.wheel(preview, { deltaY: -10 })); + expect(screen.getByText(/108%/)).toBeTruthy(); + }); + + it("the wheel keeps the point of the image under the cursor where it was", async () => { + const preview = mount(); + await loadImage(); + // The preview paints in a plain effect, a task after Apply turns on. On a + // busy machine that task came after this line, and there was no placement + // to read yet. + await waitFor(() => expect(rasterizeImage).toHaveBeenCalled()); + // The setup lays the 128 × 64 preview out at the origin, one CSS pixel a + // column. The image's centre is the preview's centre plus the offset. + const cursor = { clientX: 96, clientY: 16 }; + const underCursor = () => { + const { scale, x, y } = lastPlacement(); + return { x: (cursor.clientX - 64 - x) / scale, y: (cursor.clientY - 32 - y) / scale }; + }; + expect(underCursor()).toEqual({ x: 32, y: -16 }); + for (const deltaY of [-100, -250, 40]) { + fireEvent(preview, createEvent.wheel(preview, { deltaY, ...cursor })); + expect(underCursor().x).toBeCloseTo(32, 9); + expect(underCursor().y).toBeCloseTo(-16, 9); + } + expect(lastPlacement().scale).toBeCloseTo(1.08 ** 3.1, 9); + }); +}); diff --git a/web/src/app/pattern-lab/panels/ImportImageModal.tsx b/web/src/app/pattern-lab/panels/ImportImageModal.tsx index 8c630951b..db4773516 100644 --- a/web/src/app/pattern-lab/panels/ImportImageModal.tsx +++ b/web/src/app/pattern-lab/panels/ImportImageModal.tsx @@ -5,11 +5,17 @@ // removal, drag to position, and a live backdrop of the rest of the stack. // Lived at the bottom of PixelPanel.tsx until 2026-09; same code, own file. -import { useEffect, useRef, useState } from "react"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { useDialog } from "@/lib/hooks/useDialog"; import { rasterizeImage, type ImportFit, type RGBA } from "@/lib/lab/pixelTools"; import { labEngine } from "@/lib/lab/engine"; import { useLabStore } from "@/lib/lab/store"; import styles from "../PatternLab.module.css"; +import { WHEEL_NOTCH_PX, wheelPixels } from "./pixel/wheel"; + +const clampScale = (value: number) => Math.max(0.05, Math.min(8, value)); +// One wheel notch scales the image by 8%; a trackpad gets there in pieces. +const NOTCH_SCALE = 1.08; export default function ImportImageModal({ width, @@ -31,7 +37,14 @@ export default function ImportImageModal({ const [bgTolerance, setBgTolerance] = useState(0.12); const [scale, setScale] = useState(1); const [offset, setOffset] = useState({ x: 0, y: 0 }); + // What the wheel listener reads: it runs between renders, and two wheel + // events can land before React commits the first. + const placement = useRef({ scale, offset }); + useLayoutEffect(() => { + placement.current = { scale, offset }; + }, [scale, offset]); const [showLayers, setShowLayers] = useState(true); + const cardRef = useDialog<HTMLDivElement>(onClose); const previewRef = useRef<HTMLCanvasElement | null>(null); const backdropRef = useRef<HTMLCanvasElement | null>(null); const resultRef = useRef<Uint8ClampedArray | null>(null); @@ -138,7 +151,40 @@ export default function ImportImageModal({ context.putImageData(imageData, 0, 0); }, [image, fit, smooth, removeBg, bgTolerance, scale, offset, width, height]); - const clampScale = (value: number) => Math.max(0.05, Math.min(8, value)); + // Wheel over the preview scales the image. A native listener, because + // React delegates wheel passively and preventDefault there would let the + // card scroll under the zoom. A sideways wheel has no deltaY and stays a + // scroll. A layout effect, so the listener is on in the same commit that + // turns Apply on. A plain effect runs a task later, and a wheel landing in + // between scrolls the card. + // + // The point of the image under the cursor stays under it. The image's + // centre sits at the preview's centre plus the offset, so every distance + // from that centre grows by the same factor as the scale. + useLayoutEffect(() => { + const canvas = previewRef.current; + if (!canvas || !image) return; + const onWheel = (event: WheelEvent) => { + if (event.deltaY === 0) return; + event.preventDefault(); + const { scale: current, offset: at } = placement.current; + const next = clampScale(current * NOTCH_SCALE ** (-wheelPixels(event) / WHEEL_NOTCH_PX)); + if (next === current) return; + let nextOffset = at; + const rect = canvas.getBoundingClientRect(); + if (rect.width > 0 && rect.height > 0) { + const grow = next / current; + const x = ((event.clientX - rect.left) / rect.width) * width - width / 2; + const y = ((event.clientY - rect.top) / rect.height) * height - height / 2; + nextOffset = { x: x - (x - at.x) * grow, y: y - (y - at.y) * grow }; + } + placement.current = { scale: next, offset: nextOffset }; + setScale(next); + setOffset(nextOffset); + }; + canvas.addEventListener("wheel", onWheel, { passive: false }); + return () => canvas.removeEventListener("wheel", onWheel); + }, [image, width, height]); const onPreviewPointerDown = (event: React.PointerEvent<HTMLCanvasElement>) => { if (!image || event.button !== 0) return; @@ -172,7 +218,12 @@ export default function ImportImageModal({ aria-label="Import image" onClick={onClose} > - <div className={styles.modalCard} onClick={(event) => event.stopPropagation()}> + <div + ref={cardRef} + tabIndex={-1} + className={styles.modalCard} + onClick={(event) => event.stopPropagation()} + > <div className={styles.modalHeader}> <span>Import image → {width}×{height}</span> <button type="button" onClick={onClose} aria-label="Close"> @@ -231,11 +282,6 @@ export default function ImportImageModal({ onPointerMove={onPreviewPointerMove} onPointerUp={() => (dragRef.current = null)} onPointerCancel={() => (dragRef.current = null)} - onWheel={(event) => { - if (!image) return; - event.preventDefault(); - setScale((current) => clampScale(current * (event.deltaY < 0 ? 1.08 : 1 / 1.08))); - }} /> </div> <div diff --git a/web/src/app/pattern-lab/panels/LayersPanel.test.tsx b/web/src/app/pattern-lab/panels/LayersPanel.test.tsx new file mode 100644 index 000000000..ab8c98e5d --- /dev/null +++ b/web/src/app/pattern-lab/panels/LayersPanel.test.tsx @@ -0,0 +1,81 @@ +// Del in the layer stack. There is no undo for it, so a layer with work in it +// asks first; a blank one goes straight away. + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import LayersPanel from "./LayersPanel"; +import { useLabStore } from "@/lib/lab/store"; + +const store = () => useLabStore.getState(); +const names = () => store().layers.map((layer) => layer.name); +const del = () => screen.getByRole("button", { name: "Del" }); + +function drawOnePixel() { + const layer = store().layers.find((entry) => entry.id === store().activeLayerId); + if (!layer || layer.type !== "pixel") throw new Error("active layer is not pixel"); + const data = new Uint8ClampedArray(layer.data); + data.set([255, 77, 0, 255], 0); + store().replacePixelData(layer.id, data); +} + +beforeEach(() => { + store().discardProject(); +}); + +describe("LayersPanel Del", () => { + it("is disabled while the stack has one layer", () => { + render(<LayersPanel />); + expect(del()).toBeDisabled(); + }); + + it("deletes a pixel layer nobody has drawn on without asking", () => { + store().addPixelLayer(); + const confirm = vi.spyOn(window, "confirm"); + render(<LayersPanel />); + fireEvent.click(del()); + expect(confirm).not.toHaveBeenCalled(); + expect(names()).toEqual(["Code 1"]); + }); + + it("deletes a code layer still holding the starter pattern without asking", () => { + store().addCodeLayer(); + const confirm = vi.spyOn(window, "confirm"); + render(<LayersPanel />); + fireEvent.click(del()); + expect(confirm).not.toHaveBeenCalled(); + expect(names()).toEqual(["Code 1"]); + }); + + it("asks before deleting a drawn pixel layer, and Cancel keeps it", () => { + store().addPixelLayer(); + drawOnePixel(); + const confirm = vi.spyOn(window, "confirm").mockReturnValue(false); + render(<LayersPanel />); + fireEvent.click(del()); + expect(confirm).toHaveBeenCalledTimes(1); + expect(confirm.mock.calls[0][0]).toContain('"Pixel 1"'); + expect(names()).toEqual(["Pixel 1", "Code 1"]); + const kept = store().layers[0]; + expect(kept.type === "pixel" ? kept.data[3] : -1).toBe(255); + }); + + it("deletes the drawn pixel layer once confirmed", () => { + store().addPixelLayer(); + drawOnePixel(); + vi.spyOn(window, "confirm").mockReturnValue(true); + render(<LayersPanel />); + fireEvent.click(del()); + expect(names()).toEqual(["Code 1"]); + expect(store().activeLayerId).toBe(store().layers[0].id); + }); + + it("asks before deleting a code layer whose code was changed", () => { + store().addPixelLayer(); + store().selectLayer(store().layers[1].id); + const confirm = vi.spyOn(window, "confirm").mockReturnValue(false); + render(<LayersPanel />); + fireEvent.click(del()); + expect(confirm.mock.calls[0][0]).toContain('"Code 1"'); + expect(names()).toEqual(["Pixel 1", "Code 1"]); + }); +}); diff --git a/web/src/app/pattern-lab/panels/LayersPanel.tsx b/web/src/app/pattern-lab/panels/LayersPanel.tsx index 15d51895c..c091f4163 100644 --- a/web/src/app/pattern-lab/panels/LayersPanel.tsx +++ b/web/src/app/pattern-lab/panels/LayersPanel.tsx @@ -5,11 +5,22 @@ // The active layer's opacity + blend live at the bottom of the panel. import { useState } from "react"; -import { BLEND_MODES } from "@/lib/lab/types"; +import { BLEND_MODES, type Layer } from "@/lib/lab/types"; import { revealEditor } from "@/lib/lab/editorReveal"; import { useLabStore } from "@/lib/lab/store"; +import { STARTER_CODE } from "@/lib/lab/store/shared"; import dock from "../LabPanels.module.css"; +// A pixel layer nobody has drawn on, or a code layer still holding the +// starter pattern, has nothing to lose, so Del takes it without asking. +function holdsWork(layer: Layer): boolean { + if (layer.type === "code") return layer.code !== STARTER_CODE; + for (let i = 3; i < layer.data.length; i += 4) { + if (layer.data[i] !== 0) return true; + } + return false; +} + export default function LayersPanel() { const layers = useLabStore((state) => state.layers); const activeLayerId = useLabStore((state) => state.activeLayerId); @@ -77,7 +88,18 @@ export default function LayersPanel() { </button> <button type="button" - onClick={() => active && removeLayer(active.id)} + onClick={() => { + if (!active) return; + // Deleting has no undo, and autosave writes the smaller stack + // over the saved project within 3 s. + if ( + holdsWork(active) && + !window.confirm(`Delete the layer "${active.name}"? It can't be brought back.`) + ) { + return; + } + removeLayer(active.id); + }} disabled={!active || layers.length <= 1} title={layers.length <= 1 ? "The stack needs at least one layer" : "Delete the selected layer"} > diff --git a/web/src/app/pattern-lab/panels/PixelPanel.test.tsx b/web/src/app/pattern-lab/panels/PixelPanel.test.tsx index 0a8d7a9f4..af3e06369 100644 --- a/web/src/app/pattern-lab/panels/PixelPanel.test.tsx +++ b/web/src/app/pattern-lab/panels/PixelPanel.test.tsx @@ -2,8 +2,8 @@ // hooks: every gesture below reads the layer's RGBA buffer in the store — the // thing the device receives — not what the canvas shows. -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, createEvent, fireEvent, render, screen } from "@testing-library/react"; import PixelPanel from "./PixelPanel"; import { useLabStore } from "@/lib/lab/store"; import type { PixelLayer } from "@/lib/lab/types"; @@ -45,6 +45,10 @@ beforeEach(() => { useLabStore.getState().addPixelLayer(); }); +afterEach(() => { + vi.useRealTimers(); +}); + describe("PixelPanel", () => { it("offers to add a pixel layer when the active layer is code", () => { const state = useLabStore.getState(); @@ -90,6 +94,20 @@ describe("PixelPanel", () => { expect(rgba(1, 1)).toEqual(ORANGE); }); + it("undo still works after the matrix size changes", () => { + const { overlay, viewport } = mount(); + fireEvent.pointerDown(overlay, at(40, 20)); + fireEvent.pointerUp(overlay, at(40, 20)); + // 128×64 to 64×32 keeps the middle: (40, 20) becomes (8, 4) + act(() => useLabStore.getState().setMatrix({ width: 64, height: 32 })); + expect(pixelLayer().width).toBe(64); + expect(rgba(8, 4)).toEqual(ORANGE); + fireEvent.keyDown(viewport, { key: "z", ctrlKey: true }); + expect(rgba(8, 4)).toEqual(CLEAR); + fireEvent.keyDown(viewport, { key: "z", ctrlKey: true, shiftKey: true }); + expect(rgba(8, 4)).toEqual(ORANGE); + }); + it("tool keys switch tools: e erases, g fills, b is the pen again", () => { const { overlay, viewport } = mount(); fireEvent.keyDown(viewport, { key: "g" }); @@ -202,7 +220,8 @@ describe("PixelPanel", () => { }); it("copy as code puts a standalone pattern on the clipboard", async () => { - const { overlay } = mount(); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const { overlay, unmount } = mount(); const writeText = vi.fn<(text: string) => Promise<void>>(() => Promise.resolve()); Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } }); fireEvent.click(screen.getByTitle(/Copy this drawing/)); @@ -212,6 +231,15 @@ describe("PixelPanel", () => { fireEvent.click(screen.getByText("Layer is empty").closest("button")!); expect(writeText).toHaveBeenCalledTimes(1); expect(String(writeText.mock.calls[0][0])).toContain("draw("); + await act(async () => {}); + expect(screen.getByText("Copied ✓")).toBeTruthy(); + // The label's way back to "Copy as code" goes with the panel. A timer + // left here fired after the test file had torn its DOM down. Focusing + // the viewport leaves a zero-delay timer of jsdom's own, so what is due + // now runs first. + unmount(); + vi.advanceTimersByTime(0); + expect(vi.getTimerCount()).toBe(0); }); it("opens the image import modal", () => { @@ -232,6 +260,152 @@ describe("PixelPanel", () => { expect(screen.getByText("zoom 5×")).toBeTruthy(); }); + // jsdom lays nothing out, so every element measures 0×0 unless told. + const panelSize = (width: number, height: number) => { + vi.spyOn(Element.prototype, "clientWidth", "get").mockImplementation(() => width); + vi.spyOn(Element.prototype, "clientHeight", "get").mockImplementation(() => height); + }; + + it("on a phone the picture opens fitted, all of it in view", () => { + // the Pixel viewport on a 390×844 phone, measured in Chromium + panelSize(372, 151); + mount(); + expect(screen.getByText("zoom 1×")).toBeTruthy(); + // and the zoom stays the user's to change + fireEvent.click(screen.getByLabelText("Zoom in")); + expect(screen.getByText("zoom 2×")).toBeTruthy(); + }); + + it("a desktop panel narrower than the picture at 6× opens fitted too", () => { + // 1440×900: the viewport is 649×648, and 6× is 768 px across + panelSize(649, 648); + mount(); + expect(screen.getByText("zoom 4×")).toBeTruthy(); + }); + + it("a panel the picture already fits keeps 6×", () => { + panelSize(1200, 800); + mount(); + expect(screen.getByText("zoom 6×")).toBeTruthy(); + }); + + it("a panel laid out after it mounts is fitted when it gets its size, and only then", () => { + const observers: { fire: () => void }[] = []; + const Original = window.ResizeObserver; + class CapturingObserver { + private live = true; + constructor(private readonly callback: ResizeObserverCallback) { + observers.push({ fire: () => this.live && this.callback([], this as unknown as ResizeObserver) }); + } + observe() {} + unobserve() {} + disconnect() { + this.live = false; + } + } + Object.defineProperty(window, "ResizeObserver", { configurable: true, value: CapturingObserver }); + try { + // a tab behind another: mounted, 0×0 + let size = { width: 0, height: 0 }; + vi.spyOn(Element.prototype, "clientWidth", "get").mockImplementation(() => size.width); + vi.spyOn(Element.prototype, "clientHeight", "get").mockImplementation(() => size.height); + mount(); + expect(screen.getByText("zoom 6×")).toBeTruthy(); + size = { width: 372, height: 151 }; + act(() => observers.forEach((observer) => observer.fire())); + expect(screen.getByText("zoom 1×")).toBeTruthy(); + // a later resize leaves the user's zoom alone + fireEvent.click(screen.getByLabelText("Zoom in")); + size = { width: 200, height: 100 }; + act(() => observers.forEach((observer) => observer.fire())); + expect(screen.getByText("zoom 2×")).toBeTruthy(); + } finally { + Object.defineProperty(window, "ResizeObserver", { configurable: true, value: Original }); + } + }); + + it("the wheel zooms the drawing and does not scroll the panel", () => { + const { viewport } = mount(); + const up = createEvent.wheel(viewport, { deltaY: -100 }); + fireEvent(viewport, up); + expect(up.defaultPrevented).toBe(true); + expect(screen.getByText("zoom 7×")).toBeTruthy(); + const down = createEvent.wheel(viewport, { deltaY: 100 }); + fireEvent(viewport, down); + expect(down.defaultPrevented).toBe(true); + expect(screen.getByText("zoom 6×")).toBeTruthy(); + // a sideways wheel has no up or down: it scrolls the viewport, zoom stays + const sideways = createEvent.wheel(viewport, { deltaX: 100, deltaY: 0 }); + fireEvent(viewport, sideways); + expect(sideways.defaultPrevented).toBe(false); + expect(screen.getByText("zoom 6×")).toBeTruthy(); + }); + + it("the wheel zooms once a pixel layer is picked after a code layer", () => { + const state = useLabStore.getState(); + state.selectLayer(state.layers.find((entry) => entry.type === "code")!.id); + const { container } = render(<PixelPanel />); + act(() => useLabStore.getState().selectLayer(pixelLayer().id)); + const viewport = container.querySelector('[tabindex="0"]') as HTMLDivElement; + const up = createEvent.wheel(viewport, { deltaY: -100 }); + fireEvent(viewport, up); + expect(up.defaultPrevented).toBe(true); + expect(screen.getByText("zoom 7×")).toBeTruthy(); + }); + + it("the wheel zooms by how far it turned, not by how many events came", () => { + const { viewport } = mount(); + const wheel = (init: WheelEventInit) => fireEvent(viewport, createEvent.wheel(viewport, init)); + // a trackpad swipe: thirty small events, 120 px in all, one notch's step + for (let i = 0; i < 30; i++) wheel({ deltaY: -4 }); + expect(screen.getByText("zoom 7×")).toBeTruthy(); + // one fast flick of three notches + wheel({ deltaY: -300 }); + expect(screen.getByText("zoom 10×")).toBeTruthy(); + // a wheel that counts in lines: three lines are a notch + wheel({ deltaY: 3, deltaMode: WheelEvent.DOM_DELTA_LINE }); + expect(screen.getByText("zoom 9×")).toBeTruthy(); + }); + + it("turning the wheel back drops what was left of the other way", () => { + const { viewport } = mount(); + const wheel = (init: WheelEventInit) => fireEvent(viewport, createEvent.wheel(viewport, init)); + for (let i = 0; i < 9; i++) wheel({ deltaY: -10 }); + expect(screen.getByText("zoom 6×")).toBeTruthy(); + wheel({ deltaY: 100 }); + expect(screen.getByText("zoom 5×")).toBeTruthy(); + }); + + it("the wheel keeps the cell under the cursor where it was", () => { + const { overlay, viewport } = mount(); + // A laid-out viewport: the picture starts 16 px in (the padding), is + // pixel.width × zoom wide, and moves left as the viewport scrolls. + const stage = overlay.parentElement as HTMLDivElement; + Object.defineProperty(overlay, "getBoundingClientRect", { + configurable: true, + value: () => { + const left = 16 - viewport.scrollLeft; + const top = 16 - viewport.scrollTop; + const width = parseFloat(stage.style.width); + const height = parseFloat(stage.style.height); + return { left, top, width, height, right: left + width, bottom: top + height, x: left, y: top, toJSON: () => ({}) }; + }, + }); + // the middle of cell (50, 20) at 6× + const cursor = { clientX: 16 + 50.5 * 6, clientY: 16 + 20.5 * 6 }; + const hover = () => fireEvent.pointerMove(overlay, at(cursor.clientX, cursor.clientY, { buttons: 0 })); + hover(); + expect(screen.getByText("50,20")).toBeTruthy(); + for (const [deltaY, zoom] of [[-100, 7], [-300, 10], [200, 8]] as const) { + fireEvent(viewport, createEvent.wheel(viewport, { deltaY, ...cursor })); + expect(screen.getByText(`zoom ${zoom}×`)).toBeTruthy(); + hover(); + expect(screen.getByText("50,20")).toBeTruthy(); + } + expect(viewport.scrollLeft).toBeGreaterThan(0); + expect(viewport.scrollTop).toBeGreaterThan(0); + }); + it("switching layers drops the selection", () => { const { overlay, viewport } = mount(); fireEvent.keyDown(viewport, { key: "s" }); diff --git a/web/src/app/pattern-lab/panels/PixelPanel.tsx b/web/src/app/pattern-lab/panels/PixelPanel.tsx index 9968e055f..0d2636449 100644 --- a/web/src/app/pattern-lab/panels/PixelPanel.tsx +++ b/web/src/app/pattern-lab/panels/PixelPanel.tsx @@ -12,6 +12,7 @@ // lib/lab/pixelHistory. PixelPanel.test.tsx pins the behaviour. import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useResettingState } from "@/lib/hooks/useResettingState"; import { clearSurface, stampOver, type PixelSurface } from "@/lib/lab/pixelTools"; import { prunePixelHistories, redoPixels, snapshotPixels, undoPixels } from "@/lib/lab/pixelHistory"; import { buildPixelPatternCode } from "@/lib/lab/pixelToCode"; @@ -48,16 +49,16 @@ export default function PixelPanel() { // ── history ── const pushUndo = useCallback(() => { - if (pixel) snapshotPixels(pixel.id, pixel.data); + if (pixel) snapshotPixels(pixel.id, pixel); }, [pixel]); const commit = useCallback(() => { if (pixel) bumpPixelLayer(pixel.id); }, [pixel, bumpPixelLayer]); const undo = useCallback(() => { - if (pixel && undoPixels(pixel.id, pixel.data)) commit(); + if (pixel && undoPixels(pixel.id, pixel)) commit(); }, [pixel, commit]); const redo = useCallback(() => { - if (pixel && redoPixels(pixel.id, pixel.data)) commit(); + if (pixel && redoPixels(pixel.id, pixel)) commit(); }, [pixel, commit]); // ── the pieces ── @@ -73,7 +74,7 @@ export default function PixelPanel() { const [backdrop, setBackdrop] = useState(true); const [grid, setGrid] = useState(false); const [importOpen, setImportOpen] = useState(false); - const [codeCopy, setCodeCopy] = useState<CodeCopyState>("idle"); + const [codeCopy, flashCodeCopy] = useResettingState<CodeCopyState>("idle", 1400); const paintOverlay = usePixelCanvases({ pixel, @@ -111,18 +112,14 @@ export default function PixelPanel() { const copyAsCode = () => { if (!pixel) return; const code = buildPixelPatternCode(pixel); - const flash = (state: CodeCopyState) => { - setCodeCopy(state); - window.setTimeout(() => setCodeCopy("idle"), 1400); - }; if (!code) { - flash("empty"); + flashCodeCopy("empty"); return; } navigator.clipboard .writeText(code) - .then(() => flash("copied")) - .catch(() => flash("failed")); + .then(() => flashCodeCopy("copied")) + .catch(() => flashCodeCopy("failed")); }; if (!pixel) { @@ -166,7 +163,6 @@ export default function PixelPanel() { className={dock.pixelViewport} tabIndex={0} onKeyDown={onKeyDown} - onWheel={viewport.onWheel} > <div className={dock.pixelStage} diff --git a/web/src/app/pattern-lab/panels/pixel/usePixelViewport.ts b/web/src/app/pattern-lab/panels/pixel/usePixelViewport.ts index 88c5a8a07..f3d882e44 100644 --- a/web/src/app/pattern-lab/panels/pixel/usePixelViewport.ts +++ b/web/src/app/pattern-lab/panels/pixel/usePixelViewport.ts @@ -4,13 +4,24 @@ // box. The refs belong to the panel (it renders the elements); this hook is // handed them. -import { useCallback, useRef, useState, type RefObject } from "react"; +import { useCallback, useEffect, useLayoutEffect, useRef, useState, type RefObject } from "react"; import type { PixelLayer } from "@/lib/lab/types"; import type { Cell } from "./tools"; +import { WHEEL_NOTCH_PX, wheelPixels } from "./wheel"; export const ZOOM_MIN = 1; export const ZOOM_MAX = 32; +/** + * The largest whole zoom that shows the picture entire inside a viewport of + * this size: 16 px of padding a side and 4 px of air. Never below 1×. For + * 128×64 that is 1× in a 390×844 phone's 372×151 viewport, and 4× in the + * 649×648 one at 1440×900. + */ +export function zoomToFit(viewportWidth: number, viewportHeight: number, width: number, height: number) { + return Math.max(ZOOM_MIN, Math.floor(Math.min((viewportWidth - 40) / width, (viewportHeight - 40) / height))); +} + export function usePixelViewport({ pixel, viewportRef, @@ -21,6 +32,9 @@ export function usePixelViewport({ overlayCanvasRef: RefObject<HTMLCanvasElement | null>; }) { const [zoom, setZoom] = useState(6); + // The zoom the wheel listener reads: it runs between renders, and two wheel + // events can land before React commits the first. + const zoomRef = useRef(zoom); const [panning, setPanning] = useState(false); const [hoverCell, setHoverCell] = useState<Cell | null>(null); // Middle-drag panning of the zoomed viewport. @@ -59,22 +73,101 @@ export function usePixelViewport({ if (!pixel) return; const viewport = viewportRef.current; if (!viewport) return; - const next = Math.max( - 1, - Math.floor( - Math.min((viewport.clientWidth - 40) / pixel.width, (viewport.clientHeight - 40) / pixel.height), - ), - ); - setZoom(next); + setZoom(zoomToFit(viewport.clientWidth, viewport.clientHeight, pixel.width, pixel.height)); }, [pixel, viewportRef]); - const onWheel = useCallback( - (event: React.WheelEvent) => { + // The first time the viewport has a size, a picture that does not fit at + // 6× opens fitted. A 390 px phone showed 56 of 128 columns and 19 of 64 + // rows, and there a finger drag paints, so the rest was out of reach. The panel can mount + // before it is laid out (a tab behind another), hence the observer. Once + // only: after that the zoom is the user's, and Fit is one press. + const fitted = useRef(false); + const pictureWidth = pixel?.width ?? 0; + const pictureHeight = pixel?.height ?? 0; + useLayoutEffect(() => { + const viewport = viewportRef.current; + if (fitted.current || !viewport || pictureWidth === 0) return; + const fitOnce = () => { + if (viewport.clientWidth <= 0 || viewport.clientHeight <= 0) return false; + fitted.current = true; + const next = zoomToFit(viewport.clientWidth, viewport.clientHeight, pictureWidth, pictureHeight); + if (next < zoomRef.current) { + zoomRef.current = next; + setZoom(next); + } + return true; + }; + if (fitOnce()) return; + const observer = new ResizeObserver(() => { + if (fitOnce()) observer.disconnect(); + }); + observer.observe(viewport); + return () => observer.disconnect(); + }, [pictureWidth, pictureHeight, viewportRef]); + + // The wheel zooms. A native listener, because React delegates wheel + // passively and preventDefault there would let the viewport and the dock + // scroll under the zoom (useTimelineZoom does the same). A sideways wheel + // has no deltaY and stays a scroll. The viewport is only rendered while a + // pixel layer is selected, so the listener goes on when one is. + // + // One notch is one step, the step the ± buttons take. The travel adds up + // across events, so a trackpad swipe of thirty 4 px events is one step + // too, not thirty. Turning the other way drops what was left over. + const wheelRest = useRef(0); + // Where the cursor was over the picture (as a fraction of it) and on the + // screen, so the layout effect below can scroll that spot back under it. + const wheelAnchor = useRef<{ fx: number; fy: number; clientX: number; clientY: number } | null>(null); + const hasPixel = pixel !== undefined; + useEffect(() => { + const viewport = viewportRef.current; + if (!viewport) return; + const onWheel = (event: WheelEvent) => { + if (event.deltaY === 0) return; event.preventDefault(); - zoomBy(event.deltaY < 0 ? 1 : -1); - }, - [zoomBy], - ); + const notches = -wheelPixels(event) / WHEEL_NOTCH_PX; + const rest = Math.sign(wheelRest.current) === -Math.sign(notches) ? 0 : wheelRest.current; + const total = rest + notches; + const steps = Math.trunc(total); + wheelRest.current = total - steps; + const current = zoomRef.current; + const next = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, current + steps)); + if (next === current) { + // At either end the travel past it would otherwise wait there. + if (steps !== 0) wheelRest.current = 0; + return; + } + const rect = overlayCanvasRef.current?.getBoundingClientRect(); + if (rect && rect.width > 0 && rect.height > 0) { + const fraction = (offset: number, size: number) => Math.max(0, Math.min(1, offset / size)); + wheelAnchor.current = { + fx: fraction(event.clientX - rect.left, rect.width), + fy: fraction(event.clientY - rect.top, rect.height), + clientX: event.clientX, + clientY: event.clientY, + }; + } + zoomRef.current = next; + setZoom(next); + }; + viewport.addEventListener("wheel", onWheel, { passive: false }); + return () => viewport.removeEventListener("wheel", onWheel); + }, [hasPixel, viewportRef, overlayCanvasRef]); + + // After a zoom: scroll the spot the wheel was over back under the cursor. + // The browser clamps the scroll, so a picture smaller than the viewport + // stays centred. + useLayoutEffect(() => { + zoomRef.current = zoom; + const anchor = wheelAnchor.current; + wheelAnchor.current = null; + const viewport = viewportRef.current; + const canvas = overlayCanvasRef.current; + if (!anchor || !viewport || !canvas) return; + const rect = canvas.getBoundingClientRect(); + viewport.scrollLeft += rect.left + anchor.fx * rect.width - anchor.clientX; + viewport.scrollTop += rect.top + anchor.fy * rect.height - anchor.clientY; + }, [zoom, viewportRef, overlayCanvasRef]); /** Middle button: start panning. True when the press was taken. */ const beginPan = useCallback( @@ -121,7 +214,6 @@ export function usePixelViewport({ zoom, zoomBy, fitZoom, - onWheel, panning, hoverCell, setHoverCell, diff --git a/web/src/app/pattern-lab/panels/pixel/wheel.ts b/web/src/app/pattern-lab/panels/pixel/wheel.ts new file mode 100644 index 000000000..e7a2fdaea --- /dev/null +++ b/web/src/app/pattern-lab/panels/pixel/wheel.ts @@ -0,0 +1,16 @@ +// ── How far a wheel event went ─────────────────────────────────────────────── +// One mouse notch is 100 px in Chromium, or three lines. A trackpad sends the +// same travel as dozens of events a few pixels each, so a zoom that counts +// events races to its end on one swipe. The Pixel viewport and the image +// import both add this distance up instead. + +export const WHEEL_NOTCH_PX = 100; + +/** The event's vertical travel in pixels, whatever unit it came in. */ +export function wheelPixels(event: WheelEvent): number { + // deltaY first: Firefox reports lines only to a page that asks deltaMode first. + const dy = event.deltaY; + if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) return (dy * WHEEL_NOTCH_PX) / 3; + if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) return dy * WHEEL_NOTCH_PX; + return dy; +} diff --git a/web/src/app/pattern-lab/panels/registry.test.ts b/web/src/app/pattern-lab/panels/registry.test.ts new file mode 100644 index 000000000..9f81cf543 --- /dev/null +++ b/web/src/app/pattern-lab/panels/registry.test.ts @@ -0,0 +1,128 @@ +// The default dock, wide and narrow. At 390px the three columns left each +// panel about 110px, so a narrow dock stacks: Preview on top, the rest as +// tabs under it. The fake below keeps only what the layout code asks of +// dockview: which group a panel lands in, the order of the splits, the size +// it was given and which panel was fronted. + +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { DockviewApi } from "dockview-react"; +import { NARROW_PREVIEW_HEIGHT, PANELS, buildDefaultLayout, isNarrowDock } from "./registry"; + +type Placement = { id: string; ref: string | null; dir: string | null }; + +function fakeDock(width: number, height: number) { + const groups: string[][] = []; + const groupOf = new Map<string, string[]>(); + const placements: Placement[] = []; + const sizes = new Map<string, { width?: number; height?: number }>(); + let active: string | null = null; + + const api = { + width, + height, + addPanel(options: { id: string; position?: { referencePanel: string; direction: string } }) { + const ref = options.position?.referencePanel ?? null; + const dir = options.position?.direction ?? null; + if (ref !== null && !groupOf.has(ref)) throw new Error(`${options.id} placed before ${ref}`); + if (dir === "within" && ref !== null) { + const group = groupOf.get(ref)!; + group.push(options.id); + groupOf.set(options.id, group); + } else { + const group = [options.id]; + groups.push(group); + groupOf.set(options.id, group); + } + placements.push({ id: options.id, ref, dir }); + }, + getPanel(id: string) { + if (!groupOf.has(id)) return undefined; + return { + api: { + setActive: () => { + active = id; + }, + setSize: (size: { width?: number; height?: number }) => { + sizes.set(id, { ...sizes.get(id), ...size }); + }, + }, + }; + }, + }; + + return { api: api as unknown as DockviewApi, groups, placements, sizes, active: () => active }; +} + +function stubWidth(width: number) { + vi.stubGlobal("matchMedia", (query: string) => { + const max = /max-width:\s*(\d+)px/.exec(query); + return { matches: max ? width <= Number(max[1]) : false, media: query }; + }); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("narrow default layout", () => { + it("puts Preview alone on top and every other panel in one group below it, Code first", () => { + const dock = fakeDock(374, 723); + buildDefaultLayout(dock.api, true); + + expect(dock.groups).toEqual([ + ["preview"], + ["code", ...PANELS.filter((def) => def.id !== "preview" && def.id !== "code").map((def) => def.id)], + ]); + expect(dock.groups[1].slice(0, 3)).toEqual(["code", "layers", "pixel"]); + // One split, and it is a row: nothing sits beside Preview. + expect(dock.placements.filter((entry) => entry.dir !== "within" && entry.ref !== null)).toEqual([ + { id: "code", ref: "preview", dir: "below" }, + ]); + }); + + it("fronts Code and gives Preview its share of the height", () => { + const dock = fakeDock(374, 723); + buildDefaultLayout(dock.api, true); + + expect(dock.active()).toBe("code"); + expect(dock.sizes.get("preview")).toEqual({ height: Math.round(723 * NARROW_PREVIEW_HEIGHT) }); + // The column widths are the wide layout's; a narrow dock sets none. + expect([...dock.sizes.values()].some((size) => size.width !== undefined)).toBe(false); + }); +}); + +describe("wide default layout", () => { + it("places each panel where its registry entry says, columns before rows", () => { + const dock = fakeDock(1424, 819); + buildDefaultLayout(dock.api); + + expect(dock.placements.map((entry) => entry.id).sort()).toEqual(PANELS.map((def) => def.id).sort()); + for (const def of PANELS) { + const placed = dock.placements.find((entry) => entry.id === def.id); + expect(placed).toEqual({ id: def.id, ref: def.dock?.ref ?? null, dir: def.dock?.dir ?? null }); + } + const firstRow = dock.placements.findIndex((entry) => entry.dir === "below"); + const lastColumn = dock.placements.map((entry) => entry.dir).lastIndexOf("right"); + expect(lastColumn).toBeLessThan(firstRow); + expect(dock.groups).toHaveLength(5); + expect(dock.active()).toBe("code"); + }); +}); + +describe("isNarrowDock", () => { + it("is true on a 390px phone and false on a 1440px desktop", () => { + stubWidth(390); + expect(isNarrowDock()).toBe(true); + stubWidth(800); + expect(isNarrowDock()).toBe(true); + stubWidth(801); + expect(isNarrowDock()).toBe(false); + stubWidth(1440); + expect(isNarrowDock()).toBe(false); + }); + + it("is false where the browser has no matchMedia", () => { + vi.stubGlobal("matchMedia", undefined); + expect(isNarrowDock()).toBe(false); + }); +}); diff --git a/web/src/app/pattern-lab/panels/registry.tsx b/web/src/app/pattern-lab/panels/registry.tsx index 005fc3fc3..dec21b640 100644 --- a/web/src/app/pattern-lab/panels/registry.tsx +++ b/web/src/app/pattern-lab/panels/registry.tsx @@ -15,7 +15,7 @@ // panel to sit next to and the side, and buildDefaultLayout() adds panels in // whatever order satisfies that. // -// The heavy editors — Pixel, Director, Graphic Export, Gallery — are loaded +// The heavy editors — Pixel, Director, Graphic Export — are loaded // on demand. They are a third of the lab's code and most sessions open one // of them at most; before this every panel sat in the route's first chunk. @@ -35,7 +35,6 @@ export type PanelId = | "layers" | "code" | "pixel" - | "gallery" | "knobs" | "ramp" | "capture" @@ -79,7 +78,6 @@ export const PANELS: readonly PanelDef[] = [ { id: "layers", title: "Layers", component: LayersPanel, dock: { ref: "preview", dir: "below" }, size: { height: 0.34 } }, { id: "code", title: "Code", component: CodePanel, dock: { ref: "preview", dir: "right" }, active: true }, { id: "pixel", title: "Pixel", component: lazy(() => import("./PixelPanel")), dock: { ref: "code", dir: "within" } }, - { id: "gallery", title: "Gallery", component: lazy(() => import("./GalleryPanel")), dock: { ref: "code", dir: "within" } }, { id: "knobs", title: "Knobs", component: KnobsPanel, dock: { ref: "code", dir: "right" }, size: { width: 0.24 } }, { id: "ramp", title: "Color Ramp", component: RampPanel, dock: { ref: "knobs", dir: "below" }, size: { height: 0.42 } }, // Output stage (stills/clips at print sizes) — an add-on module, see lib/lab/capture. @@ -97,6 +95,24 @@ export const panelComponents: Record<string, FunctionComponent<IDockviewPanelPro PANELS.map((entry) => [entry.id, entry.component as FunctionComponent<IDockviewPanelProps>]), ); +/** + * At this width the default layout stacks instead of splitting into columns. + * Three columns on a 390px phone left each panel about 110px: the frame + * picker read "128 × 64 (Patter" and the Knobs range fields ran off the + * edge. 800px is where the header above the dock folds to two rows + * (LabPanels.module.css). + */ +export const NARROW_DOCK_QUERY = "(max-width: 800px)"; + +/** Asked once, when the dock is built. Turning a phone does not re-lay it; Reset layout does. */ +export function isNarrowDock(): boolean { + if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false; + return window.matchMedia(NARROW_DOCK_QUERY).matches; +} + +/** Share of a narrow dock's height that Preview keeps at the top: a 2:1 frame at full width, and its bar. */ +export const NARROW_PREVIEW_HEIGHT = 0.42; + /** * Lay every panel out from scratch, in an order that satisfies the references * so the list above can stay in menu order. @@ -107,8 +123,14 @@ export const panelComponents: Record<string, FunctionComponent<IDockviewPanelPro * relative to the grid as it stands, so "layers below preview" issued while * preview is the only column becomes a full-width strip at root level — the * layout this replaced had exactly that comment above its hand-ordered calls. + * + * A narrow dock ignores `dock` and `size`: see buildNarrowLayout. */ -export function buildDefaultLayout(api: DockviewApi) { +export function buildDefaultLayout(api: DockviewApi, narrow = false) { + if (narrow) { + buildNarrowLayout(api); + return; + } const placed = new Set<PanelId>(); const pending = [...PANELS]; const place = (def: PanelDef) => { @@ -146,3 +168,36 @@ export function buildDefaultLayout(api: DockviewApi) { if (def.size.height !== undefined) panel.api.setSize({ height: Math.round(height * def.size.height) }); } } + +/** + * The phone layout: the root panel (Preview) across the top, and every other + * panel a tab in one group below it. Each panel gets the full width, and the + * frame stays in sight while Code or Knobs is in front. + * + * The fronted panel is the first tab and the rest follow in menu order. In + * menu order Layers came first, and a 374px tab strip scrolled it out of + * sight to the left of Code. + */ +function buildNarrowLayout(api: DockviewApi) { + const root = PANELS.find((entry) => entry.dock === null); + if (!root) return; + const active = PANELS.find((entry) => entry.active && entry !== root); + const tabs = [...(active ? [active] : []), ...PANELS.filter((entry) => entry !== root && entry !== active)]; + api.addPanel({ id: root.id, component: root.id, title: root.title }); + let stack: PanelId | null = null; + for (const def of tabs) { + api.addPanel({ + id: def.id, + component: def.id, + title: def.title, + position: stack + ? { referencePanel: stack, direction: "within" } + : { referencePanel: root.id, direction: "below" }, + }); + stack ??= def.id; + } + if (active) api.getPanel(active.id)?.api.setActive(); + + const height = api.height || 700; + api.getPanel(root.id)?.api.setSize({ height: Math.round(height * NARROW_PREVIEW_HEIGHT) }); +} diff --git a/web/src/app/pattern/page.tsx b/web/src/app/pattern/page.tsx index 4db6b2faf..d449a2982 100644 --- a/web/src/app/pattern/page.tsx +++ b/web/src/app/pattern/page.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import HomeView from "@/components/HomeView"; +import { SITE_OPEN_GRAPH } from "@/lib/siteMetadata"; export const metadata: Metadata = { title: "Live Editor & Patterns — Patternflow", @@ -7,6 +8,7 @@ export const metadata: Metadata = { "Browse a preset library of LED matrix patterns and remix them in the browser Live Editor. Generate your own with AI, preview on virtual knobs, and flash to the device.", alternates: { canonical: "/pattern" }, openGraph: { + ...SITE_OPEN_GRAPH, title: "Live Editor & Patterns — Patternflow", description: "Browse and remix LED matrix patterns in the browser, generate your own with AI, and flash them to the device.", diff --git a/web/src/app/roadmap/Roadmap.module.css b/web/src/app/roadmap/Roadmap.module.css index 42fe50bb1..58e1e3825 100644 --- a/web/src/app/roadmap/Roadmap.module.css +++ b/web/src/app/roadmap/Roadmap.module.css @@ -70,6 +70,7 @@ font-size: 12px; letter-spacing: 0.05em; text-transform: uppercase; + white-space: nowrap; cursor: pointer; } @@ -82,6 +83,26 @@ color: var(--pf-cream); } +/* One row of back link, title and both toggle groups is 572px wide. Below + that the toggles ended at 422px on a 390px phone and cut 「상세보기」 in + half, so on a phone they take the second row. */ +@media (max-width: 640px) { + .topBar { + flex-wrap: wrap; + gap: 8px 16px; + padding: 10px 16px; + } + + .headerRight { + flex-basis: 100%; + margin-left: 0; + } + + .toggleBtn { + padding: 10px 14px; + } +} + .legend { display: flex; align-items: center; diff --git a/web/src/app/roadmap/RoadmapMap.test.tsx b/web/src/app/roadmap/RoadmapMap.test.tsx new file mode 100644 index 000000000..ba61fbd20 --- /dev/null +++ b/web/src/app/roadmap/RoadmapMap.test.tsx @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import RoadmapMap from './RoadmapMap'; +import { NODES } from './roadmap-data'; + +beforeEach(() => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ json: async () => ({ issues: [] }) })), + ); +}); + +function mount() { + const { container } = render(<RoadmapMap />); + // Let the /api/roadmap answer land before the test looks around. + return waitFor(() => expect(fetch).toHaveBeenCalled()).then(() => container.firstElementChild as HTMLElement); +} + +describe('Project map language', () => { + it('opens in Korean and says so, under an English document', async () => { + const map = await mount(); + expect(map).toHaveAttribute('lang', 'ko'); + expect(screen.getByRole('heading', { name: 'Project map' })).toHaveAttribute('lang', 'en'); + expect(screen.getByRole('link', { name: '← Patternflow' })).toHaveAttribute('lang', 'en'); + expect(screen.getByRole('group', { name: '언어' })).toBeInTheDocument(); + expect(screen.getByRole('img', { name: /^Patternflow 프로젝트 지도/ })).toBeInTheDocument(); + }); + + it('names each language in its own language', async () => { + await mount(); + expect(screen.getByRole('button', { name: '한글' })).toHaveAttribute('lang', 'ko'); + expect(screen.getByRole('button', { name: 'ENG' })).toHaveAttribute('lang', 'en'); + }); + + it('switches the map to English and marks the pressed button', async () => { + const map = await mount(); + const ko = screen.getByRole('button', { name: '한글' }); + const en = screen.getByRole('button', { name: 'ENG' }); + expect(ko).toHaveAttribute('aria-pressed', 'true'); + expect(en).toHaveAttribute('aria-pressed', 'false'); + + fireEvent.click(en); + expect(map).toHaveAttribute('lang', 'en'); + expect(ko).toHaveAttribute('aria-pressed', 'false'); + expect(en).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByRole('group', { name: 'Language' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Overview' })).toBeInTheDocument(); + expect(screen.getByRole('img', { name: /^Patternflow project map/ })).toBeInTheDocument(); + }); +}); + +describe('Project map detail level', () => { + it('marks the overview pressed and shows the level-2 nodes once detailed is pressed', async () => { + await mount(); + const overview = screen.getByRole('button', { name: '개요' }); + const detailed = screen.getByRole('button', { name: '상세보기' }); + const nodeCount = () => + screen.getAllByRole('button').filter((el) => el.tagName.toLowerCase() === 'g').length; + + expect(overview).toHaveAttribute('aria-pressed', 'true'); + expect(detailed).toHaveAttribute('aria-pressed', 'false'); + expect(nodeCount()).toBe(NODES.filter((n) => n.level === 1).length); + + fireEvent.click(detailed); + expect(overview).toHaveAttribute('aria-pressed', 'false'); + expect(detailed).toHaveAttribute('aria-pressed', 'true'); + expect(nodeCount()).toBe(NODES.length); + }); +}); + +describe('Project map nodes in Korean', () => { + it('reads each node state in Korean and closes the popup with 닫기', async () => { + await mount(); + const done = NODES.find((n) => n.level === 1 && n.status === 'done' && n.titleKo); + const planned = NODES.find((n) => n.level === 1 && n.status === 'planned' && n.titleKo); + expect(done && planned).toBeTruthy(); + expect(screen.getByRole('button', { name: `${done!.titleKo}, 완료` })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: `${planned!.titleKo}, 계획` })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: `${done!.titleKo}, 완료` })); + expect(screen.getByRole('heading', { name: done!.titleKo })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '닫기' })); + expect(screen.queryByRole('heading', { name: done!.titleKo })).not.toBeInTheDocument(); + }); +}); diff --git a/web/src/app/roadmap/RoadmapMap.tsx b/web/src/app/roadmap/RoadmapMap.tsx index cca6cee5f..ece8b382a 100644 --- a/web/src/app/roadmap/RoadmapMap.tsx +++ b/web/src/app/roadmap/RoadmapMap.tsx @@ -1,7 +1,7 @@ 'use client'; import Link from 'next/link'; -import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; import styles from './Roadmap.module.css'; import { EDGES, @@ -233,7 +233,7 @@ export default function RoadmapMap() { x: Math.min(0, Math.round(cw * 0.62 - nowX)), y: Math.round((ch - HEIGHT) / 2), }); - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps -- not on nowX: it moves with the canvas width, and a resize would undo the reader's own pan }, [today]); const gate = useMemo(() => { @@ -249,7 +249,7 @@ export default function RoadmapMap() { return { x: left, y, w: right - left, h }; }, [nodes]); - const clampPan = (x: number, y: number, z = zoom) => { + const clampPan = useCallback((x: number, y: number, z = zoom) => { const el = canvasRef.current; const cw = el ? el.clientWidth : width; const ch = el ? el.clientHeight : HEIGHT; @@ -267,20 +267,7 @@ export default function RoadmapMap() { x: Math.min(maxX, Math.max(minX, x)), y: Math.min(maxY, Math.max(minY, y)), }; - }; - - const zoomTo = (next: number) => { - const nz = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, Math.round(next * 10) / 10)); - if (nz === zoom) return; - const el = canvasRef.current; - const cx = el ? el.clientWidth / 2 : 0; - const cy = el ? el.clientHeight / 2 : 0; - // Keep the point at the canvas center fixed while zooming. - setPan( - clampPan(cx - ((cx - pan.x) / zoom) * nz, cy - ((cy - pan.y) / zoom) * nz, nz), - ); - setZoom(nz); - }; + }, [width, zoom]); useEffect(() => { const el = canvasRef.current; @@ -312,7 +299,7 @@ export default function RoadmapMap() { return () => { el.removeEventListener('wheel', handleWheel); }; - }, [width, zoom]); + }, [width, zoom, clampPan]); const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => { dragRef.current = { @@ -396,18 +383,27 @@ export default function RoadmapMap() { // Timeline ticks and Today/Future labels stay pinned to the top edge of the canvas when panning vertically const stickyHeaderY = Math.max(TOP - 32, (18 - pan.y) / zoom); + // The page opens in Korean under <html lang="en">, so the map says which + // language it is in; a screen reader otherwise reads the Korean in an + // English voice. The back link and the title stay English in both. return ( - <div className={styles.map}> + <div className={styles.map} lang={lang}> <header className={styles.topBar}> - <Link className={styles.back} href="/"> + <Link className={styles.back} href="/" prefetch={false} lang="en"> ← Patternflow </Link> - <h1 className={styles.mapTitle}>Project map</h1> + <h1 className={styles.mapTitle} lang="en">Project map</h1> <div className={styles.headerRight}> - <div className={styles.toggleGroup} role="group" aria-label="Language"> + <div + className={styles.toggleGroup} + role="group" + aria-label={lang === 'ko' ? '언어' : 'Language'} + > <button type="button" className={`${styles.toggleBtn} ${lang === 'ko' ? styles.toggleOn : ''}`} + aria-pressed={lang === 'ko'} + lang="ko" onClick={() => setLang('ko')} > 한글 @@ -415,15 +411,22 @@ export default function RoadmapMap() { <button type="button" className={`${styles.toggleBtn} ${lang === 'en' ? styles.toggleOn : ''}`} + aria-pressed={lang === 'en'} + lang="en" onClick={() => setLang('en')} > ENG </button> </div> - <div className={styles.toggleGroup} role="group" aria-label="Map detail level"> + <div + className={styles.toggleGroup} + role="group" + aria-label={lang === 'ko' ? '지도 보기' : 'Map detail level'} + > <button type="button" className={`${styles.toggleBtn} ${!detailed ? styles.toggleOn : ''}`} + aria-pressed={!detailed} onClick={() => setDetailed(false)} > {lang === 'ko' ? '개요' : 'Overview'} @@ -431,6 +434,7 @@ export default function RoadmapMap() { <button type="button" className={`${styles.toggleBtn} ${detailed ? styles.toggleOn : ''}`} + aria-pressed={detailed} onClick={() => setDetailed(true)} > {lang === 'ko' ? '상세보기' : 'Detailed'} @@ -459,7 +463,11 @@ export default function RoadmapMap() { width={width} height={HEIGHT} role="img" - aria-label="Patternflow project map: past releases and planned work by area over time" + aria-label={ + lang === 'ko' + ? 'Patternflow 프로젝트 지도: 분야별로 시간 순에 놓은 지난 릴리스와 계획한 일' + : 'Patternflow project map: past releases and planned work by area over time' + } > <defs> <marker @@ -607,7 +615,7 @@ export default function RoadmapMap() { className={styles.nodeGroup} role="button" tabIndex={0} - aria-label={`${titleText}, ${n.status}`} + aria-label={`${titleText}, ${lang === 'ko' ? (n.status === 'planned' ? '계획' : '완료') : n.status}`} onClick={() => handleNodeClick(n.id)} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { @@ -706,10 +714,9 @@ export default function RoadmapMap() { <button type="button" className={styles.popupClose} - aria-label="Close" onClick={() => setSelectedId(null)} > - Close + {lang === 'ko' ? '닫기' : 'Close'} </button> </div> <h2 className={styles.popupTitle}>{nodeTitle(selected, lang)}</h2> diff --git a/web/src/app/roadmap/roadmap-data.ts b/web/src/app/roadmap/roadmap-data.ts index d15c2131d..1b1750285 100644 --- a/web/src/app/roadmap/roadmap-data.ts +++ b/web/src/app/roadmap/roadmap-data.ts @@ -463,7 +463,7 @@ export const NODES: RoadmapNode[] = [ 'v2.0.0 unified the versioning: project, firmware, PCB and case became one Patternflow version instead of four. It carried the GPIO0 cold-boot fix, the custom-pattern workflow doc, canonical pattern names (Origin, Wave Saw), and a web platform that was by then substantially complete — flasher, Live Editor, journal and build map.', detailKo: 'v2.0.0에서 버전 체계를 하나로 통일했습니다. 프로젝트·펌웨어·PCB·케이스가 각자 버전을 갖는 대신 하나의 패턴플로우 버전으로 묶였습니다. GPIO0 콜드부트 수정, 커스텀 패턴 제작 문서, 패턴 이름 정규화(Origin, Wave Saw)가 들어갔고, 이 시점의 웹(플래셔, 라이브 에디터, 저널, 빌드 맵)은 이미 상당 부분 완성된 상태였습니다.', - links: [{ label: 'CHANGELOG (2.0.0)', href: `${REPO}/blob/main/CHANGELOG.md` }], + links: [{ label: 'CHANGELOG (2.0.0)', href: `${REPO}/blob/main/docs/releases/v2.0.0.md` }], }, { id: 'fw-foundation', diff --git a/web/src/app/sitemap.ts b/web/src/app/sitemap.ts index 5ff658f3e..41d7c7886 100644 --- a/web/src/app/sitemap.ts +++ b/web/src/app/sitemap.ts @@ -11,6 +11,7 @@ export default function sitemap(): MetadataRoute.Sitemap { { url: `${siteUrl}/`, lastModified: now, changeFrequency: "weekly", priority: 1 }, { url: `${siteUrl}/pattern`, lastModified: now, changeFrequency: "weekly", priority: 0.9 }, { url: `${siteUrl}/build`, lastModified: now, changeFrequency: "monthly", priority: 0.8 }, + { url: `${siteUrl}/build/breadboard`, lastModified: now, changeFrequency: "monthly", priority: 0.5 }, { url: `${siteUrl}/inside`, lastModified: now, changeFrequency: "monthly", priority: 0.7 }, { url: `${siteUrl}/roadmap`, lastModified: now, changeFrequency: "weekly", priority: 0.6 }, // The guide: the hub, then its four guides in order — Build (soldering @@ -33,6 +34,8 @@ export default function sitemap(): MetadataRoute.Sitemap { { url: `${siteUrl}/editions`, lastModified: now, changeFrequency: "monthly", priority: 0.6 }, // The catalogue of features, with a reel of each - the other axis of the shelf. { url: `${siteUrl}/features`, lastModified: now, changeFrequency: "monthly", priority: 0.6 }, + // Where a device's console sends you when a newer firmware exists. + { url: `${siteUrl}/update`, lastModified: now, changeFrequency: "monthly", priority: 0.4 }, { url: `${siteUrl}/contact`, lastModified: now, changeFrequency: "yearly", priority: 0.5 }, { url: `${siteUrl}/terms`, lastModified: now, changeFrequency: "yearly", priority: 0.3 }, // Printed on the safety leaflet in every box as the address of the EU diff --git a/web/src/app/terms/Terms.module.css b/web/src/app/terms/Terms.module.css index f412f5bb2..2989737ea 100644 --- a/web/src/app/terms/Terms.module.css +++ b/web/src/app/terms/Terms.module.css @@ -34,6 +34,7 @@ .brand:hover { color: var(--pf-led); + text-decoration: none; } .head h1 { diff --git a/web/src/app/terms/page.tsx b/web/src/app/terms/page.tsx index 8f7bd5f79..5914603c2 100644 --- a/web/src/app/terms/page.tsx +++ b/web/src/app/terms/page.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import Link from "next/link"; +import { SITE_OPEN_GRAPH } from "@/lib/siteMetadata"; import styles from "./Terms.module.css"; // Terms of use + privacy notice, deliberately as one page. Two documents means @@ -15,6 +16,7 @@ export const metadata: Metadata = { title: "Terms & Privacy / Patternflow", description: "Terms of use and privacy notice for the Patternflow community: who owns what, what we do with your patterns, how to report something, and what data we keep.", + openGraph: { ...SITE_OPEN_GRAPH, url: "/terms" }, }; const UPDATED = "24 September 2026"; @@ -25,7 +27,7 @@ export default function TermsPage() { <main className={styles.page}> <article className={styles.doc}> <header className={styles.head}> - <Link href="/" className={styles.brand}> + <Link href="/" prefetch={false} className={styles.brand}> Patternflow </Link> <h1>Terms & Privacy</h1> diff --git a/web/src/app/update/UpdateClient.tsx b/web/src/app/update/UpdateClient.tsx index 9fe4ebc56..020e141ca 100644 --- a/web/src/app/update/UpdateClient.tsx +++ b/web/src/app/update/UpdateClient.tsx @@ -36,7 +36,7 @@ function appImagePath(manifest: Manifest): string | null { } export default function UpdateClient() { - const { deviceHost, changeDeviceHost } = useDeviceHost(); + const { deviceHost, host, changeDeviceHost } = useDeviceHost(); const [manifest, setManifest] = useState<Manifest | null>(null); const [failed, setFailed] = useState(false); @@ -60,7 +60,6 @@ export default function UpdateClient() { ? new URL(`/flash/${imagePath}`, window.location.origin).toString() : null; - const host = deviceHost.trim() || "patternflow.local"; const wirelessUrl = imageUrl ? `http://${host}/update?src=${encodeURIComponent(imageUrl)}` : null; diff --git a/web/src/app/update/page.tsx b/web/src/app/update/page.tsx index d3cf1a391..b6e66b27c 100644 --- a/web/src/app/update/page.tsx +++ b/web/src/app/update/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import { SITE_OPEN_GRAPH } from "@/lib/siteMetadata"; import UpdateClient from "./UpdateClient"; // /update — where a device's console sends you when a newer firmware exists. @@ -11,6 +12,7 @@ export const metadata: Metadata = { title: "Firmware update / Patternflow", description: "Update your Patternflow over Wi-Fi. Your patterns, Wi-Fi networks and storage are untouched — an update rewrites the program only.", + openGraph: { ...SITE_OPEN_GRAPH, url: "/update" }, }; export default function UpdatePage() { diff --git a/web/src/app/update/update.module.css b/web/src/app/update/update.module.css index 50426d713..9bb6824b2 100644 --- a/web/src/app/update/update.module.css +++ b/web/src/app/update/update.module.css @@ -126,6 +126,7 @@ .go:hover { background: transparent; color: var(--pf-ink); + text-decoration: none; } .goDisabled { diff --git a/web/src/components/3d/HeroScene.module.css b/web/src/components/3d/HeroScene.module.css index e2f02ff8a..9cdb16d6a 100644 --- a/web/src/components/3d/HeroScene.module.css +++ b/web/src/components/3d/HeroScene.module.css @@ -26,6 +26,37 @@ transition-delay: 0.25s; } +/* Whose case is on screen, over the device on the Build tab: the build map's + name label (GlobeViewer.module.css, .maker) at the top of the frame, the + name alone on a highlight that hugs it. */ + +.title { + position: absolute; + top: 34px; + left: 0; + right: 0; + z-index: 10; + text-align: center; + pointer-events: none; +} + +.titleName { + padding: 3px 11px; + background: rgba(13, 13, 13, 0.72); + color: var(--cream); + font-size: 44px; + font-weight: 500; + letter-spacing: -0.025em; + line-height: 1.28; + white-space: nowrap; +} + +/* The hint (globals.css, .viewer-hint: 40px from the top) gives the top of + the frame to the title and sits under it. */ +:global(.viewer-hint).hintUnderTitle { + top: 108px; +} + /* The arrows either side of the device on the Build tab, stepping through the cases: the Inside globe's arrows (GlobeViewer.module.css), at the middle of the frame, where the device is. */ @@ -73,6 +104,22 @@ font-size: 11px; } + /* The viewer is a strip across the top of the screen here: the title and + the hint close up, clear of the device, at the build map's phone size. */ + .title { + top: 14px; + } + + .titleName { + padding: 2px 7px; + font-size: 21px; + line-height: 1.24; + } + + :global(.viewer-hint).hintUnderTitle { + top: 50px; + } + .arrow svg { width: 44px; height: 44px; diff --git a/web/src/components/3d/HeroScene.tsx b/web/src/components/3d/HeroScene.tsx index e71b56bcf..5008665d6 100644 --- a/web/src/components/3d/HeroScene.tsx +++ b/web/src/components/3d/HeroScene.tsx @@ -27,8 +27,9 @@ import patterns from './patterns'; import { useAppStore } from '@/store/useAppStore'; import { LedMatrixTexture } from './LedMatrixTexture'; import { LOGICAL_KNOB_TO_WEB_KNOB, knobUnitsPerTurn, webKnobRange } from '@/lib/pattern/controls'; -import { BUILD_CASES, stepCase, writeCaseToUrl, type CaseId } from '@/components/sections/build-cases-data'; +import { BUILD_CASES, findCase, stepCase, writeCaseToUrl, type CaseId } from '@/components/sections/build-cases-data'; import { captureEvent } from '@/lib/posthogEvents'; +import { useReducedMotion } from '@/lib/hooks/useReducedMotion'; import { CASE_MODELS, DRACO_DECODER, type CaseFinish } from './caseModels'; import { preloadCaseModel } from './preloadCaseModel'; import { @@ -40,7 +41,7 @@ import { type KnobRig, type PreparedCase, } from './heroCase'; -import { poseFor, shows } from './buildPose'; +import { fitAcross, poseFor, shows } from './buildPose'; import CaseFinishSwitch from './CaseFinishSwitch'; import { PageAlpha, StraightAlpha } from './canvasAlpha'; import { NeutralToeBack } from './neutralToe'; @@ -100,11 +101,22 @@ const MESH_TO_KNOB: Record<string, KnobId> = { const TARGET = new THREE.Vector3(0, 1.7, 0); const Y_AXIS = new THREE.Vector3(0, 1, 0); +/** The camera as the page opens: where it stands, and its vertical field of view. */ +const CAMERA_POSITION: [number, number, number] = [0.0, 6.0, 10.3]; +const CAMERA_FOV = 28; +/** + * How tall the view is where the device stands, with the camera at rest, in + * world units; times the canvas's aspect, how wide (buildPose.ts, fitAcross). + */ +const REST_VIEW_HEIGHT = + 2 * Math.tan(THREE.MathUtils.degToRad(CAMERA_FOV / 2)) * TARGET.distanceTo(new THREE.Vector3(...CAMERA_POSITION)); + /** * Solder (step 2) shows the board alone, filling the view: the diagonal of * the board-and-DevKit's box, seen from the front, is drawn this many world * units across. The official device as a whole is 4.09 at the resting scale - * (the remixes, up to 4% more). + * (Besoiobiy's and SimonePDA's up to 4% more; mbchars', lying the long way + * on its stands, 11% more, and two thirds wider). */ const BOARD_SPAN = 3.1; @@ -117,14 +129,14 @@ const ease = (perFrame: number, dt: number) => 1 - Math.pow(1 - perFrame, Math.m * the canvas and take the page with it. */ class ModelGuard extends Component<{ onError: () => void; children: ReactNode }, { failed: boolean }> { - state = { failed: false }; + override state = { failed: false }; static getDerivedStateFromError() { return { failed: true }; } - componentDidCatch() { + override componentDidCatch() { this.props.onError(); } - render() { + override render() { return this.state.failed ? null : this.props.children; } } @@ -208,10 +220,18 @@ function ProductPreview({ const activePatternId = useAppStore((state) => state.activePatternId); const customJsCode = useAppStore((state) => state.customJsCode); + // The idle sway goes on for as long as the page is open, so someone who has + // asked for less motion gets the device standing still (poseFor). + const still = useReducedMotion(); const pattern = patterns[activePatternId] || patterns['patternFlowOriginal']; const defaults = useMemo(() => pattern.defaults || {}, [pattern]); - const ledMatrix = useMemo(() => new LedMatrixTexture(), []); + // The texture says when the editor's code stops running, not each frame, so + // the panel under the editor re-renders only when the reason changes. + const ledMatrix = useMemo( + () => new LedMatrixTexture((message) => useAppStore.getState().setCustomJsError(message)), + [], + ); useEffect(() => { if (activePatternId === 'custom') { @@ -219,6 +239,9 @@ function ProductPreview({ } }, [customJsCode, activePatternId, ledMatrix]); + // The globe replaces this scene on the Inside tab; the next one starts clean. + useEffect(() => () => useAppStore.getState().setCustomJsError(null), []); + const ledMat = useMemo(() => new THREE.ShaderMaterial({ uniforms: { uTime: { value: 0 }, @@ -385,7 +408,7 @@ function ProductPreview({ const device = deviceRef.current; if (!group || !device) return; - const pose = poseFor(buildStep, explode, t); + const pose = poseFor(buildStep, explode, t, still); // A model that has just arrived starts where the step has everything, // not assembled and easing out from there: a swap is a cut, not a move. const fresh = !device.root.userData.settled; @@ -438,6 +461,10 @@ function ProductPreview({ if (pose.board) { const size = device.board.getSize(tmp.v); scale = BOARD_SPAN / Math.hypot(size.x, size.y); + } else { + // A device too wide for a tall view at the one scale is drawn smaller, + // so it stays clear of the arrows (fitAcross); any other, as it is. + scale *= fitAcross(device.width, REST_VIEW_HEIGHT * (state.size.width / state.size.height)); } group.rotation.y = THREE.MathUtils.lerp(group.rotation.y, pose.turn, kView); @@ -528,12 +555,25 @@ export default function HeroScene() { captureEvent('build_case_selected', { case_id: id, interaction: 'click', surface: 'product_preview' }); }; + // Over the device on the Build tab, whose case it is, in the build map's + // label: the name on its tab of the panel's switch, and nothing else. It + // changes with the pick, as the tab does, ahead of a model still loading + // (the note under the device says so), and reads Official for a case whose + // model did not load, since that is the one drawn. + const titled = homeTab === 'build' ? findCase(wanted) : undefined; + return ( <div id="three-canvas" style={{ width: '100%', height: '100%', position: 'relative' }}> + {titled && ( + <div className={styles.title}> + <span className={styles.titleName}>{titled.tab}</span> + </div> + )} + {/* 조작 안내 문구 (최초 1회 조작 시 서서히 사라짐) */} <div - className="viewer-hint" + className={`viewer-hint${titled ? ` ${styles.hintUnderTitle}` : ''}`} style={{ position: 'absolute', left: '0', width: '100%', textAlign: 'center', @@ -584,7 +624,7 @@ export default function HeroScene() { /> )} - <Canvas camera={{ position: [0.0, 6.0, 10.3], fov: 28 }} dpr={[1, 2]} shadows={{ type: THREE.PCFShadowMap }}> + <Canvas camera={{ position: CAMERA_POSITION, fov: CAMERA_FOV }} dpr={[1, 2]} shadows={{ type: THREE.PCFShadowMap }}> <ambientLight intensity={0.3} color="#fef6e8" /> <directionalLight position={[2.3, 3.9, 6]} intensity={2.60} color="#ffffff" castShadow shadow-mapSize-width={2048} shadow-mapSize-height={2048} diff --git a/web/src/components/3d/LedMatrixTexture.test.ts b/web/src/components/3d/LedMatrixTexture.test.ts new file mode 100644 index 000000000..855183bea --- /dev/null +++ b/web/src/components/3d/LedMatrixTexture.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it, vi } from 'vitest'; +import { LedMatrixTexture } from './LedMatrixTexture'; + +// The landing page's Live Editor preview. Code pasted into the editor that +// threw used to go to console.error and nowhere else: sixty lines a second in +// the console, nothing on the page. + +const knobs = { c1: 0, c2: 2, c3: 0.06, c4: 0 }; + +const RED = `export function draw(display) { + display.setPixel(0, 0, 255, 0, 0); +}`; + +const THROWS_IN_DRAW = `export function setup(params) { + params.t = 0; +} + +export function draw(display, params) { + const v = missing * 2; + display.setPixel(0, 0, v, v, v); +}`; + +function frames(texture: LedMatrixTexture, count: number) { + for (let i = 0; i < count; i++) texture.render(1 / 60, i / 60, knobs, knobs); +} + +/** Pixel (0, 0) on the panel: the texture stores rows bottom-up. */ +function topLeft(texture: LedMatrixTexture) { + const data = texture.texture.image.data as Uint8Array; + const idx = 63 * 128 * 4; + return [data[idx], data[idx + 1], data[idx + 2]]; +} + +function setup() { + vi.spyOn(console, 'error').mockImplementation(() => {}); + const onError = vi.fn<(message: string | null) => void>(); + return { texture: new LedMatrixTexture(onError), onError }; +} + +describe('LedMatrixTexture errors', () => { + it('reports a draw() that throws once, not on every frame, with its line', () => { + const { texture, onError } = setup(); + texture.loadCode(THROWS_IN_DRAW); + frames(texture, 60); + + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith('missing is not defined\nline 6: const v = missing * 2;'); + expect(console.error).toHaveBeenCalledTimes(1); + }); + + it('keeps the last pattern that ran while the code does not parse', () => { + const { texture, onError } = setup(); + texture.loadCode(RED); + frames(texture, 1); + texture.loadCode('export function draw(display) {\n display.setPixel(0, 0,'); + frames(texture, 1); + + expect(onError).toHaveBeenCalledTimes(1); + expect(onError.mock.calls[0][0]).toMatch(/^The code stops before it ends: 1 \( and 1 \{ never close\./); + expect(topLeft(texture)).toEqual([255, 0, 0]); + }); + + it('keeps the last pattern when setup() throws, and quotes the setup line', () => { + const { texture, onError } = setup(); + texture.loadCode(RED); + texture.loadCode(`export function setup(params) { + params.grid = params.cells.map(() => 0); +} + +export function draw(display) { + display.setPixel(0, 0, 0, 0, 255); +}`); + frames(texture, 1); + + expect(onError).toHaveBeenCalledTimes(1); + expect(onError.mock.calls[0][0]).toMatch(/\nline 2: params\.grid = params\.cells\.map\(\(\) => 0\);$/); + expect(topLeft(texture)).toEqual([255, 0, 0]); + }); + + it('says what is missing when the code has no draw()', () => { + const { texture, onError } = setup(); + texture.loadCode('export function setup(params) {}'); + + expect(onError).toHaveBeenCalledWith('Pattern must export draw(display, params, time).'); + }); + + it('clears the message once the code runs again', () => { + const { texture, onError } = setup(); + texture.loadCode(THROWS_IN_DRAW); + frames(texture, 2); + texture.loadCode(RED); + frames(texture, 2); + + expect(onError.mock.calls.map(([message]) => message === null)).toEqual([false, true]); + expect(topLeft(texture)).toEqual([255, 0, 0]); + }); + + it('says nothing for code that runs', () => { + const { texture, onError } = setup(); + texture.loadCode(RED); + frames(texture, 60); + + expect(onError).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/components/3d/LedMatrixTexture.ts b/web/src/components/3d/LedMatrixTexture.ts index adf1d9eb7..f8064dde8 100644 --- a/web/src/components/3d/LedMatrixTexture.ts +++ b/web/src/components/3d/LedMatrixTexture.ts @@ -1,6 +1,6 @@ import * as THREE from 'three'; import { getKnobValueDelta, LOGICAL_KNOB_RANGES, LOGICAL_KNOB_TO_WEB_KNOB, toEncoderDelta } from '@/lib/pattern/controls'; -import { compilePatternCode, type PatternModule, type PatternParams } from '@/lib/pattern/harness'; +import { compilePatternCode, describePatternError, type PatternModule, type PatternParams } from '@/lib/pattern/harness'; type WebKnobValues = Record<'c1' | 'c2' | 'c3' | 'c4', number>; @@ -23,6 +23,10 @@ export class LedMatrixTexture { private userModule: PatternModule | null = null; private userParams: PatternParams = {}; + // The code userModule was compiled from, so a throw from update() or draw() + // can quote its line. + private source: string | null = null; + private error: string | null = null; // Create a display wrapper to match ESP32 API structure private displayApi = { @@ -71,25 +75,43 @@ export class LedMatrixTexture { } }; - constructor() { + /** + * `onError` hears why the editor's code is not running, with the line it + * failed on when the stack gives one, and null once new code runs. Only + * when that changes: a draw() that throws throws on every frame. + */ + constructor(private readonly onError?: (message: string | null) => void) { this.data = new Uint8Array(this.width * this.height * 4); this.texture = new THREE.DataTexture(this.data, this.width, this.height, THREE.RGBAFormat); this.texture.needsUpdate = true; } - // Evaluate the custom code and setup the module + private setError(message: string | null) { + if (message === this.error) return; + this.error = message; + this.onError?.(message); + } + + // Evaluate the custom code and setup the module. Code that will not compile + // or set up leaves the last pattern that did running: while someone types, + // most keystrokes leave code that does not parse yet. public loadCode(code: string) { try { - this.userModule = compilePatternCode(code); - this.userParams = {}; - - this.userModule.setup?.(this.userParams); - + const userModule = compilePatternCode(code); + const userParams: PatternParams = {}; + userModule.setup?.(userParams); + + this.userModule = userModule; + this.userParams = userParams; + this.source = code; + this.setError(null); + // Clear the screen this.data.fill(0); this.texture.needsUpdate = true; } catch (e) { console.error("Failed to load pattern code:", e); + this.setError(describePatternError(e, code)); } } @@ -139,7 +161,11 @@ export class LedMatrixTexture { // Update texture this.texture.needsUpdate = true; } catch (e) { + // The first throw stays until the code changes. A pattern that throws + // on some frames only would otherwise flash the message on and off. + if (this.error !== null) return; console.error("Runtime error in pattern code:", e); + this.setError(describePatternError(e, this.source)); } } } diff --git a/web/src/components/3d/ViewerPanel.test.tsx b/web/src/components/3d/ViewerPanel.test.tsx new file mode 100644 index 000000000..267cd2281 --- /dev/null +++ b/web/src/components/3d/ViewerPanel.test.tsx @@ -0,0 +1,159 @@ +import { hydrateRoot } from 'react-dom/client'; +import { renderToString } from 'react-dom/server'; +import { act, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useAppStore, type HomeTabType } from '@/store/useAppStore'; +import ViewerPanel from './ViewerPanel'; + +// The home page's 3D view is the build globe (hero, Inside) or the product +// preview (Build, Pattern). A browser that gives no WebGL 2 context used to +// throw as either scene made its renderer, and the error went up to the +// site's error screen with the tabs beside it. Now the view asks first, and +// a scene that throws anyway is taken off; both leave a line in its place. + +const webgl = vi.hoisted(() => ({ answer: true, asked: 0 })); +vi.mock('@/lib/webgl', () => ({ + hasWebGL: () => { + webgl.asked++; + return webgl.answer; + }, +})); + +// The scenes stand in for themselves; one can be told to throw as it is +// made, as R3F's Canvas does when the context is refused. +const scenes = vi.hoisted(() => ({ throws: new Set<string>(), made: [] as string[] })); +function Scene({ name }: { name: string }) { + scenes.made.push(name); + if (scenes.throws.has(name)) throw new Error('Error creating WebGL context.'); + return <div data-testid="scene">{name}</div>; +} +vi.mock('./HeroScene', () => ({ default: () => <Scene name="product" /> })); +vi.mock('next/dynamic', () => ({ default: () => () => <Scene name="globe" /> })); + +const LINE = 'This browser can’t draw the 3D view.'; + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + webgl.answer = true; + webgl.asked = 0; + scenes.throws.clear(); + scenes.made = []; + useAppStore.setState({ homeTab: 'hero' }); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +// The tabs beside the view, which must outlive it. The page passes the tab +// it was made for, as HomeView does. +function Page({ tab = 'hero' }: { tab?: HomeTabType }) { + return ( + <> + <ViewerPanel initialTab={tab} /> + <button type="button">Build</button> + </> + ); +} + +describe('ViewerPanel', () => { + it('shows the globe on the hero and the product on Build where WebGL is there', () => { + render(<Page />); + expect(screen.getByTestId('scene')).toHaveTextContent('globe'); + act(() => useAppStore.setState({ homeTab: 'build' })); + // (The fade-through swaps the scene 200 ms later.) + act(() => vi.advanceTimersByTime(200)); + expect(screen.getByTestId('scene')).toHaveTextContent('product'); + expect(screen.queryByText(LINE)).not.toBeInTheDocument(); + }); + + it.each(['hero', 'build', 'pattern', 'inside'] as const)( + 'makes no scene on %s where there is no WebGL, and says so', + (tab) => { + webgl.answer = false; + useAppStore.setState({ homeTab: tab }); + render(<Page tab={tab} />); + expect(scenes.made).toEqual([]); + expect(screen.getByText(LINE)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Build' })).toBeInTheDocument(); + }, + ); + + it('takes off a scene that throws as it is made, and keeps the page', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + scenes.throws.add('product'); + useAppStore.setState({ homeTab: 'pattern' }); + render(<Page tab="pattern" />); + expect(screen.queryByTestId('scene')).not.toBeInTheDocument(); + expect(screen.getByText(LINE)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Build' })).toBeInTheDocument(); + }); + + it('tries the other scene again when the tab changes to it', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + scenes.throws.add('product'); + useAppStore.setState({ homeTab: 'build' }); + render(<Page tab="build" />); + expect(screen.getByText(LINE)).toBeInTheDocument(); + act(() => useAppStore.setState({ homeTab: 'inside' })); + act(() => vi.advanceTimersByTime(200)); + expect(screen.getByTestId('scene')).toHaveTextContent('globe'); + expect(screen.queryByText(LINE)).not.toBeInTheDocument(); + }); + + // The store says hero until RightPanel's effect tells it otherwise. Read + // from it, /build opened on the globe and kept it through the fade: long + // enough to mount it and send for every pin's photos. + it.each(['build', 'pattern'] as const)('opens /%s on the product and never makes the globe', (tab) => { + render(<Page tab={tab} />); + expect(screen.getByTestId('scene')).toHaveTextContent('product'); + // RightPanel's effect, telling the store the tab the page opened on. + act(() => useAppStore.getState().setHomeTab(tab)); + act(() => vi.advanceTimersByTime(200)); + expect(scenes.made).not.toContain('globe'); + expect(screen.getByTestId('scene')).toHaveTextContent('product'); + }); + + it('opens /pattern on the product when the store still holds Inside from an earlier visit', () => { + useAppStore.setState({ homeTab: 'inside' }); + render(<Page tab="pattern" />); + act(() => useAppStore.getState().setHomeTab('pattern')); + act(() => vi.advanceTimersByTime(200)); + expect(scenes.made).not.toContain('globe'); + }); + + it('follows the store once it changes, from whichever tab the page opened on', () => { + render(<Page tab="build" />); + act(() => useAppStore.getState().setHomeTab('build')); + act(() => useAppStore.getState().setHomeTab('inside')); + act(() => vi.advanceTimersByTime(200)); + expect(screen.getByTestId('scene')).toHaveTextContent('globe'); + act(() => useAppStore.getState().setHomeTab('hero')); + expect(screen.getByTestId('scene')).toHaveTextContent('globe'); + }); + + it('drops down on a phone from the first render of /build, as the panel beside it does', () => { + const html = renderToString(<Page tab="build" />); + expect(html).toContain('viewer-panel is-open'); + expect(renderToString(<Page />)).not.toContain('is-open'); + }); + + it.each(['hero', 'build'] as const)('sends %s as it was from the server, and the browser without WebGL takes it up without a mismatch', async (tab) => { + webgl.answer = false; + const html = renderToString(<Page tab={tab} />); + // The server cannot ask: it answers yes and never calls the probe. + expect(webgl.asked).toBe(0); + expect(html).not.toContain('draw the 3D view'); + + const container = document.createElement('div'); + container.innerHTML = html; + document.body.appendChild(container); + const recoverable = vi.fn(); + const root = await act(async () => hydrateRoot(container, <Page tab={tab} />, { onRecoverableError: recoverable })); + expect(recoverable).not.toHaveBeenCalled(); + expect(container).toHaveTextContent(LINE); + expect(container.querySelector('[data-testid="scene"]')).toBeNull(); + act(() => root.unmount()); + container.remove(); + }); +}); diff --git a/web/src/components/3d/ViewerPanel.tsx b/web/src/components/3d/ViewerPanel.tsx index 895e32d82..56382336c 100644 --- a/web/src/components/3d/ViewerPanel.tsx +++ b/web/src/components/3d/ViewerPanel.tsx @@ -1,9 +1,10 @@ 'use client'; import dynamic from 'next/dynamic'; -import { useEffect, useState } from 'react'; +import { Component, useEffect, useState, useSyncExternalStore, type ReactNode } from 'react'; import HeroScene from './HeroScene'; -import { useAppStore } from '@/store/useAppStore'; +import { useAppStore, type HomeTabType } from '@/store/useAppStore'; +import { hasWebGL } from '@/lib/webgl'; // The hero and the Inside section show the build globe; Build and Pattern // show the product preview. @@ -14,13 +15,51 @@ const GlobeViewer = dynamic( const FADE_MS = 200; +// What stands where the 3D view would be, in a browser that cannot draw it. +// The tabs beside it never depended on it. +function ViewerOff() { + return <div className="viewer-off mono">This browser can’t draw the 3D view.</div>; +} + +/** + * A scene that throws as it is made or drawn (the context refused after all, + * on settings the 1×1 probe did not ask for) is taken off, and the line above + * stands in its place. Uncaught, the error went up to the site's error screen + * and took the tabs with it. + */ +class SceneGuard extends Component<{ children: ReactNode }, { failed: boolean }> { + override state = { failed: false }; + static getDerivedStateFromError() { + return { failed: true }; + } + override render() { + return this.state.failed ? <ViewerOff /> : this.props.children; + } +} + // Wraps the 3D scene so it can react to the active home tab. // On desktop the panel is always shown (sticky left column). On mobile it stays // hidden while the hero is full-screen and only "drops down" once a section tab // (Build / Pattern / Inside) is opened from the bottom nav. -export default function ViewerPanel() { - const homeTab = useAppStore((state) => state.homeTab); +export default function ViewerPanel({ initialTab = 'hero' }: { initialTab?: HomeTabType }) { + // The store starts on the hero on every page and hears the page's tab from + // RightPanel's effect, after this panel has rendered once. Read from the + // store, /build and /pattern opened on the globe for the length of the + // fade, long enough to mount it and send for every pin's photos. So the + // panel starts on the tab the page was made for and follows the store from + // its first change. This effect runs before RightPanel's: siblings mount in + // order, and HomeView puts this panel first. + const [homeTab, setHomeTab] = useState(initialTab); + useEffect(() => useAppStore.subscribe((state) => setHomeTab(state.homeTab)), []); const isOpen = homeTab !== 'hero'; + // The server cannot ask for a context, so it answers yes and sends the page + // as it was. The client asks while hydrating, before either scene's canvas + // is measured, let alone made. The answer cannot change, hence no subscribe. + const webgl = useSyncExternalStore( + () => () => {}, + hasWebGL, + () => true, + ); // Only two distinct scenes exist: the globe (hero, inside) and the product // preview (build, pattern). The hero used to open on the product, with the @@ -48,7 +87,14 @@ export default function ViewerPanel() { return ( <div className={`viewer-panel ${isOpen ? 'is-open' : ''}`}> <div className={`viewer-fade ${fading ? 'is-fading' : ''}`}> - {shownScene === 'globe' ? <GlobeViewer /> : <HeroScene />} + {/* Keyed by scene: one that failed is tried again only after the other has been shown. */} + {webgl ? ( + <SceneGuard key={shownScene}> + {shownScene === 'globe' ? <GlobeViewer /> : <HeroScene />} + </SceneGuard> + ) : ( + <ViewerOff /> + )} </div> </div> ); diff --git a/web/src/components/3d/buildPose.test.ts b/web/src/components/3d/buildPose.test.ts index 478e249ab..6af81f02b 100644 --- a/web/src/components/3d/buildPose.test.ts +++ b/web/src/components/3d/buildPose.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { poseFor, shows } from './buildPose'; +import { REST_SCALE, fitAcross, poseFor, shows } from './buildPose'; import type { PartRole } from './heroCase'; // What the preview shows at each Build step: the case's loose pieces (a @@ -37,3 +37,57 @@ describe('poseFor and shows', () => { expect(Object.values(STEPS).filter((step) => poseFor(step, 1, 0).lit)).toEqual([STEPS.idle, STEPS.power]); }); }); + +describe('poseFor at idle', () => { + const TIMES = [0, 5, 10.5, 21, 30, 600]; + + it('sways the device by itself', () => { + const turns = TIMES.map((t) => poseFor(STEPS.idle, 0, t).turn); + expect(new Set(turns).size).toBe(TIMES.length); + expect(Math.max(...turns.map(Math.abs))).toBeLessThanOrEqual(0.45); + }); + + it('holds it still, facing the reader, for someone who has asked for less motion', () => { + for (const t of TIMES) { + const pose = poseFor(STEPS.idle, 0, t, true); + expect(pose.turn, `turn at ${t}s`).toBe(0); + expect(pose.lift, `lift at ${t}s`).toBe(0); + } + }); + + it('leaves the Build steps as they were', () => { + for (const step of [STEPS.print, STEPS.solder, STEPS.assemble, STEPS.power]) { + expect(poseFor(step, 0.6, 12, true), `step ${step}`).toEqual(poseFor(step, 0.6, 12)); + } + }); +}); + +describe('fitAcross', () => { + // The view's height at rest where the device stands (HeroScene: fov 28°, + // the camera at (0, 6, 10.3) on the target (0, 1.7, 0)), and its width on + // the two layouts the screenshots were taken on: 1440 × 900, the preview + // 690 × 900 beside the panel, and a 390 px phone, 390 × 372 over it. + const VIEW_HEIGHT = 2 * Math.tan((14 * Math.PI) / 180) * Math.hypot(6 - 1.7, 10.3); + const DESKTOP = VIEW_HEIGHT * (690 / 900); + const PHONE = VIEW_HEIGHT * (390 / 372); + // Each case's device across, model units, as tools/case-models builds it. + const UPRIGHT = { official: 24.679, 'besoiobiy-printed': 26.701, 'simonepda-lasercut': 25.599 }; + const HORIZONTAL = 41.028; + + it('keeps every case at the one scale wherever it fits', () => { + for (const width of [...Object.values(UPRIGHT), HORIZONTAL]) { + expect(fitAcross(width, PHONE), `${width} on a phone`).toBe(1); + } + for (const [id, width] of Object.entries(UPRIGHT)) { + expect(fitAcross(width, DESKTOP), `${id} on a desktop`).toBe(1); + } + }); + + it('draws the long case smaller on a tall view, by as little as keeps it clear of the arrows', () => { + const fit = fitAcross(HORIZONTAL, DESKTOP); + expect(fit).toBeLessThan(1); + expect(fit).toBeGreaterThan(0.7); + // At rest it takes 72% of the view's width, no more and no less. + expect((HORIZONTAL * REST_SCALE * fit) / DESKTOP).toBeCloseTo(0.72, 9); + }); +}); diff --git a/web/src/components/3d/buildPose.ts b/web/src/components/3d/buildPose.ts index 89a6dc4de..167fa2bb2 100644 --- a/web/src/components/3d/buildPose.ts +++ b/web/src/components/3d/buildPose.ts @@ -12,6 +12,15 @@ import type { CasePart, PartRole } from './heroCase'; */ const BOARD_TURN = Math.PI - 0.8; +/** The scale the device rests at: idle, and lit on Flash. */ +export const REST_SCALE = 0.1; + +/** + * At most this much of the view's width, the device at rest: what keeps it + * clear of the arrows either side (HeroScene). + */ +const FIT_ACROSS = 0.72; + /** What each Build step shows. */ export interface Pose { /** The device's turn about the vertical, radians, and its scale. */ @@ -34,8 +43,12 @@ const ALL: Record<PartRole, boolean> = { shell: true, knob: true, led: true, pcb const CASE_ONLY: Record<PartRole, boolean> = { shell: true, knob: true, led: false, pcb: false, devkit: false }; const BOARD_ONLY: Record<PartRole, boolean> = { shell: false, knob: false, led: false, pcb: true, devkit: true }; -/** The pose for a Build step (0 is none: idle), the Assemble slider's spread and the clock. */ -export function poseFor(step: number, explode: number, t: number): Pose { +/** + * The pose for a Build step (0 is none: idle), the Assemble slider's spread + * and the clock. `still` is a reader who has asked for less motion: idle + * stands at the middle of its sway, facing them, and stays there. + */ +export function poseFor(step: number, explode: number, t: number, still = false): Pose { switch (step) { // 1. Print / cut the case: its parts and the knobs, drawn a little apart // so it reads as parts to make, nothing inside yet. @@ -61,13 +74,13 @@ export function poseFor(step: number, explode: number, t: number): Pose { }; // 4. Flash and power on: together, lit. case 4: - return { turn: -0.5, scale: 0.1, lift: 0, spread: 0, show: ALL, loose: false, board: false, lit: true }; - // Idle: together, lit, swaying slowly. + return { turn: -0.5, scale: REST_SCALE, lift: 0, spread: 0, show: ALL, loose: false, board: false, lit: true }; + // Idle: together, lit, swaying slowly, 26° either way every 42 seconds. default: return { - turn: Math.sin(t * 0.15) * 0.45, - scale: 0.1, - lift: Math.sin(t * 0.3) * 0.03, + turn: still ? 0 : Math.sin(t * 0.15) * 0.45, + scale: REST_SCALE, + lift: still ? 0 : Math.sin(t * 0.3) * 0.03, spread: 0, show: ALL, loose: false, @@ -77,6 +90,24 @@ export function poseFor(step: number, explode: number, t: number): Pose { } } +/** + * How much smaller than its pose's scale a device is drawn, so it fits across + * the view: 1 for a device that already does. Every case is drawn at the one + * scale, so the LED panel is the same size in each, wherever that fits: on a + * phone all four do, and on a desktop's tall view the three that stand + * upright do, 25 to 27 cm across. mbchars' case lies the long way, 41 cm + * across, and on a tall view it would run off both sides, under the arrows; + * there it is drawn smaller, by as little as keeps it clear of them. + * + * `width` is the device's, assembled, in model units (PreparedCase.width); + * `viewWidth` the view's width at rest where the device stands, in world + * units: at rest, not as zoomed, since zooming in on a device must not + * shrink it. + */ +export function fitAcross(width: number, viewWidth: number): number { + return Math.min(1, (FIT_ACROSS * viewWidth) / (width * REST_SCALE)); +} + /** Whether a pose has a part out: its kind of part, and if it is a loose piece, loose pieces too. */ export function shows(pose: Pick<Pose, 'show' | 'loose'>, part: Pick<CasePart, 'role' | 'loose'>): boolean { return pose.show[part.role] && (pose.loose || !part.loose); diff --git a/web/src/components/3d/caseModels.ts b/web/src/components/3d/caseModels.ts index 16906d855..d54825fc7 100644 --- a/web/src/components/3d/caseModels.ts +++ b/web/src/components/3d/caseModels.ts @@ -2,6 +2,7 @@ import type { CaseId } from '@/components/sections/build-cases-data'; import { OFFICIAL_MODEL } from './cases/official'; import { BESOIOBIY_MODEL } from './cases/besoiobiy-printed'; import { SIMONEPDA_MODEL } from './cases/simonepda-lasercut'; +import { MBCHARS_MODEL } from './cases/mbchars-horizontal-desktop-printed'; // The device the product preview shows, one model per case on the Build // panel's switch. Each model is a single GLB built by tools/case-models from @@ -69,6 +70,7 @@ export const CASE_MODELS: Record<CaseId, CaseModel> = { official: OFFICIAL_MODEL, 'besoiobiy-printed': BESOIOBIY_MODEL, 'simonepda-lasercut': SIMONEPDA_MODEL, + 'mbchars-horizontal-desktop-printed': MBCHARS_MODEL, }; /** Where the Draco decoder comes from: the same copy the guide's models use. */ diff --git a/web/src/components/3d/cases/mbchars-horizontal-desktop-printed.ts b/web/src/components/3d/cases/mbchars-horizontal-desktop-printed.ts new file mode 100644 index 000000000..1e1d90fd8 --- /dev/null +++ b/web/src/components/3d/cases/mbchars-horizontal-desktop-printed.ts @@ -0,0 +1,42 @@ +import type { CaseModel } from '../caseModels'; + +// Mykyta Bilous' (mbchars') horizontal desktop case: tools/case-models/mbchars.py, +// from the parts in hardware/case/remixes/mbchars-horizontal-desktop-printed/stl/ +// put together. The panel lies the long way across, a quarter turn from the +// other cases, in two white frames; the board is in the orange control section +// on the right. Four joint bars hold the three front sections together from +// behind, two covers close the back, and two orange stands hold it by its +// bottom edge. The knobs are the official ones, printed white. +export const MBCHARS_MODEL: CaseModel = { + id: 'mbchars-horizontal-desktop-printed', + url: '/cases/mbchars-horizontal-desktop-printed/model.glb', + // Drawn apart the way it goes together, told from the inner frame, which + // stays. The outer frame and the control section open a little at their + // joints, so the joints show, and the joint bars come out behind them, each + // still over its joint; whatever the control section holds (the knobs, the + // board, the DevKit, the module's retainer) moves with it. The panel goes + // into the frames from the front and the knobs onto the shafts from the + // front, so both come off that way. Behind, the board comes out, the DevKit + // comes off its sockets, and the two covers come off last, as the back of + // the case. The stands drop off the bottom at each end. + explode: { + outer_frame: [-1, 0, 0], + control_section: [1, 0, 0], + joint_bar_left_top: [-0.5, 0, -3.5], + joint_bar_left_bottom: [-0.5, 0, -3.5], + joint_bar_right_top: [0.5, 0, -3.5], + joint_bar_right_bottom: [0.5, 0, -3.5], + l: [0, 0, 6.4], + c1: [1, 0, 4.2], + c2: [1, 0, 4.2], + c3: [1, 0, 4.2], + c4: [1, 0, 4.2], + pcb_v39: [1, 0, -6.8], + devkit: [1, 0, -11.2], + usb_retainer: [1, 0, -4.5], + rear_control_cover: [0, 0, -15.5], + rear_display_cover: [0, 0, -15.5], + stand_left: [-1, -2.5, 0], + stand_right: [1, -2.5, 0], + }, +}; diff --git a/web/src/components/3d/heroCase.test.ts b/web/src/components/3d/heroCase.test.ts index b161d5904..94de6c922 100644 --- a/web/src/components/3d/heroCase.test.ts +++ b/web/src/components/3d/heroCase.test.ts @@ -123,6 +123,8 @@ describe('prepareCase', () => { prepared.parts.forEach((p) => box.union(p.box)); const centre = box.getCenter(new THREE.Vector3()).add(prepared.fitPosition); expect(centre.distanceTo(FRAME_CENTRE)).toBeLessThan(1e-9); + // Its width, what the preview fits across the view (buildPose.ts, fitAcross). + expect(prepared.width).toBeCloseTo(box.max.x - box.min.x, 9); }); it('draws every case at one scale: a bigger case does not shrink the panel in it', () => { diff --git a/web/src/components/3d/heroCase.ts b/web/src/components/3d/heroCase.ts index d6e8a9a62..81d781c82 100644 --- a/web/src/components/3d/heroCase.ts +++ b/web/src/components/3d/heroCase.ts @@ -73,6 +73,8 @@ export interface PreparedCase { led: THREE.Mesh[]; /** Centres the model on FRAME_CENTRE: where the model's frame sits in the preview's. */ fitPosition: THREE.Vector3; + /** The device's width, assembled, in model units: what the view has to fit across. */ + width: number; /** The board with the DevKit on it, assembled, in the model's frame. */ board: THREE.Box3; looks: OwnLook[]; @@ -197,11 +199,15 @@ export function prepareCase(scene: THREE.Object3D, model: CaseModel): PreparedCa // Every case at the one scale, only centred: the LED panel is the same part // in each (and the knobs, but on Besoiobiy's case, which has its own caps), // so it is drawn the same size, and what differs is what really does, the - // case round it (up to 4% across the front's diagonal; Besoiobiy's case is - // 8% wider than the official one). + // case round it (up to 4% across the front's diagonal among the three that + // stand upright; Besoiobiy's case is 8% wider than the official one, and + // mbchars', lying the long way, two thirds wider). Only a view too narrow + // for a case draws it smaller, and that is the preview's to do, by the + // width recorded here (buildPose.ts, fitAcross). const device = new THREE.Box3(); parts.forEach((p) => device.union(p.box)); const fitPosition = FRAME_CENTRE.clone().sub(device.getCenter(new THREE.Vector3())); + const width = device.max.x - device.min.x; const board = new THREE.Box3(); parts.filter((p) => p.role === 'pcb' || p.role === 'devkit').forEach((p) => board.union(p.box)); @@ -283,7 +289,7 @@ export function prepareCase(scene: THREE.Object3D, model: CaseModel): PreparedCa return [{ node, ring, tints, height: box.max.z }]; }); - return { model, root, parts, knobs, led, fitPosition, board, looks, owned }; + return { model, root, parts, knobs, led, fitPosition, width, board, looks, owned }; } /** The knob a pointer is on, if it is on one: the knob itself, its ring or its hit disc. */ diff --git a/web/src/components/3d/patterns/waveTest.ts b/web/src/components/3d/patterns/waveTest.ts deleted file mode 100644 index 3a14cbf14..000000000 --- a/web/src/components/3d/patterns/waveTest.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { PatternDef } from './common'; - -// Simple radial wave — great for testing UV mapping -// Shows concentric rings from center, should look circular if aspect is correct -const waveTest: PatternDef = { - name: 'Wave Test', - defaults: { uSpeed: 2.0, uParam1: 12.0 }, - fragmentShader: ` -precision highp float; -uniform float uTime; -uniform float uSpeed; -uniform float uParam1; // frequency -uniform float uParam2; -uniform float uParam3; -uniform float uParam4; -uniform float uAspect; // mesh width/height ratio -varying vec2 vUv; - -void main() { - // Quantize to 64x128 LED grid - const float COLS = 64.0; - const float ROWS = 128.0; - vec2 cell = floor(vUv * vec2(COLS, ROWS)); - vec2 quv = (cell + 0.5) / vec2(COLS, ROWS); - - // Center and correct aspect ratio - vec2 uv = quv - 0.5; - uv.y *= uAspect; - - // Distance from center - float dist = length(uv); - - // Simple radial wave - float wave = sin(dist * uParam1 - uTime * uSpeed); - - // Map -1..1 to 0..1 - float v = wave * 0.5 + 0.5; - - // Color: dark blue → orange → white - vec3 col = mix( - vec3(0.02, 0.02, 0.08), - vec3(1.0, 0.35, 0.1), - smoothstep(0.3, 0.7, v) - ); - col = mix(col, vec3(1.0), smoothstep(0.8, 1.0, v)); - - // LOD: fade LED gaps based on screen-space pixel density - vec2 ledUV = vUv * vec2(COLS, ROWS); - vec2 f = fract(ledUV); - float screenSize = length(fwidth(ledUV)); // how many LED cells per screen pixel - float detail = 1.0 - smoothstep(uParam2, uParam3, screenSize); // uParam2=fadeStart, uParam3=fadeEnd - - float gapSize = 0.12; - float gx = smoothstep(0.0, gapSize, f.x) * (1.0 - smoothstep(1.0 - gapSize, 1.0, f.x)); - float gy = smoothstep(0.0, gapSize, f.y) * (1.0 - smoothstep(1.0 - gapSize, 1.0, f.y)); - float gridMask = mix(1.0, mix(0.06, 1.0, gx * gy), detail); - col *= gridMask; - - gl_FragColor = vec4(col, 1.0); -} -`, -}; - -export default waveTest; diff --git a/web/src/components/HomeView.tsx b/web/src/components/HomeView.tsx index 9f031874f..bf1505a57 100644 --- a/web/src/components/HomeView.tsx +++ b/web/src/components/HomeView.tsx @@ -23,7 +23,7 @@ export default function HomeView({ initialTab = 'hero', initialBuildId = null }: <div className="mobile-page-links"> <HeroJournalLink /> </div> - <ViewerPanel /> + <ViewerPanel initialTab={initialTab} /> <RightPanel initialTab={initialTab} buildContent={buildContent} diff --git a/web/src/components/community/AddHeaderModal.tsx b/web/src/components/community/AddHeaderModal.tsx index 7bf444006..35dda6151 100644 --- a/web/src/components/community/AddHeaderModal.tsx +++ b/web/src/components/community/AddHeaderModal.tsx @@ -7,6 +7,7 @@ import { captureEvent } from "@/lib/posthogEvents"; import { authClient } from "@/lib/community/auth-client"; import { COMMUNITY_FETCH_INIT, communityApiUrl } from "@/lib/community/apiBase"; import { useSubmitLatch } from "@/lib/community/submitLatch"; +import { useDialog } from "@/lib/hooks/useDialog"; import AuthModal from "./AuthModal"; import styles from "./Community.module.css"; @@ -54,6 +55,7 @@ export default function AddHeaderModal({ const [busy, setBusy] = useState(false); // One request per press, decided in the click's own tick (#455). const latch = useSubmitLatch(); + const cardRef = useDialog<HTMLDivElement>(onClose); const moderating = mode === "moderate" || mode === "moderate-port"; @@ -126,6 +128,8 @@ export default function AddHeaderModal({ return ( <div className={styles.modalOverlay} role="dialog" aria-modal="true" onClick={onClose}> <div + ref={cardRef} + tabIndex={-1} className={`${styles.modalCard} ${styles.modalCardWide}`} onClick={(event) => event.stopPropagation()} > diff --git a/web/src/components/community/AddPerformanceModal.tsx b/web/src/components/community/AddPerformanceModal.tsx index bc9ab90b9..ee69ef786 100644 --- a/web/src/components/community/AddPerformanceModal.tsx +++ b/web/src/components/community/AddPerformanceModal.tsx @@ -6,6 +6,7 @@ import { captureEvent } from "@/lib/posthogEvents"; import { authClient } from "@/lib/community/auth-client"; import { COMMUNITY_FETCH_INIT, communityApiUrl } from "@/lib/community/apiBase"; import { useSubmitLatch } from "@/lib/community/submitLatch"; +import { useDialog } from "@/lib/hooks/useDialog"; import { readPerformanceFile } from "@/lib/community/performanceFile"; import AuthModal from "./AuthModal"; import styles from "./Community.module.css"; @@ -32,6 +33,7 @@ export default function AddPerformanceModal({ const [busy, setBusy] = useState(false); // One request per press, decided in the click's own tick (#455). const latch = useSubmitLatch(); + const cardRef = useDialog<HTMLDivElement>(onClose); // The Director saves .pfs and the panel plays it, so that is what people // have. It is decoded here into the canonical form the site stores, which @@ -79,6 +81,8 @@ export default function AddPerformanceModal({ return ( <div className={styles.modalOverlay} role="dialog" aria-modal="true" onClick={onClose}> <div + ref={cardRef} + tabIndex={-1} className={`${styles.modalCard} ${styles.modalCardWide}`} onClick={(event) => event.stopPropagation()} > diff --git a/web/src/components/community/Atlas.module.css b/web/src/components/community/Atlas.module.css index b002b7272..7d7bcf72b 100644 --- a/web/src/components/community/Atlas.module.css +++ b/web/src/components/community/Atlas.module.css @@ -13,8 +13,9 @@ .toolbar { display: flex; + flex-wrap: wrap; align-items: center; - gap: 12px; + gap: 10px 12px; padding: 14px 0; } @@ -24,8 +25,16 @@ margin: 0; } -.toolbarSpacer { - flex: 1; +.toolbarActions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + margin-left: auto; +} + +.toolbarActions .toolBtn { + white-space: nowrap; } .toolBtn { @@ -74,27 +83,41 @@ cursor: default; } -/* ── filter chips ── */ +/* ── filter chips and the placing banner, over the map ── */ -.chips { +.mapTop { position: absolute; top: 12px; left: 16px; + right: 16px; + z-index: 2; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + /* The strip spans the map; only what is drawn in it takes the pointer. */ + pointer-events: none; +} + +.mapTop > * { + pointer-events: auto; +} + +.chips { display: flex; - z-index: 1; + flex-wrap: wrap; } +/* Each chip pulls the next one over its right border and the next row up + over its bottom one, so the chips read as one strip on any number of rows. */ .chip { composes: toolBtn; - border-right-width: 0; padding: 5px 10px; -} - -.chip:last-child { - border-right-width: 1px; + margin: 0 -1px -1px 0; } .chip[data-on="true"] { + position: relative; color: var(--pfc-ink); background: var(--pfc-well-2); border-color: var(--pfc-edge); @@ -133,6 +156,14 @@ margin-left: 10px; } +/* Zoom is on the wheel and a drag pans only once zoomed, so a touch screen + has none of the three gestures the hint names. */ +@media (hover: none) { + .zoomHint { + display: none; + } +} + .mapWrap[data-placing="true"] .map { cursor: crosshair; } @@ -161,7 +192,9 @@ sheet of thumbnails, newest first, not a list of titles. */ .pickerGrid { display: grid; - grid-template-columns: repeat(6, 1fr); + /* minmax(0, …): a long title is cut by its caption, it does not widen the + column. With plain 1fr the grid grew past the picker and scrolled sideways. */ + grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 8px; padding: 2px 4px 6px; } @@ -239,11 +272,8 @@ } .placeBanner { - position: absolute; - top: 12px; - left: 50%; - transform: translateX(-50%); - z-index: 2; + align-self: center; + max-width: 100%; display: flex; align-items: center; gap: 10px; @@ -252,7 +282,10 @@ border: 1px solid var(--pfc-led-edge); color: var(--pfc-led); font-size: 12.5px; - white-space: nowrap; +} + +.placeBanner > button { + flex: none; } .bannerLink { @@ -288,6 +321,24 @@ box-shadow: 0 0 8px rgba(244, 239, 230, 0.25); } +.pin:focus-visible { + outline: none; +} + +/* Drawn inside the tile: the foreignObject is the tile's width and clips + anything outside it, so an outline around the tile shows only below. */ +.pin:focus-visible .pinTile::after { + content: ""; + position: absolute; + inset: 0; + border: 2px solid var(--pfc-ink); + pointer-events: none; +} + +.pin:focus-visible .pinCaption { + color: var(--pfc-ink); +} + .pinWell { position: absolute; inset: 0; @@ -351,6 +402,16 @@ border-color: var(--pfc-danger); } +/* Why a move, a link or a removal did not save. */ +.writeError { + margin: 0 0 14px; + padding: 8px 10px; + background: rgba(232, 107, 94, 0.1); + border: 1px solid rgba(232, 107, 94, 0.4); + color: var(--pfc-danger); + font-size: 13px; +} + .legendTile { width: 8px; height: 16px; @@ -477,10 +538,29 @@ transition: r 0.12s; } -.node:hover .core { +.node:hover .core, +.node:focus-visible .core { r: 8; } +/* The keyboard's place on the map. The browser's outline would box the + label too, so the ring is drawn: cream, like selection, since orange here + means live. */ +.focusRing { + fill: none; + stroke: var(--pfc-ink); + stroke-width: 1.5; + visibility: hidden; +} + +.node:focus-visible .focusRing { + visibility: visible; +} + +.node:focus-visible .nodeLabel { + fill: var(--pfc-ink); +} + .node[data-selected="true"] .core { stroke: var(--pfc-ink); stroke-width: 1.5; @@ -501,6 +581,14 @@ animation: pulse 2.2s ease-in-out infinite; } +/* Held at the middle of its beat: the ring still marks the entry as live. */ +@media (prefers-reduced-motion: reduce) { + .pulseRing { + animation: none; + opacity: 0.5; + } +} + /* ── side panel ── */ .panel { @@ -704,14 +792,43 @@ /* ── narrow screens: stack map over panel ── */ @media (max-width: 900px) { + /* Here the buttons sit on a row of their own and may wrap, so "Add my + pattern" can be at the left edge. The picker hangs from the toolbar's + right edge instead of the button, and is never wider than the toolbar. */ + .toolbar { + position: relative; + } + + .pickerWrap { + position: static; + } + + .picker { + top: calc(100% - 8px); + width: min(396px, 100%); + } + .body { flex-direction: column; height: auto; + min-height: 0; + } + + /* At 390px the chips wrap to two rows, and over the map they hid 21 of its + 83 labels. Here they stand above it, with the banner under them. */ + .mapTop { + position: static; + padding: 12px 16px 8px; } + /* The map is as tall as its 1000:640 drawing. The old 380px box held a + drawing 228px tall at 390px and left the 152px under it blank. */ .mapWrap { - height: 60vh; - min-height: 380px; + flex: none; + } + + .map { + height: auto; } .panel { diff --git a/web/src/components/community/AtlasClient.test.tsx b/web/src/components/community/AtlasClient.test.tsx new file mode 100644 index 000000000..c3648deac --- /dev/null +++ b/web/src/components/community/AtlasClient.test.tsx @@ -0,0 +1,285 @@ +// The atlas remembers its reading language in localStorage and reads it while +// rendering. With site data blocked that read throws a SecurityError, and it +// used to take the whole map down with it. + +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import type { AnchorHTMLAttributes, ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ENTRIES } from "@/lib/atlas/data"; +import AtlasClient, { type AtlasPinData } from "./AtlasClient"; + +const { refresh } = vi.hoisted(() => ({ refresh: vi.fn() })); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ refresh, push: vi.fn(), replace: vi.fn() }), +})); + +vi.mock("next/link", () => ({ + default: ({ href, children, ...rest }: AnchorHTMLAttributes<HTMLAnchorElement> & { href: string; children: ReactNode }) => ( + <a href={href} {...rest}> + {children} + </a> + ), +})); + +// Both boot a sandbox iframe per pattern; these tests only need the map. +vi.mock("./PatternCanvas", () => ({ default: () => null })); +vi.mock("./SandboxPreview", () => ({ default: () => null })); + +const atlas = (pins: AtlasPinData[] = []) => ( + <AtlasClient pins={pins} viewerId={null} isAdmin={false} myPatterns={[]} /> +); + +const tide: AtlasPinData = { + patternId: "p-tide", + x: 40, + y: 60, + entryId: null, + kind: "pin", + isPublic: true, + title: "Slow tide", + code: "function draw() {}", + userId: "u1", + username: "maker", + displayUsername: null, +}; + +describe("atlas by keyboard", () => { + it("reaches a pin tile with Tab and opens it with Enter", () => { + render(atlas([tide])); + const tile = screen.getByRole("button", { name: "Slow tide" }); + expect(tile.tabIndex).toBe(0); + expect(fireEvent.keyDown(tile, { key: "Enter" })).toBe(false); + expect(screen.getByRole("heading", { name: "Slow tide" })).toBeTruthy(); + }); + + it("opens a point with Space and keeps the page where it was", () => { + render(atlas()); + const entry = ENTRIES[0]; + const point = screen.getAllByRole("button", { name: entry.nmEn })[0]; + // fireEvent returns false when the handler called preventDefault. + expect(fireEvent.keyDown(point, { key: " " })).toBe(false); + expect(screen.getByRole("heading", { name: entry.nmEn })).toBeTruthy(); + }); +}); + +// Every write to the map used to ignore the answer: a 401 after the login ran +// out left a dragged tile at its new spot as if it had saved, and a dropped +// connection ended in an unhandled rejection. +describe("atlas writes the server refuses", () => { + // The author's own view of the map, so the pin can be moved and removed. + const owned = ( + pins: AtlasPinData[], + myPatterns: Array<{ id: string; title: string; code: string; isPublic: boolean }> = [], + ) => <AtlasClient pins={pins} viewerId="u1" isAdmin={false} myPatterns={myPatterns} />; + + const answer = (status: number, body: unknown) => + vi.stubGlobal( + "fetch", + vi.fn(async () => Response.json(body, { status })), + ); + + const openPin = () => { + fireEvent.click(screen.getByRole("button", { name: "Slow tide" })); + expect(screen.getByRole("heading", { name: "Slow tide" })).toBeTruthy(); + }; + + const scrollIntoView = vi.fn(); + + beforeEach(() => { + refresh.mockClear(); + scrollIntoView.mockClear(); + Object.defineProperty(Element.prototype, "scrollIntoView", { + configurable: true, + value: scrollIntoView, + }); + // jsdom lays nothing out and has neither of these. With the identity as + // the map's screen matrix, a client point is a viewBox point. + Object.defineProperty(SVGSVGElement.prototype, "getScreenCTM", { + configurable: true, + value: () => ({ inverse: () => ({}) }), + }); + vi.stubGlobal( + "DOMPoint", + class { + x: number; + y: number; + constructor(x = 0, y = 0) { + this.x = x; + this.y = y; + } + matrixTransform() { + return { x: this.x, y: this.y }; + } + }, + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("puts a dragged tile back and says why when the move is refused", async () => { + answer(401, { error: "Sign in to place work on the atlas." }); + render(owned([tide])); + openPin(); + fireEvent.click(screen.getByRole("button", { name: "Edit layout" })); + + const map = screen.getByLabelText("A map of pattern space"); + fireEvent.pointerDown(screen.getByRole("button", { name: "Slow tide" })); + // viewBox (500, 320) is the middle of the plot: data (50, 50). + fireEvent.pointerMove(map, { clientX: 500, clientY: 320 }); + expect(screen.getByText("x 50 · y 50")).toBeTruthy(); + fireEvent.pointerUp(map); + + expect((await screen.findByRole("alert")).textContent).toBe( + "Sign in to place work on the atlas.", + ); + expect(screen.getByText("x 40 · y 60")).toBeTruthy(); + expect(refresh).not.toHaveBeenCalled(); + }); + + it("keeps a pin it could not remove, and the reason leaves with the selection", async () => { + answer(403, { error: "Only the pattern's author can place it." }); + render(owned([tide])); + openPin(); + fireEvent.click(screen.getByRole("button", { name: "Remove from the map" })); + + expect((await screen.findByRole("alert")).textContent).toBe( + "Only the pattern's author can place it.", + ); + expect(screen.getByRole("heading", { name: "Slow tide" })).toBeTruthy(); + expect(refresh).not.toHaveBeenCalled(); + // Remove is at the foot of the panel and the reason at its head. + expect(scrollIntoView).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getAllByRole("button", { name: ENTRIES[0].nmEn })[0]); + expect(screen.queryByRole("alert")).toBeNull(); + }); + + it("closes the panel and reloads the map when the removal saves", async () => { + answer(200, { ok: true }); + render(owned([tide])); + openPin(); + fireEvent.click(screen.getByRole("button", { name: "Remove from the map" })); + + await waitFor(() => expect(refresh).toHaveBeenCalledTimes(1)); + expect(screen.queryByRole("heading", { name: "Slow tide" })).toBeNull(); + expect(screen.queryByRole("alert")).toBeNull(); + }); + + it("says nothing was saved when the link change gets no answer", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new TypeError("Failed to fetch"); + }), + ); + render(owned([tide])); + openPin(); + const select = screen.getByRole("combobox") as HTMLSelectElement; + fireEvent.change(select, { target: { value: ENTRIES[0].id } }); + + expect((await screen.findByRole("alert")).textContent).toBe( + "Not saved. The server did not answer.", + ); + expect(select.value).toBe(""); + }); + + it("reads a proxy's error page as no answer", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("<html>Bad gateway</html>", { status: 502 })), + ); + render(owned([tide])); + openPin(); + fireEvent.click(screen.getByRole("button", { name: "Remove from the map" })); + + expect((await screen.findByRole("alert")).textContent).toBe( + "Not saved. The server did not answer.", + ); + }); + + it("stays in placing mode and says why when the drop is refused", async () => { + answer(429, { error: "Too many moves — let the pin settle a moment." }); + render(owned([], [{ id: "p-ember", title: "Ember", code: "function draw() {}", isPublic: true }])); + fireEvent.click(screen.getByRole("button", { name: "Add my pattern" })); + fireEvent.click(screen.getByTitle("Ember")); + + const catcher = screen.getByLabelText("A map of pattern space").querySelector("rect")!; + fireEvent.pointerUp(catcher, { clientX: 900, clientY: 100 }); + + expect((await screen.findByRole("alert")).textContent).toBe( + "Too many moves — let the pin settle a moment.", + ); + expect(screen.getByText(/Placing “Ember”/)).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(screen.queryByRole("alert")).toBeNull(); + }); +}); + +// A screen reader announced these as "edit text", "combo box" and "slider" and +// nothing more. The search box only had its placeholder to go on. +describe("atlas fields say what they are", () => { + const nine = Array.from({ length: 9 }, (_, i) => ({ + id: `p${i}`, + title: `Pattern ${i}`, + code: "function draw() {}", + isPublic: true, + })); + + it("names the picker's search box in both languages", () => { + render(<AtlasClient pins={[]} viewerId="u1" isAdmin={false} myPatterns={nine} />); + fireEvent.click(screen.getByRole("button", { name: "Add my pattern" })); + expect(screen.getByRole("searchbox", { name: "Search your patterns" })).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "한국어" })); + expect(screen.getByRole("searchbox", { name: "내 패턴 찾기" })).toBeTruthy(); + // The language is the module's for the rest of this file. + fireEvent.click(screen.getByRole("button", { name: "English" })); + }); + + it("names the prompt a pin was made from", () => { + render(<AtlasClient pins={[tide]} viewerId="u1" isAdmin={false} myPatterns={[]} />); + fireEvent.click(screen.getByRole("button", { name: "Slow tide" })); + expect(screen.getByRole("combobox", { name: "Made from prompt" })).toBeTruthy(); + }); + + it("names each knob slider after the knob", () => { + render(atlas([tide, { ...tide, patternId: "p-folds", title: "Folds", code: "// @knobs Folds=3..12, -, Zoom=2..17\nfunction draw() {}" }])); + fireEvent.click(screen.getByRole("button", { name: "Slow tide" })); + expect(screen.getAllByRole("slider").map((s) => s.getAttribute("aria-label"))).toEqual([ + "Knob 1", + "Knob 2", + "Knob 3", + "Knob 4", + ]); + + fireEvent.click(screen.getByRole("button", { name: "Folds" })); + expect(screen.getByRole("slider", { name: "Folds" })).toBeTruthy(); + expect(screen.getByRole("slider", { name: "Zoom" })).toBeTruthy(); + }); +}); + +describe("atlas reading language", () => { + it("opens in the language this browser chose last time", () => { + localStorage.setItem("pf-atlas-lang", "ko"); + const { unmount } = render(atlas()); + // The toggle names the language it switches to. + expect(screen.getByRole("button", { name: "English" })).toBeTruthy(); + unmount(); + localStorage.clear(); + }); + + // Last on purpose: the choice storage refused stays in the module for the + // rest of the visit, which here is the rest of this file. + it("opens with site data blocked, and the toggle still works for the visit", () => { + vi.spyOn(window, "localStorage", "get").mockImplementation(() => { + throw new DOMException("The operation is insecure.", "SecurityError"); + }); + render(atlas()); + fireEvent.click(screen.getByRole("button", { name: "한국어" })); + expect(screen.getByRole("button", { name: "English" })).toBeTruthy(); + }); +}); diff --git a/web/src/components/community/AtlasClient.tsx b/web/src/components/community/AtlasClient.tsx index 09a396892..430d7c82a 100644 --- a/web/src/components/community/AtlasClient.tsx +++ b/web/src/components/community/AtlasClient.tsx @@ -129,6 +129,8 @@ const UI = { addPattern: "Add my pattern", pickerHead: (n: number) => `Your patterns · newest first (${n})`, pickerSearch: "Search by title…", + pickerSearchLabel: "Search your patterns", + noAnswer: "Not saved. The server did not answer.", }, ko: { tagline: "실험 기능 — 더 많은 패턴 탐구를 위한 지도.", @@ -176,6 +178,8 @@ const UI = { addPattern: "내 패턴 올리기", pickerHead: (n: number) => `내 패턴 · 최신순 (${n})`, pickerSearch: "제목으로 검색…", + pickerSearchLabel: "내 패턴 찾기", + noAnswer: "저장되지 않았다. 서버가 답하지 않았다.", }, } as const; @@ -222,6 +226,12 @@ export default function AtlasClient({ // The picker lists your own patterns newest first, as thumbnails — you // recognise your work by looking at it, not by reading titles. const [pickerQuery, setPickerQuery] = useState(""); + // A write the server refused, with its reason (null when no answer came + // back), and what the panel showed then. Select something else and the + // message goes, so a refusal about one pin never sits under another. + const [writeError, setWriteError] = useState<{ text: string | null; at: Selection } | null>( + null, + ); const svgRef = useRef<SVGSVGElement | null>(null); const draggingEntryRef = useRef<string | null>(null); @@ -387,34 +397,58 @@ export default function AtlasClient({ /* ── server writes ── */ + /** One write to the atlas route. True when it saved; otherwise the panel + * says why, in the server's words when it sent any. */ + const writeAtlas = async (method: "POST" | "DELETE", body: Record<string, unknown>) => { + const at = selection; + setWriteError(null); + try { + const res = await fetch("/api/community/atlas", { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (res.ok) return true; + const payload = (await res.json().catch(() => ({}))) as { error?: unknown }; + setWriteError({ text: typeof payload.error === "string" ? payload.error : null, at }); + } catch { + setWriteError({ text: null, at }); + } + return false; + }; + + const forgetPinPosition = (patternId: string) => + setPinPositions((prev) => { + const next = { ...prev }; + delete next[patternId]; + return next; + }); + const savePin = async (pin: AtlasPinData, [x, y]: [number, number]) => { - await fetch("/api/community/atlas", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ patternId: pin.patternId, x, y, entryId: pin.entryId, kind: pin.kind }), + const saved = await writeAtlas("POST", { + patternId: pin.patternId, x, y, entryId: pin.entryId, kind: pin.kind, }); - router.refresh(); + if (saved) { + router.refresh(); + return; + } + // The drop did not stick: the tile goes back to where the server has it. + forgetPinPosition(pin.patternId); }; const setPinLink = async (pin: AtlasPinData, entryId: string | null) => { const [x, y] = pinPos(pin); - await fetch("/api/community/atlas", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ patternId: pin.patternId, x, y, entryId, kind: pin.kind }), - }); - router.refresh(); + if (await writeAtlas("POST", { patternId: pin.patternId, x, y, entryId, kind: pin.kind })) { + router.refresh(); + } }; /** Demote a tile to a research note, or promote a note back to a tile. */ const setPinKind = async (pin: AtlasPinData, kind: "pin" | "research") => { const [x, y] = pinPos(pin); - await fetch("/api/community/atlas", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ patternId: pin.patternId, x, y, entryId: pin.entryId, kind }), - }); - router.refresh(); + if (await writeAtlas("POST", { patternId: pin.patternId, x, y, entryId: pin.entryId, kind })) { + router.refresh(); + } }; const placePattern = async (pattern: NonNullable<typeof placing>, coords: [number, number]) => { @@ -428,12 +462,10 @@ export default function AtlasClient({ const entryId = nearestEntry(coords); const kind = pattern.isPublic ? "pin" : "research"; if (kind === "research" && !entryId) return; - const res = await fetch("/api/community/atlas", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ patternId: pattern.id, x: coords[0], y: coords[1], entryId, kind }), + const saved = await writeAtlas("POST", { + patternId: pattern.id, x: coords[0], y: coords[1], entryId, kind, }); - if (res.ok) { + if (saved) { setPlacing(null); setPlacingNear(null); setSelection({ kind: "pin", id: pattern.id }); @@ -442,17 +474,9 @@ export default function AtlasClient({ }; const removePin = async (patternId: string) => { - await fetch("/api/community/atlas", { - method: "DELETE", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ patternId }), - }); + if (!(await writeAtlas("DELETE", { patternId }))) return; setSelection(null); - setPinPositions((prev) => { - const next = { ...prev }; - delete next[patternId]; - return next; - }); + forgetPinPosition(patternId); router.refresh(); }; @@ -505,122 +529,136 @@ export default function AtlasClient({ const worldTransform = `translate(${view.tx} ${view.ty}) scale(${view.k})`; const inv = 1 / view.k; + const shownError = + writeError && writeError.at?.kind === selection?.kind && writeError.at?.id === selection?.id + ? writeError.text ?? t.noAnswer + : null; + return ( <div className={styles.atlas}> <div className={styles.toolbar}> <p className={styles.tagline}> {t.tagline} <span className={styles.zoomHint}>{t.zoomHint}</span> </p> - <div className={styles.toolbarSpacer} /> - {myPatterns.length > 0 && ( - <span className={styles.pickerWrap}> - <button - type="button" - className={styles.toolBtn} - data-on={pickerOpen || Boolean(placing)} - onClick={() => setPickerOpen((v) => !v)} - > - {t.addPattern} - </button> - {pickerOpen && ( - <div className={styles.picker}> - <div className={styles.pickerHead}>{t.pickerHead(myPatterns.length)}</div> - {myPatterns.length > 8 && ( - <input - type="search" - className={styles.pickerSearch} - placeholder={t.pickerSearch} - value={pickerQuery} - onChange={(ev) => setPickerQuery(ev.target.value)} - autoFocus - /> - )} - <div className={styles.pickerGrid}> - {pickerList.map((pattern) => ( - <button - key={pattern.id} - type="button" - className={styles.pickerItem} - title={pattern.title} - onClick={() => { - setPlacing(pattern); - setPickerOpen(false); - }} - > - <span className={styles.pickerTile}> - <PatternCanvas - code={pattern.code} - title={pattern.title} - className={styles.pinWell} - /> - </span> - <span className={styles.pickerCaption}> - {pattern.title} - {!pattern.isPublic && ( - <span className={styles.researchTag}> {t.privateTag}</span> - )} - </span> - </button> - ))} - </div> - </div> - )} - </span> - )} - <button type="button" className={styles.toolBtn} onClick={toggleLang}> - {lang === "en" ? "한국어" : "English"} - </button> - <button - type="button" - className={styles.toolBtn} - data-on={editMode} - onClick={() => setEditMode((v) => !v)} - > - {editMode ? t.editing : t.edit} - </button> - <button type="button" className={styles.toolBtn} onClick={exportLayout}> - {exported ? t.copiedLayout : t.copyLayout} - </button> - </div> - - <div className={styles.body}> - <div className={styles.mapWrap} data-placing={Boolean(placing)}> - <div className={styles.chips}> - {chipList.map(([key, label]) => ( - <button - key={key} - type="button" - className={styles.chip} - data-on={filter === key} - onClick={() => setFilter(key)} - > - {label} - </button> - ))} - </div> - - {placing && ( - <div className={styles.placeBanner}> - <span> - {placing.isPublic ? t.placeBanner(placing.title) : t.placeBannerResearch(placing.title)} - {placingNear && ENTRY_BY_ID.has(placingNear) && ( - <span className={styles.bannerLink}> - {" "}→ {entryName(ENTRY_BY_ID.get(placingNear)!)} - </span> - )} - </span> + {/* One block: when the row runs out, the buttons move under the + tagline together instead of each squeezing into a column. */} + <div className={styles.toolbarActions}> + {myPatterns.length > 0 && ( + <span className={styles.pickerWrap}> <button type="button" className={styles.toolBtn} - onClick={() => { - setPlacing(null); - setPlacingNear(null); - }} + data-on={pickerOpen || Boolean(placing)} + onClick={() => setPickerOpen((v) => !v)} > - {t.cancel} + {t.addPattern} </button> - </div> + {pickerOpen && ( + <div className={styles.picker}> + <div className={styles.pickerHead}>{t.pickerHead(myPatterns.length)}</div> + {myPatterns.length > 8 && ( + <input + type="search" + className={styles.pickerSearch} + aria-label={t.pickerSearchLabel} + placeholder={t.pickerSearch} + value={pickerQuery} + onChange={(ev) => setPickerQuery(ev.target.value)} + autoFocus + /> + )} + <div className={styles.pickerGrid}> + {pickerList.map((pattern) => ( + <button + key={pattern.id} + type="button" + className={styles.pickerItem} + title={pattern.title} + onClick={() => { + setPlacing(pattern); + setPickerOpen(false); + }} + > + <span className={styles.pickerTile}> + <PatternCanvas + code={pattern.code} + title={pattern.title} + className={styles.pinWell} + /> + </span> + <span className={styles.pickerCaption}> + {pattern.title} + {!pattern.isPublic && ( + <span className={styles.researchTag}> {t.privateTag}</span> + )} + </span> + </button> + ))} + </div> + </div> + )} + </span> )} + <button type="button" className={styles.toolBtn} onClick={toggleLang}> + {lang === "en" ? "한국어" : "English"} + </button> + <button + type="button" + className={styles.toolBtn} + data-on={editMode} + onClick={() => setEditMode((v) => !v)} + > + {editMode ? t.editing : t.edit} + </button> + <button type="button" className={styles.toolBtn} onClick={exportLayout}> + {exported ? t.copiedLayout : t.copyLayout} + </button> + </div> + </div> + + <div className={styles.body}> + <div className={styles.mapWrap} data-placing={Boolean(placing)}> + {/* Chips and the placing banner share one column, so the banner sits + under the chips however many rows they wrap to. */} + <div className={styles.mapTop}> + <div className={styles.chips}> + {chipList.map(([key, label]) => ( + <button + key={key} + type="button" + className={styles.chip} + data-on={filter === key} + onClick={() => setFilter(key)} + > + {label} + </button> + ))} + </div> + + {placing && ( + <div className={styles.placeBanner}> + <span> + {placing.isPublic ? t.placeBanner(placing.title) : t.placeBannerResearch(placing.title)} + {placingNear && ENTRY_BY_ID.has(placingNear) && ( + <span className={styles.bannerLink}> + {" "}→ {entryName(ENTRY_BY_ID.get(placingNear)!)} + </span> + )} + </span> + <button + type="button" + className={styles.toolBtn} + onClick={() => { + setPlacing(null); + setPlacingNear(null); + setWriteError(null); + }} + > + {t.cancel} + </button> + </div> + )} + </div> <svg ref={svgRef} @@ -739,9 +777,13 @@ export default function AtlasClient({ if (!dragMovedRef.current) setSelection({ kind: "entry", id: e.id }); }} onKeyDown={(ev) => { - if (ev.key === "Enter" || ev.key === " ") setSelection({ kind: "entry", id: e.id }); + if (ev.key !== "Enter" && ev.key !== " ") return; + // Space would scroll the page as well. + ev.preventDefault(); + setSelection({ kind: "entry", id: e.id }); }} > + <circle r={14} className={styles.focusRing} /> {placingNear === e.id && ( <circle r={13} fill="none" stroke="#F4EFE6" strokeOpacity={0.8} strokeDasharray="3 3" /> )} @@ -781,6 +823,9 @@ export default function AtlasClient({ > <foreignObject x={-22} y={-44} width={44} height={102}> <div + role="button" + tabIndex={0} + aria-label={pin.title} className={styles.pin} data-selected={isSelected} data-draggable={draggable} @@ -796,6 +841,11 @@ export default function AtlasClient({ ev.stopPropagation(); if (!dragMovedRef.current) setSelection({ kind: "pin", id: pin.patternId }); }} + onKeyDown={(ev) => { + if (ev.key !== "Enter" && ev.key !== " ") return; + ev.preventDefault(); + setSelection({ kind: "pin", id: pin.patternId }); + }} > <div className={styles.pinTile}> <PatternCanvas @@ -832,6 +882,11 @@ export default function AtlasClient({ </div> <aside className={styles.panel}> + {shownError && ( + <p role="alert" className={styles.writeError} ref={revealOnMount}> + {shownError} + </p> + )} {selectedPin ? ( <> <h2 className={styles.panelTitle}>{selectedPin.title}</h2> @@ -851,6 +906,7 @@ export default function AtlasClient({ {canEditPin(selectedPin) ? ( <select className={styles.fromSelect} + aria-label={t.madeFrom} value={selectedPin.entryId ?? ""} onChange={(ev) => void setPinLink(selectedPin, ev.target.value || null)} > @@ -1153,6 +1209,7 @@ function PinPlayer({ code, knobsLabel }: { code: string; knobsLabel: string }) { <input type="range" className={styles.knobInput} + aria-label={label} min={min} max={max} step={(max - min) / 200 || 0.01} @@ -1187,15 +1244,39 @@ function subscribeLang(onChange: () => void) { }; } +/** The choice storage would not keep (site data blocked): it still holds for + * this visit, just not the next. */ +let unsavedLang: Lang | null = null; + function readLang(): Lang { - return window.localStorage.getItem(LANG_KEY) === "ko" ? "ko" : "en"; + if (unsavedLang) return unsavedLang; + // This is the store's snapshot, read while rendering: a SecurityError from + // blocked site data here would take the whole map down with it. + try { + return window.localStorage.getItem(LANG_KEY) === "ko" ? "ko" : "en"; + } catch { + return "en"; + } } function writeLang(next: Lang) { - window.localStorage.setItem(LANG_KEY, next); + try { + window.localStorage.setItem(LANG_KEY, next); + unsavedLang = null; + } catch { + unsavedLang = next; + } for (const listener of langListeners) listener(); } +/** Remove sits at the foot of a pin panel and the page scrolls to reach it, + * so a refusal at the panel's head can land above the window. Brought into + * view once, as it appears: a stable function, so React calls it on mount + * and not on every render. */ +function revealOnMount(node: HTMLElement | null) { + node?.scrollIntoView({ block: "nearest" }); +} + function clamp(value: number, min: number, max: number) { return Math.max(min, Math.min(max, value)); } diff --git a/web/src/components/community/AuthModal.tsx b/web/src/components/community/AuthModal.tsx index 91730adc0..f10f8edda 100644 --- a/web/src/components/community/AuthModal.tsx +++ b/web/src/components/community/AuthModal.tsx @@ -1,8 +1,9 @@ "use client"; -import { useState } from "react"; +import { useState, type ReactNode } from "react"; import { authClient, dummyEmailFor } from "@/lib/community/auth-client"; import { PASSWORD_MIN, USERNAME_RE } from "@/lib/community/validate"; +import { useDialog } from "@/lib/hooks/useDialog"; import styles from "./Community.module.css"; // Username + password auth. Email is an optional recovery field only — when @@ -131,16 +132,28 @@ export default function AuthModal({ onClose, onAuthed, embedded = false }: Props if (embedded) return <div className={styles.modalBody}>{body}</div>; + return <AuthWindow onClose={onClose}>{body}</AuthWindow>; +} + +// Only the standalone window takes Esc and the focus. Embedded, the window +// around the form owns both. +function AuthWindow({ onClose, children }: { onClose: () => void; children: ReactNode }) { + const cardRef = useDialog<HTMLDivElement>(onClose); return ( <div className={styles.modalOverlay} role="dialog" aria-modal="true" onClick={onClose}> - <div className={styles.modalCard} onClick={(event) => event.stopPropagation()}> + <div + ref={cardRef} + tabIndex={-1} + className={styles.modalCard} + onClick={(event) => event.stopPropagation()} + > <div className={styles.modalHeader}> <span>Patternflow Community</span> <button type="button" onClick={onClose} aria-label="Close"> × </button> </div> - <div className={styles.modalBody}>{body}</div> + <div className={styles.modalBody}>{children}</div> </div> </div> ); diff --git a/web/src/components/community/BuildWait.test.tsx b/web/src/components/community/BuildWait.test.tsx new file mode 100644 index 000000000..ee4a47e2c --- /dev/null +++ b/web/src/components/community/BuildWait.test.tsx @@ -0,0 +1,109 @@ +// The two windows that used to wait on a build forever: the deck panel (the +// deck's only way in on a phone) and the Send window's code mode (Pattern +// Lab's To hardware). With a server that never finishes, each has to stop +// turning, say the server went quiet, and leave a way to try again. + +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import DeckPanel from "./DeckPanel"; +import SendModuleModal from "./SendModuleModal"; + +const SESSION = { user: { id: "u1", name: "Tester" } }; + +vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }) })); +vi.mock("@/lib/community/auth-client", () => ({ + authClient: { useSession: () => ({ data: SESSION, isPending: false }) }, +})); +vi.mock("@/lib/posthogEvents", () => ({ captureEvent: vi.fn() })); + +function answer(body: unknown, status = 200) { + return Promise.resolve( + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }), + ); +} + +const RUNNING = { + id: "b1", + status: "running", + queuePosition: null, + elapsedMs: 1000, + bytes: null, + error: null, + namespaces: [], + modulesUrl: null, +}; + +let fetchMock: ReturnType<typeof vi.fn>; + +beforeEach(() => { + vi.useFakeTimers({ toFake: ["setInterval", "clearInterval", "setTimeout", "clearTimeout", "Date"] }); + vi.stubEnv("NEXT_PUBLIC_BUILD_ENABLED", "1"); + fetchMock = vi.fn((_url: string, init?: RequestInit) => + init?.method === "POST" ? answer({ id: "b1" }, 202) : answer(RUNNING), + ); + vi.stubGlobal("fetch", fetchMock); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + window.localStorage.clear(); +}); + +async function wait(ms: number) { + await act(async () => { + await vi.advanceTimersByTimeAsync(ms); + }); +} + +const posts = () => fetchMock.mock.calls.filter((call) => (call[1] as RequestInit | undefined)?.method === "POST"); + +describe("waiting on a build", () => { + it("the deck panel stops after 13 seconds for one pattern and goes back to the deck", async () => { + window.localStorage.setItem( + "pf-deck", + JSON.stringify([{ patternId: "p1", title: "Wave", code: "// wave" }]), + ); + render(<DeckPanel />); + await wait(0); + + fireEvent.click(screen.getByTitle(/^Your deck \(1\//)); + fireEvent.click(screen.getByRole("button", { name: "Build 1 module" })); + await wait(0); + expect(screen.getByText(/Building modules… \(usually a second or two\)/)).toBeInTheDocument(); + + await wait(9000); + expect(screen.getByText(/Building modules… \(longer than usual\) · 9 s/)).toBeInTheDocument(); + + await wait(5000); + expect(screen.queryByText(/Building modules…/)).not.toBeInTheDocument(); + expect(screen.getByText(/has not answered in 13 seconds/)).toBeInTheDocument(); + // Back on the list, with the button that starts it again. + expect(screen.getByRole("button", { name: "Build 1 module" })).toBeEnabled(); + + const asked = fetchMock.mock.calls.length; + await wait(30_000); + expect(fetchMock.mock.calls.length).toBe(asked); + }); + + it("the Send window stops after 13 seconds and offers to try again", async () => { + render(<SendModuleModal patternTitle="Wave" code="// wave" onClose={() => {}} />); + await wait(0); + expect(posts()).toHaveLength(1); + expect(screen.getByText(/Building the module…/)).toBeInTheDocument(); + + await wait(14_000); + expect(screen.queryByText(/Building the module…/)).not.toBeInTheDocument(); + expect(screen.getByText(/has not answered in 13 seconds/)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + await wait(0); + expect(posts()).toHaveLength(2); + expect(screen.queryByText(/has not answered/)).not.toBeInTheDocument(); + expect(screen.getByText(/Building the module…/)).toBeInTheDocument(); + }); +}); diff --git a/web/src/components/community/Community.module.css b/web/src/components/community/Community.module.css index 65f5ce0dd..e5ee8526e 100644 --- a/web/src/components/community/Community.module.css +++ b/web/src/components/community/Community.module.css @@ -2527,6 +2527,12 @@ color: var(--pfc-ink); } +/* A window that takes the focus when it opens puts it on the card, as Pattern + Lab's do. The card is not a control, so it draws no ring. */ +.modalCard:focus { + outline: none; +} + .modalCardWide { width: min(720px, 100%); } @@ -2552,6 +2558,9 @@ color: var(--pfc-led); } +/* The × is 11px of glyph in a 44px box, the tab bar's thumb rule. The + negative margin gives the extra back to the header's padding, so the row + keeps its height and the glyph stays where it was. */ .modalHeader button { appearance: none; border: 0; @@ -2559,7 +2568,13 @@ font-size: 16px; cursor: pointer; color: var(--pfc-ink-faint); - margin-left: auto; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 44px; + min-height: 44px; + padding: 0; + margin: -10px -17px -10px auto; } .modalHeader button:hover { diff --git a/web/src/components/community/DeckDock.tsx b/web/src/components/community/DeckDock.tsx index 8f9397cf2..d153380f9 100644 --- a/web/src/components/community/DeckDock.tsx +++ b/web/src/components/community/DeckDock.tsx @@ -17,7 +17,7 @@ import { } from "@/lib/community/deck"; import { COMMUNITY_FETCH_INIT, buildsConfigured, communityApiUrl } from "@/lib/community/apiBase"; import { useDeviceHost } from "@/lib/community/deviceHost"; -import { captureEvent } from "@/lib/posthogEvents"; +import { useModuleBuild } from "@/lib/community/useModuleBuild"; import AuthModal from "./AuthModal"; import PatternCanvas from "./PatternCanvas"; import ShareDeckModal from "./ShareDeckModal"; @@ -77,44 +77,10 @@ function dockBelongs(pathname: string): boolean { * read as something you fill; past that the single "+" says it on its own. */ const MIN_PLACES = 3; -/** - * What a build is told to expect, and when to stop believing it. - * - * Both scale with the deck, because both used to be flat numbers sized for a - * ten-pattern cap — and a full deck at the current cap would have hit the - * timeout while the server was still working perfectly well, which reads as a - * failure and is not one. - * - * Each pattern compiles alone in about half a second and there is no firmware - * image to link. The per-pattern allowance is far more than that on purpose: - * it covers a cold compiler and a Pi that is also serving the site, without - * promising something the machine cannot keep. - * - * The timeout is the browser giving up on hearing back, not a cancellation — - * the build may well still be running — so the message says that rather than - * claiming it failed. - */ -const BUILD_BASE_S = 6; -const BUILD_PER_PATTERN_S = 1.5; -const buildExpectedS = (patterns: number) => - Math.ceil(BUILD_BASE_S + BUILD_PER_PATTERN_S * Math.max(1, patterns)); -const buildTimeoutMs = (patterns: number) => (buildExpectedS(patterns) + 5) * 1000; - /** A drag in progress: which slot is moving, how far the pointer has taken it, * and how wide one step is (measured, so it cannot drift from the CSS). */ type Drag = { index: number; startX: number; dx: number; step: number; overTrash: boolean }; -type ModuleBuildState = { - id: string; - status: "queued" | "running" | "done" | "error"; - queuePosition: number | null; - elapsedMs: number | null; - bytes: number | null; - error: string | null; - namespaces: string[]; - modulesUrl: string | null; -}; - export default function DeckDock() { const pathname = usePathname() ?? ""; const router = useRouter(); @@ -142,14 +108,16 @@ export default function DeckDock() { const [dropping, setDropping] = useState(false); const [dropNote, setDropNote] = useState<string | null>(null); - const [build, setBuild] = useState<ModuleBuildState | null>(null); - const [buildError, setBuildError] = useState<string | null>(null); + const { + build, + error: buildError, + running, + waited, + expectedS, + start: queueBuild, + reset: resetBuild, + } = useModuleBuild(); const [logOpen, setLogOpen] = useState(false); - const pollRef = useRef<number | null>(null); - // Whole seconds since the build was queued, and the instant it was. Shown - // while waiting, and read by the rule that stops waiting. - const [waited, setWaited] = useState(0); - const startedAtRef = useRef(0); useEffect(() => { const sync = () => { @@ -167,7 +135,6 @@ export default function DeckDock() { useEffect( () => () => { if (settleRef.current) cancelAnimationFrame(settleRef.current); - if (pollRef.current) window.clearInterval(pollRef.current); }, [], ); @@ -319,112 +286,26 @@ export default function DeckDock() { }; // ── Building ─────────────────────────────────────────────────────────────── - - const stopPolling = useCallback(() => { - if (pollRef.current) window.clearInterval(pollRef.current); - pollRef.current = null; - }, []); - - const poll = useCallback( - async (id: string) => { - try { - const response = await fetch( - communityApiUrl(`/api/community/builds/${id}`), - COMMUNITY_FETCH_INIT, - ); - if (!response.ok) return; - const state = (await response.json()) as ModuleBuildState; - setBuild(state); - if (state.status === "done" || state.status === "error") { - stopPolling(); - captureEvent("module_build_finished", { - status: state.status, - patterns: state.namespaces.length, - bytes: state.bytes, - }); - } - } catch { - // A dropped poll is not a failed build; the next tick catches up. - } - }, - [stopPolling], - ); + // Asking, timing and giving up live in useModuleBuild, shared with the deck + // panel and the Send window. /** * Straight from the press. No dialog in between: the deck is already on * screen and the button already says what it does, so the only thing a * confirmation step would add is a second press. */ - const startBuild = async () => { + const startBuild = () => { const items = deckItems(); if (items.length === 0) return; if (!session) { setNeedsAuth(true); return; } - setBuildError(null); - setBuild({ - id: "", - status: "queued", - queuePosition: null, - elapsedMs: null, - bytes: null, - error: null, - namespaces: [], - modulesUrl: null, - }); - try { - const response = await fetch(communityApiUrl("/api/community/builds"), { - method: "POST", - ...COMMUNITY_FETCH_INIT, - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - format: "pfm", - // Sent in deck order, which is the order they appear on the device. - patterns: items.map((item) => ({ label: item.title, code: item.code })), - }), - }); - const payload = (await response.json()) as { id?: string; error?: string }; - if (!response.ok || !payload.id) { - setBuild(null); - setBuildError(payload.error ?? "Could not queue the build."); - return; - } - captureEvent("module_build_queued", { patterns: items.length }); - const buildId = payload.id; - startedAtRef.current = Date.now(); - setWaited(0); - void poll(buildId); - // One timer for both jobs: ask how it is going, and keep the clock the - // giving-up rule reads. Ticking here rather than in `poll` means a - // request that hangs cannot also stall the timeout that exists to - // catch it. - pollRef.current = window.setInterval(() => { - const elapsed = Date.now() - startedAtRef.current; - setWaited(Math.floor(elapsed / 1000)); - if (elapsed > buildTimeoutMs(items.length)) { - stopPolling(); - setBuild(null); - setBuildError( - `The build server has not answered in ${Math.round(buildTimeoutMs(items.length) / 1000)} seconds. ` + - "The build may still be running — give it a moment and try again, and if it keeps " + - "happening the build worker is probably down.", - ); - captureEvent("module_build_finished", { status: "timeout", patterns: items.length }); - return; - } - void poll(buildId); - }, 1000); - } catch { - setBuild(null); - setBuildError("Network error — is the build server reachable?"); - } + void queueBuild(items.map((item) => ({ label: item.title, code: item.code }))); }; const clearBuild = () => { - stopPolling(); - setBuild(null); - setBuildError(null); + resetBuild(); setLogOpen(false); }; @@ -440,7 +321,6 @@ export default function DeckDock() { // invitation once there is anything to invite onto the end of. const shown = Math.min(DECK_MAX, Math.max(MIN_PLACES, deck.length + 1)); const slots = Array.from({ length: shown }, (_, index) => deck[index] ?? null); - const running = build !== null && build.status !== "done" && build.status !== "error"; const done = build?.status === "done" && build.modulesUrl; const failed = build?.status === "error" || buildError !== null; @@ -642,23 +522,21 @@ export default function DeckDock() { {running && ( <span className={styles.buildRunningNote} - data-slow={waited >= buildExpectedS(deck.length)} + data-slow={waited >= expectedS} // The clock is the honest part; the promise is the reassuring // one. Both, so a build that is taking its time does not look // like a build that has died. - title={`About half a second per pattern, and under ${buildExpectedS(deck.length)} on a busy server.`} + title={`About half a second per pattern, and under ${expectedS} on a busy server.`} > <span className={styles.buildSpinner} aria-hidden="true" /> {build?.status === "queued" && build.queuePosition && build.queuePosition > 1 ? `${build.queuePosition} in the queue` - : build?.status === "queued" + : !build || build.status === "queued" ? "Waiting for a build slot" : `Building ${deck.length} module${deck.length === 1 ? "" : "s"}`} {waited > 0 && ` · ${waited} s`} <em> - {waited >= buildExpectedS(deck.length) - ? "longer than usual" - : `usually under ${buildExpectedS(deck.length)} s`} + {waited >= expectedS ? "longer than usual" : `usually under ${expectedS} s`} </em> </span> )} @@ -671,7 +549,7 @@ export default function DeckDock() { ? "Add patterns to the deck first" : "Build the deck as loadable modules and install it over Wi-Fi" } - onClick={() => void startBuild()} + onClick={startBuild} > {running ? "Building…" : "Send to my board"} </button> diff --git a/web/src/components/community/DeckPanel.tsx b/web/src/components/community/DeckPanel.tsx index f9a5bff0f..4b38f6c4d 100644 --- a/web/src/components/community/DeckPanel.tsx +++ b/web/src/components/community/DeckPanel.tsx @@ -1,10 +1,10 @@ "use client"; import Link from "next/link"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { authClient } from "@/lib/community/auth-client"; -import { COMMUNITY_FETCH_INIT, buildsConfigured, communityApiUrl } from "@/lib/community/apiBase"; +import { buildsConfigured, communityApiUrl } from "@/lib/community/apiBase"; import { COLLECTION_EVENT, DECK_MAX, @@ -16,7 +16,7 @@ import { type CollectedPattern, } from "@/lib/community/deck"; import { useDeviceHost } from "@/lib/community/deviceHost"; -import { captureEvent } from "@/lib/posthogEvents"; +import { useModuleBuild } from "@/lib/community/useModuleBuild"; import AuthModal from "./AuthModal"; import ShareDeckModal from "./ShareDeckModal"; import styles from "./Community.module.css"; @@ -34,27 +34,14 @@ import styles from "./Community.module.css"; // Building turns the deck into loadable .pfm modules: ~½ s per pattern, one // zip, installed from the device's own /patterns page with no reflash. -type ModuleBuildState = { - id: string; - status: "queued" | "running" | "done" | "error"; - queuePosition: number | null; - elapsedMs: number | null; - bytes: number | null; - error: string | null; - namespaces: string[]; - modulesUrl: string | null; -}; - export default function DeckPanel() { const router = useRouter(); const { data: session } = authClient.useSession(); const [deck, setDeck] = useState<CollectedPattern[]>([]); const [open, setOpen] = useState(false); - const [build, setBuild] = useState<ModuleBuildState | null>(null); - const [error, setError] = useState<string | null>(null); + const { build, error, busy, waited, expectedS, start, reset } = useModuleBuild(); const [note, setNote] = useState<string | null>(null); - const [busy, setBusy] = useState(false); const [confirmEmpty, setConfirmEmpty] = useState(false); // Sharing gets its own modal, opened with the panel closed — two stacked // dialogs would fight over the overlay click. @@ -90,87 +77,13 @@ export default function DeckPanel() { return () => window.removeEventListener(DECK_PANEL_EVENT, open); }, []); - const pollRef = useRef<number | null>(null); - useEffect( - () => () => { - if (pollRef.current) window.clearInterval(pollRef.current); - }, - [], - ); - - const poll = useCallback(async (id: string) => { - try { - const response = await fetch( - communityApiUrl(`/api/community/builds/${id}`), - COMMUNITY_FETCH_INIT, - ); - if (!response.ok) return; - const state = (await response.json()) as ModuleBuildState; - setBuild(state); - if (state.status === "done" || state.status === "error") { - if (pollRef.current) window.clearInterval(pollRef.current); - pollRef.current = null; - captureEvent("module_build_finished", { - status: state.status, - patterns: state.namespaces.length, - bytes: state.bytes, - }); - } - } catch { - // A dropped poll is not a failed build; the next tick catches up. - } - }, []); - - const startBuild = async () => { + const startBuild = () => { if (deck.length === 0) return; if (!session) { setNeedsAuth(true); return; } - setBusy(true); - setError(null); - try { - const response = await fetch(communityApiUrl("/api/community/builds"), { - method: "POST", - ...COMMUNITY_FETCH_INIT, - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - format: "pfm", - // Sent in deck order, which is the order they appear on the device. - patterns: deck.map((item) => ({ label: item.title, code: item.code })), - }), - }); - const payload = (await response.json()) as { id?: string; error?: string }; - if (!response.ok || !payload.id) { - setError(payload.error ?? "Could not queue the build."); - return; - } - const buildId = payload.id; - captureEvent("module_build_queued", { patterns: deck.length }); - setBuild({ - id: buildId, - status: "queued", - queuePosition: null, - elapsedMs: null, - bytes: null, - error: null, - namespaces: [], - modulesUrl: null, - }); - void poll(buildId); - pollRef.current = window.setInterval(() => void poll(buildId), 1000); - } catch { - setError("Network error — is the build server reachable?"); - } finally { - setBusy(false); - } - }; - - const reset = () => { - if (pollRef.current) window.clearInterval(pollRef.current); - pollRef.current = null; - setBuild(null); - setError(null); + void start(deck.map((item) => ({ label: item.title, code: item.code }))); }; const close = () => { @@ -228,7 +141,10 @@ export default function DeckPanel() { ? build.queuePosition && build.queuePosition > 1 ? `Waiting — ${build.queuePosition} in the queue…` : "Waiting for a build slot…" - : "Building modules… (usually a second or two)"} + : waited >= expectedS + ? "Building modules… (longer than usual)" + : "Building modules… (usually a second or two)"} + {waited > 0 && ` · ${waited} s`} </p> )} @@ -280,6 +196,7 @@ export default function DeckPanel() { value={deviceHost} onChange={(event) => changeDeviceHost(event.target.value)} spellCheck={false} + aria-label="Device address" style={{ font: "inherit", width: "16ch", @@ -365,7 +282,7 @@ export default function DeckPanel() { type="button" className={styles.btnAccent} disabled={busy} - onClick={() => void startBuild()} + onClick={startBuild} > {busy ? "Queueing…" diff --git a/web/src/components/community/EditDetailsModal.tsx b/web/src/components/community/EditDetailsModal.tsx index 081235325..58203b2e8 100644 --- a/web/src/components/community/EditDetailsModal.tsx +++ b/web/src/components/community/EditDetailsModal.tsx @@ -10,6 +10,7 @@ import { type MadeHow, } from "@/lib/community/validate"; import { LICENSE_OPTIONS, forkLicenseOptions, licenseBySpdx } from "@/lib/pattern/share"; +import { useDialog } from "@/lib/hooks/useDialog"; import { captureEvent } from "@/lib/posthogEvents"; import { COMMUNITY_FETCH_INIT, communityApiUrl } from "@/lib/community/apiBase"; import { @@ -63,6 +64,10 @@ export default function EditDetailsModal({ ); const [error, setError] = useState<string | null>(null); const [busy, setBusy] = useState(false); + // The title field takes the focus, as its autoFocus did. autoFocus moved it + // before the hook could see which button opened the window, so the focus + // had nowhere to go back to. + const titleRef = useDialog<HTMLInputElement>(onClose); // What this pattern may be relicensed to. The pattern's CURRENT licence is // always included even when it is no longer offered (MIT, CC0): a retired @@ -121,10 +126,10 @@ export default function EditDetailsModal({ <label className={styles.field}> <span className={styles.fieldLabel}>Title</span> <input + ref={titleRef} className={styles.textInput} value={title} maxLength={TITLE_MAX} - autoFocus onChange={(event) => setTitle(event.target.value)} /> </label> diff --git a/web/src/components/community/FeedControls.tsx b/web/src/components/community/FeedControls.tsx index c4cff61d4..cd553be67 100644 --- a/web/src/components/community/FeedControls.tsx +++ b/web/src/components/community/FeedControls.tsx @@ -48,8 +48,11 @@ export default function FeedControls({ const hrefWith = (patch: Record<string, string | null>) => { const next = new URLSearchParams(params.toString()); - // Parameters from the paginated era — scrub them off any link followed - // from an old bookmark. + // Parameters from the paged feed: page and size from 22 July 2026 + // (e146de6), view from the Large/Small card toggle on 25 July (5e5d50c). + // Infinite scroll retired all three on 2 August (c4a57f1). A bookmark + // saved in those days still carries them, so every link followed from it + // drops them. next.delete("page"); next.delete("size"); next.delete("view"); diff --git a/web/src/components/community/PatternCanvas.tsx b/web/src/components/community/PatternCanvas.tsx index 50585144f..4259b6c97 100644 --- a/web/src/components/community/PatternCanvas.tsx +++ b/web/src/components/community/PatternCanvas.tsx @@ -69,7 +69,7 @@ export default function PatternCanvas({ > <div className={rotate ? styles.screenRotator : styles.screenUpright}> {thumb ? ( - // eslint-disable-next-line @next/next/no-img-element + // eslint-disable-next-line @next/next/no-img-element -- a data URL this browser just rendered; there is no file for the optimizer to fetch <img src={thumb} alt={`${title} preview`} /> ) : ( <div className={styles.cardThumbNote} aria-hidden="true" /> diff --git a/web/src/components/community/PatternCard.tsx b/web/src/components/community/PatternCard.tsx index f797b5bdc..6ce41a1aa 100644 --- a/web/src/components/community/PatternCard.tsx +++ b/web/src/components/community/PatternCard.tsx @@ -5,7 +5,6 @@ import { useEffect, useRef, useState, useSyncExternalStore } from "react"; import { createPortal } from "react-dom"; import { authClient } from "@/lib/community/auth-client"; import AuthModal from "@/components/community/AuthModal"; -import { useMediaQuery } from "@/lib/hooks/useMediaQuery"; import { renderPatternThumb } from "@/lib/community/thumbs"; import { knobSetupFromCode } from "@/lib/community/knobs"; import { @@ -450,7 +449,7 @@ export default function PatternCard({ <div className={rotateThumb ? styles.screenRotator : styles.screenUpright}> {thumb ? ( - // eslint-disable-next-line @next/next/no-img-element + // eslint-disable-next-line @next/next/no-img-element -- a data URL this browser just rendered; there is no file for the optimizer to fetch <img src={thumb} alt={`${item.title} preview`} /> ) : ( <div className={styles.cardThumbNote}>{failed ? "render error" : "rendering…"}</div> diff --git a/web/src/components/community/PublishModal.test.tsx b/web/src/components/community/PublishModal.test.tsx index 1b413754e..63d37156a 100644 --- a/web/src/components/community/PublishModal.test.tsx +++ b/web/src/components/community/PublishModal.test.tsx @@ -6,6 +6,8 @@ import { act, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { buildStoredPatternCode, communityPatternUrl } from "@/lib/community/license"; +import { CODE_MAX } from "@/lib/community/validate"; import PublishModal from "./PublishModal"; const push = vi.fn(); @@ -102,3 +104,90 @@ describe("PublishModal", () => { expect(server.mock).toHaveBeenCalledTimes(2); }); }); + +// The community stores a pattern inside its licence header and footer, and +// "Open in Pattern Lab" hands that stored text to the lab unchanged. The API +// takes the wrapping off before it measures; the modal measured the wrapped +// text, so a fork it would have accepted was refused before it was sent. +describe("PublishModal code size", () => { + /** A pattern body of exactly `length` characters. */ + function bodyOf(length: number) { + const pad = "\n// "; + return `${CODE}${pad}${"x".repeat(length - CODE.length - pad.length)}`; + } + + /** How a fork of `@ada`'s pattern comes back out of the community. */ + function storedFork(body: string) { + return buildStoredPatternCode(body, { + title: "Pentagrid Tower (remix)", + license: "CC-BY-SA-4.0", + handle: "grace", + date: "2026-10-01", + basedOn: { title: "Pentagrid Tower", handle: "ada", url: communityPatternUrl("p0") }, + }); + } + + function openFork(code: string) { + render( + <PublishModal + code={code} + parentId="p1" + parentTitle="Pentagrid Tower (remix)" + parentLicense="CC-BY-SA-4.0" + initialTitle="Pentagrid Tower (remix 2)" + onClose={() => {}} + />, + ); + return screen.getByRole("button", { name: "Publish to the wall" }); + } + + it("publishes an untouched fork whose body fits under the cap", async () => { + const code = storedFork(bodyOf(99_700)); + expect(code.length).toBeGreaterThan(CODE_MAX); + + const button = openFork(code); + act(() => button.click()); + + expect(screen.queryByText(/KB of code/)).toBeNull(); + expect(server.mock).toHaveBeenCalledTimes(1); + await server.answer(201, { id: "p2" }); + await waitFor(() => expect(push).toHaveBeenCalledWith("/community/p/p2")); + }); + + it("saves the author's own post reopened in the lab, unedited", () => { + const code = storedFork(bodyOf(99_700)); + render( + <PublishModal + code={code} + parentId={null} + parentTitle={null} + editOf={{ + id: "p1", + title: "Pentagrid Tower (remix)", + description: null, + visibility: "public", + hasCpp: false, + portCount: 0, + }} + onClose={() => {}} + />, + ); + + act(() => screen.getByRole("button", { name: "Update this pattern" }).click()); + + expect(screen.queryByText(/KB of code/)).toBeNull(); + expect(server.mock).toHaveBeenCalledTimes(1); + expect(server.mock.mock.calls[0]).toEqual([ + "/api/community/patterns/p1", + expect.objectContaining({ method: "PATCH" }), + ]); + }); + + it("still stops a body over the cap before it is sent", () => { + const button = openFork(storedFork(bodyOf(CODE_MAX + 600))); + act(() => button.click()); + + expect(screen.getByText(/This pattern is 101 KB of code/)).toBeInTheDocument(); + expect(server.mock).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/components/community/PublishModal.tsx b/web/src/components/community/PublishModal.tsx index 3e8adf055..3bdc40152 100644 --- a/web/src/components/community/PublishModal.tsx +++ b/web/src/components/community/PublishModal.tsx @@ -24,6 +24,7 @@ import { LICENSE_OPTIONS, forkLicenseOptions, licenseById, + stripShareWrapping, } from "@/lib/pattern/share"; import { captureEvent } from "@/lib/posthogEvents"; import AuthModal from "./AuthModal"; @@ -149,11 +150,16 @@ export default function PublishModal({ const send = async (): Promise<boolean> => { const trimmed = title.trim(); setError(null); - if (code.length > CODE_MAX) { + // Measured the way the API measures it, with the licence header and + // footer off. "Open in Pattern Lab" hands back the stored code with both + // still on, a few hundred characters the server never counts, and + // counting them here refused a pattern the server takes unchanged. + const codeLength = (code.length <= CODE_MAX * 2 ? stripShareWrapping(code) : code).length; + if (codeLength > CODE_MAX) { // The server says the same thing, but after a round trip that costs // one of five publishes a minute — and without the likely cause. setError( - `This pattern is ${Math.round(code.length / 1000)} KB of code; the community takes up to ${ + `This pattern is ${Math.round(codeLength / 1000)} KB of code; the community takes up to ${ CODE_MAX / 1000 } KB. Imported images as pixel layers are the usual reason — hide or delete one and share again.`, ); diff --git a/web/src/components/community/ReportModal.tsx b/web/src/components/community/ReportModal.tsx index e940623e2..774011bb9 100644 --- a/web/src/components/community/ReportModal.tsx +++ b/web/src/components/community/ReportModal.tsx @@ -11,6 +11,7 @@ import { type ReportReason, type ReportTargetType, } from "@/lib/community/validate"; +import { useDialog } from "@/lib/hooks/useDialog"; import { captureEvent } from "@/lib/posthogEvents"; import AuthModal from "./AuthModal"; import styles from "./Community.module.css"; @@ -39,6 +40,7 @@ export default function ReportModal({ // One request per press, decided in the click's own tick (#455). const latch = useSubmitLatch(); const [sent, setSent] = useState(false); + const cardRef = useDialog<HTMLDivElement>(onClose); const submit = async () => { if (!latch.take()) return; @@ -68,7 +70,12 @@ export default function ReportModal({ return ( <div className={styles.modalOverlay} role="dialog" aria-modal="true" onClick={onClose}> - <div className={styles.modalCard} onClick={(event) => event.stopPropagation()}> + <div + ref={cardRef} + tabIndex={-1} + className={styles.modalCard} + onClick={(event) => event.stopPropagation()} + > <div className={styles.modalHeader}> <span className={styles.modalKicker}>Report</span> <span>{targetLabel}</span> @@ -125,6 +132,7 @@ export default function ReportModal({ rows={3} value={detail} maxLength={REPORT_DETAIL_MAX} + aria-label="Details" placeholder="Anything that helps us look at the right thing (optional)" onChange={(event) => setDetail(event.target.value)} /> diff --git a/web/src/components/community/SendModuleModal.tsx b/web/src/components/community/SendModuleModal.tsx index 79ec8025e..88f9e8cc5 100644 --- a/web/src/components/community/SendModuleModal.tsx +++ b/web/src/components/community/SendModuleModal.tsx @@ -1,11 +1,12 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { useRouter } from "next/navigation"; import { authClient } from "@/lib/community/auth-client"; -import { COMMUNITY_FETCH_INIT, communityApiUrl } from "@/lib/community/apiBase"; +import { communityApiUrl } from "@/lib/community/apiBase"; import { useDeviceHost } from "@/lib/community/deviceHost"; import { captureEvent } from "@/lib/posthogEvents"; +import { useModuleBuild } from "@/lib/community/useModuleBuild"; import { useZipDownload } from "@/lib/community/zipDownload"; import AuthModal from "./AuthModal"; import { ZipInstallNote, ZipProgress } from "./ZipDownload"; @@ -30,17 +31,6 @@ import styles from "./Community.module.css"; // nowhere but this browser. That still goes through POST /builds, signed // in, with a single-item list, so there is one server path for it. -type ModuleBuild = { - id: string; - status: "queued" | "running" | "done" | "error"; - queuePosition: number | null; - elapsedMs: number | null; - bytes: number | null; - error: string | null; - namespaces: string[]; - modulesUrl: string | null; -}; - export default function SendModuleModal( props: { patternTitle: string; @@ -170,80 +160,12 @@ function CodeBuildBody({ patternTitle, code }: { patternTitle: string; code: str const { data: session, isPending } = authClient.useSession(); const { deviceHost, changeDeviceHost, patternsUrl } = useDeviceHost(); - const [build, setBuild] = useState<ModuleBuild | null>(null); - const [error, setError] = useState<string | null>(null); - const [busy, setBusy] = useState(false); - - const pollRef = useRef<number | null>(null); - useEffect( - () => () => { - if (pollRef.current) window.clearInterval(pollRef.current); - }, - [], - ); + const { build, error, busy, waited, expectedS, start } = useModuleBuild(); - const poll = useCallback(async (id: string) => { - try { - const response = await fetch( - communityApiUrl(`/api/community/builds/${id}`), - COMMUNITY_FETCH_INIT, - ); - if (!response.ok) return; - const state = (await response.json()) as ModuleBuild; - setBuild(state); - if (state.status === "done" || state.status === "error") { - if (pollRef.current) window.clearInterval(pollRef.current); - pollRef.current = null; - captureEvent("module_build_finished", { - status: state.status, - patterns: 1, - bytes: state.bytes, - }); - } - } catch { - // A dropped poll is not a failed build; the next tick catches up. - } - }, []); - - const startBuild = useCallback(async () => { + const startBuild = useCallback(() => { if (!session) return; - setBusy(true); - setError(null); - try { - const response = await fetch(communityApiUrl("/api/community/builds"), { - method: "POST", - ...COMMUNITY_FETCH_INIT, - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - format: "pfm", - patterns: [{ label: patternTitle, code }], - }), - }); - const payload = (await response.json()) as { id?: string; error?: string }; - if (!response.ok || !payload.id) { - setError(payload.error ?? "Could not queue the build."); - return; - } - const buildId = payload.id; - captureEvent("module_build_queued", { patterns: 1 }); - setBuild({ - id: buildId, - status: "queued", - queuePosition: null, - elapsedMs: null, - bytes: null, - error: null, - namespaces: [], - modulesUrl: null, - }); - void poll(buildId); - pollRef.current = window.setInterval(() => void poll(buildId), 1000); - } catch { - setError("Network error — is the build server reachable?"); - } finally { - setBusy(false); - } - }, [session, patternTitle, code, poll]); + void start([{ label: patternTitle, code }]); + }, [session, patternTitle, code, start]); // Building one module takes about half a second, so asking the visitor to // press a second button after opening the modal would only be a wait with @@ -252,16 +174,10 @@ function CodeBuildBody({ patternTitle, code }: { patternTitle: string; code: str useEffect(() => { if (session && !kicked.current) { kicked.current = true; - void startBuild(); + startBuild(); } }, [session, startBuild]); - const retry = () => { - setBuild(null); - setError(null); - void startBuild(); - }; - return ( <> {isPending ? ( @@ -272,14 +188,26 @@ function CodeBuildBody({ patternTitle, code }: { patternTitle: string; code: str <AuthModal embedded onClose={() => undefined} onAuthed={() => router.refresh()} /> ) : ( <div className={styles.modalBody}> - {error && <div className={styles.formError}>{error}</div>} + {error && ( + <> + <div className={styles.formError}>{error}</div> + <div className={styles.actionRow}> + <button type="button" className={styles.btn} onClick={startBuild}> + Try again + </button> + </div> + </> + )} {(busy || (build && build.status !== "done" && build.status !== "error")) && ( <p className={styles.buildStatusLine}> <span className={styles.buildSpinner} aria-hidden="true" /> {build && build.status === "queued" && build.queuePosition && build.queuePosition > 1 ? `Waiting — ${build.queuePosition} in the queue…` - : "Building the module… (usually a second or two)"} + : waited >= expectedS + ? "Building the module… (longer than usual)" + : "Building the module… (usually a second or two)"} + {waited > 0 && ` · ${waited} s`} </p> )} @@ -288,7 +216,7 @@ function CodeBuildBody({ patternTitle, code }: { patternTitle: string; code: str <div className={styles.formError}>The build failed.</div> <pre className={styles.buildLog}>{build.error}</pre> <div className={styles.actionRow}> - <button type="button" className={styles.btn} onClick={retry}> + <button type="button" className={styles.btn} onClick={startBuild}> Try again </button> </div> @@ -326,6 +254,7 @@ function CodeBuildBody({ patternTitle, code }: { patternTitle: string; code: str value={deviceHost} onChange={(event) => changeDeviceHost(event.target.value)} spellCheck={false} + aria-label="Device address" style={{ font: "inherit", width: "16ch", diff --git a/web/src/components/community/ShareDeckPackModal.tsx b/web/src/components/community/ShareDeckPackModal.tsx index 0f4f0fe0e..08e5c8f4e 100644 --- a/web/src/components/community/ShareDeckPackModal.tsx +++ b/web/src/components/community/ShareDeckPackModal.tsx @@ -40,9 +40,8 @@ export default function ShareDeckPackModal({ const [copied, setCopied] = useState(false); const inputRef = useRef<HTMLInputElement | null>(null); - // Escape closes it. The other modals here rely on the backdrop alone, which - // is fine when the content is a form you are filling in — this one is a - // thing you glance at and leave, so the fastest way out should work. + // Escape closes it. This one is a thing you glance at and leave, so it had + // the key before the form windows took it from lib/hooks/useDialog. useEffect(() => { const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") onClose(); diff --git a/web/src/components/community/ShippedPackCard.tsx b/web/src/components/community/ShippedPackCard.tsx index ed6f8becf..70641d8ff 100644 --- a/web/src/components/community/ShippedPackCard.tsx +++ b/web/src/components/community/ShippedPackCard.tsx @@ -45,7 +45,7 @@ export default function ShippedPackCard({ pack }: { pack: PatternPack }) { // resolve — and this is the card a new owner reaches first, with no account // and nothing on the board yet. Telling us the IP once, anywhere, should be // enough for all of them. - const { deviceHost, changeDeviceHost } = useDeviceHost(); + const { deviceHost, host, changeDeviceHost } = useDeviceHost(); const shown = pack.order .slice(0, STRIP_CELLS - 1) @@ -103,7 +103,7 @@ export default function ShippedPackCard({ pack }: { pack: PatternPack }) { the pack back over https and posts it to the device itself. */} <a className={styles.btnPrimary} - href={packInstallUrl(pack, deviceHost.trim() || "patternflow.local", origin)} + href={packInstallUrl(pack, host, origin)} > Install to my board </a> diff --git a/web/src/components/community/ZipDownload.test.tsx b/web/src/components/community/ZipDownload.test.tsx index 3a23c0657..a52f87ba2 100644 --- a/web/src/components/community/ZipDownload.test.tsx +++ b/web/src/components/community/ZipDownload.test.tsx @@ -58,6 +58,27 @@ describe("zip surfaces", () => { expect(screen.getByRole("link", { name: "http://192.168.0.44/patterns" })).toBeInTheDocument(); }); + it("sends a pasted http:// address and an emptied field to a real board", async () => { + window.localStorage.setItem("pf-device-host", "http://192.168.0.44/"); + const { unmount } = render( + <SendModuleModal patternTitle="Wave" patternId="p123" onClose={() => {}} />, + ); + await waitFor(() => expect(screen.getByText(/Wave is ready/)).toBeInTheDocument()); + let href = screen.getByText("Send over Wi-Fi").closest("a")?.getAttribute("href") ?? ""; + expect(new URL(href).host).toBe("192.168.0.44"); + expect(screen.getByRole("link", { name: "http://192.168.0.44/patterns" })).toBeInTheDocument(); + unmount(); + + window.localStorage.setItem("pf-device-host", ""); + render(<SendModuleModal patternTitle="Wave" patternId="p123" onClose={() => {}} />); + await waitFor(() => expect(screen.getByText(/Wave is ready/)).toBeInTheDocument()); + href = screen.getByText("Send over Wi-Fi").closest("a")?.getAttribute("href") ?? ""; + expect(new URL(href).host).toBe("patternflow.local"); + expect(screen.getByRole("link", { name: "http://patternflow.local/patterns" })).toBeInTheDocument(); + // The field keeps what was typed: nothing, over the placeholder. + expect(screen.getByRole("textbox", { name: "Board address" })).toHaveValue(""); + }); + it("pattern mode disables sending a header that cannot compile", async () => { fetchMock.mockImplementation(() => Promise.resolve( diff --git a/web/src/components/community/ZipDownload.tsx b/web/src/components/community/ZipDownload.tsx index b5feac204..024b7f414 100644 --- a/web/src/components/community/ZipDownload.tsx +++ b/web/src/components/community/ZipDownload.tsx @@ -86,8 +86,7 @@ export function ZipProgress({ // type. Changing it here changes it everywhere. export function ZipInstallNote({ kind }: { kind: ZipKind }) { - const { deviceHost, changeDeviceHost } = useDeviceHost(); - const host = deviceHost.trim() || "patternflow.local"; + const { deviceHost, host, changeDeviceHost } = useDeviceHost(); const patternsPage = `http://${host}/patterns`; return ( diff --git a/web/src/components/community/communityCss.test.ts b/web/src/components/community/communityCss.test.ts new file mode 100644 index 000000000..70fcd4fde --- /dev/null +++ b/web/src/components/community/communityCss.test.ts @@ -0,0 +1,71 @@ +// jsdom applies none of the CSS modules, so the sizes and states below are +// read from the files. In Chromium at 390px the dialog × measured 11×23px +// before these rules and 44×44px after. + +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const css = (name: string) => readFileSync(path.resolve(__dirname, name), "utf8"); + +/** The declarations of the first rule whose selector list is exactly `selector`. */ +function rule(source: string, selector: string): string { + const at = source.indexOf(`\n${selector} {`); + expect(at, `${selector} is missing`).toBeGreaterThan(-1); + return source.slice(source.indexOf("{", at) + 1, source.indexOf("}", at)); +} + +/** The declarations of `selector` inside the first `@media <query>` block. */ +function mediaRule(source: string, query: string, selector: string): string { + const at = source.indexOf(`\n@media ${query} {`); + expect(at, `@media ${query} is missing`).toBeGreaterThan(-1); + const block = source.slice(at, source.indexOf("\n}", at)); + const ruleAt = block.indexOf(`\n ${selector} {`); + expect(ruleAt, `${selector} is missing from @media ${query}`).toBeGreaterThan(-1); + return block.slice(block.indexOf("{", ruleAt) + 1, block.indexOf("}", ruleAt)); +} + +describe("community dialogs", () => { + it("give the header's close button a 44px box", () => { + const button = rule(css("Community.module.css"), ".modalHeader button"); + expect(button).toMatch(/min-width: 44px;/); + expect(button).toMatch(/min-height: 44px;/); + }); +}); + +describe("atlas", () => { + const atlas = css("Atlas.module.css"); + + it("draws the focus ring on a point only for the keyboard", () => { + expect(rule(atlas, ".focusRing")).toMatch(/visibility: hidden;/); + expect(rule(atlas, ".node:focus-visible .focusRing")).toMatch(/visibility: visible;/); + }); + + it("frames a focused pin tile inside the tile", () => { + expect(rule(atlas, ".pin:focus-visible .pinTile::after")).toMatch(/inset: 0;/); + }); + + it("stops the live ring's pulse under reduced motion", () => { + const reduced = atlas.split("@media (prefers-reduced-motion: reduce) {").slice(1); + expect(reduced.some((part) => /^\s*\.pulseRing \{[^}]*animation: none;/.test(part))).toBe(true); + }); + + it("hides the wheel and drag hint on a touch screen", () => { + expect(mediaRule(atlas, "(hover: none)", ".zoomHint")).toMatch(/display: none;/); + }); + + // In Chromium at 390px, 21 of the map's 83 labels sat under the two rows of + // chips, and 152px of the 380px map box was blank under the drawing. + describe("on a phone", () => { + const phone = (selector: string) => mediaRule(atlas, "(max-width: 900px)", selector); + + it("stands the filter chips above the map", () => { + expect(phone(".mapTop")).toMatch(/position: static;/); + }); + + it("makes the map box as tall as the drawing", () => { + expect(phone(".map")).toMatch(/height: auto;/); + expect(phone(".mapWrap")).not.toMatch(/height:/); + }); + }); +}); diff --git a/web/src/components/community/dialogKeys.test.tsx b/web/src/components/community/dialogKeys.test.tsx new file mode 100644 index 000000000..e268adc20 --- /dev/null +++ b/web/src/components/community/dialogKeys.test.tsx @@ -0,0 +1,126 @@ +// The five community windows that heard no keys: sign-in, report, Edit +// details, Add firmware header and Publish a performance. In Chromium at +// 1440px each stayed open on Esc, and four left the focus on the button +// behind them. Each case opens its window from a real button, so the focus +// has somewhere to come back to. + +import { useState, type ReactNode } from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import AddHeaderModal from "./AddHeaderModal"; +import AddPerformanceModal from "./AddPerformanceModal"; +import AuthModal from "./AuthModal"; +import EditDetailsModal from "./EditDetailsModal"; +import ReportModal from "./ReportModal"; + +const auth = vi.hoisted(() => ({ + session: { user: { id: "u1", name: "Tester" } } as { user: { id: string; name: string } } | null, +})); + +vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }) })); +vi.mock("@/lib/community/auth-client", () => ({ + authClient: { useSession: () => ({ data: auth.session, isPending: false }) }, + dummyEmailFor: (name: string) => `${name}@example.invalid`, +})); +vi.mock("@/lib/posthogEvents", () => ({ captureEvent: vi.fn() })); + +afterEach(() => { + auth.session = { user: { id: "u1", name: "Tester" } }; +}); + +function Page({ + dialog, + onClose, +}: { + dialog: (close: () => void) => ReactNode; + onClose?: () => void; +}) { + const [open, setOpen] = useState(false); + const close = () => { + onClose?.(); + setOpen(false); + }; + return ( + <> + <button type="button" onClick={() => setOpen(true)}> + Open + </button> + {open && dialog(close)} + </> + ); +} + +function open() { + const opener = screen.getByText("Open"); + opener.focus(); + fireEvent.click(opener); + return opener; +} + +function pressEsc() { + fireEvent.keyDown(document.activeElement ?? document.body, { key: "Escape" }); +} + +const WINDOWS: [string, (close: () => void) => ReactNode][] = [ + ["sign-in", (close) => <AuthModal onClose={close} />], + [ + "report", + (close) => ( + <ReportModal targetType="pattern" targetId="p1" targetLabel="Wave" onClose={close} /> + ), + ], + [ + "Edit details", + (close) => ( + <EditDetailsModal + patternId="p1" + initialTitle="Wave" + initialDescription={null} + initialLicense="CC-BY-SA-4.0" + initialMadeOn={null} + initialMadeHow={null} + initialVisibility="public" + onClose={close} + /> + ), + ], + ["Add firmware header", (close) => <AddHeaderModal patternId="p1" initialCpp={null} onClose={close} />], + ["Publish a performance", (close) => <AddPerformanceModal patternId="p1" onClose={close} />], +]; + +describe.each(WINDOWS)("the %s window", (_name, dialog) => { + it("takes the focus when it opens", () => { + render(<Page dialog={dialog} />); + const opener = open(); + expect(opener).not.toHaveFocus(); + expect(screen.getByRole("dialog")).toContainElement(document.activeElement as HTMLElement); + }); + + it("closes on Esc and gives the focus back to the button that opened it", () => { + render(<Page dialog={dialog} />); + const opener = open(); + pressEsc(); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(opener).toHaveFocus(); + }); +}); + +describe("community windows", () => { + it("Edit details still starts on the title field", () => { + render(<Page dialog={WINDOWS[2][1]} />); + open(); + expect(screen.getByRole("textbox", { name: "Title" })).toHaveFocus(); + }); + + it("a sign-in form inside the report window lets one Esc close the report", () => { + auth.session = null; + const onClose = vi.fn(); + render(<Page dialog={WINDOWS[1][1]} onClose={onClose} />); + const opener = open(); + expect(screen.getByRole("button", { name: "Create account" })).toBeTruthy(); + fireEvent.keyDown(screen.getByLabelText("Password"), { key: "Escape" }); + expect(onClose).toHaveBeenCalledTimes(1); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(opener).toHaveFocus(); + }); +}); diff --git a/web/src/components/community/fieldNames.test.tsx b/web/src/components/community/fieldNames.test.tsx new file mode 100644 index 000000000..7649a7c6d --- /dev/null +++ b/web/src/components/community/fieldNames.test.tsx @@ -0,0 +1,97 @@ +// Fields that had no name for a screen reader: the device address under a +// finished build, in the deck panel and in the Send window, and the report's +// detail box, which only had its placeholder sentence to go on. The pack +// card's address field was already "Device address"; these follow it. + +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import DeckPanel from "./DeckPanel"; +import ReportModal from "./ReportModal"; +import SendModuleModal from "./SendModuleModal"; + +const SESSION = { user: { id: "u1", name: "Tester" } }; + +vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }) })); +vi.mock("@/lib/community/auth-client", () => ({ + authClient: { useSession: () => ({ data: SESSION, isPending: false }) }, +})); +vi.mock("@/lib/posthogEvents", () => ({ captureEvent: vi.fn() })); + +const DONE = { + id: "b1", + status: "done", + queuePosition: null, + elapsedMs: 500, + bytes: 6144, + error: null, + namespaces: ["wave"], + modulesUrl: "/api/community/builds/b1/modules.zip", +}; + +function answer(body: unknown, status = 200) { + return Promise.resolve( + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }), + ); +} + +beforeEach(() => { + vi.useFakeTimers({ toFake: ["setInterval", "clearInterval", "setTimeout", "clearTimeout", "Date"] }); + vi.stubEnv("NEXT_PUBLIC_BUILD_ENABLED", "1"); + vi.stubGlobal( + "fetch", + vi.fn((_url: string, init?: RequestInit) => + init?.method === "POST" ? answer({ id: "b1" }, 202) : answer(DONE), + ), + ); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + window.localStorage.clear(); +}); + +async function wait(ms: number) { + await act(async () => { + await vi.advanceTimersByTimeAsync(ms); + }); +} + +describe("community fields say what they are", () => { + it("names the device address under a deck build", async () => { + window.localStorage.setItem( + "pf-deck", + JSON.stringify([{ patternId: "p1", title: "Wave", code: "// wave" }]), + ); + render(<DeckPanel />); + await wait(0); + fireEvent.click(screen.getByTitle(/^Your deck \(1\//)); + fireEvent.click(screen.getByRole("button", { name: "Build 1 module" })); + await wait(1000); + + expect(screen.getByRole("textbox", { name: "Device address" })).toHaveValue("patternflow.local"); + }); + + it("names the device address in the Send window", async () => { + render(<SendModuleModal patternTitle="Wave" code="// wave" onClose={() => {}} />); + await wait(1000); + + expect(screen.getByText(/Wave built/)).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: "Device address" })).toHaveValue("patternflow.local"); + }); + + it("names the report's detail box and keeps its hint", () => { + render( + <ReportModal targetType="pattern" targetId="p1" targetLabel="Wave" onClose={() => {}} />, + ); + const detail = screen.getByRole("textbox", { name: "Details" }); + expect(detail).toHaveAttribute( + "placeholder", + "Anything that helps us look at the right thing (optional)", + ); + }); +}); diff --git a/web/src/components/contact/ContactPage.tsx b/web/src/components/contact/ContactPage.tsx index 5bea2fab0..8a58bee4c 100644 --- a/web/src/components/contact/ContactPage.tsx +++ b/web/src/components/contact/ContactPage.tsx @@ -7,7 +7,7 @@ export default function ContactPage() { return ( <main className="business-page"> <header className="business-head"> - <Link className="business-lockup" href="/"> + <Link className="business-lockup" href="/" prefetch={false}> Patternflow </Link> <div className="business-meta">Contact / 2026</div> diff --git a/web/src/components/docs/BreadboardDiagram.tsx b/web/src/components/docs/BreadboardDiagram.tsx index d74927b95..0082bb8d1 100644 --- a/web/src/components/docs/BreadboardDiagram.tsx +++ b/web/src/components/docs/BreadboardDiagram.tsx @@ -1,6 +1,3 @@ -'use client'; - -import React from 'react'; type DiagramMode = 'layout' | 'encoders' | 'encoders_gnd' | 'hubconn' | 'hub_esp' | 'encoders_esp' | 'rail'; diff --git a/web/src/components/guide/AudioShots.tsx b/web/src/components/guide/AudioShots.tsx index 4242d2cd9..f73aa7b45 100644 --- a/web/src/components/guide/AudioShots.tsx +++ b/web/src/components/guide/AudioShots.tsx @@ -641,7 +641,7 @@ export default function AudioShots({ set, lang, step }: { set: AudioShotSet; lan data-on={on ? "1" : "0"} style={{ width: `${(geo.w / unitW) * 100}%`, aspectRatio: `${geo.w} / ${geo.h}` }} > - {/* eslint-disable-next-line @next/next/no-img-element */} + {/* eslint-disable-next-line @next/next/no-img-element -- a real capture or photo, shown as is */} <img src={`${BASE}${s.file}.webp`} width={geo.w} diff --git a/web/src/components/guide/CommunityShots.tsx b/web/src/components/guide/CommunityShots.tsx index 29129b4d9..47b9c9218 100644 --- a/web/src/components/guide/CommunityShots.tsx +++ b/web/src/components/guide/CommunityShots.tsx @@ -32,6 +32,8 @@ type Shot = { ko: string; /** Which page of the site it is. */ where: Record<GuideLang, string>; + /** What the picture shows, for a reader who cannot see it. */ + alt: Record<GuideLang, string>; /** How long this screen stays up before the next one (ms). */ ms?: number; }; @@ -57,6 +59,10 @@ const STEPS: Shot[][] = [ title: "Newest · Oldest · Most liked · Most forked · In decks", en: "The orders the wall comes in. Oldest goes back to the very first patterns.", ko: "벽을 늘어놓는 순서예요. Oldest를 누르면 맨 처음 패턴부터 나와요.", + alt: { + en: "The community's Patterns wall, 226 patterns, with the sort tabs from Newest to In decks marked.", + ko: "커뮤니티의 Patterns 벽, 226 PATTERNS. Newest부터 In decks까지 정렬 탭이 표시돼 있다.", + }, where: WALL, }, { @@ -68,6 +74,10 @@ const STEPS: Shot[][] = [ title: "Search titles or @author", en: "A title, or @ and a name. @engmung is the person who made Patternflow.", ko: "제목으로, 또는 @와 이름으로 찾아요. @engmung은 패턴플로우를 만든 사람이에요.", + alt: { + en: "The wall searched for @engmung: the search box and the line 111 matches for @engmung are marked.", + ko: "@engmung으로 찾은 벽. 검색 칸과 111 MATCHES FOR “@ENGMUNG” 줄이 표시돼 있다.", + }, where: WALL, }, { @@ -76,6 +86,10 @@ const STEPS: Shot[][] = [ title: "scroll on a screen to turn its knobs · hover to play", en: "The screen is split K1 to K4, left to right. The wheel turns the knob under the pointer.", ko: "화면이 왼쪽부터 K1–K4로 나뉘어 있어요. 휠은 포인터 아래 노브를 돌려요.", + alt: { + en: "Two cards on the wall. The pointer is over the right one, which shows K1 to K4 with K3 Shift at 0.74, marked.", + ko: "벽의 카드 두 장. 포인터가 오른쪽 카드 위에 있어 K1부터 K4까지와 K3 Shift 0.74가 뜨고, 그 자리가 표시돼 있다.", + }, where: WALL, ms: 4200, }, @@ -88,6 +102,10 @@ const STEPS: Shot[][] = [ title: "Hardware ready — ships a .h firmware header", en: "The right one can go on a board. The left one is JS only: it plays here, not there.", ko: "오른쪽은 보드에 올라가요. 왼쪽은 JS뿐이라 여기서만 돌아요.", + alt: { + en: "Two cards: the left one has no badge, the right one an orange .h badge in its corner, marked.", + ko: "카드 두 장. 왼쪽엔 배지가 없고, 오른쪽 구석의 주황 .h 배지가 표시돼 있다.", + }, where: WALL, }, { @@ -96,6 +114,10 @@ const STEPS: Shot[][] = [ title: ".h Flashable now", en: "The same wall, only the patterns that ship a .h.", ko: "같은 벽에서 .h가 있는 패턴만 보여줘요.", + alt: { + en: "The sort tabs with the .h Flashable now filter beside them marked, over cards that all carry .h.", + ko: "정렬 탭 옆의 .h Flashable now 필터가 표시돼 있고, 아래 카드에는 모두 .h가 붙어 있다.", + }, where: WALL, }, { @@ -104,6 +126,10 @@ const STEPS: Shot[][] = [ title: "Port this pattern (.h)", en: "On a pattern with no .h yet. Ran it on your own board? Propose the .h: signed in, and credited to you.", ko: "아직 .h가 없는 패턴에 있어요. 내 보드에서 돌려 봤다면 .h를 제안해요. 로그인해야 하고, 내 이름으로 올라가요.", + alt: { + en: "A pattern's page with no header yet, with the Port this pattern (.h) button under Firmware ports marked.", + ko: "아직 헤더가 없는 패턴 페이지. FIRMWARE PORTS의 Port this pattern (.h) 버튼이 표시돼 있다.", + }, where: PATTERN, ms: 4200, }, @@ -116,6 +142,10 @@ const STEPS: Shot[][] = [ title: "↗ Send to my Patternflow", en: "On the page of any pattern with a .h. No account needed.", ko: ".h가 있는 패턴 페이지마다 있어요. 계정이 필요 없어요.", + alt: { + en: "Under a pattern's preview, its knob sliders and buttons, with ↗ Send to my Patternflow marked.", + ko: "패턴 미리보기 아래의 노브 슬라이더와 버튼 줄. ↗ Send to my Patternflow가 표시돼 있다.", + }, where: PATTERN, }, { @@ -124,6 +154,10 @@ const STEPS: Shot[][] = [ title: "Send over Wi-Fi", en: "Opens your board's Patterns page with this pattern linked. That page fetches it and installs it.", ko: "보드의 Patterns 페이지가 열리고, 그 페이지가 이 패턴을 받아서 설치해요.", + alt: { + en: "The Send to my Patternflow dialog, koi pond is ready · 32 KB, with the Send over Wi-Fi button marked.", + ko: "Send to my Patternflow 창. koi pond is ready · 32 KB 아래의 Send over Wi-Fi 버튼이 표시돼 있다.", + }, where: PATTERN, ms: 4200, }, @@ -133,6 +167,10 @@ const STEPS: Shot[][] = [ title: "Download .zip", en: "Board out of reach from here, on a VPN say? Drop the .zip on the board's Patterns page, as it is — don't unzip it.", ko: "여기서 보드에 닿지 않으면(VPN 등) .zip을 받아 보드의 Patterns 페이지에 그대로 올려요. 압축은 풀지 않아요.", + alt: { + en: "The same dialog, with the Download .zip button under Send over Wi-Fi marked.", + ko: "같은 창. Send over Wi-Fi 아래의 Download .zip 버튼이 표시돼 있다.", + }, where: PATTERN, ms: 4200, }, @@ -145,6 +183,10 @@ const STEPS: Shot[][] = [ title: "Add to your deck", en: "The + on a card with a .h. Pressed, it turns into a −.", ko: ".h가 있는 카드의 +예요. 누르면 −로 바뀌어요.", + alt: { + en: "Cards on the wall, with the + beside the heart on the koi pond card marked.", + ko: "벽의 카드들. koi pond 카드의 하트 옆 + 버튼이 표시돼 있다.", + }, where: WALL, }, { @@ -153,6 +195,10 @@ const STEPS: Shot[][] = [ title: "Your deck 4 / 20", en: "Along the bottom of every page with patterns on it. The numbers are the order.", ko: "패턴이 있는 페이지마다 아래에 붙어 있어요. 숫자가 순서예요.", + alt: { + en: "The bar along the bottom of the wall: Your deck 4 / 20 and four numbered slots, marked.", + ko: "벽 아래쪽 바. YOUR DECK 4 / 20과 번호 붙은 칸 네 개가 표시돼 있다.", + }, where: DECK_BAR, }, { @@ -161,6 +207,10 @@ const STEPS: Shot[][] = [ title: "drag to rearrange · ✕ removes", en: "Carry a slot sideways and the others make room. Let go on ✕ and it's out.", ko: "칸을 옆으로 끌면 다른 칸이 비켜 줘요. ✕ 위에서 놓으면 빠져요.", + alt: { + en: "The same bar while a slot is carried: the slots now read 2, 1, 3, 4, and the one on the move is marked.", + ko: "칸을 끄는 중인 같은 바. 칸이 2, 1, 3, 4 차례가 되고, 옮기는 칸이 표시돼 있다.", + }, where: DECK_BAR, }, { @@ -169,6 +219,10 @@ const STEPS: Shot[][] = [ title: "press ✕ again to empty all 4", en: "✕ pressed on its own asks first. A second press empties the deck, and there is no undo.", ko: "✕만 누르면 먼저 물어요. 한 번 더 누르면 덱이 비워지고, 되돌릴 수 없어요.", + alt: { + en: "The same bar after one press on ✕: the ✕ has turned red, with press ✕ again to empty all 4 beside it, marked.", + ko: "✕를 한 번 누른 뒤의 같은 바. ✕가 빨개지고 옆에 press ✕ again to empty all 4가 뜬 자리가 표시돼 있다.", + }, where: DECK_BAR, }, { @@ -177,6 +231,10 @@ const STEPS: Shot[][] = [ title: "Send to my board", en: "Builds the deck as modules for your board. Needs you signed in — and so does Share deck, beside it.", ko: "덱을 보드용 모듈로 빌드해요. 로그인이 필요해요. 옆의 Share deck도 마찬가지예요.", + alt: { + en: "The right end of the bar, with Share deck and the orange Send to my board button marked.", + ko: "바 오른쪽 끝. Share deck 옆의 주황 SEND TO MY BOARD 버튼이 표시돼 있다.", + }, where: DECK_BAR, ms: 4200, }, @@ -189,6 +247,10 @@ const STEPS: Shot[][] = [ title: "Community decks", en: "Everyone's public decks: the first few patterns, in order, then how many more.", ko: "모두의 공개 덱이에요. 앞의 패턴 몇 개가 순서대로, 나머지는 개수로 보여요.", + alt: { + en: "Community decks, 3 public decks, each card showing a deck's first patterns. The second card, just, is marked.", + ko: "COMMUNITY DECKS, 공개 덱 3개. 카드마다 덱의 앞 패턴들이 보이고, 두 번째 카드 just가 표시돼 있다.", + }, where: DECKS, }, { @@ -197,6 +259,10 @@ const STEPS: Shot[][] = [ title: "Install to my board", en: "On a public deck. No sign-in: your board's Patterns page opens with the whole deck queued.", ko: "공개 덱에 있어요. 로그인 없이, 보드의 Patterns 페이지가 덱 전체를 담은 채로 열려요.", + alt: { + en: "The page of the deck just, by @engmung, 15 slots, with the orange Install to my board button marked.", + ko: "@engmung의 덱 just 페이지, 15칸. 주황 Install to my board 버튼이 표시돼 있다.", + }, where: DECK, }, { @@ -205,6 +271,10 @@ const STEPS: Shot[][] = [ title: "A deck's .zip also carries its running order", en: "On the board its patterns come first — right after Origin, in the deck's order.", ko: "보드에선 덱의 패턴이 맨 앞, Origin 바로 다음부터 덱 순서대로 놓여요.", + alt: { + en: "The same page, with the line A deck's .zip also carries its running order marked under Install from a file.", + ko: "같은 페이지. INSTALL FROM A FILE 아래 A deck's .zip also carries its running order 줄이 표시돼 있다.", + }, where: DECK, }, { @@ -213,6 +283,10 @@ const STEPS: Shot[][] = [ title: "Press again — this replaces your deck", en: "Copy into my deck, with a deck already in your bar: it asks before it replaces yours.", ko: "바에 이미 덱이 있으면, Copy into my deck은 내 덱을 바꾸기 전에 한 번 물어요.", + alt: { + en: "The deck's buttons after one press on Copy into my deck, which now reads Press again — this replaces your deck, marked.", + ko: "Copy into my deck을 한 번 누른 뒤의 버튼 줄. 그 버튼이 Press again — this replaces your deck으로 바뀐 채 표시돼 있다.", + }, where: DECK, ms: 4200, }, @@ -225,6 +299,10 @@ const STEPS: Shot[][] = [ title: "Open in Pattern Lab", en: "On every pattern's page, with a .h or without.", ko: "모든 패턴 페이지에 있어요. .h가 있든 없든요.", + alt: { + en: "Under a pattern's preview, its knob sliders and buttons, with Open in Pattern Lab marked.", + ko: "패턴 미리보기 아래의 노브 슬라이더와 버튼 줄. Open in Pattern Lab이 표시돼 있다.", + }, where: PATTERN, }, { @@ -233,6 +311,10 @@ const STEPS: Shot[][] = [ title: "forking from koi pond", en: "The copy arrives marked as a fork. If it's no longer a remix, × takes the mark off.", ko: "복사본에 포크 표시가 붙어서 와요. 더는 리믹스가 아니면 ×로 떼요.", + alt: { + en: "Pattern Lab with koi pond open. Under the name, beside Saved locally, forking from koi pond × is marked.", + ko: "koi pond를 연 패턴 랩. 이름 아래 Saved locally 옆의 forking from koi pond ×가 표시돼 있다.", + }, where: LAB, ms: 4200, }, @@ -242,6 +324,10 @@ const STEPS: Shot[][] = [ title: "Share", en: "Publishes it to the community as your fork. Needs you signed in.", ko: "커뮤니티에 내 포크로 올려요. 로그인이 필요해요.", + alt: { + en: "The same Lab, with the Share button at the top marked beside To hardware.", + ko: "같은 랩. 맨 위 To hardware 옆의 Share 버튼이 표시돼 있다.", + }, where: LAB, }, ], @@ -328,11 +414,11 @@ export default function CommunityShots({ lang, step }: { lang: GuideLang; step: > <div className={styles.frame}> {files.map((file) => ( - // eslint-disable-next-line @next/next/no-img-element + // eslint-disable-next-line @next/next/no-img-element -- a real capture of the community site, shown as is <img key={file} src={`${BASE}${file}.webp`} - alt={file === on.file ? on.title : ""} + alt={file === on.file ? on.alt[lang] : ""} aria-hidden={file === on.file ? undefined : true} loading="lazy" decoding="async" diff --git a/web/src/components/guide/Extras.tsx b/web/src/components/guide/Extras.tsx index e2375ac68..15c2e8e36 100644 --- a/web/src/components/guide/Extras.tsx +++ b/web/src/components/guide/Extras.tsx @@ -30,10 +30,10 @@ import type { BuildCard } from "./build/cards"; const EditorWindow = dynamic(() => import("./EditorWindow")); const AudioShots = dynamic(() => import("./AudioShots")); -// The small moving pieces that sit inside a step's card: the flasher's real -// screens and its real button, BOOT and RST in order, a deck fanning out, the -// board's own page installing a pack, the console itself — working, on a -// simulated board. +// The small moving pieces that sit inside a step's card: BOOT and RST in +// order, the flasher's real button and its real screens, a deck fanning +// out, the board's own page installing a pack, the console itself — +// working, on a simulated board. const EspWebInstallButton = "esp-web-install-button" as unknown as React.FC<{ children: React.ReactNode; @@ -81,9 +81,12 @@ function stageAt(t: number, durations: number[]) { // ── flashing ──────────────────────────────────────────────────────────────── -// The flasher's own screens (FlasherShots: ESP Web Tools' real dialog, -// captured), then the real button: it opens the same dialog, for real. -function FlashBlock({ lang }: { lang: GuideLang }) { +/** + * The flasher's real button: it opens ESP Web Tools' install dialog, for + * real. On two cards — Flash, and Wi-Fi, which presses it a second time to + * reach Patternflow's own menu — so neither has the reader scroll back for it. + */ +function FlashButton({ lang }: { lang: GuideLang }) { const ui = COPY[lang].ui; // The install button is a custom element with a shadow root, and the site's // session recorder keeps a handler on every shadow root made while it runs, @@ -101,9 +104,9 @@ function FlashBlock({ lang }: { lang: GuideLang }) { }; }, []); return ( - <div className={styles.extra}> + <> + {/* Loaded once for the page, however many cards carry the button. */} <Script type="module" src="https://unpkg.com/esp-web-tools@10/dist/web/install-button.js?module" strategy="lazyOnload" /> - {/* The button first — it is the step — then what it opens, screen by screen. */} <EspWebInstallButton ref={button} manifest="/flash/manifest.json"> <button slot="activate" type="button" className={`${styles.action} ${styles.actionFirst}`}> Flash Patternflow @@ -112,11 +115,24 @@ function FlashBlock({ lang }: { lang: GuideLang }) { {ui.flashUnsupported} </span> </EspWebInstallButton> + </> + ); +} + +// The button first — it is the step — then what it opens, screen by screen +// (FlasherShots: the dialog's own screens, captured). The card before it has +// put the board in download mode (BootSeq). +function FlashBlock({ lang }: { lang: GuideLang }) { + return ( + <div className={styles.extra}> + <FlashButton lang={lang} /> <FlasherShots set="install" lang={lang} waitForKit /> </div> ); } +// Download mode by hand, the first thing every first flash does: the +// card's three chips. function BootSeq({ lang }: { lang: GuideLang }) { const ui = COPY[lang].ui; const ref = useRef<HTMLOListElement>(null); @@ -140,9 +156,17 @@ function BootSeq({ lang }: { lang: GuideLang }) { ); } -// Straight after the install, and — the card's callout — after RST. +// The Wi-Fi, the usual way first — RST, then the button again, which now +// opens on Patternflow's own menu (the DevKit on stage presses RST while +// that first screen is up: KitFx) — then the lucky one, straight after the +// install. function WifiShots({ lang }: { lang: GuideLang }) { - return <FlasherShots set={["wifi", "dashboard"]} lang={lang} waitForKit />; + return ( + <div className={styles.extra}> + <FlashButton lang={lang} /> + <FlasherShots set={["dashboard", "wifi"]} lang={lang} waitForKit /> + </div> + ); } // ── patterns ──────────────────────────────────────────────────────────────── diff --git a/web/src/components/guide/FlasherShots.tsx b/web/src/components/guide/FlasherShots.tsx index 9ba51c8a4..0ec691cf8 100644 --- a/web/src/components/guide/FlasherShots.tsx +++ b/web/src/components/guide/FlasherShots.tsx @@ -26,6 +26,8 @@ type Shot = { title: string; en: string; ko: string; + /** What the picture shows, for a reader who cannot see it. */ + alt: Record<GuideLang, string>; /** How long this screen stays up before the next one (ms). */ ms?: number; }; @@ -34,6 +36,35 @@ const BASE = "/guide/flasher/"; const STEP_MS = 2400; const LAST_HOLD_MS = 1200; +// Three screens both ways to the Wi-Fi share: Patternflow sends no network +// list, the panel trying the network, and the end. +const SCANNING: Shot = { + src: "wifi-scanning.webp", + w: 280, + h: 188, + title: "Scanning for networks", + en: "Patternflow doesn't send a network list, so this spins about ten seconds.", + ko: "Patternflow는 주변 네트워크 목록을 보내지 않아서 10초쯤 돌아요.", + alt: { + en: "Configure Wi-Fi, a spinner over Scanning for networks.", + ko: "Configure Wi-Fi 창. 도는 원 아래에 Scanning for networks가 있다.", + }, + ms: 2800, +}; + +const CONNECTING: Shot = { + src: "wifi-connecting.webp", + w: 280, + h: 188, + title: "Trying to connect", + en: "The panel gives the network up to 30 seconds.", + ko: "패널이 최대 30초 동안 접속을 시도해요.", + alt: { + en: "Configure Wi-Fi, a spinner over Trying to connect.", + ko: "Configure Wi-Fi 창. 도는 원 아래에 Trying to connect가 있다.", + }, +}; + const CONNECTED: Shot = { src: "wifi-connected.webp", w: 280, @@ -41,6 +72,10 @@ const CONNECTED: Shot = { title: "Device connected to the network!", en: "Visit Device opens the panel's console at its new address.", ko: "Visit Device를 누르면 패널의 새 주소로 콘솔이 열려요.", + alt: { + en: "A party popper over Device connected to the network!, then Visit Device and Skip.", + ko: "축하 폭죽 그림 아래에 Device connected to the network!가 있고, 그 아래 Visit Device와 Skip이 있다.", + }, }; const SETS: Record<FlasherSet, Shot[]> = { @@ -52,6 +87,10 @@ const SETS: Record<FlasherSet, Shot[]> = { title: "Install Patternflow", en: "After you pick the port, the dialog opens here. Choose Install Patternflow.", ko: "포트를 고르면 이 창이 떠요. Install Patternflow를 누르세요.", + alt: { + en: "The flasher's dialog headed Patternflow, with two choices: Install Patternflow and Logs & Console.", + ko: "Patternflow라고 적힌 플래셔 창. Install Patternflow와 Logs & Console 두 가지가 있다.", + }, }, { src: "install-confirm.webp", @@ -60,6 +99,10 @@ const SETS: Record<FlasherSet, Shot[]> = { title: "Confirm Installation", en: "A new install wipes the board first. Press Install.", ko: "새로 설치할 땐 보드를 먼저 싹 지워요. Install을 누르세요.", + alt: { + en: "Confirm Installation: Do you want to install Patternflow v3.10.4? All data on the device will be erased. Back and Install sit below.", + ko: "Confirm Installation 창. Patternflow v3.10.4를 설치할지 묻고, 기기의 데이터가 모두 지워진다고 한다. 아래에 Back과 Install이 있다.", + }, ms: 2800, }, { @@ -69,6 +112,10 @@ const SETS: Record<FlasherSet, Shot[]> = { title: "Erasing", en: "Clearing the whole flash. It can sit here a while.", ko: "플래시 전체를 지우는 중이에요. 여기서 좀 머물 수 있어요.", + alt: { + en: "Installing, a spinner over Erasing.", + ko: "Installing 창. 도는 원 아래에 Erasing이 있다.", + }, }, { src: "install-writing.webp", @@ -77,6 +124,10 @@ const SETS: Record<FlasherSet, Shot[]> = { title: "Installing", en: "Writing, with a percentage. Keep this tab in front.", ko: "퍼센트가 올라가요. 이 탭을 앞에 띄워 두세요.", + alt: { + en: "Installing at 42%, with This will take 2 minutes and a line asking to keep the page visible.", + ko: "Installing 창, 42%. 2분쯤 걸린다는 줄과 이 페이지를 띄워 두라는 줄이 있다.", + }, ms: 2800, }, { @@ -84,66 +135,88 @@ const SETS: Record<FlasherSet, Shot[]> = { w: 320, h: 248, title: "Wrapping up", - en: "The new firmware boots and answers. Up to 20 seconds.", - ko: "새 펌웨어가 부팅해서 대답하길 기다려요. 20초까지 걸려요.", + en: "It waits up to 20 seconds for the new firmware to answer. From download mode it usually won't yet. Let it wait.", + ko: "새 펌웨어가 대답하길 20초까지 기다려요. 다운로드 모드로 구웠다면 보통 아직 대답이 없어요. 그냥 기다려요.", + alt: { + en: "Installing, a spinner over Wrapping up, with the same two lines under it.", + ko: "Installing 창. 도는 원 아래에 Wrapping up이 있고, 그 아래 같은 두 줄이 있다.", + }, }, { src: "install-complete.webp", w: 280, h: 212, title: "Installation complete!", - en: "Next takes you straight to Wi-Fi.", - ko: "Next를 누르면 바로 와이파이 설정으로 넘어가요.", + en: "The firmware is on. Press Next. If no Wi-Fi follows — usually it doesn't — close the dialog. That's normal: the next card is for exactly this.", + ko: "펌웨어가 들어갔어요. Next를 눌러요. 와이파이가 안 나오면(보통 안 나와요) 창을 닫아요. 정상이에요. 바로 다음 카드가 그 이야기예요.", + alt: { + en: "A party popper over Installation complete!, and a Next button.", + ko: "축하 폭죽 그림 아래에 Installation complete!가 있고, 그 아래 Next 버튼이 있다.", + }, }, ], - wifi: [ + // The usual way: RST, then the flasher again, which now finds Patternflow running. + dashboard: [ { - src: "wifi-scanning.webp", + src: "dashboard.webp", w: 280, - h: 188, - title: "Scanning for networks", - en: "Patternflow doesn't send a network list, so this spins about ten seconds.", - ko: "Patternflow는 주변 네트워크 목록을 보내지 않아서 10초쯤 돌아요.", - ms: 2800, + h: 344, + title: "Connect to Wi-Fi", + en: "After RST, press Flash Patternflow and pick the port again: now it opens on Patternflow's own menu. Choose Connect to Wi-Fi.", + ko: "RST 다음 Flash Patternflow를 다시 눌러 포트를 고르면, 이번엔 패턴플로우의 메뉴가 열려요. Connect to Wi-Fi를 골라요.", + alt: { + en: "The dialog headed Patternflow, now Connected to Patternflow 3.10.4 (ESP32-S3), offering Update Patternflow, Connect to Wi-Fi and Logs & Console.", + ko: "Patternflow 창. 이번엔 Connected to Patternflow 3.10.4 (ESP32-S3)가 뜨고, Update Patternflow, Connect to Wi-Fi, Logs & Console이 있다.", + }, + ms: 3600, }, + SCANNING, { - src: "wifi-form.webp", + src: "dashboard-wifi-form.webp", w: 368, h: 404, title: "Configure Wi-Fi", - en: "Leave Join other…, type the network name exactly (2.4 GHz only), the password, then Connect.", - ko: "Join other…는 그대로 두고, 네트워크 이름을 정확히(2.4 GHz만 돼요) 적고 비밀번호를 넣은 뒤 Connect.", + en: "Leave Join other…, type the network name exactly (capitals count, 2.4 GHz only), the password, then Connect.", + ko: "Join other…는 그대로 두고, 네트워크 이름을 대소문자까지 정확히(2.4 GHz만 돼요) 적고 비밀번호를 넣은 뒤 Connect.", + alt: { + en: "The Configure Wi-Fi form: Network set to Join other…, Network Name Studio_2.4G and a hidden password, then Back and Connect.", + ko: "Configure Wi-Fi 창. Network는 Join other…, Network Name은 Studio_2.4G이고 비밀번호는 가려져 있다. 아래에 Back과 Connect이 있다.", + }, ms: 3600, }, - { - src: "wifi-connecting.webp", - w: 280, - h: 188, - title: "Trying to connect", - en: "The panel gives the network up to 30 seconds.", - ko: "패널이 최대 30초 동안 접속을 시도해요.", - }, + CONNECTING, CONNECTED, ], - dashboard: [ + // The lucky case: the board restarted on its own, and Next went straight on to Wi-Fi. + wifi: [ { - src: "dashboard.webp", + src: "install-complete.webp", w: 280, - h: 344, - title: "Connect to Wi-Fi", - en: "Some boards don't restart into Patternflow on their own after flashing. Press RST, then Flash Patternflow and the port again, and choose Connect to Wi-Fi.", - ko: "굽고 나서 패턴플로우로 스스로 재시작하지 않는 보드가 있어요. RST를 한 번 누르고 Flash Patternflow를 다시 눌러 포트를 고른 뒤 Connect to Wi-Fi를 골라요.", - ms: 3600, + h: 212, + title: "Installation complete!", + en: "Sometimes the board restarts by itself and Next goes straight on to Wi-Fi. Then there's no RST to press: fill it in right there.", + ko: "가끔은 보드가 알아서 다시 켜져서 Next가 바로 와이파이로 넘어가요. 그럴 땐 RST 없이 거기서 바로 채워요.", + alt: { + en: "A party popper over Installation complete!, and a Next button.", + ko: "축하 폭죽 그림 아래에 Installation complete!가 있고, 그 아래 Next 버튼이 있다.", + }, + ms: 3200, }, + SCANNING, { - src: "dashboard-wifi-form.webp", + src: "wifi-form.webp", w: 368, h: 404, title: "Configure Wi-Fi", - en: "The same form. Here the left button reads Back instead of Skip.", - ko: "같은 입력 칸이에요. 여기선 왼쪽 버튼이 Skip 대신 Back이에요.", + en: "The same form, straight after the install: fill it in the same way. Here the left button reads Skip, not Back.", + ko: "설치 바로 다음에 뜨는 같은 입력 칸이에요. 똑같이 채워요. 여기선 왼쪽 버튼이 Back 대신 Skip이에요.", + alt: { + en: "The Configure Wi-Fi form: Network set to Join other…, Network Name Studio_2.4G and a hidden password, then Skip and Connect.", + ko: "Configure Wi-Fi 창. Network는 Join other…, Network Name은 Studio_2.4G이고 비밀번호는 가려져 있다. 아래에 Skip과 Connect이 있다.", + }, ms: 3200, }, + CONNECTING, CONNECTED, ], }; @@ -153,12 +226,13 @@ const UI = { ko: { label: "ESP Web Tools 화면 순서", step: "화면", sets: "플래셔 화면 고르기" }, } as const; -// A viewer can hold more than one sequence (the Wi-Fi card: straight after -// the install, and after RST), picked with a pair of tabs above the frame. +// A viewer can hold more than one sequence (the Wi-Fi card: the usual way, +// after RST, then the lucky one, straight after the install), picked with a +// pair of tabs above the frame. const SET_LABEL: Record<FlasherSet, Record<GuideLang, string>> = { install: { en: "Install", ko: "설치" }, - wifi: { en: "After the install", ko: "설치 다음" }, - dashboard: { en: "No Wi-Fi step?", ko: "와이파이 단계가 없었다면" }, + dashboard: { en: "Usually: after RST", ko: "보통: RST 다음" }, + wifi: { en: "Sometimes: right away", ko: "가끔: 설치 바로 다음" }, }; // Space around the dialog inside the frame, in the dialog's CSS px. @@ -272,7 +346,8 @@ export default function FlasherShots({ // Across every sequence, so switching tabs doesn't resize the card. const unitW = Math.max(...every.map((s) => s.w)) + PAD * 2; const unitH = Math.max(...every.map((s) => s.h)) + PAD * 2; - const onSrc = shots[active].src; + const on = shots[active]; + const onSrc = on.src; return ( <figure @@ -316,13 +391,13 @@ export default function FlasherShots({ } > {every.map((s) => ( - // eslint-disable-next-line @next/next/no-img-element + // eslint-disable-next-line @next/next/no-img-element -- a real capture of the flasher's dialog, shown as is <img key={s.src} src={BASE + s.src} width={s.w} height={s.h} - alt={s.src === onSrc ? s.title : ""} + alt={s.src === onSrc ? on.alt[lang] : ""} aria-hidden={s.src === onSrc ? undefined : true} decoding="async" draggable={false} diff --git a/web/src/components/guide/Guide.module.css b/web/src/components/guide/Guide.module.css index c9601a5e0..c1f7fe593 100644 --- a/web/src/components/guide/Guide.module.css +++ b/web/src/components/guide/Guide.module.css @@ -525,6 +525,7 @@ .brand:hover, .lang:hover { color: var(--g-ink); + text-decoration: none; } .lang { letter-spacing: 0.04em; @@ -575,6 +576,7 @@ } .railItem:hover { color: var(--g-ink); + text-decoration: none; } .railItem[data-active="1"] { color: var(--g-ink); @@ -1283,6 +1285,7 @@ .report:focus-visible { color: var(--g-led); background-size: 100% 1px; + text-decoration: none; } .report span { font-size: 10px; @@ -1333,6 +1336,7 @@ .action:hover, .action:focus-visible { transform: translate3d(0, -1.5px, 0); + text-decoration: none; box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.34), inset 0 -1px 0 rgb(0 0 0 / 0.12), @@ -1684,6 +1688,7 @@ .soonLink:focus-visible { border-color: var(--g-led); background: var(--g-led-wash); + text-decoration: none; } .soonLink:hover .soonArrow, .soonLink:focus-visible .soonArrow { @@ -1724,12 +1729,18 @@ .nextLink:focus-visible { border-color: var(--g-led); background: var(--g-led-wash); + text-decoration: none; } .nextLink:hover span, .nextLink:focus-visible span { color: var(--g-led); transform: translate3d(2px, -2px, 0); } +/* A way on inside the site: its → goes on, not up and out like ↗. */ +.nextLink[data-in]:hover span, +.nextLink[data-in]:focus-visible span { + transform: translate3d(2px, 0, 0); +} .nextLink:active { transform: scale(0.97); transition-duration: 0.08s; @@ -1888,6 +1899,7 @@ .report:hover span, .replay:hover span, .nextLink:hover span, + .nextLink[data-in]:hover span, .openingBack a:hover span { transform: none; } diff --git a/web/src/components/guide/GuideExperience.tsx b/web/src/components/guide/GuideExperience.tsx index 6e1f3b90e..f56be08f5 100644 --- a/web/src/components/guide/GuideExperience.tsx +++ b/web/src/components/guide/GuideExperience.tsx @@ -9,7 +9,7 @@ import { sceneById } from "./scenes"; import { useGuideStore, type GuideLang, type GuidePageId } from "./store"; import Extras, { LiveThumbs } from "./Extras"; import { hereFor, reportUrl } from "./report"; -import { PAGES, type PageChapter } from "./pages"; +import { PAGES, linkWay, type PageChapter } from "./pages"; import { scriptMismatches } from "./checks"; import { useDeskFits } from "./desk/query"; import DeskCue from "./desk/DeskCue"; @@ -345,7 +345,7 @@ function Chapter({ // stands the resident (ui/resident/RailResident): it runs along as the // reader goes on. It is play — hidden from assistive technology, outside the // rail's box — and the rail is what it was without it. -function ChapterRail({ chapters }: { chapters: PageChapter[] }) { +function ChapterRail({ chapters, label }: { chapters: PageChapter[]; label: string }) { const scene = useGuideStore((s) => s.scene); const step = useGuideStore((s) => s.step); const cardIn = useGuideStore((s) => s.cardIn); @@ -391,7 +391,7 @@ function ChapterRail({ chapters }: { chapters: PageChapter[] }) { }, [index]); return ( - <nav ref={nav} className={styles.rail} aria-label="Chapters" data-leaves=""> + <nav ref={nav} className={styles.rail} aria-label={label} data-leaves=""> <span ref={marker} className={styles.railMarker} style={{ "--f": fill } as CSSProperties} aria-hidden="true" /> {chapters.map(({ id, label, copy, first }) => ( <Fragment key={id}> @@ -473,10 +473,10 @@ export default function GuideExperience({ lang, page = "play" }: { lang: GuideLa WIP </span> </div> - <ChapterRail chapters={chapters} /> + <ChapterRail chapters={chapters} label={copy.ui.chapters} /> {/* The same guide in the other language, at the step the reader is on. */} <GuideLink to={page} lang={other} keepPlace className={styles.lang} hrefLang={other}> - {copy.langSwitch.label} + <span lang={other}>{copy.langSwitch.label}</span> </GuideLink> </header> @@ -585,12 +585,29 @@ export default function GuideExperience({ lang, page = "play" }: { lang: GuideLa {next.until} </p> <div className={styles.nextLinks} data-line="" style={{ "--n": next.groups.length + 2 } as CSSProperties}> - {next.links.map((l) => ( - <a key={l.href} href={l.href} className={styles.nextLink}> - {l.label} - <span aria-hidden="true">↗</span> - </a> - ))} + {next.links.map((l) => { + const to = linkWay(l.href); + if (to.way === "guide") + return ( + <GuideLink key={l.href} to={to.page} lang={to.lang} className={styles.nextLink} data-in=""> + {l.label} + <span aria-hidden="true">→</span> + </GuideLink> + ); + if (to.way === "site") + return ( + <Link key={l.href} href={l.href} className={styles.nextLink} data-in=""> + {l.label} + <span aria-hidden="true">→</span> + </Link> + ); + return ( + <a key={l.href} href={l.href} className={styles.nextLink} target="_blank" rel="noopener noreferrer"> + {l.label} + <span aria-hidden="true">↗</span> + </a> + ); + })} </div> <div data-line="" style={{ "--n": next.groups.length + 3 } as CSSProperties}> <Report diff --git a/web/src/components/guide/GuideHub.tsx b/web/src/components/guide/GuideHub.tsx index efeeece62..6adf3049c 100644 --- a/web/src/components/guide/GuideHub.tsx +++ b/web/src/components/guide/GuideHub.tsx @@ -135,7 +135,7 @@ export default function GuideHub({ lang }: { lang: GuideLang }) { </span> </div> <GuideLink to="hub" lang={other} className={styles.lang} hrefLang={other}> - {copy.langSwitch.label} + <span lang={other}>{copy.langSwitch.label}</span> </GuideLink> </header> diff --git a/web/src/components/guide/LabShots.tsx b/web/src/components/guide/LabShots.tsx index 782663aa7..d37c26ab3 100644 --- a/web/src/components/guide/LabShots.tsx +++ b/web/src/components/guide/LabShots.tsx @@ -26,7 +26,6 @@ type ShotId = | "recent" | "copyPrompt" | "paste" - | "gallery" | "bw" | "ramp" | "rampRandom" @@ -58,17 +57,16 @@ const GEO: Record<ShotId, Geo> = { bw: { src: "preview-bw.webp", w: 401, h: 206, ring: null }, color: { src: "preview-color.webp", w: 401, h: 206, ring: null }, convert: { src: "convert.webp", w: 456, h: 256, ring: [2.85, 63.1, 94.3, 16.41] }, - copyPrompt: { src: "code.webp", w: 485, h: 236, ring: [59.65, 15.68, 20.89, 15.25] }, + copyPrompt: { src: "code.webp", w: 539, h: 236, ring: [64.06, 15.68, 18.8, 15.25] }, director: { src: "director.webp", w: 480, h: 292, ring: [25.21, 12.67, 35.35, 12.33] }, exportGo: { src: "export.webp", w: 476, h: 176, ring: [1.05, 76.93, 97.2, 20.45] }, exportSize: { src: "export.webp", w: 476, h: 176, ring: [1.05, 21.02, 65.76, 20.45] }, - gallery: { src: "gallery.webp", w: 485, h: 236, ring: [62.91, 15.68, 25.4, 15.25] }, hwLayers: { src: "hw-layers.webp", w: 456, h: 188, ring: [71.96, 60.77, 22.77, 20.21] }, knobs: { src: "knobs.webp", w: 345, h: 186, ring: [4.06, 76.55, 93.91, 18.28] }, layers: { src: "layers.webp", w: 423, h: 240, ring: [2.84, 0.83, 30.66, 15.0] }, mask: { src: "layers.webp", w: 423, h: 240, ring: [3.31, 87.11, 57.06, 9.97] }, name: { src: "name.webp", w: 400, h: 64, ring: [14.0, 6.25, 72.0, 56.25] }, - paste: { src: "code.webp", w: 485, h: 236, ring: [37.05, 15.68, 11.87, 15.25] }, + paste: { src: "code.webp", w: 539, h: 236, ring: [30.18, 15.68, 10.68, 15.25] }, pasted: { src: "pasted.webp", w: 456, h: 224, ring: [2.85, 62.31, 94.3, 31.96] }, perf: { src: "ports.webp", w: 464, h: 149, ring: [21.79, 76.96, 32.49, 21.93] }, port: { src: "ports.webp", w: 464, h: 149, ring: [24.92, 8.72, 29.55, 21.93] }, @@ -82,146 +80,244 @@ const GEO: Record<ShotId, Geo> = { }; // lab_shots.py:end -/** The UI's own words for the shot (quoted as the Lab shows them), and a line about it. */ -type Words = { title: string; en: string; ko: string }; +/** + * The UI's own words for the shot (quoted as the Lab shows them), a line + * about it, and what the picture shows, for a reader who cannot see it. + */ +type Words = { title: string; en: string; ko: string; alt: Record<GuideLang, string> }; const WORDS: Record<ShotId, Words> = { name: { title: "name this pattern", en: "At the top of the Lab. To hardware writes this name into the .h; the Director and Share start from it.", ko: "랩 맨 위에 있어요. To hardware가 이 이름을 .h에 적고, Director와 Share도 이 이름에서 시작해요.", + alt: { + en: "The top of Pattern Lab: the empty name field reads name this pattern, with Local draft under it.", + ko: "패턴 랩 맨 위. 빈 이름 칸에 name this pattern이 흐리게 적혀 있고, 그 아래에 Local draft가 있다.", + }, }, saved: { title: "Saved locally", en: "Saved as you go — in this browser only, not on a server or in your other browsers.", ko: "하는 대로 저장돼요. 이 브라우저에만요. 서버에도, 다른 브라우저에도 없어요.", + alt: { + en: "The same name field, now reading My first pattern, with Saved locally under it.", + ko: "같은 이름 칸에 My first pattern이 들어 있고, 그 아래는 Saved locally다.", + }, }, recent: { title: "Recent ▾", en: "Earlier work, parked when something replaced it. Share and To hardware sit beside it.", ko: "무언가에 밀려난 이전 작업이 여기 있어요. 옆에 Share와 To hardware가 있어요.", + alt: { + en: "The Lab's top buttons with the Recent menu open under them: Park this and start fresh, then nothing parked yet.", + ko: "랩 위쪽 버튼 줄. Recent 메뉴가 열려 있고, Park this and start fresh 아래에 nothing parked yet이 있다.", + }, }, copyPrompt: { title: "Copy prompt", en: "Copies the AI prompt for this layer, with its code in it.", ko: "이 레이어의 코드가 담긴 AI 프롬프트를 복사해요.", + alt: { + en: "The Code tab's toolbar over the pattern's code, with the Copy prompt button marked.", + ko: "Code 탭의 도구 줄과 그 아래 패턴 코드. Copy prompt 버튼이 표시돼 있다.", + }, }, paste: { title: "Paste", en: "Swaps this layer's code for what you copied. ‹ 1/6 › steps through the Lab's own examples.", ko: "복사한 코드로 이 레이어를 바꿔요. ‹ 1/6 ›은 랩에 든 예제를 넘겨요.", - }, - gallery: { - title: "Generate · Key", - en: "Key takes your Gemini key, kept in this browser and sent only to Google. Generate asks for n variations; click a card to load one.", - ko: "Key에 Gemini 키를 넣어요. 이 브라우저에만 저장되고 구글로만 가요. Generate를 누르면 n개를 받고, 카드를 누르면 불러와요.", + alt: { + en: "The Code tab's toolbar over the pattern's code, with the Paste button marked.", + ko: "Code 탭의 도구 줄과 그 아래 패턴 코드. Paste 버튼이 표시돼 있다.", + }, }, bw: { title: "Preview", en: "A v-field on the default ramp: each pixel's value, 0 to 1, shown black to white.", ko: "기본 램프 위의 v-field예요. 픽셀마다 0에서 1 사이 값이 검정에서 흰색으로 보여요.", + alt: { + en: "The preview: concentric rings shading from black to white.", + ko: "미리보기. 검정에서 흰색으로 번지는 동심원이다.", + }, }, ramp: { title: "Random ramp", en: "Two to twelve stops in new colours, every press.", ko: "누를 때마다 새 색으로 2~12개의 지점이 생겨요.", + alt: { + en: "The Color Ramp panel with a plain black-to-white bar, and the Random ramp button marked.", + ko: "Color Ramp 패널. 바는 검정에서 흰색으로 이어지고, Random ramp 버튼이 표시돼 있다.", + }, }, rampRandom: { title: "Color Ramp", en: "Click the bar to add a stop, drag one to move it, pick its colour below. Undo steps back.", ko: "바를 클릭하면 지점이 생기고, 끌면 옮겨지고, 색은 아래에서 골라요. Undo로 되돌려요.", + alt: { + en: "The same panel after Random ramp: the bar runs from purple through black to blue, and its four stops are marked.", + ko: "Random ramp를 누른 뒤의 같은 패널. 바가 보라에서 검정을 지나 파랑으로 이어지고, 지점 네 개가 표시돼 있다.", + }, }, color: { title: "Preview", - en: "The same values through that ramp. Want the AI to pick colours instead? Set the Gallery's v-field to rgb before copying the prompt.", - ko: "같은 값이 그 램프를 거친 모습이에요. 색을 AI에게 맡기려면, 프롬프트를 복사하기 전에 Gallery의 v-field를 rgb로 바꿔요.", + en: "The same values through that ramp. Want the AI to pick colours instead? Set v-field beside Copy prompt to rgb before copying.", + ko: "같은 값이 그 램프를 거친 모습이에요. 색을 AI에게 맡기려면, 복사하기 전에 Copy prompt 옆의 v-field를 rgb로 바꿔요.", + alt: { + en: "The preview through that ramp: the same rings in purple and blue, with dark bands between them.", + ko: "그 램프를 거친 미리보기. 같은 동심원이 보라와 파랑으로 보이고, 그 사이는 어두운 띠다.", + }, }, knobs: { title: "Knobs", en: "The boxes either side of a slider are its range. Drag a digit sideways, or double-click and type.", ko: "슬라이더 양옆 칸이 범위예요. 숫자를 옆으로 끌거나, 두 번 클릭해 입력해요.", + alt: { + en: "The Knobs panel with Glitch and Speed, each a slider between two boxes. Speed's row, 0.0 to 5.0, is marked.", + ko: "Knobs 패널의 Glitch와 Speed. 슬라이더마다 양옆에 칸이 있고, 0.0에서 5.0까지인 Speed 줄이 표시돼 있다.", + }, }, push: { title: "Push", en: "A short press, as on the board. Holding a knob belongs to the board.", ko: "보드에서 짧게 누르는 것과 같아요. 꾹 누르기는 보드 몫이에요.", + alt: { + en: "The same Knobs panel, with Glitch's Push button marked.", + ko: "같은 Knobs 패널. Glitch의 Push 버튼이 표시돼 있다.", + }, }, convert: { title: "Copy the conversion prompt", en: "The prompt for your AI: the pattern with its name, knobs and ranges, the ramp as a finished colour table, and how Patternflow's C++ is written.", ko: "AI에게 줄 프롬프트예요. 패턴과 그 이름, 노브와 범위, 완성된 색 표로 바뀐 램프, 그리고 패턴플로우 C++를 쓰는 법이 들어 있어요.", + alt: { + en: "The Convert to a firmware header dialog, with the Copy the conversion prompt button marked above the box for the .h.", + ko: "Convert to a firmware header 창. .h를 붙일 칸 위의 Copy the conversion prompt 버튼이 표시돼 있다.", + }, }, pasted: { title: "Looks like a header ✓", en: "Here, the real .h of the pattern the Lab opens with. Copy .h and Download .h keep a copy; Next → goes on.", ko: "여기 붙인 건 랩이 처음 여는 패턴의 실제 .h예요. Copy .h와 Download .h로 사본을 남기고, Next →로 넘어가요.", + alt: { + en: "The same dialog with a header pasted in. Looks like a header ✓ shows under the box, above the Next → button, and both are marked.", + ko: "헤더를 붙여 넣은 같은 창. 칸 아래에 Looks like a header ✓가 뜨고, 그 줄과 아래 Next → 버튼이 표시돼 있다.", + }, }, apply: { title: "↗ Apply to my Patternflow", en: "Builds the module in a second or two, then offers Send over Wi-Fi and Download .zip instead. Needs you signed in to the community.", ko: "1~2초 만에 모듈을 만들고, Send over Wi-Fi와 Download .zip instead를 보여 줘요. 커뮤니티에 로그인해야 해요.", + alt: { + en: "The On to hardware dialog: Header ready (5.8 KB), and the ↗ Apply to my Patternflow button under it marked.", + ko: "On to hardware 창. Header ready (5.8 KB) 아래의 ↗ Apply to my Patternflow 버튼이 표시돼 있다.", + }, }, layers: { title: "+ Code · + Pixel", en: "The top of the list is the top of the stack. Drag to reorder, double-click to rename.", ko: "목록 맨 위가 맨 위 레이어예요. 끌어서 순서를 바꾸고, 두 번 클릭해 이름을 바꿔요.", + alt: { + en: "The Layers panel with Pixel 1 above Code 2 above Code 1, and the + Code and + Pixel buttons marked.", + ko: "Layers 패널. 위에서부터 Pixel 1, Code 2, Code 1이 있고, + Code와 + Pixel 버튼이 표시돼 있다.", + }, }, mask: { title: "masks layer below", en: "Code 2 here is a mask: it draws nothing, and Code 1 shows only where Code 2 is bright. invert flips it.", ko: "여기 Code 2는 마스크예요. 직접 그리지 않고, Code 2가 밝은 곳에서만 Code 1이 보여요. invert는 반대로요.", + alt: { + en: "The same panel with Code 2 selected and tagged MASK↓. Its masks layer below box is ticked and marked.", + ko: "같은 패널에서 Code 2가 골라져 있고 MASK↓ 표시가 붙었다. 체크된 masks layer below 칸이 표시돼 있다.", + }, }, blend: { title: "Opacity · Blend", en: "For a layer that paints: how much of it shows, and how it mixes with what's under it — normal, add, multiply or screen.", ko: "그리는 레이어라면 얼마나 보일지, 아래와 어떻게 섞일지를 정해요. normal, add, multiply, screen이 있어요.", + alt: { + en: "The same panel with Code 1 selected: Opacity at 100% and Blend set to normal, both marked.", + ko: "Code 1을 고른 같은 패널. Opacity는 100%, Blend는 normal이고 둘 다 표시돼 있다.", + }, }, hwLayers: { title: "Copy prompt", en: "A stack gets one prompt per code layer. A layer not done yet keeps a stub, so a half-done port still builds.", ko: "레이어를 쌓으면 코드 레이어마다 프롬프트가 하나씩이에요. 아직 안 한 레이어는 빈 자리로 남아서, 반만 해도 빌드돼요.", + alt: { + en: "The To hardware dialog for My first pattern, 2 layers to translate, with the Copy prompt button of L0 · Code 1 marked.", + ko: "My first pattern의 To hardware 창. 옮길 레이어가 둘이고, L0 · Code 1의 Copy prompt 버튼이 표시돼 있다.", + }, }, previewLayers: { title: "Preview", en: "Code 1 through Code 2's mask.", ko: "Code 2의 마스크를 거친 Code 1이에요.", + alt: { + en: "The preview through the mask: Code 1's rings on the left half, black on the right.", + ko: "마스크를 거친 미리보기. 왼쪽 절반엔 Code 1의 동심원이 있고, 오른쪽은 검정이다.", + }, }, exportSize: { title: "Business card 3.5 × 2 in @ 300 dpi (1050 × 600)", en: "A size preset, like the frame × 4 to × 16, Full HD or 4K. Custom… goes up to 4096 px a side.", ko: "크기 프리셋이에요. 프레임의 4~16배, Full HD, 4K 같은 것들이 있고, Custom…은 한 변 4096px까지 돼요.", + alt: { + en: "The Graphic Export controls, with the size preset Business card 3.5 × 2 in @ 300 dpi (1050 × 600) marked.", + ko: "Graphic Export 설정. 크기 프리셋 Business card 3.5 × 2 in @ 300 dpi (1050 × 600)이 표시돼 있다.", + }, }, exportGo: { title: "Save PNG · ● Record", en: "A still, or a clip of up to a minute at 24, 30 or 60 fps, as MP4 or WebM.", ko: "정지 이미지, 또는 24·30·60fps로 1분까지 녹화한 MP4나 WebM이에요.", + alt: { + en: "The same controls with their bottom row marked: Save PNG, then the Director box, 30 fps, 6 s, MP4 and ● Record.", + ko: "같은 설정의 맨 아래 줄이 표시돼 있다. Save PNG 다음에 Director 칸, 30 fps, 6 s, MP4, ● Record가 있다.", + }, }, director: { title: ".pfs · .mid · Render…", en: "Keyframes on Glitch's lane, a smooth curve between them; ▶ plays it. .pfs is the show for a Performance-edition board, .mid the same moves as MIDI for Ableton, Render… a video.", ko: "Glitch 줄에 찍은 키프레임과 그 사이 곡선이에요. ▶로 재생해요. .pfs는 퍼포먼스 에디션 보드용 쇼, .mid는 에이블톤용 MIDI, Render…는 영상이에요.", + alt: { + en: "The Director: a curve on Glitch's lane across twelve seconds, and the .pfs, .mid and Render… buttons marked.", + ko: "디렉터. Glitch 줄에 12초에 걸친 곡선이 있고, .pfs, .mid, Render… 버튼이 표시돼 있다.", + }, }, upload: { title: "Upload to the community", en: "Under Apply. The pattern and its .h go up together. Opened from someone's pattern, it goes up as their fork: the top of the Lab says forking from …, and its × undoes that.", ko: "Apply 아래에 있어요. 패턴과 .h가 함께 올라가요. 남의 패턴에서 시작했다면 그 패턴의 포크로 올라가요. 랩 맨 위에 forking from …이 뜨고, ×로 뗄 수 있어요.", + alt: { + en: "The bottom of the On to hardware dialog, with the Upload to the community button marked above ← Back to the header.", + ko: "On to hardware 창 아래쪽. ← Back to the header 위의 Upload to the community 버튼이 표시돼 있다.", + }, }, port: { title: "Port this pattern (.h)", en: "On a pattern with no header yet. Propose the .h you made, signed in, and it goes live at once, credited to you.", ko: "아직 헤더가 없는 패턴에 있어요. 만든 .h를 로그인해서 제안하면 바로 올라가고, 내 이름이 붙어요.", + alt: { + en: "A pattern's page on the community site: No firmware header yet, and the Port this pattern (.h) button under Firmware ports marked.", + ko: "커뮤니티의 패턴 페이지. 아직 헤더가 없다는 줄이 있고, FIRMWARE PORTS의 Port this pattern (.h) 버튼이 표시돼 있다.", + }, }, perf: { title: "Publish a performance", en: "Adds a Director show, the .pfs you saved, to anyone's pattern. Signed in, and live at once, credited to you.", ko: "디렉터 쇼, 저장해 둔 .pfs를 누구의 패턴에든 붙여요. 로그인해야 하고, 바로 올라가며 내 이름이 붙어요.", + alt: { + en: "The same page, with the Publish a performance button under Performances marked.", + ko: "같은 페이지. PERFORMANCES의 Publish a performance 버튼이 표시돼 있다.", + }, }, }; /** Which screens each step of the chapter shows (copy/lab.ts steps with extra: "labShots"). */ const STEPS: Record<number, ShotId[]> = { 1: ["name", "saved", "recent"], - 2: ["copyPrompt", "paste", "gallery"], + 2: ["copyPrompt", "paste"], 3: ["bw", "ramp", "rampRandom", "color"], 4: ["knobs", "push"], 5: ["convert"], @@ -341,13 +437,13 @@ export default function LabShots({ lang, step }: { lang: GuideLang; step: number } > {images.map((g) => ( - // eslint-disable-next-line @next/next/no-img-element + // eslint-disable-next-line @next/next/no-img-element -- a real capture of the lab, shown as is <img key={g.src} src={BASE + g.src} width={g.w} height={g.h} - alt={g.src === onSrc ? words.title : ""} + alt={g.src === onSrc ? words.alt[lang] : ""} aria-hidden={g.src === onSrc ? undefined : true} loading="lazy" decoding="async" diff --git a/web/src/components/guide/LabWindow.module.css b/web/src/components/guide/LabWindow.module.css index 9a7ae67b2..32ad45dda 100644 --- a/web/src/components/guide/LabWindow.module.css +++ b/web/src/components/guide/LabWindow.module.css @@ -45,6 +45,9 @@ outline: 2px solid var(--lw-led); outline-offset: 4px; } +.opener:hover { + text-decoration: none; +} @media (hover: hover) and (prefers-reduced-motion: no-preference) { .opener:hover { transform: translateY(-2px); diff --git a/web/src/components/guide/build/Build.module.css b/web/src/components/guide/build/Build.module.css index 86304d3c9..6a639a225 100644 --- a/web/src/components/guide/build/Build.module.css +++ b/web/src/components/guide/build/Build.module.css @@ -49,6 +49,7 @@ .link:focus-visible { border-color: var(--g-led); background: var(--g-led-wash); + text-decoration: none; } /* ── the parts list ── */ @@ -292,6 +293,7 @@ summary.bomHead:hover .bomChev, .where:hover, .where:focus-visible { color: var(--g-led); + text-decoration: none; } /* ── figures ── */ @@ -354,6 +356,7 @@ summary.bomHead:hover .bomChev, .fileName:hover, .fileName:focus-visible { color: var(--g-led); + text-decoration: none; } .fileNote { font-size: 12.5px; diff --git a/web/src/components/guide/copy.ts b/web/src/components/guide/copy.ts index a3fb15c0c..19d7bb4bf 100644 --- a/web/src/components/guide/copy.ts +++ b/web/src/components/guide/copy.ts @@ -133,6 +133,8 @@ export type GuideCopy = { noteBy: string; /** The WIP chip's tooltip, beside the brand. */ wip: string; + /** The chapter rail at the top, for assistive tech. */ + chapters: string; /** The "Stuck here?" links (report.ts). */ report: { step: string; hint: string; general: string; generalTitle: string; generalWhere: string }; /** On a Build card: play the step's motion on the stage again. */ @@ -169,7 +171,7 @@ const en: GuideCopy = { flash: { num: "01", title: "Give it a brain", - lede: "Once, the first time: the ESP32 gets its firmware and your Wi-Fi.", + lede: "Once, the first time: download mode, the firmware, then your Wi-Fi — which usually takes a second go.", steps: [ { kicker: "The ESP32", @@ -196,39 +198,52 @@ const en: GuideCopy = { warn: "Nothing shows up at all? It's almost always a charge-only cable. Try another one first.", }, { - kicker: "Flash", - title: "Flash Patternflow.", + kicker: "Download mode", + title: "Hold BOOT, tap RST, let go.", body: [ - "In desktop Chrome or Edge, press Flash Patternflow below and pick the port. The dialog walks you through the rest; its screens are under the button. The whole thing takes about two minutes.", + "Every first flash starts here, before you press anything on this page. Press and hold BOOT, tap RST, then let go of BOOT. The board now waits for its firmware.", ], - extra: "flashButton", + warn: "In download mode the flasher can't tell it's already Patternflow, so it erases it — installed patterns and saved Wi-Fi too. Fine on a new build. A board already running Patternflow updates over Wi-Fi instead, from its console's Update page.", + extra: "bootSeq", }, { - kicker: "No port in the list?", - title: "Hold BOOT, tap RST, let go.", + kicker: "Flash", + title: "Flash Patternflow.", body: [ - "Put it in download mode by hand, then press Flash Patternflow again. The port appears as USB JTAG/serial debug unit.", + "In desktop Chrome or Edge, press Flash Patternflow below and pick USB JTAG/serial debug unit. The dialog walks you through the rest; its screens are under the button. About two minutes.", + "Not in the list? Hold BOOT, tap RST again, then press the button again.", ], - warn: "In download mode the flasher can't tell it's already Patternflow, so it treats it as new and erases it — installed patterns and saved Wi-Fi too.", - extra: "bootSeq", + extra: "flashButton", }, { kicker: "Wi-Fi", - title: "Type your network.", + title: "Press RST. Then the Wi-Fi.", body: [ - "Press Next, then type your network's name exactly — capitals count — and its password. 2.4 GHz only: the ESP32 can't see 5 GHz.", + "Usually the dialog ends without asking for your Wi-Fi. That's normal, nothing failed: the board just hasn't restarted into Patternflow yet.", + "Close the dialog, press RST once, then press Flash Patternflow below and pick the port again. This time it opens on Patternflow's own menu instead of installing: choose Connect to Wi-Fi, type your network's name exactly — capitals count — and its password, and Connect.", ], + warn: "2.4 GHz only: the ESP32 can't see 5 GHz. Still won't join? Carry on: this chapter's last card does it from a phone.", extra: "wifiShots", }, { kicker: "Power", title: "Back in. Power on.", body: [ - "Seat the ESP32 back in its sockets the way the silkscreen shows, slide the back cover on, and plug Patternflow's power cable into a power bank.", + "Unplug the USB cable. Seat the ESP32 back in its sockets the way the silkscreen shows, slide the back cover on, and plug Patternflow's power cable into a power bank.", "Power always goes in through that cable — never through the ESP32's USB ports.", ], note: "A red, squarish pattern comes on. That's Origin: the first pattern I ever made.", }, + { + kicker: "Still no Wi-Fi?", + title: "The panel is a network itself.", + body: [ + "Hold K2, the top-left knob, for a second. CONNECTED and an address: it's on your Wi-Fi, carry on. HOTSPOT and a name like patternflow-a1b2, over two lines: it found no network it knows, so it became one.", + "Join that network from a phone or laptop — the password is patternflow — and open http://192.168.4.1/. The phone will say there's no internet: true, and it stays connected.", + "On the Wi-Fi page, pick your network from the ones the panel found, add its password, and Save. The panel's screen then shows JOINED and its new address, or why it couldn't.", + ], + warn: "Neither on K2 yet, or not in your phone's list? The hotspot only comes up about 15 seconds after power-on. Wait, refresh the list (or switch Wi-Fi off and on), and look for the exact name K2 shows.", + }, ], }, knobs: { @@ -380,6 +395,7 @@ const en: GuideCopy = { ui: { noteBy: "From the maker", wip: "Work in progress: this guide is still being written and will keep changing", + chapters: "Chapters", report: { step: "Stuck here? Tell us", hint: "Opens a GitHub issue with this step already filled in", @@ -423,7 +439,7 @@ const ko: GuideCopy = { flash: { num: "01", title: "두뇌 깨우기", - lede: "처음 한 번만. ESP32에 펌웨어와 와이파이를 넣어요.", + lede: "처음 한 번만. 다운로드 모드로 펌웨어를 굽고, 와이파이를 넣어요. 와이파이는 보통 두 번째에 돼요.", steps: [ { kicker: "ESP32", @@ -447,39 +463,55 @@ const ko: GuideCopy = { ], warn: "아무것도 안 잡히면 거의 항상 충전 전용 케이블이에요. 케이블부터 바꿔 보세요.", }, + { + kicker: "다운로드 모드", + // "RST 한 번" stays on one line (no-break spaces): the title broke inside it. + title: "BOOT 누른 채로, RST\u00a0한\u00a0번, BOOT 떼기.", + body: [ + "처음 구울 땐 언제나 여기서 시작해요. 이 페이지에서 뭘 누르기 전에요. BOOT를 누른 채로 RST를 한 번 눌렀다 떼고, BOOT를 떼요. 이제 보드가 펌웨어를 기다려요.", + ], + warn: "다운로드 모드에선 플래셔가 이미 패턴플로우인지 몰라서 새 보드로 보고 지워요. 설치한 패턴과 저장된 와이파이까지요. 새로 만든 보드라면 괜찮아요. 이미 패턴플로우가 도는 보드는 대신 콘솔의 Update 페이지에서 와이파이로 업데이트해요.", + extra: "bootSeq", + }, { kicker: "굽기", title: "‘Flash Patternflow’로 구워요.", body: [ - "데스크톱 크롬이나 엣지에서 아래 Flash Patternflow를 누르고 포트를 골라요. 나머지는 창이 차례로 안내해요. 그 화면들은 버튼 아래에 순서대로 있어요. 다 해서 2분쯤 걸려요.", + "데스크톱 크롬이나 엣지에서 아래 Flash Patternflow를 누르고 USB JTAG/serial debug unit을 골라요. 나머지는 창이 차례로 안내해요. 그 화면들은 버튼 아래에 순서대로 있어요. 2분쯤 걸려요.", + "목록에 없으면 BOOT를 누른 채로 RST를 한 번 더 누르고, 버튼을 다시 눌러요.", ], extra: "flashButton", }, - { - kicker: "목록에 포트가 없다면", - // "RST 한 번" stays on one line (no-break spaces): the title broke inside it. - title: "BOOT 누른 채로, RST\u00a0한\u00a0번, BOOT 떼기.", - body: ["직접 다운로드 모드로 넣고 Flash Patternflow를 다시 눌러요. USB JTAG/serial debug unit이라는 이름으로 나타나요."], - warn: "다운로드 모드에선 플래셔가 이미 패턴플로우인지 몰라서 새 보드로 보고 지워요. 설치한 패턴과 저장된 와이파이까지요.", - extra: "bootSeq", - }, { kicker: "와이파이", - title: "네트워크 이름을 입력해요.", + // "RST 한 번" on one line, as above. + title: "RST\u00a0한\u00a0번, 그다음 와이파이.", body: [ - "Next를 누르고, 네트워크 이름을 대소문자까지 정확히 치고 비밀번호를 넣어요. 2.4 GHz만 돼요. ESP32는 5 GHz를 못 봐요.", + "보통은 창이 와이파이를 묻지 않고 끝나요. 정상이에요. 실패한 게 아니라, 보드가 아직 패턴플로우로 다시 켜지지 않았을 뿐이에요.", + "창을 닫고 RST를 한 번 누른 뒤, 아래 Flash Patternflow를 다시 눌러 포트를 다시 골라요. 이번엔 설치 대신 패턴플로우의 메뉴가 열려요. Connect to Wi-Fi를 고르고, 네트워크 이름을 대소문자까지 정확히 치고 비밀번호를 넣은 뒤 Connect.", ], + warn: "2.4 GHz만 돼요. ESP32는 5 GHz를 못 봐요. 그래도 안 붙으면 일단 넘어가요. 이 장의 마지막 카드에서 폰으로 할 수 있어요.", extra: "wifiShots", }, { kicker: "전원", title: "다시 꽂고, 전원.", body: [ - "ESP32를 실크 인쇄 방향대로 소켓에 다시 꽂고, 뒷면 덮개를 밀어 닫고, 패턴플로우 전원 케이블을 보조배터리에 연결해요.", + "USB 케이블을 빼요. ESP32를 실크 인쇄 방향대로 소켓에 다시 꽂고, 뒷면 덮개를 밀어 닫고, 패턴플로우 전원 케이블을 보조배터리에 연결해요.", "전원은 언제나 그 케이블로만 들어가요. ESP32의 USB 포트로 넣지 않아요.", ], note: "빨간 네모네모한 패턴이 켜진다. Origin, 내가 처음 만든 패턴이다.", }, + { + kicker: "아직 와이파이가 없다면", + title: "패널이 직접 네트워크가 돼요.", + body: [ + "K2(왼쪽 위 노브)를 1초 꾹 눌러요. CONNECTED와 주소가 보이면 와이파이에 붙은 거예요. 다음으로 가요. HOTSPOT과 patternflow-a1b2 같은 이름이 두 줄로 보이면, 아는 네트워크를 못 찾아서 패널이 직접 네트워크가 된 거예요.", + "폰이나 노트북으로 그 네트워크에 접속하고(비밀번호는 patternflow) http://192.168.4.1/ 을 열어요. 폰이 인터넷이 없다고 할 거예요. 맞는 말이고, 연결은 그대로 유지돼요.", + "Wi-Fi 페이지에서 패널이 찾은 네트워크 중 내 것을 고르고, 비밀번호를 넣은 뒤 Save. 패널 화면에 JOINED와 새 주소가 뜨거나, 안 된 이유가 떠요.", + ], + warn: "K2에 아직 둘 다 안 보이거나 폰 목록에 없나요? 핫스팟은 켜고 15초쯤 지나야 떠요. 기다렸다가 목록을 새로 고치거나 와이파이를 껐다 켜고, K2에 뜬 이름과 정확히 같은 걸 찾아요.", + }, ], }, knobs: { @@ -623,6 +655,7 @@ const ko: GuideCopy = { ui: { noteBy: "만든 사람의 한마디", wip: "작업 중이에요. 가이드는 아직 쓰는 중이라 계속 바뀌어요", + chapters: "챕터", report: { step: "여기서 막혔나요? 알려 주세요", hint: "이 단계가 채워진 GitHub 이슈가 열려요", diff --git a/web/src/components/guide/copy/audio.ts b/web/src/components/guide/copy/audio.ts index 988667d98..a4fa90fc4 100644 --- a/web/src/components/guide/copy/audio.ts +++ b/web/src/components/guide/copy/audio.ts @@ -15,9 +15,10 @@ import type { GuideLang } from "../store"; // The small print every guide shares is copy.ts's `ui`. The words of the // captures are AudioShots.tsx's, the editor window's are EditorWindow.tsx's. // -// Sources for every fact, and nothing else: AUDIO_GUIDE.md, docs/midi-ableton.md, -// docs/midi-spec.md, docs/audio-ws-spec.md, tools/patternflow-audio-extension -// (its README, popup and editor), the console pages +// Sources for every fact, and nothing else: docs/AUDIO_GUIDE.md, +// docs/midi-ableton.md, docs/midi-spec.md, docs/audio-ws-spec.md, +// tools/patternflow-audio-extension (its README, popup and editor), the +// console pages // (firmware/patternflow/console/_audio_in_bar.html, _audio_in_adapter.js, // midi.html, update.html), web/src/app/editions and the firmware // (patternflow.ino's NETWORK screen, features/midi). UI words are quoted as @@ -318,7 +319,7 @@ const en: AudioCopy = { { label: "The phone app", href: `${GH}tools/patternflow-audio-android/README.md` }, { label: "A Director show as .mid", href: `${GH}docs/director-midi.md` }, { label: "The MIDI map", href: `${GH}docs/midi-spec.md` }, - { label: "AUDIO_GUIDE.md", href: `${GH}AUDIO_GUIDE.md` }, + { label: "AUDIO_GUIDE.md", href: `${GH}docs/AUDIO_GUIDE.md` }, { label: "Editions", href: "/editions" }, { label: "All guides", href: "/guide" }, ], @@ -567,7 +568,7 @@ const ko: AudioCopy = { { label: "폰 앱", href: `${GH}tools/patternflow-audio-android/README.md` }, { label: "디렉터 쇼를 .mid로", href: `${GH}docs/director-midi.md` }, { label: "MIDI 맵", href: `${GH}docs/midi-spec.md` }, - { label: "AUDIO_GUIDE.md", href: `${GH}AUDIO_GUIDE.md` }, + { label: "AUDIO_GUIDE.md", href: `${GH}docs/AUDIO_GUIDE.md` }, { label: "Editions", href: "/editions" }, { label: "가이드 전체", href: "/guide/ko" }, ], diff --git a/web/src/components/guide/copy/build.ts b/web/src/components/guide/copy/build.ts index 2d7641a18..7acfe68f1 100644 --- a/web/src/components/guide/copy/build.ts +++ b/web/src/components/guide/copy/build.ts @@ -22,7 +22,15 @@ import type { CheckCard, LinkCard } from "../build/cards"; // docs/panel-compatibility.md. The board's known issues are BUILD_GUIDE.md // §10: link there, don't restate them. The board has one power input, J4, the // screw terminal: USB-C is never power. Firmware is the Play guide's 01 Flash, -// not repeated here. There are no kits. +// not repeated here: the step that sends a builder there says only the order +// that chapter goes in, so nobody starts at the button. BOOT and RST first, +// every first flash; then Flash Patternflow; the Wi-Fi step that usually +// doesn't appear after it (RST, then Connect to Wi-Fi from the flasher again); +// the panel's own network as the way round, once it is lit. The hand-off card +// stops the builder at that chapter's Power step, because seating the DevKit +// and the first power are this guide's; First light then names the step +// after it, the panel's own network, for whoever skipped the Wi-Fi. There +// are no kits. // // No multimeter, anywhere: the maker cut the short check, the continuity // check and the 5 V measurement as steps nobody needs. After the wiring the @@ -392,7 +400,8 @@ const en: BuildCopy = { kicker: "Flash", title: "Flash it in the Play guide.", body: [ - "The DevKit is flashed on its own, out of the board, from desktop Chrome or Edge. The Play guide's 01 Flash walks through it: the left USB port, BOOT and RST, your Wi-Fi on 2.4 GHz.", + "The DevKit is flashed on its own, out of the board, from desktop Chrome or Edge, through its left USB port. The Play guide's 01 Flash goes in this order: hold BOOT, tap RST, let go, and only then press Flash Patternflow.", + "After the install the Wi-Fi step usually doesn't appear, and that's expected: press RST, press Flash Patternflow again, pick the port and choose Connect to Wi-Fi, on 2.4 GHz. Or leave it for later: a lit panel with no Wi-Fi raises a network of its own.", ], extra: "build:handoff", }, @@ -410,6 +419,7 @@ const en: BuildCopy = { body: [ "Slide the power bank into its compartment and plug in the J4 lead. Within a second or two the panel lights with Origin.", "One pattern is right, not a failed install. The Basics pack goes on in the Play guide's 03.", + "Skipped the Wi-Fi, or it didn't take? Give it 15 seconds, then hold K2, the top-left shaft from the front, for a second: the panel shows HOTSPOT and the name of the network it raised. Play 01's last step, the one after where you stopped, shows how to join it.", ], extra: "build:linksPlay03", }, @@ -562,9 +572,9 @@ const en: BuildCopy = { handoff: { label: "Flash it — Play 01", href: "/guide/play#flash-2", - line: "It opens in a new tab. Stop at its last step, “Back in. Power on.”, and come back to this tab.", + line: "It opens in a new tab. Stop at its Power step, where the DevKit goes back in, and come back to this tab: seating it and first light are the next steps here.", }, - knobMap: { screens: ["Brightness", "Network: Wi-Fi and IP address", "Each knob's number", "Pattern select"] }, + knobMap: { screens: ["Brightness", "Network: Wi-Fi and IP, or HOTSPOT", "Each knob's number", "Pattern select"] }, links: { linksPcb: [ { label: "PCBWay shared project", href: PCBWAY }, @@ -824,7 +834,8 @@ const ko: BuildCopy = { kicker: "굽기", title: "굽기는 연주 가이드에서.", body: [ - "DevKit은 기판에서 뺀 채로, 데스크톱 크롬이나 엣지에서 따로 구워요. 연주 가이드의 01 굽기가 왼쪽 USB 포트, BOOT와 RST, 2.4 GHz 와이파이까지 차례로 보여줘요.", + "DevKit은 기판에서 뺀 채로, 데스크톱 크롬이나 엣지에서 왼쪽 USB 포트로 따로 구워요. 연주 가이드의 01 굽기는 이 순서예요. BOOT를 누른 채로 RST를 한 번 누르고 BOOT를 떼요. 그다음에 Flash Patternflow를 눌러요.", + "설치가 끝나도 와이파이 단계는 대개 안 나와요. 그게 정상이에요. RST를 한 번 누르고, Flash Patternflow를 다시 눌러 포트를 다시 고른 뒤 Connect to Wi-Fi를 골라요. 2.4 GHz만 돼요. 나중으로 미뤄도 괜찮아요. 와이파이가 없는 패널은 불이 켜지고 나면 자기 네트워크를 직접 띄워요.", ], extra: "build:handoff", }, @@ -842,6 +853,7 @@ const ko: BuildCopy = { body: [ "보조배터리를 칸에 밀어 넣고 J4 전원선을 꽂아요. 1~2초 안에 패널이 Origin으로 켜져요.", "패턴이 하나뿐인 게 맞아요. 설치 실패가 아니에요. Basics 팩은 연주 가이드의 03에서 넣어요.", + "와이파이를 건너뛰었거나 연결이 안 됐다면, 15초쯤 기다렸다가 K2(앞에서 보면 왼쪽 위)를 1초쯤 꾹 눌러요. HOTSPOT과 패널이 직접 띄운 네트워크 이름이 보여요. 거기 붙는 법은 연주 01의 마지막 단계, 아까 멈춘 곳 바로 다음에 있어요.", ], extra: "build:linksPlay03", }, @@ -1004,9 +1016,9 @@ const ko: BuildCopy = { handoff: { label: "굽기 — 연주 01", href: "/guide/play/ko#flash-2", - line: "새 탭에서 열려요. 마지막 단계 ‘다시 꽂고, 전원.’에서 멈추고 이 탭으로 돌아와요.", + line: "새 탭에서 열려요. DevKit을 다시 꽂는 전원 단계에서 멈추고 이 탭으로 돌아와요. 꽂기와 첫 불빛은 여기서 이어져요.", }, - knobMap: { screens: ["밝기", "네트워크: 와이파이와 IP 주소", "노브마다 번호", "패턴 고르기"] }, + knobMap: { screens: ["밝기", "네트워크: 와이파이와 IP, 또는 HOTSPOT", "노브마다 번호", "패턴 고르기"] }, links: { linksPcb: [ { label: "PCBWay 공유 프로젝트", href: PCBWAY }, diff --git a/web/src/components/guide/desk/DeskStage.tsx b/web/src/components/guide/desk/DeskStage.tsx index 1cbd26d3f..5170502ac 100644 --- a/web/src/components/guide/desk/DeskStage.tsx +++ b/web/src/components/guide/desk/DeskStage.tsx @@ -275,7 +275,7 @@ function LabFrame({ {otherTab && ( <p className={styles.notice} role="status"> <span>{words.otherTab}</span> - <button type="button" className={styles.noticeClose} onClick={() => setOtherTab(false)} aria-label="OK"> + <button type="button" className={styles.noticeClose} onClick={() => setOtherTab(false)} aria-label={words.otherTabOk}> <svg viewBox="0 0 16 16" aria-hidden="true"> <path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" /> </svg> diff --git a/web/src/components/guide/desk/ai/answers.test.ts b/web/src/components/guide/desk/ai/answers.test.ts index 17bb66926..4cd61d6f0 100644 --- a/web/src/components/guide/desk/ai/answers.test.ts +++ b/web/src/components/guide/desk/ai/answers.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; -import { buildVariantCopyPrompt } from "@/lib/ai/gemini"; +import { buildVariantCopyPrompt } from "@/lib/ai/variantPrompt"; import { buildCppPrompt } from "@/lib/lab/cppPrompt"; import { buildHExport } from "@/lib/lab/hExport"; import { codeLayerFromSource, initialLabPreset } from "@/lib/lab/store/shared"; diff --git a/web/src/components/guide/desk/ai/answers.ts b/web/src/components/guide/desk/ai/answers.ts index 8f9d9c58d..89d0fb7c9 100644 --- a/web/src/components/guide/desk/ai/answers.ts +++ b/web/src/components/guide/desk/ai/answers.ts @@ -8,7 +8,7 @@ import { HEADER_0710, HEADER_0713 } from "./headers"; // their builders always write: // // the variation prompt Copy prompt, app/pattern-lab/panels/CodePanel.tsx -// → buildVariantCopyPrompt, lib/ai/gemini.ts +// → buildVariantCopyPrompt, lib/ai/variantPrompt.ts // the conversion prompt Copy the conversion prompt, app/pattern-lab/HardwareModal.tsx // → buildCppPrompt, lib/lab/cppPrompt.ts // diff --git a/web/src/components/guide/desk/community/parts.tsx b/web/src/components/guide/desk/community/parts.tsx index d9bc60d0b..fbdd2ba49 100644 --- a/web/src/components/guide/desk/community/parts.tsx +++ b/web/src/components/guide/desk/community/parts.tsx @@ -296,7 +296,7 @@ export function PatternCard({ p }: { p: PracticePattern }) { aria-hidden="true" > <div className={cs.knobZoneBarInMeta}> - {setup.labels.map((l, i) => ( + {setup.labels.map((_, i) => ( <div key={i} className={`${cs.knobSegmentInMeta} ${i === k ? cs.activeKnobSegment : ""}`}> <span>K{i + 1}</span> </div> diff --git a/web/src/components/guide/desk/words.ts b/web/src/components/guide/desk/words.ts index e307d29ed..89f540ddf 100644 --- a/web/src/components/guide/desk/words.ts +++ b/web/src/components/guide/desk/words.ts @@ -23,6 +23,8 @@ export type DeskWords = { labBadge: string; /** The Lab in another tab saved the same draft. */ otherTab: string; + /** The button that puts that notice away. */ + otherTabOk: string; /** The desk as a whole, for assistive tech. */ deskLabel: string; }; @@ -38,6 +40,7 @@ export const DESK_WORDS: Record<GuideLang, DeskWords> = { labTitle: "Pattern Lab / Patternflow", labBadge: "Your Lab", otherTab: "Pattern Lab just saved from another tab. Edit in one place: two open Labs overwrite each other's saves.", + otherTabOk: "OK", deskLabel: "Practice desk", }, ko: { @@ -50,6 +53,7 @@ export const DESK_WORDS: Record<GuideLang, DeskWords> = { labTitle: "Pattern Lab / Patternflow", labBadge: "내 랩", otherTab: "다른 탭의 패턴 랩이 방금 저장했어요. 한 곳에서만 고쳐 주세요. 둘 다 열려 있으면 서로의 저장을 덮어써요.", + otherTabOk: "확인", deskLabel: "연습 창", }, }; diff --git a/web/src/components/guide/flash.test.ts b/web/src/components/guide/flash.test.ts new file mode 100644 index 000000000..c4de70483 --- /dev/null +++ b/web/src/components/guide/flash.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { COPY } from "./copy"; +import { SCENES, type Step } from "./scenes"; +import { FIRMWARE } from "@/lib/guide/panelScreens"; + +// Play's 01 Flash, in the order a first flash really goes: download mode +// before the button, every time; the Wi-Fi on the assumption that it does +// not come by itself (RST, then the flasher again); power through the +// Patternflow's own cable; and the panel's own network for when the Wi-Fi +// still won't take. + +const langs = ["en", "ko"] as const; +const flash = SCENES.find((s) => s.id === "flash")!.steps as Step[]; + +describe("Play 01 Flash", () => { + it("puts download mode before the flasher, and the flasher's button on the Wi-Fi card too", () => { + for (const lang of langs) { + expect(COPY[lang].flash.steps.map((s) => s.extra ?? null)).toEqual([null, null, null, "bootSeq", "flashButton", "wifiShots", null, null]); + } + expect(flash.findIndex((s) => s.bootSeq)).toBe(3); + expect(flash.findIndex((s) => s.flashing)).toBe(4); + // BOOT and RST loop on the stage: the step has room to watch them. + expect(flash[3].dwell).toBeGreaterThan(1); + expect(COPY.en.flash.steps[3].title).toBe("Hold BOOT, tap RST, let go."); + }); + + it("still lands the Build guide's way in (#flash-2) on “Hold it like this”", () => { + expect(COPY.en.flash.steps[1].kicker).toBe("Hold it like this"); + expect(COPY.ko.flash.steps[1].kicker).toBe("이렇게 들어요"); + }); + + it("presses RST on the Wi-Fi card, and on no other", () => { + expect(flash.flatMap((s, i) => (s.rstPulse ? [i] : []))).toEqual([5]); + expect(flash[5]).toMatchObject({ wifi: "esp", espTags: ["rst"] }); + }); + + it("powers on at step 6 — the stage's reassembly keys on it — through the power cable only", () => { + expect(flash.flatMap((s, i) => (s.power ? [i] : []))).toEqual([6, 7]); + expect(flash[6]).toMatchObject({ esp: 0 }); + expect(COPY.en.flash.steps[6].title).toBe("Back in. Power on."); + expect(COPY.ko.flash.steps[6].title).toBe("다시 꽂고, 전원."); + expect(COPY.en.flash.steps[6].body.join(" ")).toMatch(/never through the ESP32's USB ports/); + expect(COPY.ko.flash.steps[6].body.join(" ")).toMatch(/USB 포트로 넣지 않아요/); + }); + + it("ends on the panel's own network: powered, K2 held long enough to read the hotspot's name", () => { + const last = flash[flash.length - 1]; + expect(last).toMatchObject({ power: true, hotspot: true, focus: 1, view: "screenKnobs" }); + expect(flash.filter((s) => s.hotspot)).toHaveLength(1); + // A long press opens NETWORK; K2 closes it again before its own idle timer would. + const at = (kind: "press" | "release") => (last.demo ?? []).filter((a) => kind in a).map((a) => a.at); + const [down, exit] = at("press"); + const [up] = at("release"); + expect(up - down).toBeGreaterThanOrEqual(FIRMWARE.longPressMs); + expect(exit).toBeLessThan(down + FIRMWARE.longPressMs + FIRMWARE.networkIdleExitMs); + for (const lang of langs) { + const words = JSON.stringify(COPY[lang].flash.steps.at(-1)); + for (const fact of ["HOTSPOT", "patternflow-a1b2", "http://192.168.4.1/", "15"]) expect(words).toContain(fact); + } + }); +}); diff --git a/web/src/components/guide/meta.ts b/web/src/components/guide/meta.ts index 8e9e4c713..6b32930a1 100644 --- a/web/src/components/guide/meta.ts +++ b/web/src/components/guide/meta.ts @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import { SITE_OPEN_GRAPH } from "@/lib/siteMetadata"; import { PAGES, pagePath } from "./pages"; import { HUB_COPY } from "./copy/hub"; import type { GuideLang, GuideScreen } from "./store"; @@ -6,7 +7,8 @@ import type { GuideLang, GuideScreen } from "./store"; /** * A guide page's metadata (app/guide/**): its title and description in the * page's language, its own address as canonical, and the other language's as - * an alternate — English is the x-default. + * an alternate — English is the x-default. Its link preview carries the + * site's picture (lib/siteMetadata.ts). */ export function guideMetadata(page: GuideScreen, lang: GuideLang): Metadata { const meta = page === "hub" ? HUB_COPY[lang].meta : PAGES[page].text(lang).meta; @@ -17,6 +19,12 @@ export function guideMetadata(page: GuideScreen, lang: GuideLang): Metadata { title: `${meta.title} / Patternflow`, description: meta.description, alternates: { canonical: url, languages: { en, ko, "x-default": en } }, - openGraph: { title: meta.title, description: meta.description, url, ...(lang === "ko" ? { locale: "ko_KR" } : {}) }, + openGraph: { + ...SITE_OPEN_GRAPH, + title: meta.title, + description: meta.description, + url, + ...(lang === "ko" ? { locale: "ko_KR" } : {}), + }, }; } diff --git a/web/src/components/guide/pages.test.ts b/web/src/components/guide/pages.test.ts index 79f1fa561..ba7537347 100644 --- a/web/src/components/guide/pages.test.ts +++ b/web/src/components/guide/pages.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { GUIDE_ORDER, HUB_PATH, PAGES, pagePath } from "./pages"; +import { GUIDE_ORDER, HUB_PATH, PAGES, linkWay, pagePath } from "./pages"; import { scriptMismatches } from "./checks"; import { scenesOf, type Step } from "./scenes"; import { HUB_COPY } from "./copy/hub"; @@ -134,6 +134,27 @@ describe("guide pages", () => { for (const page of pages) for (const lang of ["en", "ko"] as const) expect(PAGES[page].text(lang).opening.back?.to).toBe("hub"); }); + it("sorts each guide's end links into the guide, the site and away", () => { + expect(linkWay("/guide")).toEqual({ way: "guide", page: "hub", lang: "en" }); + expect(linkWay("/guide/ko")).toEqual({ way: "guide", page: "hub", lang: "ko" }); + expect(linkWay("/guide/play/ko")).toEqual({ way: "guide", page: "play", lang: "ko" }); + expect(linkWay("/guide/play#flash")).toEqual({ way: "site" }); + expect(linkWay("/pattern-lab")).toEqual({ way: "site" }); + expect(linkWay("https://community.patternflow.work/community")).toEqual({ way: "away" }); + expect(linkWay("//example.com/")).toEqual({ way: "away" }); + for (const page of pages) { + for (const lang of ["en", "ko"] as const) { + for (const { href } of PAGES[page].text(lang).next.links) { + const to = linkWay(href); + if (to.way === "away") expect(href).toMatch(/^https:\/\//); + // A link back into the guide stays in the reader's language. + if (to.way === "guide") expect(to.lang).toBe(lang); + } + } + } + expect(PAGES.audio.text("en").next.links.map((l) => linkWay(l.href).way)).toEqual(["away", "away", "away", "away", "away", "site", "guide"]); + }); + it("never offers a kit or an assembled board", () => { const words = JSON.stringify([HUB_COPY, ...pages.flatMap((p) => [PAGES[p].text("en"), PAGES[p].text("ko")])]); expect(words).not.toMatch(/\bkits?\b|pre-?assembled|assembled board|키트|완제품/i); diff --git a/web/src/components/guide/pages.ts b/web/src/components/guide/pages.ts index 6a1f32970..b1c782467 100644 --- a/web/src/components/guide/pages.ts +++ b/web/src/components/guide/pages.ts @@ -161,6 +161,21 @@ export function screenAt(pathname: string): { page: GuideScreen; lang: GuideLang return null; } +/** + * Where a link at a guide's end goes. Another page of the guide is followed + * in place (world/GuideLink): the words leave and the stage stays. Another + * page of the site is an ordinary link, →, in the same tab. Anything else is + * away from the site, ↗, in a new tab. The community is on its own host, so + * it is away. + */ +export type LinkWay = { way: "guide"; page: GuideScreen; lang: GuideLang } | { way: "site" } | { way: "away" }; + +export function linkWay(href: string): LinkWay { + if (!href.startsWith("/") || href.startsWith("//")) return { way: "away" }; + const screen = screenAt(href); + return screen ? { way: "guide", ...screen } : { way: "site" }; +} + /** * Where the reader is on a guide, as an anchor: "#flash-3" for a step whose * card is on screen, "#flash" for a chapter's title, "#next" for the end, and diff --git a/web/src/components/guide/scenes.ts b/web/src/components/guide/scenes.ts index 7d1a86a9d..ff66d99eb 100644 --- a/web/src/components/guide/scenes.ts +++ b/web/src/components/guide/scenes.ts @@ -100,9 +100,20 @@ export type Step = { flashing?: boolean; /** Loop the BOOT/RST download-mode sequence on the module (timing.ts). */ bootSeq?: boolean; - /** Point out RST and press it once a loop (timing.ts). */ + /** + * Point out RST and press it once a loop (timing.ts) — only while the + * card's flasher screens are at the one that says to, or none are on + * screen (stage/KitFx.tsx reads which from the store). + */ rstPulse?: boolean; wifi?: "esp" | "device" | null; + /** + * The panel has no network it knows and is its own Wi-Fi network: hold K2 + * shows HOTSPOT and its name instead of CONNECTED and the address + * (deviceSim setHotspot; KitFx says it to the board every frame). A step + * that does not declare it has the board on the network. + */ + hotspot?: boolean; /** Pixels streaming from the deck into the panel. */ stream?: boolean; /** A scripted demo, looped every `period` ms while nobody has the knobs. */ @@ -167,16 +178,41 @@ export const SCENES: SceneDef[] = [ { view: "esp", power: false, pack: "origin", mode: "off", esp: 1, espTags: [] }, // 2 — the LEFT port { view: "espPorts", power: false, pack: "origin", mode: "off", esp: 1, cable: 1, espTags: ["usb", "uart"] }, - // 3 — Flash Patternflow - { view: "esp", power: false, pack: "origin", mode: "off", esp: 1, cable: 1, flashing: true }, - // 4 — nothing in the port list: BOOT + RST, slowly, with room to watch it + // 3 — download mode first, every first flash: BOOT + RST, slowly, + // with room to watch it (and the reader's own finger on them: Device.tsx) { view: "espButtons", power: false, pack: "origin", mode: "off", esp: 1, cable: 1, bootSeq: true, espTags: ["boot", "rst"], dwell: 1.6 }, - // 5 — Wi-Fi; and if that step never came, RST, right here — pressed - // only while the card's "No Wi-Fi step?" screens say to (KitFx reads - // which flasher screen is up from the store) + // 4 — Flash Patternflow: data up the cable + { view: "esp", power: false, pack: "origin", mode: "off", esp: 1, cable: 1, flashing: true }, + // 5 — Wi-Fi, which after a flash from download mode usually takes a + // second go: RST once, right here, then the flasher again. RST is + // pressed while the card's first screens say to (or none are up), and + // never under the Wi-Fi form, where it would cut the setup off; the + // arcs leave the antenna once it is past RST (KitFx reads which + // flasher screen is up from the store) { view: "esp", power: false, pack: "origin", mode: "off", esp: 1, cable: 1, wifi: "esp", rstPulse: true, espTags: ["rst"] }, - // 6 — back in, power on (the cable comes out first: timing.ts) + // 6 — back in, power on (the cable comes out first: timing.ts). Stays + // step 6: the stage's reassembly keys on it (Device.tsx choreoSpeed, + // GuideCanvas `reassembling`) { view: "hero", power: true, pack: "origin", mode: "run", esp: 0 }, + // 7 — still no Wi-Fi: hold K2, and the panel is its own network — + // HOTSPOT and its name (patternflow-a1b2) on the NETWORK screen, held + // long enough to read it + { + ...base, + view: "screenKnobs", + mode: "run", + focus: 1, + hotspot: true, + demo: [ + { at: 0, mode: "run" }, + { at: 300, press: 1 }, + { at: 1400, release: 1 }, + // K2 = EXIT, before the screen's own eight seconds run out. + { at: 7600, press: 1 }, + { at: 7750, release: 1 }, + ], + period: 9000, + }, ], }, { diff --git a/web/src/components/guide/shots.test.tsx b/web/src/components/guide/shots.test.tsx new file mode 100644 index 000000000..f2dc71beb --- /dev/null +++ b/web/src/components/guide/shots.test.tsx @@ -0,0 +1,83 @@ +import { act, render } from "@testing-library/react"; +import type { ReactElement } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import CommunityShots from "./CommunityShots"; +import FlasherShots, { type FlasherSet } from "./FlasherShots"; +import LabShots from "./LabShots"; +import type { GuideLang } from "./store"; + +// The screens in the Make guide's cards and the flasher's card are pictures +// of English UI. Their captions quote it in both languages, and so did the +// pictures' alt: a reader of /guide/make/ko heard "Paste" for the Code tab +// and "Preview" for three different previews. Each shot now says what it +// shows in the reader's language. + +const HANGUL = /\p{Script=Hangul}/u; + +beforeEach(() => { + // Reduced motion: nothing steps on its own, the dots alone move the set. + vi.stubGlobal("matchMedia", (query: string) => ({ + media: query, + matches: query === "(prefers-reduced-motion: reduce)", + addEventListener: () => {}, + removeEventListener: () => {}, + })); + vi.stubGlobal( + "IntersectionObserver", + class { + observe() {} + disconnect() {} + }, + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +/** The alt of the picture on screen, for every screen the viewer holds, in order. */ +function alts(ui: ReactElement): string[] { + const { container, unmount } = render(ui); + const dots = Array.from(container.querySelectorAll<HTMLButtonElement>("button[data-done]")); + const seen: string[] = []; + for (const dot of dots.length ? dots : [null]) { + if (dot) act(() => dot.click()); + const on = container.querySelectorAll<HTMLImageElement>('img[data-on="1"]'); + expect(on).toHaveLength(1); + seen.push(on[0].alt); + // The others are hidden, and say nothing. + for (const img of container.querySelectorAll<HTMLImageElement>('img[data-on="0"]')) { + expect(img.alt).toBe(""); + expect(img.getAttribute("aria-hidden")).toBe("true"); + } + } + unmount(); + return seen; +} + +const VIEWERS: [string, (lang: GuideLang) => ReactElement[]][] = [ + ["LabShots", (lang) => Array.from({ length: 10 }, (_, i) => <LabShots key={i} lang={lang} step={i + 1} />)], + ["CommunityShots", (lang) => Array.from({ length: 6 }, (_, i) => <CommunityShots key={i} lang={lang} step={i} />)], + [ + "FlasherShots", + (lang) => (["install", "dashboard", "wifi"] as FlasherSet[]).map((set) => <FlasherShots key={set} set={set} lang={lang} />), + ], +]; + +describe.each(VIEWERS)("%s", (_, viewers) => { + it("says what each screen shows in the reader's language", () => { + const en = viewers("en").map(alts); + const ko = viewers("ko").map(alts); + expect(ko.map((set) => set.length)).toEqual(en.map((set) => set.length)); + for (const [i, set] of en.entries()) { + for (const [j, alt] of set.entries()) { + expect(alt.length, `${i}/${j}`).toBeGreaterThan(20); + expect(alt, alt).not.toMatch(HANGUL); + expect(ko[i][j], ko[i][j]).toMatch(HANGUL); + } + // Two shots of one image ring different controls, and say so. + expect(new Set(set).size, set.join(" | ")).toBe(set.length); + expect(new Set(ko[i]).size, ko[i].join(" | ")).toBe(ko[i].length); + } + }); +}); diff --git a/web/src/components/guide/stage/Device.tsx b/web/src/components/guide/stage/Device.tsx index 3ef84e75c..c866ecfb0 100644 --- a/web/src/components/guide/stage/Device.tsx +++ b/web/src/components/guide/stage/Device.tsx @@ -27,17 +27,14 @@ import { KNOB_TOP_Z, knobWorldCenter, MODEL_SCALE, - MODEL_URL, } from "./geometry"; import { - CASE_URL, + DEVICE_MODELS, DEVKIT_LIFT_END, DEVKIT_SEAT, - DEVKIT_URL, devkitPose, M_TO_MODEL, PCB_PLACEMENT, - PCB_URL, SLIDER_OFF, SLIDER_SECONDS, sliderPose, @@ -106,6 +103,10 @@ const KNOB_R = 0.813; * too small to find by touch. */ const KNOB_HIT_R = 1.45; +/** A knob's zone on the page (the touch it may hold): drawn this size and scaled to the hit disc on screen, px. */ +const ZONE_PX = 100; +/** …and this much wider all round than the disc, px. */ +const ZONE_MARGIN = 6; /** * The dial round each knob: a band on the case face with a dot riding it at * the knob's angle — something to take hold of and see go round, since the @@ -161,13 +162,13 @@ const caseMaterial = () => plaMaterial(); const POWER_OFF_S = 0.11; export default function Device() { - const [ledGltf, caseGltf, pcbGltf, kitGltf] = useGLTF([MODEL_URL, CASE_URL, PCB_URL, DEVKIT_URL], DRACO_URL); + const [ledGltf, caseGltf, pcbGltf, kitGltf] = useGLTF(DEVICE_MODELS, DRACO_URL); const scene = useMemo(() => { const root = new THREE.Group(); root.name = "patternflow"; - // Of the official case model only the LED panel is used: its case, board - // and DevKit are the guide's own files (parts.ts), which it carries - // simplified for the landing page's preview. + // Of the official case model only the LED panel is used (led-panel.glb, + // cut out of it): its case, board and DevKit are the guide's own files + // (parts.ts), which it carries simplified for the landing page's preview. const ledSrc = ledGltf.scene.getObjectByName("l"); if (ledSrc) { const led = ledSrc.clone(true); @@ -386,6 +387,64 @@ export default function Device() { accum: number; turned: boolean; } | null>(null); + const canvas = useThree((st) => st.gl.domElement); + // Where the stage's DOM goes (the knob tags are there): the element its + // pointer events are read from. + const surface = useThree((st) => (st.events.connected as HTMLElement | null | undefined) ?? st.gl.domElement); + + // A finger on a knob turns it; anywhere else it scrolls the page. Holding a + // scroll back takes a touch listener that can cancel, and the browser waits + // for the page's script before it scrolls wherever such a listener is: not + // under the element under the finger, but anywhere in that element's box. + // On the window, and then on the stage, which fills the screen, that was + // every scroll of the page, over the cards too, and on a phone each one + // waited for the frame the stage was drawing. So the listener is on four + // small discs of the page instead, one over each knob's hit disc, moved + // with it every frame (below). A touch there reaches the stage as before + // (the Canvas reads pointers by their place on the screen, GuideCanvas). + const zones = useMemo( + () => + [0, 1, 2, 3].map(() => { + if (typeof document === "undefined") return null; + const el = document.createElement("div"); + el.dataset.knobZone = ""; + el.setAttribute("aria-hidden", "true"); + Object.assign(el.style, { + position: "absolute", + left: "0", + top: "0", + width: `${ZONE_PX}px`, + height: `${ZONE_PX}px`, + borderRadius: "50%", + transformOrigin: "0 0", + touchAction: "pan-y", + visibility: "hidden", + }); + return el; + }), + [], + ); + useEffect(() => { + // Pointer events come before their touch events, so by touchstart the + // knob has already been taken (onPointerDown). + const holdTouch = (e: TouchEvent) => { + if (drag.current && e.cancelable) e.preventDefault(); + }; + for (const z of zones) { + if (!z) continue; + surface.appendChild(z); + z.addEventListener("touchstart", holdTouch, { passive: false }); + z.addEventListener("touchmove", holdTouch, { passive: false }); + } + return () => { + for (const z of zones) { + if (!z) continue; + z.removeEventListener("touchstart", holdTouch); + z.removeEventListener("touchmove", holdTouch); + z.remove(); + } + }; + }, [zones, surface]); useEffect(() => { const sim = getSim(); @@ -430,23 +489,13 @@ export default function Device() { drag.current = null; document.body.style.cursor = ""; }; - // The page scrolls under a finger (the canvas is touch-action: pan-y); - // a touch that landed on a knob is the knob's. Pointer events come before - // their touch events, so by touchstart the knob has already been taken. - const holdTouch = (e: TouchEvent) => { - if (drag.current && e.cancelable) e.preventDefault(); - }; window.addEventListener("pointermove", onMove); window.addEventListener("pointerup", onUp); window.addEventListener("pointercancel", onCancel); - window.addEventListener("touchstart", holdTouch, { passive: false }); - window.addEventListener("touchmove", holdTouch, { passive: false }); return () => { window.removeEventListener("pointermove", onMove); window.removeEventListener("pointerup", onUp); window.removeEventListener("pointercancel", onCancel); - window.removeEventListener("touchstart", holdTouch); - window.removeEventListener("touchmove", holdTouch); }; }, []); @@ -482,7 +531,8 @@ export default function Device() { // the knob or on its dial. const world = knobMesh.localToWorld(new THREE.Vector3(0, 0, KNOB_H)); world.project(e.camera); - const rect = (e.nativeEvent.target as HTMLElement).getBoundingClientRect(); + // The canvas's box, not the target's: a touch lands on a knob's zone. + const rect = canvas.getBoundingClientRect(); const cx = (world.x * 0.5 + 0.5) * rect.width + rect.left; const cy = (-world.y * 0.5 + 0.5) * rect.height + rect.top; drag.current = { @@ -522,6 +572,9 @@ export default function Device() { const shown = useRef({ turns: [0, 0, 0, 0], press: [0, 0, 0, 0], power: 0, held: false, tick: 0, back: 0, esp: 0 }); const tmp = useMemo(() => ({ pos: new THREE.Vector3(), quat: new THREE.Quaternion() }), []); const labelRefs = useRef<(HTMLDivElement | null)[]>([]); + // Where each knob's zone was last put, px (r 0: hidden). + const zoneAt = useRef([0, 1, 2, 3].map(() => ({ x: 0, y: 0, r: 0 }))); + const zoneTmp = useMemo(() => ({ c: new THREE.Vector3(), a: new THREE.Vector3(), b: new THREE.Vector3() }), []); const readout = useRef({ lastTurns: [0, 0, 0, 0], movedAt: [-1e9, -1e9, -1e9, -1e9], @@ -735,6 +788,44 @@ export default function Device() { } }); + // Each knob's zone over its hit disc as it now stands on screen (the + // Build stage and the hub's exploded view move the knobs; the camera + // moves on). Written only when it has moved half a pixel. + for (let i = 0; i < 4; i++) { + const z = zones[i]; + const k = parts.knobs[i]; + if (!z) continue; + const zs = zoneAt.current[i]; + let x = 0; + let y = 0; + let r = 0; + if (k) { + const c = k.localToWorld(zoneTmp.c.set(0, 0, KNOB_H + 0.02)).project(state.camera); + const a = k.localToWorld(zoneTmp.a.set(KNOB_HIT_R, 0, KNOB_H + 0.02)).project(state.camera); + const b = k.localToWorld(zoneTmp.b.set(0, KNOB_HIT_R, KNOB_H + 0.02)).project(state.camera); + if (c.z > -1 && c.z < 1 && Math.abs(c.x) < 1.2 && Math.abs(c.y) < 1.2) { + x = (c.x * 0.5 + 0.5) * size.width; + y = (-c.y * 0.5 + 0.5) * size.height; + const ra = Math.hypot((a.x - c.x) * 0.5 * size.width, (a.y - c.y) * 0.5 * size.height); + const rb = Math.hypot((b.x - c.x) * 0.5 * size.width, (b.y - c.y) * 0.5 * size.height); + r = Math.max(ra, rb) + ZONE_MARGIN; + } + } + if (r === 0) { + if (zs.r !== 0) { + zs.r = 0; + z.style.visibility = "hidden"; + } + continue; + } + if (Math.abs(x - zs.x) < 0.5 && Math.abs(y - zs.y) < 0.5 && Math.abs(r - zs.r) < 0.5) continue; + if (zs.r === 0) z.style.visibility = ""; + zs.x = x; + zs.y = y; + zs.r = r; + z.style.transform = `translate3d(${(x - r).toFixed(1)}px, ${(y - r).toFixed(1)}px, 0) scale(${((2 * r) / ZONE_PX).toFixed(3)})`; + } + // The back slider and the ESP32 DevKit, in the order hands do it: the // slider slides off its rails, then — while it is laid down behind the // case — the DevKit comes straight off its pins; it is carried round to @@ -875,4 +966,4 @@ function smoothstep(x: number, e0: number, e1: number) { return t * t * (3 - 2 * t); } -useGLTF.preload([MODEL_URL, CASE_URL, PCB_URL, DEVKIT_URL], DRACO_URL); +useGLTF.preload(DEVICE_MODELS, DRACO_URL); diff --git a/web/src/components/guide/stage/GuideCanvas.tsx b/web/src/components/guide/stage/GuideCanvas.tsx index 8cac4da3c..a4b3fb1f2 100644 --- a/web/src/components/guide/stage/GuideCanvas.tsx +++ b/web/src/components/guide/stage/GuideCanvas.tsx @@ -6,7 +6,7 @@ import { Canvas, useFrame, useThree } from "@react-three/fiber"; import { Environment, Lightformer } from "@react-three/drei"; -import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react"; +import { lazy, Suspense, useEffect, useMemo, useRef, useState, type RefObject } from "react"; import * as THREE from "three"; import Device from "./Device"; import Explode from "./Explode"; @@ -460,9 +460,128 @@ function Warmup() { // So on that page they go out as the camera comes round behind the panel's // plane; what they are for is in front and not in those shots. +// The key light's shadow is drawn again only when what it falls from has +// moved. three draws it on every render of the scene: the casters into a +// map 2048 across (1024 on the lower tiers) and then a 16-sample blur over +// the whole map, both ways (VSM), while most of the time the reader is +// reading a card and nothing on the stage moves but the camera, which the +// map does not depend on. Before each frame's first render it takes down +// what the map does depend on (every mesh three would draw into it, VSM +// counting what receives a shadow too: where it is, its geometry, its +// material and the planes that cut it, and the light) and compares; the +// map is drawn when any of it has moved by more than a hair, or the map is +// new (a tier changed its size). + +/** Closer than this to the last drawn, a number has not moved (world units, or a matrix's scale): far under a shadow map's texel. */ +const SHADOW_STILL = 1e-5; + +function useShadowOnChange(light: RefObject<THREE.DirectionalLight | null>, on: boolean) { + const scene = useThree((s) => s.scene); + const gl = useThree((s) => s.gl); + const frame = useRef(0); + useFrame(() => { + frame.current++; + }); + useEffect(() => { + const l = light.current; + if (!l || !on) return; + const shadow = l.shadow; + shadow.autoUpdate = false; + shadow.needsUpdate = true; + let seen = -1; + let last = new Float64Array(0); + let next = new Float64Array(8192); + let n = 0; + const put = (v: number) => { + if (n === next.length) { + const grown = new Float64Array(n * 2); + grown.set(next); + next = grown; + } + next[n++] = v; + }; + const putMatrix = (e: ArrayLike<number>, at = 0) => { + for (let i = 0; i < 16; i++) put(e[at + i]); + }; + // A material is told apart by a number of its own (three's id is not in its types). + const ids = new WeakMap<object, number>(); + let lastId = 0; + const idOf = (x: object) => { + let id = ids.get(x); + if (id === undefined) { + id = ++lastId; + ids.set(x, id); + } + return id; + }; + const putMaterial = (m: THREE.Material) => { + put(idOf(m)); + put(m.version); + put(m.visible ? 1 : 0); + put(m.side); + put(m.alphaTest); + if (m.clipShadows && m.clippingPlanes) { + for (const p of m.clippingPlanes) { + put(p.normal.x); + put(p.normal.y); + put(p.normal.z); + put(p.constant); + } + } + }; + const take = (o: THREE.Object3D) => { + const mesh = o as THREE.Mesh; + if (!(mesh.isMesh || (o as THREE.Line).isLine || (o as THREE.Points).isPoints)) return; + if (!o.castShadow && !o.receiveShadow) return; + put(o.id); + put(o.castShadow ? 1 : 0); + putMatrix(o.matrixWorld.elements); + const g = mesh.geometry; + put(g.id); + const pos = g.attributes.position; + put(pos ? ("isInterleavedBufferAttribute" in pos ? pos.data.version : pos.version) : -1); + put(g.index?.version ?? -1); + put(g.drawRange.start); + put(g.drawRange.count); + const inst = o as THREE.InstancedMesh; + if (inst.isInstancedMesh) { + put(inst.count); + const a = inst.instanceMatrix.array; + for (let i = 0; i < inst.count * 16; i++) put(a[i]); + } + if (Array.isArray(mesh.material)) mesh.material.forEach(putMaterial); + else if (mesh.material) putMaterial(mesh.material); + }; + const before = scene.onBeforeRender; + scene.onBeforeRender = function (...args) { + before.apply(this, args); + // Once a frame: the effects render the scene more than once in it. + if (seen === frame.current) return; + seen = frame.current; + n = 0; + putMatrix(l.matrixWorld.elements); + putMatrix(l.target.matrixWorld.elements); + put(shadow.mapSize.x); + put(gl.localClippingEnabled ? 1 : 0); + scene.traverseVisible(take); + let moved = shadow.map === null || n !== last.length; + for (let i = 0; !moved && i < n; i++) moved = Math.abs(next[i] - last[i]) > SHADOW_STILL; + if (moved) { + shadow.needsUpdate = true; + last = next.slice(0, n); + } + }; + return () => { + scene.onBeforeRender = before; + shadow.autoUpdate = true; + }; + }, [light, on, scene, gl]); +} + /** The key light. Its shadow map follows the quality tier: the old map is let go when the size changes. */ function KeyLight({ shadows, mapSize }: { shadows: boolean; mapSize: number }) { const light = useRef<THREE.DirectionalLight>(null); + useShadowOnChange(light, shadows); const made = useRef(mapSize); useEffect(() => { const s = light.current?.shadow; @@ -537,6 +656,10 @@ export default function GuideCanvas() { return ( <Canvas camera={{ position: [4.2, 1.9, 11.6], fov: 26, near: 0.05, far: 100 }} + // A pointer is read by its place on the screen, where the canvas + // starts (it fills it, fixed): a touch on a knob lands on that knob's + // zone (Device.tsx), and its offset is from the zone's corner. + eventPrefix="client" dpr={[1, quality.dpr]} gl={{ antialias: false, alpha: true, powerPreference: "high-performance" }} flat diff --git a/web/src/components/guide/stage/KitFx.tsx b/web/src/components/guide/stage/KitFx.tsx index c72895b61..4b58f18bd 100644 --- a/web/src/components/guide/stage/KitFx.tsx +++ b/web/src/components/guide/stage/KitFx.tsx @@ -20,10 +20,13 @@ import { NO_POINTER, pillSize, placeTag, screenY, type TagSide } from "./tags"; // What happens on and around the ESP32 DevKit in chapter one: the USB-C // cable going into the left port, the ports and buttons named, BOOT and RST -// pressed in order, data running up the cable while it flashes, and Wi-Fi -// leaving the antenna. Mounted inside the DevKit's own group, so everything -// here is in its canonical frame (parts.ts): metres, +Y antenna, +Z toward -// the reader, USB-C ports at the −Y end. +// pressed in order for download mode, data running up the cable while it +// flashes, RST pressed once more before the Wi-Fi and the Wi-Fi leaving the +// antenna, and — once it is back in the device — its hotspot, when the +// chapter's last card says the panel is a network itself. Mounted inside +// the DevKit's own group, so everything here is in its canonical frame +// (parts.ts): metres, +Y antenna, +Z toward the reader, USB-C ports at the +// −Y end. // // The cable only ever exists while the DevKit is held up in front of the // reader, and the two take turns (timing.ts, kitState): it is brought up @@ -314,6 +317,10 @@ export default function KitFx({ boot, rst }: Props) { powerHeld.current = false; if (s.power) sim.setMode(s.mode && s.mode !== "off" ? s.mode : "run"); } + // The DevKit's radio: on the network, or — the chapter's last card, + // "Still no Wi-Fi?" — its own hotspot, which hold K2 then names + // (scenes.ts Step.hotspot). Said every frame, so no step keeps it. + sim.setHotspot(Boolean(s.hotspot)); // ── the cable ────────────────────────────────────────────────────────── // Up to 1 it comes (rising into place and fading in) or goes (dropping @@ -365,12 +372,16 @@ export default function KitFx({ boot, rst }: Props) { } // ── rings, buttons, tags ─────────────────────────────────────────────── - // The Wi-Fi step's fallback: RST is named and pressed only while the - // card shows the "No Wi-Fi step?" screen that says to press it. Pressed - // during the normal Wi-Fi setup, RST would abort it. + // The Wi-Fi step: RST first, as its card says — named and pressed while + // the card's flasher screens are at the first of the usual way + // ("Usually: after RST", Connect to Wi-Fi), or while none of them is on + // screen and the card's words are what is being read. Never under the + // Wi-Fi form, or the setup straight after an install: pressed there, + // RST would abort it. const fl = useGuideStore.getState().flasher; - const rstNow = Boolean(s.rstPulse) && fl?.set === "dashboard" && fl.index === 0; - const rstSince = rstNow && fl ? now - fl.at : 0; + const rstNow = Boolean(s.rstPulse) && (!fl || (fl.set === "dashboard" && fl.index === 0)); + // From when that screen came up, a beat after it; with no screen up, on the clock. + const rstSince = rstNow ? (fl ? now - fl.at : now) : 0; const tagsOn = new Set(presented ? (s.espTags ?? []).filter((k) => k !== "rst" || !s.rstPulse || rstNow) : []); const fade = (ring: THREE.Mesh, target: number) => { const mat = ring.material as THREE.MeshBasicMaterial; @@ -418,14 +429,16 @@ export default function KitFx({ boot, rst }: Props) { light.intensity += ((presented ? KIT_LIGHT : 0) - light.intensity) * Math.min(1, dt * 3); // The Wi-Fi's arcs are the page's sign, as the device's own are (Fx.tsx): in the page's accent. + // They wait while RST is the thing to do: the Wi-Fi comes after it. const accent = stageAccent(); + const arcs = s.wifi === "esp" && !rstNow; waves.forEach((w, i) => { const phase = (((t * 0.7 + i / 3) % 1) + 1) % 1; w.scale.setScalar(1 + phase * 1.9); const mat = w.material as THREE.MeshBasicMaterial; mat.color.copy(accent); - const target = s.wifi === "esp" ? (1 - phase) * 0.9 * here : 0; - mat.opacity += (target - mat.opacity) * Math.min(1, dt * (s.wifi === "esp" ? 6 : 10)); + const target = arcs ? (1 - phase) * 0.9 * here : 0; + mat.opacity += (target - mat.opacity) * Math.min(1, dt * (arcs ? 6 : 10)); w.visible = mat.opacity > 0.01; }); diff --git a/web/src/components/guide/stage/StageHand.tsx b/web/src/components/guide/stage/StageHand.tsx index a8f0c698b..87317be2d 100644 --- a/web/src/components/guide/stage/StageHand.tsx +++ b/web/src/components/guide/stage/StageHand.tsx @@ -93,7 +93,8 @@ export default function StageHand({ reducedMotion }: { reducedMotion: boolean }) hand.x = (e.clientX / window.innerWidth) * 2 - 1; hand.y = (e.clientY / window.innerHeight) * 2 - 1; hand.here = true; - hand.onStage = e.target === canvas; + // A knob's zone (Device.tsx) is the stage too. + hand.onStage = e.target === canvas || (e.target instanceof HTMLElement && "knobZone" in e.target.dataset); hand.moves++; }; const onDown = (e: PointerEvent) => { diff --git a/web/src/components/guide/stage/build/BuildStage.tsx b/web/src/components/guide/stage/build/BuildStage.tsx index fe26d3469..2efc5e15f 100644 --- a/web/src/components/guide/stage/build/BuildStage.tsx +++ b/web/src/components/guide/stage/build/BuildStage.tsx @@ -118,8 +118,8 @@ import { getSim, useGuideStore } from "../../store"; import { buildClock } from "../../timing"; import { stepOf } from "../../scenes"; import { type ExplodePart, explodeState, partOffset } from "../explodeParts"; -import { DRACO_URL, KNOB_PRESS, MODEL_OFFSET, MODEL_SCALE, MODEL_URL } from "../geometry"; -import { CASE_URL, DEVKIT_PRESENT, DEVKIT_SEAT, PCB_PLACEMENT, PCB_URL } from "../parts"; +import { DRACO_URL, KNOB_PRESS, MODEL_OFFSET, MODEL_SCALE } from "../geometry"; +import { DEVICE_MODELS, DEVKIT_PRESENT, DEVKIT_SEAT, PCB_PLACEMENT } from "../parts"; import { placeTag, type TagSide } from "../tags"; import { handHold } from "../hand"; import { stageFocus } from "../look/focus"; @@ -602,7 +602,10 @@ function keepKit<T extends KitParts>(key: unknown[], make: () => T): T { } export default function BuildStage() { - const [splitGltf, platesGltf, caseGltf, ledGltf, pcbGltf] = useGLTF([SPLIT_URL, PLATES_URL, CASE_URL, MODEL_URL, PCB_URL], DRACO_URL); + // The device's files are Device's own load (parts.ts DEVICE_MODELS); the + // two of the build's are the list preloaded below. + const [ledGltf, caseGltf, pcbGltf] = useGLTF(DEVICE_MODELS, DRACO_URL); + const [splitGltf, platesGltf] = useGLTF([SPLIT_URL, PLATES_URL], DRACO_URL); const { scene: world, gl, camera, size } = useThree(); // ── everything the build adds ───────────────────────────────────────────── diff --git a/web/src/components/guide/stage/build/layout.ts b/web/src/components/guide/stage/build/layout.ts index 6779dc52b..8ec798624 100644 --- a/web/src/components/guide/stage/build/layout.ts +++ b/web/src/components/guide/stage/build/layout.ts @@ -23,8 +23,7 @@ import { PCB_PLACEMENT } from "../parts"; // (the scratchpad's build3d/): export_build.py (Blender: case-split.glb and // plates.glb), kicad_pads.py (pads.ts), register_plates.py (PLATE_POSES). -export const SPLIT_URL = "/guide/build/case-split.glb"; -export const PLATES_URL = "/guide/build/plates.glb"; +export { PLATES_URL, SPLIT_URL } from "../files"; const v = (x: number, y: number, z: number) => new THREE.Vector3(x, y, z); const q = (x: number, y: number, z: number, order: THREE.EulerOrder = "XYZ") => new THREE.Quaternion().setFromEuler(new THREE.Euler(x, y, z, order)); @@ -67,9 +66,9 @@ export function boardToModel(local: THREE.Vector3, pose: Pose, out = new THREE.V } // The parts as they arrive on the mat: a row behind the board in the order -// they are soldered (BUILD_GUIDE_v2 §4's small-to-tall order mapped onto v3's -// refs), each upright as it will sit on the board; the encoders shaft-up with -// their washer and nut in front of them. +// they are soldered (docs/legacy/BUILD_GUIDE_v2.md §4's small-to-tall order +// mapped onto v3's refs), each upright as it will sit on the board; the +// encoders shaft-up with their washer and nut in front of them. export const ROW_Z = 21.6; export const ROW: Record<string, number> = { "U1_socket_pins1-22": -6.8, diff --git a/web/src/components/guide/stage/files.ts b/web/src/components/guide/stage/files.ts new file mode 100644 index 000000000..e089b1488 --- /dev/null +++ b/web/src/components/guide/stage/files.ts @@ -0,0 +1,34 @@ +// The stage's files by address, and nothing else. Nothing is imported here, +// three.js least of all, so the page can start fetching them before the +// stage's code has arrived (world/GuideWorld). What each file is and where +// it comes from: parts.ts (the device's four), geometry.ts (the LED panel), +// build/layout.ts (the Build stage's two). + +/** + * The official case model's LED panel, "l", the one part of that file the + * guide uses, cut out of it by scripts/cut-led-panel.ts. The landing page's + * preview loads the whole file (public/cases/official/model.glb): 317 KB, + * where the panel is 11. + */ +export const LED_URL = "/guide/led-panel.glb"; +export const CASE_URL = "/guide/case-v39.glb"; +export const PCB_URL = "/guide/pcb-v39.glb"; +export const DEVKIT_URL = "/guide/devkit.glb"; + +/** + * The four, as Device loads them. useGLTF keeps what it loads under the whole + * list it was given, so anything else on the stage that needs these files + * asks for this list, in this order, and is handed Device's. The Build stage + * asked for three of them in a list of its own, and they were fetched, parsed + * and decoded a second time as the Build guide opened. + */ +export const DEVICE_MODELS = [LED_URL, CASE_URL, PCB_URL, DEVKIT_URL]; + +export const SPLIT_URL = "/guide/build/case-split.glb"; +export const PLATES_URL = "/guide/build/plates.glb"; +/** The Build stage's own two, as it loads them (build/BuildStage.tsx). */ +export const BUILD_MODELS = [SPLIT_URL, PLATES_URL]; + +export const DRACO_URL = "https://www.gstatic.com/draco/versioned/decoders/1.5.7/"; +/** What the models' decoder fetches from there in a browser with WebAssembly (three's DRACOLoader). */ +export const DRACO_FILES = [`${DRACO_URL}draco_wasm_wrapper.js`, `${DRACO_URL}draco_decoder.wasm`]; diff --git a/web/src/components/guide/stage/geometry.ts b/web/src/components/guide/stage/geometry.ts index 63b8c6b58..d3a34ea02 100644 --- a/web/src/components/guide/stage/geometry.ts +++ b/web/src/components/guide/stage/geometry.ts @@ -11,9 +11,7 @@ import * as THREE from "three"; // drawn for. The stage scales the model by MODEL_SCALE and lifts it by // MODEL_OFFSET so its middle sits at the origin. -/** The official case model: the guide takes its LED panel, "l", and nothing else. The landing page's preview loads the same file. */ -export const MODEL_URL = "/cases/official/model.glb"; -export const DRACO_URL = "https://www.gstatic.com/draco/versioned/decoders/1.5.7/"; +export { DRACO_URL, LED_URL } from "./files"; export const MODEL_SCALE = 0.1; export const MODEL_OFFSET = new THREE.Vector3(0, -1.65, 0); diff --git a/web/src/components/guide/stage/ledPanel.test.ts b/web/src/components/guide/stage/ledPanel.test.ts new file mode 100644 index 000000000..b3cbf9efa --- /dev/null +++ b/web/src/components/guide/stage/ledPanel.test.ts @@ -0,0 +1,13 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { TARGET, render } from "../../../../scripts/cut-led-panel"; + +// The stage's LED panel is a copy cut out of the official case model +// (scripts/cut-led-panel.ts). When tools/case-models writes that model anew, +// the copy is cut again, or this fails. + +describe("the LED panel the stage loads", () => { + it("is the official case model's panel, as the cut writes it", () => { + expect(Buffer.from(render()).equals(readFileSync(TARGET))).toBe(true); + }); +}); diff --git a/web/src/components/guide/stage/parts.ts b/web/src/components/guide/stage/parts.ts index d511ed25e..9c5971c0a 100644 --- a/web/src/components/guide/stage/parts.ts +++ b/web/src/components/guide/stage/parts.ts @@ -8,18 +8,18 @@ import * as THREE from "three"; // 0904_v3.9, its assembled copy) by the pure translation // (−300.2723, +58.4313, −1.9078) that lands the blend's LED // panel exactly on the LED mesh below -// - model.glb public/cases/official/, the official case model the -// landing page's preview shows (tools/case-models), of -// which only the LED mesh, "l", is used +// - led-panel.glb the LED mesh, "l", of public/cases/official/model.glb, +// the official case model the landing page's preview +// shows (tools/case-models), cut out of it by +// web/scripts/cut-led-panel.ts // - pcb-v39.glb the v3.9 board, exported from hardware/pcb/kicad with // kicad-cli, placed so its four encoders sit under the knobs // - devkit.glb the ESP32-S3 DevKit, in a canonical frame: origin at the // board's centre, +Y toward the antenna, +Z out of the // component side, +X to the reader's right, metres. -export const CASE_URL = "/guide/case-v39.glb"; -export const PCB_URL = "/guide/pcb-v39.glb"; -export const DEVKIT_URL = "/guide/devkit.glb"; +// Their addresses are in files.ts. +export { CASE_URL, DEVICE_MODELS, DEVKIT_URL, PCB_URL } from "./files"; /** Metres → model units. */ export const M_TO_MODEL = 100; diff --git a/web/src/components/guide/store.ts b/web/src/components/guide/store.ts index f068ba925..4c245acdd 100644 --- a/web/src/components/guide/store.ts +++ b/web/src/components/guide/store.ts @@ -92,9 +92,10 @@ type GuideState = { narrow: boolean; /** * The flasher screens a card is playing right now (FlasherShots), so the - * DevKit on stage can act out the one on screen: RST is pressed only while - * the "No Wi-Fi step?" sequence says to press it. `at` is when this screen - * came up (performance.now()). Null when no flasher viewer is running. + * DevKit on stage can act out the one on screen: on the Wi-Fi card, RST + * is pressed while the first screen of the usual way ("Usually: after + * RST") is up, and never under the Wi-Fi form. `at` is when this screen + * came up (performance.now()). Null when no flasher viewer is on screen. */ flasher: { set: string; index: number; at: number } | null; /** The DevKit is held up in front of the reader (KitFx), so a card about it can start. */ diff --git a/web/src/components/guide/timing.ts b/web/src/components/guide/timing.ts index c5c42d7a1..86581b3b4 100644 --- a/web/src/components/guide/timing.ts +++ b/web/src/components/guide/timing.ts @@ -38,7 +38,11 @@ export function bootPhase(now: number = performance.now()): BootPhase { return { k, bootDown, rstDown, chips: [bootDown, rstDown, released] }; } -/** RST alone, once per loop: where it is, for the "no Wi-Fi step?" fallback. */ +/** + * RST alone, once per loop: where it is, for the Wi-Fi step — after a flash + * from download mode the board starts Patternflow only once RST is pressed, + * and the flasher's Wi-Fi comes after that. + */ export const RST_PULSE = { period: 4400, down: 1600, up: 2000 } as const; export function rstPulseDown(now: number = performance.now()): boolean { diff --git a/web/src/components/guide/world/GuideWorld.tsx b/web/src/components/guide/world/GuideWorld.tsx index 32517726c..a3a690b70 100644 --- a/web/src/components/guide/world/GuideWorld.tsx +++ b/web/src/components/guide/world/GuideWorld.tsx @@ -3,11 +3,14 @@ import dynamic from "next/dynamic"; import { usePathname } from "next/navigation"; import { Component, useEffect, useState, type CSSProperties, type ReactNode } from "react"; +import { preload } from "react-dom"; +import { hasWebGL } from "@/lib/webgl"; import styles from "../Guide.module.css"; import { useDarkDocument } from "../darkDocument"; import { screenAt } from "../pages"; import { useGuideStore, type GuideScreen } from "../store"; import { stageLoad } from "../ui/stageLoad"; +import { BUILD_MODELS, DEVICE_MODELS, DRACO_FILES } from "../stage/files"; // The guide's world: one stage for /guide, /guide/build, /guide/play and // /guide/make, in both languages. app/guide/layout.tsx mounts this once and @@ -36,22 +39,9 @@ const WorldStage = dynamic(() => import("./WorldStage"), { ssr: false }); // A browser that cannot give the stage a WebGL 2 context (turned off, an old // machine, a blocked GPU) gets the guide without it: the words, the cards and -// the pictures are all there. Asked once, before the stage's chunk is; the -// stage used to be loaded regardless, throw as it made its renderer, and +// the pictures are all there. Asked (lib/webgl) before the stage's chunk is; +// the stage used to be loaded regardless, throw as it made its renderer, and // leave the page waiting on a canvas that would never draw. -let webgl: boolean | null = null; -function hasWebGL(): boolean { - if (webgl !== null) return webgl; - try { - // (A 1×1 canvas nobody holds: its context goes with it.) - const probe = document.createElement("canvas"); - probe.width = probe.height = 1; - webgl = !!probe.getContext("webgl2"); - } catch { - webgl = false; - } - return webgl; -} /** * The stage is the guide's picture, not the guide. If it throws as it is @@ -62,14 +52,14 @@ function hasWebGL(): boolean { * told, as where there is no WebGL at all. */ class StageGuard extends Component<{ children: ReactNode }, { failed: boolean }> { - state = { failed: false }; + override state = { failed: false }; static getDerivedStateFromError() { return { failed: true }; } - componentDidCatch() { + override componentDidCatch() { stageLoad.set({ failed: true }); } - render() { + override render() { return this.state.failed ? null : this.props.children; } } @@ -98,6 +88,19 @@ function World({ screen, children }: { screen: GuideScreen; children: ReactNode // The stage loads the first time a page with the device is on screen, and stays. const [staged, setStaged] = useState(false); if (!staged && entered && page !== "make" && hasWebGL()) setStaged(true); + // The stage's models, and the decoder they are packed for, start down with + // its code, not after it. They were asked for only once three.js had + // arrived and run (the decoder once the first model was in), and on a slow + // line that is seconds. The stage fetches them the same way later (same + // address, CORS, no credentials of another site's) and the browser hands + // it these. + useEffect(() => { + if (!staged) return; + for (const url of [...DEVICE_MODELS, ...DRACO_FILES]) preload(url, { as: "fetch", crossOrigin: "anonymous" }); + }, [staged]); + useEffect(() => { + if (staged && page === "build") for (const url of BUILD_MODELS) preload(url, { as: "fetch", crossOrigin: "anonymous" }); + }, [staged, page]); // No stage is coming: whoever waits for it (the first visit's counter) is told. useEffect(() => { if (entered && page !== "make" && !hasWebGL()) stageLoad.set({ failed: true }); diff --git a/web/src/components/guide/world/hubHand.test.ts b/web/src/components/guide/world/hubHand.test.ts index 84662341f..1a5d3760c 100644 --- a/web/src/components/guide/world/hubHand.test.ts +++ b/web/src/components/guide/world/hubHand.test.ts @@ -3,7 +3,7 @@ import { DeviceSim } from "@/lib/guide/deviceSim"; import { knobIsTurned, turnBackNow, turnOn } from "../hubKnob"; import { useGuideStore } from "../store"; import { handBackNow, handIsOn, handSteer } from "./hubHand"; -import { makeNext, makeRestore } from "./hubMake"; +import { RESTORE_MS, makeClock, makeNext, makeRestore } from "./hubMake"; // The hub's panel answers the hand: a mouse over it turns K1 and K2 a few // clicks. The board is the one every guide shares, so whatever comes next — @@ -14,17 +14,26 @@ import { makeNext, makeRestore } from "./hubMake"; const FRAME = 1 / 60; const MS = 1000 * FRAME; +/** + * The page's clock, which Make's redraw runs on (hubMake.ts makeClock), moved + * by the frames here. On the wall clock a test that followed a redraw found + * it still running on a fast machine, and the hand never turned at all. + */ +let now = 0; + /** The pointer held at one place on the panel for `frames` frames (null: off the panel). */ function hold(sim: DeviceSim, at: { u: number; v: number } | null, frames: number) { for (let i = 0; i < frames; i++) { handSteer(sim, at, MS); sim.tick(FRAME); + now += MS; } } describe("the hub's panel under the hand", () => { let sim: DeviceSim; beforeEach(() => { + makeClock.now = () => now; useGuideStore.setState({ page: "hub", preview: null, leaving: null, handsOn: false }); sim = new DeviceSim(); sim.tick(FRAME); @@ -35,6 +44,9 @@ describe("the hub's panel under the hand", () => { handBackNow(sim); makeRestore(sim); turnBackNow(sim); + // Origin is drawn back before the next test's board is looked at. + now += RESTORE_MS; + makeClock.now = () => performance.now(); }); it("does nothing with the pointer off the panel", () => { @@ -108,6 +120,7 @@ describe("the hub's panel under the hand", () => { it("gives the board back before the reader's own turn, and keeps out of it afterwards", () => { hold(sim, { u: 1, v: 0 }, 90); + expect(sim.snapshot().turns.slice(0, 2)).toEqual([9, 0]); // The reader takes K3: Device presses it and says so (store.handsOn); HubAnswers calls handBackNow. sim.press(2); useGuideStore.setState({ handsOn: true }); diff --git a/web/src/components/journal/JournalLightbox.tsx b/web/src/components/journal/JournalLightbox.tsx index b99ca2d51..56e79fe6e 100644 --- a/web/src/components/journal/JournalLightbox.tsx +++ b/web/src/components/journal/JournalLightbox.tsx @@ -83,6 +83,7 @@ export default function JournalLightbox() { close </button> <figure onClick={(event) => event.stopPropagation()}> + {/* eslint-disable-next-line @next/next/no-img-element -- whichever journal image was clicked, at the src it already has */} <img src={image.src} alt={image.alt} /> {image.caption && <figcaption>{image.caption}</figcaption>} </figure> diff --git a/web/src/components/sections/BuildPanel.module.css b/web/src/components/sections/BuildPanel.module.css index baacdd40b..a6992ac5b 100644 --- a/web/src/components/sections/BuildPanel.module.css +++ b/web/src/components/sections/BuildPanel.module.css @@ -121,6 +121,7 @@ outline: 0; background: var(--pf-ink-muted); border-color: var(--pf-ink-muted); + text-decoration: none; } .quickLinks { @@ -146,10 +147,15 @@ .quickLinks a:hover, .quickLinks a:focus-visible { - outline: 0; background: var(--pf-cream-2); } +/* The cream fill alone is 1.23:1 on white; the LED ring is what a keyboard sees. */ +.quickLinks a:focus-visible { + outline: 2px solid var(--pf-led); + outline-offset: -2px; +} + .quickLinks strong { font-size: var(--pf-fs-row-d); font-weight: 500; diff --git a/web/src/components/sections/BuildPanel.test.tsx b/web/src/components/sections/BuildPanel.test.tsx index ef3247f5d..82dbf26ea 100644 --- a/web/src/components/sections/BuildPanel.test.tsx +++ b/web/src/components/sections/BuildPanel.test.tsx @@ -5,7 +5,7 @@ import { useAppStore } from '@/store/useAppStore'; import type { SectionContent } from '@/lib/content'; import BuildPanel from './BuildPanel'; -// The Build panel's case switch: three tabs over one card, the official case +// The Build panel's case switch: four tabs over one card, the official case // first; switching changes the card, step 01 and the Start here row, writes // ?case= while /build is on screen, and a ?case= link opens on that case. The // case lives in the app store, where the 3D preview reads it, so the switch @@ -77,7 +77,7 @@ const startRow = (name: RegExp) => describe('the case switch', () => { it('opens on the official case, with the build.md lead above it', () => { render(<BuildPanel content={content} isActive />); - expect(screen.getAllByRole('tab')).toHaveLength(3); + expect(screen.getAllByRole('tab')).toHaveLength(4); expect(tab(/Official/)).toHaveAttribute('aria-selected', 'true'); expect(tab(/Besoiobiy/)).toHaveAttribute('aria-selected', 'false'); expect(screen.getByText('Every case here holds the same v3 board.')).toBeInTheDocument(); @@ -169,8 +169,7 @@ describe('the case switch', () => { fireEvent.keyDown(official, { key: 'ArrowRight' }); expect(tab(/Besoiobiy/)).toHaveAttribute('aria-selected', 'true'); expect(tab(/Besoiobiy/)).toHaveFocus(); - // false: the key was consumed, so End does not also scroll the panel. - expect(fireEvent.keyDown(tab(/Besoiobiy/), { key: 'End' })).toBe(false); + fireEvent.keyDown(tab(/Besoiobiy/), { key: 'ArrowRight' }); expect(tab(/SimonePDA/)).toHaveAttribute('aria-selected', 'true'); expect(tab(/SimonePDA/)).toHaveFocus(); expect(screen.getByRole('tabpanel')).toHaveAccessibleName(/SimonePDA/); @@ -189,11 +188,34 @@ describe('the case switch', () => { 'href', `${GH}/tree/main/hardware/case/remixes/simonepda-lasercut`, ); - fireEvent.keyDown(tab(/SimonePDA/), { key: 'ArrowRight' }); + // false: the key was consumed, so End does not also scroll the panel. + expect(fireEvent.keyDown(tab(/SimonePDA/), { key: 'End' })).toBe(false); + expect(tab(/mbchars/)).toHaveAttribute('aria-selected', 'true'); + expect(tab(/mbchars/)).toHaveFocus(); + expect(screen.getByRole('tabpanel')).toHaveAccessibleName(/mbchars/); + expect(card().getByRole('heading', { name: 'Mykyta Bilous’s horizontal desktop case' })).toBeInTheDocument(); + expect(card().getByRole('link', { name: 'Mykyta Bilous' })).toHaveAttribute('href', 'https://github.com/mbchars'); + expect(card().getByText(/Bambu Lab P2S/)).toBeInTheDocument(); + expect(card().getByRole('link', { name: /assembly\.md/ })).toHaveAttribute( + 'href', + `${GH}/blob/main/hardware/case/remixes/mbchars-horizontal-desktop-printed/assembly.md`, + ); + expect(readme()).toHaveAttribute( + 'href', + `${GH}/blob/main/hardware/case/remixes/mbchars-horizontal-desktop-printed/README.md`, + ); + expect(card().getByText(/puts it together from its STL files/)).toBeInTheDocument(); + expect(useAppStore.getState().buildCase).toBe('mbchars-horizontal-desktop-printed'); + expect(stepOne()).toHaveTextContent('Print the nine plates of the 3MF'); + expect(startRow(/^Print the case\s*mbchars-horizontal-desktop-printed\//)).toHaveAttribute( + 'href', + `${GH}/tree/main/hardware/case/remixes/mbchars-horizontal-desktop-printed`, + ); + fireEvent.keyDown(tab(/mbchars/), { key: 'ArrowRight' }); expect(tab(/Official/)).toHaveAttribute('aria-selected', 'true'); fireEvent.keyDown(tab(/Official/), { key: 'ArrowLeft' }); - expect(tab(/SimonePDA/)).toHaveAttribute('aria-selected', 'true'); - expect(fireEvent.keyDown(tab(/SimonePDA/), { key: 'Home' })).toBe(false); + expect(tab(/mbchars/)).toHaveAttribute('aria-selected', 'true'); + expect(fireEvent.keyDown(tab(/mbchars/), { key: 'Home' })).toBe(false); expect(tab(/Official/)).toHaveAttribute('aria-selected', 'true'); expect(tab(/Official/)).toHaveFocus(); // Home on the first tab picks nothing new, so it sends nothing: like a @@ -362,7 +384,16 @@ describe('the rest of the panel', () => { `${GH}/blob/main/docs/panel-compatibility.md`, ); expect(screen.getByRole('link', { name: 'Breadboard guide' })).toHaveAttribute('href', '/build/breadboard'); - expect(screen.getByRole('link', { name: 'v2 board guide' })).toHaveAttribute('href', `${GH}/blob/main/BUILD_GUIDE_v2.md`); + expect(screen.getByRole('link', { name: 'v2 board guide' })).toHaveAttribute('href', `${GH}/blob/main/docs/legacy/BUILD_GUIDE_v2.md`); expect(screen.queryByText(/laser-cut, or an older/)).not.toBeInTheDocument(); }); + + // On /build the hero's h1 is inert (RightPanel.tsx), so the panel's title is + // the page's h1 and the case name sits one level under it. + it("titles the page with an h1 and the case card with an h2", () => { + render(<BuildPanel content={content} isActive />); + expect(screen.getAllByRole('heading', { level: 1 }).map((h) => h.textContent)).toEqual(['Build your own.']); + expect(card().getByRole('heading', { level: 2, name: 'The official printed case' })).toBeInTheDocument(); + expect(screen.queryAllByRole('heading', { level: 3 })).toEqual([]); + }); }); diff --git a/web/src/components/sections/BuildPanel.tsx b/web/src/components/sections/BuildPanel.tsx index 5a86b2a49..6d66742ff 100644 --- a/web/src/components/sections/BuildPanel.tsx +++ b/web/src/components/sections/BuildPanel.tsx @@ -230,7 +230,7 @@ export default function BuildPanel({ content, isActive }: BuildPanelProps) { return ( <div className="panel-content pf-section-panel" id="build"> <div className="panel-header"> - <h2 className="pf-h2">{content.title || 'Build your own.'}</h2> + <h1 className="pf-h2">{content.title || 'Build your own.'}</h1> <p className="pf-sub">{content.subtitle || 'Print, solder, assemble, flash.'}</p> </div> @@ -414,7 +414,7 @@ export default function BuildPanel({ content, isActive }: BuildPanelProps) { <Link className="pf-link" href="/build/breadboard"> Breadboard guide </Link> - <a className="pf-link" href="https://github.com/engmung/Patternflow/blob/main/BUILD_GUIDE_v2.md" target="_blank" rel="noreferrer"> + <a className="pf-link" href="https://github.com/engmung/Patternflow/blob/main/docs/legacy/BUILD_GUIDE_v2.md" target="_blank" rel="noreferrer"> v2 board guide </a> </div> diff --git a/web/src/components/sections/CasePicker.module.css b/web/src/components/sections/CasePicker.module.css index 323ca5f99..c121e4369 100644 --- a/web/src/components/sections/CasePicker.module.css +++ b/web/src/components/sections/CasePicker.module.css @@ -22,11 +22,12 @@ margin: 0; } -/* ── The switch ── three cells over the card, the selected one in ink, so the - tab and the card it opened read as one piece. */ +/* ── The switch ── four cells over the card (two rows of two on a phone), + the selected one in ink, so the tab and the card it opened read as one + piece. */ .tabs { display: grid; - grid-template-columns: repeat(3, 1fr); + grid-template-columns: repeat(4, 1fr); border: 1px solid var(--pf-rule); } @@ -176,7 +177,7 @@ gap: 6px 14px; } -/* .panel-body h3 is global too: 24px with a 48px top margin. */ +/* An h2 under the panel's h1, so the global .panel-body h3 no longer reaches it. */ .card .name { margin: 0; color: var(--pf-ink); @@ -361,11 +362,16 @@ /* The global a:hover underline would run under both halves of the row. */ .links a:hover, .links a:focus-visible { - outline: 0; background: var(--pf-cream-2); text-decoration: none; } +/* Same ring as the case tabs above; the fill alone barely shows on white. */ +.links a:focus-visible { + outline: 2px solid var(--pf-led); + outline-offset: -2px; +} + .links strong { font-size: var(--pf-fs-row-d); font-weight: 500; @@ -384,10 +390,23 @@ /* A phone: the label goes over its value, and the padding comes in. */ @container (max-width: 420px) { + /* Four across, a name like "SimonePDA" no longer fits its cell. */ + .tabs { + grid-template-columns: repeat(2, 1fr); + } + .tab { padding: 10px 10px; } + .tab:nth-child(2n) { + border-right: 0; + } + + .tab:nth-child(-n + 2) { + border-bottom: 1px solid var(--pf-rule); + } + .photoMeta { padding: 10px 14px 0; } diff --git a/web/src/components/sections/CasePicker.tsx b/web/src/components/sections/CasePicker.tsx index e52fd8419..7cd712d00 100644 --- a/web/src/components/sections/CasePicker.tsx +++ b/web/src/components/sections/CasePicker.tsx @@ -25,8 +25,8 @@ interface CasePickerProps { // both: a build, and a render or a view from behind. const STRIP = 2; -// The case, three ways. A tablist rather than a row of toggles: the switch -// replaces the whole card below it, and arrow keys move along the three the +// The case, four ways. A tablist rather than a row of toggles: the switch +// replaces the whole card below it, and arrow keys move along the four the // way they do in any other tab set. export default function CasePicker({ cases, @@ -64,7 +64,7 @@ export default function CasePicker({ return ( <div className={`pf-block ${styles.picker}`}> - <span className="pf-kicker">The case — three ways to make it</span> + <span className="pf-kicker">The case — four ways to make it</span> {lead && lead.trim().length > 0 && ( <div className={`pf-prose ${styles.lead}`}> <ReactMarkdown>{lead}</ReactMarkdown> @@ -143,7 +143,7 @@ export default function CasePicker({ <div className={styles.body}> <div className={styles.head}> - <h3 className={styles.name}>{selected.name}</h3> + <h2 className={styles.name}>{selected.name}</h2> <span className={styles.kind}>{selected.kind === 'official' ? 'Official' : 'Community remix'}</span> </div> <p className={styles.summary}>{selected.summary}</p> diff --git a/web/src/components/sections/Deck.tsx b/web/src/components/sections/Deck.tsx deleted file mode 100644 index 44447ce42..000000000 --- a/web/src/components/sections/Deck.tsx +++ /dev/null @@ -1,50 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import BuildPanel from './BuildPanel'; -import InsidePanel from './InsidePanel'; -import PatternPanel from './PatternPanel'; -import { SectionContent } from '@/lib/content'; - -type TabType = 'build' | 'inside' | 'pattern'; - -interface DeckProps { - buildContent: SectionContent; - patternContent: SectionContent; - insideContent: SectionContent; -} - -export default function Deck({ buildContent, patternContent, insideContent }: DeckProps) { - const [activeTab, setActiveTab] = useState<TabType>('build'); - - return ( - <div className="deck-wrap"> - <div className="tab-bar"> - <button - className={`tab-btn ${activeTab === 'build' ? 'active' : ''}`} - onClick={() => setActiveTab('build')} - > - Build your own - </button> - <button - className={`tab-btn ${activeTab === 'inside' ? 'active' : ''}`} - onClick={() => setActiveTab('inside')} - > - Inside the work - </button> - <button - className={`tab-btn ${activeTab === 'pattern' ? 'active' : ''}`} - onClick={() => setActiveTab('pattern')} - > - Create Pattern - </button> - </div> - - <div className="deck-content" id="deck"> - {activeTab === 'build' && <BuildPanel content={buildContent} isActive />} - {activeTab === 'inside' && <InsidePanel content={insideContent} />} - {activeTab === 'pattern' && <PatternPanel content={patternContent} />} - </div> - </div> - ); -} diff --git a/web/src/components/sections/Hero.test.tsx b/web/src/components/sections/Hero.test.tsx new file mode 100644 index 000000000..54f7ba56f --- /dev/null +++ b/web/src/components/sections/Hero.test.tsx @@ -0,0 +1,168 @@ +import type { AnchorHTMLAttributes, ReactNode } from 'react'; +import { renderToString } from 'react-dom/server'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import Hero from './Hero'; + +// The hero's video is a YouTube embed that starts by itself and takes no +// clicks. A button over it stops it and plays it again. Someone whose system +// asks for reduced motion gets the poster under it and no embed at all, and the +// server, which cannot know, sends the poster alone. + +vi.mock('next/link', () => ({ + default: ({ + href, + children, + ...rest + }: AnchorHTMLAttributes<HTMLAnchorElement> & { href: string; children: ReactNode }) => ( + <a href={href} {...rest}> + {children} + </a> + ), +})); + +vi.mock('@/lib/posthogEvents', () => ({ captureEvent: () => {} })); + +const REDUCE = '(prefers-reduced-motion: reduce)'; + +// One reduced-motion setting for the page, which a test can flip the way the +// system settings would, firing change on every query that listens. +let reduce = false; +const listeners = new Set<() => void>(); +function setReduce(next: boolean) { + reduce = next; + act(() => listeners.forEach((fn) => fn())); +} + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + reduce = false; + listeners.clear(); + vi.stubGlobal('matchMedia', (query: string) => ({ + media: query, + get matches() { + return query === REDUCE ? reduce : false; + }, + addEventListener: (_: string, fn: () => void) => listeners.add(fn), + removeEventListener: (_: string, fn: () => void) => listeners.delete(fn), + })); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +const poster = (root: ParentNode) => root.querySelector('img[alt="Patternflow physical device"]'); +const video = (root: ParentNode) => root.querySelector('iframe'); +const stopButton = () => screen.getByRole('button', { name: 'Stop the video' }); +const playButton = () => screen.getByRole('button', { name: 'Play the video' }); + +describe('Hero', () => { + it('plays the video over the poster, hidden while the player loads', () => { + const { container } = render(<Hero />); + expect(poster(container)).not.toBeNull(); + expect(video(container)?.getAttribute('src')).toContain('youtube.com/embed/'); + expect(video(container)?.style.opacity).toBe('0'); + + act(() => vi.advanceTimersByTime(3000)); + expect(video(container)?.style.opacity).toBe('1'); + + act(() => vi.advanceTimersByTime(89000)); + expect(video(container)?.style.opacity).toBe('0'); + }); + + it('shows the poster alone when reduced motion is on', () => { + reduce = true; + const { container } = render(<Hero />); + expect(poster(container)).not.toBeNull(); + expect(video(container)).toBeNull(); + expect(screen.queryByRole('button')).toBeNull(); + + act(() => vi.advanceTimersByTime(10000)); + expect(video(container)).toBeNull(); + }); + + it('drops the video when reduced motion turns on, and starts it hidden again when it turns off', () => { + const { container } = render(<Hero />); + act(() => vi.advanceTimersByTime(3000)); + expect(video(container)?.style.opacity).toBe('1'); + + setReduce(true); + expect(video(container)).toBeNull(); + expect(poster(container)).not.toBeNull(); + + setReduce(false); + expect(video(container)?.style.opacity).toBe('0'); + act(() => vi.advanceTimersByTime(3000)); + expect(video(container)?.style.opacity).toBe('1'); + }); + + it('sends the poster without the embed from the server', () => { + const html = renderToString(<Hero />); + expect(html).toContain('Patternflow physical device'); + expect(html).not.toContain('<iframe'); + expect(html).not.toContain('<button'); + }); + + // The original is 4032 wide and 2.1 MB, and it was the first screen's + // largest paint: 8.7 s on a phone at 9 Mbps. The poster asks the optimizer + // for the column's width instead, and starts loading with the page. + it('sends a poster sized to the column, never the original, loading at once', () => { + const html = renderToString(<Hero />); + const img = new DOMParser().parseFromString(html, 'text/html').querySelector('img'); + expect(img?.getAttribute('alt')).toBe('Patternflow physical device'); + expect(img?.getAttribute('src')).toMatch(/^\/_next\/image\?url=%2Fproduct_v2\.jpg&/); + expect(img?.getAttribute('srcset')).toContain('/_next/image?url=%2Fproduct_v2.jpg&w=1080&q=75 1080w'); + expect(img?.getAttribute('sizes')).toBe('(max-width: 900px) calc(100vw - 48px), 580px'); + expect(img?.getAttribute('loading')).toBe('eager'); + expect(img?.getAttribute('fetchpriority')).toBe('high'); + expect(html).not.toMatch(/(src|srcSet|imageSrcSet)="\/product_v2\.jpg"/); + }); + + it('stops the video from its button and plays it again from the top', () => { + const { container } = render(<Hero />); + act(() => vi.advanceTimersByTime(3000)); + expect(video(container)?.style.opacity).toBe('1'); + + fireEvent.click(stopButton()); + expect(video(container)).toBeNull(); + expect(poster(container)).not.toBeNull(); + act(() => vi.advanceTimersByTime(100000)); + expect(video(container)).toBeNull(); + + // The same button, so focus stays where the hand left it. + const button = playButton(); + fireEvent.click(button); + expect(stopButton()).toBe(button); + expect(video(container)?.style.opacity).toBe('0'); + act(() => vi.advanceTimersByTime(3000)); + expect(video(container)?.style.opacity).toBe('1'); + }); + + it('offers Play once the video has ended, and Play starts a fresh one', () => { + const { container } = render(<Hero />); + expect(stopButton()).not.toBeNull(); + act(() => vi.advanceTimersByTime(92000)); + const ended = video(container); + expect(ended?.style.opacity).toBe('0'); + + fireEvent.click(playButton()); + expect(video(container)).not.toBe(ended); + expect(video(container)?.style.opacity).toBe('0'); + act(() => vi.advanceTimersByTime(3000)); + expect(video(container)?.style.opacity).toBe('1'); + expect(stopButton()).not.toBeNull(); + }); + + it('takes the button away with the video when reduced motion turns on', () => { + render(<Hero />); + fireEvent.click(stopButton()); + setReduce(true); + expect(screen.queryByRole('button')).toBeNull(); + + // A fresh video, playing, as when reduced motion turned off before. + setReduce(false); + expect(stopButton()).not.toBeNull(); + }); +}); diff --git a/web/src/components/sections/Hero.tsx b/web/src/components/sections/Hero.tsx index b108a34a5..670844779 100644 --- a/web/src/components/sections/Hero.tsx +++ b/web/src/components/sections/Hero.tsx @@ -1,16 +1,19 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; +import Image from 'next/image'; import HeroJournalLink from "@/components/journal/HeroJournalLink"; import { captureEvent } from "@/lib/posthogEvents"; import { CROWD_SUPPLY_URL } from "@/lib/crowdSupply"; +import { useReducedMotion } from "@/lib/hooks/useReducedMotion"; // How many people have made a Patternflow, by the nearest thing to a count // there is: the community's membership, which tracks it closely. Rounded and // said as "around", because it is an estimate. const PEOPLE_WHO_MADE_ONE = 40; -export default function Hero() { +// One play of the video, from the start. +function HeroEmbed({ onEnded }: { onEnded: () => void }) { const [isVideoVisible, setIsVideoVisible] = useState(false); useEffect(() => { @@ -22,13 +25,82 @@ export default function Hero() { // Hide the video and return to static image after the video ends (3s delay + 89s video duration = 92s) const hideTimer = setTimeout(() => { setIsVideoVisible(false); + onEnded(); }, 92000); return () => { clearTimeout(showTimer); clearTimeout(hideTimer); }; - }, []); + }, [onEnded]); + + return ( + <iframe + src="https://www.youtube.com/embed/OXt-yg_7qdk?autoplay=1&mute=1&controls=0&modestbranding=1&disablekb=1&playsinline=1&rel=0" + title="Patternflow Demo Video" + frameBorder="0" + allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" + allowFullScreen + style={{ + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + display: 'block', + pointerEvents: 'none', + opacity: isVideoVisible ? 1 : 0, + transition: 'opacity 0.6s ease-in-out', + }} + /> + ); +} + +// Mounted only while motion is welcome. Turning reduced motion off again +// mounts a fresh one, which hides for its first three seconds like the first. +// The embed takes no clicks, so the button over it is the one way to stop the +// video before its 89 seconds are up (WCAG 2.2.2). Stop takes the embed away +// and the poster under it stays. Play mounts a fresh embed, which starts from +// the top, and so does Play after the video has ended. +function HeroVideo() { + const [state, setState] = useState<'playing' | 'ended' | 'stopped'>('playing'); + const [take, setTake] = useState(0); + const onEnded = useCallback(() => setState('ended'), []); + const playing = state === 'playing'; + + return ( + <> + {state !== 'stopped' && <HeroEmbed key={take} onEnded={onEnded} />} + <button + type="button" + className="hero-video-toggle" + aria-label={playing ? 'Stop the video' : 'Play the video'} + onClick={() => { + if (playing) { + setState('stopped'); + } else { + setTake((n) => n + 1); + setState('playing'); + } + }} + > + <svg viewBox="0 0 12 12" width="12" height="12" aria-hidden="true" focusable="false"> + {playing ? ( + <rect x="1" y="1" width="10" height="10" fill="currentColor" /> + ) : ( + <path d="M2 0.5 11.5 6 2 11.5Z" fill="currentColor" /> + )} + </svg> + </button> + </> + ); +} + +export default function Hero() { + // The embed starts by itself, so someone who has asked their system for less + // motion gets the poster and no embed at all. The server cannot know, so it + // sends the poster alone and the client adds the video after hydration. + const reducedMotion = useReducedMotion(); return ( <section className="hero"> @@ -51,38 +123,22 @@ export default function Hero() { paddingBottom: '56.25%', height: 0 }}> - {/* Static fallback and loading placeholder */} - <img - src="/product_v2.jpg" - alt="Patternflow physical device" - style={{ - position: 'absolute', - top: 0, - left: 0, - width: '100%', - height: '100%', - objectFit: 'cover', - display: 'block' - }} - /> - <iframe - src="https://www.youtube.com/embed/OXt-yg_7qdk?autoplay=1&mute=1&controls=0&modestbranding=1&disablekb=1&playsinline=1&rel=0" - title="Patternflow Demo Video" - frameBorder="0" - allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" - allowFullScreen - style={{ - position: 'absolute', - top: 0, - left: 0, - width: '100%', - height: '100%', - display: 'block', - pointerEvents: 'none', - opacity: isVideoVisible ? 1 : 0, - transition: 'opacity 0.6s ease-in-out', - }} + {/* Static fallback and loading placeholder. The file is the 4032-wide + camera original, 2.1 MB, drawn at 340px on a phone and 578px at + most on a desktop, so it goes through the image optimizer at the + column's width. The column is the viewport less the deck's 24px + gutters up to 900px, and the copy's 580px cap above. It is the + largest thing on the first screen, so it loads at once. */} + <Image + src="/product_v2.jpg" + alt="Patternflow physical device" + fill + sizes="(max-width: 900px) calc(100vw - 48px), 580px" + loading="eager" + fetchPriority="high" + style={{ objectFit: 'cover' }} /> + {!reducedMotion && <HeroVideo />} </div> {/* L3 — the strongest line we have, per the manifesto: a description of how the system works, with nothing in it to disbelieve. */} diff --git a/web/src/components/sections/InsideGlobe/BuildCard.module.css b/web/src/components/sections/InsideGlobe/BuildCard.module.css index f63310ac6..0110738d0 100644 --- a/web/src/components/sections/InsideGlobe/BuildCard.module.css +++ b/web/src/components/sections/InsideGlobe/BuildCard.module.css @@ -130,6 +130,10 @@ word-break: break-all; } + .link:hover { + text-decoration: none; + } + /* Step through builds without going back up to the map. */ .pager { display: flex; diff --git a/web/src/components/sections/InsideGlobe/BuildCard.tsx b/web/src/components/sections/InsideGlobe/BuildCard.tsx index 3977ef716..3ba704fd7 100644 --- a/web/src/components/sections/InsideGlobe/BuildCard.tsx +++ b/web/src/components/sections/InsideGlobe/BuildCard.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import Image from 'next/image'; import PhotoLightbox from './PhotoLightbox'; +import PinLink from './PinLink'; import { formatBuildDate, CARD_TILES } from './builds'; import { useBuildSelection } from './useBuildSelection'; import styles from './BuildCard.module.css'; @@ -78,15 +79,7 @@ export default function BuildCard() { {selected.links && selected.links.length > 0 && ( <div className={styles.links}> {selected.links.map((link) => ( - <a - key={link.href} - className={styles.link} - href={link.href} - target="_blank" - rel="noopener noreferrer" - > - {link.label} ↗ - </a> + <PinLink key={link.href} className={styles.link} href={link.href} label={link.label} /> ))} </div> )} diff --git a/web/src/components/sections/InsideGlobe/BuildIndex.module.css b/web/src/components/sections/InsideGlobe/BuildIndex.module.css index a897c7673..aee345caf 100644 --- a/web/src/components/sections/InsideGlobe/BuildIndex.module.css +++ b/web/src/components/sections/InsideGlobe/BuildIndex.module.css @@ -23,6 +23,10 @@ background: var(--pf-hover, rgba(20, 20, 20, 0.05)); } +.item:hover { + text-decoration: none; +} + .item:focus-visible { outline: 2px solid var(--pf-led); outline-offset: 2px; diff --git a/web/src/components/sections/InsideGlobe/BuildIndex.test.tsx b/web/src/components/sections/InsideGlobe/BuildIndex.test.tsx index 829550c85..7df294362 100644 --- a/web/src/components/sections/InsideGlobe/BuildIndex.test.tsx +++ b/web/src/components/sections/InsideGlobe/BuildIndex.test.tsx @@ -4,7 +4,7 @@ import { useAppStore } from '@/store/useAppStore'; import BuildFilters from './BuildFilters'; import BuildIndex from './BuildIndex'; import BuildRouteSync from './BuildRouteSync'; -import { builds } from './builds'; +import { builds, originOf } from './builds'; beforeEach(() => { useAppStore.setState({ selectedBuildId: null, insideFilter: 'all' }); @@ -22,9 +22,11 @@ describe('Inside browsing', () => { expect(list.getAllByRole('link')).toHaveLength(builds.length); expect(list.queryByRole('img')).not.toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'Projects' })); - expect(list.getAllByRole('link')).toHaveLength(2); + expect(list.getAllByRole('link')).toHaveLength(4); expect(list.getByRole('link', { name: /MOTIFLOW/ })).toHaveAttribute('href', '/inside/azmano-iran'); expect(list.getByRole('link', { name: /Raspberry Pi port/ })).toBeInTheDocument(); + expect(list.getByRole('link', { name: /over MQTT/ })).toHaveAttribute('href', '/inside/simonepda-mqtt-uae'); + expect(list.getByRole('link', { name: /Norns screen mirror/ })).toHaveAttribute('href', '/inside/jonwtr-belgium'); expect(screen.getByRole('button', { name: 'Projects' })).toHaveAttribute('aria-pressed', 'true'); }); @@ -37,13 +39,23 @@ describe('Inside browsing', () => { fireEvent.click(screen.getByRole('button', { name: 'In use' })); expect(useAppStore.getState().selectedBuildId).toBeNull(); expect(window.location.pathname).toBe('/inside'); - // "In use" now holds the first sale, so MOTIFLOW (a project) is what the filter hides. - expect(list.getAllByRole('link')).toHaveLength(1); + // "In use" holds the first sale and the showings, so MOTIFLOW (a project) is what the filter hides. + expect(list.getAllByRole('link')).toHaveLength(5); expect(list.getByRole('link', { name: /The first sale/ })).toHaveAttribute('href', '/inside/seunghun-san-francisco'); + expect(list.getByRole('link', { name: /Played at Anbu/ })).toHaveAttribute('href', '/inside/seunghun-anbu-seoul'); fireEvent.click(screen.getByRole('button', { name: 'Builds' })); expect(list.getByRole('link', { name: /In the DJ booth/ })).toBeInTheDocument(); }); + it('files every showing under In use, hanging off a pin that exists', () => { + const showings = builds.filter((build) => build.kind === 'showing'); + expect(showings.length).toBeGreaterThan(0); + for (const showing of showings) { + expect(showing.category).toBe('in-use'); + expect(originOf(showing)).toBeDefined(); + } + }); + it('reveals a directly linked entry even after browsing another category', () => { useAppStore.setState({ insideFilter: 'builds' }); const list = mount('iran-azmano'); diff --git a/web/src/components/sections/InsideGlobe/Globe.tsx b/web/src/components/sections/InsideGlobe/Globe.tsx index b31da29c8..621091d22 100644 --- a/web/src/components/sections/InsideGlobe/Globe.tsx +++ b/web/src/components/sections/InsideGlobe/Globe.tsx @@ -11,14 +11,16 @@ import { Float32BufferAttribute, Matrix4, Quaternion, + Shape, Vector3, } from 'three'; import type { Build } from './builds'; import { builds, latLngToVec3, originOf } from './builds'; import type { BuildGroup } from './groups'; import type { FanObstacle } from './groups'; -import { angularDistance, fanAngle, fanChordPx, fanLean, fanRadiusPx, groupBuilds } from './groups'; +import { GROUP_ANGLE_DEG, angularDistance, fanAngle, fanChordPx, fanLean, fanRadiusPx, groupBuilds } from './groups'; import landData from './land.json'; +import { useReducedMotion } from '@/lib/hooks/useReducedMotion'; const COLORS = { graticule: '#CFC7B4', // faint lat/long grid — the see-through ocean @@ -215,8 +217,8 @@ const ARC_SAMPLES = 96; // Links between each build and its few nearest builds (deduped), not a full // mesh. Only actual builds take part: this web means "how far Patternflow has -// spread", so a collaboration joining it would overstate the count. Those get -// their own line back to where they came from. +// spread", so a collaboration, a sale or a showing joining it would overstate +// the count. Those get their own line back to where they came from. function webArcs(entries: Build[]): number[][] { const built = entries.filter((build) => build.kind === 'build'); const arcs: number[][] = []; @@ -252,13 +254,17 @@ function webArcs(entries: Build[]): number[][] { return arcs; } -// One line from each collaboration, and each sale, back to where it came from. +// One line from each collaboration, sale and showing back to where it came +// from. None from a pin that sits on its origin's own spot, as the showings in +// Seoul sit on the first build: that line would have no length, only its dots +// piled on the pin. function collaborationArcs(entries: Build[]): number[][] { const arcs: number[][] = []; for (const build of entries) { if (build.kind === 'build') continue; const origin = originOf(build); if (!origin || !entries.some((entry) => entry.id === origin.id)) continue; + if (angularDistance(origin.location, build.location) <= (GROUP_ANGLE_DEG * Math.PI) / 180) continue; arcs.push( linkArc( origin.location.lat, origin.location.lng, @@ -310,13 +316,14 @@ const _dotQuat = new Quaternion(); const _dotMatrix = new Matrix4(); // Dots running along the links, so the web reads as something live and moving -// between the builds rather than a fixed diagram. +// between the builds rather than a fixed diagram. For someone who has asked +// for less motion they stand where they start, spaced along each link. // // Instanced spheres rather than a Points cloud: point sprites are squares held // flat to the screen, which is invisible at rest but unmistakable once a pin is // picked and the globe dollies in. Being round, they need no orientation — the // rotation is left at identity and the scale is uniform. -function LinkDots({ arcs, opacity }: { arcs: number[][]; opacity: number }) { +function LinkDots({ arcs, opacity, still }: { arcs: number[][]; opacity: number; still: boolean }) { const meshRef = useRef<InstancedMesh>(null); const count = arcs.length * DOTS_PER_LINK; @@ -336,7 +343,7 @@ function LinkDots({ arcs, opacity }: { arcs: number[][]; opacity: number }) { useFrame((state) => { const mesh = meshRef.current; if (!mesh) return; - const time = state.clock.elapsedTime; + const time = still ? 0 : state.clock.elapsedTime; let d = 0; for (const arc of arcs) { @@ -411,15 +418,43 @@ const smoothstep = (value: number, from: number, to: number) => { return t * t * (3 - 2 * t); }; +// How far each mark reaches from its centre, in pin radii: the shapes below +// are drawn to it, and an open fan keeps clear of it. +const PIN_REACH: Record<Build['kind'], number> = { + build: 1, + collaboration: 1.77, + sold: 1.45, + showing: 1.6, +}; + +// A showing's triangle, point up. Flat in the pin's own plane like the ring, +// so a fanned-out showing, stood upright to the viewer, is the right way up. +const SHOWING_TRIANGLE = (() => { + const reach = PIN_RADIUS * PIN_REACH.showing; + const half = reach * Math.sin(Math.PI / 3); + const shape = new Shape(); + shape.moveTo(0, reach); + shape.lineTo(-half, -reach / 2); + shape.lineTo(half, -reach / 2); + shape.closePath(); + return shape; +})(); +// One object, so a pin re-rendered on hover keeps its geometry. +const SHOWING_EXTRUDE = { depth: PIN_RADIUS * 0.8, bevelEnabled: false }; + // A dot means a Patternflow exists there; a collaboration is an open ring, // something that grew out of one; a sale is a diamond, a unit that went out -// rather than one that was built there. Reads at a glance without a second -// colour, and survives colour-blindness. +// rather than one that was built there; a showing is a triangle, a unit set +// out there for a while for people to play. Reads at a glance without a +// second colour, and survives colour-blindness. function PinShape({ kind }: { kind: Build['kind'] }) { if (kind === 'collaboration') { return <torusGeometry args={[PIN_RADIUS * 1.35, PIN_RADIUS * 0.42, 10, 28]} />; } - if (kind === 'sold') return <octahedronGeometry args={[PIN_RADIUS * 1.45, 0]} />; + if (kind === 'sold') return <octahedronGeometry args={[PIN_RADIUS * PIN_REACH.sold, 0]} />; + if (kind === 'showing') { + return <extrudeGeometry args={[SHOWING_TRIANGLE, SHOWING_EXTRUDE]} />; + } return <sphereGeometry args={[PIN_RADIUS, 16, 16]} />; } @@ -930,7 +965,7 @@ const FIT_RADIUS = 1.35; // globe radius including markers const FIT_FRACTION = 0.7; // share of the limiting dimension to occupy const FOCUS_DOLLY = 0.62; // zoom-in factor when a build is selected const DRAG_SPEED = 0.006; // radians per pixel of drag -const DRIFT_SPEED = 0.18; // radians per second, idle +const DRIFT_SPEED = 0.18; // radians per second, idle; none with less motion // The globe leans a degree or two towards the pointer - enough to feel that // it has noticed, not enough to move a pin out from under it. Mouse only, and @@ -992,10 +1027,9 @@ function GlobeScene({ // Every pin as an open fan has to see it: where, and how big its mark is. const neighbours = useMemo<Neighbour[]>( () => groups.map(({ id, members, center }) => { - const kind = members[0].kind; const reach = members.length > 1 ? (ringRadii(members.length).at(-1) ?? 0) / PIN_RADIUS + GROUP_RING_TUBE - : kind === 'collaboration' ? 1.77 : kind === 'sold' ? 1.45 : 1; + : PIN_REACH[members[0].kind]; return { id, at: new Vector3(...latLngToVec3(center.lat, center.lng, PIN_LAYER_RADIUS)), @@ -1004,10 +1038,7 @@ function GlobeScene({ }), [groups], ); - const reducedMotion = useMemo( - () => window.matchMedia('(prefers-reduced-motion: reduce)').matches, - [], - ); + const reducedMotion = useReducedMotion(); const canvas = useThree((state) => state.gl.domElement); const [hoverId, setHoverId] = useState<string | null>(null); @@ -1078,9 +1109,11 @@ function GlobeScene({ // The globe holds still under a pointer that is on a pin, and while a // group stands open: a fan that slid away as you reached for it would be // no use. It picks its drift back up gradually, the way it would if it - // had weight. + // had weight. Someone who has asked for less motion gets no drift at + // all: the globe turns when they drag it, and stays where they leave it. const held = !!selected || !!heldOpen || !!hoveredId || dragging.current; - drift.current += ((held ? 0 : DRIFT_SPEED) - drift.current) * easeShare(held ? 9 : 2.5, delta); + if (reducedMotion) drift.current = 0; + else drift.current += ((held ? 0 : DRIFT_SPEED) - drift.current) * easeShare(held ? 9 : 2.5, delta); if (selected) { // Ease the picked location around to face the camera. @@ -1204,8 +1237,8 @@ function GlobeScene({ <GlobeWireframe /> <LinkLines arcs={web} opacity={0.45} /> <LinkLines arcs={collaborations} opacity={0.22} /> - <LinkDots arcs={web} opacity={0.9} /> - <LinkDots arcs={collaborations} opacity={0.5} /> + <LinkDots arcs={web} opacity={0.9} still={reducedMotion} /> + <LinkDots arcs={collaborations} opacity={0.5} still={reducedMotion} /> {groups.map((group) => { if (group.members.length === 1) { const build = group.members[0]; diff --git a/web/src/components/sections/InsideGlobe/GlobeViewer.test.tsx b/web/src/components/sections/InsideGlobe/GlobeViewer.test.tsx new file mode 100644 index 000000000..e2b870e0d --- /dev/null +++ b/web/src/components/sections/InsideGlobe/GlobeViewer.test.tsx @@ -0,0 +1,54 @@ +import { act, render } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useAppStore } from '@/store/useAppStore'; +import { builds, CARD_TILES, STRIP_TILES } from './builds'; +import GlobeViewer from './GlobeViewer'; + +// The globe warms each pin's opening photos so a pin opens with them. Below +// 901px the 3D view is hidden on the hero, yet the globe there still sent for +// every one of them, in the large variant a 3× phone picks: 65 photos and +// 8.3 MB on 22 October, for a map nobody saw. + +vi.mock('./Globe', () => ({ default: () => <div data-testid="globe" /> })); + +const media = vi.hoisted(() => ({ wide: false })); +const TILES = builds.flatMap((build) => (build.images ?? []).slice(0, Math.max(STRIP_TILES, CARD_TILES))); + +beforeEach(() => { + vi.stubGlobal('matchMedia', (query: string) => ({ + media: query, + matches: query === '(min-width: 901px)' && media.wide, + addEventListener: () => {}, + removeEventListener: () => {}, + })); + useAppStore.setState({ homeTab: 'hero', selectedBuildId: null, insideFilter: 'all' }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +const warmed = (container: HTMLElement) => container.querySelectorAll('.preload img').length; + +describe('GlobeViewer', () => { + it('warms no photo for the hidden globe of a phone’s hero', () => { + media.wide = false; + const { container, getByTestId } = render(<GlobeViewer />); + expect(getByTestId('globe')).toBeInTheDocument(); + expect(warmed(container)).toBe(0); + }); + + it('warms every pin’s opening photos once a phone opens Inside', () => { + media.wide = false; + const { container } = render(<GlobeViewer />); + act(() => useAppStore.getState().setHomeTab('inside')); + expect(TILES.length).toBeGreaterThan(0); + expect(warmed(container)).toBe(TILES.length); + }); + + it('warms them on the hero from 901px up, where the globe stands beside the page', () => { + media.wide = true; + const { container } = render(<GlobeViewer />); + expect(warmed(container)).toBe(TILES.length); + }); +}); diff --git a/web/src/components/sections/InsideGlobe/GlobeViewer.tsx b/web/src/components/sections/InsideGlobe/GlobeViewer.tsx index a3ff4a7d1..d3afc810c 100644 --- a/web/src/components/sections/InsideGlobe/GlobeViewer.tsx +++ b/web/src/components/sections/InsideGlobe/GlobeViewer.tsx @@ -5,9 +5,11 @@ import Image from 'next/image'; import Globe from './Globe'; import BuildFilters from './BuildFilters'; import PhotoLightbox from './PhotoLightbox'; +import PinLink from './PinLink'; import { builds, formatBuildDate, CARD_TILES, STRIP_TILES } from './builds'; import { useBuildSelection } from './useBuildSelection'; import { useAppStore } from '@/store/useAppStore'; +import { useMediaQuery } from '@/lib/hooks/useMediaQuery'; import styles from './GlobeViewer.module.css'; // The Inside section's viewer: an interactive globe with the picked build's @@ -21,7 +23,15 @@ export default function GlobeViewer() { const { selectedId, select: selectBuild, visibleBuilds } = useBuildSelection(); // On the hero the globe stands in for the product shot: no filters, and a // pin opens the Inside section rather than a detail card beside hero copy. - const onHero = useAppStore((state) => state.homeTab) === 'hero'; + const homeTab = useAppStore((state) => state.homeTab); + const onHero = homeTab === 'hero'; + // Below 901px the 3D view is hidden on the hero (globals.css) and drops + // down only for a tab, so a phone shows the globe on Inside alone. The + // photos below are warmed only where the globe is seen: a phone's hidden + // hero globe sent for all of them, in the large variant a 3× screen picks. + // The query reads false until measured, so a phone never renders them. + const wide = useMediaQuery('(min-width: 901px)'); + const warm = wide || homeTab === 'inside'; const [galleryIndex, setGalleryIndex] = useState<number | null>(null); const [hasSelected, setHasSelected] = useState(false); @@ -69,17 +79,19 @@ export default function GlobeViewer() { return ( <div className={styles.viewer}> {/* Offscreen preloader: fetch each pin's opening tiles (the same optimized - variant the strip and card use) eagerly the moment the globe mounts, so + variant the strip and card use) eagerly the moment the globe is seen, so opening a pin shows its photos instantly instead of lazy-loading. Low fetch priority so they queue behind the globe itself rather than competing with it for bandwidth on first paint. */} - <div className={styles.preload} aria-hidden> - {allImages.map((image) => ( - <span key={image.src} className={styles.preloadBox}> - <Image src={image.src} alt="" fill sizes="280px" loading="eager" fetchPriority="low" /> - </span> - ))} - </div> + {warm && ( + <div className={styles.preload} aria-hidden> + {allImages.map((image) => ( + <span key={image.src} className={styles.preloadBox}> + <Image src={image.src} alt="" fill sizes="280px" loading="eager" fetchPriority="low" /> + </span> + ))} + </div> + )} <Globe entries={visibleBuilds} selectedBuildId={selectedId ?? undefined} onSelectBuild={select} /> {!onHero && <BuildFilters />} @@ -168,16 +180,13 @@ export default function GlobeViewer() { {selected.links && selected.links.length > 0 && ( <div className={styles.links}> {selected.links.map((link) => ( - <a + <PinLink key={link.href} className={styles.link} href={link.href} - target="_blank" - rel="noopener noreferrer" + label={link.label} onClick={(event) => event.stopPropagation()} - > - {link.label} ↗ - </a> + /> ))} </div> )} diff --git a/web/src/components/sections/InsideGlobe/PinLink.test.tsx b/web/src/components/sections/InsideGlobe/PinLink.test.tsx new file mode 100644 index 000000000..b2591ff5b --- /dev/null +++ b/web/src/components/sections/InsideGlobe/PinLink.test.tsx @@ -0,0 +1,59 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { BUILD_CASES } from '../build-cases-data'; +import PinLink from './PinLink'; +import { builds } from './builds'; + +beforeEach(() => { + window.history.replaceState(null, '', '/inside/simonepda-lasercut-uae'); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('PinLink', () => { + it('moves this tab to a path in the site and lets the tabs follow', () => { + const popped = vi.fn(); + window.addEventListener('popstate', popped); + render(<PinLink href="/build?case=simonepda-lasercut" label="Build this case" />); + const link = screen.getByRole('link', { name: 'Build this case →' }); + expect(link).not.toHaveAttribute('target'); + fireEvent.click(link); + window.removeEventListener('popstate', popped); + expect(window.location.pathname + window.location.search).toBe('/build?case=simonepda-lasercut'); + expect(popped).toHaveBeenCalledTimes(1); + }); + + it('leaves a modified click to the browser', () => { + // Seen after the link's own handler, then stopped: jsdom cannot navigate. + let leftToBrowser = false; + const after = (event: MouseEvent) => { + leftToBrowser = !event.defaultPrevented; + event.preventDefault(); + }; + window.addEventListener('click', after); + render(<PinLink href="/build?case=simonepda-lasercut" label="Build this case" />); + fireEvent.click(screen.getByRole('link'), { metaKey: true }); + window.removeEventListener('click', after); + expect(leftToBrowser).toBe(true); + expect(window.location.pathname).toBe('/inside/simonepda-lasercut-uae'); + }); + + it('opens a full address in a new tab', () => { + render(<PinLink href="https://www.instagram.com/simonepda" label="instagram.com/simonepda" />); + const link = screen.getByRole('link', { name: 'instagram.com/simonepda ↗' }); + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', 'noopener noreferrer'); + }); + + it('gives every /build?case= link on a pin a case the build page has', () => { + const cases = new Set<string>(BUILD_CASES.map((item) => item.id)); + const named = builds + .flatMap((build) => build.links ?? []) + .map((link) => link.href.match(/^\/build\?case=(.+)$/)?.[1]) + .filter((id): id is string => id !== undefined); + expect(named.length).toBeGreaterThan(0); + for (const id of named) expect(cases.has(id), id).toBe(true); + }); +}); diff --git a/web/src/components/sections/InsideGlobe/PinLink.tsx b/web/src/components/sections/InsideGlobe/PinLink.tsx new file mode 100644 index 000000000..af7b16610 --- /dev/null +++ b/web/src/components/sections/InsideGlobe/PinLink.tsx @@ -0,0 +1,44 @@ +'use client'; + +import type { MouseEvent } from 'react'; + +// A link on a pin. A path into the site (/build?case=…) stays in this tab and +// changes it: the address moves, and the tabs follow it on popstate the way +// they follow Back, so the 3D scene is not loaded again. A full address opens +// in a new tab, with ↗. A modified click is left to the browser either way. +export default function PinLink({ + href, + label, + className, + onClick, +}: { + href: string; + label: string; + className?: string; + onClick?: (event: MouseEvent<HTMLAnchorElement>) => void; +}) { + // One slash, not two: //host is an address on another site. + if (!/^\/(?!\/)/.test(href)) { + return ( + <a className={className} href={href} target="_blank" rel="noopener noreferrer" onClick={onClick}> + {label} ↗ + </a> + ); + } + + return ( + <a + className={className} + href={href} + onClick={(event) => { + onClick?.(event); + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + event.preventDefault(); + window.history.pushState(null, '', href); + window.dispatchEvent(new PopStateEvent('popstate')); + }} + > + {label} → + </a> + ); +} diff --git a/web/src/components/sections/InsideGlobe/builds.ts b/web/src/components/sections/InsideGlobe/builds.ts index 0317b0115..77e16fcb4 100644 --- a/web/src/components/sections/InsideGlobe/builds.ts +++ b/web/src/components/sections/InsideGlobe/builds.ts @@ -17,7 +17,13 @@ export type BuildImage = { // apart on purpose, because the maintainer wants them to be their own list — // a /works page one day is a filter on this kind, not a second source of // truth. The globe gives it its own pin and a line back to where it came from. -export type BuildKind = 'build' | 'collaboration' | 'sold'; +// +// `showing` is a unit set out somewhere for a while for people to play: an +// exhibition, a night by the turntables, a party. It is a unit that already has +// a pin, travelling, not Patternflow reaching a new place, so like a sale it +// stays out of the web, gets its own pin and a line back to that unit, and +// files under In use. +export type BuildKind = 'build' | 'collaboration' | 'sold' | 'showing'; // Subject of the entry, independent of the collaboration relationship above. export type BuildCategory = 'builds' | 'projects' | 'in-use'; @@ -38,8 +44,8 @@ export type Build = { kind: BuildKind; title: string; category: BuildCategory; - // For a collaboration, the pin it grew out of — the globe arcs a line back to - // it. Defaults to the origin build when left out. + // For a collaboration, a sale or a showing, the pin it grew out of — the + // globe arcs a line back to it. Defaults to the origin build when left out. originId?: string; // Where the pin sits and what it is called. Keep the two at the same // resolution: a city label gets the city's coordinates, a country label gets @@ -52,6 +58,8 @@ export type Build = { // itself. Day precision is more than any of these are known to. date: string; description: string; + // A path into the site (/build?case=…) changes the tab in place; a full + // address opens in a new tab. See PinLink. links?: { href: string; label: string }[]; images?: BuildImage[]; }; @@ -177,7 +185,10 @@ export const builds: Build[] = [ maker: 'Seunghun LEE', date: '2026-09', description: - 'Sent to Martin Lopez, who put more patterns on the community wall — and more consistently, and more fun ones — than anyone.', + 'Sent to Martin Lopez, who put more patterns on the community wall — and more consistently, and more fun ones — than anyone. One of three units built with what the first sale earned.', + images: [ + { src: '/builds/martin/lit.jpg', alt: "A pink and blue pattern of crosses and stars lit on Martin Lopez's Patternflow, in a dark room" }, + ], }, { id: 'sf-swartz', @@ -189,11 +200,14 @@ export const builds: Build[] = [ maker: 'Seunghun LEE', date: '2026-09', description: - 'The first Patternflow sold as a work: $500, to Mike Swartz, for USER EXPERIENCE — an exhibition in San Francisco that opens in October 2026. This is the unit that spent the month before in front of the camera for Instagram. What it earned goes straight back into growing the community, as the certificate says.', + 'The first Patternflow sold as a work: $500, to Mike Swartz, for USER EXPERIENCE, an exhibition in San Francisco that opened in October 2026. This is the unit that spent the month before in front of the camera for Instagram. What it earned goes straight back into growing the community, as the certificate says.', links: [ { href: 'https://www.instagram.com/userexperience.place', label: 'USER EXPERIENCE on Instagram' }, ], images: [ + { src: '/builds/swartz/on-show.jpg', alt: 'The first sold Patternflow lying flat on a pale wooden table in the gallery, a teal and orange pattern on the panel and its card in front' }, + { src: '/builds/swartz/storefront.jpg', alt: 'The USER EXPERIENCE gallery from the street: a black front with the name in white above open glass doors' }, + { src: '/builds/swartz/gallery.jpg', alt: 'Inside the USER EXPERIENCE gallery: long wooden tables of objects, a rail of clothes and a blue wall at the back, the Patternflow lit on the nearest table' }, { src: '/builds/swartz/certificate.jpg', alt: 'The certificate that went with the first sold Patternflow, beside its pattern card' }, ], }, @@ -338,20 +352,230 @@ export const builds: Build[] = [ ], }, { - id: 'ukraine-extraplojo', + id: 'ukraine-mbchars', title: 'Horizontal build', category: 'builds', - slug: 'extraplojo-ukraine', + slug: 'mbchars-ukraine', kind: 'build', location: { lat: 50.4501, lng: 30.5234, label: 'Kyiv, Ukraine' }, - maker: 'extraplojo', + maker: 'mbchars', date: '2026-10', description: "A horizontal Patternflow in orange and white, for a desk or a wall. The case is held together with brass inserts instead of glue. It is also the maker's first 3D design, first PCB order and first soldering job, and it came out beautifully.", + links: [ + { href: 'https://github.com/engmung/Patternflow/tree/main/hardware/case/remixes/mbchars-horizontal-desktop-printed', label: 'Case files on GitHub' }, + { href: '/build?case=mbchars-horizontal-desktop-printed', label: 'Build this case' }, + ], + images: [ + { src: '/builds/mbchars/front.jpg', alt: "mbchars's horizontal Patternflow from the front: a rainbow gradient on the panel, an orange side panel with four white knobs, and orange feet" }, + { src: '/builds/mbchars/angle.jpg', alt: "mbchars's Patternflow at an angle, blue and orange waves on the panel and a braided orange cable running off to the side" }, + { src: '/builds/mbchars/inside.jpg', alt: "Inside mbchars's case: the board in the orange control box beside the panel, with brass inserts at the corners" }, + ], + }, + { + id: 'russia-zuperzuche', + title: 'The first print', + category: 'builds', + slug: 'zuperzuche-russia', + kind: 'build', + location: { lat: 55.7558, lng: 37.6173, label: 'Moscow, Russia' }, + maker: 'ZuperZuche', + date: '2026-10', + description: + 'Found by accident, with the PCBs ordered ten minutes later. A month on, the only thing missing was the case, so the maker bought a 3D printer and made it the first thing printed.', + images: [ + { src: '/builds/zuperzuche/fuji.jpg', alt: "ZuperZuche's white horizontal Patternflow in front of a modular synthesizer, with a pixel-art Mount Fuji at sunset on the panel" }, + { src: '/builds/zuperzuche/frog-pond.jpg', alt: "A pixel-art frog pond on the panel of ZuperZuche's Patternflow: lily pads and small flowers on teal water, seen from above" }, + { src: '/builds/zuperzuche/cat.jpg', alt: "A cat plush in the foreground, with ZuperZuche's Patternflow behind it glowing red, purple and blue" }, + ], + }, + { + id: 'seoul-hongik', + title: 'The process, on the wall', + category: 'in-use', + slug: 'seunghun-hongik-seoul', + kind: 'showing', + originId: 'seoul-v1', + location: { lat: 37.5665, lng: 126.978, label: 'Seoul, Korea' }, + maker: 'Seunghun LEE', + date: '2026-06', + description: + "The exhibition of the Hongik University course Authorial Design Studio (1), in the visual design department's corridor on the seventh floor of Hongmun Hall. The written process went up on the wall behind the unit. The unit died on the first day, and fixing it is how the SMD parts came off the board: the encoders' capacitors and resistors turned out not to be needed.", + images: [ + { src: '/builds/hongik/wall.jpg', alt: 'A Patternflow with a red pattern on a table in front of a wall of pinned-up process notes, sketches and printouts, an open case and loose parts beside it' }, + { src: '/builds/hongik/lit.jpg', alt: 'The same table from the side, a blue and pink pattern on the panel and the notes running on along a red wall' }, + ], + }, + { + id: 'usa-morelli', + title: 'A gift, for helping out', + category: 'builds', + slug: 'seunghun-philadelphia', + kind: 'build', + location: { lat: 39.9526, lng: -75.1652, label: 'Near Philadelphia, USA' }, + maker: 'Seunghun LEE', + date: '2026-09', + description: + 'Sent to Jake Morelli, at a studio near Philadelphia, who is helping out with Patternflow. One of three units built with what the first sale earned.', + }, + { + id: 'spain-vargas', + title: 'A gift, to Spain', + category: 'builds', + slug: 'seunghun-spain', + kind: 'build', + location: { lat: 40.4637, lng: -3.7492, label: 'Spain' }, + maker: 'Seunghun LEE', + date: '2026-09', + description: + 'Sent to José Manuel Vargas, in Spain. One of three units built with what the first sale earned.', + }, + { + id: 'uae-simonepda-mqtt', + title: 'Ajman to Seoul, over MQTT', + category: 'projects', + slug: 'simonepda-mqtt-uae', + kind: 'collaboration', + originId: 'seoul-v1', + location: { lat: 25.4052, lng: 55.5136, label: 'Ajman, UAE' }, + maker: 'SimonePDA', + date: '2026-09', + description: + "An MQTT link, tested with SimonePDA: a knob turned on SimonePDA's Patternflow in Ajman changed the panel of the first build in Seoul. flowlocal, a box that gives a Patternflow a hotspot of its own, comes out of the same work and is to be published soon.", + images: [ + { src: '/journal/the-messy-desk/flowlocal.jpg', alt: 'A flowlocal built in Seoul, held open: a battery cell and a small screen above the boards' }, + ], + }, + { + id: 'seoul-anbu', + title: 'Played at Anbu', + category: 'in-use', + slug: 'seunghun-anbu-seoul', + kind: 'showing', + originId: 'seoul-v1', + location: { lat: 37.5665, lng: 126.978, label: 'Seoul, Korea' }, + maker: 'Seunghun LEE', + date: '2026-10', + description: + "Anbu (안부), an exhibition by visually impaired artists near Seoul Station. Patternflow was not the exhibit. It was a corner where visitors played the artists' paintings, illustrations and sculptures as patterns: one knob picks the work, the other three play it, and the sound follows the knobs. How it was set up, and the code it ran, will be written up soon.", + images: [ + { src: '/builds/anbu/visitors.jpg', alt: "A visitor turning the knobs of a Patternflow lying flat on a white plinth, a painting on its panel and the artists' works on the wall behind" }, + { src: '/builds/anbu/demo.jpg', alt: 'A Patternflow lying flat beside a laptop running Ableton Live, an orange pixel picture of a creature in sunglasses on its panel' }, + ], + }, + { + id: 'seoul-hongdae', + title: 'A record bar in Hongdae', + category: 'in-use', + slug: 'seunghun-hongdae-seoul', + kind: 'showing', + originId: 'seoul-v1', + location: { lat: 37.5665, lng: 126.978, label: 'Seoul, Korea' }, + maker: 'Seunghun LEE', + date: '2026-10', + description: + 'A DJ night at a record bar in Hongdae. A Patternflow with the microphone fitted stood beside the turntables and moved with the music, the first time the microphone was used outside home. Photos: mondkim.', + images: [ + { src: '/builds/hongdae/decks.jpg', alt: 'A white Patternflow on the bar beside two turntables and a Pioneer mixer, a red and white tiled pattern on its panel' }, + { src: '/builds/hongdae/close.jpg', alt: 'Close on the Patternflow, red and white tiles on the panel, with the mixer and its cables behind' }, + { src: '/builds/hongdae/dj.jpg', alt: 'The DJ behind the decks with shelves of records at the back, a red swirl on the Patternflow in front' }, + ], + }, + { + id: 'uae-simonepda-lasercut', + title: 'Laser-cut case', + category: 'builds', + slug: 'simonepda-lasercut-uae', + kind: 'build', + location: { lat: 25.4052, lng: 55.5136, label: 'Ajman, UAE' }, + maker: 'SimonePDA', + date: '2026-10', + description: + 'A case drawn for the laser cutter, and cut three times, in clear acrylic and in MDF. One flat plate carries everything: the panel screws on from behind, and the board sits beside it under a small finger-jointed box. The drawing is in the repository for anyone to cut.', + links: [ + { href: 'https://github.com/engmung/Patternflow/tree/main/hardware/case/remixes/simonepda-lasercut', label: 'Case files on GitHub' }, + { href: '/build?case=simonepda-lasercut', label: 'Build this case' }, + ], + images: [ + { src: '/cases/simonepda-lasercut/acrylic_front.jpg', alt: "SimonePDA's laser-cut case in clear acrylic, upright: the LED panel on the left, the board and its four knobs to the right" }, + { src: '/cases/simonepda-lasercut/mdf_front.jpg', alt: 'The same case cut in MDF and turned on its side: the LED panel above, four knobs below it on the right' }, + { src: '/cases/simonepda-lasercut/mdf_back.jpg', alt: 'The MDF case from behind: screws through the sheet into the panel, four windows over the panel’s connectors, the board under an acrylic box' }, + { src: '/cases/simonepda-lasercut/board_in_box.jpg', alt: 'The v3.0 board under its finger-jointed acrylic box, wired at the two screw terminals' }, + ], + }, + { + id: 'russia-besoiobiy', + title: 'Lettered down the side', + category: 'builds', + slug: 'besoiobiy-russia', + kind: 'build', + location: { lat: 61.524, lng: 105.3188, label: 'Russia' }, + maker: 'Besoiobiy', + date: '2026-10', + description: + 'A printed case in two long pieces glued side by side: a frame around the panel, and a box for the board and the power bank with PATTERNFLOW lettered down it. Twelve M3 bolts clamp the frame, its covers and the panel together. The files are in the repository for anyone to print.', + links: [ + { href: 'https://github.com/engmung/Patternflow/tree/main/hardware/case/remixes/besoiobiy-printed', label: 'Case files on GitHub' }, + { href: '/build?case=besoiobiy-printed', label: 'Build this case' }, + ], + images: [ + { src: '/cases/besoiobiy-printed/built.jpg', alt: 'Besoiobiy’s build held upright: the lit LED panel on the left, the white box on the right with four black knobs at the top and PATTERNFLOW lettered down its side' }, + ], + }, + { + id: 'seoul-ddp', + title: 'At Seoul Design Week', + category: 'in-use', + slug: 'seunghun-ddp-seoul', + kind: 'showing', + originId: 'seoul-v1', + location: { lat: 37.5665, lng: 126.978, label: 'Seoul, Korea' }, + maker: 'Seunghun LEE', + date: '2026-10', + description: + 'Taken to the Seoul Design Week network party at DDP, Dongdaemun Design Plaza, on 16 October 2026, and left running on a table among the guests.', + images: [ + { src: '/builds/ddp/pedestal.jpg', alt: 'A Patternflow on a round table draped in purple, the DJs and stage lights in the haze behind it' }, + { src: '/builds/ddp/crowd.jpg', alt: 'Guests crowding round the Patternflow, two of them filming it on their phones' }, + { src: '/builds/ddp/phones.jpg', alt: 'A hand at the knobs while someone films the panel on a phone, a red and white tiled pattern lit' }, + { src: '/builds/ddp/room.jpg', alt: "The party around the Patternflow's table, the lit glass wall of the hall behind" }, + ], + }, + { + id: 'belgium-jonwtr', + title: 'Norns screen mirror', + category: 'projects', + slug: 'jonwtr-belgium', + kind: 'build', + location: { lat: 51.0543, lng: 3.7174, label: 'Ghent, Belgium' }, + maker: 'jonwtr', + date: '2026-10', + description: + 'Mirrors the screen of a shieldXL, a norns clone, onto the panel. Still a work in progress — laser-cut wood and a diffusing surface are next.', + images: [ + { src: '/builds/jonwtr/goldeneye.jpg', alt: 'The norns screen mirrored in red on the panel: two tape reels and 007_goldeneye.wav' }, + { src: '/builds/jonwtr/mirror.jpg', alt: "From above: the shieldXL's green pad screen mirrored on the panel, beside a grid controller" }, + { src: '/builds/jonwtr/surface.jpg', alt: 'A shieldXL and a Synthux Simple Touch resting on the glowing panel, played with both hands' }, + ], + }, + { + id: 'russia-dmitryleeo', + title: 'On air', + category: 'builds', + slug: 'dmitryleeo-russia', + kind: 'build', + location: { lat: 55.7558, lng: 37.6173, label: 'Moscow, Russia' }, + maker: "Dmitry Lee'O", + date: '2026-10', + description: + 'A black Patternflow connected to Cubase over RTP-MIDI and used in live performance. The maker plays original electronic music on YouTube, and the panel is joining the Friday stream.', + links: [ + { href: 'https://www.youtube.com/@dmitryleeo', label: 'youtube.com/@dmitryleeo' }, + ], images: [ - { src: '/builds/extraplojo/front.jpg', alt: "extraplojo's horizontal Patternflow from the front: a rainbow gradient on the panel, an orange side panel with four white knobs, and orange feet" }, - { src: '/builds/extraplojo/angle.jpg', alt: "extraplojo's Patternflow at an angle, blue and orange waves on the panel and a braided orange cable running off to the side" }, - { src: '/builds/extraplojo/inside.jpg', alt: "Inside extraplojo's case: the board in the orange control box beside the panel, with brass inserts at the corners" }, + { src: '/builds/dmitryleeo/on-air.jpg', alt: "Dmitry Lee'O's black Patternflow standing on a desk in a home studio, a red and white tile pattern on the panel and an ON AIR sign behind it" }, + { src: '/builds/dmitryleeo/space.jpg', alt: 'The same Patternflow on its side, a pixel-art spaceship and a planet on the panel' }, + { src: '/builds/dmitryleeo/studio.jpg', alt: 'The same Patternflow on its side between a mixer and a keyboard, a pixel-art platform game on the panel' }, ], }, ]; @@ -370,7 +594,8 @@ export function buildBySlug(slug: string): Build | undefined { return builds.find((build) => build.slug === slug); } -// The pin a collaboration or a sale hangs off — its own origin, or the first build. +// The pin a collaboration, a sale or a showing hangs off — its own origin, or +// the first build. export function originOf(build: Build): Build | undefined { const origin = builds.find((entry) => entry.id === (build.originId ?? builds[0]?.id)); return origin && origin.id !== build.id ? origin : undefined; diff --git a/web/src/components/sections/InsideGlobe/groups.test.ts b/web/src/components/sections/InsideGlobe/groups.test.ts index 896a792ce..512e9edd5 100644 --- a/web/src/components/sections/InsideGlobe/groups.test.ts +++ b/web/src/components/sections/InsideGlobe/groups.test.ts @@ -77,23 +77,28 @@ describe('grouping builds that sit too close to tell apart', () => { expect(backwards.map((group) => group.id).sort()).toEqual(forwards.map((group) => group.id).sort()); }); - it('reads the real map: the UK is three, France two, northern California three', () => { + it('reads the real map: Seoul is five, the UK three, France two, northern California three, Ajman three, Moscow two', () => { const multi = idsOf(groupBuilds(builds)) .filter((ids) => ids.length > 1) .map((ids) => [...ids].sort()); - expect(multi).toHaveLength(3); + expect(multi).toHaveLength(6); + expect(multi).toContainEqual(['seoul-anbu', 'seoul-ddp', 'seoul-hongdae', 'seoul-hongik', 'seoul-v1']); expect(multi).toContainEqual(['uk-nath', 'uk-saladman', 'uk-slowrush']); expect(multi).toContainEqual(['france-day', 'paris-v1']); expect(multi).toContainEqual(['california-lopez', 'sf-swartz', 'usa-jon']); + expect(multi).toContainEqual(['uae-simonepda', 'uae-simonepda-lasercut', 'uae-simonepda-mqtt']); + expect(multi).toContainEqual(['russia-dmitryleeo', 'russia-zuperzuche']); }); it('follows the filter: a group is only the entries on show', () => { const builtOnly = builds.filter((build) => build.category === 'builds'); const multi = idsOf(groupBuilds(builtOnly)).filter((ids) => ids.length > 1).map((ids) => [...ids].sort()); - // France loses its project and California its sale. + // France and Ajman lose their projects, California its sale, Seoul its showings. expect(multi).toContainEqual(['uk-nath', 'uk-saladman', 'uk-slowrush']); expect(multi).toContainEqual(['california-lopez', 'usa-jon']); - expect(multi).toHaveLength(2); + expect(multi).toContainEqual(['uae-simonepda', 'uae-simonepda-lasercut']); + expect(multi).toContainEqual(['russia-dmitryleeo', 'russia-zuperzuche']); + expect(multi).toHaveLength(4); }); }); @@ -104,7 +109,7 @@ describe('the count beside the list', () => { expect(named('Leeds, UK')).toBe('United Kingdom'); expect(named('United Kingdom')).toBe('United Kingdom'); expect(named('San Francisco, USA')).toBe('USA'); - expect(countCountries(builds)).toBe(11); + expect(countCountries(builds)).toBe(14); }); }); diff --git a/web/src/components/sections/InsidePanel.module.css b/web/src/components/sections/InsidePanel.module.css index 7a61ef556..fea5752a0 100644 --- a/web/src/components/sections/InsidePanel.module.css +++ b/web/src/components/sections/InsidePanel.module.css @@ -35,8 +35,13 @@ .joinCell:hover, .joinCell:focus-visible { - outline: 0; background: var(--pf-cream-2); + text-decoration: none; +} + +.joinCell:focus-visible { + outline: 2px solid var(--pf-led); + outline-offset: -2px; } .joinKicker { diff --git a/web/src/components/sections/InsidePanel.test.tsx b/web/src/components/sections/InsidePanel.test.tsx new file mode 100644 index 000000000..ed9b30cf2 --- /dev/null +++ b/web/src/components/sections/InsidePanel.test.tsx @@ -0,0 +1,27 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import type { SectionContent } from '@/lib/content'; +import { useAppStore } from '@/store/useAppStore'; +import InsidePanel from './InsidePanel'; + +// The Inside tab. On /inside the hero's h1 is inert (RightPanel.tsx), so the +// panel's title is the page's h1. + +const content: SectionContent = { title: 'Inside the work.', subtitle: '', content: '' }; + +beforeEach(() => { + useAppStore.setState({ selectedBuildId: null, insideFilter: 'all' }); + window.history.replaceState(null, '', '/inside'); +}); + +describe('InsidePanel', () => { + it('titles the page with its one h1', () => { + render(<InsidePanel content={content} />); + expect(screen.getAllByRole('heading', { level: 1 }).map((h) => h.textContent)).toEqual(['Inside the work.']); + }); + + it('falls back to its own title when the content has none', () => { + render(<InsidePanel content={{ ...content, title: '' }} />); + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Inside the work.'); + }); +}); diff --git a/web/src/components/sections/InsidePanel.tsx b/web/src/components/sections/InsidePanel.tsx index 28a6c4d0e..507942597 100644 --- a/web/src/components/sections/InsidePanel.tsx +++ b/web/src/components/sections/InsidePanel.tsx @@ -51,7 +51,7 @@ export default function InsidePanel({ content }: InsidePanelProps) { return ( <div className="panel-content pf-section-panel" id="inside" aria-label={content.title}> <div className="panel-header"> - <h2 className="pf-h2">{content.title || 'Inside the work.'}</h2> + <h1 className="pf-h2">{content.title || 'Inside the work.'}</h1> <p className="pf-sub">{content.subtitle}</p> <div className={styles.joinBand} aria-label="Ways to join Patternflow"> {JOIN.map((row) => { diff --git a/web/src/components/sections/PatternPanel.module.css b/web/src/components/sections/PatternPanel.module.css index 3e9eac6c1..f47c0b9ef 100644 --- a/web/src/components/sections/PatternPanel.module.css +++ b/web/src/components/sections/PatternPanel.module.css @@ -25,6 +25,28 @@ overflow: hidden; } +/* The editor's height. Monaco fills this box and scrolls inside it, so the + page does not grow with the code: most visitors paste generated code and + rarely scroll the source themselves. */ +.editorBox { + height: 480px; +} + +/* The message, and on its own line the line of code it failed on when the + stack gives one. */ +.editorError { + margin: 0; + padding: 10px 14px; + border-top: 1px solid var(--pf-rule); + background: var(--pf-cream); + color: var(--pf-danger); + font-family: var(--pf-mono); + font-size: var(--pf-fs-mono); + line-height: 1.5; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + /* One line under the editor instead of four numbered steps: what to do here, and the single button that belongs to it. */ .editorFoot { @@ -233,6 +255,7 @@ .nextCard:hover { background: var(--pf-cream-2); + text-decoration: none; } .nextKicker { @@ -243,7 +266,7 @@ letter-spacing: var(--pf-track-mono); } -.nextCard h3 { +.nextCard h2 { margin: 0; font-size: var(--pf-fs-row); font-weight: 500; @@ -378,9 +401,48 @@ } +/* Below 900px the 3D preview and the tab bar are pinned over the top of the + page (--mb-viewer-h and --mb-nav-h in globals.css), which leaves 421px of a + 390x844 phone. A finger on the editor scrolls the code and never the page, + and at 480px there were scroll positions where the editor was all of those + 421px, so there was nowhere to put a finger that moved the page. The editor + now leaves room for the preset bar's two rows (90px with its rules), and the + bar and the code fit on the screen together. svh keeps it fitting while the + browser's toolbar is showing; the vh line is for browsers without svh. */ +@media (max-width: 900px) { + .editorBox { + height: clamp(120px, calc(100vh - var(--mb-viewer-h) - var(--mb-nav-h) - 90px), 480px); + height: clamp(120px, calc(100svh - var(--mb-viewer-h) - var(--mb-nav-h) - 90px), 480px); + } +} + /* The hardware block drops under the exits when the panel gets narrow. */ @container (max-width: 620px) { .branches { grid-template-columns: 1fr; } } + +/* One row needs about 575px: 261 for the arrows and both buttons, and the + name cell around "01 / 42 Wave Saw on the device", the widest name. A + phone's column is 296px at 390, and there the name cell came out 36px wide + and showed "01" and nothing else. Below that width the name takes the top + row alone and the four buttons share the row under it, in their old order, + so Tab still goes left to right. The total goes, since "All 42" is the + next row down, and that leaves every name room at 390. */ +@container (max-width: 580px) { + .presetBar { + grid-template-columns: 48px 48px minmax(0, 1fr) minmax(0, 1fr); + } + + .presetNow { + grid-row: 1; + grid-column: 1 / -1; + border-right: 0; + border-bottom: 1px solid var(--pf-rule); + } + + .presetTotal { + display: none; + } +} diff --git a/web/src/components/sections/PatternPanel.test.tsx b/web/src/components/sections/PatternPanel.test.tsx new file mode 100644 index 000000000..2223eb7e0 --- /dev/null +++ b/web/src/components/sections/PatternPanel.test.tsx @@ -0,0 +1,170 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, render, screen } from '@testing-library/react'; +import type { SectionContent } from '@/lib/content'; +import { useAppStore } from '@/store/useAppStore'; +import { showcasePresets } from '@/lib/presets'; +import PatternPanel from './PatternPanel'; + +// The Live Editor on /pattern. The preview reports why pasted code is not +// running (components/3d/LedMatrixTexture.ts); the panel shows it under the +// editor. The panel is mounted on every tab of the home view, but the editor +// and the flasher script only once the Pattern tab has been opened. + +vi.mock('@monaco-editor/react', () => ({ + default: ({ height }: { height?: string | number }) => <textarea aria-label="Code" data-height={height} />, +})); +vi.mock('next/script', () => ({ default: ({ src }: { src: string }) => <span data-script={src} /> })); +vi.mock('@/lib/posthogEvents', () => ({ captureEvent: () => {} })); + +const content: SectionContent = { title: 'Pattern', subtitle: '', content: '' }; + +beforeEach(() => { + vi.stubGlobal('matchMedia', (query: string) => ({ + media: query, + matches: false, + addEventListener: () => {}, + removeEventListener: () => {}, + })); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + useAppStore.getState().setCustomJsError(null); +}); + +describe('PatternPanel', () => { + it('shows why the code is not running under the editor, and clears it', () => { + render(<PatternPanel content={content} isActive />); + const status = screen.getByRole('status'); + expect(status).toBeEmptyDOMElement(); + + act(() => useAppStore.getState().setCustomJsError('missing is not defined\nline 6: const v = missing * 2;')); + expect(status).toHaveTextContent('missing is not defined line 6: const v = missing * 2;'); + + act(() => useAppStore.getState().setCustomJsError(null)); + expect(status).toBeEmptyDOMElement(); + }); + + it('leaves the editor and the flasher script out until the tab first opens, then keeps them', () => { + const flasher = () => document.querySelector('[data-script*="esp-web-tools"]'); + const { rerender } = render(<PatternPanel content={content} isActive={false} />); + expect(screen.queryByRole('textbox', { name: 'Code' })).toBeNull(); + expect(flasher()).toBeNull(); + + rerender(<PatternPanel content={content} isActive />); + expect(screen.getByRole('textbox', { name: 'Code' })).toBeInTheDocument(); + expect(flasher()).not.toBeNull(); + + rerender(<PatternPanel content={content} isActive={false} />); + expect(screen.getByRole('textbox', { name: 'Code' })).toBeInTheDocument(); + expect(flasher()).not.toBeNull(); + }); + + // The height lives in CSS (.editorBox), where the phone rule can shrink it + // to fit under the pinned 3D preview. A pixel height on the editor itself + // would win over that rule. + it('fills one box with the editor, the same box before and after the tab first opens', () => { + const { container, rerender } = render(<PatternPanel content={content} isActive={false} />); + const box = container.querySelector('.editorBox'); + expect(box).toBeEmptyDOMElement(); + + rerender(<PatternPanel content={content} isActive />); + const editor = screen.getByRole('textbox', { name: 'Code' }); + expect(editor.parentElement).toBe(box); + expect(editor).toHaveAttribute('data-height', '100%'); + }); + + it('copies a creation prompt with the code in the editor and the step one detent moves Knob 2', async () => { + const writeText = vi.fn<(text: string) => Promise<void>>(() => Promise.resolve()); + Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true }); + const alert = vi.fn(); + vi.stubGlobal('alert', alert); + try { + render(<PatternPanel content={content} isActive />); + act(() => useAppStore.getState().setCustomJsCode('export function draw(display) {}')); + screen.getByRole('button', { name: 'Copy creation prompt' }).click(); + + const prompt = writeText.mock.calls[0]?.[0] ?? ''; + expect(prompt).toContain('- Knob 2: 0.1 to 10.0, clamps at edges, step 0.206 per detent.'); + expect(prompt).toContain('```javascript\nexport function draw(display) {}\n```'); + await act(async () => {}); + expect(alert).toHaveBeenCalledTimes(1); + expect(alert.mock.calls[0]?.[0]).toMatch(/^Creation prompt copied to clipboard!/); + } finally { + delete (navigator as { clipboard?: unknown }).clipboard; + } + }); + + // The alert used to go up before the write had finished, so a browser that + // refused it still said the prompt was copied. + it('says Copy failed instead of copied when the browser refuses the write', async () => { + const writeText = vi.fn<(text: string) => Promise<void>>(() => + Promise.reject(new DOMException('Write permission denied.', 'NotAllowedError')), + ); + Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true }); + const alert = vi.fn(); + vi.stubGlobal('alert', alert); + try { + render(<PatternPanel content={content} isActive />); + screen.getByRole('button', { name: 'Copy creation prompt' }).click(); + await act(async () => {}); + + expect(alert).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: 'Copy failed' })).toBeInTheDocument(); + } finally { + delete (navigator as { clipboard?: unknown }).clipboard; + } + }); + + it('says Copy failed on a page with no clipboard at all, and comes back to its label', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + const alert = vi.fn(); + vi.stubGlobal('alert', alert); + try { + render(<PatternPanel content={content} isActive />); + screen.getByRole('button', { name: 'Copy creation prompt' }).click(); + await act(async () => {}); + + expect(alert).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: 'Copy failed' })).toBeInTheDocument(); + act(() => vi.advanceTimersByTime(1400)); + expect(screen.getByRole('button', { name: 'Copy creation prompt' })).toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }); + + // The build server bakes a .pfm on its own machine; nothing compiles in the + // browser. Firmware is a PlatformIO build, which the firmware README puts + // first. + it('says where the build server compiles, and sends firmware builds to PlatformIO', () => { + render(<PatternPanel content={content} isActive />); + const link = screen.getByRole('link', { name: 'PlatformIO' }); + expect(link).toHaveAttribute( + 'href', + 'https://github.com/engmung/Patternflow/blob/main/firmware/README.md#build-with-platformio-recommended', + ); + const note = link.closest('p'); + expect(note).toHaveTextContent('compiles your pattern into a module on its own machine'); + expect(note).not.toHaveTextContent('in the browser'); + }); + + it('points the hero preview at Origin while the tab is still closed', () => { + useAppStore.getState().setActivePatternId('patternFlowOriginal'); + useAppStore.getState().setCustomJsCode(''); + render(<PatternPanel content={content} isActive={false} />); + expect(useAppStore.getState().activePatternId).toBe('custom'); + expect(useAppStore.getState().customJsCode).toBe(showcasePresets.find((p) => p.id === 'origin')?.code); + }); + + // On /pattern the hero's h1 is inert (RightPanel.tsx), so the panel's title + // is the page's h1 and the two cards after the editor sit under it. + it('titles the page with an h1 and the next steps with h2s', () => { + render(<PatternPanel content={content} isActive />); + expect(screen.getAllByRole('heading', { level: 1 }).map((h) => h.textContent)).toEqual(['Pattern']); + expect(screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent)).toEqual([ + 'Pattern Lab ↗', + 'Community ↗', + ]); + }); +}); diff --git a/web/src/components/sections/PatternPanel.tsx b/web/src/components/sections/PatternPanel.tsx index 6ee519ab9..e62baf492 100644 --- a/web/src/components/sections/PatternPanel.tsx +++ b/web/src/components/sections/PatternPanel.tsx @@ -10,72 +10,9 @@ import { showcasePresets } from '@/lib/presets'; import { captureEvent } from '@/lib/posthogEvents'; import { communityHref } from '@/lib/community/apiBase'; import { useMediaQuery } from '@/lib/hooks/useMediaQuery'; +import { useResettingState } from '@/lib/hooks/useResettingState'; import styles from './PatternPanel.module.css'; - -const getVariantPrompt = (code: string) => `I am writing custom LED patterns in JavaScript for Patternflow's 128x64 LED matrix web preview. - -I will give you one existing Patternflow pattern. Use it as a seed, not as a cage. Create exactly 5 distinct standalone variations that explore different visual directions. - -Very important output rules: -- Return exactly 5 separate JavaScript code blocks. -- Each code block must be a complete standalone Patternflow pattern. -- Do not combine the 5 variations into one file. -- Do not add a mode selector, preset array, switch statement, or any code that contains multiple patterns in one output. -- Do not write wrapper text inside the code blocks. -- Put a short variation name before each code block. -- Do not include nested triple backticks inside any code block. - -Required API for every variation: -- export function setup(params) {} -- export function update(dt, input, params) {} -- export function draw(display, params, time) {} -- Use input.knobValues as the primary control API. input.knobValues is an array of 4 absolute knob values after the min/max ranges are applied. -- input.knobNormalized is also available when a 0.0-1.0 value is useful. -- Keep input.knobDeltas only as compatibility fallback if needed. -- Use display.width and display.height in loops. Do not hardcode 128 or 64 inside draw(). -- Use only plain JavaScript and Math.*. No browser APIs, DOM APIs, imports, async code, external libraries, dynamic evaluation, or per-pixel allocations. - -Creative control mapping: -- It is okay to keep one knob as animation speed, preferably Knob 2, if that suits the variation. -- Do not keep all four knobs as the same old hue/speed/mode/frequency template unless it is genuinely the best fit. -- Redesign the other controls creatively for each variation. Examples: cell size, symmetry fold, glitch amount, palette split, trail length, scanline spacing, pulse width, inversion threshold, rotation, warp depth, density, edge thickness, phase offset, bloom-like gain, or motif selection. -- Each of the 5 variations should have a slightly different control personality. The controls should reveal the unique idea of that variation. -- Include a short comment near setup() or update() naming what the 4 knobs do for that specific variation. - -Color direction: -- Make color part of the pattern logic, not just a global hue wash. -- Avoid relying on a single full-frame gradient or a uniform hue shift across the whole image. -- Prefer colors that respond to local pattern values: distance fields, cell seeds, stripe index, phase, brightness, threshold bands, motion direction, edge thickness, density, or mask state. -- Good examples: large values become red while small values become blue; interior/exterior use different palettes; threshold bands step through 3-5 colors; cell IDs pick related colors; moving fronts leave warmer highlights; thin edges are white while filled regions are saturated. -- Both smooth local gradients and stepped posterized color bands are welcome, as long as the color changes are tied to the geometry or signal of the pattern. -- Keep at least some pixels near full LED brightness. - -Variation direction: -- Keep the general intent and the four control roles understandable, but do not copy the original structure too literally. -- At least 3 of the 5 variations must change the main drawing algorithm, not only constants, colors, thresholds, or speed. -- Avoid making all 5 outputs feel like the same pattern with different parameter values. -- Do not reuse the same grid, shape, distance formula, or composition in every variation. -- Give each variation a different dominant idea. Use these five directions: - 1. Structural remix: change the main geometry or repetition system. - 2. Motion remix: change how time moves through the pattern. - 3. Palette/material remix: change color logic, brightness rhythm, or foreground/background relationship. - 4. Domain remix: warp, mirror, fold, scroll, rotate, or otherwise remap coordinates. - 5. Contrast remix: make a clearly different sparse/dense, hard/soft, or organic/mechanical interpretation. -- The variations can be bold. They should still feel related to the seed, but not trapped inside its exact look. -- Keep the patterns bright enough for an LED matrix and reasonably ESP32-friendly. -- Avoid smoothing/lerping knob-controlled values unless the visual idea specifically needs inertia. - -Knob ranges for the Patternflow live editor: -- Knob 1: 0.0 to 1.0, wraps at edges, default step 0.05 per detent. -- Knob 2: 0.1 to 10.0, clamps at edges, default step 0.10 per detent. -- Knob 3: 0.0 to 4.9, clamps at edges, default step 0.05 per detent. -- Knob 4: 0.0 to 1.0, wraps at edges, default step 0.05 per detent. - -Existing pattern: -\`\`\`javascript -${code} -\`\`\``; - +import { getVariantPrompt } from './variantPrompt'; type EspWebInstallButtonProps = { children: React.ReactNode; @@ -86,14 +23,10 @@ const EspWebInstallButton = 'esp-web-install-button' as unknown as React.Element interface PatternPanelProps { content: SectionContent; + isActive: boolean; } const EDITOR_LINE_HEIGHT = 20; -// Fixed editor height — most visitors only paste AI-generated code and -// rarely scroll the source themselves, so growing the page with line -// count just pushed the rest of the section out of view. The editor -// now scrolls internally instead. -const EDITOR_HEIGHT = 480; // Phones show a sliding window of preset numbers around the active one instead // of the whole library — all 42 would wrap into a wall of rows. Keep in sync @@ -110,13 +43,14 @@ const MOBILE_PRESET_WINDOW = 7; // now; the rest arrive as modules. const NUM_BASICS_PATTERNS = 33; -export default function PatternPanel({ content }: PatternPanelProps) { +export default function PatternPanel({ content, isActive }: PatternPanelProps) { // Start with Origin selected; the effect below loads it into the editor. const [activePresetId, setActivePresetId] = useState<string | null>(() => showcasePresets.some((p) => p.id === 'origin') ? 'origin' : null, ); const customJsCode = useAppStore(state => state.customJsCode); const setCustomJsCode = useAppStore(state => state.setCustomJsCode); + const customJsError = useAppStore(state => state.customJsError); // The Live Editor renders through the 'custom' pattern, so the 3D preview // reflects the editor (and loaded presets). Load Origin into the editor @@ -127,9 +61,21 @@ export default function PatternPanel({ content }: PatternPanelProps) { if (origin) { setCustomJsCode(origin.code); } - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps -- once on mount; a later run would put Origin over the reader's own code }, []); + // The panel is mounted on /, /build and /inside too, folded away, so the + // effect above can point the hero preview at Origin. The editor and the + // flasher are not: Monaco comes from jsdelivr, 4.2 MB once its loader is + // in, and the flasher from unpkg. Both wait for the first time the tab + // opens. After that they stay, so going back to the tab finds the editor + // as it was left, undo history included. Latched while rendering, so the + // editor is there in the render that opens the tab. + const [opened, setOpened] = useState(isActive); + if (isActive && !opened) { + setOpened(true); + } + const handleLoadPreset = (presetId: string) => { const preset = showcasePresets.find((p) => p.id === presetId); if (!preset) return; @@ -186,18 +132,28 @@ export default function PatternPanel({ content }: PatternPanelProps) { const [showAllPresets, setShowAllPresets] = useState(false); const activePreset = activePresetIndex >= 0 ? showcasePresets[activePresetIndex] : null; - const handleCopyVariantPrompt = () => { - navigator.clipboard.writeText(getVariantPrompt(customJsCode)); - captureEvent('copy_variants_prompt_clicked', { - surface: 'live_editor', - }); + // The alert says the prompt is on the clipboard, so it waits for the write. + // When the browser refuses it (no permission, a page served over plain + // http), the button says "Copy failed" for a moment instead. + const [copyFailed, flashCopyFailed] = useResettingState(false, 1400); + const handleCopyVariantPrompt = async () => { + try { + const copying = navigator.clipboard.writeText(getVariantPrompt(customJsCode)); + captureEvent('copy_variants_prompt_clicked', { + surface: 'live_editor', + }); + await copying; + } catch { + flashCopyFailed(true); + return; + } alert('Creation prompt copied to clipboard! Paste it in ChatGPT/Claude to get 5 pattern variations.'); }; return ( <div className="panel-content pf-section-panel" id="pattern"> <div className="panel-header"> - <h2 className="pf-h2">{content.title}</h2> + <h1 className="pf-h2">{content.title}</h1> <p className="pf-sub">{content.subtitle}</p> </div> <div className="panel-body"> @@ -220,11 +176,13 @@ export default function PatternPanel({ content }: PatternPanelProps) { </div> )} - <Script - type="module" - src="https://unpkg.com/esp-web-tools@10/dist/web/install-button.js?module" - strategy="lazyOnload" - /> + {opened && ( + <Script + type="module" + src="https://unpkg.com/esp-web-tools@10/dist/web/install-button.js?module" + strategy="lazyOnload" + /> + )} <div className={styles.workspace}> {/* No tab bar: two of its three cells navigated to other pages, so it @@ -239,7 +197,9 @@ export default function PatternPanel({ content }: PatternPanelProps) { </button> <div className={styles.presetNow}> <span className={styles.presetIndex}> - {String(activePresetIndex >= 0 ? activePresetIndex + 1 : 0).padStart(2, '0')} / {showcasePresets.length} + {String(activePresetIndex >= 0 ? activePresetIndex + 1 : 0).padStart(2, '0')} + {/* Dropped on phones, where "All 42" sits right under it. */} + <span className={styles.presetTotal}> / {showcasePresets.length}</span> </span> <span className={styles.presetName}>{activePreset?.name ?? 'Custom'}</span> {/* The one accent in this panel: it means "this is what the @@ -280,35 +240,48 @@ export default function PatternPanel({ content }: PatternPanelProps) { })} </div> )} - <Editor - height={EDITOR_HEIGHT} - defaultLanguage="javascript" - theme="vs-dark" - value={customJsCode} - onChange={(val) => { - const next = val || ''; - setCustomJsCode(next); - // Deselect the chip once the code is edited away from the preset. - if (activePresetId) { - const preset = showcasePresets.find((p) => p.id === activePresetId); - if (!preset || preset.code !== next) setActivePresetId(null); - } - }} - options={{ - minimap: { enabled: false }, - fontSize: 14, - lineHeight: EDITOR_LINE_HEIGHT, - scrollBeyondLastLine: false, - scrollbar: { - vertical: 'auto', - horizontal: 'auto', - handleMouseWheel: true, - }, - overviewRulerLanes: 0, - hideCursorInOverviewRuler: true, - automaticLayout: true, - }} - /> + {/* The box holds the editor's height, before Monaco loads and + after, so the page does not jump when the tab first opens. + The CSS shrinks it on phones (.editorBox). */} + <div className={styles.editorBox}> + {opened && ( + <Editor + height="100%" + defaultLanguage="javascript" + theme="vs-dark" + value={customJsCode} + onChange={(val) => { + const next = val || ''; + setCustomJsCode(next); + // Deselect the chip once the code is edited away from the preset. + if (activePresetId) { + const preset = showcasePresets.find((p) => p.id === activePresetId); + if (!preset || preset.code !== next) setActivePresetId(null); + } + }} + options={{ + minimap: { enabled: false }, + fontSize: 14, + lineHeight: EDITOR_LINE_HEIGHT, + scrollBeyondLastLine: false, + scrollbar: { + vertical: 'auto', + horizontal: 'auto', + handleMouseWheel: true, + }, + overviewRulerLanes: 0, + hideCursorInOverviewRuler: true, + automaticLayout: true, + }} + /> + )} + </div> + {/* Why the code above is not what the preview shows. Pasted code + that threw used to reach the browser console and nothing + else. The region stays mounted so a reader hears it change. */} + <div role="status"> + {customJsError && <pre className={styles.editorError}>{customJsError}</pre>} + </div> {/* The four numbered steps collapsed to this: what the editor is for, and the one button that belongs next to it. Everything about hardware moved to its own block below. */} @@ -317,8 +290,8 @@ export default function PatternPanel({ content }: PatternPanelProps) { Paste AI-generated code here, or edit the preset. The preview updates as you type. </span> - <button type="button" onClick={handleCopyVariantPrompt}> - Copy creation prompt + <button type="button" onClick={() => void handleCopyVariantPrompt()}> + {copyFailed ? 'Copy failed' : 'Copy creation prompt'} </button> </div> </div> @@ -331,24 +304,31 @@ export default function PatternPanel({ content }: PatternPanelProps) { <span className="pf-kicker">Where it goes next</span> <Link href="/pattern-lab" className={styles.nextCard}> <span className={styles.nextKicker}>01</span> - <h3>Pattern Lab ↗</h3> + <h2>Pattern Lab ↗</h2> <p>The full studio — batch generation, color ramps, and ESP32 C++ compile.</p> </Link> <Link href={communityHref()} className={styles.nextCard}> <span className={styles.nextKicker}>02</span> - <h3>Community ↗</h3> + <h2>Community ↗</h2> <p>Share what you make, fork what everyone else made.</p> </Link> + {/* The build server makes a .pfm on its own machine + (lib/community/server/buildJobs.ts) and the board's Patterns + page installs it. Firmware still has to be built for a new + feature or a panel other than the stock one (PANEL_GEOMETRY + and PANEL_PROFILE in firmware/patternflow/config.h), and the + firmware README puts PlatformIO before the Arduino IDE. */} <p className={styles.advancedNote}> - The build server compiles your pattern into firmware in the browser. The{' '} + The build server compiles your pattern into a module on its own machine, and + your board installs it over Wi-Fi. You build the firmware yourself, with{' '} <a - href="https://github.com/engmung/Patternflow/blob/main/firmware/README.md" + href="https://github.com/engmung/Patternflow/blob/main/firmware/README.md#build-with-platformio-recommended" target="_blank" rel="noopener noreferrer" > - Arduino IDE - </a>{' '} - is only for new firmware features or a different matrix size. + PlatformIO + </a> + , only for a new firmware feature or a panel other than the stock 128×64. </p> </div> @@ -371,19 +351,28 @@ export default function PatternPanel({ content }: PatternPanelProps) { Browser flashing works in desktop Chrome or Edge. </div> </EspWebInstallButton> - {/* Kept to one line. A first install can go wrong at the port, - at the pattern count and at Wi-Fi, but a note under a button - is the wrong place to answer all three — the guide holds it, - and the link is how you get there. */} + {/* Kept short. A first install goes wrong in three places: the + port, the order (download mode before the button, every first + flash) and the Wi-Fi step, which usually doesn't appear after + a download-mode install. A note under a button can name the + moves, in order, but is the wrong place to walk through them — + the guide holds that, and the link is how you get there. "A + first flash" because download mode erases everything, + patterns and saved Wi-Fi too: a board already running + Patternflow updates over Wi-Fi from its console's Update page + instead. */} <p className={styles.hardwareNote}> - Plug the ESP32-S3 into the <b>left</b> USB-C port. Sets up Wi-Fi and boots into - Origin, then{' '} + For a first flash, before you press it: the ESP32-S3 out of the board, a data cable + in its <b>left</b> USB-C port. Hold <b>BOOT</b>, tap <b>RST</b>, let go of BOOT, + then press Flash Patternflow. You get Origin, and{' '} <a href="https://community.patternflow.work/community/decks">the Basics pack</a>{' '} adds {NUM_BASICS_PATTERNS} more in one click. </p> <p className={styles.hardwareNote}> - <Link href="/guide/play#flash">The guide</Link> shows each step on a board you can - turn: the port, the Wi-Fi, and what to do if the board doesn’t show up ( + The Wi-Fi step usually doesn’t appear after the install, and that’s + expected: press <b>RST</b>, press Flash Patternflow again, pick the port and choose + Connect to Wi-Fi. <Link href="/guide/play#flash">The guide</Link> shows every screen + on a board you can turn ( <a href="https://github.com/engmung/Patternflow/blob/main/BUILD_GUIDE.md#8-firmware" target="_blank" diff --git a/web/src/components/sections/RightPanel.test.tsx b/web/src/components/sections/RightPanel.test.tsx new file mode 100644 index 000000000..9b87d9f6e --- /dev/null +++ b/web/src/components/sections/RightPanel.test.tsx @@ -0,0 +1,112 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, within } from '@testing-library/react'; +import type { SectionContent } from '@/lib/content'; +import RightPanel from './RightPanel'; + +// The section tabs on /, /build, /pattern and /inside. The desktop column and +// the phone's bar are two navs with one label, and CSS shows one at a time; +// either way the open tab reads as pressed, its ✕ stays out of its name, and +// the panel under them is the page's main landmark. + +vi.mock('./Hero', () => ({ default: () => <section>hero</section> })); +vi.mock('./Milestones', () => ({ default: () => null })); +vi.mock('./Sponsor', () => ({ default: () => null })); +vi.mock('../layout/Footer', () => ({ default: () => null })); +vi.mock('./BuildPanel', () => ({ default: () => <section>build panel</section> })); +vi.mock('./PatternPanel', () => ({ + default: ({ isActive }: { isActive: boolean }) => ( + <section data-testid="pattern-panel" data-active={String(isActive)}> + pattern panel + </section> + ), +})); +vi.mock('./InsidePanel', () => ({ default: () => <section>inside panel</section> })); +vi.mock('@/lib/posthogEvents', () => ({ captureEvent: () => {} })); + +const content: SectionContent = { title: '', subtitle: '', content: '' }; + +beforeEach(() => { + vi.stubGlobal('matchMedia', (query: string) => ({ + media: query, + matches: false, + addEventListener: () => {}, + removeEventListener: () => {}, + })); + vi.stubGlobal('scrollTo', () => {}); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function renderAt(path: string, tab: 'hero' | 'build' | 'pattern' | 'inside') { + window.history.replaceState(null, '', path); + return render( + <RightPanel initialTab={tab} buildContent={content} patternContent={content} insideContent={content} />, + ); +} + +const pressed = (nav: HTMLElement) => + within(nav) + .getAllByRole('button') + .filter((b) => b.getAttribute('aria-pressed') === 'true') + .map((b) => b.textContent?.replace('✕', '')); + +describe('RightPanel', () => { + it('names both tab rows Sections and marks the open tab pressed in each', () => { + renderAt('/build', 'build'); + const navs = screen.getAllByRole('navigation', { name: 'Sections' }); + expect(navs).toHaveLength(2); + for (const nav of navs) { + expect(pressed(nav)).toEqual(['Build']); + for (const name of ['Pattern', 'Inside']) { + expect(within(nav).getByRole('button', { name })).toHaveAttribute('aria-pressed', 'false'); + } + } + }); + + it('keeps the ✕ on the open desktop tab out of its name', () => { + renderAt('/build', 'build'); + const [desktop] = screen.getAllByRole('navigation', { name: 'Sections' }); + const build = within(desktop).getByRole('button', { name: 'Build' }); + expect(build).toHaveTextContent('✕'); + expect(build).toHaveAttribute('aria-pressed', 'true'); + }); + + it('moves pressed with a click, and a second click on the open tab closes it', () => { + renderAt('/', 'hero'); + const [desktop, phone] = screen.getAllByRole('navigation', { name: 'Sections' }); + expect(pressed(desktop)).toEqual([]); + + fireEvent.click(within(desktop).getByRole('button', { name: 'Pattern' })); + expect(pressed(desktop)).toEqual(['Pattern']); + expect(pressed(phone)).toEqual(['Pattern']); + expect(window.location.pathname).toBe('/pattern'); + + fireEvent.click(within(desktop).getByRole('button', { name: 'Pattern' })); + expect(pressed(desktop)).toEqual([]); + expect(window.location.pathname).toBe('/'); + }); + + it('puts the panels in one main, outside the tabs', () => { + renderAt('/inside', 'inside'); + const main = screen.getByRole('main'); + expect(main).toHaveTextContent('inside panel'); + for (const nav of screen.getAllByRole('navigation', { name: 'Sections' })) { + expect(main.contains(nav)).toBe(false); + } + }); + + it('tells the Pattern panel when its tab is open, so the editor waits for it', () => { + renderAt('/', 'hero'); + const panel = screen.getByTestId('pattern-panel'); + expect(panel).toHaveAttribute('data-active', 'false'); + + const [desktop] = screen.getAllByRole('navigation', { name: 'Sections' }); + fireEvent.click(within(desktop).getByRole('button', { name: 'Pattern' })); + expect(panel).toHaveAttribute('data-active', 'true'); + + fireEvent.click(within(desktop).getByRole('button', { name: 'Build' })); + expect(panel).toHaveAttribute('data-active', 'false'); + }); +}); diff --git a/web/src/components/sections/RightPanel.tsx b/web/src/components/sections/RightPanel.tsx index 3a0743e7a..b35e532b9 100644 --- a/web/src/components/sections/RightPanel.tsx +++ b/web/src/components/sections/RightPanel.tsx @@ -45,7 +45,7 @@ function tabFromPath(pathname: string): TabType { export default function RightPanel({ initialTab = 'hero', buildContent, patternContent, insideContent }: RightPanelProps) { const [activeTab, setActiveTab] = useState<TabType>(initialTab); const [buildPanelKey, setBuildPanelKey] = useState(0); - const contentRef = useRef<HTMLDivElement>(null); + const contentRef = useRef<HTMLElement>(null); const activeTabRef = useRef<TabType>(initialTab); // Apply a tab change to local + global state (no URL update). @@ -130,37 +130,45 @@ export default function RightPanel({ initialTab = 'hero', buildContent, patternC return ( <div className="right-panel-layout"> - <div className="vertical-nav"> + {/* The desktop column. It carries the phone bar's label: CSS shows one + of the two, so a screen reader meets one Sections nav either way. The + open tab is pressed, as on the phone, and its ✕ is only drawn. */} + <nav className="vertical-nav" aria-label="Sections"> <button className={`v-tab-btn ${activeTab === 'build' ? 'active' : ''}`} + aria-pressed={activeTab === 'build'} onClick={() => handleTabClick('build')} > - {activeTab === 'build' && <span className="close-icon">✕</span>} + {activeTab === 'build' && <span className="close-icon" aria-hidden="true">✕</span>} <span className="tab-text" data-mobile-label="1. Build">Build</span> </button> <button className={`v-tab-btn ${activeTab === 'pattern' ? 'active' : ''}`} + aria-pressed={activeTab === 'pattern'} onClick={() => handleTabClick('pattern')} > - {activeTab === 'pattern' && <span className="close-icon">✕</span>} + {activeTab === 'pattern' && <span className="close-icon" aria-hidden="true">✕</span>} <span className="tab-text" data-mobile-label="2. Pattern">Pattern</span> </button> <button className={`v-tab-btn ${activeTab === 'inside' ? 'active' : ''}`} + aria-pressed={activeTab === 'inside'} onClick={() => handleTabClick('inside')} > - {activeTab === 'inside' && <span className="close-icon">✕</span>} + {activeTab === 'inside' && <span className="close-icon" aria-hidden="true">✕</span>} <span className="tab-text" data-mobile-label="3. Inside">Inside</span> </button> - </div> + </nav> - <div className={`content-panel ${activeTab !== 'hero' ? 'bg-white viewer-open' : ''}`} ref={contentRef}> + <main className={`content-panel ${activeTab !== 'hero' ? 'bg-white viewer-open' : ''}`} ref={contentRef}> <div className="deck-content"> {/* The panels not on screen are only faded and folded away, so they are inert too: out of the tab order and the accessibility tree. Otherwise a keyboard could reach the Build panel's case switch - from the Pattern tab and change the case under the preview. */} + from the Pattern tab and change the case under the preview. + Inert takes the hero's h1 with it, so each tab's title is an h1 + of its own. */} <div className={`panel-wrapper ${activeTab === 'hero' ? 'active' : ''}`} inert={activeTab !== 'hero'}> <Hero /> <Milestones /> @@ -171,13 +179,13 @@ export default function RightPanel({ initialTab = 'hero', buildContent, patternC <BuildPanel key={buildPanelKey} content={buildContent} isActive={activeTab === 'build'} /> </div> <div className={`panel-wrapper ${activeTab === 'pattern' ? 'active' : ''}`} inert={activeTab !== 'pattern'}> - <PatternPanel content={patternContent} /> + <PatternPanel content={patternContent} isActive={activeTab === 'pattern'} /> </div> <div className={`panel-wrapper ${activeTab === 'inside' ? 'active' : ''}`} inert={activeTab !== 'inside'}> <InsidePanel content={insideContent} /> </div> </div> - </div> + </main> {/* Mobile-only bottom nav. Tapping a section drops the 3D viewer down from the top; tapping the active one again returns to the full-screen hero. */} diff --git a/web/src/components/sections/build-cases-data.test.ts b/web/src/components/sections/build-cases-data.test.ts index 205f84d07..7752a6fd5 100644 --- a/web/src/components/sections/build-cases-data.test.ts +++ b/web/src/components/sections/build-cases-data.test.ts @@ -60,12 +60,27 @@ function readmeHeader(folder: string): Record<string, string> { return fields; } +// How each remix's README takes over from the build guide, and how its card +// says so. Besoiobiy's and SimonePDA's replace the printing and case-assembly +// sections by number; mbchars' sends the reader to its own illustrated +// assembly instructions and BOM changes, and back to the build guide for the +// controller's setup and operation. +const GUIDE_BACKING: Record<string, { readme: RegExp; card: RegExp }> = { + 'besoiobiy-printed': { readme: /\(4 and 6\)/, card: /sections 4 and 6/ }, + 'simonepda-lasercut': { readme: /\(4 and 6\)/, card: /sections 4 and 6/ }, + 'mbchars-horizontal-desktop-printed': { + readme: /\[BOM changes\]\(bom\.md\)[^\n]*\[illustrated assembly instructions\]\(assembly\.md\)/, + card: /assembly instructions \(assembly\.md\) and BOM changes \(bom\.md\)/, + }, +}; + describe('the case cards', () => { - it('are the official case and the two remixes, official first and the default', () => { + it('are the official case and the three remixes, official first and the default', () => { expect(BUILD_CASES.map((item) => item.id)).toEqual([ 'official', 'besoiobiy-printed', 'simonepda-lasercut', + 'mbchars-horizontal-desktop-printed', ]); expect(DEFAULT_CASE).toBe('official'); expect(BUILD_CASES[0].kind).toBe('official'); @@ -133,10 +148,13 @@ describe('the case cards', () => { const date = header.Verified.match(/\d{4}-\d{2}-\d{2}/)?.[0]; expect(date).toBeTruthy(); expect(item.verified).toContain(date as string); - // Both READMEs replace the printing and case-assembly sections. + // What the card says the README does to the build guide is what the + // README says, in its own words. const readme = fs.readFileSync(path.join(REPO_ROOT, folder, 'README.md'), 'utf8'); - expect(readme).toMatch(/\(4 and 6\)/); - expect(item.guide).toMatch(/sections 4 and 6/); + const backing = GUIDE_BACKING[item.id]; + expect(backing, `${item.id}: no entry in GUIDE_BACKING`).toBeDefined(); + expect(readme).toMatch(backing.readme); + expect(item.guide).toMatch(backing.card); }, ); diff --git a/web/src/components/sections/build-cases-data.ts b/web/src/components/sections/build-cases-data.ts index c62bddc26..8426909fb 100644 --- a/web/src/components/sections/build-cases-data.ts +++ b/web/src/components/sections/build-cases-data.ts @@ -1,5 +1,5 @@ -// The three cases the Build panel lets a reader switch between: the official -// printed case and the two community remixes in hardware/case/remixes/. +// The four cases the Build panel lets a reader switch between: the official +// printed case and the three community remixes in hardware/case/remixes/. // // Every claim here is copied from a file in the repo, not written fresh — the // official case from hardware/case/README.md and BUILD_GUIDE.md §1, §4 and §6, @@ -20,7 +20,7 @@ export const blob = (path: string) => `${REPO}/blob/main/${path}`; /** A folder on main. */ export const tree = (path: string) => `${REPO}/tree/main/${path}`; -export type CaseId = 'official' | 'besoiobiy-printed' | 'simonepda-lasercut'; +export type CaseId = 'official' | 'besoiobiy-printed' | 'simonepda-lasercut' | 'mbchars-horizontal-desktop-printed'; export interface CasePhoto { src: string; @@ -80,6 +80,7 @@ export interface BuildCase { const OFFICIAL = 'hardware/case'; const BESOIOBIY = 'hardware/case/remixes/besoiobiy-printed'; const SIMONE = 'hardware/case/remixes/simonepda-lasercut'; +const MBCHARS = 'hardware/case/remixes/mbchars-horizontal-desktop-printed'; export const BUILD_CASES: BuildCase[] = [ { @@ -291,6 +292,71 @@ export const BUILD_CASES: BuildCase[] = [ desc: 'Laser-cut the plate, the border strips, the box and the feet, and print the knobs.', }, }, + { + id: 'mbchars-horizontal-desktop-printed', + tab: 'mbchars', + method: 'Printed remix', + kind: 'remix', + name: 'Mykyta Bilous’s horizontal desktop case', + summary: + 'The panel the long way across, on two removable stands, with the four controls on the right. Three front sections print face down, two white frames and an orange control section, and four joint bars on brass inserts hold them together without glue. Two rear covers open onto the electronics and have screw openings for hanging it on a wall. The power-bank compartment is gone: the control section holds a separate trigger module that feeds J4, the screw terminal, at 5 V.', + author: { + name: 'Mykyta Bilous', + note: 'mbchars', + href: 'https://github.com/mbchars', + }, + material: + 'PLA, set up for a Bambu Lab P2S with a 0.4 mm nozzle, 0.20 mm layers and a textured plate. White frames, covers, joint bars and knobs; orange control section, stands and module retainer. The colours are optional.', + fits: 'v3.9 and v3.0 boards (built on v3.0; v3.9 has the same outline), and a 320 × 160 mm panel with the hole pattern it was tested on.', + make: 'Nine plates in one Bambu Studio project, one colour each: about 24 hours and 714 g of PLA. The front sections and the module retainer print with the supports set up in the 3MF; the rest need none.', + parts: + '25 M3 heat-set inserts (5 mm across, 5 mm long) and 25 M3 × 8 countersunk screws; a PDSink 303PDSink01 trigger module set to 5 V, two wires from it to J4, and a supply it takes, in place of the power bank. No glue.', + verified: 'Built by its author, and photographed (2026-10-07).', + license: 'CC BY-SA 4.0', + caution: + 'Check that your panel’s hole pattern agrees with the frame before you print: a panel’s size alone does not say it fits. Set the trigger module to 5 V and measure its output before you connect it to J4: a higher voltage can damage the board and the panel.', + cautionHref: `${blob(`${MBCHARS}/README.md`)}#parts-and-assembly`, + guide: + 'Its own illustrated assembly instructions (assembly.md) and BOM changes (bom.md) take the place of the build guide’s printing, case assembly and power bank. The build guide still covers the controller’s setup and operation.', + readme: blob(`${MBCHARS}/README.md`), + modelNote: 'The 3D view puts it together from its STL files.', + photos: [ + { + src: '/cases/mbchars-horizontal-desktop-printed/front.jpg', + alt: 'The case on its two orange stands: the lit LED panel the long way across in a white frame, and the orange control section on the right with four white knobs near the top', + width: 1200, + height: 1200, + }, + { + src: '/cases/mbchars-horizontal-desktop-printed/angle.jpg', + alt: 'The case at an angle: blue and orange waves on the panel, the white frame in two sections, and the orange control section with its power cable leaving low on its side', + width: 1200, + height: 1200, + }, + { + src: '/cases/mbchars-horizontal-desktop-printed/rear_assembly.jpg', + alt: 'The case open from behind, covers off: the board in the orange control section, the trigger module below it wired to J4, the ribbon cable across to the panel, and brass inserts at the corners', + width: 900, + height: 1200, + }, + ], + credit: 'Photos by Mykyta Bilous.', + links: [ + { label: 'README and every file', target: 'mbchars-horizontal-desktop-printed/', href: tree(MBCHARS) }, + { + label: 'Bambu Studio project, nine plates', + target: 'print_layout.3mf', + href: blob(`${MBCHARS}/print_layout.3mf`), + }, + { label: 'Illustrated assembly', target: 'assembly.md', href: blob(`${MBCHARS}/assembly.md`) }, + { label: 'What changes in the BOM', target: 'bom.md', href: blob(`${MBCHARS}/bom.md`) }, + { label: 'One STL per part', target: 'stl/', href: tree(`${MBCHARS}/stl`) }, + ], + step: { + title: 'Print the case', + desc: 'Print the nine plates of the 3MF, about 24 hours, set the brass inserts, and join the three front sections with the joint bars.', + }, + }, ]; export const DEFAULT_CASE: CaseId = 'official'; diff --git a/web/src/components/sections/variantPrompt.test.ts b/web/src/components/sections/variantPrompt.test.ts new file mode 100644 index 000000000..b45179b02 --- /dev/null +++ b/web/src/components/sections/variantPrompt.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { DETENTS_PER_TURN, TURNS_PER_FULL_RANGE } from '@/lib/shared/constants'; +import { getVariantPrompt } from './variantPrompt'; + +// The prompt "Copy creation prompt" hands a model. Its knob lines have to say +// what one detent does to each knob, the same rule the preview's knobs and the +// encoder follow: a knob's whole range in two turns of 24 detents. + +const knobLines = (prompt: string) => prompt.split('\n').filter((line) => line.startsWith('- Knob ')); + +describe('getVariantPrompt', () => { + it("gives each knob the step one detent moves it, from the knob's range", () => { + const detentsPerRange = DETENTS_PER_TURN * TURNS_PER_FULL_RANGE; + const step = (span: number) => Math.round((span / detentsPerRange) * 1000) / 1000; + + // 48 detents a range today: 0.021, 0.206, 0.102 and 0.021. The lines said + // 0.05, 0.10, 0.05 and 0.05. + expect(knobLines(getVariantPrompt(''))).toEqual([ + `- Knob 1: 0.0 to 1.0, wraps at edges, step ${step(1)} per detent.`, + `- Knob 2: 0.1 to 10.0, clamps at edges, step ${step(9.9)} per detent.`, + `- Knob 3: 0.0 to 4.9, clamps at edges, step ${step(4.9)} per detent.`, + `- Knob 4: 0.0 to 1.0, wraps at edges, step ${step(1)} per detent.`, + ]); + }); + + it('says how many detents make a turn and a range', () => { + expect(getVariantPrompt('')).toContain( + `The encoder has ${DETENTS_PER_TURN} detents a turn, and ${TURNS_PER_FULL_RANGE} turns cross a knob's whole range:`, + ); + }); + + it('ends with the seed pattern in a javascript block', () => { + const code = 'export function draw(display) {}'; + expect(getVariantPrompt(code).endsWith(`\`\`\`javascript\n${code}\n\`\`\``)).toBe(true); + }); +}); diff --git a/web/src/components/sections/variantPrompt.ts b/web/src/components/sections/variantPrompt.ts new file mode 100644 index 000000000..1c7989c82 --- /dev/null +++ b/web/src/components/sections/variantPrompt.ts @@ -0,0 +1,90 @@ +import { + ENCODER_CLICKS_PER_TURN, + LOGICAL_KNOB_RANGES, + LOGICAL_KNOB_WRAP, + TURNS_PER_FULL_RANGE, + knobDetentStep, +} from '@/lib/pattern/controls'; + +// What "Copy creation prompt" under the Live Editor puts on the clipboard +// (PatternPanel.tsx). +// +// The knob lines are worked out from the ranges the preview's knobs turn +// through. They used to be typed in, and said 0.05 a detent (0.10 for Knob 2) +// long after the encoder was counted at 24 detents a turn with two turns to a +// range, which makes Knob 2's detent 0.206. + +// Three decimals, as the Lab's C++ prompt rounds the same step +// (lib/lab/cppPrompt.ts). +function roundStep(value: number) { + return Math.round(value * 1000) / 1000; +} + +export function knobRangeLines() { + return LOGICAL_KNOB_RANGES.map(([min, max], index) => { + const edges = LOGICAL_KNOB_WRAP[index] ? 'wraps' : 'clamps'; + const step = roundStep(knobDetentStep([min, max])); + return `- Knob ${index + 1}: ${min.toFixed(1)} to ${max.toFixed(1)}, ${edges} at edges, step ${step} per detent.`; + }).join('\n'); +} + +export const getVariantPrompt = (code: string) => `I am writing custom LED patterns in JavaScript for Patternflow's 128x64 LED matrix web preview. + +I will give you one existing Patternflow pattern. Use it as a seed, not as a cage. Create exactly 5 distinct standalone variations that explore different visual directions. + +Very important output rules: +- Return exactly 5 separate JavaScript code blocks. +- Each code block must be a complete standalone Patternflow pattern. +- Do not combine the 5 variations into one file. +- Do not add a mode selector, preset array, switch statement, or any code that contains multiple patterns in one output. +- Do not write wrapper text inside the code blocks. +- Put a short variation name before each code block. +- Do not include nested triple backticks inside any code block. + +Required API for every variation: +- export function setup(params) {} +- export function update(dt, input, params) {} +- export function draw(display, params, time) {} +- Use input.knobValues as the primary control API. input.knobValues is an array of 4 absolute knob values after the min/max ranges are applied. +- input.knobNormalized is also available when a 0.0-1.0 value is useful. +- Keep input.knobDeltas only as compatibility fallback if needed. +- Use display.width and display.height in loops. Do not hardcode 128 or 64 inside draw(). +- Use only plain JavaScript and Math.*. No browser APIs, DOM APIs, imports, async code, external libraries, dynamic evaluation, or per-pixel allocations. + +Creative control mapping: +- It is okay to keep one knob as animation speed, preferably Knob 2, if that suits the variation. +- Do not keep all four knobs as the same old hue/speed/mode/frequency template unless it is genuinely the best fit. +- Redesign the other controls creatively for each variation. Examples: cell size, symmetry fold, glitch amount, palette split, trail length, scanline spacing, pulse width, inversion threshold, rotation, warp depth, density, edge thickness, phase offset, bloom-like gain, or motif selection. +- Each of the 5 variations should have a slightly different control personality. The controls should reveal the unique idea of that variation. +- Include a short comment near setup() or update() naming what the 4 knobs do for that specific variation. + +Color direction: +- Make color part of the pattern logic, not just a global hue wash. +- Avoid relying on a single full-frame gradient or a uniform hue shift across the whole image. +- Prefer colors that respond to local pattern values: distance fields, cell seeds, stripe index, phase, brightness, threshold bands, motion direction, edge thickness, density, or mask state. +- Good examples: large values become red while small values become blue; interior/exterior use different palettes; threshold bands step through 3-5 colors; cell IDs pick related colors; moving fronts leave warmer highlights; thin edges are white while filled regions are saturated. +- Both smooth local gradients and stepped posterized color bands are welcome, as long as the color changes are tied to the geometry or signal of the pattern. +- Keep at least some pixels near full LED brightness. + +Variation direction: +- Keep the general intent and the four control roles understandable, but do not copy the original structure too literally. +- At least 3 of the 5 variations must change the main drawing algorithm, not only constants, colors, thresholds, or speed. +- Avoid making all 5 outputs feel like the same pattern with different parameter values. +- Do not reuse the same grid, shape, distance formula, or composition in every variation. +- Give each variation a different dominant idea. Use these five directions: + 1. Structural remix: change the main geometry or repetition system. + 2. Motion remix: change how time moves through the pattern. + 3. Palette/material remix: change color logic, brightness rhythm, or foreground/background relationship. + 4. Domain remix: warp, mirror, fold, scroll, rotate, or otherwise remap coordinates. + 5. Contrast remix: make a clearly different sparse/dense, hard/soft, or organic/mechanical interpretation. +- The variations can be bold. They should still feel related to the seed, but not trapped inside its exact look. +- Keep the patterns bright enough for an LED matrix and reasonably ESP32-friendly. +- Avoid smoothing/lerping knob-controlled values unless the visual idea specifically needs inertia. + +Knob ranges for the Patternflow live editor. The encoder has ${ENCODER_CLICKS_PER_TURN} detents a turn, and ${TURNS_PER_FULL_RANGE} turns cross a knob's whole range: +${knobRangeLines()} + +Existing pattern: +\`\`\`javascript +${code} +\`\`\``; diff --git a/web/src/lib/ai/gemini.ts b/web/src/lib/ai/gemini.ts deleted file mode 100644 index e4b261e2f..000000000 --- a/web/src/lib/ai/gemini.ts +++ /dev/null @@ -1,491 +0,0 @@ -// Bring-your-own-key Gemini integration for Pattern Lab. -// -// The API key is stored only in the browser (localStorage) and sent directly to -// Google's endpoint from the client — it never touches our servers. Because this -// is a static, open-source site we deliberately avoid bundling any shared key. - -import { - buildMatrixAnnotationLine, - describeMatrixShape, - withMatrixAnnotation, - type MatrixSize, -} from "@/lib/pattern/matrix"; - -// Swap these to change the model / reasoning depth used for in-app generation. -// -// NOTE: we call the `generateContent` endpoint, not the newer Interactions API. -// `/v1beta/interactions` does not send CORS headers, so browsers block it from -// any origin (localhost and production alike). `generateContent` does send them, -// which is what makes client-side BYOK work without a server proxy. -export const GEMINI_MODEL = "gemini-3.6-flash"; - -// The settings vocabulary (thinking levels, colour modes, the variant shape) -// lives in ./settings so the lab can persist a GenSettings block without -// importing this client. Re-exported here for everyone who already does. -import { - COLOR_MODES, - GEMINI_THINKING_LEVEL, - THINKING_LEVELS, - type ColorMode, - type PatternVariant, - type ThinkingLevelKey, -} from "./settings"; -export { COLOR_MODES, GEMINI_THINKING_LEVEL, THINKING_LEVELS }; -export type { ColorMode, PatternVariant, ThinkingLevelKey }; - -// --------------------------------------------------------------------------- -// License / attribution stamped onto every generated pattern. -// Edit these two templates to change the notice. `{{name}}` and `{{year}}` are -// filled in automatically; everything else is yours to change. -// --------------------------------------------------------------------------- -export const LICENSE_HEADER_TEMPLATE = `// Pattern: {{name}} -// Author: your name here -// SPDX-License-Identifier: CC-BY-SA-4.0 -// Date: {{date}} -// Made with Patternflow Pattern Lab — https://patternflow.work/pattern-lab`; - -export const LICENSE_FOOTER_TEMPLATE = `// --- -// Made with Patternflow Pattern Lab — https://patternflow.work/pattern-lab -// Licensed CC-BY-SA-4.0. Keep this notice if you share or remix.`; - -function stampLicense(code: string, name: string): string { - const today = new Date(); - const date = today.toISOString().slice(0, 10); // YYYY-MM-DD, filled automatically - const header = LICENSE_HEADER_TEMPLATE.replaceAll("{{name}}", name) - .replaceAll("{{date}}", date) - .replaceAll("{{year}}", String(today.getFullYear())); - return `${header}\n\n${code.trim()}\n\n${LICENSE_FOOTER_TEMPLATE}\n`; -} - -// Some models put the whole pattern on one line or emit literal "\n" escapes -// inside the JSON string. If there are no real newlines but escaped ones exist, -// restore them so the code shows as normal multi-line text in the editor. -function normalizeCode(code: string): string { - if (code.includes("\n")) return code; - if (code.includes("\\n")) { - return code - .replace(/\\r\\n/g, "\n") - .replace(/\\n/g, "\n") - .replace(/\\t/g, "\t"); - } - return code; -} - -export const GEMINI_KEY_STORAGE = "patternflow_gemini_key"; - -const KNOB_LABELS = ["Knob 1", "Knob 2", "Knob 3", "Knob 4"]; - -export function loadGeminiKey(): string { - if (typeof window === "undefined") return ""; - try { - return window.localStorage.getItem(GEMINI_KEY_STORAGE) ?? ""; - } catch { - return ""; - } -} - -export function saveGeminiKey(key: string) { - if (typeof window === "undefined") return; - try { - if (key) { - window.localStorage.setItem(GEMINI_KEY_STORAGE, key); - } else { - window.localStorage.removeItem(GEMINI_KEY_STORAGE); - } - } catch { - // Ignore private-mode / storage-disabled sessions. - } -} - -function controlLines(knobs: number[], ranges: Array<[number, number]>) { - return ranges - .map( - (range, index) => - `- ${KNOB_LABELS[index]} range: ${range[0]} to ${range[1]}, current value: ${knobs[index]}`, - ) - .join("\n"); -} - -// ── Frame ─────────────────────────────────────────────────────────────────── -// One honest statement of the pixel grid, derived from the numbers alone. This -// is the ONLY place the model is told about shape — no compensating axis -// flips, no second "orientation" knob to disagree with it. -function frameLine(matrix: MatrixSize) { - const { width, height } = matrix; - const shape = describeMatrixShape(matrix); - const shapeNote = - shape === "landscape" - ? `It is WIDER than it is tall (${(width / height).toFixed(2)}:1). Compose for a wide frame.` - : shape === "portrait" - ? `It is TALLER than it is wide (1:${(height / width).toFixed(2)}). Compose for a tall frame — a design meant for a wide frame will read as cramped and over-zoomed here.` - : "It is SQUARE. Compose for a square frame."; - return `Frame — the pattern is drawn into a ${width} × ${height} pixel grid: ${width} wide (x = 0..${width - 1}) and ${height} tall (y = 0..${height - 1}). ${shapeNote} Lay the composition, bands, and motion out for these proportions.`; -} - -function variantIntro(matrix: MatrixSize) { - return `I am writing custom LED patterns in JavaScript for a Patternflow ${matrix.width}x${matrix.height} LED matrix web preview. - -${frameLine(matrix)} - -I will give you one existing Patternflow pattern. Use it as a seed, not as a cage. Create exactly 5 distinct standalone variations that explore different visual directions.`; -} - -// Technical + quality spec shared by every path (API rules, controls, color). -// Split into pieces so the pixel-write rule and the color section can swap -// depending on ColorMode. -const TECH_API_COMMON = `Required API for every pattern: -- export function setup(params) {} -- export function update(dt, input, params) {} -- export function draw(display, params, time) {} -- Use input.knobValues as the primary control API. input.knobValues is an array of 4 absolute knob values after the min/max ranges are applied. -- Declare the ranges your 4 knobs want with ONE comment line near the top of the file, exactly this format: - // @knobs Folds=3..12, Speed=0.1..10, Zoom=2..17, Contrast=0.1..1 - (exactly 4 comma-separated entries, Name=min..max, short names). Pattern Lab parses this line, renames the on-screen knobs, and sets their min/max automatically — the user can then retune any range and your pattern follows, because you read the values back through input.knobValues. -- Read input.knobValues[i] directly as the parameter value (matching your @knobs ranges). Do NOT read input.knobNormalized and remap it with baked constants such as 3 + kn[0] * 9 — that disconnects Pattern Lab's range editor (the user's min/max edits stop doing anything). input.knobNormalized is acceptable only for a truly unitless 0..1 blend where any range would mean the same thing. -- Keep input.knobDeltas only as compatibility fallback if needed. -- Optional: each knob also has a push button. input.btnPressed[i] is true only on the frame it is pressed (edge); input.btnHeld[i] is true while it is held down. Use these for momentary actions like reset, freeze, cycle, or trigger. Do not use long-press or mode-switching; that is a reserved system gesture. -- IMPORTANT: input is passed ONLY to update(dt, input, params). draw's signature is draw(display, params, time) with NO input argument — params.input does not exist. To read knob or button values inside draw, use params.knobValues / params.knobNormalized / params.knobDeltas / params.btnPressed / params.btnHeld (the harness mirrors the latest input onto params every frame), or stash whatever you need on params during update. Never read input.* or params.input.* inside draw. -- Use display.width and display.height in loops. Never hardcode the frame's pixel dimensions inside draw() — the same pattern is run at other resolutions, and a hardcoded size crops or zooms it. -- Declare the frame you composed for with ONE comment line near the top of the file, exactly this format: - {{MATRIX_LINE}} - Use exactly the dimensions given above. Pattern Lab reads this line to set the preview resolution, and the firmware uses it to map the pattern onto the physical panel.`; - -const TECH_PIXEL_RGB = `- Write each pixel with display.setPixel(x, y, r, g, b) — EXACTLY five arguments, where r, g, b are three SEPARATE integer arguments in the range 0–255. Never pass a color array such as display.setPixel(x, y, [r, g, b]); never pass a packed color or a 4th alpha argument. The array form renders pure black. -- Use only plain JavaScript and Math.*. No browser APIs, DOM APIs, imports, async code, external libraries, dynamic evaluation, or per-pixel allocations.`; - -const TECH_PIXEL_VFIELD = `- Write each pixel with display.setValue(x, y, v) — EXACTLY three arguments, where v is a single number from 0.0 to 1.0. Do NOT call display.setPixel anywhere. -- Use only plain JavaScript and Math.*. No browser APIs, DOM APIs, imports, async code, external libraries, dynamic evaluation, or per-pixel allocations.`; - -const TECH_CONTROLS = `Creative control mapping: -- It is okay to keep one knob as animation speed, preferably Knob 2, if that suits the pattern. -- Do not keep all four knobs as the same old hue/speed/mode/frequency template unless it is genuinely the best fit. -- Redesign the controls creatively for each pattern. Examples: cell size, symmetry fold, glitch amount, palette split, trail length, scanline spacing, pulse width, inversion threshold, rotation, warp depth, density, edge thickness, phase offset, bloom-like gain, or motif selection. -- Each pattern should have a slightly different control personality. The controls should reveal the unique idea of that pattern. -- Include a short comment near setup() or update() naming what the 4 knobs do for that specific pattern.`; - -const TECH_COLOR_RGB = `Color direction: -- Make color part of the pattern logic, not just a global hue wash. -- Avoid relying on a single full-frame gradient or a uniform hue shift across the whole image. -- Prefer colors that respond to local pattern values: distance fields, cell seeds, stripe index, phase, brightness, threshold bands, motion direction, edge thickness, density, or mask state. -- Good examples: large values become red while small values become blue; interior/exterior use different palettes; threshold bands step through 3-5 colors; cell IDs pick related colors; moving fronts leave warmer highlights; thin edges are white while filled regions are saturated. -- Both smooth local gradients and stepped posterized color bands are welcome, as long as the color changes are tied to the geometry or signal of the pattern. -- Keep at least some pixels near full LED brightness.`; - -const TECH_COLOR_VFIELD = `Value-field direction (IMPORTANT — there is NO color in this pattern): -- The pattern outputs only a scalar value field via display.setValue(x, y, v). Color is applied OUTSIDE the pattern by a user-controlled color ramp that maps v = 0..1 to colors. -- Do NOT compute any colors, palettes, hues, RGB values, or hsvToRgb helpers anywhere. No color logic at all — pour all effort into geometry, structure, and motion. -- Treat v as the pattern's tonal value and design the field so it reads well under ANY ramp: - - Use the full 0..1 range every frame: some pixels near 0, some near 1. - - Build deliberate tonal structure tied to the geometry: edges, bands, plateaus, gradients driven by distance, phase, cell id, density, or masks. - - Value contrast is your only material — sharp boundaries, layered levels, and clear figure/ground separation matter more than in RGB mode. - - Avoid uniform mid-grey mush (everything hovering near 0.5) and avoid pure binary output unless the idea is intentionally hard-edged. -- Knobs must control geometry/motion/structure, never color (no hue knobs — color belongs to the ramp).`; - -function buildTechGuide(colorMode: ColorMode, matrix: MatrixSize) { - const pixelRule = colorMode === "vfield" ? TECH_PIXEL_VFIELD : TECH_PIXEL_RGB; - const colorSection = colorMode === "vfield" ? TECH_COLOR_VFIELD : TECH_COLOR_RGB; - const api = TECH_API_COMMON.replace("{{MATRIX_LINE}}", buildMatrixAnnotationLine(matrix)); - return `${api}\n${pixelRule}\n\n${TECH_CONTROLS}\n\n${colorSection}`; -} - -// Seed-based direction for the clipboard prompt (remix one existing pattern). -const VARIANT_SEED_DIRECTION = `Variation direction: -- Keep the general intent and the four control roles understandable, but do not copy the original structure too literally. -- At least 3 of the 5 variations must change the main drawing algorithm, not only constants, colors, thresholds, or speed. -- Avoid making all 5 outputs feel like the same pattern with different parameter values. -- Do not reuse the same grid, shape, distance formula, or composition in every variation. -- Give each variation a different dominant idea. Use these five directions: - 1. Structural remix: change the main geometry or repetition system. - 2. Motion remix: change how time moves through the pattern. - 3. Palette/material remix: change color logic, brightness rhythm, or foreground/background relationship. - 4. Domain remix: warp, mirror, fold, scroll, rotate, or otherwise remap coordinates. - 5. Contrast remix: make a clearly different sparse/dense, hard/soft, or organic/mechanical interpretation. -- The variations can be bold. They should still feel related to the seed, but not trapped inside its exact look. -- Keep the patterns bright enough for an LED matrix and reasonably ESP32-friendly. -- Avoid smoothing/lerping knob-controlled values unless the visual idea specifically needs inertia.`; - -// Divergence direction for the in-app call: draw on many references, spread wide. -const VARIANT_DIVERGE_DIRECTION = `Direction — maximize variety: -- These are NEW patterns, not edits of one source. Do not converge on a single look across the set. -- Borrow ideas freely, but never closely reproduce a single familiar template or any reference shown. -- Give each output a different dominant idea. Spread the set across these axes: structure/geometry, how time moves, color logic, coordinate domain (warp/mirror/fold/scroll/rotate), and density/contrast. -- Most outputs must use a different main drawing algorithm — not the same pattern with different constants, colors, thresholds, or speed. -- Do not reuse the same grid, shape, distance formula, or composition across outputs. -- Be bold: it is good if the set spans very different moods — sparse vs dense, hard vs soft, organic vs mechanical, geometric vs noisy. -- Keep the patterns bright enough for an LED matrix and reasonably ESP32-friendly. -- Avoid smoothing/lerping knob-controlled values unless the visual idea specifically needs inertia.`; - -// Prompt for the clipboard fallback: the model returns 5 markdown code blocks. -// Follows the same ColorMode as the in-app call so v-field users get v-field -// output from external chatbots too. -export function buildVariantCopyPrompt( - code: string, - knobs: number[], - ranges: Array<[number, number]>, - colorMode: ColorMode = "rgb", - matrix: MatrixSize = { width: 128, height: 64 }, -) { - return `${variantIntro(matrix)} - -Very important output rules: -- Return exactly 5 separate JavaScript code blocks. -- Each code block must be a complete standalone Patternflow pattern. -- Do not combine the 5 variations into one file. -- Do not add a mode selector, preset array, switch statement, or any code that contains multiple patterns in one output. -- Do not write wrapper text inside the code blocks. -- Put a short variation name before each code block. -- Do not include nested triple backticks inside any code block. - -${buildTechGuide(colorMode, matrix)} - -${VARIANT_SEED_DIRECTION} - -Current Pattern Lab controls: -${controlLines(knobs, ranges)} - -Existing pattern${ - colorMode === "vfield" - ? " (NOTE: if this seed calls display.setPixel and computes colors, it is an older RGB-mode pattern — take only its geometry/motion ideas; every variation you output must use display.setValue(x, y, v) with NO color logic)" - : "" - }: -\`\`\`javascript -${code} -\`\`\``; -} - -// Instruction for the in-app structured-output call: the model returns JSON -// matching the schema below, so there are no markdown fences to parse. `count` -// sets how many patterns; `examples` is a random sample of existing Patternflow -// patterns used to widen the model's range so output is not all one style. -function buildVariantApiInstruction( - code: string, - knobs: number[], - ranges: Array<[number, number]>, - count: number, - examples: Array<{ name: string; code: string }>, - matrix: MatrixSize, - seedWithCurrent: boolean, - colorMode: ColorMode, -) { - const phrase = count === 1 ? "1 standalone pattern" : `${count} distinct standalone patterns`; - - const noReferences = examples.length === 0 && !seedWithCurrent; - const creativityLine = noReferences - ? "No reference patterns are provided, on purpose. Invent the most original, surprising, and varied patterns you can purely from the rules below — do not fall back on common defaults.\n\n" - : ""; - - // Existing presets are RGB-mode; when generating value fields, make sure the - // model doesn't imitate their setPixel/color code. - const vfieldReferenceCaveat = - colorMode === "vfield" - ? " NOTE: the references are older RGB-mode patterns that call display.setPixel and compute colors — your output must instead call display.setValue(x, y, v) with NO color logic. Use the references only for geometry, structure, and motion ideas." - : ""; - - // Every bundled preset was composed for the stock 128×64 panel. Asking for a - // different frame while showing landscape references is exactly how patterns - // came out cropped and over-zoomed, so say so outright. - const frameReferenceCaveat = - matrix.width === 128 && matrix.height === 64 - ? "" - : ` NOTE: the references were composed for a 128×64 frame; you are composing for ${matrix.width}×${matrix.height}. Take their ideas, not their proportions — re-lay every composition out for the ${describeMatrixShape(matrix)} frame described above.`; - - const referenceBlock = examples.length - ? `Reference patterns — these show the RANGE of styles that work on Patternflow. Use them ONLY as inspiration for what is possible. Do NOT copy, port, or closely reproduce any single one.${vfieldReferenceCaveat}${frameReferenceCaveat} - -${examples.map((example) => `===== ${example.name} =====\n${example.code}`).join("\n\n")} - -` - : ""; - - return `I am writing custom LED patterns in JavaScript for a Patternflow ${matrix.width}x${matrix.height} LED matrix web preview. - -${frameLine(matrix)} - -Create exactly ${phrase} that are genuinely different from one another. Aim for wide variety, not small tweaks of a single look. - -${creativityLine}Return the patterns as structured JSON matching the provided schema: an array of exactly ${count} object${count === 1 ? "" : "s"}. Each object has: -- "name": a short pattern name. -- "knobNotes": one line naming what the 4 knobs do for that pattern. -- "code": a complete standalone Patternflow pattern as plain JavaScript text. Do NOT wrap it in markdown fences. Do NOT combine patterns, add a mode selector, preset array, or switch statement that holds multiple patterns. -- Format the code as normal, readable multi-line JavaScript using real line breaks and indentation. Do NOT put the whole pattern on a single line and do NOT minify it. -- Do NOT add any license header, copyright line, author/date comment, or attribution footer — those are added automatically. Start directly with the pattern code. - -${buildTechGuide(colorMode, matrix)} - -${VARIANT_DIVERGE_DIRECTION} - -${referenceBlock}${ - seedWithCurrent - ? `The pattern currently open in the editor (you may relate to it loosely, but prioritize variety over staying close to it): -\`\`\`javascript -${code} -\`\`\` - -` - : "" - }Current Pattern Lab controls: -${controlLines(knobs, ranges)}`; -} - -function errorStatus(error: unknown): number | undefined { - const status = (error as { status?: unknown })?.status; - return typeof status === "number" ? status : undefined; -} - -// Transient server-side failures worth retrying: overload (503), quota bursts -// (429), and the occasional 500. -function isRetryableError(error: unknown): boolean { - const status = errorStatus(error); - if (status === 503 || status === 429 || status === 500) return true; - const message = error instanceof Error ? error.message : String(error); - return /(503|500|429|UNAVAILABLE|overloaded|RESOURCE_EXHAUSTED)/i.test(message); -} - -// Turn the SDK's verbose error blobs into one human-readable line for the UI. -function describeGeminiError(error: unknown): string { - const status = errorStatus(error); - const message = error instanceof Error ? error.message : String(error); - if (status === 503 || /503|UNAVAILABLE|overloaded/i.test(message)) { - return "Gemini is temporarily overloaded (503). Please try again in a moment."; - } - if (status === 429 || /429|RESOURCE_EXHAUSTED/i.test(message)) { - return "Rate limit or quota reached (429). Wait a bit, then retry."; - } - if (status === 403 || /403|PERMISSION_DENIED/i.test(message)) { - return "Your API key can't access this model (403). Check the key, or switch GEMINI_MODEL."; - } - if (status === 400 || /400|API key not valid|INVALID_ARGUMENT/i.test(message)) { - return "Request rejected (400). The API key may be invalid or the request malformed."; - } - return message || "Generation failed."; -} - -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -async function withRetry<T>(fn: () => Promise<T>, attempts = 4): Promise<T> { - let lastError: unknown; - for (let attempt = 0; attempt < attempts; attempt++) { - try { - return await fn(); - } catch (error) { - lastError = error; - if (!isRetryableError(error) || attempt === attempts - 1) break; - await sleep(800 * 2 ** attempt); // 0.8s, 1.6s, 3.2s - } - } - throw lastError; -} - -export async function generatePatternVariants({ - apiKey, - code, - knobs, - ranges, - count = 5, - thinkingLevel = GEMINI_THINKING_LEVEL, - examples = [], - matrix = { width: 128, height: 64 }, - seedWithCurrent = true, - colorMode = "rgb", -}: { - apiKey: string; - code: string; - knobs: number[]; - ranges: Array<[number, number]>; - count?: number; - thinkingLevel?: ThinkingLevelKey; - examples?: Array<{ name: string; code: string }>; - matrix?: MatrixSize; - seedWithCurrent?: boolean; - colorMode?: ColorMode; -}): Promise<PatternVariant[]> { - // Dynamic import keeps the SDK out of the initial page bundle — it only loads - // when the user actually clicks Generate. - const { GoogleGenAI, Type, ThinkingLevel } = await import("@google/genai"); - const ai = new GoogleGenAI({ apiKey }); - - // Structured output: the schema forces exactly 5 objects so there are no - // markdown code fences to parse out. Retry transient overloads (503/429). - let response; - try { - response = await withRetry(() => - ai.models.generateContent({ - model: GEMINI_MODEL, - contents: buildVariantApiInstruction( - code, - knobs, - ranges, - count, - examples, - matrix, - seedWithCurrent, - colorMode, - ), - config: { - temperature: 1, - topP: 0.95, - maxOutputTokens: 65536, - thinkingConfig: { thinkingLevel: ThinkingLevel[thinkingLevel] }, - responseMimeType: "application/json", - responseSchema: { - type: Type.ARRAY, - minItems: String(count), - maxItems: String(count), - items: { - type: Type.OBJECT, - properties: { - name: { type: Type.STRING }, - knobNotes: { type: Type.STRING }, - code: { type: Type.STRING }, - }, - required: ["name", "code"], - propertyOrdering: ["name", "knobNotes", "code"], - }, - }, - }, - }), - ); - } catch (error) { - throw new Error(describeGeminiError(error)); - } - - const text = response.text; - if (!text) throw new Error("Gemini returned an empty response."); - - let parsed: unknown; - try { - parsed = JSON.parse(text); - } catch { - throw new Error("Gemini returned malformed JSON."); - } - if (!Array.isArray(parsed)) { - throw new Error("Unexpected response shape from Gemini."); - } - - const variants: PatternVariant[] = parsed - .filter( - (item): item is { name?: unknown; knobNotes?: unknown; code?: unknown } => - Boolean(item) && typeof item === "object", - ) - .filter((item) => typeof item.code === "string" && item.code.trim().length > 0) - .map((item) => { - const name = - typeof item.name === "string" && item.name.trim() ? item.name.trim() : "Variation"; - // Models drop the @matrix line often enough that it can't be trusted to - // the prompt alone, and a pattern with no declared frame silently falls - // back to 128×64 later on. The requested size is the authority here — it - // is what the model was told to compose for. - const code = withMatrixAnnotation(normalizeCode(item.code as string), matrix); - return { - name, - knobNotes: typeof item.knobNotes === "string" ? item.knobNotes : undefined, - code: stampLicense(code, name), - }; - }); - - if (variants.length === 0) { - throw new Error("No usable variations were returned."); - } - - return variants; -} diff --git a/web/src/lib/ai/settings.ts b/web/src/lib/ai/settings.ts index 1ceb10031..c62a621a7 100644 --- a/web/src/lib/ai/settings.ts +++ b/web/src/lib/ai/settings.ts @@ -1,17 +1,9 @@ -// ── Generation settings the rest of the app may name ───────────────────────── -// The lab's project stores a GenSettings block (how many variants, how much -// thinking, which colour model) and persists it; the community's atlas quotes -// the prompt vocabulary. None of that needs the Gemini client — a 500-line -// module that talks to Google — so the vocabulary lives here, dependency-free, -// and gemini.ts re-exports it for callers that already import from there. +// ── The colour model a copied prompt asks for ─────────────────────────────── +// The lab's project stores it (serialize.ts) and Copy prompt reads it +// (variantPrompt.ts). It lives apart from the prompt text so the serializer +// can name it without pulling the prompt in. -// More thinking = more varied/correct output but slower. -export type ThinkingLevelKey = "MINIMAL" | "LOW" | "MEDIUM" | "HIGH"; -export const THINKING_LEVELS: ThinkingLevelKey[] = ["MINIMAL", "LOW", "MEDIUM", "HIGH"]; -// Default reasoning depth. "LOW" keeps batches responsive. -export const GEMINI_THINKING_LEVEL: ThinkingLevelKey = "LOW"; - -// How generated patterns handle colour: +// How the patterns an AI writes handle colour: // - "rgb": the model colours every pixel itself via setPixel (classic mode). // - "vfield": the model outputs a pure 0..1 value field via setValue; colour is // applied by the user-controlled Color Ramp in Pattern Lab. Keeps the model @@ -28,10 +20,3 @@ export const GEMINI_THINKING_LEVEL: ThinkingLevelKey = "LOW"; // at the display boundary. See lib/pattern/matrix.ts. export type ColorMode = "rgb" | "vfield"; export const COLOR_MODES: ColorMode[] = ["vfield", "rgb"]; - -/** One generated pattern, as the gallery keeps it. */ -export type PatternVariant = { - name: string; - knobNotes?: string; - code: string; -}; diff --git a/web/src/lib/ai/variantPrompt.ts b/web/src/lib/ai/variantPrompt.ts new file mode 100644 index 000000000..f0edaa6f9 --- /dev/null +++ b/web/src/lib/ai/variantPrompt.ts @@ -0,0 +1,156 @@ +// The prompt Pattern Lab's Copy prompt puts on the clipboard (CodePanel.tsx): +// the focused code layer as a seed, the frame, the knobs as they stand and +// the API rules, for whatever AI chat the user pastes it into. Two places copy +// its wording. The guide's practice AI tells it apart by lines it always +// writes (components/guide/desk/ai/answers.ts, checked by answers.test.ts), +// and the community atlas mirrors it (lib/atlas/data.ts). Change them with it. + +import { buildMatrixAnnotationLine, describeMatrixShape, type MatrixSize } from "@/lib/pattern/matrix"; +import type { ColorMode } from "./settings"; + +const KNOB_LABELS = ["Knob 1", "Knob 2", "Knob 3", "Knob 4"]; + +function controlLines(knobs: number[], ranges: Array<[number, number]>) { + return ranges + .map( + (range, index) => + `- ${KNOB_LABELS[index]} range: ${range[0]} to ${range[1]}, current value: ${knobs[index]}`, + ) + .join("\n"); +} + +// ── Frame ─────────────────────────────────────────────────────────────────── +// One honest statement of the pixel grid, derived from the numbers alone. This +// is the ONLY place the model is told about shape — no compensating axis +// flips, no second "orientation" knob to disagree with it. +function frameLine(matrix: MatrixSize) { + const { width, height } = matrix; + const shape = describeMatrixShape(matrix); + const shapeNote = + shape === "landscape" + ? `It is WIDER than it is tall (${(width / height).toFixed(2)}:1). Compose for a wide frame.` + : shape === "portrait" + ? `It is TALLER than it is wide (1:${(height / width).toFixed(2)}). Compose for a tall frame — a design meant for a wide frame will read as cramped and over-zoomed here.` + : "It is SQUARE. Compose for a square frame."; + return `Frame — the pattern is drawn into a ${width} × ${height} pixel grid: ${width} wide (x = 0..${width - 1}) and ${height} tall (y = 0..${height - 1}). ${shapeNote} Lay the composition, bands, and motion out for these proportions.`; +} + +function variantIntro(matrix: MatrixSize) { + return `I am writing custom LED patterns in JavaScript for a Patternflow ${matrix.width}x${matrix.height} LED matrix web preview. + +${frameLine(matrix)} + +I will give you one existing Patternflow pattern. Use it as a seed, not as a cage. Create exactly 5 distinct standalone variations that explore different visual directions.`; +} + +// Technical + quality spec (API rules, controls, color). +// Split into pieces so the pixel-write rule and the color section can swap +// depending on ColorMode. +const TECH_API_COMMON = `Required API for every pattern: +- export function setup(params) {} +- export function update(dt, input, params) {} +- export function draw(display, params, time) {} +- Use input.knobValues as the primary control API. input.knobValues is an array of 4 absolute knob values after the min/max ranges are applied. +- Declare the ranges your 4 knobs want with ONE comment line near the top of the file, exactly this format: + // @knobs Folds=3..12, Speed=0.1..10, Zoom=2..17, Contrast=0.1..1 + (exactly 4 comma-separated entries, Name=min..max, short names). Pattern Lab parses this line, renames the on-screen knobs, and sets their min/max automatically — the user can then retune any range and your pattern follows, because you read the values back through input.knobValues. +- Read input.knobValues[i] directly as the parameter value (matching your @knobs ranges). Do NOT read input.knobNormalized and remap it with baked constants such as 3 + kn[0] * 9 — that disconnects Pattern Lab's range editor (the user's min/max edits stop doing anything). input.knobNormalized is acceptable only for a truly unitless 0..1 blend where any range would mean the same thing. +- Keep input.knobDeltas only as compatibility fallback if needed. +- Optional: each knob also has a push button. input.btnPressed[i] is true only on the frame it is pressed (edge); input.btnHeld[i] is true while it is held down. Use these for momentary actions like reset, freeze, cycle, or trigger. Do not use long-press or mode-switching; that is a reserved system gesture. +- IMPORTANT: input is passed ONLY to update(dt, input, params). draw's signature is draw(display, params, time) with NO input argument — params.input does not exist. To read knob or button values inside draw, use params.knobValues / params.knobNormalized / params.knobDeltas / params.btnPressed / params.btnHeld (the harness mirrors the latest input onto params every frame), or stash whatever you need on params during update. Never read input.* or params.input.* inside draw. +- Use display.width and display.height in loops. Never hardcode the frame's pixel dimensions inside draw() — the same pattern is run at other resolutions, and a hardcoded size crops or zooms it. +- Declare the frame you composed for with ONE comment line near the top of the file, exactly this format: + {{MATRIX_LINE}} + Use exactly the dimensions given above. Pattern Lab reads this line to set the preview resolution, and the firmware uses it to map the pattern onto the physical panel.`; + +const TECH_PIXEL_RGB = `- Write each pixel with display.setPixel(x, y, r, g, b) — EXACTLY five arguments, where r, g, b are three SEPARATE integer arguments in the range 0–255. Never pass a color array such as display.setPixel(x, y, [r, g, b]); never pass a packed color or a 4th alpha argument. The array form renders pure black. +- Use only plain JavaScript and Math.*. No browser APIs, DOM APIs, imports, async code, external libraries, dynamic evaluation, or per-pixel allocations.`; + +const TECH_PIXEL_VFIELD = `- Write each pixel with display.setValue(x, y, v) — EXACTLY three arguments, where v is a single number from 0.0 to 1.0. Do NOT call display.setPixel anywhere. +- Use only plain JavaScript and Math.*. No browser APIs, DOM APIs, imports, async code, external libraries, dynamic evaluation, or per-pixel allocations.`; + +const TECH_CONTROLS = `Creative control mapping: +- It is okay to keep one knob as animation speed, preferably Knob 2, if that suits the pattern. +- Do not keep all four knobs as the same old hue/speed/mode/frequency template unless it is genuinely the best fit. +- Redesign the controls creatively for each pattern. Examples: cell size, symmetry fold, glitch amount, palette split, trail length, scanline spacing, pulse width, inversion threshold, rotation, warp depth, density, edge thickness, phase offset, bloom-like gain, or motif selection. +- Each pattern should have a slightly different control personality. The controls should reveal the unique idea of that pattern. +- Include a short comment near setup() or update() naming what the 4 knobs do for that specific pattern.`; + +const TECH_COLOR_RGB = `Color direction: +- Make color part of the pattern logic, not just a global hue wash. +- Avoid relying on a single full-frame gradient or a uniform hue shift across the whole image. +- Prefer colors that respond to local pattern values: distance fields, cell seeds, stripe index, phase, brightness, threshold bands, motion direction, edge thickness, density, or mask state. +- Good examples: large values become red while small values become blue; interior/exterior use different palettes; threshold bands step through 3-5 colors; cell IDs pick related colors; moving fronts leave warmer highlights; thin edges are white while filled regions are saturated. +- Both smooth local gradients and stepped posterized color bands are welcome, as long as the color changes are tied to the geometry or signal of the pattern. +- Keep at least some pixels near full LED brightness.`; + +const TECH_COLOR_VFIELD = `Value-field direction (IMPORTANT — there is NO color in this pattern): +- The pattern outputs only a scalar value field via display.setValue(x, y, v). Color is applied OUTSIDE the pattern by a user-controlled color ramp that maps v = 0..1 to colors. +- Do NOT compute any colors, palettes, hues, RGB values, or hsvToRgb helpers anywhere. No color logic at all — pour all effort into geometry, structure, and motion. +- Treat v as the pattern's tonal value and design the field so it reads well under ANY ramp: + - Use the full 0..1 range every frame: some pixels near 0, some near 1. + - Build deliberate tonal structure tied to the geometry: edges, bands, plateaus, gradients driven by distance, phase, cell id, density, or masks. + - Value contrast is your only material — sharp boundaries, layered levels, and clear figure/ground separation matter more than in RGB mode. + - Avoid uniform mid-grey mush (everything hovering near 0.5) and avoid pure binary output unless the idea is intentionally hard-edged. +- Knobs must control geometry/motion/structure, never color (no hue knobs — color belongs to the ramp).`; + +function buildTechGuide(colorMode: ColorMode, matrix: MatrixSize) { + const pixelRule = colorMode === "vfield" ? TECH_PIXEL_VFIELD : TECH_PIXEL_RGB; + const colorSection = colorMode === "vfield" ? TECH_COLOR_VFIELD : TECH_COLOR_RGB; + const api = TECH_API_COMMON.replace("{{MATRIX_LINE}}", buildMatrixAnnotationLine(matrix)); + return `${api}\n${pixelRule}\n\n${TECH_CONTROLS}\n\n${colorSection}`; +} + +// Seed-based direction: remix one existing pattern. +const VARIANT_SEED_DIRECTION = `Variation direction: +- Keep the general intent and the four control roles understandable, but do not copy the original structure too literally. +- At least 3 of the 5 variations must change the main drawing algorithm, not only constants, colors, thresholds, or speed. +- Avoid making all 5 outputs feel like the same pattern with different parameter values. +- Do not reuse the same grid, shape, distance formula, or composition in every variation. +- Give each variation a different dominant idea. Use these five directions: + 1. Structural remix: change the main geometry or repetition system. + 2. Motion remix: change how time moves through the pattern. + 3. Palette/material remix: change color logic, brightness rhythm, or foreground/background relationship. + 4. Domain remix: warp, mirror, fold, scroll, rotate, or otherwise remap coordinates. + 5. Contrast remix: make a clearly different sparse/dense, hard/soft, or organic/mechanical interpretation. +- The variations can be bold. They should still feel related to the seed, but not trapped inside its exact look. +- Keep the patterns bright enough for an LED matrix and reasonably ESP32-friendly. +- Avoid smoothing/lerping knob-controlled values unless the visual idea specifically needs inertia.`; + + +// The model returns 5 markdown code blocks, in the ColorMode picked beside the +// button. +export function buildVariantCopyPrompt( + code: string, + knobs: number[], + ranges: Array<[number, number]>, + colorMode: ColorMode = "rgb", + matrix: MatrixSize = { width: 128, height: 64 }, +) { + return `${variantIntro(matrix)} + +Very important output rules: +- Return exactly 5 separate JavaScript code blocks. +- Each code block must be a complete standalone Patternflow pattern. +- Do not combine the 5 variations into one file. +- Do not add a mode selector, preset array, switch statement, or any code that contains multiple patterns in one output. +- Do not write wrapper text inside the code blocks. +- Put a short variation name before each code block. +- Do not include nested triple backticks inside any code block. + +${buildTechGuide(colorMode, matrix)} + +${VARIANT_SEED_DIRECTION} + +Current Pattern Lab controls: +${controlLines(knobs, ranges)} + +Existing pattern${ + colorMode === "vfield" + ? " (NOTE: if this seed calls display.setPixel and computes colors, it is an older RGB-mode pattern — take only its geometry/motion ideas; every variation you output must use display.setValue(x, y, v) with NO color logic)" + : "" + }: +\`\`\`javascript +${code} +\`\`\``; +} diff --git a/web/src/lib/atlas/data.ts b/web/src/lib/atlas/data.ts index 4483331e5..b3e1cf89a 100644 --- a/web/src/lib/atlas/data.ts +++ b/web/src/lib/atlas/data.ts @@ -13,9 +13,10 @@ // prompt is English-only — it is written for AIs, not for readers. // // The generation prompt (BASE) mirrors Pattern Lab's COPY PROMPT assembly -// (lib/ai/gemini.ts: buildVariantCopyPrompt → buildTechGuide) verbatim, with the -// atlas taste/craft directions layered where the lab puts VARIANT_*_DIRECTION. -// If gemini.ts's wording changes, change it here too. +// (lib/ai/variantPrompt.ts: buildVariantCopyPrompt → buildTechGuide) +// verbatim, with the atlas taste/craft directions layered where the lab puts +// VARIANT_SEED_DIRECTION. If variantPrompt.ts's wording changes, change it +// here too. export type AtlasFamilyId = | "ink" | "waterfall" | "optics" | "advect" diff --git a/web/src/lib/campaignRoutes.ts b/web/src/lib/campaignRoutes.ts index a4edabb0e..ed10345c2 100644 --- a/web/src/lib/campaignRoutes.ts +++ b/web/src/lib/campaignRoutes.ts @@ -9,6 +9,12 @@ export type CampaignRoute = { }; }; +// Short links for the v2.0.0 launch, added 12 May 2026 (39fd47b7). Each was +// made for one place to post it, which utm_source and utm_medium name: four +// subreddits, Hacker News, Instagram, PCBWay's open-source project page and X. +// A post that is still up still links one of these, so they stay. +// next.config.ts rewrites each to its destination, and posthogEvents.ts tags +// the visit from the same entry. export const CAMPAIGN_ROUTES = [ { path: "/r/arduino", diff --git a/web/src/lib/community/cors.test.ts b/web/src/lib/community/cors.test.ts new file mode 100644 index 000000000..57423c29b --- /dev/null +++ b/web/src/lib/community/cors.test.ts @@ -0,0 +1,124 @@ +// originBlocked() is the first thing 29 community routes call. It is what +// stops a cross-site write: a like toggle sent with no Content-Type goes out +// without a preflight, so by the time the browser hides the answer the like +// has been saved. The smoke scripts leave Origin off on purpose, so this file +// is where the answer to a real browser's Origin is checked. + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { originBlocked, preflight, withCors } from "./cors"; + +const SITE = "https://patternflow.work"; +const COMMUNITY = "https://community.patternflow.work"; + +/** A request as it reaches the community host, with the headers given. */ +function request(headers: Record<string, string>, method = "POST"): Request { + return new Request(`${COMMUNITY}/api/community/patterns/p1/like`, { + method, + headers: { host: "community.patternflow.work", ...headers }, + }); +} + +const saved = process.env.COMMUNITY_ALLOWED_ORIGINS; + +beforeEach(() => { + // The spacing is how a hand-edited env file tends to look. + process.env.COMMUNITY_ALLOWED_ORIGINS = ` ${SITE} , http://localhost:3000`; +}); + +afterEach(() => { + if (saved === undefined) delete process.env.COMMUNITY_ALLOWED_ORIGINS; + else process.env.COMMUNITY_ALLOWED_ORIGINS = saved; +}); + +describe("originBlocked", () => { + it("lets a request with no Origin through to the session check", () => { + expect(originBlocked(request({}))).toBeNull(); + }); + + it("lets the community host's own pages through", () => { + expect(originBlocked(request({ origin: COMMUNITY }))).toBeNull(); + }); + + it("lets an origin on the allow-list through", () => { + expect(originBlocked(request({ origin: SITE }))).toBeNull(); + expect(originBlocked(request({ origin: "http://localhost:3000" }))).toBeNull(); + }); + + it("refuses any other site with a 403", async () => { + const blocked = originBlocked(request({ origin: "https://example.com" })); + expect(blocked?.status).toBe(403); + expect(await blocked?.json()).toEqual({ error: "Origin not allowed." }); + }); + + it("matches whole origins, not the start of one", () => { + for (const origin of [ + "https://patternflow.work.example.com", + "https://community.patternflow.work.example.com", + "https://patternflow.work:8443", + `${SITE}/`, + ]) { + expect(originBlocked(request({ origin }))?.status, origin).toBe(403); + } + }); + + it("refuses the plain-http twin of an allowed origin", () => { + expect(originBlocked(request({ origin: "http://patternflow.work" }))?.status).toBe(403); + expect(originBlocked(request({ origin: "http://community.patternflow.work" }))?.status).toBe(403); + }); + + it("refuses the opaque origin a sandboxed frame or a file sends", () => { + expect(originBlocked(request({ origin: "null" }))?.status).toBe(403); + }); + + it("takes the scheme from the proxy in front of it", () => { + // A tunnel that ends TLS hands the Pi plain http and says what it was. + const tunnelled = { host: "pi.local:3000", "x-forwarded-proto": "http" }; + expect(originBlocked(request({ ...tunnelled, origin: "http://pi.local:3000" }))).toBeNull(); + expect(originBlocked(request({ ...tunnelled, origin: "https://pi.local:3000" }))?.status).toBe(403); + }); + + it("reads a local host as http", () => { + expect(originBlocked(request({ host: "localhost:3100", origin: "http://localhost:3100" }))).toBeNull(); + expect(originBlocked(request({ host: "127.0.0.1:3100", origin: "http://127.0.0.1:3100" }))).toBeNull(); + }); + + it("refuses everyone but its own host when the allow-list is unset", () => { + delete process.env.COMMUNITY_ALLOWED_ORIGINS; + expect(originBlocked(request({ origin: COMMUNITY }))).toBeNull(); + expect(originBlocked(request({ origin: SITE }))?.status).toBe(403); + }); +}); + +describe("withCors", () => { + it("names an allowed origin back, with credentials", () => { + const response = withCors(request({ origin: SITE }), Response.json({ liked: true })); + expect(response.headers.get("access-control-allow-origin")).toBe(SITE); + expect(response.headers.get("access-control-allow-credentials")).toBe("true"); + expect(response.headers.get("vary")).toBe("Origin"); + }); + + it("adds nothing for a foreign origin or a request without one", () => { + const cases: Record<string, string>[] = [{ origin: "https://example.com" }, {}]; + for (const headers of cases) { + const response = withCors(request(headers), Response.json({ liked: true })); + expect(response.headers.get("access-control-allow-origin")).toBeNull(); + expect(response.headers.get("access-control-allow-credentials")).toBeNull(); + } + }); +}); + +describe("preflight", () => { + it("answers an allowed origin with the methods and the one header the routes take", () => { + const response = preflight(request({ origin: SITE }, "OPTIONS")); + expect(response.status).toBe(204); + expect(response.headers.get("access-control-allow-origin")).toBe(SITE); + expect(response.headers.get("access-control-allow-methods")).toBe("GET, POST, PATCH, DELETE, OPTIONS"); + expect(response.headers.get("access-control-allow-headers")).toBe("Content-Type"); + }); + + it("refuses any other origin with a bare 403", () => { + const response = preflight(request({ origin: "https://example.com" }, "OPTIONS")); + expect(response.status).toBe(403); + expect(response.headers.get("access-control-allow-origin")).toBeNull(); + }); +}); diff --git a/web/src/lib/community/deck.test.ts b/web/src/lib/community/deck.test.ts new file mode 100644 index 000000000..d6de01caf --- /dev/null +++ b/web/src/lib/community/deck.test.ts @@ -0,0 +1,93 @@ +// The deck when the browser will not keep it. With site data blocked, merely +// reading `window.localStorage` throws a SecurityError, and the dock that +// draws the deck sits on every community page: a throw here took the whole +// page down to "This page couldn't load". A private mode that refuses writes is +// the milder case. Either way the deck should still work for the visit. + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { CollectedPattern } from "./deck"; + +const item = (n: number): CollectedPattern => ({ + patternId: `p${n}`, + title: `Pattern ${n}`, + code: "#pragma once", +}); + +// deck.ts keeps what storage refused in module state, so each test starts +// from a fresh copy of the module. +async function freshDeck() { + vi.resetModules(); + return import("./deck"); +} + +function blockSiteData() { + vi.spyOn(window, "localStorage", "get").mockImplementation(() => { + throw new DOMException("The operation is insecure.", "SecurityError"); + }); +} + +beforeEach(() => localStorage.clear()); + +describe("deck with site data blocked", () => { + it("reads as empty instead of throwing", async () => { + blockSiteData(); + const deck = await freshDeck(); + expect(deck.deckItems()).toEqual([]); + expect(deck.deckHas("p1")).toBe(false); + }); + + it("keeps what was collected for the rest of the visit", async () => { + blockSiteData(); + const deck = await freshDeck(); + const heard = vi.fn(); + window.addEventListener(deck.COLLECTION_EVENT, heard); + + expect(deck.deckAdd(item(1))).toEqual({ ok: true }); + deck.deckAdd(item(2)); + deck.deckAdd(item(3)); + expect(deck.deckHas("p2")).toBe(true); + + deck.deckMove("p3", -1); + deck.deckReorder(0, 2); + expect(deck.deckItems().map((i) => i.patternId)).toEqual(["p3", "p2", "p1"]); + + deck.deckRemove("p2"); + expect(deck.deckItems().map((i) => i.patternId)).toEqual(["p3", "p1"]); + expect(heard).toHaveBeenCalledTimes(6); + window.removeEventListener(deck.COLLECTION_EVENT, heard); + }); + + it("still stops at the cap", async () => { + blockSiteData(); + const deck = await freshDeck(); + for (let n = 1; n <= deck.DECK_MAX; n += 1) deck.deckAdd(item(n)); + expect(deck.deckAdd(item(99)).ok).toBe(false); + expect(deck.deckItems()).toHaveLength(deck.DECK_MAX); + }); +}); + +describe("deck when storage refuses writes", () => { + it("shows the deck the person just changed, not the stale stored one", async () => { + localStorage.setItem("pf-deck", JSON.stringify([item(1)])); + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new DOMException("The quota has been exceeded.", "QuotaExceededError"); + }); + const deck = await freshDeck(); + + deck.deckAdd(item(2)); + expect(deck.deckItems().map((i) => i.patternId)).toEqual(["p1", "p2"]); + deck.deckClear(); + expect(deck.deckItems()).toEqual([]); + }); +}); + +describe("deck with storage working", () => { + it("writes through, so another tab or the next visit sees it", async () => { + const deck = await freshDeck(); + deck.deckAdd(item(1)); + expect(JSON.parse(localStorage.getItem("pf-deck") ?? "[]")).toEqual([item(1)]); + + const nextVisit = await freshDeck(); + expect(nextVisit.deckItems()).toEqual([item(1)]); + }); +}); diff --git a/web/src/lib/community/deck.ts b/web/src/lib/community/deck.ts index 47dccc482..ed7a1b22b 100644 --- a/web/src/lib/community/deck.ts +++ b/web/src/lib/community/deck.ts @@ -119,12 +119,20 @@ export function openDeckPanel(): void { } const DECK_KEY = "pf-deck"; -/** What the deck was called when it was the only list. Migrated once, on read. */ +/** Written by the module cart from 27 July 2026 (2caa602) until the cart + * became the deck on 29 July (f3f163b). Moved to the deck on the first read + * that finds it. */ const LEGACY_CART_KEY = "pf-module-cart"; -/** The removed Saved list. Cleared on first read so it does not sit in +/** The Saved list, written from 29 July 2026 (f3f163b) until it was removed on + * 12 August (a0f14bc). Cleared on first read so it does not sit in * people's browsers forever holding copies of patterns nothing can show. */ const RETIRED_SAVED_KEY = "pf-saved"; +/** What storage refused to keep: blocked site data, or a private mode that + * throws on write. Held for as long as the page lives, and read ahead of + * storage so the deck shows what the person just did, not the copy before. */ +const unsaved = new Map<string, CollectedPattern[]>(); + function parse(raw: string | null): CollectedPattern[] { if (!raw) return []; try { @@ -145,29 +153,41 @@ function parse(raw: string | null): CollectedPattern[] { function read(key: string): CollectedPattern[] { if (typeof window === "undefined") return []; - // Anyone who had a cart before this split keeps it, as their deck. Done on - // read rather than in a migration step so it works whenever they next visit, - // from whichever page they land on. - if (key === DECK_KEY) { - const legacy = window.localStorage.getItem(LEGACY_CART_KEY); - if (legacy !== null && window.localStorage.getItem(DECK_KEY) === null) { - window.localStorage.setItem(DECK_KEY, legacy); - window.localStorage.removeItem(LEGACY_CART_KEY); + // A copy: the callers edit what they get back before writing it. + const held = unsaved.get(key); + if (held) return [...held]; + try { + // Anyone who had a cart before this split keeps it, as their deck. Done on + // read rather than in a migration step so it works whenever they next + // visit, from whichever page they land on. + if (key === DECK_KEY) { + const legacy = window.localStorage.getItem(LEGACY_CART_KEY); + if (legacy !== null && window.localStorage.getItem(DECK_KEY) === null) { + window.localStorage.setItem(DECK_KEY, legacy); + window.localStorage.removeItem(LEGACY_CART_KEY); + } + // Same idea for the list that went away: nothing reads it now, and + // leaving it behind would keep a copy of somebody's saved patterns in + // their browser indefinitely with no screen able to show or clear it. + window.localStorage.removeItem(RETIRED_SAVED_KEY); } - // Same idea for the list that went away: nothing reads it now, and leaving - // it behind would keep a copy of somebody's saved patterns in their - // browser indefinitely with no screen able to show or clear it. - window.localStorage.removeItem(RETIRED_SAVED_KEY); + return parse(window.localStorage.getItem(key)); + } catch { + // Site data blocked: reading `window.localStorage` at all throws a + // SecurityError. The dock reads this on every community page, so letting + // it through turned each of them into "This page couldn't load". + return []; } - return parse(window.localStorage.getItem(key)); } function write(key: string, items: CollectedPattern[]): void { try { window.localStorage.setItem(key, JSON.stringify(items)); + unsaved.delete(key); } catch { - // Private mode: the list degrades to per-page state; the event still fires - // so the UI reflects what the visitor just did. + // Private mode or blocked site data: the list degrades to per-page state; + // the event still fires so the UI reflects what the visitor just did. + unsaved.set(key, [...items]); } window.dispatchEvent(new CustomEvent(COLLECTION_EVENT)); } diff --git a/web/src/lib/community/deviceHost.test.ts b/web/src/lib/community/deviceHost.test.ts new file mode 100644 index 000000000..02524a100 --- /dev/null +++ b/web/src/lib/community/deviceHost.test.ts @@ -0,0 +1,70 @@ +// The board address as people type it. An emptied field used to make +// http:///patterns?src=…, which a browser reads as a host called "patterns", +// and an address pasted from the console's own tab (http://192.168.0.44/) +// made a host called "http". + +import { act, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { deviceHostName, useDeviceHost } from "./deviceHost"; + +afterEach(() => { + window.localStorage.clear(); +}); + +describe("deviceHostName", () => { + it("falls back to patternflow.local when the field is empty", () => { + expect(deviceHostName("")).toBe("patternflow.local"); + expect(deviceHostName(" ")).toBe("patternflow.local"); + expect(deviceHostName("http://")).toBe("patternflow.local"); + }); + + it("drops the scheme and anything after the host", () => { + expect(deviceHostName("http://192.168.0.44")).toBe("192.168.0.44"); + expect(deviceHostName(" HTTP://192.168.0.44/ ")).toBe("192.168.0.44"); + expect(deviceHostName("https://patternflow.local/patterns")).toBe("patternflow.local"); + expect(deviceHostName("192.168.0.44/")).toBe("192.168.0.44"); + expect(deviceHostName("192.168.0.44/?src=x#top")).toBe("192.168.0.44"); + }); + + it("keeps a plain host and its port as typed", () => { + expect(deviceHostName("patternflow.local")).toBe("patternflow.local"); + expect(deviceHostName("192.168.0.44:8080")).toBe("192.168.0.44:8080"); + expect(deviceHostName("patternflow")).toBe("patternflow"); + }); +}); + +describe("useDeviceHost", () => { + const patternsHost = (stored: string) => { + window.localStorage.setItem("pf-device-host", stored); + const { result } = renderHook(() => useDeviceHost()); + const url = new URL(result.current.patternsUrl("/api/community/patterns/p123/zip")); + return { url, result }; + }; + + it("sends an emptied field to patternflow.local, not to a host called patterns", () => { + const { url, result } = patternsHost(""); + expect(url.host).toBe("patternflow.local"); + expect(url.pathname).toBe("/patterns"); + expect(result.current.host).toBe("patternflow.local"); + // The field itself still shows what was typed: nothing, over the placeholder. + expect(result.current.deviceHost).toBe(""); + }); + + it("sends a pasted http:// address to the board, not to a host called http", () => { + const { url, result } = patternsHost("http://192.168.0.44/"); + expect(url.host).toBe("192.168.0.44"); + expect(url.pathname).toBe("/patterns"); + expect(url.searchParams.get("src")).toBe( + `${window.location.origin}/api/community/patterns/p123/zip`, + ); + expect(result.current.host).toBe("192.168.0.44"); + expect(result.current.deviceHost).toBe("http://192.168.0.44/"); + }); + + it("follows the field when it changes", () => { + const { result } = patternsHost("patternflow.local"); + act(() => result.current.changeDeviceHost("https://10.0.0.7/")); + expect(result.current.host).toBe("10.0.0.7"); + expect(new URL(result.current.patternsUrl("/x")).host).toBe("10.0.0.7"); + }); +}); diff --git a/web/src/lib/community/deviceHost.ts b/web/src/lib/community/deviceHost.ts index 301586421..be140d844 100644 --- a/web/src/lib/community/deviceHost.ts +++ b/web/src/lib/community/deviceHost.ts @@ -8,6 +8,11 @@ // enough. DeviceHostCapture writes the same key when a panel's console links // here with ?device=<ip>. // +// The field keeps what was typed, and every link is built from +// deviceHostName() of it. An emptied field used to make http:///patterns, +// which a browser reads as a host called "patterns", and an address pasted +// from the console's own tab (http://192.168.0.44/) made a host called "http". +// // The URLs handed to the device must be absolute even when the community API // is same-origin, because it is the *device* that fetches them, from its own // origin on the LAN. @@ -27,6 +32,16 @@ const KEY = "pf-device-host"; const FALLBACK = "patternflow.local"; const CHANGE_EVENT = "pf-device-host-change"; +/** The host a link should go to: no scheme, no path, and the fallback name when empty. */ +export function deviceHostName(value: string): string { + const host = value + .trim() + .replace(/^https?:\/\//i, "") + .split(/[/?#]/)[0] + .trim(); + return host || FALLBACK; +} + function readStored(): string { try { return window.localStorage.getItem(KEY) ?? FALLBACK; @@ -56,21 +71,16 @@ export function useDeviceHost() { window.dispatchEvent(new Event(CHANGE_EVENT)); }; + const host = deviceHostName(deviceHost); + const absolute = (apiPath: string) => new URL(communityApiUrl(apiPath), window.location.origin).toString(); /** Device's pattern manager, told to fetch and install a module build. */ const patternsUrl = (modulesUrl: string) => { if (typeof window === "undefined") return "#"; - return `http://${deviceHost.trim()}/patterns?src=${encodeURIComponent(absolute(modulesUrl))}`; - }; - - /** Device's update page, told to fetch and flash a firmware build. */ - const updateUrl = (buildId: string) => { - if (typeof window === "undefined") return "#"; - const firmware = absolute(`/api/community/builds/${buildId}/firmware`); - return `http://${deviceHost.trim()}/update?src=${encodeURIComponent(firmware)}`; + return `http://${host}/patterns?src=${encodeURIComponent(absolute(modulesUrl))}`; }; - return { deviceHost, changeDeviceHost, patternsUrl, updateUrl }; + return { deviceHost, host, changeDeviceHost, patternsUrl }; } diff --git a/web/src/lib/community/download.ts b/web/src/lib/community/download.ts index 5029db984..77532f1b8 100644 --- a/web/src/lib/community/download.ts +++ b/web/src/lib/community/download.ts @@ -75,7 +75,3 @@ export function downloadPatternHeader(pattern: DownloadablePattern, cpp: string) export function licensedJsText(pattern: DownloadablePattern, code: string) { return buildSharedPatternFile(code, shareMetaFor(pattern)); } - -export function licensedHeaderText(pattern: DownloadablePattern, cpp: string) { - return buildSharedHeaderFile(cpp, shareMetaFor(pattern)); -} diff --git a/web/src/lib/community/jsonBody.test.ts b/web/src/lib/community/jsonBody.test.ts new file mode 100644 index 000000000..e01872344 --- /dev/null +++ b/web/src/lib/community/jsonBody.test.ts @@ -0,0 +1,47 @@ +// readJsonObject is the one place the community's write routes turn a body +// into fields. `null`, an array, a number and a string all parse as JSON, and +// none of them has fields to read. The routes themselves are driven with +// `null` in app/api/community/nullBody.test.ts. + +import { describe, expect, it } from "vitest"; +import { readJsonObject } from "./jsonBody"; + +const post = (body?: string) => + new Request("http://localhost:3000/api/community/patterns", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + }); + +async function refusal(body?: string) { + const result = await readJsonObject(post(body)); + if (!(result instanceof Response)) throw new Error(`${body} was read as an object`); + return { status: result.status, payload: await result.json() }; +} + +describe("readJsonObject", () => { + it("hands back an object's fields", async () => { + expect(await readJsonObject(post('{"title":"Tide","knobs":[1,2]}'))).toEqual({ + title: "Tide", + knobs: [1, 2], + }); + }); + + it("hands back an empty object, which the partial edits treat as nothing to change", async () => { + expect(await readJsonObject(post("{}"))).toEqual({}); + }); + + it.each(["null", "[]", '[{"title":"Tide"}]', "1", '"Tide"', "true"])( + "answers %s with a 400 that says an object was expected", + async (body) => { + expect(await refusal(body)).toEqual({ status: 400, payload: { error: "Expected a JSON object." } }); + }, + ); + + it.each([["no body", undefined], ["an empty body", ""], ["a cut-off object", '{"title":']])( + "answers %s with a 400 that says the JSON is invalid", + async (_label, body) => { + expect(await refusal(body)).toEqual({ status: 400, payload: { error: "Invalid JSON body." } }); + }, + ); +}); diff --git a/web/src/lib/community/jsonBody.ts b/web/src/lib/community/jsonBody.ts new file mode 100644 index 000000000..06e0f7426 --- /dev/null +++ b/web/src/lib/community/jsonBody.ts @@ -0,0 +1,21 @@ +// The community's write routes all take a JSON object. `request.json()` only +// says the body parsed, and `null` parses: until 22 October 2026 a body of +// `null` got past the `catch` in 22 handlers and threw a TypeError on the +// first field read. Next.js answered with a 500 that had no JSON and no CORS +// headers, so a Pattern Lab on another origin could not read why. + +/** The request's body as an object, or the 400 that says why it is not one. */ +export async function readJsonObject( + request: Request, +): Promise<Record<string, unknown> | Response> { + let body: unknown; + try { + body = await request.json(); + } catch { + return Response.json({ error: "Invalid JSON body." }, { status: 400 }); + } + if (body === null || typeof body !== "object" || Array.isArray(body)) { + return Response.json({ error: "Expected a JSON object." }, { status: 400 }); + } + return body as Record<string, unknown>; +} diff --git a/web/src/lib/community/provenance.test.ts b/web/src/lib/community/provenance.test.ts new file mode 100644 index 000000000..b71db8ccc --- /dev/null +++ b/web/src/lib/community/provenance.test.ts @@ -0,0 +1,155 @@ +// Provenance badges against the lines Pattern Lab writes by itself. Every Lab +// publish carries `@matrix` and every value-field layer carries `@ramp`, +// touched or not. Until 21 October 2026 the badges counted the line alone, so +// a variant published the way it came out of the Lab's AI gallery showed +// "Framed" and "Colour shaped" on its page. + +import { describe, expect, it } from "vitest"; +import { defaultKnobState, withKnobsAnnotation } from "@/lib/lab/annotations"; +import { flattenLayers } from "@/lib/lab/flatten"; +import { DEFAULT_RAMP_STATE, cloneRampState, type CodeLayer, type KnobRange, type RampState } from "@/lib/lab/types"; +import { DEFAULT_MATRIX, withMatrixAnnotation, type MatrixSize } from "@/lib/pattern/matrix"; +import { withRampAnnotation } from "@/lib/pattern/ramp"; +import { MADE_HOW_LABELS } from "./validate"; +import { provenanceFor, provenanceSignals } from "./provenance"; + +const VALUE_FIELD = `export function draw(display, params, time) { + for (let y = 0; y < display.height; y++) { + for (let x = 0; x < display.width; x++) display.setValue(x, y, (x / display.width + time) % 1); + } +}`; + +function codeLayer(id: string, ramp: RampState = cloneRampState(DEFAULT_RAMP_STATE), recolor = false): CodeLayer { + return { + id, + type: "code", + name: id, + visible: true, + opacity: 1, + blend: "normal", + role: "paint", + maskInvert: false, + code: VALUE_FIELD, + ramp, + recolor, + knobsAnnotationRaw: null, + matrixAnnotationRaw: null, + }; +} + +type LabState = { matrix?: MatrixSize; labels?: string[]; ranges?: KnobRange[] }; + +// The single-layer branch of buildExportCode (app/pattern-lab/PatternLabClient.tsx), +// with every stop at full alpha so the premultiply there changes nothing. +function publishedByLab(layer: CodeLayer, state: LabState = {}): string { + const knobs = defaultKnobState(); + let code = withMatrixAnnotation(layer.code, state.matrix ?? DEFAULT_MATRIX); + code = withKnobsAnnotation(code, state.labels ?? knobs.labels, state.ranges ?? knobs.ranges); + return withRampAnnotation(code, { + stops: layer.ramp.stops.map((stop) => ({ position: stop.position, color: stop.color })), + mode: layer.ramp.mode, + wrap: layer.ramp.wrap, + recolor: layer.recolor, + }); +} + +const ids = (code: string, hasHeader = false) => provenanceSignals(code, hasHeader).map((signal) => signal.id); + +describe("provenanceSignals", () => { + it("gives an untouched value-field layer no badge", () => { + const code = publishedByLab(codeLayer("variant")); + expect(code).toContain("// @matrix 128x64"); + expect(code).toContain("// @ramp linear 0:#000000 1:#ffffff"); + expect(ids(code)).toEqual([]); + }); + + it("gives an untouched flattened stack no badge from its knobs or frame", () => { + const knobs = defaultKnobState(); + const code = flattenLayers([codeLayer("top"), codeLayer("bottom")], DEFAULT_MATRIX, { + labels: knobs.labels, + ranges: knobs.ranges, + }); + expect(code).toContain("// @knobs Knob 1=0..1, Knob 2=0.1..10, Knob 3=0..4.9, Knob 4=0..1"); + expect(code).toContain("// @matrix 128x64"); + expect(ids(code)).toEqual([]); + }); + + it("counts a ramp somebody changed", () => { + const recoloured = cloneRampState(DEFAULT_RAMP_STATE); + recoloured.stops[1].color = "#ff4d00"; + expect(ids(publishedByLab(codeLayer("a", recoloured)))).toEqual(["ramp"]); + + const stepped = { ...cloneRampState(DEFAULT_RAMP_STATE), mode: "step" as const }; + expect(ids(publishedByLab(codeLayer("b", stepped)))).toEqual(["ramp"]); + + const wrapped = { ...cloneRampState(DEFAULT_RAMP_STATE), wrap: true }; + expect(ids(publishedByLab(codeLayer("c", wrapped)))).toEqual(["ramp"]); + + const threeStops = cloneRampState(DEFAULT_RAMP_STATE); + threeStops.stops.splice(1, 0, { position: 0.5, color: "#808080", alpha: 1 }); + expect(ids(publishedByLab(codeLayer("d", threeStops)))).toEqual(["ramp"]); + }); + + it("counts recolor switched on over the stock ramp", () => { + expect(ids(publishedByLab(codeLayer("a", cloneRampState(DEFAULT_RAMP_STATE), true)))).toEqual(["ramp"]); + }); + + it("reads the stock ramp in either stop order and either case", () => { + expect(ids("// @ramp linear 1:#FFFFFF 0:#000000\nfunction draw() {}")).toEqual([]); + }); + + it("counts knobs somebody named or retuned", () => { + const knobs = defaultKnobState(); + const named = ["Folds", ...knobs.labels.slice(1)]; + expect(ids(publishedByLab(codeLayer("a"), { labels: named }))).toEqual(["knobs"]); + + const ranges = knobs.ranges.map((range): KnobRange => [range[0], range[1]]); + ranges[1] = [0.1, 3]; + expect(ids(publishedByLab(codeLayer("b"), { ranges }))).toEqual(["knobs"]); + }); + + it("does not count a knobs line that only repeats the stock slots", () => { + expect(ids("// @knobs Knob 1=0..1, -, -, -\nfunction draw() {}")).toEqual([]); + }); + + it("counts any frame other than 128×64, the portrait one too", () => { + const square = provenanceSignals(publishedByLab(codeLayer("a"), { matrix: { width: 64, height: 64 } }), false); + expect(square.map((signal) => signal.id)).toEqual(["matrix"]); + expect(square[0].detail).toBe("Composed for 64×64. Every new pattern starts at 128×64."); + + expect(ids(publishedByLab(codeLayer("b"), { matrix: { width: 64, height: 128 } }))).toEqual(["matrix"]); + }); + + it("ignores lines their own parser throws away", () => { + const code = ["// @ramp sparkly 0:#000000", "// @knobs whatever", "// @matrix 4x4", "function draw() {}"].join("\n"); + expect(ids(code)).toEqual([]); + }); + + it("keeps the layer stack and the hardware header as they were", () => { + expect(ids("// @stack v1:abc\nfunction draw() {}", true)).toEqual(["stack", "hardware"]); + }); + + it("lists every signal in one order", () => { + const ramp = cloneRampState(DEFAULT_RAMP_STATE); + ramp.stops[0].color = "#081840"; + const code = publishedByLab(codeLayer("all", ramp), { + matrix: { width: 64, height: 64 }, + labels: ["Folds", "Speed", "Zoom", "Contrast"], + }); + expect(ids(`${code}\n// @stack v1:abc\n`, true)).toEqual(["ramp", "knobs", "stack", "matrix", "hardware"]); + }); +}); + +describe("provenanceFor", () => { + it("names a made_how the community knows", () => { + const record = provenanceFor("function draw() {}", false, "ai-generated"); + expect(record.madeHow).toBe("ai-generated"); + expect(record.madeHowLabel).toBe(MADE_HOW_LABELS["ai-generated"]); + expect(record.signals).toEqual([]); + }); + + it("drops a made_how it does not know", () => { + const record = provenanceFor("function draw() {}", false, "by robots"); + expect(record).toEqual({ madeHow: null, madeHowLabel: null, madeHowBlurb: null, signals: [] }); + }); +}); diff --git a/web/src/lib/community/provenance.ts b/web/src/lib/community/provenance.ts index 5fa75e61e..77b4cd38e 100644 --- a/web/src/lib/community/provenance.ts +++ b/web/src/lib/community/provenance.ts @@ -1,3 +1,8 @@ +import { applyKnobEntries, defaultKnobState, knobStateIsDefault } from "@/lib/lab/annotations"; +import { DEFAULT_RAMP_STATE } from "@/lib/lab/types"; +import { parseKnobsAnnotation } from "@/lib/pattern/knobs"; +import { DEFAULT_MATRIX, formatMatrix, matrixesEqual, parseMatrixAnnotation } from "@/lib/pattern/matrix"; +import { parseRampAnnotation, type RampAnnotation } from "@/lib/pattern/ramp"; import { MADE_HOW_BLURBS, MADE_HOW_LABELS, type MadeHow } from "./validate"; // ───────────────────────────────────────────────────────────────────────────── @@ -14,6 +19,15 @@ import { MADE_HOW_BLURBS, MADE_HOW_LABELS, type MadeHow } from "./validate"; // // This is deliberately not a score. It is a list of things that happened, shown // as they are, and a pattern with none of them is not accused of anything. +// +// A line is only evidence when it says something other than what Pattern Lab +// writes on its own. The Lab puts `@matrix` on everything it publishes, `@ramp` +// on every value-field layer, and `@knobs` on every flattened stack with code +// in it. Until 21 October 2026 the mere line counted, so an untouched variant +// from the Lab's AI gallery came out "Framed" and "Colour shaped". Each line is now read with its +// own parser and set against the Lab's own defaults: a 128×64 frame, the +// black-to-white linear ramp a new code layer gets with recolor off, and +// Knob 1–4 on their stock ranges. // ───────────────────────────────────────────────────────────────────────────── export type ProvenanceSignal = { @@ -23,22 +37,36 @@ export type ProvenanceSignal = { detail: string; }; -const RAMP_RE = /^\s*\/\/\s*@ramp\b/m; -const KNOBS_RE = /^\s*\/\/\s*@knobs\b/m; const STACK_RE = /^\s*\/\/\s*@stack\b/m; -const MATRIX_RE = /^\s*\/\/\s*@matrix\b/m; + +/** The ramp a new code layer gets (lib/lab/store/shared.ts), as `@ramp` writes it. */ +function isLabDefaultRamp(ramp: RampAnnotation): boolean { + if (ramp.mode !== DEFAULT_RAMP_STATE.mode || ramp.wrap !== DEFAULT_RAMP_STATE.wrap) return false; + if (ramp.recolor) return false; + const stops = [...ramp.stops].sort((a, b) => a.position - b.position); + return ( + stops.length === DEFAULT_RAMP_STATE.stops.length && + stops.every((stop, index) => { + const base = DEFAULT_RAMP_STATE.stops[index]; + return stop.position === base.position && stop.color === base.color.toLowerCase(); + }) + ); +} export function provenanceSignals(code: string, hasHeader: boolean): ProvenanceSignal[] { const signals: ProvenanceSignal[] = []; - if (RAMP_RE.test(code)) { + const ramp = parseRampAnnotation(code); + if (ramp && !isLabDefaultRamp(ramp)) { signals.push({ id: "ramp", label: "Colour shaped", detail: "Carries a colour ramp — the stops and blend mode were chosen by the author.", }); } - if (KNOBS_RE.test(code)) { + const knobEntries = parseKnobsAnnotation(code); + const knobs = knobEntries ? applyKnobEntries(knobEntries, defaultKnobState()) : null; + if (knobs && !knobStateIsDefault(knobs.labels, knobs.ranges)) { signals.push({ id: "knobs", label: "Knobs tuned", @@ -52,11 +80,12 @@ export function provenanceSignals(code: string, hasHeader: boolean): ProvenanceS detail: "Composed from a layer stack in Pattern Lab, not a single generated file.", }); } - if (MATRIX_RE.test(code)) { + const frame = parseMatrixAnnotation(code); + if (frame && !matrixesEqual(frame, DEFAULT_MATRIX)) { signals.push({ id: "matrix", label: "Framed", - detail: "Composed for a specific matrix size.", + detail: `Composed for ${formatMatrix(frame)}. Every new pattern starts at ${formatMatrix(DEFAULT_MATRIX)}.`, }); } if (hasHeader) { diff --git a/web/src/lib/community/ratelimit.test.ts b/web/src/lib/community/ratelimit.test.ts new file mode 100644 index 000000000..61fd0cc85 --- /dev/null +++ b/web/src/lib/community/ratelimit.test.ts @@ -0,0 +1,90 @@ +// The community routes' rate limiter keeps every key in one map and sweeps out +// idle keys once the map passes 10,000. The sweep used to judge every key by +// the window of the call that set it off. A minute-long like then emptied the +// hour-long build window, and somebody who had used their 30 builds for the +// hour got a 31st once enough other keys had piled up. + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const MINUTE = 60_000; +const HOUR = 60 * MINUTE; +const START = Date.parse("2026-10-21T12:00:00Z"); + +/** Moves the clock to `ms` after the start of the test. */ +const at = (ms: number) => vi.setSystemTime(START + ms); + +// The buckets are module state, so each test starts from a fresh copy. +async function freshLimiter() { + vi.resetModules(); + return (await import("./ratelimit")).rateLimit; +} + +beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date"] }); + at(0); +}); + +afterEach(() => vi.useRealTimers()); + +describe("rateLimit", () => { + it("lets `limit` calls through in a window and refuses the next", async () => { + const rateLimit = await freshLimiter(); + for (let i = 0; i < 5; i++) expect(rateLimit("publish:ana", 5, MINUTE)).toBe(true); + expect(rateLimit("publish:ana", 5, MINUTE)).toBe(false); + }); + + it("frees a call once its own window has passed, and refused calls do not count", async () => { + const rateLimit = await freshLimiter(); + expect(rateLimit("publish:ana", 2, MINUTE)).toBe(true); + at(30_000); + expect(rateLimit("publish:ana", 2, MINUTE)).toBe(true); + at(45_000); + expect(rateLimit("publish:ana", 2, MINUTE)).toBe(false); + + // The first call drops out of the window a minute after it was made, not + // a minute after the refusal. + at(MINUTE - 1); + expect(rateLimit("publish:ana", 2, MINUTE)).toBe(false); + at(MINUTE); + expect(rateLimit("publish:ana", 2, MINUTE)).toBe(true); + expect(rateLimit("publish:ana", 2, MINUTE)).toBe(false); + }); + + it("counts each key on its own", async () => { + const rateLimit = await freshLimiter(); + for (let i = 0; i < 5; i++) rateLimit("report:ana", 5, MINUTE); + expect(rateLimit("report:ana", 5, MINUTE)).toBe(false); + expect(rateLimit("report:ben", 5, MINUTE)).toBe(true); + expect(rateLimit("post:ana", 5, MINUTE)).toBe(true); + }); + + it("keeps an hour of builds through a sweep that minute-long keys set off", async () => { + const rateLimit = await freshLimiter(); + for (let i = 0; i < 30; i++) expect(rateLimit("build:ana", 30, HOUR)).toBe(true); + expect(rateLimit("build:ana", 30, HOUR)).toBe(false); + + // Two minutes on, 10,000 people like a pattern. The last like takes the + // map past 10,000 keys and sweeps it with a one-minute window. + at(2 * MINUTE); + for (let i = 0; i < 10_000; i++) rateLimit(`like:u${i}`, 60, MINUTE); + + expect(rateLimit("build:ana", 30, HOUR)).toBe(false); + }); + + it("still sweeps out the keys whose own window has passed", async () => { + const rateLimit = await freshLimiter(); + for (let i = 0; i < 10_000; i++) rateLimit(`like:u${i}`, 60, MINUTE); + rateLimit("build:ana", 30, HOUR); + + at(2 * MINUTE); + const deleted = vi.spyOn(Map.prototype, "delete"); + rateLimit("like:late", 60, MINUTE); + const keys = deleted.mock.calls.map(([key]) => key); + deleted.mockRestore(); + + expect(keys).toHaveLength(10_000); + expect(keys).toContain("like:u0"); + expect(keys).not.toContain("build:ana"); + expect(keys).not.toContain("like:late"); + }); +}); diff --git a/web/src/lib/community/ratelimit.ts b/web/src/lib/community/ratelimit.ts index b2cb712cd..7899cd4fd 100644 --- a/web/src/lib/community/ratelimit.ts +++ b/web/src/lib/community/ratelimit.ts @@ -1,24 +1,28 @@ // In-memory sliding-window rate limiter. This app runs as a single long-lived // process (the Pi), so process memory is a perfectly good store — no Redis. -const buckets = new Map<string, number[]>(); +// Each key remembers its own window, because the cleanup below sweeps keys +// that other routes set with other windows. +const buckets = new Map<string, { windowMs: number; hits: number[] }>(); /** Returns true when the call is allowed, false when over the limit. */ export function rateLimit(key: string, limit: number, windowMs: number): boolean { const now = Date.now(); const cutoff = now - windowMs; - const hits = (buckets.get(key) ?? []).filter((t) => t > cutoff); + const hits = (buckets.get(key)?.hits ?? []).filter((t) => t > cutoff); if (hits.length >= limit) { - buckets.set(key, hits); + buckets.set(key, { windowMs, hits }); return false; } hits.push(now); - buckets.set(key, hits); + buckets.set(key, { windowMs, hits }); - // Opportunistic cleanup so long-idle keys don't accumulate forever. + // Opportunistic cleanup so long-idle keys don't accumulate forever. A key is + // idle when its own window has passed: a like's minute says nothing about + // the hour of builds. if (buckets.size > 10_000) { - for (const [k, v] of buckets) { - if (v.every((t) => t <= cutoff)) buckets.delete(k); + for (const [k, bucket] of buckets) { + if (bucket.hits.every((t) => t <= now - bucket.windowMs)) buckets.delete(k); } } return true; diff --git a/web/src/lib/community/server/builds.ts b/web/src/lib/community/server/builds.ts index c541ba9c0..14e68e64a 100644 --- a/web/src/lib/community/server/builds.ts +++ b/web/src/lib/community/server/builds.ts @@ -14,9 +14,13 @@ import { builds } from "./schema"; export type BuildStatus = "queued" | "running" | "done" | "error"; /** - * "bin" — a whole flashable image (legacy; still what pre-loader firmware - * needs). "pfm" — loadable modules, zipped, installed over Wi-Fi with no - * reflash. See moduleRunner.ts for why the difference is 14 s vs ½ s. + * "pfm" — loadable modules, zipped, installed over Wi-Fi with no reflash. + * "bin" — a whole flashable image. The community built those from 25 July 2026 + * (c5e0907) until 12 August (4c13474), and POST /builds has answered "bin" + * with 410 since. The worker builds modules whatever a row says (processSend + * in buildJobs.ts). "bin" is still the default here and on the column + * (schema.ts), though every caller in the repository passes "pfm". + * See moduleRunner.ts for why the difference is 14 s vs ½ s. */ export type BuildFormat = "bin" | "pfm"; diff --git a/web/src/lib/community/server/deckZip.ts b/web/src/lib/community/server/deckZip.ts index 481aa64a8..9b9907b58 100644 --- a/web/src/lib/community/server/deckZip.ts +++ b/web/src/lib/community/server/deckZip.ts @@ -43,7 +43,6 @@ import { deckPatterns } from "./schema"; export type DeckSkipReason = | "no-header" | "compile-error" - | "duplicate-name" | "missing" /** The build service could not compile it just now (not the code's fault). */ | "unavailable"; diff --git a/web/src/lib/community/server/schema.ts b/web/src/lib/community/server/schema.ts index 6a589c034..475ef5c5a 100644 --- a/web/src/lib/community/server/schema.ts +++ b/web/src/lib/community/server/schema.ts @@ -626,11 +626,13 @@ export const builds = sqliteTable( /** queued → running → done | error */ status: text("status").notNull().default("queued"), /** - * What the worker produces. "bin" is a whole flashable image — the legacy - * path, and still what a device running firmware older than the module - * loader needs. "pfm" is loadable modules: one .pfm per pattern, zipped, - * built in ~½ s and installed over Wi-Fi at the device's /patterns page - * with no reflash. + * What the worker produces. "pfm" is loadable modules: one .pfm per + * pattern, zipped, built in ~½ s and installed over Wi-Fi at the device's + * /patterns page with no reflash. "bin" was a whole flashable image, built + * from 25 July 2026 until 12 August (4c13474), and the builds route + * answers it with 410 now. It is the default because + * 0005_add-build-format.sql added this column on 27 July under the image + * builds already in the table. */ format: text("format").notNull().default("bin"), /** JSON: [{ label, code }] — the C++ headers to build. */ diff --git a/web/src/lib/community/useModuleBuild.test.ts b/web/src/lib/community/useModuleBuild.test.ts new file mode 100644 index 000000000..8d8205bdc --- /dev/null +++ b/web/src/lib/community/useModuleBuild.test.ts @@ -0,0 +1,273 @@ +// useModuleBuild — queueing a .pfm build and waiting for it, which the deck +// bar, the deck panel and the Send window all do. The route is the server +// smokes' business; this is the waiting. It stops when the build settles, and +// stops when the server has been quiet for longer than the build was promised. +// An old build's answer never lands on top of a newer one. + +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { captureEvent } from "@/lib/posthogEvents"; +import { buildExpectedS, buildTimeoutMs, useModuleBuild, type ModuleBuildState } from "./useModuleBuild"; + +vi.mock("@/lib/posthogEvents", () => ({ captureEvent: vi.fn() })); + +function answer(body: unknown, status = 200) { + return Promise.resolve( + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }), + ); +} + +function state(status: ModuleBuildState["status"], extra: Partial<ModuleBuildState> = {}) { + return { + id: "b1", + status, + queuePosition: null, + elapsedMs: null, + bytes: null, + error: null, + namespaces: [], + modulesUrl: null, + ...extra, + } satisfies ModuleBuildState; +} + +/** A fetch that answers when the test says so. */ +function deferred() { + let resolve: (response: Response) => void = () => {}; + const promise = new Promise<Response>((done) => { + resolve = done; + }); + return { + promise, + answer: (body: unknown) => resolve(new Response(JSON.stringify(body), { status: 200 })), + }; +} + +const WAVE = { label: "Wave", code: "// wave" }; +const deckOf = (n: number) => Array.from({ length: n }, (_, i) => ({ label: `P${i + 1}`, code: `// ${i}` })); + +let fetchMock: ReturnType<typeof vi.fn>; + +beforeEach(() => { + vi.useFakeTimers({ toFake: ["setInterval", "clearInterval", "setTimeout", "clearTimeout", "Date"] }); + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +/** Let pending promises settle, then move the clock. */ +async function tick(ms = 0) { + await act(async () => { + await vi.advanceTimersByTimeAsync(ms); + }); +} + +const polls = () => fetchMock.mock.calls.filter((call) => String(call[0]).includes("/builds/")).length; + +describe("module build", () => { + it("expects longer of a bigger build and waits five seconds past that", () => { + expect(buildExpectedS(1)).toBe(8); + expect(buildTimeoutMs(1)).toBe(13_000); + expect(buildExpectedS(10)).toBe(21); + expect(buildTimeoutMs(10)).toBe(26_000); + // An empty list is never sent, but the sum still treats it as one. + expect(buildExpectedS(0)).toBe(buildExpectedS(1)); + }); + + it("queues the patterns in order and stops asking once the build is done", async () => { + fetchMock + .mockImplementationOnce(() => answer({ id: "b1" }, 202)) + .mockImplementationOnce(() => answer(state("running"))) + .mockImplementationOnce(() => + answer(state("done", { namespaces: ["p1", "p2"], bytes: 12_288, modulesUrl: "/m" })), + ); + const { result } = renderHook(() => useModuleBuild()); + + act(() => { + void result.current.start(deckOf(2)); + }); + expect(result.current.busy).toBe(true); + expect(result.current.running).toBe(true); + await tick(); + expect(result.current.busy).toBe(false); + expect(result.current.build?.status).toBe("running"); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("/api/community/builds"); + expect(init.method).toBe("POST"); + expect(init.credentials).toBe("include"); + expect(JSON.parse(String(init.body))).toEqual({ format: "pfm", patterns: deckOf(2) }); + + await tick(1000); + expect(result.current.build?.status).toBe("done"); + expect(result.current.running).toBe(false); + expect(result.current.error).toBeNull(); + + await tick(30_000); + expect(polls()).toBe(2); + expect(captureEvent).toHaveBeenCalledWith("module_build_queued", { patterns: 2 }); + expect(captureEvent).toHaveBeenCalledWith("module_build_finished", { + status: "done", + patterns: 2, + bytes: 12_288, + }); + }); + + it("counts the seconds and says when the build is past what it was promised", async () => { + fetchMock + .mockImplementationOnce(() => answer({ id: "b1" })) + .mockImplementation(() => answer(state("running"))); + const { result } = renderHook(() => useModuleBuild()); + + act(() => { + void result.current.start([WAVE]); + }); + await tick(); + expect(result.current.waited).toBe(0); + expect(result.current.expectedS).toBe(8); + await tick(3000); + expect(result.current.waited).toBe(3); + await tick(5000); + expect(result.current.waited).toBeGreaterThanOrEqual(result.current.expectedS); + expect(result.current.running).toBe(true); + }); + + it.each([ + [1, 13], + [10, 26], + ])("gives up on a %i-pattern build after %i seconds of a server that keeps saying running", async (n, seconds) => { + fetchMock + .mockImplementationOnce(() => answer({ id: "b1" })) + .mockImplementation(() => answer(state("running"))); + const { result } = renderHook(() => useModuleBuild()); + + act(() => { + void result.current.start(deckOf(n)); + }); + await tick(); + await tick(seconds * 1000); + // Exactly at the limit it is still listening. + expect(result.current.running).toBe(true); + expect(result.current.error).toBeNull(); + + await tick(1000); + expect(result.current.running).toBe(false); + expect(result.current.build).toBeNull(); + expect(result.current.error).toContain(`has not answered in ${seconds} seconds`); + expect(result.current.error).toContain("may still be running"); + expect(captureEvent).toHaveBeenCalledWith("module_build_finished", { status: "timeout", patterns: n }); + + const asked = polls(); + await tick(60_000); + expect(polls()).toBe(asked); + }); + + it("gives up on time even when the requests themselves hang", async () => { + fetchMock + .mockImplementationOnce(() => answer({ id: "b1" })) + .mockImplementation(() => new Promise<Response>(() => {})); + const { result } = renderHook(() => useModuleBuild()); + + act(() => { + void result.current.start([WAVE]); + }); + await tick(); + await tick(14_000); + expect(result.current.running).toBe(false); + expect(result.current.error).toContain("has not answered in 13 seconds"); + }); + + it("says why the server would not queue it, and does not start asking", async () => { + fetchMock.mockImplementationOnce(() => answer({ error: "Too many builds — wait a minute." }, 429)); + const { result } = renderHook(() => useModuleBuild()); + + act(() => { + void result.current.start([WAVE]); + }); + await tick(5000); + expect(result.current.error).toBe("Too many builds — wait a minute."); + expect(result.current.build).toBeNull(); + expect(result.current.running).toBe(false); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("reads a request that never arrived as the network, not the build", async () => { + fetchMock.mockImplementationOnce(() => Promise.reject(new TypeError("Failed to fetch"))); + const { result } = renderHook(() => useModuleBuild()); + + act(() => { + void result.current.start([WAVE]); + }); + await tick(); + expect(result.current.error).toBe("Network error — is the build server reachable?"); + expect(result.current.running).toBe(false); + }); + + it("keeps a finished build when a slower answer from an earlier tick arrives after it", async () => { + const slow = deferred(); + fetchMock + .mockImplementationOnce(() => answer({ id: "b1" })) + .mockImplementationOnce(() => slow.promise) + .mockImplementationOnce(() => answer(state("done", { modulesUrl: "/m" }))); + const { result } = renderHook(() => useModuleBuild()); + + act(() => { + void result.current.start([WAVE]); + }); + await tick(); + await tick(1000); + expect(result.current.build?.status).toBe("done"); + + await act(async () => { + slow.answer(state("running")); + }); + expect(result.current.build?.status).toBe("done"); + }); + + it("forgets a build on reset, and an answer still on its way does not bring it back", async () => { + const late = deferred(); + fetchMock + .mockImplementationOnce(() => answer({ id: "b1" })) + .mockImplementationOnce(() => late.promise); + const { result } = renderHook(() => useModuleBuild()); + + act(() => { + void result.current.start([WAVE]); + }); + await tick(); + act(() => { + result.current.reset(); + }); + await act(async () => { + late.answer(state("done", { modulesUrl: "/m" })); + }); + expect(result.current.build).toBeNull(); + expect(result.current.running).toBe(false); + + await tick(30_000); + expect(polls()).toBe(1); + }); + + it("stops asking when the window that started it goes away", async () => { + fetchMock + .mockImplementationOnce(() => answer({ id: "b1" })) + .mockImplementation(() => answer(state("running"))); + const { result, unmount } = renderHook(() => useModuleBuild()); + + act(() => { + void result.current.start([WAVE]); + }); + await tick(2000); + const asked = polls(); + unmount(); + await tick(30_000); + expect(polls()).toBe(asked); + }); +}); diff --git a/web/src/lib/community/useModuleBuild.ts b/web/src/lib/community/useModuleBuild.ts new file mode 100644 index 000000000..649db9701 --- /dev/null +++ b/web/src/lib/community/useModuleBuild.ts @@ -0,0 +1,218 @@ +"use client"; + +// Queue a build of loadable modules (.pfm) and wait for it, giving up when it +// has been too long. +// +// Three places build: the deck bar along the bottom of the community +// (DeckDock), the deck panel behind the header chip (DeckPanel, the deck's +// only way in on a phone, where the bar is hidden), and "Send to my +// Patternflow" for code that exists only in this browser (SendModuleModal, +// which Pattern Lab's To hardware window opens too). Each used to carry its +// own copy of this, and only the bar's ever stopped asking. With the build +// worker down, the other two kept a spinner turning beside "usually a second +// or two" until the window was closed. + +import { useCallback, useEffect, useRef, useState } from "react"; +import { COMMUNITY_FETCH_INIT, communityApiUrl } from "@/lib/community/apiBase"; +import { captureEvent } from "@/lib/posthogEvents"; + +export type ModuleBuildState = { + id: string; + status: "queued" | "running" | "done" | "error"; + queuePosition: number | null; + elapsedMs: number | null; + bytes: number | null; + error: string | null; + namespaces: string[]; + modulesUrl: string | null; +}; + +/** One pattern as POST /api/community/builds takes it. */ +export type ModuleBuildPattern = { label: string; code: string }; + +/** + * What a build is told to expect, and when to stop believing it. + * + * Both scale with the build, because both used to be flat numbers sized for a + * ten-pattern cap — and a full deck at the current cap would have hit the + * timeout while the server was still working perfectly well, which reads as a + * failure and is not one. + * + * Each pattern compiles alone in about half a second and there is no firmware + * image to link. The per-pattern allowance is far more than that on purpose: + * it covers a cold compiler and a Pi that is also serving the site, without + * promising something the machine cannot keep. + * + * The timeout is the browser giving up on hearing back, not a cancellation — + * the build may well still be running — so the message says that rather than + * claiming it failed. + */ +const BUILD_BASE_S = 6; +const BUILD_PER_PATTERN_S = 1.5; +export const buildExpectedS = (patterns: number) => + Math.ceil(BUILD_BASE_S + BUILD_PER_PATTERN_S * Math.max(1, patterns)); +export const buildTimeoutMs = (patterns: number) => (buildExpectedS(patterns) + 5) * 1000; + +const POLL_MS = 1000; + +const finished = (state: ModuleBuildState | null) => + state !== null && (state.status === "done" || state.status === "error"); + +export function useModuleBuild() { + const [build, setBuild] = useState<ModuleBuildState | null>(null); + // Why there is no build to show: the server would not queue it, the network + // dropped the request, or it stopped answering. A build the server ran and + // rejected is `build.status === "error"` instead, with its log. + const [error, setError] = useState<string | null>(null); + // The request that queues it is in flight. + const [busy, setBusy] = useState(false); + // Whole seconds since the build was queued. Shown while waiting. + const [waited, setWaited] = useState(0); + const [patterns, setPatterns] = useState(0); + + const pollRef = useRef<number | null>(null); + // Bumped by every start and reset, so an answer for an older build is + // dropped rather than drawn over whatever replaced it. + const token = useRef(0); + // Not part of the token: Strict Mode unmounts and mounts once on purpose, + // and the request a mount effect started has to survive that. + const mounted = useRef(false); + + const stopPolling = useCallback(() => { + if (pollRef.current !== null) window.clearInterval(pollRef.current); + pollRef.current = null; + }, []); + + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + stopPolling(); + }; + }, [stopPolling]); + + const start = useCallback( + async (list: ModuleBuildPattern[]) => { + if (list.length === 0) return; + stopPolling(); + const mine = ++token.current; + const alive = () => mounted.current && token.current === mine; + setBuild(null); + setError(null); + setWaited(0); + setPatterns(list.length); + setBusy(true); + + let id: string; + try { + const response = await fetch(communityApiUrl("/api/community/builds"), { + method: "POST", + ...COMMUNITY_FETCH_INIT, + headers: { "Content-Type": "application/json" }, + // In the order given, which is the order they appear on the device. + body: JSON.stringify({ format: "pfm", patterns: list }), + }); + const payload = (await response.json()) as { id?: string; error?: string }; + if (!alive()) return; + if (!response.ok || !payload.id) { + setError(payload.error ?? "Could not queue the build."); + return; + } + id = payload.id; + } catch { + if (alive()) setError("Network error — is the build server reachable?"); + return; + } finally { + if (alive()) setBusy(false); + } + + captureEvent("module_build_queued", { patterns: list.length }); + setBuild({ + id, + status: "queued", + queuePosition: null, + elapsedMs: null, + bytes: null, + error: null, + namespaces: [], + modulesUrl: null, + }); + + // Set once the build has an outcome, so a slow answer from an earlier + // tick cannot put a finished build back to "running". + let settled = false; + const poll = async () => { + try { + const response = await fetch( + communityApiUrl(`/api/community/builds/${id}`), + COMMUNITY_FETCH_INIT, + ); + if (!response.ok) return; + const state = (await response.json()) as ModuleBuildState; + if (!alive() || settled) return; + setBuild(state); + if (finished(state)) { + settled = true; + stopPolling(); + captureEvent("module_build_finished", { + status: state.status, + patterns: list.length, + bytes: state.bytes, + }); + } + } catch { + // A dropped poll is not a failed build; the next tick catches up. + } + }; + + const startedAt = Date.now(); + const limit = buildTimeoutMs(list.length); + void poll(); + // One timer for both jobs: ask how it is going, and keep the clock the + // giving-up rule reads. Ticking here rather than in `poll` means a + // request that hangs cannot also stall the timeout that exists to + // catch it. + pollRef.current = window.setInterval(() => { + const elapsed = Date.now() - startedAt; + setWaited(Math.floor(elapsed / 1000)); + if (elapsed > limit) { + settled = true; + stopPolling(); + setBuild(null); + setError( + `The build server has not answered in ${Math.round(limit / 1000)} seconds. ` + + "The build may still be running — give it a moment and try again, and if it keeps " + + "happening the build worker is probably down.", + ); + captureEvent("module_build_finished", { status: "timeout", patterns: list.length }); + return; + } + void poll(); + }, POLL_MS); + }, + [stopPolling], + ); + + /** Forget the build. One still running on the server is not cancelled. */ + const reset = useCallback(() => { + token.current += 1; + stopPolling(); + setBuild(null); + setError(null); + setBusy(false); + setWaited(0); + }, [stopPolling]); + + return { + build, + error, + busy, + /** Queueing, or queued and not yet done or failed. */ + running: busy || (build !== null && !finished(build)), + waited, + /** What the build in hand was told to expect, in seconds. */ + expectedS: buildExpectedS(patterns), + start, + reset, + }; +} diff --git a/web/src/lib/community/zipDownload.ts b/web/src/lib/community/zipDownload.ts index f0ad370a3..20c15f7b5 100644 --- a/web/src/lib/community/zipDownload.ts +++ b/web/src/lib/community/zipDownload.ts @@ -20,9 +20,12 @@ import { useCallback, useEffect, useRef, useState } from "react"; -// "duplicate-name" is kept for older servers; the current deck route renames a -// colliding file instead of leaving it out. "unavailable" is a slot the build -// service failed on just now for a reason that was not the code's. +// No server has sent "duplicate-name". It arrived with this file on 24 +// September 2026 (e261978), and the deck route in that same commit already +// renamed a colliding file instead of leaving it out (assignPackSlugs in +// modulePack.ts). Before it, a deck's zip was one build with no skip list. +// "unavailable" is a slot the build service failed on just now for a reason +// that was not the code's. export type ZipSkipReason = "no-header" | "compile-error" | "duplicate-name" | "missing" | "unavailable"; export type ZipSkip = { position: number; title: string; reason: ZipSkipReason }; diff --git a/web/src/lib/envExample.test.ts b/web/src/lib/envExample.test.ts new file mode 100644 index 000000000..511af00a9 --- /dev/null +++ b/web/src/lib/envExample.test.ts @@ -0,0 +1,96 @@ +import { readFileSync, readdirSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +// web/.env.example is the list of what a deployment can set, and +// ARCHITECTURE.md and docs/SERVICES.md send people to it as the whole list. +// For two months after the worker stopped building firmware images it still +// offered ARDUINO_CLI_PATH and BUILD_FQBN, which nothing read, and left out +// PF_XTENSA_BIN, which the Pi's worker cannot compile without. + +/** web/: this file is web/src/lib/. */ +const WEB = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); + +// Read by the code but not set by a deployment: Node's own, the Arduino and +// PlatformIO installs the compiler search walks (builderRev.ts), the one +// next.config.ts sets at build time, and the check scripts' switches. +const NOT_DEPLOYMENT = new Set([ + "NODE_ENV", + "INIT_CWD", + "ARDUINO_DIRECTORIES_DATA", + "LOCALAPPDATA", + "PLATFORMIO_CORE_DIR", + "PATTERN_SANDBOX_VERSION", + "PF_DRIFT_STRICT", + "PF_LOCK_BASE", +]); + +// Listed, and read by a library rather than by this code: Better Auth takes +// its base URL from the environment itself. +const READ_BY_LIBRARY = new Set(["BETTER_AUTH_URL"]); + +function sourceFiles(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) return sourceFiles(full); + if (!/\.(ts|tsx|mjs)$/.test(entry.name) || /\.test\.tsx?$/.test(entry.name)) return []; + return [full]; + }); +} + +/** Every name the site, the worker and the scripts read as `process.env.NAME`. */ +function namesTheCodeReads(): Set<string> { + const files = [ + ...sourceFiles(path.join(WEB, "src")), + ...sourceFiles(path.join(WEB, "scripts")), + path.join(WEB, "next.config.ts"), + path.join(WEB, "drizzle.config.ts"), + ]; + const names = new Set<string>(); + for (const file of files) { + for (const match of readFileSync(file, "utf8").matchAll(/process\.env\.([A-Z][A-Z0-9_]*)/g)) { + names.add(match[1]); + } + } + return names; +} + +/** `NAME=` and `#NAME=` lines: set, or offered commented out. */ +function namesTheExampleLists(): string[] { + const text = readFileSync(path.join(WEB, ".env.example"), "utf8"); + return [...text.matchAll(/^#?([A-Z][A-Z0-9_]*)=/gm)].map((match) => match[1]); +} + +/** The backticked names in ARCHITECTURE.md's "Environment variables" table. */ +function namesTheArchitectureTableLists(): string[] { + const text = readFileSync(path.join(WEB, "ARCHITECTURE.md"), "utf8"); + const section = text.split("## Environment variables")[1]?.split(/\n## /)[0] ?? ""; + const rows = section.split("\n").filter((line) => line.startsWith("|") && !line.includes(":---")); + return rows.slice(1).flatMap((row) => [...row.split("|")[2].matchAll(/`([A-Z][A-Z0-9_]*)`/g)].map((m) => m[1])); +} + +describe("web/.env.example", () => { + it("lists every variable the code reads", () => { + const listed = new Set(namesTheExampleLists()); + const missing = [...namesTheCodeReads()].filter((name) => !NOT_DEPLOYMENT.has(name) && !listed.has(name)); + expect(missing).toEqual([]); + }); + + it("lists nothing the code has stopped reading", () => { + const read = namesTheCodeReads(); + const unread = namesTheExampleLists().filter((name) => !read.has(name) && !READ_BY_LIBRARY.has(name)); + expect(unread).toEqual([]); + }); + + it("lists each variable once", () => { + const names = namesTheExampleLists(); + expect(names.filter((name, i) => names.indexOf(name) !== i)).toEqual([]); + }); + + it("names the same variables as the table in ARCHITECTURE.md", () => { + const table = namesTheArchitectureTableLists(); + expect(table.length).toBeGreaterThan(0); + expect([...table].sort()).toEqual([...namesTheExampleLists()].sort()); + }); +}); diff --git a/web/src/lib/firmware/moduleRunner.ts b/web/src/lib/firmware/moduleRunner.ts index 9ac9086a2..04ae957a6 100644 --- a/web/src/lib/firmware/moduleRunner.ts +++ b/web/src/lib/firmware/moduleRunner.ts @@ -1,15 +1,16 @@ // Builds a submitted pattern into a loadable module (.pfm) instead of a whole // firmware image. // -// Why this exists alongside buildRunner.ts: the .bin path recompiles the entire -// sketch — every preset lands in the same translation unit — so one pattern costs -// 14-16 s and produces ~1.1 MB that has to be flashed over the top of the -// running firmware. A module is that pattern's code alone: ~360 ms to build, -// 3-22 KB, and it installs by POSTing it to the device's /patterns with no -// reflash and no reboot. +// A whole image recompiles the entire sketch — every preset lands in the same +// translation unit — so one pattern costs 14-16 s and produces ~1.1 MB that +// has to be flashed over the top of the running firmware. A module is that +// pattern's code alone: ~360 ms to build, 3-22 KB, and it installs by POSTing +// it to the device's /patterns with no reflash and no reboot. // -// Both paths stay for now. A device on firmware older than the module loader -// cannot load a .pfm, so it still needs an image. +// The community built images too, from 25 July 2026 (c5e0907) until 4c13474 +// removed them and their runner on 12 August. Since then POST +// /api/community/builds answers "bin" with 410 and a pointer to +// patternflow.work/update, where a board gets a released image. // // The heavy lifting is the two Python scripts in firmware/toolchain/, which are // also what a maintainer runs by hand — one code path for both, so a build here diff --git a/web/src/lib/guide/deviceSim.test.ts b/web/src/lib/guide/deviceSim.test.ts index 6e4fe0938..12dc9c910 100644 --- a/web/src/lib/guide/deviceSim.test.ts +++ b/web/src/lib/guide/deviceSim.test.ts @@ -1,10 +1,12 @@ -import { beforeEach, describe, expect, it } from "vitest"; -import { DeviceSim, SIM_IP } from "./deviceSim"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { PatternRuntime } from "@/lib/pattern/harness"; +import { DeviceSim, SIM_HOTSPOT, SIM_IP } from "./deviceSim"; import { drawOverlay, PANEL_H, PANEL_W } from "./panelScreens"; // The Audio guide's part of the simulated board: the Audio edition's two rows // on the NETWORK screen (hold K2), and the lanes — what sound writes over the -// knobs' values without turning the knobs (docs/audio-ws-spec.md). +// knobs' values without turning the knobs (docs/audio-ws-spec.md). And Play's +// last card of 01 Flash: the same screen while the panel is its own hotspot. const FRAME = 1 / 60; @@ -17,6 +19,16 @@ function run(sim: DeviceSim, ms: number, each?: () => void) { } } +/** + * Leave the board alone for `ms`, in the longest step tick() takes (0.1 s). + * Its timers count board time, not frames: fifteen seconds of NETWORK at 60 + * frames a second drew 900 screens and ran past the 5 s a test is given on a + * busy machine. + */ +function wait(sim: DeviceSim, ms: number) { + for (let t = 0; t < ms; t += 100) sim.tick(0.1); +} + function values(sim: DeviceSim) { return [0, 1, 2, 3].map((i) => sim.knobReadout(i).value); } @@ -93,11 +105,11 @@ describe("the Audio edition's rows on NETWORK", () => { it("a turn keeps the screen open past its eight seconds, as it does on the board", () => { sim.setEdition("audio"); sim.setMode("network"); - run(sim, 6000); + wait(sim, 6000); sim.turn(2, -1); - run(sim, 6000); + wait(sim, 6000); expect(sim.snapshot().mode).toBe("network"); - run(sim, 3000); + wait(sim, 3000); expect(sim.snapshot().mode).toBe("run"); }); @@ -131,6 +143,49 @@ describe("the Audio edition's rows on NETWORK", () => { }); }); +describe("the hotspot on NETWORK", () => { + let sim: DeviceSim; + + beforeEach(() => { + sim = new DeviceSim(); + sim.tick(FRAME); + }); + + it("names the panel's own network where the address would be, as the firmware draws it", () => { + sim.setHotspot(true); + sim.setMode("network"); + sim.tick(FRAME); + const hotspot = new Uint8ClampedArray(PANEL_W * PANEL_H * 4); + drawOverlay(hotspot, { kind: "network", wifi: "CONNECTING", ip: "-", hotspotName: SIM_HOTSPOT }); + expect(Array.from(sim.frame)).toEqual(Array.from(hotspot)); + expect(Array.from(sim.frame)).not.toEqual(Array.from(networkFrame())); + expect(SIM_HOTSPOT).toMatch(/^patternflow-[0-9a-f]{4}$/); + }); + + it("is on the network again once the step no longer says otherwise", () => { + sim.setHotspot(true); + sim.setMode("network"); + sim.tick(FRAME); + sim.setHotspot(false); + sim.tick(FRAME); + expect(Array.from(sim.frame)).toEqual(Array.from(networkFrame())); + }); + + it("opens on hold K2 and leaves on a press of K2, as on the network", () => { + sim.setHotspot(true); + sim.press(1); + run(sim, 1100); + sim.release(1); + sim.tick(FRAME); + expect(sim.snapshot().mode).toBe("network"); + sim.press(1); + sim.tick(FRAME); + sim.release(1); + sim.tick(FRAME); + expect(sim.snapshot().mode).toBe("run"); + }); +}); + describe("the lanes", () => { let sim: DeviceSim; let home: number[]; @@ -337,3 +392,149 @@ describe("the lanes", () => { expect(frames(visited)).toEqual(frames(play)); }); }); + +// What a socket of the virtual panel reads off the board, and what it writes +// that no hand did. +describe("the sockets", () => { + let sim: DeviceSim; + const none = [false, false, false, false]; + + beforeEach(() => { + sim = new DeviceSim(); + sim.tick(FRAME); + }); + + it("read the knobs as /api/status does: a hand's clicks, the bus, the lanes", () => { + expect(sim.knobState()).toEqual({ + knobs: [0, 0, 0, 0], + params: [500, 500, 500, 500], + paramActive: none, + lanes: [0, 0, 0, 0], + laneActive: none, + }); + // Two edges a click on K2 (the /knobs page): its detent is two clicks. + sim.setKnobSettings(none, [4, 2, 4, 4]); + sim.turn(0, 3); + sim.turn(1, -1); + sim.applyRemoteParam(2, 750); + sim.setEdition("audio"); + sim.setLaneSource("ws"); + sim.setLane("ws", 3, 0.25); + sim.tick(FRAME); + expect(sim.knobState()).toEqual({ + knobs: [3, -2, 0, 0], + params: [500, 500, 750, 500], + paramActive: [false, false, true, false], + lanes: [0, 0, 0, 0.25], + laneActive: [false, false, false, true], + }); + }); + + it("drive the lanes from the WebSocket, and the editor writes nothing then", () => { + sim.setEdition("audio"); + sim.setLaneSource("ws"); + sim.setLane("editor", 0, 0.9); + sim.setLane("ws", 1, 0.9); + sim.tick(FRAME); + expect(sim.knobState().laneActive).toEqual([false, true, false, false]); + }); + + it("let one lane go on off=N, and the next write takes it again", () => { + sim.setEdition("audio"); + sim.setLaneSource("ws"); + const home = values(sim); + run(sim, 100, () => [0, 1, 2, 3].forEach((i) => sim.setLane("ws", i, 0.9))); + sim.releaseLane("editor", 2); + sim.releaseLane("ws", 1); + sim.tick(FRAME); + expect(sim.knobState().laneActive).toEqual([true, false, true, true]); + expect(sim.knobReadout(1).value).toBe(home[1]); + sim.setLane("ws", 1, 0.9); + sim.tick(FRAME); + expect(sim.knobState().laneActive[1]).toBe(true); + }); + + it("let one bus channel go on an empty param/N, inside the 250 ms a hand waits out", () => { + sim.applyRemoteParam(0, 800); + sim.applyRemoteParam(1, 800); + sim.tick(FRAME); + sim.turn(0, 1); + sim.releaseRemoteParam(1); + sim.tick(FRAME); + expect(sim.knobState().paramActive).toEqual([true, false, false, false]); + }); + + it("move a knob by clicks no hand made, one for one, and knobs counts none of them", () => { + const hand = new DeviceSim(); + hand.tick(FRAME); + hand.turn(1, 3); + hand.tick(FRAME); + sim.nudge(1, 3); + sim.tick(FRAME); + expect(values(sim)).toEqual(values(hand)); + expect(sim.given()?.input.knobDeltas).toEqual([0, 3, 0, 0]); + expect(sim.knobState().knobs).toEqual([0, 0, 0, 0]); + expect(sim.snapshot().turns).toEqual([0, 0, 0, 0]); + }); + + it("leave a nudged knob on its lane, and its bus channel held and in charge", () => { + sim.setEdition("audio"); + sim.setLaneSource("ws"); + sim.applyRemoteParam(3, 800); + run(sim, 300, () => sim.setLane("ws", 0, 0.5)); + const held = values(sim)[3]; + sim.setLane("ws", 0, 0.5); + sim.nudge(0, 2); + sim.nudge(3, 2); + sim.tick(FRAME); + expect(sim.knobState().laneActive[0]).toBe(true); + expect(sim.snapshot().laneHeld[0]).toBe(false); + expect(sim.knobState().paramActive[3]).toBe(true); + expect(values(sim)[3]).toBe(held); + expect(sim.given()?.input.knobDeltas).toEqual([2, 0, 0, 0]); + }); + + it("give the pattern a remote button's press once, and held until it is up, with no screen", () => { + sim.turn(2, 4); + sim.tick(FRAME); + expect(sim.knobReadout(2).value).toBeGreaterThan(sim.knobReadout(2).min); + sim.remoteButton(2, true); + sim.tick(FRAME); + expect(sim.given()?.input.btnPressed).toEqual([false, false, true, false]); + // Origin's press, as a hand's: K3 is back at its start. + expect(sim.knobReadout(2).value).toBe(sim.knobReadout(2).min); + sim.remoteButton(2, true); + run(sim, 2000); + expect(sim.given()?.input.btnPressed).toEqual(none); + expect(sim.given()?.input.btnHeld).toEqual([false, false, true, false]); + // A hand held this long opens KNOB MAP. + expect(sim.snapshot().mode).toBe("run"); + sim.remoteButton(2, false); + sim.tick(FRAME); + expect(sim.given()?.input.btnHeld).toEqual(none); + }); + + it("wake nothing: asleep, a nudge and a remote button are lost", () => { + sim.setSleeping(true); + run(sim, 1000); + sim.nudge(0, 5); + sim.remoteButton(0, true); + sim.tick(FRAME); + expect(sim.snapshot().mode).toBe("sleep"); + expect(sim.given()).toBeNull(); + sim.remoteButton(0, false); + sim.setSleeping(false); + sim.tick(FRAME); + expect(sim.given()?.input.knobDeltas).toEqual([0, 0, 0, 0]); + expect(sim.given()?.input.btnPressed).toEqual(none); + }); + + it("read what the pattern threw, frame by frame", () => { + const spy = vi.spyOn(PatternRuntime.prototype, "renderFrame").mockReturnValueOnce({ ok: false, error: "boom" }); + sim.tick(FRAME); + expect(sim.given()?.error).toBe("boom"); + sim.tick(FRAME); + expect(sim.given()?.error).toBeNull(); + spy.mockRestore(); + }); +}); diff --git a/web/src/lib/guide/deviceSim.ts b/web/src/lib/guide/deviceSim.ts index bcde653bb..64ad5ff8b 100644 --- a/web/src/lib/guide/deviceSim.ts +++ b/web/src/lib/guide/deviceSim.ts @@ -13,13 +13,14 @@ // device prints on its KNOB MAP screen. Where each one sits on the front is // the 3D stage's business. -import { PATTERN_DETENTS_PER_TURN, PatternRuntime, type ColorRamp, type PatternInput } from "@/lib/pattern/harness"; +import { PatternRuntime, type ColorRamp, type PatternInput } from "@/lib/pattern/harness"; import { parseRampAnnotation } from "@/lib/pattern/ramp"; import { hexToRgb } from "@/lib/pattern/color"; import { knobSetupFromCode } from "@/lib/community/knobs"; import { LOGICAL_KNOB_DEFAULTS, knobUnitsPerTurn } from "@/lib/pattern/controls"; import { livePresets } from "@/lib/presets"; import { BASICS_PACK } from "@/lib/pattern/packs"; +import { BUS_MAX, BUS_UNITS_PER_CLICK, DETENTS_PER_TURN, EDGES_PER_DETENT, KNOB_COUNT } from "@/lib/shared/constants"; import { BASICS_NAMES } from "./basicsNames"; import { brightnessPercent, @@ -37,8 +38,11 @@ export type SimMode = "off" | "run" | "brightness" | "network" | "knobmap" | "se export type SimPack = "origin" | "basics"; /** The firmware the board runs: the default, or the Audio edition (the OSC and AUD rows on hold K2). */ export type SimEdition = "core" | "audio"; -/** What may drive the four lanes: the extension's editor on the Audio guide (components/guide/EditorWindow). */ -export type SimLaneSource = "editor"; +/** + * What may drive the four lanes: the extension's editor on the Audio guide + * (components/guide/EditorWindow), or the audio WebSocket (docs/audio-ws-spec.md). + */ +export type SimLaneSource = "editor" | "ws"; export type SimPattern = { slug: string; @@ -62,6 +66,12 @@ type FrameInput = { detents: number[]; edges: boolean[]; clicks: boolean[]; long /** The address the simulated board shows on its NETWORK screen; the guide's phone types the same one. */ export const SIM_IP = "192.168.0.42"; +/** + * The simulated board's own network, when it has none it knows: the shared + * name plus the last two bytes of its MAC (src/core_hotspot.h name()). + */ +export const SIM_HOTSPOT = "patternflow-a1b2"; + // The ramp a setValue() pattern gets without an @ramp line, same as the // community sandbox, so a pattern looks here the way it does on its card. const DEFAULT_RAMP: ColorRamp = { @@ -135,19 +145,33 @@ export type SimConsoleState = { knobs: [number, number, number, number]; }; +/** The knobs as /api/status reports them (docs/rest-api.md). */ +export type SimKnobState = { + /** Clicks each encoder has made since load, after its /knobs settings. A hand's, and nothing else's. */ + knobs: [number, number, number, number]; + /** The bus, 0..1000 per channel, and which channels a remote writer holds. */ + params: [number, number, number, number]; + paramActive: [boolean, boolean, boolean, boolean]; + /** Each lane's last value, 0..1, and whether it has its knob once the bus and the hands have had theirs. */ + lanes: [number, number, number, number]; + laneActive: [boolean, boolean, boolean, boolean]; +}; + +/** What the pattern was given on a frame, and the error it threw there. */ +export type SimGiven = { input: PatternInput; error: string | null }; + // The absolute parameter bus (src/core_bus.h): what the console's sliders // write. A channel is held once written; a change of the held value reaches // a pattern as clicks, ten bus units each, the remainder carried; a hand on // that encoder takes it back after a grace. -const BUS_UNITS_PER_CLICK = 10; -const BUS_RELEASE_GRACE_MS = 250; +export const BUS_RELEASE_GRACE_MS = 250; // The lanes (docs/audio-ws-spec.md): what sound writes. A lane is an absolute // 0..1 reading the pattern gets lerped into the knob's range, under the bus // and over the encoder. It is handed back 500 ms after the last message that // set it, and a turn by hand takes the knob for five seconds. -const LANE_RELEASE_MS = 500; -const LANE_HAND_HOLD_MS = 5000; +export const LANE_RELEASE_MS = 500; +export const LANE_HAND_HOLD_MS = 5000; // The two rows an Audio edition board draws under NETWORK, by the features' // short names, in the order K2 and K3 turn them (patternflow.ino:1688-1713). const ROW_OSC = 0; @@ -163,6 +187,8 @@ export class DeviceSim { private pack: SimPack = "origin"; private edition: SimEdition = "core"; private laneSource: SimLaneSource | null = null; + /** No network it knows, and its own hotspot up (setHotspot). */ + private hotspot = false; /** The edition's two switches, OSC and AUD: on after every setEdition. */ private rowOn = [true, true]; // The lanes are an overlay: read when a frame is drawn, never written into @@ -189,7 +215,7 @@ export class DeviceSim { private now = 0; private time = 0; - private knobs: Knob[] = Array.from({ length: 4 }, () => ({ + private knobs: Knob[] = Array.from({ length: KNOB_COUNT }, () => ({ down: false, downAt: 0, longFired: false, @@ -220,6 +246,13 @@ export class DeviceSim { private busResidual = [0, 0, 0, 0]; private busPending = [0, 0, 0, 0]; + // The sockets' side of the board (nudge, remoteButton, knobState, given). + private clicks = [0, 0, 0, 0]; + private nudgePending = [0, 0, 0, 0]; + private remoteDown = [false, false, false, false]; + private remoteEdge = [false, false, false, false]; + private lastGiven: SimGiven | null = null; + /** Called with the new mode whenever it changes. */ onModeChange: ((mode: SimMode) => void) | null = null; @@ -289,6 +322,19 @@ export class DeviceSim { this.rowOn = [true, true]; } + // ── the Wi-Fi ────────────────────────────────────────────────────────────── + + /** + * The board without a network it knows, as its own Wi-Fi network: the + * NETWORK screen reads HOTSPOT and the hotspot's name where it otherwise + * reads CONNECTED and the address (patternflow.ino drawNetworkInfo). Off, + * the board is on the network — what every step but Play's last card of + * 01 Flash shows. + */ + setHotspot(on: boolean) { + this.hotspot = on; + } + /** * Who may drive the lanes, or null for nobody. Changing it lets every lane * go at once: each knob's value is what it was before a lane took it. @@ -309,11 +355,16 @@ export class DeviceSim { * the AUD row switched off stops all four. */ setLane(source: SimLaneSource, knob: number, value: number) { - if (source !== this.laneSource || !Number.isInteger(knob) || knob < 0 || knob > 3 || !Number.isFinite(value)) return; + if (source !== this.laneSource || !Number.isInteger(knob) || knob < 0 || knob >= KNOB_COUNT || !Number.isFinite(value)) return; this.laneValue[knob] = clamp(value, 0, 1); this.laneAt[knob] = this.now; } + /** off=N: the lane goes back to its encoder now, as it would 500 ms after its last write. */ + releaseLane(source: SimLaneSource, knob: number) { + if (source === this.laneSource && isKnob(knob)) this.laneAt[knob] = -1e9; + } + /** * The guide may draw over the frame — not the firmware's doing, the page's: * the hub redraws the panel row by row when it shows a pattern being made @@ -338,7 +389,9 @@ export class DeviceSim { turn(knob: number, detents: number) { if (!Number.isFinite(detents) || detents === 0) return; // clickScale is 1 unless a console changed the knob's settings. - this.pendingDetents[knob] += detents * this.clickScale[knob]; + const clicks = detents * this.clickScale[knob]; + this.pendingDetents[knob] += clicks; + this.clicks[knob] += clicks; this.turns[knob] += detents; // "Hands always win": a turn takes the knob from its lane for five seconds. this.handAt[knob] = this.now; @@ -417,7 +470,8 @@ export class DeviceSim { this.pendingDetents = [0, 0, 0, 0]; const toPattern = this.handle(input); - if (this.busPending.some((d) => d !== 0)) this.applyBus(toPattern.detents); + const remote = [...this.busPending, ...this.nudgePending].some((d) => d !== 0) || this.remoteEdge.some(Boolean); + if (remote) this.applyRemote(toPattern); this.render(step, toPattern); } @@ -478,8 +532,8 @@ export class DeviceSim { * (core_bus.h applyRemoteParam). The 3D knob does not turn — nothing did. */ applyRemoteParam(knob: number, value: number) { - if (knob < 0 || knob > 3 || !Number.isFinite(value)) return; - const v = clamp(Math.round(value), 0, 1000); + if (knob < 0 || knob >= KNOB_COUNT || !Number.isFinite(value)) return; + const v = clamp(Math.round(value), 0, BUS_MAX); if (this.busHeld[knob]) { const acc = this.busResidual[knob] + (v - this.busValue[knob]); const d = Math.trunc(acc / BUS_UNITS_PER_CLICK); @@ -491,6 +545,51 @@ export class DeviceSim { this.busHeldAt[knob] = this.now; } + /** An empty MQTT param/N: the channel is let go now, grace or not (docs/mqtt-spec.md). */ + releaseRemoteParam(knob: number) { + if (isKnob(knob)) this.releaseBus(knob); + } + + /** + * Clicks that no hand made: OSC's knob/N/delta, MIDI CC 24 to 27, MQTT + * knob/N, the WebSocket's d=, POST /api/params dN. They reach the pattern + * where a turn's would, one for one, except on a channel the bus holds, + * which outranks them (docs/audio-ws-spec.md). The specs give a hand two + * things these do not get: the knob back from its lane, and its bus + * channel freed (mqtt-spec.md, midi-spec.md). They wake nothing, and + * `knobs` does not count them. + */ + nudge(knob: number, clicks: number) { + if (isKnob(knob) && Number.isInteger(clicks)) this.nudgePending[knob] += clicks; + } + + /** + * A button no hand pressed: MIDI notes 60 to 63 (docs/midi-spec.md). Down is + * a press edge, and the button reads as held until it is up. Only the + * pattern is given it, so it opens no screen and wakes nothing. + */ + remoteButton(knob: number, down: boolean) { + if (!isKnob(knob)) return; + if (down && !this.remoteDown[knob]) this.remoteEdge[knob] = true; + this.remoteDown[knob] = down; + } + + /** The fields of /api/status a socket reads: `knobs`, `params`, `paramActive`, `lanes`, `laneActive`. */ + knobState(): SimKnobState { + return { + knobs: [...this.clicks] as SimKnobState["knobs"], + params: [...this.busValue] as SimKnobState["params"], + paramActive: [...this.busHeld] as SimKnobState["paramActive"], + lanes: [...this.laneValue] as SimKnobState["lanes"], + laneActive: [0, 1, 2, 3].map((i) => this.laneDrives(i)) as SimKnobState["laneActive"], + }; + } + + /** What the pattern was given on the last lit frame, and what it threw; null while the panel is dark. */ + given(): SimGiven | null { + return this.lastGiven; + } + /** What a knob controls on the running pattern, for the readout beside it. */ knobReadout(knob: number): { label: string; value: number; min: number; max: number } { const [min, max] = this.ranges[knob] ?? [0, 1]; @@ -499,9 +598,9 @@ export class DeviceSim { /** The /knobs page: which way each encoder counts and how many of its 4 edges make a click. */ setKnobSettings(invert: boolean[], edgesPerClick: number[]) { - for (let i = 0; i < 4; i++) { - const sub = [1, 2, 4].includes(edgesPerClick[i]) ? edgesPerClick[i] : 4; - this.clickScale[i] = (invert[i] ? -1 : 1) * (4 / sub); + for (let i = 0; i < KNOB_COUNT; i++) { + const sub = [1, 2, 4].includes(edgesPerClick[i]) ? edgesPerClick[i] : EDGES_PER_DETENT; + this.clickScale[i] = (invert[i] ? -1 : 1) * (EDGES_PER_DETENT / sub); } } @@ -536,15 +635,22 @@ export class DeviceSim { this.busResidual[knob] = 0; } - /** The bus's clicks reach the pattern where a turn of that knob would. */ - private applyBus(detents: number[]) { + /** The bus's clicks, a nudge's and a remote button's press reach the pattern where a hand's would. */ + private applyRemote(toPattern: { detents: number[]; presses: boolean[] }) { const knobs = this.mode === "run" ? [0, 1, 2, 3] : this.mode === "brightness" ? [1, 2, 3] : []; const moved = [0, 0, 0, 0]; - for (const i of knobs) moved[i] = this.busPending[i]; + for (const i of knobs) moved[i] = this.busPending[i] + (this.busHeld[i] ? 0 : this.nudgePending[i]); + const edges = this.remoteEdge; this.busPending = [0, 0, 0, 0]; + this.nudgePending = [0, 0, 0, 0]; + this.remoteEdge = [false, false, false, false]; if (!knobs.length) return; this.moveValues(moved, knobs); - for (const i of knobs) detents[i] += moved[i]; + for (const i of knobs) { + toPattern.detents[i] += moved[i]; + if (edges[i]) toPattern.presses[i] = true; + } + if (this.mode === "run") this.originPress(edges); } // ── firmware rules (patternflow.ino; see panelScreens.ts FIRMWARE) ────────── @@ -654,20 +760,23 @@ export class DeviceSim { // The press edge reaches the pattern the moment the button goes down, // so a long-press delivers one too (patternflow.ino:1201-1205). this.moveValues(f.detents, [0, 1, 2, 3]); - // Origin's own click sends that knob back to its start (its update() - // zeroes the parameter); the knob's readout follows it there. - if (this.patterns[this.active]?.slug === "origin") { - const values = this.currentValues(); - f.edges.forEach((e, i) => { - if (e) values[i] = this.ranges[i]?.[0] ?? 0; - }); - } + this.originPress(f.edges); return { detents: f.detents.slice(), presses: f.edges.slice() }; } } return none(); } + // Origin's own click sends that knob back to its start (its update() + // zeroes the parameter); the knob's readout follows it there. + private originPress(edges: boolean[]) { + if (this.patterns[this.active]?.slug !== "origin") return; + const values = this.currentValues(); + edges.forEach((e, i) => { + if (e) values[i] = this.ranges[i]?.[0] ?? 0; + }); + } + private enterFrom(mode: SimMode) { this.setMode(mode); return { detents: [0, 0, 0, 0], presses: [false, false, false, false] }; @@ -679,7 +788,7 @@ export class DeviceSim { const d = detents[i]; if (!d) continue; const [min, max] = this.ranges[i] ?? [0, 1]; - const next = values[i] + d * (knobUnitsPerTurn([min, max]) / PATTERN_DETENTS_PER_TURN); + const next = values[i] + d * (knobUnitsPerTurn([min, max]) / DETENTS_PER_TURN); values[i] = this.wraps[i] ? wrap(next, min, max) : clamp(next, min, max); } } @@ -689,7 +798,7 @@ export class DeviceSim { // SELECT clears the name card (patternflow.ino:1731). if (mode === "select") this.noticeUntil = -1; // SELECT drops every held bus channel, so browsing never fights one (patternflow.ino). - if (mode === "select") for (let i = 0; i < 4; i++) if (this.busHeld[i]) this.releaseBus(i); + if (mode === "select") for (let i = 0; i < KNOB_COUNT; i++) if (this.busHeld[i]) this.releaseBus(i); this.mode = mode; this.idleAt = this.now; this.onModeChange?.(mode); @@ -792,6 +901,7 @@ export class DeviceSim { if (this.mode === "off" || this.mode === "sleep") { out.fill(0); for (let i = 3; i < out.length; i += 4) out[i] = 255; + this.lastGiven = null; return; } @@ -806,9 +916,10 @@ export class DeviceSim { }), knobRanges: ranges, btnPressed: toPattern.presses, - btnHeld: this.knobs.map((k) => this.mode === "run" && k.down), + btnHeld: this.knobs.map((k, i) => this.mode === "run" && (k.down || this.remoteDown[i])), }; - this.runtime.renderFrame(dt, this.time, input); + const result = this.runtime.renderFrame(dt, this.time, input); + this.lastGiven = { input, error: result.ok ? null : (result.error ?? "") }; const src = this.runtime.data; // A short power-on: the frame fades up over the first 0.6 s after boot. @@ -832,7 +943,10 @@ export class DeviceSim { case "brightness": return { kind: "brightness", percent: brightnessPercent(this.level) }; case "network": - return { kind: "network", wifi: "CONNECTED", ip: SIM_IP, rows: this.rows() }; + // No station link and the hotspot up: the name to join, not an address. + return this.hotspot + ? { kind: "network", wifi: "CONNECTING", ip: "-", connected: false, hotspotName: SIM_HOTSPOT, rows: this.rows() } + : { kind: "network", wifi: "CONNECTED", ip: SIM_IP, rows: this.rows() }; case "knobmap": return { kind: "knobmap", @@ -854,6 +968,10 @@ export class DeviceSim { } } +function isKnob(knob: number) { + return Number.isInteger(knob) && knob >= 0 && knob < KNOB_COUNT; +} + function clamp(v: number, min: number, max: number) { return Math.max(min, Math.min(max, v)); } @@ -861,5 +979,10 @@ function clamp(v: number, min: number, max: number) { function wrap(v: number, min: number, max: number) { const span = max - min; if (span <= 0) return min; - return ((((v - min) % span) + span) % span) + min; + const r = (((v - min) % span) + span) % span; + // Clicks summed in floating point can come home a hair below the start, + // which wraps to a hair below the top: nine clicks of K1 out and nine back + // left Origin's hue at 0.9999999999999993, and its readout said "hue 1.00" + // with a full bar where it had said 0.00. That is the start. + return span - r < span * 1e-9 ? min : r + min; } diff --git a/web/src/lib/guide/firmwareConstants.test.ts b/web/src/lib/guide/firmwareConstants.test.ts new file mode 100644 index 000000000..57d48240e --- /dev/null +++ b/web/src/lib/guide/firmwareConstants.test.ts @@ -0,0 +1,127 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { BUS_RELEASE_GRACE_MS, LANE_HAND_HOLD_MS, LANE_RELEASE_MS } from "./deviceSim"; +import { brightnessPercent, FIRMWARE, MODULE_NAME_BYTES, type FirmwareConstants } from "./panelScreens"; + +// The guide's panel plays by the firmware's timings and limits, and those +// numbers were copied out of the firmware by hand: 5000 ms in brightness, 350 +// ms before SELECT loads, three detents a step. check:shared keeps the values +// both sides read from shared/constants.json, and none of these is one of +// them. On 21 October 2026 all 21 copies matched the firmware. This file is +// what says so the day one stops matching. The firmware is only read here. + +/** The repository's root: this file is web/src/lib/guide/. */ +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../.."); +const firmwareSource = (file: string) => + readFileSync(path.join(ROOT, "firmware/patternflow", file), "utf8"); + +/** The right-hand side of the one `const`, `constexpr` or `#define` that names `name` in `file`. */ +function declared(file: string, name: string): string { + const text = firmwareSource(file); + const found = [ + ...text.matchAll(new RegExp(`\\b(?:const|constexpr)\\s+[\\w:]+\\s+${name}\\s*=\\s*([^;]+);`, "g")), + ...text.matchAll(new RegExp(`^\\s*#define\\s+${name}\\s+(\\S+)`, "gm")), + ]; + if (found.length !== 1) { + throw new Error(`firmware/patternflow/${file} declares ${name} ${found.length} times, not once`); + } + return found[0][1].trim(); +} + +/** A declared number without its C suffix: 1.0f is 1. */ +function declaredNumber(file: string, name: string): number { + const raw = declared(file, name); + const m = /^(\d+(?:\.\d+)?)[fFuUlL]*$/.exec(raw); + if (!m) throw new Error(`${name} in firmware/patternflow/${file} is ${raw}, not a number`); + return Number(m[1]); +} + +// FIRMWARE keys copied from a constant of the same value. +const NAMED: [keyof FirmwareConstants, string, string][] = [ + ["brightnessIdleExitMs", "patternflow.ino", "BRIGHTNESS_IDLE_MS"], + ["brightnessSaveDelayMs", "patternflow.ino", "BRIGHTNESS_SAVE_DELAY_MS"], + ["networkIdleExitMs", "patternflow.ino", "OSC_INFO_IDLE_MS"], + ["knobmapIdleExitMs", "patternflow.ino", "KNOB_MAP_IDLE_MS"], + ["knobmapHighlightMs", "patternflow.ino", "KNOB_MAP_HILITE_MS"], + ["updateIdleExitMs", "patternflow.ino", "UPDATE_IDLE_MS"], + ["infoRedrawMs", "patternflow.ino", "NET_INFO_REDRAW_MS"], + ["selectDetentsPerStep", "patternflow.ino", "SELECT_DETENTS_PER_STEP"], + ["selectSettleMs", "patternflow.ino", "SELECT_SETTLE_MS"], + ["patternSaveDelayMs", "patternflow.ino", "PATTERN_SAVE_DELAY_MS"], + ["networkMaxFeatureRows", "patternflow.ino", "MAX_FEATURE_ROWS"], + ["wakeGuardMs", "src/core_sleep.h", "WAKE_GUARD_MS"], + ["defaultBrightness", "config.h", "DEFAULT_BRIGHTNESS"], +]; + +// The sim's own module constants, under the name each has in the firmware. +const LOOSE: [string, string, string, number][] = [ + ["MODULE_NAME_BYTES in panelScreens.ts", "pattern_registry.h", "MODULE_NAME_BYTES", MODULE_NAME_BYTES], + ["BUS_RELEASE_GRACE_MS in deviceSim.ts", "src/core_bus.h", "ABSOLUTE_RELEASE_GRACE_MS", BUS_RELEASE_GRACE_MS], + ["LANE_RELEASE_MS in deviceSim.ts", "features/audio/core_audio_ws.h", "AUDIO_TIMEOUT_MS", LANE_RELEASE_MS], + ["LANE_HAND_HOLD_MS in deviceSim.ts", "patternflow.ino", "LANE_HANDS_OFF_MS", LANE_HAND_HOLD_MS], +]; + +describe("the guide's panel against the firmware", () => { + it.each(NAMED)("FIRMWARE.%s is %s's %s", (key, file, name) => { + expect(FIRMWARE[key]).toBe(declaredNumber(file, name)); + }); + + it.each(LOOSE)("%s is %s's %s", (_sim, file, name, value) => { + expect(value).toBe(declaredNumber(file, name)); + }); + + it("shows the name card as long as CONTENT_NOTICE_SECONDS, which counts seconds", () => { + expect(FIRMWARE.contentNoticeMs).toBe(declaredNumber("patternflow.ino", "CONTENT_NOTICE_SECONDS") * 1000); + }); + + it("turns brightness in the step and between the bounds loop() clamps to", () => { + const turns = [ + ...firmwareSource("patternflow.ino").matchAll( + /constrain\(\s*\(int\)\s*currentBrightness\s*\+\s*\w+\s*\*\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/g, + ), + ]; + expect(turns, "constrain((int)currentBrightness + d * step, min, max) in patternflow.ino").toHaveLength(1); + const [step, min, max] = turns[0].slice(1).map(Number); + expect(FIRMWARE.brightnessStepPerDetent).toBe(step); + expect(FIRMWARE.brightnessMin).toBe(min); + expect(FIRMWARE.brightnessMax).toBe(max); + }); + + it("prints a brightness byte as the percent the panel prints, for every byte", () => { + const formulas = [ + ...firmwareSource("patternflow.ino").matchAll(/\(\s*currentBrightness\s*\*\s*(\d+)\s*\+\s*(\d+)\s*\)\s*\/\s*(\d+)/g), + ].map((m) => m.slice(1).join(" ")); + // The BRIGHTNESS screen and the serial line both print it. + expect(formulas.length).toBeGreaterThan(0); + expect(new Set(formulas).size, `patternflow.ino rounds the percent ${formulas.length} ways`).toBe(1); + const [scale, round, full] = formulas[0].split(" ").map(Number); + for (let level = 0; level <= 255; level++) { + expect(brightnessPercent(level)).toBe(Math.trunc((level * scale + round) / full)); + } + expect(FIRMWARE.defaultBrightnessPercent).toBe(brightnessPercent(FIRMWARE.defaultBrightness)); + }); + + it("takes the long press, the debounce and the edges per detent from shared/constants.json, as the firmware does", () => { + expect(declared("patternflow.ino", "MODE_HOLD_MS")).toBe("PF_SHARED_LONG_PRESS_MS"); + const encoders = firmwareSource("src/core_encoders.h"); + expect(encoders).toMatch(/>\s*PF_SHARED_BUTTON_DEBOUNCE_MS\b/); + expect(encoders).toMatch(/knobSubSteps\[4\]\s*=\s*\{\s*PF_SHARED_EDGES_PER_DETENT\b/); + }); + + it("checks every FIRMWARE key, so a new one comes with a row here", () => { + const checked = [ + ...NAMED.map(([key]) => key), + "contentNoticeMs", + "brightnessStepPerDetent", + "brightnessMin", + "brightnessMax", + "defaultBrightnessPercent", + "longPressMs", + "buttonDebounceMs", + "edgesPerDetent", + ]; + expect(checked.sort()).toEqual(Object.keys(FIRMWARE).sort()); + }); +}); diff --git a/web/src/lib/guide/panelScreens.ts b/web/src/lib/guide/panelScreens.ts index 5fdced609..57738502b 100644 --- a/web/src/lib/guide/panelScreens.ts +++ b/web/src/lib/guide/panelScreens.ts @@ -10,8 +10,9 @@ // firmware/patternflow/src/hub75/ESP32-HUB75-MatrixPanel-I2S-DMA.h // drawPixel/fillRect overrides, the // rotation transform, RGB565 -> 888 -// Line numbers cited here are from those files at v3.10.4. When a screen -// changes there, change it here - the test pins a few pixels so drift shows. +// Firmware constants are cited by name. Other line numbers cited here are +// from those files at v3.10.4. When a screen changes there, change it here - +// the test pins a few pixels so drift shows. // // Things the port reproduces on purpose, because the panel does them: // - Adafruit GFX's text wrap is on (the default; nothing in the firmware turns @@ -36,11 +37,18 @@ // License: MIT. The two font tables this draws with are BSD - see glcdFont.ts // and tomThumbFont.ts for their notices. +import { + BUTTON_DEBOUNCE_MS, + EDGES_PER_DETENT, + LONG_PRESS_MS, + PANEL_GEOMETRIES, + PANEL_GEOM_DEFAULT, +} from "@/lib/shared/constants"; import { GLCD_FONT } from "./glcdFont"; import { TOM_THUMB, type GfxFont } from "./tomThumbFont"; -export const PANEL_W = 128; -export const PANEL_H = 64; +export const PANEL_W: number = PANEL_GEOMETRIES[PANEL_GEOM_DEFAULT].resW; +export const PANEL_H: number = PANEL_GEOMETRIES[PANEL_GEOM_DEFAULT].resH; const FRAME_BYTES = PANEL_W * PANEL_H * 4; export type Rgb = readonly [number, number, number]; @@ -71,15 +79,15 @@ export type PanelOverlay = wifi: WifiStatusText | (string & {}); /** ipString(): the dotted IP, or "-" when not connected (src/core_wifi.h:629-632). */ ip: string; - /** Feature toggle rows; a core build has none. At most two are drawn (patternflow.ino:759). */ + /** Feature toggle rows; a core build has none. At most two are drawn (MAX_FEATURE_ROWS, patternflow.ino). */ rows?: { name: string; on: boolean }[]; /** The hotspot's name, e.g. "patternflow-a1b2", when the panel's own AP is up. Shown only while not connected. */ hotspotName?: string; /** PatternflowWifi::isConnected(). Defaults to wifi === "CONNECTED". */ connected?: boolean; - /** "K1 = SLEEP" line; true unless a build compiles sleep out (config.h:247-248). */ + /** "K1 = SLEEP" line; true unless a build compiles sleep out (PF_SLEEP_ENABLED, config.h). */ sleepHint?: boolean; - /** "K4=UPDATE" line; true unless a build compiles the web update out (net_config.h:172-173). */ + /** "K4=UPDATE" line; true unless a build compiles the web update out (PF_WEBUPDATE_ENABLED, net_config.h). */ updateHint?: boolean; } | { @@ -123,7 +131,7 @@ export type PanelOverlay = // ── Firmware constants ────────────────────────────────────────────────────── -const DEFAULT_BRIGHTNESS = 204; // firmware/patternflow/config.h:241 ("80%") +const DEFAULT_BRIGHTNESS = 204; // DEFAULT_BRIGHTNESS, firmware/patternflow/config.h ("80%") /** The percentage the panel prints for a brightness byte (patternflow.ino:693). */ export function brightnessPercent(level: number): number { @@ -155,50 +163,53 @@ export interface FirmwareConstants { } export const FIRMWARE: Readonly<FirmwareConstants> = Object.freeze({ - // MODE_HOLD_MS, patternflow.ino:269. Fires WHILE still held, on the first - // frame the hold exceeds it (strict >, src/core_encoders.h:172); the release - // that follows is not a click (core_encoders.h:151-154). - longPressMs: 1000, - // Button edges closer than this are ignored (src/core_encoders.h:143). - buttonDebounceMs: 50, - // Quadrature edges per detent (knobSubSteps default, src/core_encoders.h:76); - // one detent = one knob delta (patternflow.ino:1168-1182, no acceleration). - edgesPerDetent: 4, - // BRIGHTNESS_IDLE_MS, patternflow.ino:270; checked with > at :1566. The timer - // restarts on entry (:1539) and only when a turn actually changes the level - // (:1555-1559) - turning against the 5/255 clamp does not keep it open. + // MODE_HOLD_MS in patternflow.ino, LONG_PRESS_MS in shared/constants.json. + // Fires WHILE still held, on the first frame the hold exceeds it (strict >, + // Button::longPressed in src/core_encoders.h); the release that follows is + // not a click (Button::pressed). + longPressMs: LONG_PRESS_MS, + // Button edges closer than this are ignored (Button::pressed in + // src/core_encoders.h, BUTTON_DEBOUNCE_MS in shared/constants.json). + buttonDebounceMs: BUTTON_DEBOUNCE_MS, + // Quadrature edges per detent (the knobSubSteps default in + // src/core_encoders.h, EDGES_PER_DETENT in shared/constants.json); one + // detent = one knob delta (readInputFrame in patternflow.ino, no acceleration). + edgesPerDetent: EDGES_PER_DETENT, + // BRIGHTNESS_IDLE_MS in patternflow.ino, checked with > in loop(). The timer + // restarts on entry and only when a turn actually changes the level - + // turning against the 5/255 clamp does not keep it open. brightnessIdleExitMs: 5000, - // BRIGHTNESS_SAVE_DELAY_MS, patternflow.ino:273 (NVS write after the mode closes). + // BRIGHTNESS_SAVE_DELAY_MS in patternflow.ino (NVS write after the mode closes). brightnessSaveDelayMs: 3000, - // OSC_INFO_IDLE_MS (the NETWORK screen's timer despite the name), patternflow.ino:274, :1658. + // OSC_INFO_IDLE_MS in patternflow.ino (the NETWORK screen's timer despite the name). networkIdleExitMs: 8000, - // KNOB_MAP_IDLE_MS, patternflow.ino:276, :1687. + // KNOB_MAP_IDLE_MS in patternflow.ino. knobmapIdleExitMs: 8000, - // KNOB_MAP_HILITE_MS, patternflow.ino:277; a knob stays lit this long after its last detent (:1005-1006). + // KNOB_MAP_HILITE_MS in patternflow.ino: a knob stays lit this long after its last detent (drawKnobMap). knobmapHighlightMs: 600, - // UPDATE_IDLE_MS, patternflow.ino:278, :1711 (not while flashing). + // UPDATE_IDLE_MS in patternflow.ino (not while flashing). updateIdleExitMs: 600000, - // NET_INFO_REDRAW_MS, patternflow.ino:275: info screens repaint at most this often (or on a change). + // NET_INFO_REDRAW_MS in patternflow.ino: info screens repaint at most this often (or on a change). infoRedrawMs: 250, - // SELECT_DETENTS_PER_STEP, patternflow.ino:240; see stepSelect(). + // SELECT_DETENTS_PER_STEP in patternflow.ino; see stepSelect(). selectDetentsPerStep: 3, - // SELECT_SETTLE_MS, patternflow.ino:234: the highlighted pattern loads once K4 has rested this long (:1943). + // SELECT_SETTLE_MS in patternflow.ino: the highlighted pattern loads once K4 has rested this long. selectSettleMs: 350, - // PATTERN_SAVE_DELAY_MS, patternflow.ino:207 (only once SELECT is left, :313). + // PATTERN_SAVE_DELAY_MS in patternflow.ino (only once SELECT is left, savePatternIfSettled). patternSaveDelayMs: 3000, - // CONTENT_NOTICE_SECONDS, patternflow.ino:279 - the name card after a remote pick; SELECT never shows it. + // CONTENT_NOTICE_SECONDS in patternflow.ino - the name card after a remote pick; SELECT never shows it. contentNoticeMs: 1000, - // constrain(currentBrightness + d * 5, 5, 255), patternflow.ino:1554. + // constrain(currentBrightness + d * 5, 5, 255) in loop(), patternflow.ino. brightnessMin: 5, brightnessMax: 255, brightnessStepPerDetent: 5, - // DEFAULT_BRIGHTNESS, config.h:241 - a byte, not a percent. + // DEFAULT_BRIGHTNESS in config.h - a byte, not a percent. defaultBrightness: DEFAULT_BRIGHTNESS, // brightnessPercent(204) = 80. defaultBrightnessPercent: brightnessPercent(DEFAULT_BRIGHTNESS), - // MAX_FEATURE_ROWS, patternflow.ino:759. + // MAX_FEATURE_ROWS in drawNetworkInfo, patternflow.ino. networkMaxFeatureRows: 2, - // WAKE_GUARD_MS, src/core_sleep.h:64: input ignored this long after falling asleep. + // WAKE_GUARD_MS in src/core_sleep.h: input ignored this long after falling asleep. wakeGuardMs: 800, }); @@ -224,8 +235,8 @@ export function wrapIndex(index: number, count: number): number { return ((index % count) + count) % count; } -// Fixed-size name buffers: MODULE_NAME_BYTES, firmware/patternflow/pattern_registry.h:139. -const MODULE_NAME_BYTES = 40; +// Fixed-size name buffers: MODULE_NAME_BYTES, firmware/patternflow/pattern_registry.h. +export const MODULE_NAME_BYTES = 40; const LATIN1_FOLD = "AAAAAAACEEEEIIII" + // C0-CF "DNOOOOOxOUUUUYPs" + // D0-DF @@ -235,8 +246,8 @@ const LATIN1_FOLD = /** * A pattern name as the panel prints it: asciiFold (patternflow.ino:577-601) * over the UTF-8 bytes. Latin-1 letters lose their accent, anything else - * becomes one '?'. Stored names live in 40-byte buffers (pattern_registry.h:139, - * snprintf at :217), so at most 39 bytes of the name survive. + * becomes one '?'. Stored names live in 40-byte buffers (MODULE_NAME_BYTES in + * pattern_registry.h, filled with snprintf), so at most 39 bytes of the name survive. */ export function asciiFold(name: string): string { const bytes = new TextEncoder().encode(name).subarray(0, MODULE_NAME_BYTES - 1); @@ -282,17 +293,17 @@ const rgb565 = (r: number, g: number, b: number): Rgb => color565to888(color565( /** The firmware's panel palette, as it lands in the frame buffer. */ export const PANEL_COLORS = Object.freeze({ black: rgb565(0, 0, 0), - led: rgb565(232, 85, 46), // pfLedC, patternflow.ino:635 - white: rgb565(255, 255, 255), // pfWhiteC, :636 - gray: rgb565(140, 140, 140), // pfGrayC, :637 - dim: rgb565(90, 90, 90), // pfDimC, :638 - rule: rgb565(60, 60, 60), // pfRuleC, :639 - green: rgb565(80, 220, 130), // pfGreenC, :640 - red: rgb565(255, 80, 80), // pfRedC, :641 - blue: rgb565(120, 180, 255), // pfBlueC, :642 - selectPage: rgb565(190, 190, 190), // "i / N", :1054 - selectHint: rgb565(200, 200, 200), // "HOLD TO SELECT", :1084 - knobActiveFill: rgb565(74, 27, 15), // KNOB MAP active disc, :1009 + led: rgb565(232, 85, 46), // pfLedC in patternflow.ino + white: rgb565(255, 255, 255), // pfWhiteC + gray: rgb565(140, 140, 140), // pfGrayC + dim: rgb565(90, 90, 90), // pfDimC + rule: rgb565(60, 60, 60), // pfRuleC + green: rgb565(80, 220, 130), // pfGreenC + red: rgb565(255, 80, 80), // pfRedC + blue: rgb565(120, 180, 255), // pfBlueC + selectPage: rgb565(190, 190, 190), // "i / N", drawSelectingMode + selectHint: rgb565(200, 200, 200), // "HOLD TO SELECT", drawSelectingMode + knobActiveFill: rgb565(74, 27, 15), // KNOB MAP active disc, drawKnobMap }); // ── A small Adafruit GFX over an RGBA frame ───────────────────────────────── @@ -729,8 +740,8 @@ function drawUiLine(g: PanelGfx, font: GfxFont | null, text: string, yTop: numbe } // PatternflowUiText::drawWrappedName, src/core_ui_text.h:447-449 -> drawWrapped -// (:388-445) with PF_UI_FONT_SELECT = the built-in 5x7 (core_ui_fonts.h:50-52) -// and pitch 9 (:64-66). +// (:388-445) with PF_UI_FONT_SELECT = the built-in 5x7 and PF_UI_PITCH_SELECT +// = 9 (both in core_ui_fonts.h). // // Only drawWrapped's first branch is ported, because it is the only one that // runs: it measures the whole name with getTextBounds, and with GFX wrap on, diff --git a/web/src/lib/hooks/useDialog.test.tsx b/web/src/lib/hooks/useDialog.test.tsx new file mode 100644 index 000000000..40300b52d --- /dev/null +++ b/web/src/lib/hooks/useDialog.test.tsx @@ -0,0 +1,108 @@ +// useDialog: the keys and the focus a Pattern Lab modal owes. Each case opens +// a small dialog from a real button, the way the lab does, so the focus has +// somewhere to come back to. + +import { useState } from "react"; +import { createEvent, fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { useDialog } from "./useDialog"; + +function Dialog({ onClose }: { onClose: () => void }) { + const cardRef = useDialog<HTMLDivElement>(onClose); + return ( + <div role="dialog" aria-modal="true" aria-label="Example"> + <div ref={cardRef} tabIndex={-1} data-testid="card"> + <input aria-label="Field" /> + <button type="button" onClick={onClose}> + Close + </button> + </div> + </div> + ); +} + +function Page({ onClose }: { onClose?: () => void }) { + const [open, setOpen] = useState(false); + const close = () => { + onClose?.(); + setOpen(false); + }; + return ( + <> + <button type="button" onClick={() => setOpen(true)}> + Open + </button> + {open && <Dialog onClose={close} />} + </> + ); +} + +function open() { + const opener = screen.getByText("Open"); + opener.focus(); + fireEvent.click(opener); + return opener; +} + +describe("useDialog", () => { + it("moves the focus into the dialog when it opens", () => { + render(<Page />); + open(); + expect(screen.getByTestId("card")).toHaveFocus(); + }); + + it("Esc closes it and the focus goes back to the button that opened it", () => { + render(<Page />); + const opener = open(); + fireEvent.keyDown(screen.getByLabelText("Field"), { key: "Escape" }); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(opener).toHaveFocus(); + }); + + it("Esc still closes it after the focus fell to the body", () => { + render(<Page />); + open(); + (document.activeElement as HTMLElement).blur(); + expect(document.body).toHaveFocus(); + fireEvent.keyDown(document.body, { key: "Escape" }); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("leaves an Esc that something inside already used", () => { + const onClose = vi.fn(); + render(<Page onClose={onClose} />); + open(); + const field = screen.getByLabelText("Field"); + const used = createEvent.keyDown(field, { key: "Escape" }); + used.preventDefault(); + fireEvent(field, used); + expect(onClose).not.toHaveBeenCalled(); + expect(screen.getByRole("dialog")).toBeTruthy(); + }); + + it("leaves an Esc that ends an IME composition", () => { + const onClose = vi.fn(); + render(<Page onClose={onClose} />); + open(); + fireEvent.keyDown(screen.getByLabelText("Field"), { key: "Escape", isComposing: true }); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("hears other keys as nothing", () => { + const onClose = vi.fn(); + render(<Page onClose={onClose} />); + open(); + fireEvent.keyDown(screen.getByLabelText("Field"), { key: "Enter" }); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("stops listening once it is closed", () => { + const onClose = vi.fn(); + render(<Page onClose={onClose} />); + open(); + fireEvent.click(screen.getByText("Close")); + expect(onClose).toHaveBeenCalledTimes(1); + fireEvent.keyDown(document.body, { key: "Escape" }); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/web/src/lib/hooks/useDialog.ts b/web/src/lib/hooks/useDialog.ts new file mode 100644 index 000000000..fa9c3fa7f --- /dev/null +++ b/web/src/lib/hooks/useDialog.ts @@ -0,0 +1,44 @@ +"use client"; + +import { useEffect, useRef, type RefObject } from "react"; + +/** + * What a modal owes the keyboard. Esc closes it, the focus moves into it when + * it opens, and the focus goes back to whatever opened it when it closes. + * + * The ref goes on an element inside the dialog with tabIndex={-1}. Esc is + * heard on the window, so it still works after a step change took the focused + * button away and left the focus on the body. An Esc that something inside + * already used (preventDefault), or one that ends an IME composition, is left + * alone. + * + * A dialog that opens another on top passes an onClose that closes the top + * one first: one Esc, one window. + */ +export function useDialog<T extends HTMLElement>(onClose: () => void): RefObject<T | null> { + const ref = useRef<T | null>(null); + const close = useRef(onClose); + + useEffect(() => { + close.current = onClose; + }); + + useEffect(() => { + const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null; + ref.current?.focus({ preventScroll: true }); + return () => { + if (opener?.isConnected) opener.focus({ preventScroll: true }); + }; + }, []); + + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if (event.key !== "Escape" || event.defaultPrevented || event.isComposing) return; + close.current(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); + + return ref; +} diff --git a/web/src/lib/hooks/useDismiss.test.tsx b/web/src/lib/hooks/useDismiss.test.tsx new file mode 100644 index 000000000..2e3b5c0de --- /dev/null +++ b/web/src/lib/hooks/useDismiss.test.tsx @@ -0,0 +1,110 @@ +// useDismiss: the Pattern Lab's header menus (Recent, Panels) closed only +// when the mouse left them. The fixture is built the way the lab builds them, +// a wrapper holding the toggle and the menu, with a field and a canvas beside +// it to press on and tab to. + +import { useState } from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { useDismiss } from "./useDismiss"; + +function Header() { + const [open, setOpen] = useState(false); + const wrapRef = useDismiss<HTMLDivElement>(open, () => setOpen(false)); + return ( + <> + <div ref={wrapRef}> + <button type="button" aria-expanded={open} onClick={() => setOpen((current) => !current)}> + Panels ▾ + </button> + {open && ( + <div data-testid="menu"> + <button type="button">Preview</button> + <button type="button">Pixel</button> + </div> + )} + </div> + <input aria-label="Pattern name" /> + {/* Like the dock, it keeps its presses from going any further. */} + <canvas + data-testid="canvas" + ref={(canvas) => canvas?.addEventListener("pointerdown", (event) => event.stopPropagation())} + /> + </> + ); +} + +function openMenu() { + const toggle = screen.getByText("Panels ▾"); + toggle.focus(); + fireEvent.click(toggle); + expect(screen.getByTestId("menu")).toBeTruthy(); + return toggle; +} + +describe("useDismiss", () => { + it("a press outside the menu closes it", () => { + render(<Header />); + openMenu(); + fireEvent.pointerDown(document.body); + expect(screen.queryByTestId("menu")).toBeNull(); + }); + + it("a press on a canvas that keeps its presses still closes it", () => { + render(<Header />); + openMenu(); + fireEvent.pointerDown(screen.getByTestId("canvas")); + expect(screen.queryByTestId("menu")).toBeNull(); + }); + + it("a press on an item leaves it open", () => { + render(<Header />); + openMenu(); + fireEvent.pointerDown(screen.getByText("Pixel")); + expect(screen.getByTestId("menu")).toBeTruthy(); + }); + + it("the toggle still closes it, in one press", () => { + render(<Header />); + const toggle = openMenu(); + fireEvent.pointerDown(toggle); + fireEvent.click(toggle); + expect(screen.queryByTestId("menu")).toBeNull(); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + }); + + it("Esc closes it and puts the focus back on the toggle", () => { + render(<Header />); + const toggle = openMenu(); + const item = screen.getByText("Pixel"); + item.focus(); + fireEvent.keyDown(item, { key: "Escape" }); + expect(screen.queryByTestId("menu")).toBeNull(); + expect(toggle).toHaveFocus(); + }); + + it("Tab from its last item to the next field closes it", () => { + render(<Header />); + openMenu(); + const field = screen.getByLabelText("Pattern name"); + fireEvent.focusOut(screen.getByText("Pixel"), { relatedTarget: field }); + expect(screen.queryByTestId("menu")).toBeNull(); + }); + + it("the focus moving between its own items leaves it open", () => { + render(<Header />); + openMenu(); + fireEvent.focusOut(screen.getByText("Preview"), { relatedTarget: screen.getByText("Pixel") }); + expect(screen.getByTestId("menu")).toBeTruthy(); + }); + + it("a closed menu hears nothing", () => { + render(<Header />); + const toggle = screen.getByText("Panels ▾"); + fireEvent.pointerDown(document.body); + fireEvent.keyDown(document.body, { key: "Escape" }); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + fireEvent.click(toggle); + expect(screen.getByTestId("menu")).toBeTruthy(); + }); +}); diff --git a/web/src/lib/hooks/useDismiss.ts b/web/src/lib/hooks/useDismiss.ts new file mode 100644 index 000000000..f2a67f379 --- /dev/null +++ b/web/src/lib/hooks/useDismiss.ts @@ -0,0 +1,60 @@ +"use client"; + +import { useEffect, useRef, type RefObject } from "react"; + +/** + * A pop-up menu that closes without a mouse. A finger never leaves a menu the + * way a pointer does, so a press anywhere outside it closes it. Esc closes it + * and puts the focus back on its toggle. Tab on past its last item closes it + * as well. + * + * The ref goes on the element that holds both the toggle and the menu, so a + * press on the toggle stays the toggle's own. The toggle is the element in + * there that carries aria-expanded. + * + * The press is heard in the capture phase. The lab's canvases and the dock + * take their presses for themselves, and a menu has to hear them first. + */ +export function useDismiss<T extends HTMLElement>( + open: boolean, + onDismiss: () => void, +): RefObject<T | null> { + const ref = useRef<T | null>(null); + const dismiss = useRef(onDismiss); + + useEffect(() => { + dismiss.current = onDismiss; + }); + + useEffect(() => { + const wrap = ref.current; + if (!open || !wrap) return; + const onPress = (event: PointerEvent) => { + if (event.target instanceof Node && wrap.contains(event.target)) return; + dismiss.current(); + }; + const onKey = (event: KeyboardEvent) => { + if (event.key !== "Escape" || event.defaultPrevented || event.isComposing) return; + const hadFocus = wrap.contains(document.activeElement); + dismiss.current(); + if (hadFocus) wrap.querySelector<HTMLElement>("[aria-expanded]")?.focus(); + }; + // No relatedTarget is the focus falling to the body: a press on nothing, + // which onPress has, or an item that took itself away. + const onFocusOut = (event: FocusEvent) => { + if (event.relatedTarget instanceof Node && !wrap.contains(event.relatedTarget)) { + dismiss.current(); + } + }; + document.addEventListener("pointerdown", onPress, true); + window.addEventListener("keydown", onKey); + wrap.addEventListener("focusout", onFocusOut); + return () => { + document.removeEventListener("pointerdown", onPress, true); + window.removeEventListener("keydown", onKey); + wrap.removeEventListener("focusout", onFocusOut); + }; + }, [open]); + + return ref; +} diff --git a/web/src/lib/hooks/useReducedMotion.test.ts b/web/src/lib/hooks/useReducedMotion.test.ts new file mode 100644 index 000000000..7278c4af6 --- /dev/null +++ b/web/src/lib/hooks/useReducedMotion.test.ts @@ -0,0 +1,63 @@ +// useReducedMotion: the first screen's video, the 3D device's sway and the +// Inside globe's drift all stop on it, and start again when it turns off. +// The setting is a stub the test flips the way the system settings would. + +import { act, renderHook } from "@testing-library/react"; +import { renderToString } from "react-dom/server"; +import { createElement } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useReducedMotion } from "./useReducedMotion"; + +const REDUCE = "(prefers-reduced-motion: reduce)"; + +let reduce = false; +const listeners = new Set<() => void>(); + +beforeEach(() => { + reduce = false; + listeners.clear(); + vi.stubGlobal("matchMedia", (query: string) => ({ + media: query, + get matches() { + return query === REDUCE ? reduce : false; + }, + addEventListener: (_: string, fn: () => void) => listeners.add(fn), + removeEventListener: (_: string, fn: () => void) => listeners.delete(fn), + })); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("useReducedMotion", () => { + it("reads the setting as it is", () => { + reduce = true; + expect(renderHook(() => useReducedMotion()).result.current).toBe(true); + }); + + it("follows it when it changes, both ways", () => { + const { result } = renderHook(() => useReducedMotion()); + expect(result.current).toBe(false); + reduce = true; + act(() => listeners.forEach((fn) => fn())); + expect(result.current).toBe(true); + reduce = false; + act(() => listeners.forEach((fn) => fn())); + expect(result.current).toBe(false); + }); + + it("stops listening once nothing uses it", () => { + const { unmount } = renderHook(() => useReducedMotion()); + expect(listeners.size).toBe(1); + unmount(); + expect(listeners.size).toBe(0); + }); + + it("answers yes on the server, which cannot know", () => { + function Probe() { + return createElement("i", null, String(useReducedMotion())); + } + expect(renderToString(createElement(Probe))).toBe("<i>true</i>"); + }); +}); diff --git a/web/src/lib/hooks/useReducedMotion.ts b/web/src/lib/hooks/useReducedMotion.ts new file mode 100644 index 000000000..9db79ad9d --- /dev/null +++ b/web/src/lib/hooks/useReducedMotion.ts @@ -0,0 +1,20 @@ +"use client"; + +import { useSyncExternalStore } from "react"; + +// Whether the reader has asked their system for less motion, followed as they +// change it. The server cannot know, so it answers yes: the page it sends +// holds still, and anything that moves by itself arrives with the client. +const REDUCED_MOTION = "(prefers-reduced-motion: reduce)"; + +function subscribe(onChange: () => void) { + const mq = window.matchMedia(REDUCED_MOTION); + mq.addEventListener("change", onChange); + return () => mq.removeEventListener("change", onChange); +} +const getSnapshot = () => window.matchMedia(REDUCED_MOTION).matches; +const getServerSnapshot = () => true; + +export function useReducedMotion(): boolean { + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); +} diff --git a/web/src/lib/hooks/useResettingState.test.ts b/web/src/lib/hooks/useResettingState.test.ts new file mode 100644 index 000000000..37245f83c --- /dev/null +++ b/web/src/lib/hooks/useResettingState.test.ts @@ -0,0 +1,56 @@ +// useResettingState: the "Copied ✓" a Pattern Lab button wears for a moment. +// What matters is the timer, so the clock is fake and every case ends by +// counting the timers still waiting. + +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useResettingState } from "./useResettingState"; + +beforeEach(() => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("useResettingState", () => { + it("goes back to idle once the time is up", () => { + const { result } = renderHook(() => useResettingState("idle", 1200)); + act(() => result.current[1]("copied")); + expect(result.current[0]).toBe("copied"); + act(() => vi.advanceTimersByTime(1199)); + expect(result.current[0]).toBe("copied"); + act(() => vi.advanceTimersByTime(1)); + expect(result.current[0]).toBe("idle"); + expect(vi.getTimerCount()).toBe(0); + }); + + it("a second set starts the wait over instead of ending with the first", () => { + const { result } = renderHook(() => useResettingState<string | null>(null, 1200)); + act(() => result.current[1]("header")); + act(() => vi.advanceTimersByTime(1000)); + act(() => result.current[1]("header")); + expect(vi.getTimerCount()).toBe(1); + act(() => vi.advanceTimersByTime(1000)); + expect(result.current[0]).toBe("header"); + act(() => vi.advanceTimersByTime(200)); + expect(result.current[0]).toBeNull(); + }); + + it("leaves no timer behind when the component goes away mid-wait", () => { + const { result, unmount } = renderHook(() => useResettingState(false, 1200)); + act(() => result.current[1](true)); + expect(vi.getTimerCount()).toBe(1); + unmount(); + expect(vi.getTimerCount()).toBe(0); + }); + + it("a set that lands after the component went away starts nothing", () => { + const { result, unmount } = renderHook(() => useResettingState(false, 1200)); + const set = result.current[1]; + unmount(); + set(true); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/web/src/lib/hooks/useResettingState.ts b/web/src/lib/hooks/useResettingState.ts new file mode 100644 index 000000000..43b47f02b --- /dev/null +++ b/web/src/lib/hooks/useResettingState.ts @@ -0,0 +1,44 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +/** + * A state that goes back to `idle` by itself, `ms` after it was last set: + * the "Copied ✓" a button wears for a second and a bit. + * + * Setting it again starts the wait over, so a second click is not cut short + * by the first click's timer. The timer goes with the component: a panel + * closed inside that second leaves nothing behind, and a set that lands after + * it closed (a clipboard write resolving late) starts no timer at all. A + * timer left behind used to fire after the Pattern Lab tests had torn their + * DOM down, and failed a run in which every test had passed. + */ +export function useResettingState<T>(idle: T, ms: number): [T, (value: T) => void] { + const [value, setValue] = useState<T>(idle); + const timer = useRef<number | null>(null); + const mounted = useRef(false); + + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + if (timer.current !== null) window.clearTimeout(timer.current); + timer.current = null; + }; + }, []); + + const set = useCallback( + (next: T) => { + if (!mounted.current) return; + if (timer.current !== null) window.clearTimeout(timer.current); + setValue(next); + timer.current = window.setTimeout(() => { + timer.current = null; + setValue(idle); + }, ms); + }, + [idle, ms], + ); + + return [value, set]; +} diff --git a/web/src/lib/lab/capture/shaderSource.ts b/web/src/lib/lab/capture/shaderSource.ts index 91167ac9b..ee2e561fa 100644 --- a/web/src/lib/lab/capture/shaderSource.ts +++ b/web/src/lib/lab/capture/shaderSource.ts @@ -122,7 +122,7 @@ export function remapShaderLog(log: string): string { .map((line) => line.trim()) .filter(Boolean) .map((line) => - line.replace(/\b0:(\d+):/g, (whole, digits: string) => + line.replace(/\b0:(\d+):/g, (_, digits: string) => Number(digits) >= 100000 ? "wrapper:" : `line ${digits}:`, ), ) diff --git a/web/src/lib/lab/compositor.test.ts b/web/src/lib/lab/compositor.test.ts new file mode 100644 index 000000000..f8b0b2622 --- /dev/null +++ b/web/src/lib/lab/compositor.test.ts @@ -0,0 +1,271 @@ +// The Lab preview against the flattened export. Until 18 October 2026 the +// preview wrote each layer's result into the byte buffer before blending the +// next one, so anything an add layer pushed past 255 was cut off there. The +// export and the C++ header add up in floats to the top layer and clamp once, +// so two (200, 0, 0) add layers under a (128, 128, 128) multiply showed R 128 +// in the Lab and 201 on the device. + +import { describe, expect, it } from "vitest"; +import { createIdleInput, PatternRuntime } from "@/lib/pattern/harness"; +import { compositeLayers, type CompositeEntry } from "./compositor"; +import { LabEngine, type EngineProject } from "./engine"; +import { flattenLayers } from "./flatten"; +import { buildHExport } from "./hExport"; +import { + BLEND_MODES, + DEFAULT_RAMP_STATE, + type BlendMode, + type CodeLayer, + type Layer, + type PixelLayer, +} from "./types"; + +type RGBA = [number, number, number, number]; + +function solid(pixelCount: number, color: RGBA): Uint8ClampedArray { + const data = new Uint8ClampedArray(pixelCount * 4); + for (let i = 0; i < pixelCount; i++) data.set(color, i * 4); + return data; +} + +function onePixel(entries: CompositeEntry[]): number[] { + const out = new Uint8ClampedArray(4); + compositeLayers(out, entries, 1); + return Array.from(out); +} + +const RED_200: RGBA = [200, 0, 0, 255]; +const GREY_128: RGBA = [128, 128, 128, 255]; + +describe("compositeLayers", () => { + it("carries an add layer's overflow into a multiply above it", () => { + const add = { data: solid(1, RED_200), opacity: 1, blend: "add" as const }; + const multiply = { data: solid(1, GREY_128), opacity: 1, blend: "multiply" as const }; + expect(onePixel([add, add, multiply])).toEqual([201, 0, 0, 255]); + }); + + it("carries it under a half-opacity normal layer too", () => { + const add = { data: solid(1, RED_200), opacity: 1, blend: "add" as const }; + const half = { data: solid(1, GREY_128), opacity: 0.5, blend: "normal" as const }; + expect(onePixel([add, add, half])).toEqual([255, 64, 64, 255]); + }); + + it("clamps the finished pixel to 255", () => { + const add = { data: solid(1, RED_200), opacity: 1, blend: "add" as const }; + expect(onePixel([add, add])).toEqual([255, 0, 0, 255]); + }); + + it("rounds half up once, as the export's setPixel does", () => { + // 1 × 0.5 = 0.5 → 1. A Uint8ClampedArray store rounds half to even (0). + const add = { data: solid(1, [1, 3, 5, 255]), opacity: 0.5, blend: "add" as const }; + expect(onePixel([add])).toEqual([1, 2, 3, 255]); + }); + + it("skips a pixel its mask switches off", () => { + const add = { data: solid(2, RED_200), opacity: 1, blend: "add" as const }; + const masked = { ...add, mask: new Uint8Array([1, 0]) }; + const out = new Uint8ClampedArray(8); + compositeLayers(out, [add, masked], 2); + expect(Array.from(out)).toEqual([255, 0, 0, 255, 200, 0, 0, 255]); + }); +}); + +// The same stack through both paths: the engine the preview draws with, and +// the pattern flattenLayers writes, run by the runtime that plays exported +// patterns. Seeded, so a failure names the same pixels every run. +describe("preview matches the flattened export", () => { + const W = 16; + const H = 8; + const N = W * H; + + function random(seed: number) { + let state = seed >>> 0; + return () => { + state = (Math.imul(state, 1664525) + 1013904223) >>> 0; + return state / 2 ** 32; + }; + } + + function pixelLayer( + id: string, + next: () => number, + blend: BlendMode, + opacity: number, + role: PixelLayer["role"] = "paint", + ): PixelLayer { + const data = new Uint8ClampedArray(N * 4); + for (let i = 0; i < data.length; i++) { + const roll = next(); + if ((i & 3) !== 3) data[i] = Math.floor(roll * 256); + // Plenty of fully opaque and fully clear pixels, the rest in between. + else data[i] = roll < 0.3 ? 255 : roll < 0.5 ? 0 : Math.floor(next() * 256); + } + return { + id, + type: "pixel", + name: id, + visible: true, + opacity, + blend, + role, + maskInvert: false, + width: W, + height: H, + data, + rev: 0, + }; + } + + // layers[0] is the top of the stack, as in the Layers panel. + function stack(seed: number): PixelLayer[] { + const next = random(seed); + const layers: PixelLayer[] = []; + for (let k = 0; k < 6; k++) { + const blend = BLEND_MODES[Math.floor(next() * BLEND_MODES.length)]; + // 0 too: seeds 9 and 11 put the mask on a 0% layer. + const opacity = [1, 0.5, 0.25, 0.8, 0][Math.floor(next() * 5)]; + layers.unshift(pixelLayer(`paint-${k}`, next, blend, opacity)); + } + // A mask over the third paint layer from the top. + layers.splice(2, 0, pixelLayer("mask", next, "normal", 1, "mask")); + return layers; + } + + function project(layers: Layer[]): EngineProject { + return { + matrix: { width: W, height: H }, + layers, + activeLayerId: layers[0].id, + knobs: [0, 0, 0, 0], + ranges: [[0, 1], [0, 1], [0, 1], [0, 1]], + }; + } + + function exported(layers: Layer[]): Uint8ClampedArray { + const runtime = new PatternRuntime(W, H); + const loaded = runtime.loadCode(flattenLayers(layers, { width: W, height: H })); + expect(loaded.error).toBeUndefined(); + const result = runtime.renderFrame(1 / 60, 0, createIdleInput([0, 0, 0, 0])); + expect(result.error).toBeUndefined(); + return runtime.data; + } + + function differences(a: Uint8ClampedArray, b: Uint8ClampedArray): string[] { + const found: string[] = []; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) found.push(`byte ${i}: preview ${a[i]}, export ${b[i]}`); + } + return found; + } + + it("the add, add, multiply stack from the bug", () => { + const add = (id: string): PixelLayer => ({ ...pixelLayer(id, () => 0, "add", 1), data: solid(N, RED_200) }); + const layers: PixelLayer[] = [ + { ...pixelLayer("multiply", () => 0, "multiply", 1), data: solid(N, GREY_128) }, + add("add-2"), + add("add-1"), + ]; + const preview = new LabEngine().render(project(layers), 1 / 60, 0).data; + expect(Array.from(preview.subarray(0, 4))).toEqual([201, 0, 0, 255]); + expect(differences(preview, exported(layers))).toEqual([]); + }); + + it.each([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12])("random stack %i", (seed) => { + const layers = stack(seed); + const preview = new LabEngine().render(project(layers), 1 / 60, 0).data; + expect(differences(preview, exported(layers))).toEqual([]); + }); + + it.each([1, 2, 3])("the Pixel panel backdrop, random stack %i", (seed) => { + const layers = stack(seed); + const hidden = layers[0].id; + const backdrop = new LabEngine().compositeBackdrop({ width: W, height: H }, layers, hidden).data; + const rest = layers.filter((layer) => layer.id !== hidden); + expect(differences(backdrop, exported(rest))).toEqual([]); + }); + + // A mask over a 0% layer gates nothing. Until 22 October 2026 the export + // and the header dropped the 0% layer first and handed its mask to the + // layer below, and the preview did the same for a code layer that had sat + // at 0% since it was added: it had never run, so it had no buffer to anchor. + describe("a mask over a 0% layer", () => { + const RED: RGBA = [255, 0, 0, 255]; + const red = (): PixelLayer => ({ ...pixelLayer("red", () => 0, "normal", 1), data: solid(N, RED) }); + // On over the left half of the frame only. + const leftHalf = (): PixelLayer => { + const layer = pixelLayer("mask", () => 0, "normal", 1, "mask"); + for (let i = 0; i < N; i++) layer.data.set(i % W < W / 2 ? [255, 255, 255, 255] : [0, 0, 0, 0], i * 4); + return layer; + }; + const blue = (opacity: number): CodeLayer => ({ + id: "blue", + type: "code", + name: "blue", + visible: true, + opacity, + blend: "normal", + role: "paint", + maskInvert: false, + code: `export function draw(display) { + for (let y = 0; y < display.height; y++) + for (let x = 0; x < display.width; x++) display.setPixel(x, y, 0, 0, 255); +}`, + ramp: DEFAULT_RAMP_STATE, + recolor: false, + knobsAnnotationRaw: null, + matrixAnnotationRaw: null, + }); + + // The header's composite plan: each paint group and the masks gating it. + function headerPlan(layers: Layer[]) { + const { scaffold } = buildHExport({ + name: "Plan", + matrix: { width: W, height: H }, + layers, + knobs: [0, 0, 0, 0], + ranges: [[0, 1], [0, 1], [0, 1], [0, 1]], + knobLabels: ["A", "B", "C", "D"], + }); + const names = [...scaffold.matchAll(/^\/\/ {3}L\d+\. (\S+) /gm)].map((match) => match[1]); + const table = (name: string) => + scaffold.match(new RegExp(`${name}\\[\\d+\\]\\s*=\\s*\\{([^}]*)\\}`))![1].split(",").map(Number); + const at = table("G_MASK_AT"); + const count = table("G_MASK_N"); + const masks = table("MASK_LAYER"); + return table("G_LAYER") + .slice(0, Number(scaffold.match(/GROUP_N = (\d+)/)![1])) + .map((layer, group) => ({ + layer: names[layer], + masks: masks.slice(at[group], at[group] + count[group]).map((mask) => names[mask]), + })); + } + + it("a pixel layer: the red under it shows whole, in the preview, the export and the header", () => { + const layers: Layer[] = [leftHalf(), pixelLayer("zero", () => 0.5, "normal", 0), red()]; + const preview = new LabEngine().render(project(layers), 1 / 60, 0).data; + expect(differences(preview, solid(N, RED))).toEqual([]); + expect(differences(preview, exported(layers))).toEqual([]); + expect(headerPlan(layers)).toEqual([{ layer: "red", masks: [] }]); + }); + + it("the header still hands a mask over a 50% layer to that layer", () => { + const layers: Layer[] = [leftHalf(), pixelLayer("half", () => 0.5, "normal", 0.5), red()]; + expect(headerPlan(layers)).toEqual([ + { layer: "red", masks: [] }, + { layer: "half", masks: ["mask"] }, + ]); + }); + + it("a code layer at 0% since it was added, as one turned down from 100%", () => { + const layers: Layer[] = [leftHalf(), blue(0), red()]; + const fresh = new LabEngine().render(project(layers), 1 / 60, 0).data; + expect(differences(fresh, solid(N, RED))).toEqual([]); + expect(differences(fresh, exported(layers))).toEqual([]); + + const turnedDown = new LabEngine(); + turnedDown.render(project([leftHalf(), blue(1), red()]), 1 / 60, 0); + expect(differences(turnedDown.render(project(layers), 1 / 60, 1 / 60).data, fresh)).toEqual([]); + expect(headerPlan(layers)).toEqual([{ layer: "red", masks: [] }]); + }); + }); +}); diff --git a/web/src/lib/lab/compositor.ts b/web/src/lib/lab/compositor.ts index e422a7c6b..ffb7cdd38 100644 --- a/web/src/lib/lab/compositor.ts +++ b/web/src/lib/lab/compositor.ts @@ -1,10 +1,10 @@ // ── Layer compositor ───────────────────────────────────────────────────────── // Straight-alpha painter's algorithm over an opaque black base (the physical // LED panel is black when off). Blend modes are limited to what the firmware -// can afford per pixel; the flattened JS export replicates this math 1:1, so -// keep the two in sync (see flatten.ts). +// can afford per pixel; the flattened JS export and the C++ header replicate +// this math 1:1, so keep the three in sync (see flatten.ts and hExport.ts). -import type { BlendMode } from "./types"; +import type { BlendMode, Layer } from "./types"; export type CompositeEntry = { /** Straight RGBA bytes, width × height × 4 — same frame as the target. */ @@ -15,6 +15,30 @@ export type CompositeEntry = { mask?: Uint8Array; }; +/** + * The layers an export carries, bottom → top (layers[0] is the top, as in the + * Layers panel): the visible ones, less each paint layer at 0% and the masks + * resting on it. A mask over a 0% layer gates nothing. Dropping the 0% layer + * alone would hand its masks to the next paint layer down, which the preview + * (engine.ts buildEntries) never does. flatten.ts and hExport.ts both export + * this stack, so the two can't drift apart. + */ +export function exportStack(layers: Layer[]): Layer[] { + const stack: Layer[] = []; + let overZero = false; + for (let index = layers.length - 1; index >= 0; index--) { + const layer = layers[index]; + if (!layer.visible) continue; + if (layer.role === "mask") { + if (!overZero) stack.push(layer); + continue; + } + overZero = !(layer.opacity > 0); + if (!overZero) stack.push(layer); + } + return stack; +} + /** * AND one mask layer's buffer into a coverage bitmap (1 byte per pixel). * Pixel-art masks switch on wherever something was drawn (alpha ≥ 0.5); @@ -43,30 +67,31 @@ export function applyMaskToCoverage( } } -/** Fill the target with opaque black, then blend entries bottom → top. */ +/** + * Blend entries bottom → top over opaque black. Each pixel adds up in floats + * and is rounded and clamped to a byte once, after the top layer — the order + * flatten.ts and hExport.ts use. Two add layers at (200, 0, 0) carry R = 400 + * into a multiply above them, so (128, 128, 128) leaves 201 rather than 128. + */ export function compositeLayers( out: Uint8ClampedArray, entries: CompositeEntry[], pixelCount: number, ) { + const opacities = entries.map((entry) => Math.max(0, Math.min(1, entry.opacity))); + for (let i = 0; i < pixelCount; i++) { const j = i * 4; - out[j] = 0; - out[j + 1] = 0; - out[j + 2] = 0; - out[j + 3] = 255; - } - - for (const entry of entries) { - const source = entry.data; - const opacity = Math.max(0, Math.min(1, entry.opacity)); - if (opacity <= 0) continue; - const blend = entry.blend; - const mask = entry.mask; + let r = 0; + let g = 0; + let b = 0; - for (let i = 0; i < pixelCount; i++) { - if (mask && mask[i] === 0) continue; - const j = i * 4; + for (let e = 0; e < entries.length; e++) { + const opacity = opacities[e]; + if (opacity <= 0) continue; + const entry = entries[e]; + if (entry.mask && entry.mask[i] === 0) continue; + const source = entry.data; const alpha = (source[j + 3] / 255) * opacity; if (alpha <= 0) continue; const sr = source[j]; @@ -74,24 +99,30 @@ export function compositeLayers( const sb = source[j + 2]; const inv = 1 - alpha; - if (blend === "add") { - out[j] = out[j] + sr * alpha; - out[j + 1] = out[j + 1] + sg * alpha; - out[j + 2] = out[j + 2] + sb * alpha; - } else if (blend === "multiply") { - out[j] = out[j] * inv + ((out[j] * sr) / 255) * alpha; - out[j + 1] = out[j + 1] * inv + ((out[j + 1] * sg) / 255) * alpha; - out[j + 2] = out[j + 2] * inv + ((out[j + 2] * sb) / 255) * alpha; - } else if (blend === "screen") { - out[j] = out[j] * inv + (255 - ((255 - out[j]) * (255 - sr)) / 255) * alpha; - out[j + 1] = out[j + 1] * inv + (255 - ((255 - out[j + 1]) * (255 - sg)) / 255) * alpha; - out[j + 2] = out[j + 2] * inv + (255 - ((255 - out[j + 2]) * (255 - sb)) / 255) * alpha; + if (entry.blend === "add") { + r += sr * alpha; + g += sg * alpha; + b += sb * alpha; + } else if (entry.blend === "multiply") { + r = r * inv + ((r * sr) / 255) * alpha; + g = g * inv + ((g * sg) / 255) * alpha; + b = b * inv + ((b * sb) / 255) * alpha; + } else if (entry.blend === "screen") { + r = r * inv + (255 - ((255 - r) * (255 - sr)) / 255) * alpha; + g = g * inv + (255 - ((255 - g) * (255 - sg)) / 255) * alpha; + b = b * inv + (255 - ((255 - b) * (255 - sb)) / 255) * alpha; } else { - out[j] = out[j] * inv + sr * alpha; - out[j + 1] = out[j + 1] * inv + sg * alpha; - out[j + 2] = out[j + 2] * inv + sb * alpha; + r = r * inv + sr * alpha; + g = g * inv + sg * alpha; + b = b * inv + sb * alpha; } - // Alpha stays 255: the base is opaque. } + + // Math.round, not the array's own round-half-to-even: the export's + // setPixel rounds this way, and the header's (int)(c + 0.5) does too. + out[j] = Math.min(255, Math.round(r)); + out[j + 1] = Math.min(255, Math.round(g)); + out[j + 2] = Math.min(255, Math.round(b)); + out[j + 3] = 255; // the base is opaque } } diff --git a/web/src/lib/lab/cppPrompt.ts b/web/src/lib/lab/cppPrompt.ts index e5b74a04a..db5c98fb4 100644 --- a/web/src/lib/lab/cppPrompt.ts +++ b/web/src/lib/lab/cppPrompt.ts @@ -86,7 +86,7 @@ export function buildCppPrompt({ .join("\n"); // Value-field patterns carry no color of their own. The ramp LUT is - // precomputed HERE (same buildRampLUT as the live preview) and emitted as + // precomputed HERE (same buildRampLUTRGBA as the live preview) and emitted as // a finished C array — models must not write sorting/interpolation code, // which is exactly where weaker models broke (unsorted stops → all-black // LUT, hallucinated tokens in hand-rolled lerp loops). The shared detector diff --git a/web/src/lib/lab/director/bake.ts b/web/src/lib/lab/director/bake.ts index 0d6cf59ac..20c658311 100644 --- a/web/src/lib/lab/director/bake.ts +++ b/web/src/lib/lab/director/bake.ts @@ -7,8 +7,10 @@ // The shipping path is PFST v2 (bakeShowV2): cues on the 0.1 s grid, curve // segments adaptively flattened into EASE pieces the player lerps between, // within a sub-detent error of the authored bezier. bakeShow (v1, the dense -// whole-second staircase) stays for compatibility checks and old players — -// nothing in the lab's own path uses it anymore. +// whole-second staircase) was the shipping path until 25 August 2026 +// (7749e69). Its one reader now is scripts/lab-director-smoke.ts, which runs +// its round trips on it and measures v2's cue count against it. Firmware +// before v3.6.3 plays only v1 tables, and nothing on the site bakes for it. import { PFST_MAX_CUES, diff --git a/web/src/lib/lab/director/types.ts b/web/src/lib/lab/director/types.ts index 36b73ba4e..d30696bb8 100644 --- a/web/src/lib/lab/director/types.ts +++ b/web/src/lib/lab/director/types.ts @@ -121,14 +121,3 @@ export function emptyShow(): DirectorShow { export function showHasContent(show: DirectorShow): boolean { return show.lanes.some((lane) => lane.length > 0) || show.messages.length > 0; } - -export function cloneShow(show: DirectorShow): DirectorShow { - return { - title: show.title, - length: show.length, - loop: show.loop, - lanes: show.lanes.map((lane) => lane.map((k) => ({ ...k, cp: [...k.cp] }))) as DirectorShow["lanes"], - messages: show.messages.map((m) => ({ ...m })), - patternCues: show.patternCues?.map((c) => ({ ...c })), - }; -} diff --git a/web/src/lib/lab/editorReveal.ts b/web/src/lib/lab/editorReveal.ts index 8a743110c..79fde9b19 100644 --- a/web/src/lib/lab/editorReveal.ts +++ b/web/src/lib/lab/editorReveal.ts @@ -6,7 +6,7 @@ // (which owns the dockview api) fronts the matching editor tab. // // Deliberately NOT store state: deriving it from activeLayerId would front -// tabs on every programmatic selection (gallery loads, imports, deletes), and +// tabs on every programmatic selection (preset steps, imports, deletes), and // re-clicking the already-active layer — the exact "let me in" gesture — would // change nothing and emit nothing. diff --git a/web/src/lib/lab/engine.ts b/web/src/lib/lab/engine.ts index aa998d412..314b5d656 100644 --- a/web/src/lib/lab/engine.ts +++ b/web/src/lib/lab/engine.ts @@ -254,11 +254,20 @@ export class LabEngine { const layer = layers[index]; if (layer.id === excludeId || !layer.visible) continue; + if (layer.role !== "mask" && layer.opacity <= 0) { + // A mask over a 0% layer gates nothing, the same rule as the export + // (compositor.ts exportStack). It must not fall through onto a deeper + // layer. Checked before the buffer: a code layer at 0% from the start + // has never run, so it has none. + lastPaint = null; + continue; + } + const buffer = this.bufferOf(layer, matrix); if (!buffer) continue; if (layer.role === "mask") { - if (!lastPaint) continue; // mask at the very bottom gates nothing + if (!lastPaint) continue; // nothing below it to gate, or a 0% layer if (!lastPaint.mask) lastPaint.mask = new Uint8Array(pixelCount).fill(1); applyMaskToCoverage( lastPaint.mask, @@ -270,12 +279,6 @@ export class LabEngine { continue; } - if (layer.opacity <= 0) { - // Invisible paint still anchors masks above it, so they don't leak - // onto a deeper layer. - lastPaint = { data: buffer, opacity: 0, blend: layer.blend }; - continue; - } const entry: CompositeEntry = { data: buffer, opacity: layer.opacity, blend: layer.blend }; entries.push(entry); lastPaint = entry; diff --git a/web/src/lib/lab/flatten.ts b/web/src/lib/lab/flatten.ts index f496dfac2..bb5c4d4bb 100644 --- a/web/src/lib/lab/flatten.ts +++ b/web/src/lib/lab/flatten.ts @@ -24,6 +24,7 @@ import { buildRampLUTRGBA } from "@/lib/pattern/harness"; import { withMatrixAnnotation, type MatrixSize } from "@/lib/pattern/matrix"; import { codeUsesValueField } from "@/lib/pattern/ramp"; import { KNOBS_ANNOTATION_RE, buildKnobsAnnotationLine, defaultKnobState } from "./annotations"; +import { exportStack } from "./compositor"; import { rampStateToHarness } from "./engine"; import { bytesToBase64 } from "./serialize"; @@ -110,11 +111,10 @@ export function flattenLayers( matrix: MatrixSize, knobConfig?: FlattenKnobs, ): string { - // Bottom → top for compositing. Masks ride along regardless of opacity - // (opacity is a paint concept; a mask is binary). - const stack = [...layers] - .reverse() - .filter((layer) => layer.visible && (layer.role === "mask" || layer.opacity > 0)); + // Bottom → top for compositing. Masks ride along regardless of their own + // opacity (opacity is a paint concept; a mask is binary), unless they rest + // on a 0% layer: those gate nothing, in the preview too. + const stack = exportStack(layers); const manifest = stack .map( diff --git a/web/src/lib/lab/hExport.ts b/web/src/lib/lab/hExport.ts index c4860c70a..e6933f2f9 100644 --- a/web/src/lib/lab/hExport.ts +++ b/web/src/lib/lab/hExport.ts @@ -31,6 +31,7 @@ import { knobDetentStep } from "@/lib/pattern/controls"; import type { MatrixSize } from "@/lib/pattern/matrix"; import { codeUsesValueField } from "@/lib/pattern/ramp"; import { KNOBS_ANNOTATION_RE } from "./annotations"; +import { exportStack } from "./compositor"; import { rampStateToHarness } from "./engine"; import type { CodeLayer, KnobRange, Layer } from "./types"; @@ -155,10 +156,9 @@ export type HExport = { export function buildHExport(args: HExportArgs): HExport { const { name, matrix, knobs, ranges, knobLabels } = args; - // Bottom → top, matching the compositor. - const stack = [...args.layers] - .reverse() - .filter((layer) => layer.visible && (layer.role === "mask" || layer.opacity > 0)); + // Bottom → top, matching the compositor. A 0% layer leaves with the masks + // resting on it: a mask over a 0% layer gates nothing. + const stack = exportStack(args.layers); const ns = `LabStack_${sanitizeName(name)}`; const isPanelFrame = diff --git a/web/src/lib/lab/legacyDraft.ts b/web/src/lib/lab/legacyDraft.ts index fbd71b394..3a5f48657 100644 --- a/web/src/lib/lab/legacyDraft.ts +++ b/web/src/lib/lab/legacyDraft.ts @@ -1,36 +1,30 @@ -// ── Pattern Lab draft autosave ─────────────────────────────────────────────── -// The lab is a long-session tool: people tune a pattern for an hour, edit the -// ramp, generate a gallery. Closing the tab (or a crash) used to throw all of -// that away, because only the ramp and the Experiment patch were persisted. +// ── Pattern Lab: the July draft ────────────────────────────────────────────── +// The lab's autosave (e19fe19, 25 July 2026) stored the working session under +// two keys on purpose: the draft (small, always worth keeping: code, knobs, +// ranges, ...) and the AI gallery (up to hundreds of KB of generated +// patterns). A quota failure while writing the large gallery must never take +// the small draft down with it, hence the split. // -// This module stores the rest of the working state in localStorage so reopening -// the page lands back where the user left off. Two keys on purpose: -// -// DRAFT_STORAGE small, always worth keeping (code, knobs, ranges, ...) -// GALLERY_STORAGE potentially hundreds of KB of generated patterns -// -// A quota failure while writing the (large) gallery must never take the (small) -// draft down with it, hence the split. Every read is defensive: a draft written -// by an older build, or hand-edited, must degrade to "no draft" rather than -// crash the lab on boot. - -import { COLOR_MODES, THINKING_LEVELS } from "@/lib/ai/settings"; -import type { ColorMode, PatternVariant, ThinkingLevelKey } from "@/lib/ai/settings"; +// Pattern Lab v2 (9be6e98, 27 July) moved the working session into the layered +// project and stopped writing the draft. loadDraft stays for serialize.ts's +// migrateLegacyDraft. The gallery kept its key until the lab dropped it on +// 23 October 2026, and dropAiGallery clears what it left behind. Every read is +// defensive: a draft written by an older build, or hand-edited, must degrade +// to "no draft" rather than crash the lab on boot. + +import { COLOR_MODES } from "@/lib/ai/settings"; +import type { ColorMode } from "@/lib/ai/settings"; import { isValidMatrix, type MatrixSize } from "@/lib/pattern/matrix"; -import { LAB_STORAGE, readJson, removeStorage as remove, writeJson } from "./persist"; +import { LAB_STORAGE, readJson, removeStorage as remove } from "./persist"; export const DRAFT_STORAGE = LAB_STORAGE.legacyDraft; -export const GALLERY_STORAGE = LAB_STORAGE.legacyGallery; -// Guard rails so one runaway value can't blow the whole quota. +// Guard rail so one runaway value can't blow the whole quota. const MAX_CODE_CHARS = 200_000; -const MAX_GALLERY_ITEMS = 48; +/** The July draft also kept the gallery's count, thinking and refs; only this outlived it. */ export type DraftGenSettings = { - count: number; - thinking: ThinkingLevelKey; - refs: number; colorMode: ColorMode; }; @@ -47,13 +41,10 @@ export type LabDraft = { * covers drafts whose code predates the annotation. */ matrix: MatrixSize; - editorView: "code" | "gallery"; forkOf: { id: string; title: string } | null; gen: DraftGenSettings; }; -export type DraftGalleryItem = PatternVariant & { id: string; pinned?: boolean }; - function numberArray(value: unknown, length: number): number[] | null { if (!Array.isArray(value) || value.length !== length) return null; const result = value.map(Number); @@ -117,8 +108,6 @@ export function loadDraft(knobCount: number): Partial<LabDraft> | null { : null; const gen = (raw.gen ?? {}) as Record<string, unknown>; - const genCount = Number(gen.count); - const genRefs = Number(gen.refs); // Each field falls back independently: a draft missing (or corrupting) one // value still restores everything else. @@ -130,59 +119,24 @@ export function loadDraft(knobCount: number): Partial<LabDraft> | null { knobLabels: stringArray(raw.knobLabels, knobCount) ?? undefined, recolor: typeof raw.recolor === "boolean" ? raw.recolor : undefined, matrix: readMatrix(raw), - editorView: oneOf(raw.editorView, ["code", "gallery"] as const, "code"), forkOf, gen: { - count: Number.isFinite(genCount) ? genCount : 5, - thinking: oneOf<ThinkingLevelKey>(gen.thinking, THINKING_LEVELS, "LOW"), - refs: Number.isFinite(genRefs) ? genRefs : 6, colorMode: oneOf<ColorMode>(gen.colorMode, COLOR_MODES, "vfield"), }, }; } -export function saveDraft(draft: Omit<LabDraft, "savedAt">): boolean { - if (draft.code.length > MAX_CODE_CHARS) return false; - return writeJson(DRAFT_STORAGE, { ...draft, savedAt: Date.now() }); -} - export function clearDraft() { remove(DRAFT_STORAGE); - remove(GALLERY_STORAGE); -} - -export function loadGallery(): DraftGalleryItem[] { - const parsed = readJson(GALLERY_STORAGE); - if (!Array.isArray(parsed)) return []; - return parsed - .filter( - (item): item is DraftGalleryItem => - Boolean(item) && - typeof item === "object" && - typeof (item as DraftGalleryItem).id === "string" && - typeof (item as DraftGalleryItem).code === "string" && - typeof (item as DraftGalleryItem).name === "string", - ) - .slice(0, MAX_GALLERY_ITEMS) - .map((item) => ({ - id: item.id, - name: item.name, - code: item.code, - knobNotes: typeof item.knobNotes === "string" ? item.knobNotes : undefined, - pinned: Boolean(item.pinned), - })); } /** - * Best-effort gallery persist. Generated patterns can add up to hundreds of KB, - * so on a quota failure we retry with the pinned items only (the ones the user - * explicitly kept) before giving up — a partial save beats none. + * The AI gallery's patterns and the Gemini key it kept. Nothing writes either + * since 23 October 2026. hydrate calls this on every open of the lab, so a + * browser that held them loses them the first time, and one that never did + * loses nothing. */ -export function saveGallery(items: DraftGalleryItem[]): boolean { - const trimmed = items.slice(0, MAX_GALLERY_ITEMS); - if (writeJson(GALLERY_STORAGE, trimmed)) return true; - const pinned = trimmed.filter((item) => item.pinned); - if (pinned.length > 0 && writeJson(GALLERY_STORAGE, pinned)) return true; - remove(GALLERY_STORAGE); - return false; +export function dropAiGallery() { + remove(LAB_STORAGE.gallery); + remove(LAB_STORAGE.geminiKey); } diff --git a/web/src/lib/lab/persist.ts b/web/src/lib/lab/persist.ts index 6be03e56a..8312ff3b6 100644 --- a/web/src/lib/lab/persist.ts +++ b/web/src/lib/lab/persist.ts @@ -6,26 +6,54 @@ // lab stored without grepping for "patternflow_lab_". // // Bumping a version means: add the new key here, keep the old one, and write a -// migration that reads the old and writes the new (serialize.ts's -// migrateLegacyDraft is the precedent — it lifts _draft_v1 + _gallery_v1 into -// _project_v2 the first time the new lab opens). +// migration that reads the old and writes the new. serialize.ts's +// migrateLegacyDraft is the precedent: it lifts _draft_v1 and _ramp_v1 into a +// _project_v2 project. A key whose feature is gone stays listed here with the +// date, so the lab can still remove it (legacyDraft.ts dropAiGallery). export const LAB_STORAGE = { - /** The whole layered project — layers, knobs, ramps, director, gen settings. serialize.ts */ + /** The whole layered project — layers, knobs, ramps, director, prompt colour mode. serialize.ts */ project: "patternflow_lab_project_v2", /** The dockview layout. PatternLabClient.tsx */ layout: "patternflow_lab_layout_v1", + /** + * The dockview layout when the lab opens 800px wide or less. A tablet can + * open the lab upright one day and on its side the next, and each width + * keeps the dock it was arranged at. Written since 16 October 2026; before + * that a phone saved its three squeezed columns under _layout_v1, which a + * narrow screen no longer reads. PatternLabClient.tsx + */ + layoutNarrow: "patternflow_lab_layout_narrow_v1", /** Ring of parked sessions, newest first. sessions.ts */ sessions: "patternflow_lab_sessions_v1", /** Graphic Export output settings. capture/settings.ts */ capture: "patternflow_lab_capture_v1", /** Shader twins by layer id / code hash. capture/shaderStore.ts */ shader: "patternflow_lab_shader_v1", - /** v1 lab: the single-pattern draft. Read once by the migration. legacyDraft.ts */ + /** + * The single-pattern draft, written by the lab's autosave from 25 July 2026 + * (e19fe19) until Pattern Lab v2 replaced it on 27 July (9be6e98). Nothing + * writes it now. The migration reads it on every open where no project + * restores, an empty canvas after a stash included. legacyDraft.ts + */ legacyDraft: "patternflow_lab_draft_v1", - /** v1 lab: the generated gallery. Still live — the gallery has not moved into the project. legacyDraft.ts */ - legacyGallery: "patternflow_lab_gallery_v1", - /** v1 lab: the colour ramp, kept apart from the draft back then. Read once by the migration. serialize.ts */ + /** + * The AI gallery's generated patterns, written by the lab's autosave from + * 25 July 2026 (e19fe19) until the gallery was taken out on 23 October. + * Nothing writes it now, and the lab removes it on open. legacyDraft.ts + */ + gallery: "patternflow_lab_gallery_v1", + /** + * The Gemini API key the AI gallery saved for whoever set one. Nothing + * writes it since 23 October 2026, and the lab removes it on open. + * legacyDraft.ts + */ + geminiKey: "patternflow_gemini_key", + /** + * The colour ramp, written by the lab on every ramp edit from 2 July 2026 + * (67b9798) until Pattern Lab v2 on 27 July (9be6e98). Nothing writes it + * now. The migration reads it with the draft. serialize.ts + */ legacyRamp: "patternflow_ramp_v1", } as const; diff --git a/web/src/lib/lab/pixelHistory.test.ts b/web/src/lib/lab/pixelHistory.test.ts new file mode 100644 index 000000000..e9c6f7701 --- /dev/null +++ b/web/src/lib/lab/pixelHistory.test.ts @@ -0,0 +1,120 @@ +// Pixel undo frames across a matrix resize. Until 16 October 2026 a frame was a +// bare buffer, so undo after shrinking the matrix threw a RangeError from +// Uint8ClampedArray.set, and undo after growing it wrote the old rows across +// the new, wider ones. + +import { beforeEach, describe, expect, it } from "vitest"; +import { MAX_UNDO, prunePixelHistories, redoPixels, snapshotPixels, undoPixels } from "./pixelHistory"; +import { resizeSurface, type PixelSurface, type RGBA } from "./pixelTools"; + +const RED: RGBA = [255, 0, 0, 255]; +const BLUE: RGBA = [0, 0, 255, 255]; +const CLEAR: RGBA = [0, 0, 0, 0]; + +function blank(width: number, height: number): PixelSurface { + return { data: new Uint8ClampedArray(width * height * 4), width, height }; +} + +function paint(surface: PixelSurface, x: number, y: number, color: RGBA): void { + surface.data.set(color, (y * surface.width + x) * 4); +} + +function rgba(surface: PixelSurface, x: number, y: number): number[] { + const i = (y * surface.width + x) * 4; + return Array.from(surface.data.subarray(i, i + 4)); +} + +// What resizePixelLayers does to a layer: a new buffer, same id. +function resized(surface: PixelSurface, width: number, height: number): PixelSurface { + return { data: resizeSurface(surface.data, surface.width, surface.height, width, height), width, height }; +} + +beforeEach(() => prunePixelHistories([])); + +describe("pixel undo", () => { + it("undo and redo at one size", () => { + const layer = blank(3, 3); + snapshotPixels("a", layer); + paint(layer, 1, 1, RED); + expect(undoPixels("a", layer)).toBe(true); + expect(rgba(layer, 1, 1)).toEqual(CLEAR); + expect(redoPixels("a", layer)).toBe(true); + expect(rgba(layer, 1, 1)).toEqual(RED); + expect(redoPixels("a", layer)).toBe(false); + }); + + it("after the matrix shrinks, undo puts the frame back cropped to the new size", () => { + const layer = blank(4, 2); + paint(layer, 1, 0, RED); + snapshotPixels("a", layer); + paint(layer, 2, 1, BLUE); + // 4×2 to 2×2 keeps the middle two columns: x 1 becomes 0, x 2 becomes 1 + const small = resized(layer, 2, 2); + expect(rgba(small, 1, 1)).toEqual(BLUE); + + expect(undoPixels("a", small)).toBe(true); + expect(rgba(small, 0, 0)).toEqual(RED); + expect(rgba(small, 1, 1)).toEqual(CLEAR); + expect(redoPixels("a", small)).toBe(true); + expect(rgba(small, 1, 1)).toEqual(BLUE); + }); + + it("after the matrix grows, undo puts the old rows back centred", () => { + const layer = blank(2, 1); + paint(layer, 0, 0, RED); + paint(layer, 1, 0, BLUE); + snapshotPixels("a", layer); + paint(layer, 1, 0, RED); + // 2×1 to 4×3 pads one pixel on each side: the row lands at y 1, x 1 and 2 + const big = resized(layer, 4, 3); + + expect(undoPixels("a", big)).toBe(true); + const expected = blank(4, 3); + paint(expected, 1, 1, RED); + paint(expected, 2, 1, BLUE); + expect(Array.from(big.data)).toEqual(Array.from(expected.data)); + }); + + it("frames from both sides of a resize come back in order", () => { + const layer = blank(4, 2); + snapshotPixels("a", layer); + paint(layer, 1, 0, RED); + const small = resized(layer, 2, 2); + snapshotPixels("a", small); + paint(small, 1, 1, BLUE); + + expect(undoPixels("a", small)).toBe(true); + expect(rgba(small, 0, 0)).toEqual(RED); + expect(rgba(small, 1, 1)).toEqual(CLEAR); + expect(undoPixels("a", small)).toBe(true); + expect(rgba(small, 0, 0)).toEqual(CLEAR); + expect(undoPixels("a", small)).toBe(false); + }); + + it("redo after a resize between the undo and the redo", () => { + const layer = blank(4, 2); + snapshotPixels("a", layer); + paint(layer, 2, 1, BLUE); + undoPixels("a", layer); + const small = resized(layer, 2, 2); + + expect(redoPixels("a", small)).toBe(true); + expect(rgba(small, 1, 1)).toEqual(BLUE); + }); + + it(`keeps the last ${MAX_UNDO} frames`, () => { + const layer = blank(1, 1); + for (let i = 0; i <= MAX_UNDO; i++) snapshotPixels("a", layer); + for (let i = 0; i < MAX_UNDO; i++) expect(undoPixels("a", layer)).toBe(true); + expect(undoPixels("a", layer)).toBe(false); + }); + + it("prune drops the stacks of layers that are gone", () => { + const layer = blank(1, 1); + snapshotPixels("kept", layer); + snapshotPixels("gone", layer); + prunePixelHistories(["kept"]); + expect(undoPixels("gone", layer)).toBe(false); + expect(undoPixels("kept", layer)).toBe(true); + }); +}); diff --git a/web/src/lib/lab/pixelHistory.ts b/web/src/lib/lab/pixelHistory.ts index f7ab93615..6eb0c1470 100644 --- a/web/src/lib/lab/pixelHistory.ts +++ b/web/src/lib/lab/pixelHistory.ts @@ -3,10 +3,17 @@ // React: buffers, not state. The Pixel panel calls snapshot() before a stroke, // undo()/redo() on the keys, and prune() with the live layer ids so a deleted // layer's stacks — up to MAX_UNDO frames of RGBA — do not live for the tab. +// +// A frame keeps the size it was taken at. Changing the matrix size gives the +// layer a new buffer under the same id (resizePixelLayers in store/shared.ts), +// so an older frame goes back through resizeSurface, centred and cropped or +// padded the way the resize treated the layer itself. + +import { resizeSurface, type PixelSurface } from "./pixelTools"; export const MAX_UNDO = 40; -type History = { undo: Uint8ClampedArray[]; redo: Uint8ClampedArray[] }; +type History = { undo: PixelSurface[]; redo: PixelSurface[] }; const histories = new Map<string, History>(); @@ -19,31 +26,39 @@ function historyFor(id: string): History { return history; } -/** Remember the buffer as it is now, before it changes. Clears the redo stack. */ -export function snapshotPixels(id: string, data: Uint8ClampedArray): void { +function frameOf(surface: PixelSurface): PixelSurface { + return { data: new Uint8ClampedArray(surface.data), width: surface.width, height: surface.height }; +} + +function restore(surface: PixelSurface, frame: PixelSurface): void { + surface.data.set(resizeSurface(frame.data, frame.width, frame.height, surface.width, surface.height)); +} + +/** Remember the surface as it is now, before it changes. Clears the redo stack. */ +export function snapshotPixels(id: string, surface: PixelSurface): void { const history = historyFor(id); - history.undo.push(new Uint8ClampedArray(data)); + history.undo.push(frameOf(surface)); if (history.undo.length > MAX_UNDO) history.undo.shift(); history.redo = []; } -/** Restore the previous snapshot INTO `data`. False when there is none. */ -export function undoPixels(id: string, data: Uint8ClampedArray): boolean { +/** Restore the previous snapshot INTO `surface.data`, at its size. False when there is none. */ +export function undoPixels(id: string, surface: PixelSurface): boolean { const history = historyFor(id); const previous = history.undo.pop(); if (!previous) return false; - history.redo.push(new Uint8ClampedArray(data)); - data.set(previous); + history.redo.push(frameOf(surface)); + restore(surface, previous); return true; } -/** Re-apply the last undone snapshot INTO `data`. False when there is none. */ -export function redoPixels(id: string, data: Uint8ClampedArray): boolean { +/** Re-apply the last undone snapshot INTO `surface.data`, at its size. False when there is none. */ +export function redoPixels(id: string, surface: PixelSurface): boolean { const history = historyFor(id); const next = history.redo.pop(); if (!next) return false; - history.undo.push(new Uint8ClampedArray(data)); - data.set(next); + history.undo.push(frameOf(surface)); + restore(surface, next); return true; } diff --git a/web/src/lib/lab/retiredGallery.test.ts b/web/src/lib/lab/retiredGallery.test.ts new file mode 100644 index 000000000..ae30202f8 --- /dev/null +++ b/web/src/lib/lab/retiredGallery.test.ts @@ -0,0 +1,152 @@ +// The AI gallery came out of the lab on 23 October 2026. What it left behind +// in a browser that used it: a dock layout with a Gallery tab, a project whose +// `gen` block holds batch settings, its generated patterns and the Gemini key. + +import { beforeEach, describe, expect, it } from "vitest"; +import { PANELS } from "@/app/pattern-lab/panels/registry"; +import { LAB_STORAGE } from "./persist"; +import { deserializeProject, dropRetiredPanels, layoutViewCount } from "./serialize"; +import { useLabStore } from "./store"; + +const known = new Set(PANELS.map((panel) => panel.id)); + +function panel(id: string, title: string) { + return { id, contentComponent: id, title }; +} + +// The default desktop layout as dockview's toJSON writes it, with the Gallery +// tab where the registry used to put it: inside the Code group, fronted. +function savedLayout() { + return { + grid: { + root: { + type: "branch", + data: [ + { + type: "branch", + data: [ + { type: "leaf", data: { views: ["preview"], activeView: "preview", id: "1" }, size: 500 }, + { type: "leaf", data: { views: ["layers"], activeView: "layers", id: "2" }, size: 300 }, + ], + size: 420, + }, + { + type: "leaf", + data: { views: ["code", "pixel", "gallery", "capture", "director"], activeView: "gallery", id: "3" }, + size: 680, + }, + { + type: "branch", + data: [ + { type: "leaf", data: { views: ["knobs"], activeView: "knobs", id: "4" }, size: 460 }, + { type: "leaf", data: { views: ["ramp"], activeView: "ramp", id: "5" }, size: 340 }, + ], + size: 340, + }, + ], + size: 800, + }, + width: 1440, + height: 800, + orientation: "HORIZONTAL", + }, + panels: { + preview: panel("preview", "Preview"), + layers: panel("layers", "Layers"), + code: panel("code", "Code"), + pixel: panel("pixel", "Pixel"), + gallery: panel("gallery", "Gallery"), + capture: panel("capture", "Graphic Export"), + director: panel("director", "Director"), + knobs: panel("knobs", "Knobs"), + ramp: panel("ramp", "Color Ramp"), + }, + activeGroup: "3", + }; +} + +beforeEach(() => localStorage.clear()); + +describe("a saved layout from before the gallery went", () => { + it("keeps every other panel where it was and fronts the tab beside the gone one", () => { + const layout = dropRetiredPanels(savedLayout(), known)!; + expect(layout).not.toBeNull(); + expect(Object.keys(layout.panels)).not.toContain("gallery"); + const editors = layout.grid.root.data[1] as { data: { views: string[]; activeView: string } }; + expect(editors.data.views).toEqual(["code", "pixel", "capture", "director"]); + expect(editors.data.activeView).toBe("code"); + expect(layout.grid.width).toBe(1440); + expect(layoutViewCount(layout)).toBe(8); + }); + + it("hands back a layout with nothing retired unchanged", () => { + const layout = dropRetiredPanels(savedLayout(), known)!; + expect(dropRetiredPanels(layout, known)).toBe(layout); + }); + + it("gives up on a layout where the gallery had a group to itself", () => { + const layout = savedLayout(); + layout.grid.root.data[1] = { + type: "leaf", + data: { views: ["gallery"], activeView: "gallery", id: "3" }, + size: 680, + }; + expect(dropRetiredPanels(layout, known)).toBeNull(); + expect(layoutViewCount(dropRetiredPanels(layout, known))).toBe(0); + }); + + it("takes the tab out of a floating group too", () => { + const layout = { + ...savedLayout(), + floatingGroups: [ + { + data: { views: ["knobs", "gallery"], activeView: "knobs", id: "6" }, + position: { left: 10, top: 10, width: 300, height: 200 }, + }, + ], + }; + const result = dropRetiredPanels(layout, known)!; + expect(result.floatingGroups[0].data.views).toEqual(["knobs"]); + }); +}); + +describe("a project saved with the gallery's settings", () => { + it("restores with the colour mode and lets the batch settings go", () => { + const json = JSON.stringify({ + version: 2, + name: "Old piece", + matrix: { width: 128, height: 64 }, + layers: [ + { + id: "a", + type: "code", + name: "Code 1", + visible: true, + opacity: 1, + blend: "normal", + code: "function draw() {}", + }, + ], + activeLayerId: "a", + knobs: [0.5, 0.5, 0.5, 0.5], + ranges: [[0, 1], [0, 1], [0, 1], [0, 1]], + knobLabels: ["Knob 1", "Knob 2", "Knob 3", "Knob 4"], + gen: { count: 12, thinking: "HIGH", refs: 3, colorMode: "rgb" }, + }); + const project = deserializeProject(json); + expect(project?.gen).toEqual({ colorMode: "rgb" }); + }); +}); + +describe("opening the lab", () => { + it("removes the gallery's patterns and the Gemini key, and nothing else", () => { + localStorage.setItem(LAB_STORAGE.gallery, JSON.stringify([{ id: "x", name: "Variation", code: "function draw() {}" }])); + localStorage.setItem(LAB_STORAGE.geminiKey, "AIza-not-a-real-key"); + localStorage.setItem(LAB_STORAGE.capture, "{}"); + useLabStore.getState().hydrate(); + expect(localStorage.getItem(LAB_STORAGE.gallery)).toBeNull(); + expect(localStorage.getItem(LAB_STORAGE.geminiKey)).toBeNull(); + expect(localStorage.getItem(LAB_STORAGE.capture)).toBe("{}"); + expect(useLabStore.getState()).not.toHaveProperty("gallery"); + }); +}); diff --git a/web/src/lib/lab/serialize.test.ts b/web/src/lib/lab/serialize.test.ts index 08b37e0c1..9b2699563 100644 --- a/web/src/lib/lab/serialize.test.ts +++ b/web/src/lib/lab/serialize.test.ts @@ -30,7 +30,7 @@ function projectFixture(): LabProject { knobLabels: ["A", "B", "C", "D"], forkOf: null, editOf: null, - gen: { count: 5, thinking: "LOW", refs: 6, colorMode: "vfield" }, + gen: { colorMode: "vfield" }, director: emptyShow(), }; } diff --git a/web/src/lib/lab/serialize.ts b/web/src/lib/lab/serialize.ts index 555b21c25..645078105 100644 --- a/web/src/lib/lab/serialize.ts +++ b/web/src/lib/lab/serialize.ts @@ -4,13 +4,14 @@ // Reads are defensive: a project written by any other build must degrade to // "no project" (or partial restore) rather than crash the lab on boot. // -// Migration: when no v2 project exists, the old single-pattern draft -// (patternflow_lab_draft_v1 + patternflow_ramp_v1) is lifted into a one-layer -// project, so nobody loses work by opening the new lab once. +// Migration: when no project restores, the single-pattern draft the lab wrote +// from 25 to 27 July 2026 (patternflow_lab_draft_v1 + patternflow_ramp_v1) is +// lifted into a one-layer project, so nobody lost work by opening the new lab. +// persist.ts says who wrote each key. import { RAMP_MODES, type RampMode } from "@/lib/pattern/harness"; -import { COLOR_MODES, THINKING_LEVELS } from "@/lib/ai/settings"; -import type { ColorMode, ThinkingLevelKey } from "@/lib/ai/settings"; +import { COLOR_MODES } from "@/lib/ai/settings"; +import type { ColorMode } from "@/lib/ai/settings"; import { isValidMatrix, DEFAULT_MATRIX, type MatrixSize } from "@/lib/pattern/matrix"; import { loadDraft } from "@/lib/lab/legacyDraft"; import { PATTERN_KNOB_COUNT } from "@/lib/pattern/harness"; @@ -46,6 +47,7 @@ import { export const PROJECT_STORAGE = LAB_STORAGE.project; export const LAYOUT_STORAGE = LAB_STORAGE.layout; +export const LAYOUT_NARROW_STORAGE = LAB_STORAGE.layoutNarrow; /** * How many panels a serialized dockview layout actually PLACES. @@ -76,7 +78,54 @@ export function layoutViewCount(layout: unknown): number { const grid = (layout as { grid?: { root?: unknown } } | null | undefined)?.grid; return walk(grid?.root); } -/** Old single-pattern lab keys, read once for migration. */ + +/** + * A saved dock layout without the panels this build no longer has, or null + * when taking one out would leave a group with no tabs. + * + * dockview builds every saved panel through the component map, and a name the + * map lacks throws ("Only React.memo(...), React.ForwardRef(...) and + * functional components are accepted"). The restore catches that and lays out + * the default, so when the Gallery panel went on 23 October 2026 every layout + * still holding its tab, the default one included, would have been thrown + * away for that one tab. A group left empty would restore as a blank pane, so + * that case still gets the default. + */ +export function dropRetiredPanels<T>(layout: T, known: ReadonlySet<string>): T | null { + if (!isRecord(layout) || !isRecord(layout.panels)) return layout; + const { panels, ...rest } = layout; + const retired = new Set( + Object.entries(panels) + .filter(([id, panel]) => { + const component = isRecord(panel) ? (panel.contentComponent ?? panel.id ?? id) : id; + return typeof component !== "string" || !known.has(component); + }) + .map(([id]) => id), + ); + if (retired.size === 0) return layout; + + let emptied = false; + const keep = (ids: unknown[]) => ids.filter((id) => !(typeof id === "string" && retired.has(id))); + const strip = (node: unknown): unknown => { + if (Array.isArray(node)) return node.map(strip); + if (!isRecord(node)) return node; + const next: Record<string, unknown> = {}; + for (const [key, value] of Object.entries(node)) next[key] = strip(value); + if (Array.isArray(node.views)) { + const views = keep(node.views); + if (views.length === 0 && node.views.length > 0) emptied = true; + next.views = views; + if (typeof node.activeView === "string" && retired.has(node.activeView)) next.activeView = views[0]; + } + if (Array.isArray(node.panelIds)) next.panelIds = keep(node.panelIds); + return next; + }; + const result = strip(rest) as Record<string, unknown>; + if (emptied) return null; + result.panels = Object.fromEntries(Object.entries(panels).filter(([id]) => !retired.has(id))); + return result as T; +} +/** The colour ramp the lab wrote from 2 to 27 July 2026. Only the migration reads it. */ const LEGACY_RAMP_STORAGE = LAB_STORAGE.legacyRamp; const MAX_CODE_CHARS = 200_000; @@ -251,14 +300,7 @@ export function serializeProject(project: LabProject): string | null { function readGen(raw: unknown): GenSettings { const gen = (raw ?? {}) as Record<string, unknown>; - const count = Number(gen.count); - const refs = Number(gen.refs); return { - count: Number.isFinite(count) ? Math.max(1, Math.min(20, Math.round(count))) : 5, - thinking: THINKING_LEVELS.includes(gen.thinking as ThinkingLevelKey) - ? (gen.thinking as ThinkingLevelKey) - : "LOW", - refs: Number.isFinite(refs) ? refs : 6, colorMode: COLOR_MODES.includes(gen.colorMode as ColorMode) ? (gen.colorMode as ColorMode) : "vfield", @@ -533,10 +575,13 @@ export function clearProject() { removeStorage(PROJECT_STORAGE); } -// ── v1 migration ── +// ── migration from the July draft ── /** - * Lift the old single-pattern draft into a one-layer project. Returns null - * when there is nothing to migrate. + * Lift the single-pattern draft the lab's autosave wrote from 25 to 27 July + * 2026 into a one-layer project. Returns null when there is no draft. The + * store calls this whenever loadProject returns null, and an empty canvas + * saves a project loadProject refuses, so a browser that still holds the + * draft gets it back on every such open, not once. */ export function migrateLegacyDraft(): (LabProject & { savedAt: number }) | null { const draft = loadDraft(PATTERN_KNOB_COUNT); @@ -582,7 +627,7 @@ export function migrateLegacyDraft(): (LabProject & { savedAt: number }) | null knobLabels: draft.knobLabels ?? knobState.labels, director: emptyShow(), forkOf: draft.forkOf ?? null, - // The legacy single-draft format predates in-place editing entirely. + // The July draft predates in-place editing entirely. editOf: null, gen: readGen(draft.gen), }; diff --git a/web/src/lib/lab/sessions.ts b/web/src/lib/lab/sessions.ts index c88474ae5..e4acc6223 100644 --- a/web/src/lib/lab/sessions.ts +++ b/web/src/lib/lab/sessions.ts @@ -18,7 +18,7 @@ // localStorage budget, and a quota failure drops the oldest and retries // rather than breaking the save. -import { LAB_STORAGE, readJson, removeStorage, writeJson } from "./persist"; +import { LAB_STORAGE, readJson, writeJson } from "./persist"; const KEY = LAB_STORAGE.sessions; @@ -95,9 +95,3 @@ export function stashSession( export function readSession(id: string): string | null { return read().find((entry) => entry.id === id)?.json ?? null; } - -export function deleteSession(id: string): void { - const remaining = read().filter((entry) => entry.id !== id); - if (remaining.length === 0) removeStorage(KEY); - else writeJson(KEY, remaining); -} diff --git a/web/src/lib/lab/store.ts b/web/src/lib/lab/store.ts index 61a7aab21..7043a4441 100644 --- a/web/src/lib/lab/store.ts +++ b/web/src/lib/lab/store.ts @@ -1,8 +1,8 @@ // ── Lab store ──────────────────────────────────────────────────────────────── -// One zustand store holds the whole layered project plus the AI gallery. The -// render engine reads it imperatively each frame (labEngine + getState), while -// panels subscribe to the slices they draw. All updates are immutable EXCEPT -// pixel buffers, which are mutated in place and versioned via `rev`. +// One zustand store holds the whole layered project. The render engine reads +// it imperatively each frame (labEngine + getState), while panels subscribe to +// the slices they draw. All updates are immutable EXCEPT pixel buffers, which +// are mutated in place and versioned via `rev`. // // The actions live in ./store/<slice>.ts, one file per concern, composed here; // ./store/shared.ts holds the helpers they share. The store's shape (LabStore), @@ -10,18 +10,16 @@ import { create } from "zustand"; import type { MatrixSize } from "@/lib/pattern/matrix"; -import { saveGallery } from "@/lib/lab/legacyDraft"; import { saveProject } from "./serialize"; import { createAutosave } from "./autosave"; import type { DirectorShow } from "./director/types"; -import { isCodeLayer, type BlendMode, type CodeLayer, type EditRef, type ForkRef, type LayerRole, type GalleryItem, type GenJob, type GenSettings, type LabProject, type Layer, type RampState, type RampStopState } from "./types"; +import { isCodeLayer, type BlendMode, type CodeLayer, type EditRef, type ForkRef, type LayerRole, type GenSettings, type LabProject, type Layer, type RampState, type RampStopState } from "./types"; import { defaultProject } from "./store/shared"; import { createProjectSlice } from "./store/project"; import { createLayersSlice } from "./store/layers"; import { createKnobsSlice } from "./store/knobs"; import { createRampSlice } from "./store/ramp"; import { createDirectorSlice } from "./store/director"; -import { createGallerySlice } from "./store/gallery"; export { codeLayerFromSource } from "./store/shared"; @@ -31,8 +29,6 @@ export type LabStore = LabProject & { restoredAt: number | null; /** Browser autosave state; never included in the serialized project. */ saveStatus: "idle" | "pending" | "saved" | "error"; - gallery: GalleryItem[]; - jobs: GenJob[]; layerErrors: Record<string, string | null>; /** Selected ramp stop per code layer (UI state, not persisted). */ rampSelection: Record<string, number>; @@ -88,8 +84,6 @@ export type LabStore = LabProject & { bumpPixelLayer: (id: string) => void; replacePixelData: (id: string, data: Uint8ClampedArray) => void; - setGallery: (update: (current: GalleryItem[]) => GalleryItem[]) => void; - setJobs: (update: (current: GenJob[]) => GenJob[]) => void; setLayerErrors: (errors: Record<string, string | null>) => void; }; @@ -98,8 +92,6 @@ export const useLabStore = create<LabStore>((set, get) => ({ hydrated: false, restoredAt: null, saveStatus: "idle", - gallery: [], - jobs: [], layerErrors: {}, rampSelection: {}, @@ -108,12 +100,11 @@ export const useLabStore = create<LabStore>((set, get) => ({ ...createKnobsSlice(set), ...createRampSlice(set), ...createDirectorSlice(set), - ...createGallerySlice(set), })); // ── persistence ── -// Debounced project autosave + gallery persist, gated until hydration so the -// pre-restore default state can never overwrite a stored project. +// Debounced project autosave, gated until hydration so the pre-restore +// default state can never overwrite a stored project. const autosave = createAutosave( () => saveProject(useLabStore.getState()), (saved) => useLabStore.setState({ saveStatus: saved ? "saved" : "error" }), @@ -135,10 +126,6 @@ if (typeof window !== "undefined") { useLabStore.subscribe((state, previous) => { if (!state.hydrated) return; - if (state.gallery !== previous.gallery) { - saveGallery(state.gallery); - } - const projectChanged = state.name !== previous.name || state.matrix !== previous.matrix || @@ -164,11 +151,6 @@ export function useActiveLayer(): Layer | undefined { return useLabStore((state) => state.layers.find((layer) => layer.id === state.activeLayerId)); } -export function useActiveCodeLayer(): CodeLayer | undefined { - const layer = useActiveLayer(); - return isCodeLayer(layer) ? layer : undefined; -} - /** * The code layer whose knobs/ramp the side panels should edit: the active * layer when it is code, otherwise the topmost code layer. diff --git a/web/src/lib/lab/store/gallery.ts b/web/src/lib/lab/store/gallery.ts deleted file mode 100644 index fab95e3ee..000000000 --- a/web/src/lib/lab/store/gallery.ts +++ /dev/null @@ -1,15 +0,0 @@ -// ── Lab store slice: the AI gallery and its generation queue ── -// One of the slices store.ts composes. Bodies are unchanged from the single -// 800-line store they came out of; only the file boundary is new. - -import type { LabStore } from "../store"; -import type { SliceSet } from "./kinds"; - -export type GallerySlice = Pick<LabStore, "setGallery" | "setJobs">; - -export function createGallerySlice(set: SliceSet): GallerySlice { - return { - setGallery: (update) => set((state) => ({ gallery: update(state.gallery) })), - setJobs: (update) => set((state) => ({ jobs: update(state.jobs) })), - }; -} diff --git a/web/src/lib/lab/store/project.ts b/web/src/lib/lab/store/project.ts index 2a4668893..84621627c 100644 --- a/web/src/lib/lab/store/project.ts +++ b/web/src/lib/lab/store/project.ts @@ -1,9 +1,9 @@ -// ── Lab store slice: the whole project: hydration, the session ring, matrix, generation settings, lineage, name ── +// ── Lab store slice: the whole project: hydration, the session ring, matrix, prompt settings, lineage, name ── // One of the slices store.ts composes. Bodies are unchanged from the single // 800-line store they came out of; only the file boundary is new. import { matrixesEqual } from "@/lib/pattern/matrix"; -import { clearDraft, loadGallery } from "@/lib/lab/legacyDraft"; +import { clearDraft, dropAiGallery } from "@/lib/lab/legacyDraft"; import { clearProject, deserializeProject, loadProject, migrateLegacyDraft, serializeProject } from "../serialize"; import { readSession, stashSession } from "../sessions"; import { cloneRampState, layerId, type Layer } from "../types"; @@ -17,6 +17,9 @@ export function createProjectSlice(set: SliceSet, get: SliceGet): ProjectSlice { return { hydrate: () => { if (get().hydrated) return; + dropAiGallery(); + // No project, or one with no layers: lift the July draft if the browser + // still holds one. persist.ts names the keys. const restored = loadProject() ?? migrateLegacyDraft(); if (restored) { const { savedAt, ...project } = restored; @@ -24,21 +27,18 @@ export function createProjectSlice(set: SliceSet, get: SliceGet): ProjectSlice { ...project, hydrated: true, restoredAt: savedAt || Date.now(), - gallery: loadGallery(), }); return; } - set({ hydrated: true, gallery: loadGallery() }); + set({ hydrated: true }); }, discardProject: () => { clearProject(); - clearDraft(); // also clears the legacy draft + gallery keys + clearDraft(); // the July draft's key set({ ...defaultProject(), restoredAt: null, - gallery: [], - jobs: [], layerErrors: {}, }); }, diff --git a/web/src/lib/lab/store/shared.ts b/web/src/lib/lab/store/shared.ts index 52289d0a8..6e3a11c92 100644 --- a/web/src/lib/lab/store/shared.ts +++ b/web/src/lib/lab/store/shared.ts @@ -92,7 +92,7 @@ export function defaultProject(): LabProject { knobLabels: knobState.labels, forkOf: null, editOf: null, - gen: { count: 5, thinking: "LOW", refs: 6, colorMode: "vfield" }, + gen: { colorMode: "vfield" }, director: emptyShow(), }; } diff --git a/web/src/lib/lab/types.ts b/web/src/lib/lab/types.ts index f6a266462..3c712b83f 100644 --- a/web/src/lib/lab/types.ts +++ b/web/src/lib/lab/types.ts @@ -13,7 +13,7 @@ import type { RampMode } from "@/lib/pattern/harness"; import type { MatrixSize } from "@/lib/pattern/matrix"; -import type { ColorMode, PatternVariant, ThinkingLevelKey } from "@/lib/ai/settings"; +import type { ColorMode } from "@/lib/ai/settings"; import type { DirectorShow } from "./director/types"; export type KnobRange = [number, number]; @@ -123,26 +123,15 @@ export type EditRef = { portCount: number; } | null; +/** + * What Copy prompt asks an AI for. Until 23 October 2026 this also held the + * AI gallery's batch size, thinking level and reference count; a project + * saved then still carries them, and readGen (serialize.ts) lets them go. + */ export type GenSettings = { - count: number; - thinking: ThinkingLevelKey; - refs: number; colorMode: ColorMode; }; -export type GalleryItem = PatternVariant & { id: string; pinned?: boolean }; - -export type GenJob = { - id: string; - count: number; - thinkingLevel: ThinkingLevelKey; - status: "running" | "done" | "error"; - startedAt: number; - finishedAt?: number; - resultCount?: number; - error?: string; -}; - export type LabProject = { /** * The piece's name — one identity for every hand-off: the hardware diff --git a/web/src/lib/pattern/controls.test.ts b/web/src/lib/pattern/controls.test.ts new file mode 100644 index 000000000..6c5604285 --- /dev/null +++ b/web/src/lib/pattern/controls.test.ts @@ -0,0 +1,200 @@ +// The knob math every preview shares: how far one detent moves a value, which +// knobs wrap, and how a value change becomes the encoder clicks a pattern +// reads in input.knobDeltas. The first-screen preview goes through +// getKnobValueDelta by front-panel id; the lab, the sandbox and the guide go +// through knobTargetToDelta by logical index. Both used to spell out the wrap +// rule themselves. + +import { describe, expect, it } from "vitest"; +import { DETENTS_PER_TURN, TURNS_PER_FULL_RANGE } from "@/lib/shared/constants"; +import { LabEngine } from "@/lib/lab/engine"; +import { DEFAULT_RAMP_STATE, cloneRampState, type CodeLayer } from "@/lib/lab/types"; +import { knobTargetToDelta } from "./harness"; +import { + ENCODER_CLICKS_PER_TURN, + LOGICAL_KNOB_RANGES, + LOGICAL_KNOB_TO_WEB_KNOB, + LOGICAL_KNOB_WRAP, + getKnobValueDelta, + knobDetentStep, + knobUnitsPerTurn, + logicalKnobUnitsPerTurn, + toEncoderDelta, + webKnobRange, + wrapKnobDelta, + type KnobId, +} from "./controls"; + +describe("travel", () => { + it("crosses any range in the same number of turns", () => { + for (const range of [[0, 1], [0.1, 10], [-20, 20], [3, 12]] as const) { + const span = range[1] - range[0]; + expect(knobUnitsPerTurn(range) * TURNS_PER_FULL_RANGE).toBeCloseTo(span, 12); + expect(knobDetentStep(range) * DETENTS_PER_TURN * TURNS_PER_FULL_RANGE).toBeCloseTo(span, 12); + } + }); + + it("counts the encoder's detents from shared/constants.json", () => { + expect(ENCODER_CLICKS_PER_TURN).toBe(DETENTS_PER_TURN); + }); + + it("turns one detent's worth of value back into one click", () => { + for (const range of LOGICAL_KNOB_RANGES) { + expect(toEncoderDelta(range, knobDetentStep(range))).toBeCloseTo(1, 12); + expect(toEncoderDelta(range, -knobUnitsPerTurn(range))).toBeCloseTo(-DETENTS_PER_TURN, 12); + } + }); + + it("takes a pattern's declared ranges per knob and the lab's defaults for the rest", () => { + const perTurn = logicalKnobUnitsPerTurn([[0, 10]]); + expect(perTurn).toHaveLength(4); + expect(perTurn[0]).toBeCloseTo(10 / TURNS_PER_FULL_RANGE, 12); + LOGICAL_KNOB_RANGES.slice(1).forEach((range, index) => { + expect(perTurn[index + 1]).toBeCloseTo(knobUnitsPerTurn(range), 12); + }); + }); +}); + +describe("front-panel ids", () => { + it("finds each knob's range through the logical order (Hue, Speed, Mode, Freq)", () => { + expect(webKnobRange("c1")).toEqual(LOGICAL_KNOB_RANGES[0]); + expect(webKnobRange("c2")).toEqual(LOGICAL_KNOB_RANGES[1]); + expect(webKnobRange("c4")).toEqual(LOGICAL_KNOB_RANGES[2]); + expect(webKnobRange("c3")).toEqual(LOGICAL_KNOB_RANGES[3]); + }); +}); + +describe("wrapping", () => { + it("takes the short way across the seam", () => { + expect(wrapKnobDelta(0.05 - 0.95)).toBeCloseTo(0.1, 12); + expect(wrapKnobDelta(0.95 - 0.05)).toBeCloseTo(-0.1, 12); + }); + + it("leaves a move of half a range or less alone", () => { + expect(wrapKnobDelta(0.3)).toBe(0.3); + expect(wrapKnobDelta(-0.3)).toBe(-0.3); + expect(wrapKnobDelta(0.5)).toBe(0.5); + expect(wrapKnobDelta(-0.5)).toBe(-0.5); + }); + + it("wraps the front-panel knobs that LOGICAL_KNOB_WRAP marks, and only those", () => { + const wraps = (id: KnobId) => getKnobValueDelta(id, 0.05, 0.95) > 0; + expect((["c1", "c2", "c3", "c4"] as const).filter(wraps)).toEqual(["c1", "c3"]); + LOGICAL_KNOB_TO_WEB_KNOB.forEach((id, logicalIndex) => { + expect(wraps(id)).toBe(LOGICAL_KNOB_WRAP[logicalIndex]); + }); + }); + + it("wraps at half of the span it is given", () => { + expect(wrapKnobDelta(5 - 355, 360)).toBeCloseTo(10, 12); + expect(wrapKnobDelta(355 - 5, 360)).toBeCloseTo(-10, 12); + expect(wrapKnobDelta(7.5, 360)).toBe(7.5); + expect(wrapKnobDelta(180, 360)).toBe(180); + }); + + it("gives the same clicks by front-panel id as the lab's path gives by logical index", () => { + const moves: Array<[number, number]> = [ + [0.95, 0.05], + [0.05, 0.95], + [0.2, 0.45], + [0.6, 0.1], + ]; + LOGICAL_KNOB_TO_WEB_KNOB.forEach((id, logicalIndex) => { + const range = LOGICAL_KNOB_RANGES[logicalIndex]; + for (const [previous, next] of moves) { + const byId = toEncoderDelta(range, getKnobValueDelta(id, next, previous)); + const byIndex = knobTargetToDelta( + previous, + next, + LOGICAL_KNOB_WRAP[logicalIndex], + knobUnitsPerTurn(range), + ); + expect(byId).toBeCloseTo(byIndex, 9); + } + }); + }); +}); + +// Until 22 October 2026 the wrap took every range as 0..1 and turned at ±0.5, +// so a pattern declaring `@knobs Hue=0..360` read one detent (7.5) as 6.5 and +// handed the pattern 0.867 of a click. +describe("a wrapping knob on a range other than 0..1", () => { + const HUE: [number, number] = [0, 360]; + const perTurn = knobUnitsPerTurn(HUE); + const clicks = (previous: number, next: number) => knobTargetToDelta(previous, next, true, perTurn); + + it("reads one detent as one click", () => { + expect(clicks(100, 100 + knobDetentStep(HUE))).toBeCloseTo(1, 12); + expect(clicks(100, 100 - knobDetentStep(HUE))).toBeCloseTo(-1, 12); + }); + + it("keeps the direction of a move smaller than a detent", () => { + expect(clicks(100, 100.7)).toBeCloseTo(0.7 / knobDetentStep(HUE), 12); + }); + + it("crosses the seam the short way", () => { + expect(clicks(355, 5)).toBeCloseTo(10 / knobDetentStep(HUE), 12); + expect(clicks(5, 355)).toBeCloseTo(-10 / knobDetentStep(HUE), 12); + }); + + it("counts the clicks of a sweep across the whole range, as the Director plays one", () => { + const frames = 120; + let total = 0; + for (let frame = 0; frame < frames; frame++) { + total += clicks((frame / frames) * 360, ((frame + 1) / frames) * 360); + } + expect(total).toBeCloseTo(TURNS_PER_FULL_RANGE * DETENTS_PER_TURN, 9); + // Looping back to the start lands where it began: no clicks at all. + expect(clicks(360, 0)).toBeCloseTo(0, 12); + }); + + it("does the same on a range that does not start at 0", () => { + const range: [number, number] = [-20, 20]; + const step = knobDetentStep(range); + expect(knobTargetToDelta(3, 3 + step, true, knobUnitsPerTurn(range))).toBeCloseTo(1, 12); + expect(knobTargetToDelta(19, -19, true, knobUnitsPerTurn(range))).toBeCloseTo(2 / step, 9); + }); + + it("hands the Lab's pattern one click for each detent on knobs 1 and 4", () => { + // The pattern counts the clicks it is handed on knobs 1 and 4 and writes + // five times each count into pixel (0, 0), so 24 clicks read 120. + const code = ` + function update(dt, input, params) { + params.a = (params.a || 0) + input.knobDeltas[0]; + params.b = (params.b || 0) + input.knobDeltas[3]; + } + function draw(display, params) { + display.setPixel(0, 0, (params.a || 0) * 5, (params.b || 0) * 5, 0); + } + `; + const layer: CodeLayer = { + id: "count", + type: "code", + name: "count", + visible: true, + opacity: 1, + blend: "normal", + role: "paint", + maskInvert: false, + code, + ramp: cloneRampState(DEFAULT_RAMP_STATE), + recolor: false, + knobsAnnotationRaw: null, + matrixAnnotationRaw: null, + }; + const ranges: Array<[number, number]> = [HUE, [0.1, 10], [0, 4.9], [0, 100]]; + const engine = new LabEngine(); + const at = (knob1: number, knob4: number) => + engine.render( + { matrix: { width: 1, height: 1 }, layers: [layer], activeLayerId: layer.id, knobs: [knob1, 2, 0, knob4], ranges }, + 1 / 60, + 0, + ).data; + at(0, 0); + let frame = at(0, 0); + for (let detent = 1; detent <= DETENTS_PER_TURN; detent++) { + frame = at(detent * knobDetentStep(HUE), detent * knobDetentStep(ranges[3])); + } + expect(Array.from(frame.subarray(0, 2))).toEqual([DETENTS_PER_TURN * 5, DETENTS_PER_TURN * 5]); + }); +}); diff --git a/web/src/lib/pattern/controls.ts b/web/src/lib/pattern/controls.ts index 7c945677b..50ae53086 100644 --- a/web/src/lib/pattern/controls.ts +++ b/web/src/lib/pattern/controls.ts @@ -1,16 +1,17 @@ -import { PATTERN_DETENTS_PER_TURN } from "./harness"; +import { BUS_MAX, DETENTS_PER_TURN, TURNS_PER_FULL_RANGE } from "@/lib/shared/constants"; // Detents in one full turn of the physical encoder. One fact, one constant: -// harness.ts owns it (see the note there — the reference Bourns -// PEC11R-...-S0024 is 24, not the 20 assumed for a long time) and this is the -// name the knob math has always used for it. -export const ENCODER_CLICKS_PER_TURN = PATTERN_DETENTS_PER_TURN; +// shared/constants.json owns it (see the note in harness.ts — the reference +// Bourns PEC11R-...-S0024 is 24, not the 20 assumed for a long time) and this +// is the name the knob math has always used for it. +export const ENCODER_CLICKS_PER_TURN = DETENTS_PER_TURN; // The device's absolute bus speaks integers 0..1000 (PF_BUS_MAX in -// firmware/patternflow/src/core_bus.h). Everything here that writes a wire -// value — the Director's bake, the .pfs codec, the MIDI export — uses this -// rather than the literal, so the two sides can only disagree in one place. -export const BUS_WIRE_MAX = 1000; +// firmware/patternflow/src/core_bus.h, BUS_MAX in shared/constants.json). +// Everything here that writes a wire value — the Director's bake, the .pfs +// codec, the MIDI export — uses this rather than the literal, so the two sides +// can only disagree in one place. +export const BUS_WIRE_MAX = BUS_MAX; // How many full turns cross a parameter's ENTIRE range. // @@ -21,7 +22,7 @@ export const BUS_WIRE_MAX = 1000; // Deriving travel from the range makes every knob feel the same regardless of // what it controls, and keeps the web preview and the encoder in step because // both read the value from here. -export const TURNS_PER_FULL_RANGE = 2; +export { TURNS_PER_FULL_RANGE }; /** Value units covered by one full turn, for a parameter spanning `range`. */ export function knobUnitsPerTurn(range: readonly [number, number]) { @@ -67,17 +68,25 @@ export function webKnobRange(knobId: KnobId): readonly [number, number] { return LOGICAL_KNOB_RANGES[logicalIndex] ?? [0, 1]; } -export function getKnobValueDelta(knobId: KnobId, current: number, previous: number) { - let delta = current - previous; - - if (knobId === 'c1' || knobId === 'c3') { - if (delta > 0.5) delta -= 1; - if (delta < -0.5) delta += 1; - } - +/** + * A wrapping knob's change between two readings, taken the short way round a + * range `span` wide: on 0..1, 0.95 to 0.05 is +0.1 across the seam, not -0.9. + * The span has to be the knob's own. A pattern that declares Hue=0..360 moves + * 7.5 a detent, and wrapping that at ±0.5 read the detent as 6.5. + */ +export function wrapKnobDelta(delta: number, span = 1) { + if (delta > span / 2) return delta - span; + if (delta < -span / 2) return delta + span; return delta; } +export function getKnobValueDelta(knobId: KnobId, current: number, previous: number) { + const delta = current - previous; + if (!LOGICAL_KNOB_WRAP[LOGICAL_KNOB_TO_WEB_KNOB.indexOf(knobId)]) return delta; + const range = webKnobRange(knobId); + return wrapKnobDelta(delta, range[1] - range[0]); +} + export function toEncoderDelta(range: readonly [number, number], valueDelta: number) { return valueDelta * (ENCODER_CLICKS_PER_TURN / knobUnitsPerTurn(range)); } diff --git a/web/src/lib/pattern/harness.test.ts b/web/src/lib/pattern/harness.test.ts new file mode 100644 index 000000000..a61eff5d9 --- /dev/null +++ b/web/src/lib/pattern/harness.test.ts @@ -0,0 +1,104 @@ +// A pasted pattern that was cut off used to be reported as "Unexpected token +// 'return'" or "Unexpected token ')'": the `return` compilePatternCode puts +// after the code, and the `)` that `new Function` closes it with. Neither is in +// the editor. The Lab's Code panel and the Live Editor both show what +// describePatternError says, through PatternRuntime and LedMatrixTexture. + +import { describe, expect, it } from "vitest"; +import { PatternRuntime, describePatternError } from "./harness"; + +const FULL = `export function setup(params) { + params.t = 0; +} + +export function draw(display, params, time) { + for (let y = 0; y < display.height; y++) { + for (let x = 0; x < display.width; x++) { + display.setPixel(x, y, 255, 0, 0); + } + } +} +`; + +function loadError(code: string) { + const result = new PatternRuntime(8, 8).loadCode(code); + expect(result.ok).toBe(false); + return result.error; +} + +describe("a pattern cut off partway", () => { + it("says the code stops early, and quotes the last line, when cut inside a call", () => { + expect(loadError(FULL.slice(0, FULL.indexOf("255")))).toBe( + "The code stops before it ends: 1 ( and 3 { never close. Was the paste cut off?\n" + + "line 8 (the last): display.setPixel(x, y,", + ); + }); + + it("says so when only the last } is missing", () => { + expect(loadError(FULL.trimEnd().slice(0, -1))).toBe( + "The code stops before it ends: 1 { never closes. Was the paste cut off?\nline 10 (the last): }", + ); + }); + + it("names the string, template or comment the code stops inside", () => { + expect(loadError(`function draw(display) {\n const label = "Patt`)).toMatch( + /^The code stops before it ends: 1 " and 1 \{ never close\./, + ); + expect(loadError("function draw(display) {\n const label = `x = ${display.width")).toMatch( + /^The code stops before it ends: 1 \$\{, 1 ` and 1 \{ never close\./, + ); + expect(loadError("function draw(display) {\n /* fill the panel")).toMatch( + /^The code stops before it ends: 1 \/\* and 1 \{ never close\./, + ); + }); + + it("does not count brackets inside strings, comments, templates or regular expressions", () => { + const code = `function draw(display, params) { + const a = "((["; + const b = '{{'; + const c = \`\${display.width} ((\`; + const d = /[({]+/g.test(a) ? display.width / 2 : display.height / 2; + // ((({ + /* {[( */ + display.setPixel(d, 0,`; + expect(loadError(code)).toBe( + "The code stops before it ends: 1 ( and 1 { never close. Was the paste cut off?\n" + + "line 8 (the last): display.setPixel(d, 0,", + ); + }); + + it("keeps the tail of a long last line, where the paste stopped", () => { + const tail = "display.setPixel(x, y, 255, 0, "; + const message = loadError(`function draw(display) {\n ${"const pad = 0; ".repeat(8)}${tail}`); + expect(message).toMatch(/\nline 2 \(the last\): …/); + expect(message?.endsWith(tail.trim())).toBe(true); + }); + + it("skips blank lines after the cut when it quotes the last line", () => { + expect(loadError("function draw(display) {\n display.setPixel(0, 0,\n\n\n")).toMatch( + /\nline 2 \(the last\): display\.setPixel\(0, 0,$/, + ); + }); +}); + +describe("a syntax error that is not a cut", () => { + it("keeps the engine's message when a ) is missing in the middle", () => { + const code = FULL.replace("255, 0, 0)", "255, 0, 0"); + expect(loadError(code)).not.toMatch(/stops before it ends/); + }); + + it("keeps the engine's message when a string runs into the next line", () => { + const code = FULL.replace("255, 0, 0);", '"red);'); + expect(loadError(code)).not.toMatch(/stops before it ends/); + }); + + it("keeps the engine's message when every bracket closes", () => { + const code = FULL.replace("let y = 0;", "let y = = 0;"); + expect(loadError(code)).not.toMatch(/stops before it ends/); + }); + + it("leaves errors that are not syntax errors alone", () => { + const error = new TypeError("display.fill is not a function"); + expect(describePatternError(error, "function draw(display) {")).toBe("display.fill is not a function"); + }); +}); diff --git a/web/src/lib/pattern/harness.ts b/web/src/lib/pattern/harness.ts index 8fbf77296..ef8136b0f 100644 --- a/web/src/lib/pattern/harness.ts +++ b/web/src/lib/pattern/harness.ts @@ -1,14 +1,23 @@ -export const PATTERN_MATRIX_WIDTH = 128; -export const PATTERN_MATRIX_HEIGHT = 64; -export const PATTERN_KNOB_COUNT = 4; +import { + DETENTS_PER_TURN, + KNOB_COUNT, + PANEL_GEOMETRIES, + PANEL_GEOM_DEFAULT, + TURNS_PER_FULL_RANGE, +} from "@/lib/shared/constants"; +import { wrapKnobDelta } from "@/lib/pattern/controls"; + +export const PATTERN_MATRIX_WIDTH: number = PANEL_GEOMETRIES[PANEL_GEOM_DEFAULT].resW; +export const PATTERN_MATRIX_HEIGHT: number = PANEL_GEOMETRIES[PANEL_GEOM_DEFAULT].resH; +export const PATTERN_KNOB_COUNT = KNOB_COUNT; // Detents in one full turn of the physical encoder. The BOM reference part is // Bourns PEC11R-4220F-S0024 — the S0024 suffix means 24 detents / 24 PPR, which // distributors confirm. This was 20 for a long time, which silently put the web // preview 20 % out of step with the hardware it claims to mirror. // Sourcing a different encoder? Generic EC11s ship as 20, 24 or 30 detents — -// this constant has to match whatever is actually in the build. -// Keep in sync with ENCODER_CLICKS_PER_TURN in patternflowControls.ts. -export const PATTERN_DETENTS_PER_TURN = 24; +// DETENTS_PER_TURN in shared/constants.json has to match whatever is actually +// in the build. controls.ts reads the same value as ENCODER_CLICKS_PER_TURN. +export const PATTERN_DETENTS_PER_TURN = DETENTS_PER_TURN; export type PatternParams = Record<string, unknown>; @@ -43,20 +52,6 @@ export type PatternRenderResult = { error?: string; }; -export type PatternStillOptions = { - width?: number; - height?: number; - seconds?: number; - fps?: number; - knobStart?: number[]; - knobTargets?: number[]; - knobRanges?: Array<[number, number]>; - knobWrap?: boolean[]; - knobUnitsPerTurn?: number[]; - ramp?: ColorRamp | null; - recolor?: boolean; -}; - function clampByte(value: number) { if (!Number.isFinite(value)) return 0; return Math.max(0, Math.min(255, Math.round(value))); @@ -419,15 +414,172 @@ function getFunctionPreambleLines(): number { return functionPreambleLines; } +// ── Code that stops early ─────────────────────────────────────────────────── +// A paste cut off partway is the commonest syntax error, and the engine blames +// a token the author never typed. Cut inside `display.setPixel(x, y, ` and the +// `return` compilePatternCode appends below the code lands inside the call: +// "Unexpected token 'return'". Lose only the last `}` and it is the `)` that +// `new Function` closes the body with: "Unexpected token ')'". Neither is in +// the editor. What is still open where the code ends says it was cut. + +const OPENER_OF: Record<string, string> = { ")": "(", "]": "[", "}": "{" }; + +// After one of these words a `/` starts a regular expression, not a division. +const REGEX_AFTER_WORDS = new Set([ + "return", "typeof", "instanceof", "in", "of", "new", "delete", "void", + "throw", "case", "do", "else", "yield", "await", +]); + +function regexCanStart(prev: string, word: string) { + if (word) return REGEX_AFTER_WORDS.has(word); + return prev === "" || "(,=:[!&|?{};+-*%<>~^".includes(prev); +} + +/** + * What is still open where the code ends, innermost first: `(`, `[`, `{`, a + * template's `${`, and the quote, backtick or `/*` the code stops inside. + * Brackets inside strings, comments and regular expressions do not count. + * Null when the code is broken in the middle instead (a closer that meets the + * wrong opener, a string that runs into a newline): there the engine's own + * message is the better guess. + */ +function openAtEnd(code: string): string[] | null { + // Each entry is "(", "[", "{", "${" or "`"; a "`" on top means the scan is + // in template text. + const stack: string[] = []; + const innermostFirst = (inside?: string) => + inside ? [inside, ...stack.slice().reverse()] : stack.slice().reverse(); + // The last significant character outside strings and comments, and the word + // it ends, to tell a regular expression from a division. + let prev = ""; + let word = ""; + let i = 0; + + while (i < code.length) { + const c = code[i]; + + if (stack[stack.length - 1] === "`") { + if (c === "\\") i += 2; + else if (c === "`") { + stack.pop(); + prev = c; + word = ""; + i++; + } else if (c === "$" && code[i + 1] === "{") { + stack.push("${"); + prev = "{"; + word = ""; + i += 2; + } else i++; + continue; + } + + if (/\s/.test(c)) { + i++; + continue; + } + if (/[\w$]/.test(c)) { + let j = i + 1; + while (j < code.length && /[\w$]/.test(code[j])) j++; + word = code.slice(i, j); + prev = code[j - 1]; + i = j; + continue; + } + + const next = code[i + 1]; + if (c === "/" && next === "/") { + const end = code.indexOf("\n", i); + if (end < 0) break; + i = end; + continue; + } + if (c === "/" && next === "*") { + const end = code.indexOf("*/", i + 2); + if (end < 0) return innermostFirst("/*"); + i = end + 2; + continue; + } + if (c === '"' || c === "'") { + let j = i + 1; + while (j < code.length && code[j] !== c) { + if (code[j] === "\n") return null; + j += code[j] === "\\" ? 2 : 1; + } + if (j >= code.length) return innermostFirst(c); + prev = c; + word = ""; + i = j + 1; + continue; + } + if (c === "/" && regexCanStart(prev, word)) { + let j = i + 1; + let inClass = false; + while (j < code.length && (inClass || code[j] !== "/")) { + if (code[j] === "\n") return null; + if (code[j] === "[") inClass = true; + else if (code[j] === "]") inClass = false; + j += code[j] === "\\" ? 2 : 1; + } + if (j >= code.length) return null; + // The flags that follow scan as a word; with none, a `/` after the + // literal divides. + prev = c; + word = ""; + i = j + 1; + continue; + } + + if (c === "`" || c === "(" || c === "[" || c === "{") stack.push(c); + else if (c === "}" && stack[stack.length - 1] === "${") stack.pop(); + else if (c in OPENER_OF && stack.pop() !== OPENER_OF[c]) return null; + prev = c; + word = ""; + i++; + } + + return innermostFirst(); +} + +/** + * "The code stops before it ends: 1 ( and 3 { never close." with the last line + * the code has, or null when nothing is left open at the end. + */ +function describeCutOff(code: string): string | null { + const open = openAtEnd(code); + if (!open || open.length === 0) return null; + + const counts = new Map<string, number>(); + for (const token of open) counts.set(token, (counts.get(token) ?? 0) + 1); + const parts = [...counts].map(([token, count]) => `${count} ${token}`); + const list = parts.length > 1 ? `${parts.slice(0, -1).join(", ")} and ${parts[parts.length - 1]}` : parts[0]; + const verb = open.length === 1 ? "never closes" : "never close"; + + const lines = code.split("\n"); + let last = lines.length; + while (last > 1 && !lines[last - 1].trim()) last--; + // The end of the line is where the paste stopped, so a long one keeps its tail. + const source = lines[last - 1].trim(); + const quoted = source.length > 80 ? `…${source.slice(-79)}` : source; + return `The code stops before it ends: ${list} ${verb}. Was the paste cut off?\nline ${last} (the last): ${quoted}`; +} + /** * Message plus, when the stack allows it, the pattern's own line number and the - * source of that line. Falls back to the bare message — a syntax error carries - * no usable frame, and a wrong line number would be worse than none. + * source of that line. A syntax error carries no usable frame, so it gets the + * bare message, unless the code ends with brackets still open: then it says the + * code was cut off and quotes the last line. A wrong line number would be worse + * than none. */ export function describePatternError(error: unknown, code: string | null): string { const message = error instanceof Error ? error.message : String(error); if (!code) return message; + if (error instanceof Error && error.name === "SyntaxError") { + const cutOff = describeCutOff(code); + if (cutOff) return cutOff; + } + const location = stackLocation(error); if (!location) return message; @@ -472,8 +624,7 @@ export class PatternRuntime { // is mapped through the ramp. Lets the ramp restyle ordinary RGB patterns. public recolor = false; - private rampLUT: Uint8Array | null = null; // 256×3 RGB (recolor path) - private rampLUTA: Uint8Array | null = null; // 256×4 RGBA (setValue path) + private rampLUTA: Uint8Array | null = null; // 256×4 RGBA (setValue and recolor paths) private module: PatternModule | null = null; private params: PatternParams = {}; private source: string | null = null; @@ -543,7 +694,6 @@ export class PatternRuntime { public setRamp(ramp: ColorRamp | null) { this.ramp = ramp; - this.rampLUT = ramp && ramp.stops.length > 0 ? buildRampLUT(ramp) : null; this.rampLUTA = ramp && ramp.stops.length > 0 ? buildRampLUTRGBA(ramp) : null; } @@ -638,65 +788,8 @@ export function createIdleInput( } export function knobTargetToDelta(previous: number, next: number, wrap = false, unitsPerTurn = 1) { - let delta = next - previous; - if (wrap) { - if (delta > 0.5) delta -= 1; - if (delta < -0.5) delta += 1; - } + // The range is TURNS_PER_FULL_RANGE turns wide, so the seam sits half of + // that away from any reading. + const delta = wrap ? wrapKnobDelta(next - previous, unitsPerTurn * TURNS_PER_FULL_RANGE) : next - previous; return delta * (PATTERN_DETENTS_PER_TURN / unitsPerTurn); } - -export function renderPatternStill( - code: string, - { - width = PATTERN_MATRIX_WIDTH, - height = PATTERN_MATRIX_HEIGHT, - seconds = 2.4, - fps = 30, - knobStart = [0.5, 0.5, 0.5, 0.5], - knobTargets = [0.5, 0.5, 0.5, 0.5], - knobRanges, - knobWrap, - knobUnitsPerTurn, - ramp, - recolor, - }: PatternStillOptions = {}, -) { - const runtime = new PatternRuntime(width, height); - if (ramp) runtime.setRamp(ramp); - runtime.recolor = Boolean(recolor && ramp); - const loadResult = runtime.loadCode(code); - if (!loadResult.ok) { - return { ...loadResult, data: runtime.data }; - } - - const frameCount = Math.max(1, Math.floor(seconds * fps)); - const firstDeltas = knobTargets.map((target, index) => - knobTargetToDelta( - knobStart[index] ?? 0.5, - target, - knobWrap?.[index] ?? false, - knobUnitsPerTurn?.[index] ?? 1, - ), - ); - const knobNormalized = knobTargets.map((target, index) => { - const range = knobRanges?.[index]; - if (!range) return target; - const span = Math.max(0.0001, range[1] - range[0]); - return (target - range[0]) / span; - }); - - for (let frame = 0; frame < frameCount; frame++) { - const input = createIdleInput(frame === 0 ? firstDeltas : undefined, { - knobValues: knobTargets, - knobNormalized, - knobRanges, - }); - const result = runtime.renderFrame(1 / fps, frame / fps, input); - if (!result.ok) { - return { ...result, data: runtime.data }; - } - } - - return { ok: true, data: new Uint8ClampedArray(runtime.data) }; -} diff --git a/web/src/lib/pattern/knobs.test.ts b/web/src/lib/pattern/knobs.test.ts new file mode 100644 index 000000000..eee5dcc98 --- /dev/null +++ b/web/src/lib/pattern/knobs.test.ts @@ -0,0 +1,149 @@ +// The `@knobs` line is how a pattern's knob names and ranges leave the lab. +// Publish, flatten and the lab's own export write it; the lab, the community's +// cards and the sandbox read it back. Until 21 October the printer wrote a +// knob with an empty name as "-=0..1", which reads back as a knob called "-", +// and rounded a fine range such as 0.0001..0.0004 to "0..0", which the parser +// throws away. + +import { describe, expect, it } from "vitest"; +import { + KNOB_LABEL_MAX, + buildKnobsAnnotationLine, + matchKnobsAnnotation, + parseKnobsAnnotation, + stripKnobsAnnotation, +} from "./knobs"; + +const LAB_DEFAULT_RANGES: [number, number][] = [ + [0, 1], + [0.1, 10], + [0, 4.9], + [0, 1], +]; + +/** Write a line and read it back: the trip every published pattern makes. */ +const roundTrip = (labels: string[], ranges: [number, number][]) => + parseKnobsAnnotation(buildKnobsAnnotationLine(labels, ranges)); + +describe("parseKnobsAnnotation", () => { + it("reads names and ranges, and '-' skips a slot", () => { + const code = "// @knobs Folds=3..12, -, Zoom=2..17, Contrast=0.1..1\nfunction draw() {}"; + expect(parseKnobsAnnotation(code)).toEqual([ + { name: "Folds", min: 3, max: 12 }, + null, + { name: "Zoom", min: 2, max: 17 }, + { name: "Contrast", min: 0.1, max: 1 }, + ]); + }); + + it("finds the line below other code and reads negative bounds", () => { + const code = "const seed = 7;\n //@knobs Pitch = -20 .. 20\nfunction draw() {}"; + expect(parseKnobsAnnotation(code)).toEqual([{ name: "Pitch", min: -20, max: 20 }, null, null, null]); + }); + + it("drops an entry whose range is empty, backwards or missing", () => { + expect(parseKnobsAnnotation("// @knobs A=1..1, B=5..2, C=fast, D=0..1")).toEqual([ + null, + null, + null, + { name: "D", min: 0, max: 1 }, + ]); + }); + + it("is null without a line, or when nothing on it parses", () => { + expect(parseKnobsAnnotation("function draw() {}")).toBeNull(); + expect(parseKnobsAnnotation("// @knobs -, -, -, -")).toBeNull(); + expect(parseKnobsAnnotation("// @knobs Speed")).toBeNull(); + expect(parseKnobsAnnotation("const note = '@knobs Speed=0..1';")).toBeNull(); + }); + + it("reads four slots and leaves a fifth alone", () => { + const entries = parseKnobsAnnotation("// @knobs A=0..1, B=0..2, C=0..3, D=0..4, E=0..5"); + expect(entries?.map((entry) => entry?.name)).toEqual(["A", "B", "C", "D"]); + }); + + it("clips a name to what the device's status screen can show", () => { + const entries = parseKnobsAnnotation("// @knobs TemperatureDrift=0..1"); + expect(entries?.[0]?.name).toBe("TemperatureDri"); + expect(entries?.[0]?.name).toHaveLength(KNOB_LABEL_MAX); + }); +}); + +describe("buildKnobsAnnotationLine", () => { + it("writes the line the parser reads back", () => { + const line = buildKnobsAnnotationLine( + ["Folds", "Speed", "Zoom", "Contrast"], + [ + [3, 12], + [0.1, 10], + [2, 17], + [0.1, 1], + ], + ); + expect(line).toBe("// @knobs Folds=3..12, Speed=0.1..10, Zoom=2..17, Contrast=0.1..1"); + expect(parseKnobsAnnotation(line)).toEqual([ + { name: "Folds", min: 3, max: 12 }, + { name: "Speed", min: 0.1, max: 10 }, + { name: "Zoom", min: 2, max: 17 }, + { name: "Contrast", min: 0.1, max: 1 }, + ]); + }); + + it("gives a knob with no name its default label, which reads back as that knob", () => { + const labels = ["", "Speed", " ", ",="]; + expect(buildKnobsAnnotationLine(labels, LAB_DEFAULT_RANGES)).toBe( + "// @knobs Knob 1=0..1, Speed=0.1..10, Knob 3=0..4.9, Knob 4=0..1", + ); + expect(roundTrip(labels, LAB_DEFAULT_RANGES)?.map((entry) => entry?.name)).toEqual([ + "Knob 1", + "Speed", + "Knob 3", + "Knob 4", + ]); + }); + + it("turns the annotation's separators inside a name into spaces", () => { + const names = roundTrip(["Hue, warm", "a=b", "Zoom", "Mix"], LAB_DEFAULT_RANGES)?.map( + (entry) => entry?.name, + ); + expect(names).toEqual(["Hue warm", "a b", "Zoom", "Mix"]); + }); + + it("keeps a fine range whole", () => { + const ranges: [number, number][] = [ + [0.0001, 0.0004], + [1.0004, 1.0009], + [-0.0005, 0.0005], + [0, 1], + ]; + expect(roundTrip(["Drift", "Detune", "Jitter", "Mix"], ranges)).toEqual([ + { name: "Drift", min: 0.0001, max: 0.0004 }, + { name: "Detune", min: 1.0004, max: 1.0009 }, + { name: "Jitter", min: -0.0005, max: 0.0005 }, + { name: "Mix", min: 0, max: 1 }, + ]); + }); + + it("leaves float noise out of the line", () => { + const line = buildKnobsAnnotationLine(["Mix"], [[0.1 + 0.2, 0.7 * 3]]); + expect(line).toBe("// @knobs Mix=0.3..2.1"); + }); + + it("writes 0..1 for a label that has no range", () => { + expect(buildKnobsAnnotationLine(["A", "B"], [[2, 3]])).toBe("// @knobs A=2..3, B=0..1"); + }); +}); + +describe("stripKnobsAnnotation and matchKnobsAnnotation", () => { + it("strip takes off a written line and gives the code back as it was", () => { + const code = "function draw(display) {\n display.clear();\n}"; + const line = buildKnobsAnnotationLine(["Speed"], [[0, 4]]); + expect(stripKnobsAnnotation(`${line}\n${code}`)).toBe(code); + expect(stripKnobsAnnotation(code)).toBe(code); + }); + + it("match returns the line as it is written, indent included", () => { + expect(matchKnobsAnnotation(" // @knobs A=0..1\nfunction draw() {}")).toBe(" // @knobs A=0..1"); + expect(matchKnobsAnnotation("function draw() {}")).toBeNull(); + }); +}); diff --git a/web/src/lib/pattern/knobs.ts b/web/src/lib/pattern/knobs.ts index 871a1d71b..c99b6c8eb 100644 --- a/web/src/lib/pattern/knobs.ts +++ b/web/src/lib/pattern/knobs.ts @@ -58,11 +58,17 @@ export function buildKnobsAnnotationLine( labels: readonly string[], ranges: readonly (readonly [number, number])[], ): string { - const trim = (value: number) => `${Math.round(value * 1000) / 1000}`; + // Twelve significant digits drop float noise (0.1 + 0.2 prints as 0.3) and + // keep a fine range whole. Rounding to three decimals wrote 0.0001..0.0004 + // as 0..0, which the parser throws away. + const trim = (value: number) => `${Number(value.toPrecision(12))}`; const entries = labels.map((label, index) => { const range = ranges[index] ?? [0, 1]; - // Commas and equals signs are the annotation's own separators. - const name = label.replace(/[,=]/g, " ").trim() || "-"; + // Commas and equals signs are the annotation's own separators. An empty + // name takes the slot's default: "-" with a range after it reads back as + // a knob called "-". + const name = + label.replace(/[,=]/g, " ").trim() || (DEFAULT_KNOB_LABELS[index] ?? `Knob ${index + 1}`); return `${name}=${trim(range[0])}..${trim(range[1])}`; }); return `// @knobs ${entries.join(", ")}`; diff --git a/web/src/lib/pattern/matrix.test.ts b/web/src/lib/pattern/matrix.test.ts new file mode 100644 index 000000000..4ad0ac85e --- /dev/null +++ b/web/src/lib/pattern/matrix.test.ts @@ -0,0 +1,119 @@ +// `@matrix` carries the frame a pattern draws into. The lab, the community +// pages and the sandbox all read it through this module, and 33 files import +// it; a pattern without the line is the stock 128×64 panel. + +import { describe, expect, it } from "vitest"; +import { + DEFAULT_MATRIX, + MATRIX_MAX, + MATRIX_MIN, + buildMatrixAnnotationLine, + clampMatrixDimension, + describeMatrixShape, + formatMatrix, + isStockPanelFrame, + isValidMatrix, + matchMatrixAnnotation, + matrixFit, + matrixFromCode, + parseMatrixAnnotation, + stripMatrixAnnotation, + withMatrixAnnotation, +} from "./matrix"; + +const DRAW = "function draw(display) {\n display.clear();\n}"; + +describe("parseMatrixAnnotation", () => { + it("reads width and height", () => { + expect(parseMatrixAnnotation(`// @matrix 64x128\n${DRAW}`)).toEqual({ width: 64, height: 128 }); + }); + + it("takes ×, * and spaces between the numbers, and finds the line below other code", () => { + expect(parseMatrixAnnotation("// @matrix 64 × 32")).toEqual({ width: 64, height: 32 }); + expect(parseMatrixAnnotation("const a = 1;\n //@matrix 32*32\n")).toEqual({ width: 32, height: 32 }); + }); + + it("refuses a side outside 8..512 instead of clamping it", () => { + expect(parseMatrixAnnotation(`// @matrix ${MATRIX_MIN - 1}x64`)).toBeNull(); + expect(parseMatrixAnnotation(`// @matrix 128x${MATRIX_MAX + 1}`)).toBeNull(); + expect(parseMatrixAnnotation(`// @matrix ${MATRIX_MIN}x${MATRIX_MAX}`)).toEqual({ + width: MATRIX_MIN, + height: MATRIX_MAX, + }); + }); + + it("refuses a line with anything after the size", () => { + expect(parseMatrixAnnotation("// @matrix 128x64 rotated")).toBeNull(); + expect(parseMatrixAnnotation("// @matrix 128x64x2")).toBeNull(); + expect(parseMatrixAnnotation("// @matrix 128 by 64")).toBeNull(); + }); +}); + +describe("matrixFromCode", () => { + it("falls back to the stock panel when the code says nothing", () => { + expect(DEFAULT_MATRIX).toEqual({ width: 128, height: 64 }); + expect(matrixFromCode(DRAW)).toEqual(DEFAULT_MATRIX); + expect(matrixFromCode(`// @matrix 4x4\n${DRAW}`)).toEqual(DEFAULT_MATRIX); + }); +}); + +describe("writing the line", () => { + it("builds the line the parser reads back", () => { + const line = buildMatrixAnnotationLine({ width: 64, height: 128 }); + expect(line).toBe("// @matrix 64x128"); + expect(parseMatrixAnnotation(line)).toEqual({ width: 64, height: 128 }); + expect(matchMatrixAnnotation(`${line}\n${DRAW}`)).toBe(line); + }); + + it("replaces an old line with one at the top, and strip gives the code back", () => { + const old = `// @matrix 64x64\n${DRAW}`; + const code = withMatrixAnnotation(old, { width: 128, height: 64 }); + expect(code).toBe(`// @matrix 128x64\n${DRAW}`); + expect(withMatrixAnnotation(code, { width: 128, height: 64 })).toBe(code); + expect(stripMatrixAnnotation(code)).toBe(DRAW); + expect(stripMatrixAnnotation(DRAW)).toBe(DRAW); + }); +}); + +describe("sizes typed by hand", () => { + it("clampMatrixDimension pulls a number into range and rounds it", () => { + expect(clampMatrixDimension(4)).toBe(MATRIX_MIN); + expect(clampMatrixDimension(600)).toBe(MATRIX_MAX); + expect(clampMatrixDimension(63.6)).toBe(64); + expect(clampMatrixDimension(Number.NaN)).toBeNull(); + }); + + it("isValidMatrix wants both sides inside the range", () => { + expect(isValidMatrix({ width: 128, height: 64 })).toBe(true); + expect(isValidMatrix({ width: 4, height: 64 })).toBe(false); + expect(isValidMatrix({ width: 128 })).toBe(false); + expect(isValidMatrix(null)).toBe(false); + }); +}); + +describe("describing a frame", () => { + it("counts the stock panel either way up as the stock panel", () => { + expect(isStockPanelFrame({ width: 128, height: 64 })).toBe(true); + expect(isStockPanelFrame({ width: 64, height: 128 })).toBe(true); + expect(isStockPanelFrame({ width: 64, height: 64 })).toBe(false); + expect(isStockPanelFrame({ width: 256, height: 64 })).toBe(false); + }); + + it("names the shape and prints the size with ×", () => { + expect(describeMatrixShape({ width: 128, height: 64 })).toBe("landscape"); + expect(describeMatrixShape({ width: 64, height: 128 })).toBe("portrait"); + expect(describeMatrixShape({ width: 64, height: 64 })).toBe("square"); + expect(formatMatrix({ width: 128, height: 64 })).toBe("128×64"); + }); +}); + +describe("matrixFit", () => { + const panel = { width: 128, height: 64 }; + + it("draws a matching frame straight through, turns a swapped one, and letterboxes the rest", () => { + expect(matrixFit({ width: 128, height: 64 }, panel)).toBe("direct"); + expect(matrixFit({ width: 64, height: 128 }, panel)).toBe("rotate"); + expect(matrixFit({ width: 64, height: 64 }, panel)).toBe("letterbox"); + expect(matrixFit({ width: 32, height: 32 }, { width: 32, height: 32 })).toBe("direct"); + }); +}); diff --git a/web/src/lib/pattern/matrix.ts b/web/src/lib/pattern/matrix.ts index 10b328c4f..4ecc7ce43 100644 --- a/web/src/lib/pattern/matrix.ts +++ b/web/src/lib/pattern/matrix.ts @@ -24,10 +24,15 @@ // public/pattern-sandbox.html is built from this module // (scripts/build-sandbox.ts), so there is no second parser to keep in step. +import { PANEL_GEOMETRIES, PANEL_GEOM_DEFAULT } from "@/lib/shared/constants"; + export type MatrixSize = { width: number; height: number }; /** The stock Patternflow panel. Used whenever a pattern carries no @matrix. */ -export const DEFAULT_MATRIX: MatrixSize = { width: 128, height: 64 }; +export const DEFAULT_MATRIX: MatrixSize = { + width: PANEL_GEOMETRIES[PANEL_GEOM_DEFAULT].resW, + height: PANEL_GEOMETRIES[PANEL_GEOM_DEFAULT].resH, +}; /** * The stock panel in either orientation — the board is mounted whichever way @@ -69,7 +74,7 @@ export function clampMatrixDimension(value: number): number | null { * Pixels per frame beyond which the JS preview starts to struggle — four times * the stock panel. Not a limit, just the point worth warning about. */ -export const MATRIX_HEAVY_PIXELS = 128 * 64 * 4; +export const MATRIX_HEAVY_PIXELS = DEFAULT_MATRIX.width * DEFAULT_MATRIX.height * 4; export function isValidMatrix(size: Partial<MatrixSize> | null | undefined): boolean { if (!size) return false; diff --git a/web/src/lib/pattern/pfst.ts b/web/src/lib/pattern/pfst.ts index de07a9e67..baf47d09d 100644 --- a/web/src/lib/pattern/pfst.ts +++ b/web/src/lib/pattern/pfst.ts @@ -184,21 +184,6 @@ export function durationOf(perf: Performance): number { return Math.max(perf.length, maxCue); } -/** Distinct display names the timeline actually selects (for availability checks). */ -export function timelinePatternNames(perf: Performance): string[] { - const seen = new Set<string>(); - const out: string[] = []; - for (const cue of perf.timeline) { - const name = (cue.pattern ?? "").trim(); - if (!name) continue; - const key = name.toLowerCase(); - if (seen.has(key)) continue; - seen.add(key); - out.push(name); - } - return out; -} - /** JSON-facing form: dense param arrays stay arrays, sparse become 1-based objects. */ export function serializePerformance(perf: Performance): Record<string, unknown> { return { diff --git a/web/src/lib/pattern/share.ts b/web/src/lib/pattern/share.ts index 20483fe39..f57d3fe2c 100644 --- a/web/src/lib/pattern/share.ts +++ b/web/src/lib/pattern/share.ts @@ -1,6 +1,8 @@ // Licence + attribution machinery for shared patterns (community publishing -// and downloads). The editor stays clean while editing; the licence header + -// attribution footer are injected only at export time. +// and downloads). The Live Editor and the Lab never add the block while you +// write. The community bakes the licence header + attribution footer into the +// stored code on publish and on edit (lib/community/license.ts), and a +// download wraps the code it hands out (lib/community/download.ts). export type LicenseOption = { id: string; label: string; spdx: string }; @@ -164,20 +166,22 @@ export function buildAttributionFooter(meta: ShareMeta): string { // said "render error", the page showed a SyntaxError, and nothing said why. const TRAILING_COMMENTS = String.raw`(?:\n(?:[ \t]*\/\/[^\n]*|[ \t]*))*\s*$`; const COMMUNITY_FOOTER_RE = new RegExp(String.raw`\/\/ ── Made with Patternflow[^\n]*` + TRAILING_COMMENTS); -const GEMINI_FOOTER_RE = new RegExp( +const LAB_AI_FOOTER_RE = new RegExp( String.raw`\/\/ ---[ \t]*\n\/\/ (?:Generated at|Made with) [^\n]*patternflow\.work[^\n]*` + TRAILING_COMMENTS, ); // Remove a header/footer we previously injected, so re-sharing an already // exported file does not stack duplicate blocks. Also strips the header/footer -// the in-app Gemini generation stamps onto patterns (see gemini.ts), so shared -// files carry ONE licence block — the user-titled one from this flow. +// the Lab's AI gallery stamped onto every pattern it generated until it was +// taken out on 23 October 2026. Those patterns still sit in saved layers and +// published code, and a shared file should carry ONE licence block, the +// user-titled one from this flow. export function stripShareWrapping(code: string): string { return code .replace(/\/\/ ===== Patternflow pattern =====[\s\S]*?\/\/ =+\n?/, "") .replace(COMMUNITY_FOOTER_RE, "") .replace(/^\s*\/\/ Pattern:[\s\S]*?\/\/ Made with Patternflow Pattern Lab[^\n]*\n?/, "") - .replace(GEMINI_FOOTER_RE, "") + .replace(LAB_AI_FOOTER_RE, "") .trim(); } diff --git a/web/src/lib/posthogEvents.test.ts b/web/src/lib/posthogEvents.test.ts new file mode 100644 index 000000000..ec22075eb --- /dev/null +++ b/web/src/lib/posthogEvents.test.ts @@ -0,0 +1,119 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import type { PostHog } from 'posthog-js'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Events captured before posthog-js has arrived wait in the module, so each +// test takes a fresh copy of it. +async function events() { + vi.resetModules(); + return import('./posthogEvents'); +} + +function fakeClient() { + const capture = vi.fn(); + return { capture, client: { capture } as unknown as PostHog }; +} + +beforeEach(() => { + vi.stubEnv('NEXT_PUBLIC_POSTHOG_KEY', 'phc_test'); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.useRealTimers(); + window.sessionStorage.clear(); + window.history.replaceState(null, '', '/'); +}); + +describe('events before posthog-js has loaded', () => { + it('are sent once the client arrives, in order, with the time and page they happened on', async () => { + const { attachPostHog, captureEvent, capturePageview } = await events(); + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date('2026-10-21T09:00:00Z')); + window.history.replaceState(null, '', '/contact?utm_source=x'); + capturePageview(); + captureEvent('sponsor_click', { tier: 'gold' }); + + vi.setSystemTime(new Date('2026-10-21T09:00:04Z')); + window.history.replaceState(null, '', '/build'); + const { capture, client } = fakeClient(); + attachPostHog(client); + + const at = { timestamp: new Date('2026-10-21T09:00:00Z') }; + expect(capture.mock.calls).toEqual([ + ['$pageview', { $current_url: 'http://localhost:3000/contact?utm_source=x', utm_source: 'x' }, at], + [ + 'sponsor_click', + expect.objectContaining({ path: '/contact', utm_source: 'x', tier: 'gold' }), + at, + ], + ]); + }); + + it('go straight to the client after it has arrived', async () => { + const { attachPostHog, captureEvent } = await events(); + const { capture, client } = fakeClient(); + attachPostHog(client); + captureEvent('deck_open'); + expect(capture).toHaveBeenCalledTimes(1); + expect(capture.mock.calls[0]).toHaveLength(2); + expect(capture.mock.calls[0][0]).toBe('deck_open'); + }); + + it('keep the first 100 when the page never finishes loading', async () => { + const { attachPostHog, captureEvent } = await events(); + for (let i = 0; i < 150; i++) captureEvent(`e${i}`); + const { capture, client } = fakeClient(); + attachPostHog(client); + expect(capture).toHaveBeenCalledTimes(100); + expect(capture.mock.calls[0][0]).toBe('e0'); + expect(capture.mock.calls[99][0]).toBe('e99'); + }); + + it('are sent once, to the first client', async () => { + const { attachPostHog, captureEvent } = await events(); + captureEvent('a'); + const first = fakeClient(); + const second = fakeClient(); + attachPostHog(first.client); + attachPostHog(second.client); + captureEvent('b'); + expect(first.capture.mock.calls.map((call) => call[0])).toEqual(['a', 'b']); + expect(second.capture).not.toHaveBeenCalled(); + }); + + it('are not kept on a build without a key', async () => { + vi.stubEnv('NEXT_PUBLIC_POSTHOG_KEY', ''); + const { attachPostHog, captureEvent, capturePageview } = await events(); + capturePageview(); + captureEvent('a'); + const { capture, client } = fakeClient(); + attachPostHog(client); + expect(capture).not.toHaveBeenCalled(); + }); +}); + +// A static import of posthog-js anywhere on the client puts its 318 KB back +// into the first scripts of every page. Types and import() are fine. +it('no source file imports posthog-js except as a type or with import()', () => { + const src = path.resolve(__dirname, '..'); + const offending: string[] = []; + const walk = (dir: string) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const file = path.join(dir, entry.name); + if (entry.isDirectory()) walk(file); + else if (/\.(ts|tsx)$/.test(entry.name) && !/\.test\.tsx?$/.test(entry.name)) { + readFileSync(file, 'utf8') + .split('\n') + .forEach((line, i) => { + if (!/['"]posthog-js(\/[^'"]*)?['"]/.test(line)) return; + if (/^\s*import type\b/.test(line) || /\bimport\(\s*['"]posthog-js/.test(line)) return; + offending.push(`${path.relative(src, file)}:${i + 1}: ${line.trim()}`); + }); + } + } + }; + walk(src); + expect(offending).toEqual([]); +}); diff --git a/web/src/lib/posthogEvents.ts b/web/src/lib/posthogEvents.ts index 5c88958f7..af94f5d4d 100644 --- a/web/src/lib/posthogEvents.ts +++ b/web/src/lib/posthogEvents.ts @@ -1,4 +1,4 @@ -import posthog from 'posthog-js'; +import type { PostHog } from 'posthog-js'; import { CAMPAIGN_ROUTES } from './campaignRoutes'; type EventProperties = Record<string, string | number | boolean | null | undefined>; @@ -13,6 +13,32 @@ const UTM_KEYS = [ const CAMPAIGN_STORAGE_KEY = 'patternflow_campaign'; +// posthog-js arrives after the page has loaded (providers/PostHogProvider.tsx), +// so it stays out of every page's first scripts: on /contact it was the largest +// of them, 318 KB against react-dom's 207 KB. An event captured before then +// waits here with its own time and URL, and goes when the client is handed in. +// The cap is a page that never finishes loading; the first 100 are kept. +const PENDING_MAX = 100; +let client: PostHog | null = null; +const pending: { event: string; properties: EventProperties; timestamp: Date }[] = []; + +function send(event: string, properties: EventProperties) { + if (client) { + client.capture(event, properties); + } else if (pending.length < PENDING_MAX) { + pending.push({ event, properties, timestamp: new Date() }); + } +} + +/** The initialised client, once: what waited is sent, in order, with the time it happened. */ +export function attachPostHog(posthog: PostHog) { + if (client) return; + client = posthog; + for (const { event, properties, timestamp } of pending.splice(0)) { + posthog.capture(event, properties, { timestamp }); + } +} + function getPropertiesFromSearch(params: URLSearchParams) { const properties: Record<string, string> = {}; @@ -62,10 +88,19 @@ export function getCampaignProperties() { return readStoredCampaign(); } +export function capturePageview() { + if (typeof window === 'undefined' || !process.env.NEXT_PUBLIC_POSTHOG_KEY) return; + + send('$pageview', { + $current_url: window.location.href, + ...getCampaignProperties(), + }); +} + export function captureEvent(eventName: string, properties: EventProperties = {}) { if (typeof window === 'undefined' || !process.env.NEXT_PUBLIC_POSTHOG_KEY) return; - posthog.capture(eventName, { + send(eventName, { ...getCampaignProperties(), path: window.location.pathname, url: window.location.href, diff --git a/web/src/lib/presets/README.md b/web/src/lib/presets/README.md index 0f86b0a38..1cb94c457 100644 --- a/web/src/lib/presets/README.md +++ b/web/src/lib/presets/README.md @@ -9,6 +9,6 @@ The Live Editor's preset library: one JavaScript pattern per file, registered in - `types.ts` — `LivePreset`. `kind: 'custom'` marks a pattern that is not one of the numbered presets. - Files are named by the date the pattern was made (`pattern-0510.ts`), which is also the sort key and the firmware slug, so they are not renamed. -Adding one (maintainer): copy `_TEMPLATE.ts`, register it in `index.ts`, generate the `.h` twin (Pattern Lab → *Copy C++ prompt*, or `port_preset.py` from the header side), run `python firmware/toolchain/check_presets.py`, then `python firmware/toolchain/make_pack.py` to rebuild the Basics pack. The full pattern-system map is [`web/ARCHITECTURE.md`](../../../ARCHITECTURE.md#pattern-system). +Adding one (maintainer): copy `_TEMPLATE.ts`, register it in `index.ts`, generate the `.h` twin (Pattern Lab → *Copy C++ prompt*), run `python firmware/toolchain/check_presets.py`, then rebuild the Basics pack: `port_preset.py --all` and `build_module.py --all` turn the headers into modules, and `make_pack.py` bundles them (all three in `firmware/toolchain/`; see [`firmware/modules/README.md`](../../../../firmware/modules/README.md)). The full pattern-system map is [`web/ARCHITECTURE.md`](../../../ARCHITECTURE.md#pattern-system). License: CC BY-SA 4.0 per file — artwork, not code. diff --git a/web/src/lib/shared/constants.ts b/web/src/lib/shared/constants.ts new file mode 100644 index 000000000..e5211834b --- /dev/null +++ b/web/src/lib/shared/constants.ts @@ -0,0 +1,29 @@ +// GENERATED by web/scripts/gen-shared.ts from shared/constants.json. Do not edit. +// Change the JSON and run `npm run gen:shared` in web/. check:shared fails +// while this file says anything else. + +// The geometries PANEL_GEOMETRY picks from in config.h. Two 64x64 modules in a +// chain make the same 128x64 canvas as one 128x64 module. +export const PANEL_GEOMETRIES = { + "128x64_SINGLE": { id: 1, physW: 128, physH: 64, chain: 1, resW: 128, resH: 64 }, + "64x64_SINGLE": { id: 2, physW: 64, physH: 64, chain: 1, resW: 64, resH: 64 }, + "64x64_CHAIN2": { id: 3, physW: 64, physH: 64, chain: 2, resW: 128, resH: 64 }, +} as const; +export const PANEL_GEOM_DEFAULT = "128x64_SINGLE"; + +// The PEC11R-4220F-S0024 has 24 detents a turn. Two turns cross a parameter's +// whole range, so 48 detents do. +export const KNOB_COUNT = 4; +export const DETENTS_PER_TURN = 24; +export const TURNS_PER_FULL_RANGE = 2; + +// Quadrature edges in one detent. A press held past LONG_PRESS_MS is a long +// press, and button edges closer than BUTTON_DEBOUNCE_MS are ignored. +export const EDGES_PER_DETENT = 4; +export const LONG_PRESS_MS = 1000; +export const BUTTON_DEBOUNCE_MS = 50; + +// The absolute bus speaks integers 0 to BUS_MAX. A pattern that reads only knob +// deltas gets one click for every BUS_UNITS_PER_CLICK a held channel moves. +export const BUS_MAX = 1000; +export const BUS_UNITS_PER_CLICK = 10; diff --git a/web/src/lib/siteMetadata.ts b/web/src/lib/siteMetadata.ts new file mode 100644 index 000000000..5e301a078 --- /dev/null +++ b/web/src/lib/siteMetadata.ts @@ -0,0 +1,27 @@ +import type { Metadata } from "next"; + +// What a link to any page carries when it is pasted into a chat or a post. +// +// Next merges metadata one key deep: a page that sets openGraph replaces the +// root layout's openGraph whole, picture and all ("Overwriting fields" in +// node_modules/next/dist/docs/01-app/03-api-reference/04-functions/ +// generate-metadata.md). So a page that names its own preview spreads this in +// first. A page that sets no openGraph inherits this as it is, and Next fills +// og:title and og:description from the page's own title and description. +// +// The root layout's openGraph is this and nothing more. Until 16 October +// 2026 it also carried the home page's title and address, and /features, +// /editions, /roadmap and every other page that sets no openGraph previewed +// as the home page. +export const SITE_OPEN_GRAPH = { + type: "website", + siteName: "Patternflow", + images: [ + { + url: "/og-image.png", + width: 1200, + height: 630, + type: "image/png", + }, + ], +} satisfies NonNullable<Metadata["openGraph"]>; diff --git a/web/src/lib/webgl.test.ts b/web/src/lib/webgl.test.ts new file mode 100644 index 000000000..9e9282fa3 --- /dev/null +++ b/web/src/lib/webgl.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// hasWebGL keeps its first answer for the visit, so each case loads the +// module afresh and sets up what the browser says before it asks. +async function load() { + vi.resetModules(); + return (await import("./webgl")).hasWebGL; +} + +let asked: string[]; +beforeEach(() => { + asked = []; +}); + +function browserGives(context: unknown) { + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(function (this: HTMLCanvasElement, kind: string) { + asked.push(`${kind} ${this.width}×${this.height}`); + if (context instanceof Error) throw context; + return context as RenderingContext | null; + } as typeof HTMLCanvasElement.prototype.getContext); +} + +describe("hasWebGL", () => { + it("asks a 1×1 canvas for WebGL 2, the least three.js draws with", async () => { + browserGives({}); + const hasWebGL = await load(); + expect(hasWebGL()).toBe(true); + expect(asked).toEqual(["webgl2 1×1"]); + }); + + it("is false where the browser hands back no context", async () => { + browserGives(null); + const hasWebGL = await load(); + expect(hasWebGL()).toBe(false); + }); + + it("is false where asking throws", async () => { + browserGives(new Error("blocked")); + const hasWebGL = await load(); + expect(hasWebGL()).toBe(false); + }); + + it("asks once a visit: the guide and the home page share the answer", async () => { + browserGives(null); + const hasWebGL = await load(); + hasWebGL(); + hasWebGL(); + hasWebGL(); + expect(asked).toHaveLength(1); + }); +}); diff --git a/web/src/lib/webgl.ts b/web/src/lib/webgl.ts new file mode 100644 index 000000000..11473416b --- /dev/null +++ b/web/src/lib/webgl.ts @@ -0,0 +1,22 @@ +// Whether this browser will give a page a WebGL 2 context. three.js draws +// with nothing less (WebGL 1 went in r163), so a canvas made where this is +// false throws as it makes its renderer. It is false where WebGL is turned +// off or the GPU is blocked. +// +// Asked once and kept. The guide's stage (components/guide/world/GuideWorld) +// and the home page's 3D view (components/3d/ViewerPanel) ask before they +// make a canvas. Only in the browser: the server has no document to ask. +let answer: boolean | null = null; + +export function hasWebGL(): boolean { + if (answer !== null) return answer; + try { + // (A 1×1 canvas nobody holds: its context goes with it.) + const probe = document.createElement("canvas"); + probe.width = probe.height = 1; + answer = !!probe.getContext("webgl2"); + } catch { + answer = false; + } + return answer; +} diff --git a/web/src/providers/PostHogProvider.test.tsx b/web/src/providers/PostHogProvider.test.tsx new file mode 100644 index 000000000..66d6ff214 --- /dev/null +++ b/web/src/providers/PostHogProvider.test.tsx @@ -0,0 +1,78 @@ +import { render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// The root layout wraps every page in this provider. posthog-js waits for the +// page's load event; the pageview captured on mount waits for posthog-js. + +const posthog = vi.hoisted(() => ({ init: vi.fn(), capture: vi.fn() })); +vi.mock('posthog-js', () => ({ default: posthog })); +vi.mock('next/navigation', () => ({ + usePathname: () => '/contact', + useSearchParams: () => new URLSearchParams(), +})); + +// postHogEnabled is read when the module is evaluated, as the build inlines it. +async function provider() { + vi.resetModules(); + return (await import('./PostHogProvider')).PostHogProvider; +} + +function stillLoading() { + Object.defineProperty(document, 'readyState', { configurable: true, get: () => 'loading' }); +} + +beforeEach(() => { + posthog.init.mockClear(); + posthog.capture.mockClear(); + vi.stubEnv('NEXT_PUBLIC_POSTHOG_KEY', 'phc_test'); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + Reflect.deleteProperty(document, 'readyState'); +}); + +describe('PostHogProvider', () => { + it('starts posthog-js only after the load event, then sends the pageview it held', async () => { + const PostHogProvider = await provider(); + stillLoading(); + render( + <PostHogProvider> + <p>page</p> + </PostHogProvider>, + ); + expect(screen.getByText('page')).toBeInTheDocument(); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(posthog.init).not.toHaveBeenCalled(); + expect(posthog.capture).not.toHaveBeenCalled(); + + Reflect.deleteProperty(document, 'readyState'); + window.dispatchEvent(new Event('load')); + await vi.waitFor(() => expect(posthog.init).toHaveBeenCalledTimes(1)); + expect(posthog.init.mock.calls[0][0]).toBe('phc_test'); + expect(posthog.capture).toHaveBeenCalledWith( + '$pageview', + { $current_url: 'http://localhost:3000/' }, + { timestamp: expect.any(Date) }, + ); + }); + + it('starts it once however often the provider mounts', async () => { + const PostHogProvider = await provider(); + const first = render(<PostHogProvider>a</PostHogProvider>); + await vi.waitFor(() => expect(posthog.init).toHaveBeenCalled()); + first.unmount(); + render(<PostHogProvider>b</PostHogProvider>); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(posthog.init).toHaveBeenCalledTimes(1); + }); + + it('never fetches posthog-js on a build without a key', async () => { + vi.stubEnv('NEXT_PUBLIC_POSTHOG_KEY', ''); + const PostHogProvider = await provider(); + render(<PostHogProvider>page</PostHogProvider>); + window.dispatchEvent(new Event('load')); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(posthog.init).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/providers/PostHogProvider.tsx b/web/src/providers/PostHogProvider.tsx index 1c4bcec48..6f84fbe34 100644 --- a/web/src/providers/PostHogProvider.tsx +++ b/web/src/providers/PostHogProvider.tsx @@ -1,35 +1,51 @@ 'use client' -import { getCampaignProperties } from '@/lib/posthogEvents' +import { attachPostHog, capturePageview } from '@/lib/posthogEvents' import { usePathname, useSearchParams } from 'next/navigation' -import posthog from 'posthog-js' -import { PostHogProvider as PHProvider } from 'posthog-js/react' import { Suspense, useEffect, useRef } from 'react' -// Initialize once at module scope (client only). Runs before hydration -// effects, so the pageview effect below can rely on it without extra state. const postHogEnabled = typeof window !== 'undefined' && !!process.env.NEXT_PUBLIC_POSTHOG_KEY -if (postHogEnabled) { - posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY as string, { - api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://us.i.posthog.com', - ui_host: 'https://us.posthog.com', - defaults: '2026-01-30', - person_profiles: 'identified_only', - capture_pageview: false, - session_recording: { - // Pattern Lab sends the user's BYOK Gemini key to this host. Strip the - // request from session replay so a key can never leak via a URL query - // string or captured payload. - maskNetworkRequestFn: (request) => { - if (request.url?.includes('generativelanguage.googleapis.com')) { - return { ...request, url: 'https://generativelanguage.googleapis.com/(redacted)' } - } - return request - }, - }, - }) +// posthog-js is fetched once the page's own load event has fired and the main +// thread is idle, so it never stands in front of the page it measures. Until +// then lib/posthogEvents.ts holds what is captured, pageviews included. +let requested = false + +function loadPostHog() { + if (requested) return + requested = true + void import('posthog-js') + .then(({ default: posthog }) => { + posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY as string, { + api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://us.i.posthog.com', + ui_host: 'https://us.posthog.com', + defaults: '2026-01-30', + person_profiles: 'identified_only', + capture_pageview: false, + }) + attachPostHog(posthog) + }) + // A chunk that does not arrive, or an init that throws, costs the + // analytics and never the page. + .catch(() => {}) +} + +function afterPageLoad(start: () => void) { + let idle = 0 + const schedule = () => { + idle = + typeof window.requestIdleCallback === 'function' + ? window.requestIdleCallback(start, { timeout: 2000 }) + : window.setTimeout(start, 0) + } + if (document.readyState === 'complete') schedule() + else window.addEventListener('load', schedule, { once: true }) + return () => { + window.removeEventListener('load', schedule) + if (typeof window.cancelIdleCallback === 'function') window.cancelIdleCallback(idle) + else window.clearTimeout(idle) + } } function PostHogPageView({ enabled }: { enabled: boolean }) { @@ -50,22 +66,24 @@ function PostHogPageView({ enabled }: { enabled: boolean }) { if (previousUrl.current === currentPath) return previousUrl.current = currentPath - posthog.capture('$pageview', { - $current_url: window.location.href, - ...getCampaignProperties(), - }) + capturePageview() }, [enabled, pathname, search]) return null } export function PostHogProvider({ children }: { children: React.ReactNode }) { + useEffect(() => { + if (!postHogEnabled) return + return afterPageLoad(loadPostHog) + }, []) + return ( - <PHProvider client={posthog}> + <> <Suspense fallback={null}> <PostHogPageView enabled={postHogEnabled} /> </Suspense> {children} - </PHProvider> + </> ) } diff --git a/web/src/proxy.ts b/web/src/proxy.ts index 7387f8e1c..df9de46fb 100644 --- a/web/src/proxy.ts +++ b/web/src/proxy.ts @@ -32,6 +32,11 @@ function normalizeJournalPath(pathname: string) { return pathname.length > 1 ? pathname.replace(/\/+$/, "") : pathname; } +// No journal page ends in /en/en, but the first ?lang=en redirect in +// next.config.ts makes one: it reads /journal/en?lang=en as a post called "en" +// and answers /journal/en/en. The language switch on the Korean index has +// linked /journal/en?lang=en since 17 May 2026 (668eaa2c), and this collapse +// came the next day (83050af7). function collapseDuplicateEnglishSuffix(pathname: string) { const normalizedPathname = normalizeJournalPath(pathname); diff --git a/web/src/store/useAppStore.ts b/web/src/store/useAppStore.ts index f6337960b..6005e262b 100644 --- a/web/src/store/useAppStore.ts +++ b/web/src/store/useAppStore.ts @@ -36,6 +36,10 @@ interface AppState { setActivePatternId: (id: string) => void; customJsCode: string; setCustomJsCode: (code: string) => void; + // Why the Live Editor's code is not running, as the preview's + // LedMatrixTexture reports it; null while it runs. + customJsError: string | null; + setCustomJsError: (message: string | null) => void; buildStep: number; setBuildStep: (step: number) => void; // How far the device is pulled apart on build step 3, 0 (assembled) to 1 @@ -118,6 +122,8 @@ export function draw(display, params, globalTime) { } }`, setCustomJsCode: (code) => set({ customJsCode: code }), + customJsError: null, + setCustomJsError: (message) => set({ customJsError: message }), buildStep: 0, setBuildStep: (step) => set({ buildStep: step }), explode: 1, diff --git a/web/tsconfig.json b/web/tsconfig.json index cf9c65d3e..383867e21 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -5,6 +5,10 @@ "allowJs": true, "skipLibCheck": true, "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, "noEmit": true, "esModuleInterop": true, "module": "esnext", diff --git a/web/vitest.config.ts b/web/vitest.config.ts index 2f042c96b..88aceb3ae 100644 --- a/web/vitest.config.ts +++ b/web/vitest.config.ts @@ -1,14 +1,16 @@ -// Component tests for the Pattern Lab panels — the one place React behaviour -// (pointer gestures, keyboard shortcuts, undo, selection, the Director's -// keyframes) is exercised under a DOM. The rest of the test surface is the -// bespoke `check:*` smoke scripts; this is the harness they could not be. +// Every src/**/*.test.ts and *.test.tsx, each beside the file it tests. The +// Pattern Lab panels came first, which is where `check:panels` gets its name: +// React behaviour (pointer gestures, keyboard shortcuts, undo, selection, the +// Director's keyframes) exercised under a DOM. The guide, the landing +// sections, the community and lib/ have tests here now too. The rest of the +// test surface is the bespoke `check:*` smoke scripts. // // npm run check:panels (part of check:ci) // // jsdom has no canvas; test/setup.ts stubs a 2D context that accepts the // calls the panels make and asserts nothing about pixels on screen — the -// tests read the layer's RGBA buffer in the store instead, which is what the -// device receives. +// Lab tests read the layer's RGBA buffer in the store instead, which is what +// the device receives. import path from "node:path"; import { defineConfig } from "vitest/config";