From ffde537c7495d8975b9a8da25ddbb6cc6c8e59c5 Mon Sep 17 00:00:00 2001
From: Patternflow Bot
Date: Wed, 14 Oct 2026 21:36:23 +0900
Subject: [PATCH 01/82] docs: the cleanup has a baseline
From Patternflow-lab/Patternflow#11
---
AGENTS.md | 1 +
.../2026-10-cleanup-baseline.md | 83 ++
docs/investigations/2026-10-doc-map.csv | 1008 +++++++++++++++++
docs/rfc-cleanup-progress.md | 38 +
docs/rfc-cleanup.md | 368 ++++++
web/package.json | 3 +-
web/scripts/doc-map.ts | 191 ++++
7 files changed, 1691 insertions(+), 1 deletion(-)
create mode 100644 docs/investigations/2026-10-cleanup-baseline.md
create mode 100644 docs/investigations/2026-10-doc-map.csv
create mode 100644 docs/rfc-cleanup-progress.md
create mode 100644 docs/rfc-cleanup.md
create mode 100644 web/scripts/doc-map.ts
diff --git a/AGENTS.md b/AGENTS.md
index 9a3f68dd6..306e68560 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -23,6 +23,7 @@ It is standalone (not Eurorack), powered from a 5 V power bank through a screw t
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.
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)
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-doc-map.csv b/docs/investigations/2026-10-doc-map.csv
new file mode 100644
index 000000000..bb3c9aa69
--- /dev/null
+++ b/docs/investigations/2026-10-doc-map.csv
@@ -0,0 +1,1008 @@
+from,to,kind,anchor,resolves
+.github/PULL_REQUEST_TEMPLATE.md,CONTRIBUTING.md,absolute-blob,where-your-contribution-goes,yes
+AGENTS.md,docs/EDITIONS.md,relative,,yes
+AGENTS.md,docs/rfc-cleanup.md,relative,,yes
+AUDIO_GUIDE.md,/editions,site,,yes
+AUDIO_GUIDE.md,/guide/audio,site,,yes
+AUDIO_GUIDE.md,AGENTS.md,relative,hard-rules-do-not-violate,yes
+AUDIO_GUIDE.md,BUILD_GUIDE.md,relative,8-firmware,yes
+AUDIO_GUIDE.md,CONTRIBUTING.md,relative,,yes
+AUDIO_GUIDE.md,FEATURE_GUIDE.md,relative,,yes
+AUDIO_GUIDE.md,docs/audio-ws-spec.md,relative,,yes
+AUDIO_GUIDE.md,docs/director-midi.md,relative,,yes
+AUDIO_GUIDE.md,docs/images/audio-guide/pdm-mic-wiring.jpg,relative,,yes
+AUDIO_GUIDE.md,docs/midi-ableton.md,relative,,yes
+AUDIO_GUIDE.md,docs/midi-spec.md,relative,,yes
+AUDIO_GUIDE.md,docs/osc-spec.md,relative,,yes
+AUDIO_GUIDE.md,docs/rest-api.md,relative,,yes
+AUDIO_GUIDE.md,docs/rest-api.md,relative,microphone-audio-edition,yes
+AUDIO_GUIDE.md,firmware/patternflow/console/README.md,relative,the-page-that-is-assembled-audio-in,yes
+AUDIO_GUIDE.md,firmware/patternflow/features/audio,relative,,yes
+AUDIO_GUIDE.md,firmware/patternflow/features/audio_in,relative,,yes
+AUDIO_GUIDE.md,firmware/patternflow/features/midi,relative,,yes
+AUDIO_GUIDE.md,integrations/ableton,relative,,yes
+AUDIO_GUIDE.md,tools/patternflow-audio-android,relative,,yes
+AUDIO_GUIDE.md,tools/patternflow-audio-android/README.md,relative,,yes
+AUDIO_GUIDE.md,tools/patternflow-audio-extension,relative,,yes
+AUDIO_GUIDE.md,tools/patternflow-audio-extension/README.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,BUILD_GUIDE_v2.md,relative,,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/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
+BUILD_GUIDE_v2.md,/,site,,yes
+BUILD_GUIDE_v2.md,/build/breadboard,site,,yes
+BUILD_GUIDE_v2.md,BUILD_GUIDE.md,relative,,yes
+BUILD_GUIDE_v2.md,BUILD_GUIDE.md,relative,6-case-assembly,yes
+BUILD_GUIDE_v2.md,docs/assembly/README.md,relative,,yes
+BUILD_GUIDE_v2.md,docs/build-guide/images/all_parts.jpg,relative,,yes
+BUILD_GUIDE_v2.md,docs/build-guide/images/back_cover.jpg,relative,,yes
+BUILD_GUIDE_v2.md,docs/build-guide/images/case_bond.jpg,relative,,yes
+BUILD_GUIDE_v2.md,docs/build-guide/images/divider_bond.jpg,relative,,yes
+BUILD_GUIDE_v2.md,docs/build-guide/images/encoder_back_correct.jpg,relative,,yes
+BUILD_GUIDE_v2.md,docs/build-guide/images/encoder_front_wrong.jpg,relative,,yes
+BUILD_GUIDE_v2.md,docs/build-guide/images/encoder_nut.jpg,relative,,yes
+BUILD_GUIDE_v2.md,docs/build-guide/images/esp32_insert.jpg,relative,,yes
+BUILD_GUIDE_v2.md,docs/build-guide/images/first_boot.jpg,relative,,yes
+BUILD_GUIDE_v2.md,docs/build-guide/images/knobs.jpg,relative,,yes
+BUILD_GUIDE_v2.md,docs/build-guide/images/matrix_bump_cut1.jpg,relative,,yes
+BUILD_GUIDE_v2.md,docs/build-guide/images/matrix_bump_cut2.jpg,relative,,yes
+BUILD_GUIDE_v2.md,docs/build-guide/images/matrix_screw.jpg,relative,,yes
+BUILD_GUIDE_v2.md,docs/build-guide/images/parts_layout.jpg,relative,,yes
+BUILD_GUIDE_v2.md,docs/build-guide/images/pcb_assembly_setup.jpg,relative,,yes
+BUILD_GUIDE_v2.md,docs/build-guide/images/pcb_insert.webm,relative,,yes
+BUILD_GUIDE_v2.md,docs/build-guide/images/pcb_inserted.jpg,relative,,yes
+BUILD_GUIDE_v2.md,docs/build-guide/images/pcb_left_screw.jpg,relative,,yes
+BUILD_GUIDE_v2.md,docs/build-guide/images/pcb_slider.jpg,relative,,yes
+BUILD_GUIDE_v2.md,docs/build-guide/images/power_wire_measure.jpg,relative,,yes
+BUILD_GUIDE_v2.md,docs/build-guide/images/usb_wire_cut.jpg,relative,,yes
+BUILD_GUIDE_v2.md,docs/build-guide/images/web_flash.jpg,relative,,yes
+BUILD_GUIDE_v2.md,docs/panel-compatibility.md,relative,,yes
+BUILD_GUIDE_v2.md,firmware/CUSTOM_PATTERNS.md,relative,,yes
+BUILD_GUIDE_v2.md,firmware/README.md,relative,required-board-package,yes
+BUILD_GUIDE_v2.md,firmware/patternflow/config.h,relative,,yes
+BUILD_GUIDE_v2.md,hardware/case/legacy_v2/encloser_v2.1.stl,relative,,yes
+BUILD_GUIDE_v2.md,hardware/pcb/gerber,relative,,yes
+BUILD_GUIDE_v2.md,v2.1.0:hardware/case,absolute-blob,,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,/panel-tuner.html,site,,yes
+CHANGELOG.md,/roadmap,site,,yes
+CHANGELOG.md,/update,site,,yes
+CHANGELOG.md,/variants,site,,yes
+CHANGELOG.md,BUILD_GUIDE.md,relative,,yes
+CHANGELOG.md,docs/investigations/2026-08-core2-heap-and-the-5s-stall.md,relative,,yes
+CHANGELOG.md,docs/investigations/2026-08-the-panel-clock-and-the-wifi-radio.md,relative,,yes
+CHANGELOG.md,docs/investigations/2026-09-firmware-runtime.md,relative,,yes
+CHANGELOG.md,docs/panel-compatibility.md,relative,,yes
+CHANGELOG.md,docs/pfst-v2-spec.md,relative,,yes
+CODE_OF_CONDUCT.md,SECURITY.md,relative,,yes
+CODE_OF_CONDUCT.md,docs/manifesto.md,relative,6-how-we-treat-each-other,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,CODE_OF_CONDUCT.md,relative,,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,SUPPORT.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/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,firmware/bundles/README.md,relative,,yes
+CONTRIBUTING.md,firmware/patternflow/features/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,/,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,AUDIO_GUIDE.md,relative,,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,BUILD_GUIDE_v2.md,relative,,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,SUPPORT.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/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
+SECURITY.md,/editions,site,,yes
+SECURITY.md,/pattern,site,,yes
+SECURITY.md,/update,site,,yes
+SECURITY.md,docs/rest-api.md,relative,transport,yes
+SUPPORT.md,/guide,site,,yes
+SUPPORT.md,/guide/play,site,flash,yes
+SUPPORT.md,/inside,site,,yes
+SUPPORT.md,/pattern,site,,yes
+SUPPORT.md,/pattern-lab,site,,yes
+SUPPORT.md,BUILD_GUIDE.md,relative,8-firmware,yes
+SUPPORT.md,CONTRIBUTING.md,relative,,yes
+SUPPORT.md,SECURITY.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,/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,SUPPORT.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/build-guide/images,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/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/mqtt-spec.md,relative,,yes
+docs/README.md,docs/osc-spec.md,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.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,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/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,AUDIO_GUIDE.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/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,tools/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,BUILD_GUIDE_v2.md,relative,,yes
+docs/assembly/README.md,PATTERN_GUIDE.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/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/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/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/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/v3.0.0-launch.md,docs/RELEASING.md,relative,,yes
+docs/releases/v3.0.0.md,/,site,,yes
+docs/releases/v3.0.0.md,BUILD_GUIDE.md,absolute-blob,2-power-input--use-the-screw-terminal,yes
+docs/releases/v3.0.0.md,v3.0.0:BUILD_GUIDE.md,absolute-blob,,yes
+docs/releases/v3.0.0.md,v3.0.0:BUILD_GUIDE_v2.md,absolute-blob,,yes
+docs/releases/v3.0.0.md,v3.0.0:CHANGELOG.md,absolute-blob,,yes
+docs/releases/v3.0.0.md,v3.0.0:hardware/bom/bom_v3.0.csv,absolute-blob,,yes
+docs/releases/v3.1.0.md,/roadmap,site,,yes
+docs/releases/v3.1.0.md,v3.1.0:BUILD_GUIDE.md,absolute-blob,,yes
+docs/releases/v3.1.0.md,v3.1.0:BUILD_GUIDE.md,absolute-blob,2-power-input--use-the-screw-terminal,yes
+docs/releases/v3.1.0.md,v3.1.0:CHANGELOG.md,absolute-blob,,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-cleanup-progress.md,docs/investigations/2026-10-cleanup-baseline.md,relative,,yes
+docs/rfc-cleanup-progress.md,docs/investigations/2026-10-doc-map.csv,relative,,yes
+docs/rfc-cleanup-progress.md,docs/rfc-cleanup.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
+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,AUDIO_GUIDE.md,relative,,yes
+firmware/README.md,BUILD_GUIDE.md,relative,8-firmware,yes
+firmware/README.md,PATTERN_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,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,BUILD_GUIDE_v2.md,relative,,yes
+hardware/README.md,LICENSE-CC-BY-SA,relative,,yes
+hardware/README.md,docs/assembly/README.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,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/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/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,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
+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/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,AUDIO_GUIDE.md,absolute-blob,,yes
+web/src/app/features/features-data.ts,PATTERN_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,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,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,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,BUILD_GUIDE_v2.md,absolute-blob,,yes
+web/src/components/sections/BuildPanel.tsx,docs/assembly/README.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/rfc-cleanup-progress.md b/docs/rfc-cleanup-progress.md
new file mode 100644
index 000000000..d70dee051
--- /dev/null
+++ b/docs/rfc-cleanup-progress.md
@@ -0,0 +1,38 @@
+# 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: step 0 done, step 1 next.** 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 | — |
+| 2 | Drift checks: links, BOM triad, features, editions, presets, roadmap | — |
+| 3 | Code hygiene: lint/typecheck zero, legacy named or gone, HUB75 upstream patch, shared constants | — |
+| 4 | Test spine: contract vectors ×5, virtual panel | — |
+| 5 | RFC drafts: any-resolution, Linux player | — |
+
+Gate for any-resolution (§2.12): not met.
+
+---
+
+## 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.
diff --git a/docs/rfc-cleanup.md b/docs/rfc-cleanup.md
new file mode 100644
index 000000000..95226670c
--- /dev/null
+++ b/docs/rfc-cleanup.md
@@ -0,0 +1,368 @@
+# RFC: cleanup before expansion
+
+*Tidying the tree before any-resolution work starts. Written 2026-10-14.*
+
+> **Status, 14 October 2026.** Proposed. 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)
+> is what has to be true before the any-resolution RFCs open.
+
+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` holds verbatim excerpts the maintainer
+> chose. 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 the maintainer: three to five paragraphs of
+his own, copied as they are, from wherever he likes. Agents do not pick
+them. `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`.
diff --git a/web/package.json b/web/package.json
index d05a1187d..a462f844d 100644
--- a/web/package.json
+++ b/web/package.json
@@ -41,7 +41,8 @@
"check:panels": "vitest run",
"check:ci": "npm run check:license && npm run check:moderation && npm run check:headermod && npm run check:hidemod && npm run check:retention && npm run check:deck && npm run check:pack && npm run check:sharing && npm run check:publish && npm run check:notify && npm run check:ports && npm run check:workshop && npm run check:sandbox && npm run check:capture && npm run check:thumbs && npm run check:flatten && npm run check:labedit && npm run check:performance && npm run check:pixelcode && npm run check:director && npm run check:pfstvectors && npm run check:ramp && npm run check:panels && npm run check:sandbox-sync && npm run check:modcache",
"build:sandbox": "tsx scripts/build-sandbox.ts",
- "check:sandbox-sync": "tsx scripts/build-sandbox.ts --check"
+ "check:sandbox-sync": "tsx scripts/build-sandbox.ts --check",
+ "doc-map": "tsx scripts/doc-map.ts"
},
"dependencies": {
"@google/genai": "^2.9.0",
diff --git a/web/scripts/doc-map.ts b/web/scripts/doc-map.ts
new file mode 100644
index 000000000..b2cee3bdd
--- /dev/null
+++ b/web/scripts/doc-map.ts
@@ -0,0 +1,191 @@
+/**
+ * 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.
+ *
+ * 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, 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.
+ */
+import { execFileSync } from "node:child_process";
+import fs from "node:fs";
+import path from "node:path";
+
+const ROOT = execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" }).trim();
+const OUT = "docs/investigations/2026-10-doc-map.csv";
+const CONSOLE = "firmware/patternflow/console/";
+const REDIRECTS = "web/next.config.ts";
+const SKIP = /(^|\/)(hub75|webserver|pubsubclient)\//;
+
+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 ──
+
+type Row = { from: string; to: string; kind: string; anchor: string; resolves: "yes" | "no" };
+
+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);
+ };
+ 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"))));
+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 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(` ${r.from} → ${r.to}${r.anchor ? "#" + r.anchor : ""} (${r.kind})`);
From 387487dffd6bdf14ad5bd689abf36805e39f9204 Mon Sep 17 00:00:00 2001
From: Patternflow Bot
Date: Wed, 14 Oct 2026 22:03:56 +0900
Subject: [PATCH 02/82] docs: cleanup step 1 is planned
From Patternflow-lab/Patternflow#12
---
.../2026-10-cleanup-step1-plan.md | 331 ++++++++++++++++++
docs/rfc-cleanup-progress.md | 2 +-
2 files changed, 332 insertions(+), 1 deletion(-)
create mode 100644 docs/investigations/2026-10-cleanup-step1-plan.md
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/rfc-cleanup-progress.md b/docs/rfc-cleanup-progress.md
index d70dee051..de9db7ac9 100644
--- a/docs/rfc-cleanup-progress.md
+++ b/docs/rfc-cleanup-progress.md
@@ -10,7 +10,7 @@ record.
| 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 | — |
+| 1 | Document structure: root, legacy guide, CHANGELOG split, ko/en parity list | planned ([three lanes](investigations/2026-10-cleanup-step1-plan.md)) |
| 2 | Drift checks: links, BOM triad, features, editions, presets, roadmap | — |
| 3 | Code hygiene: lint/typecheck zero, legacy named or gone, HUB75 upstream patch, shared constants | — |
| 4 | Test spine: contract vectors ×5, virtual panel | — |
From b55d59a55528c8dc80eec9a51d8da4f8a8098a54 Mon Sep 17 00:00:00 2001
From: Patternflow Bot
Date: Wed, 14 Oct 2026 23:23:38 +0900
Subject: [PATCH 03/82] docs: the v2 build guide moved to
`docs/legacy/BUILD_GUIDE_v2.md`
From Patternflow-lab/Patternflow#13
---
AGENTS.md | 4 +-
BUILD_GUIDE.md | 2 +-
README.md | 4 +-
docs/README.md | 3 +-
docs/REPOSITORY.md | 4 +-
docs/assembly/README.md | 4 +-
.../legacy/BUILD_GUIDE_v2.md | 56 +++++++++----------
hardware/README.md | 2 +-
hardware/case/README.md | 4 +-
.../components/guide/stage/build/layout.ts | 6 +-
.../components/sections/BuildPanel.test.tsx | 2 +-
web/src/components/sections/BuildPanel.tsx | 2 +-
12 files changed, 47 insertions(+), 46 deletions(-)
rename BUILD_GUIDE_v2.md => docs/legacy/BUILD_GUIDE_v2.md (89%)
diff --git a/AGENTS.md b/AGENTS.md
index 306e68560..e2bfc0d89 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -8,7 +8,7 @@ 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 board**: v3.9, and v3.0 the same way), `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.
+- `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 legacy v2.x build guide (`docs/legacy/BUILD_GUIDE_v2.md`), the manifesto, investigations, and media (build-guide images in `docs/build-guide/`). 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), `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, `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.
@@ -20,7 +20,7 @@ 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.
diff --git a/BUILD_GUIDE.md b/BUILD_GUIDE.md
index 8afef288e..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.
diff --git a/README.md b/README.md
index c81b35358..33d8b887a 100644
--- a/README.md
+++ b/README.md
@@ -97,7 +97,7 @@ The **[Build Guide](BUILD_GUIDE.md)** is the official route: the v3.9 board in a
**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.
-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](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).
+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).
**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.
@@ -237,7 +237,7 @@ Folder by folder, and what you want to do → where to start: **[docs/REPOSITORY
**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](BUILD_GUIDE_v2.md)
+**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](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](SUPPORT.md) · [Repository map](docs/REPOSITORY.md) · [Changelog](CHANGELOG.md) · [License Summary](docs/LICENSE-SUMMARY.md) · [Manifesto](docs/manifesto.md)
diff --git a/docs/README.md b/docs/README.md
index ca4b90a94..194891f8e 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -1,6 +1,6 @@
# 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: **a guide somebody follows start to finish lives at the repository root** (`BUILD_GUIDE.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).
## Contracts — other software is built against these, not against the firmware source
@@ -28,6 +28,7 @@ What is here, and what is not. The rule for the split: **a guide somebody follow
- [`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
diff --git a/docs/REPOSITORY.md b/docs/REPOSITORY.md
index 828b47433..ca5adb6f9 100644
--- a/docs/REPOSITORY.md
+++ b/docs/REPOSITORY.md
@@ -9,11 +9,11 @@ A map for people. (AI coding agents get the same map with more rules in [`../AGE
| `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) |
-| `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) |
+| `docs/` | Contracts other software is built against, how the firmware is put together, walk-throughs, the assembly map, the legacy v2.x build guide (`legacy/`), 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, the official one and every remix on its case switch | [`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 (including the guide's **Stuck here?** form and **Share your build**), the CI workflows (web, every firmware composition, the core-names-no-feature checks, doc links, 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 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) | |
+| root | `README.md`, the four guides (`BUILD_GUIDE.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) | |
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.**
diff --git a/docs/assembly/README.md b/docs/assembly/README.md
index da89ad3d4..beb91b416 100644
--- a/docs/assembly/README.md
+++ b/docs/assembly/README.md
@@ -1,7 +1,7 @@
# 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.
@@ -51,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/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.
-
+
---
@@ -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/hardware/README.md b/hardware/README.md
index d254d54d9..633ac2f83 100644
--- a/hardware/README.md
+++ b/hardware/README.md
@@ -11,7 +11,7 @@ Open hardware files for Patternflow: the board, the enclosure, the bill of mater
| v2.1 | Legacy. Last of the v2.x generation; **does not fit the v3 cases** | `pcb/gerber/patternflow_v2.1_gerber.zip` | inside the [v2.1.0 guide](https://github.com/engmung/Patternflow/blob/v2.1.0/BUILD_GUIDE.md#1-bill-of-materials-bom) | `case/legacy_v2/` |
| v1.0, v2.0 | Archived early revisions, kept for the record | `pcb/gerber/patternflow_v1.0_gerber.zip`, `pcb/gerber/patternflow_v2.0_gerber.zip` (do not order) | — | — |
-The build itself is [`../BUILD_GUIDE.md`](../BUILD_GUIDE.md) (v3 boards; the same build in 3D is [patternflow.work/guide/build](https://patternflow.work/guide/build)) and [`../BUILD_GUIDE_v2.md`](../BUILD_GUIDE_v2.md) (v2.x); the map of every enclosure + electronics combination is [`docs/assembly/README.md`](../docs/assembly/README.md). Every v3.x firmware release runs on every v3 board; the pin map has not changed since v2.
+The build itself is [`../BUILD_GUIDE.md`](../BUILD_GUIDE.md) (v3 boards; the same build in 3D is [patternflow.work/guide/build](https://patternflow.work/guide/build)) and [`../docs/legacy/BUILD_GUIDE_v2.md`](../docs/legacy/BUILD_GUIDE_v2.md) (v2.x); the map of every enclosure + electronics combination is [`docs/assembly/README.md`](../docs/assembly/README.md). Every v3.x firmware release runs on every v3 board; the pin map has not changed since v2.
## Folders
diff --git a/hardware/case/README.md b/hardware/case/README.md
index 1cd18a986..a8b770489 100644
--- a/hardware/case/README.md
+++ b/hardware/case/README.md
@@ -45,12 +45,12 @@ The full photo sequence lives in the [build guide](../../BUILD_GUIDE.md) — §4
## `legacy_v2/` — v2.x boards only
-Everything that fits the v2.x board generation, kept for existing builds. **None of these fit the v3 board.** Follow the [v2 build guide](../../BUILD_GUIDE_v2.md).
+Everything that fits the v2.x board generation, kept for existing builds. **None of these fit the v3 board.** Follow the [v2 build guide](../../docs/legacy/BUILD_GUIDE_v2.md).
| File | What |
|---|---|
| `encloser_v2.1.stl` | ✅ **Recommended v2 print** — the snap-fit design cut for the v2.1 board, every part in one STL (knobs included). Print & assembly verified. No body gluing, snap-fit back panel, two wall-mount holes, and **no LED-matrix bump trimming** ([#19](https://github.com/engmung/Patternflow/issues/19) recesses built in). Assemble per the sequence in the [v3 guide §6](../../BUILD_GUIDE.md#6-case-assembly). |
-| `plate_main.stl` + `plate_dividers.stl` | Classic split plates (256 mm bed, glued) — the path the [v2 guide](../../BUILD_GUIDE_v2.md) documents with full photos |
+| `plate_main.stl` + `plate_dividers.stl` | Classic split plates (256 mm bed, glued) — the path the [v2 guide](../../docs/legacy/BUILD_GUIDE_v2.md) documents with full photos |
| `oneshot_v2_part1/2.stl` | One-piece snap-fit for the v2.x board (330 mm+ bed) |
(An `easyfit` plate variant existed briefly but was retired over a fit defect — [#154](https://github.com/engmung/Patternflow/issues/154). It remains browsable at the [v2.1.0 tag](https://github.com/engmung/Patternflow/tree/v2.1.0/hardware/case).)
diff --git a/web/src/components/guide/stage/build/layout.ts b/web/src/components/guide/stage/build/layout.ts
index 6779dc52b..a07c0ea89 100644
--- a/web/src/components/guide/stage/build/layout.ts
+++ b/web/src/components/guide/stage/build/layout.ts
@@ -67,9 +67,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 = {
"U1_socket_pins1-22": -6.8,
diff --git a/web/src/components/sections/BuildPanel.test.tsx b/web/src/components/sections/BuildPanel.test.tsx
index c5005fc0c..1997647d2 100644
--- a/web/src/components/sections/BuildPanel.test.tsx
+++ b/web/src/components/sections/BuildPanel.test.tsx
@@ -384,7 +384,7 @@ 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();
});
});
diff --git a/web/src/components/sections/BuildPanel.tsx b/web/src/components/sections/BuildPanel.tsx
index 5a86b2a49..129e93cb8 100644
--- a/web/src/components/sections/BuildPanel.tsx
+++ b/web/src/components/sections/BuildPanel.tsx
@@ -414,7 +414,7 @@ export default function BuildPanel({ content, isActive }: BuildPanelProps) {
Breadboard guide
-
+
v2 board guide
From e8519b4325f843f0b0356e82eca8136b422722ac Mon Sep 17 00:00:00 2001
From: Patternflow Bot
Date: Thu, 15 Oct 2026 00:16:14 +0900
Subject: [PATCH 04/82] docs: the doc map lists the Korean/English pairs
From Patternflow-lab/Patternflow#15
---
docs/investigations/2026-10-doc-map.csv | 85 +-
docs/investigations/2026-10-doc-parity.csv | 1367 ++++++++++++++++++++
web/scripts/doc-map.ts | 212 ++-
3 files changed, 1622 insertions(+), 42 deletions(-)
create mode 100644 docs/investigations/2026-10-doc-parity.csv
diff --git a/docs/investigations/2026-10-doc-map.csv b/docs/investigations/2026-10-doc-map.csv
index bb3c9aa69..331c46584 100644
--- a/docs/investigations/2026-10-doc-map.csv
+++ b/docs/investigations/2026-10-doc-map.csv
@@ -31,7 +31,6 @@ 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,BUILD_GUIDE_v2.md,relative,,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
@@ -44,6 +43,7 @@ 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
@@ -56,40 +56,6 @@ 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
-BUILD_GUIDE_v2.md,/,site,,yes
-BUILD_GUIDE_v2.md,/build/breadboard,site,,yes
-BUILD_GUIDE_v2.md,BUILD_GUIDE.md,relative,,yes
-BUILD_GUIDE_v2.md,BUILD_GUIDE.md,relative,6-case-assembly,yes
-BUILD_GUIDE_v2.md,docs/assembly/README.md,relative,,yes
-BUILD_GUIDE_v2.md,docs/build-guide/images/all_parts.jpg,relative,,yes
-BUILD_GUIDE_v2.md,docs/build-guide/images/back_cover.jpg,relative,,yes
-BUILD_GUIDE_v2.md,docs/build-guide/images/case_bond.jpg,relative,,yes
-BUILD_GUIDE_v2.md,docs/build-guide/images/divider_bond.jpg,relative,,yes
-BUILD_GUIDE_v2.md,docs/build-guide/images/encoder_back_correct.jpg,relative,,yes
-BUILD_GUIDE_v2.md,docs/build-guide/images/encoder_front_wrong.jpg,relative,,yes
-BUILD_GUIDE_v2.md,docs/build-guide/images/encoder_nut.jpg,relative,,yes
-BUILD_GUIDE_v2.md,docs/build-guide/images/esp32_insert.jpg,relative,,yes
-BUILD_GUIDE_v2.md,docs/build-guide/images/first_boot.jpg,relative,,yes
-BUILD_GUIDE_v2.md,docs/build-guide/images/knobs.jpg,relative,,yes
-BUILD_GUIDE_v2.md,docs/build-guide/images/matrix_bump_cut1.jpg,relative,,yes
-BUILD_GUIDE_v2.md,docs/build-guide/images/matrix_bump_cut2.jpg,relative,,yes
-BUILD_GUIDE_v2.md,docs/build-guide/images/matrix_screw.jpg,relative,,yes
-BUILD_GUIDE_v2.md,docs/build-guide/images/parts_layout.jpg,relative,,yes
-BUILD_GUIDE_v2.md,docs/build-guide/images/pcb_assembly_setup.jpg,relative,,yes
-BUILD_GUIDE_v2.md,docs/build-guide/images/pcb_insert.webm,relative,,yes
-BUILD_GUIDE_v2.md,docs/build-guide/images/pcb_inserted.jpg,relative,,yes
-BUILD_GUIDE_v2.md,docs/build-guide/images/pcb_left_screw.jpg,relative,,yes
-BUILD_GUIDE_v2.md,docs/build-guide/images/pcb_slider.jpg,relative,,yes
-BUILD_GUIDE_v2.md,docs/build-guide/images/power_wire_measure.jpg,relative,,yes
-BUILD_GUIDE_v2.md,docs/build-guide/images/usb_wire_cut.jpg,relative,,yes
-BUILD_GUIDE_v2.md,docs/build-guide/images/web_flash.jpg,relative,,yes
-BUILD_GUIDE_v2.md,docs/panel-compatibility.md,relative,,yes
-BUILD_GUIDE_v2.md,firmware/CUSTOM_PATTERNS.md,relative,,yes
-BUILD_GUIDE_v2.md,firmware/README.md,relative,required-board-package,yes
-BUILD_GUIDE_v2.md,firmware/patternflow/config.h,relative,,yes
-BUILD_GUIDE_v2.md,hardware/case/legacy_v2/encloser_v2.1.stl,relative,,yes
-BUILD_GUIDE_v2.md,hardware/pcb/gerber,relative,,yes
-BUILD_GUIDE_v2.md,v2.1.0:hardware/case,absolute-blob,,yes
CHANGELOG.md,/guide,site,,yes
CHANGELOG.md,/guide/build,site,,yes
CHANGELOG.md,/guide/make,site,,yes
@@ -194,7 +160,6 @@ 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,BUILD_GUIDE_v2.md,relative,,yes
README.md,CHANGELOG.md,relative,,yes
README.md,CONTRIBUTING.md,relative,,yes
README.md,FEATURE_GUIDE.md,relative,,yes
@@ -208,6 +173,7 @@ 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
@@ -288,6 +254,7 @@ docs/README.md,docs/build-guide/images,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
@@ -338,8 +305,8 @@ 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,BUILD_GUIDE_v2.md,relative,,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
@@ -364,6 +331,41 @@ docs/investigations/2026-10-cleanup-baseline.md,docs/investigations/2026-10-doc-
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/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
@@ -407,6 +409,7 @@ 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-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-doc-map.csv,relative,,yes
docs/rfc-cleanup-progress.md,docs/rfc-cleanup.md,relative,,yes
docs/rfc-core-and-variants-progress.md,docs/EDITIONS.md,relative,,yes
@@ -532,9 +535,9 @@ firmware/toolchain/README.md,firmware/patternflow/console/README.md,relative,,ye
hardware/README.md,/build,site,,yes
hardware/README.md,/guide/build,site,,yes
hardware/README.md,BUILD_GUIDE.md,relative,,yes
-hardware/README.md,BUILD_GUIDE_v2.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
@@ -550,7 +553,7 @@ hardware/bom/README.md,v2.1.0:BUILD_GUIDE.md,absolute-blob,1-bill-of-materials-b
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,BUILD_GUIDE_v2.md,relative,,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
@@ -961,7 +964,7 @@ web/src/components/guide/copy/build.ts,hardware/case/bed_330mm/encloser.stl,abso
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,BUILD_GUIDE_v2.md,absolute-blob,,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
@@ -975,8 +978,8 @@ web/src/components/sections/BuildPanel.test.tsx,hardware/case/remixes/simonepda-
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,BUILD_GUIDE_v2.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
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/web/scripts/doc-map.ts b/web/scripts/doc-map.ts
index b2cee3bdd..ef2d5f371 100644
--- a/web/scripts/doc-map.ts
+++ b/web/scripts/doc-map.ts
@@ -1,6 +1,7 @@
/**
* 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.
+ * `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).
@@ -26,13 +27,35 @@
* 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.
+ *
+ * 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";
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)\//;
@@ -189,3 +212,190 @@ 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(` ${r.from} → ${r.to}${r.anchor ? "#" + r.anchor : ""} (${r.kind})`);
+
+// ── 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(", "));
From 6479937015cc54331eb759cf39c294bce5d09a09 Mon Sep 17 00:00:00 2001
From: Patternflow Bot
Date: Thu, 15 Oct 2026 00:29:32 +0900
Subject: [PATCH 05/82] docs: the doc map reads the YAML under `.github/`
From Patternflow-lab/Patternflow#17
---
docs/investigations/2026-10-doc-map.csv | 6 ++++++
web/scripts/doc-map.ts | 11 +++++++----
2 files changed, 13 insertions(+), 4 deletions(-)
diff --git a/docs/investigations/2026-10-doc-map.csv b/docs/investigations/2026-10-doc-map.csv
index 331c46584..9b1f35940 100644
--- a/docs/investigations/2026-10-doc-map.csv
+++ b/docs/investigations/2026-10-doc-map.csv
@@ -1,4 +1,10 @@
from,to,kind,anchor,resolves
+.github/FUNDING.yml,/,site,,yes
+.github/ISSUE_TEMPLATE/config.yml,CONTRIBUTING.md,absolute-blob,,yes
+.github/ISSUE_TEMPLATE/config.yml,SUPPORT.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
AGENTS.md,docs/EDITIONS.md,relative,,yes
AGENTS.md,docs/rfc-cleanup.md,relative,,yes
diff --git a/web/scripts/doc-map.ts b/web/scripts/doc-map.ts
index ef2d5f371..d4dbae1c5 100644
--- a/web/scripts/doc-map.ts
+++ b/web/scripts/doc-map.ts
@@ -11,9 +11,11 @@
* 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, 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:
+ * 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//
@@ -197,7 +199,8 @@ function linksIn(f: string): Row[] {
// ── 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.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);
From 067b400bda0298aaeb08c104a9ab9f0782ed1fa8 Mon Sep 17 00:00:00 2001
From: Patternflow Bot
Date: Thu, 15 Oct 2026 06:49:01 +0900
Subject: [PATCH 06/82] docs: `CHANGELOG.md` is split by release into
`docs/releases/`
From Patternflow-lab/Patternflow#14
---
docs/RELEASING.md | 7 +-
docs/releases/v1.0.0.md | 39 +++++
docs/releases/v1.1.0.md | 16 ++
docs/releases/v2.0.0.md | 24 +++
docs/releases/v2.1.0.md | 61 ++++++++
docs/releases/v3.0.0-notes.md | 51 +++++++
docs/releases/v3.0.0.md | 68 +++------
docs/releases/v3.1.0-notes.md | 48 ++++++
docs/releases/v3.1.0.md | 74 ++++-----
docs/releases/v3.10.0.md | 19 +++
docs/releases/v3.10.1.md | 227 ++++++++++++++++++++++++++++
docs/releases/v3.10.2.md | 23 +++
docs/releases/v3.10.3.md | 23 +++
docs/releases/v3.10.4.md | 5 +
docs/releases/v3.10.5.md | 33 ++++
docs/releases/v3.11.0.md | 70 +++++++++
docs/releases/v3.2.0.md | 33 ++++
docs/releases/v3.3.0.md | 32 ++++
docs/releases/v3.4.0.md | 26 ++++
docs/releases/v3.4.9.md | 4 +
docs/releases/v3.5.0.md | 4 +
docs/releases/v3.5.1.md | 4 +
docs/releases/v3.5.2.md | 4 +
docs/releases/v3.6.3.md | 47 ++++++
docs/releases/v3.7.0.md | 25 +++
docs/releases/v3.7.1.md | 12 ++
docs/releases/v3.8.0.md | 20 +++
docs/releases/v3.9.0.md | 32 ++++
docs/releases/v3.9.1.md | 9 ++
docs/releases/v3.9.2.md | 9 ++
docs/releases/v3.9.3.md | 10 ++
docs/releases/v3.9.4.md | 10 ++
docs/releases/v3.9.5.md | 27 ++++
firmware/toolchain/release.py | 71 ++++++++-
web/src/app/roadmap/roadmap-data.ts | 2 +-
35 files changed, 1065 insertions(+), 104 deletions(-)
create mode 100644 docs/releases/v1.0.0.md
create mode 100644 docs/releases/v1.1.0.md
create mode 100644 docs/releases/v2.0.0.md
create mode 100644 docs/releases/v2.1.0.md
create mode 100644 docs/releases/v3.0.0-notes.md
create mode 100644 docs/releases/v3.1.0-notes.md
create mode 100644 docs/releases/v3.10.0.md
create mode 100644 docs/releases/v3.10.1.md
create mode 100644 docs/releases/v3.10.2.md
create mode 100644 docs/releases/v3.10.3.md
create mode 100644 docs/releases/v3.10.4.md
create mode 100644 docs/releases/v3.10.5.md
create mode 100644 docs/releases/v3.11.0.md
create mode 100644 docs/releases/v3.2.0.md
create mode 100644 docs/releases/v3.3.0.md
create mode 100644 docs/releases/v3.4.0.md
create mode 100644 docs/releases/v3.4.9.md
create mode 100644 docs/releases/v3.5.0.md
create mode 100644 docs/releases/v3.5.1.md
create mode 100644 docs/releases/v3.5.2.md
create mode 100644 docs/releases/v3.6.3.md
create mode 100644 docs/releases/v3.7.0.md
create mode 100644 docs/releases/v3.7.1.md
create mode 100644 docs/releases/v3.8.0.md
create mode 100644 docs/releases/v3.9.0.md
create mode 100644 docs/releases/v3.9.1.md
create mode 100644 docs/releases/v3.9.2.md
create mode 100644 docs/releases/v3.9.3.md
create mode 100644 docs/releases/v3.9.4.md
create mode 100644 docs/releases/v3.9.5.md
diff --git a/docs/RELEASING.md b/docs/RELEASING.md
index 6aa9548ee..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,7 +103,7 @@ 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.
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/firmware/toolchain/release.py b/firmware/toolchain/release.py
index a6b6c9a08..d4c68a174 100644
--- a/firmware/toolchain/release.py
+++ b/firmware/toolchain/release.py
@@ -8,7 +8,10 @@
`cut` (on dev, clean tree, written [Unreleased] section):
1. bumps PF_IMPROV_FW_VERSION and each named edition's PF_VARIANT_VERSION,
2. turns CHANGELOG's [Unreleased] into "## [X.Y.Z] - today" under a fresh
- [Unreleased], and the "current" lines in AGENTS.md,
+ [Unreleased], and the "current" lines in AGENTS.md; copies the new
+ section to docs/releases/vX.Y.Z.md, and turns the fourth-newest section
+ in CHANGELOG into a line in its list of older releases (the root keeps
+ three in full),
3. runs shelf.sh for the core and each named edition (the images the site
serves, built from a sketch copy without patternflow_secrets.h - shelf.sh
refuses an image that carries credentials or the wrong version),
@@ -50,6 +53,9 @@
ROOT = Path(__file__).resolve().parents[2]
NET_CONFIG = ROOT / "firmware/patternflow/net_config.h"
CHANGELOG = ROOT / "CHANGELOG.md"
+RELEASES = ROOT / "docs/releases"
+IN_FULL = 3 # releases CHANGELOG.md keeps in full; each older one is a line under OLDER
+OLDER = "## Older releases"
AGENTS = ROOT / "AGENTS.md"
MANIFEST = ROOT / "web/public/flash/manifest.json"
EDITIONS_TS = ROOT / "web/src/app/editions/editions-data.ts"
@@ -193,6 +199,66 @@ def bump_changelog(core: str, today: str, allow_empty: bool) -> str:
return body
+# A release section runs from its "## [X.Y.Z] - date" line to the next "## ".
+SECTION = re.compile(r"^## \[(?!Unreleased\])([^\]]+)\][^\n]*\n.*?(?=^## |\Z)", re.M | re.S)
+
+
+def release_file(label: str) -> Path:
+ # [3.11.0] and [v1.1.0] alike: the file name always carries the v.
+ return RELEASES / f"v{label.lstrip('v')}.md"
+
+
+def as_filed(section: str) -> str:
+ """A section as its file in docs/releases/ holds it: relative links two
+ folders up, nothing else changed."""
+ return re.sub(r"\]\((?![a-z][a-z0-9+.-]*:|[#/])", "](../../", section.rstrip("\n")) + "\n"
+
+
+def older_line(section: str) -> str:
+ label, rest = re.match(r"## \[([^\]]+)\]([^\n]*)", section).groups()
+ return f"- [{label}](docs/releases/{release_file(label).name}){rest}"
+
+
+def check_filing(core: str) -> None:
+ """Before anything is written: the new release has no file yet, and the
+ section about to leave CHANGELOG.md says what its file says, so the
+ root's copy is not lost."""
+ if release_file(core[1:]).exists():
+ die(f"docs/releases/{release_file(core[1:]).name} already exists")
+ text, _ = read(CHANGELOG)
+ sections = list(SECTION.finditer(text))
+ if len(sections) >= IN_FULL:
+ leaving = sections[IN_FULL - 1]
+ path = release_file(leaving.group(1))
+ if path.exists() and read(path)[0] != as_filed(leaving.group(0)):
+ die(f"CHANGELOG.md's [{leaving.group(1)}] section and docs/releases/{path.name} differ; "
+ "make them agree before cutting, since the section leaves the root")
+
+
+def file_changelog(core: str) -> list[str]:
+ """Copy the section just dated to docs/releases/, and turn the one after
+ the newest IN_FULL into a line under OLDER. Returns the files it wrote."""
+ text, crlf = read(CHANGELOG)
+ sections = list(SECTION.finditer(text))
+ written = [release_file(core[1:])]
+ write(written[0], as_filed(sections[0].group(0)), crlf)
+ if len(sections) > IN_FULL:
+ leaving = sections[IN_FULL]
+ path = release_file(leaving.group(1))
+ if not path.exists():
+ write(path, as_filed(leaving.group(0)), crlf)
+ written.append(path)
+ text = text[:leaving.start()] + text[leaving.end():]
+ if OLDER not in text:
+ text = text.rstrip("\n") + f"\n\n{OLDER}\n\n"
+ text = text.replace(f"{OLDER}\n\n", f"{OLDER}\n\n{older_line(leaving.group(0))}\n", 1)
+ write(CHANGELOG, text, crlf)
+ print(f" CHANGELOG.md: [{leaving.group(1)}] -> a line under '{OLDER[3:]}'")
+ written = [p.relative_to(ROOT).as_posix() for p in written]
+ print(" " + ", ".join(written) + ": written")
+ return written
+
+
def bump_agents_editions(editions_now: dict[str, str]) -> None:
if not all(editions_now.get(n) for n in EDITIONS):
return
@@ -279,10 +345,12 @@ def cmd_cut(args: argparse.Namespace) -> None:
die("the working tree has uncommitted changes:\n " + "\n ".join(dirty))
if out(["git", "tag", "-l", core]):
die(f"tag {core} already exists")
+ check_filing(core)
print(f"== cut {core}" + (" with " + ", ".join(f"{k} {v}" for k, v in editions.items()) if editions else ""))
bump_firmware(core, editions)
bump_changelog(core, today, args.allow_empty_changelog)
+ filed = file_changelog(core)
bump_agents(core, today, {**current_edition_versions(), **editions})
if args.no_build:
@@ -308,6 +376,7 @@ def cmd_cut(args: argparse.Namespace) -> None:
else:
paths = ["firmware/patternflow/net_config.h", "CHANGELOG.md", "AGENTS.md",
"web/public/flash/manifest.json", "web/src/app/editions/editions-data.ts"]
+ paths += filed
paths += [f"firmware/bundles/{n}/overrides.h" for n in editions]
run(["git", "add", "-A", "--", *paths, "web/public/flash/bin"])
summary = f"Core {core}" + "".join(f", {n.capitalize()} {v}" for n, v in editions.items())
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',
From d6107627d78f9b2638e376bfa0bedc47942904a4 Mon Sep 17 00:00:00 2001
From: Patternflow Bot
Date: Thu, 15 Oct 2026 06:49:06 +0900
Subject: [PATCH 07/82] docs: the community files moved to `.github/` and the
audio guide to
From Patternflow-lab/Patternflow#16
---
.../CODE_OF_CONDUCT.md | 2 +-
.github/ISSUE_TEMPLATE/config.yml | 2 +-
SECURITY.md => .github/SECURITY.md | 2 +-
SUPPORT.md => .github/SUPPORT.md | 4 +-
AGENTS.md | 2 +-
CONTRIBUTING.md | 2 +-
README.md | 10 ++--
AUDIO_GUIDE.md => docs/AUDIO_GUIDE.md | 52 +++++++++----------
docs/README.md | 7 +--
docs/REPOSITORY.md | 12 ++---
firmware/README.md | 2 +-
tools/patternflow-audio-extension/README.md | 2 +-
web/src/app/features/features-data.ts | 2 +-
web/src/components/guide/copy/audio.ts | 11 ++--
14 files changed, 57 insertions(+), 55 deletions(-)
rename CODE_OF_CONDUCT.md => .github/CODE_OF_CONDUCT.md (94%)
rename SECURITY.md => .github/SECURITY.md (80%)
rename SUPPORT.md => .github/SUPPORT.md (94%)
rename AUDIO_GUIDE.md => docs/AUDIO_GUIDE.md (90%)
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/SECURITY.md b/.github/SECURITY.md
similarity index 80%
rename from SECURITY.md
rename to .github/SECURITY.md
index 1193d2bb5..772748efa 100644
--- a/SECURITY.md
+++ b/.github/SECURITY.md
@@ -13,7 +13,7 @@ Please report privately through [GitHub's report form](https://github.com/engmun
## 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`. 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.
+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 94%
rename from SUPPORT.md
rename to .github/SUPPORT.md
index a06b26f7b..e9dc5ab2b 100644
--- a/SUPPORT.md
+++ b/.github/SUPPORT.md
@@ -6,13 +6,13 @@ 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 |
+| 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.**
diff --git a/AGENTS.md b/AGENTS.md
index e2bfc0d89..ea069424f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -8,7 +8,7 @@ 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 legacy v2.x build guide (`docs/legacy/BUILD_GUIDE_v2.md`), the manifesto, investigations, and media (build-guide images in `docs/build-guide/`). 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), `AUDIO_GUIDE.md` (sound into the panel). The live roadmap is patternflow.work/journal and the `/roadmap` page.
+- `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 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/`). 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/`. `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.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 3cf1200ae..157ffb339 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,6 +1,6 @@
# 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
diff --git a/README.md b/README.md
index 33d8b887a..31b6f71fe 100644
--- a/README.md
+++ b/README.md
@@ -34,7 +34,7 @@ Started by media artist SeungHun Lee ([@engmung](https://github.com/engmung)) in
- **[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](AUDIO_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
@@ -151,7 +151,7 @@ The default firmware is the instrument and nothing else, which leaves the most m
| :--- | :--- | :--- | :--- |
| **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](AUDIO_GUIDE.md) · [extension](tools/patternflow-audio-extension) |
+| **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) |
@@ -238,9 +238,9 @@ Folder by folder, and what you want to do → where to start: **[docs/REPOSITORY
**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](AUDIO_GUIDE.md) · [MIDI in Ableton](docs/midi-ableton.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](SUPPORT.md) · [Repository map](docs/REPOSITORY.md) · [Changelog](CHANGELOG.md) · [License Summary](docs/LICENSE-SUMMARY.md) · [Manifesto](docs/manifesto.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
@@ -266,7 +266,7 @@ Folder by folder, and what you want to do → where to start: **[docs/REPOSITORY
## 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. Questions and ideas go to the **[Discord](https://discord.gg/Vr9QtsxeTk)**, and **[SUPPORT.md](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).
+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
diff --git a/AUDIO_GUIDE.md b/docs/AUDIO_GUIDE.md
similarity index 90%
rename from AUDIO_GUIDE.md
rename to docs/AUDIO_GUIDE.md
index a6e8536e4..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.
---
@@ -28,16 +28,16 @@ Any of these puts the same firmware on your panel:
→ 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
+ **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
@@ -47,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
@@ -87,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
@@ -187,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
@@ -195,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.
@@ -206,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
@@ -223,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
@@ -264,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 |
| --- | --- |
@@ -277,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.
@@ -319,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
@@ -335,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
@@ -350,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;
@@ -364,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/README.md b/docs/README.md
index 194891f8e..2e19b448e 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -1,6 +1,6 @@
# 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`, `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: **a guide somebody follows start to finish lives at the repository root** (`BUILD_GUIDE.md`, `PATTERN_GUIDE.md`, `FEATURE_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).
## Contracts — other software is built against these, not against the firmware source
@@ -19,6 +19,7 @@ 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
- [`../firmware/CUSTOM_PATTERNS.md`](../firmware/CUSTOM_PATTERNS.md) — the hands-on pattern route (the everyday one is [`PATTERN_GUIDE.md`](../PATTERN_GUIDE.md))
@@ -34,12 +35,12 @@ What is here, and what is not. The rule for the split: **a guide somebody follow
## 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 guides in the root, and every step has a **Stuck here?** link that opens an issue with that step filled in.
+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
## Running the project
diff --git a/docs/REPOSITORY.md b/docs/REPOSITORY.md
index ca5adb6f9..6071a2b16 100644
--- a/docs/REPOSITORY.md
+++ b/docs/REPOSITORY.md
@@ -9,11 +9,11 @@ A map for people. (AI coding agents get the same map with more rules in [`../AGE
| `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) |
-| `docs/` | Contracts other software is built against, how the firmware is put together, walk-throughs, the assembly map, the legacy v2.x build guide (`legacy/`), project records and media | [`README.md`](README.md) |
+| `docs/` | Contracts other software is built against, how the firmware is put together, walk-throughs, the audio guide (`AUDIO_GUIDE.md`), the assembly map, the legacy v2.x build guide (`legacy/`), 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, the official one and every remix on its case switch | [`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 (including the guide's **Stuck here?** form and **Share your build**), the CI workflows (web, every firmware composition, the core-names-no-feature checks, doc links, 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 four guides (`BUILD_GUIDE.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, 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.**
@@ -26,7 +26,7 @@ The rule for where a document lives: **a guide somebody follows start to finish
| 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) |
+| 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) |
@@ -43,8 +43,8 @@ Filenames are lowercase with underscores. Tags are `vX.Y.Z`; the firmware source
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`:
-- `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), `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)
+- `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`
+- `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`, `firmware/README.md`, `hardware/bom/bom_v3.9.csv`
- `web/src/app/compliance/` — the `/compliance` URL is printed on the box. Never move, redirect or noindex it.
diff --git a/firmware/README.md b/firmware/README.md
index c7c290141..5f99ca1fd 100644
--- a/firmware/README.md
+++ b/firmware/README.md
@@ -945,7 +945,7 @@ 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.
diff --git a/tools/patternflow-audio-extension/README.md b/tools/patternflow-audio-extension/README.md
index 664875e04..ebcd31686 100644
--- a/tools/patternflow-audio-extension/README.md
+++ b/tools/patternflow-audio-extension/README.md
@@ -4,7 +4,7 @@ Captures the audio of a Chrome or Edge tab, splits it into four frequency
bands, and sends each band to one of the panel's four knobs over your local
network. The panel has to run the **Audio edition**
([patternflow.work/editions](https://patternflow.work/editions)).
-[`AUDIO_GUIDE.md`](../../AUDIO_GUIDE.md) is the guide to everything around it;
+[`AUDIO_GUIDE.md`](../../docs/AUDIO_GUIDE.md) is the guide to everything around it;
the wire protocol is a written contract,
[`docs/audio-ws-spec.md`](../../docs/audio-ws-spec.md).
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/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" },
],
From fb502f7e24b87322f1a32853d5487d6909bd02a5 Mon Sep 17 00:00:00 2001
From: Patternflow Bot
Date: Thu, 15 Oct 2026 08:11:54 +0900
Subject: [PATCH 08/82] docs: cleanup step 2 is planned
From Patternflow-lab/Patternflow#19
---
.../2026-10-cleanup-step2-plan.md | 279 ++++++++++++++++++
docs/rfc-cleanup-progress.md | 2 +-
docs/rfc-cleanup.md | 28 +-
3 files changed, 303 insertions(+), 6 deletions(-)
create mode 100644 docs/investigations/2026-10-cleanup-step2-plan.md
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/rfc-cleanup-progress.md b/docs/rfc-cleanup-progress.md
index de9db7ac9..5fbeacf1b 100644
--- a/docs/rfc-cleanup-progress.md
+++ b/docs/rfc-cleanup-progress.md
@@ -11,7 +11,7 @@ record.
| --- | --- | --- |
| 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 | planned ([three lanes](investigations/2026-10-cleanup-step1-plan.md)) |
-| 2 | Drift checks: links, BOM triad, features, editions, presets, roadmap | — |
+| 2 | Drift checks: links, BOM triad, features, editions, presets, roadmap, changelog; the human-text lock and the voice lint | planned ([fourteen PRs](investigations/2026-10-cleanup-step2-plan.md)) |
| 3 | Code hygiene: lint/typecheck zero, legacy named or gone, HUB75 upstream patch, shared constants | — |
| 4 | Test spine: contract vectors ×5, virtual panel | — |
| 5 | RFC drafts: any-resolution, Linux player | — |
diff --git a/docs/rfc-cleanup.md b/docs/rfc-cleanup.md
index 95226670c..a147c62bb 100644
--- a/docs/rfc-cleanup.md
+++ b/docs/rfc-cleanup.md
@@ -276,8 +276,7 @@ Append to `AGENTS.md`:
> 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` holds verbatim excerpts the maintainer
-> chose. Read it before writing anything longer than a sentence. Match its
+> 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,
@@ -318,9 +317,8 @@ Append to `AGENTS.md`:
> `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 the maintainer: three to five paragraphs of
-his own, copied as they are, from wherever he likes. Agents do not pick
-them. `scripts/voice-lint.ts` (`check:voice`) reads the banned list from
+`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.
@@ -366,3 +364,23 @@ draft to proposed — when all of these hold at HEAD:
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.
From e877cf5ac4b50adb97ea12d2c3964e8273007a95 Mon Sep 17 00:00:00 2001
From: Patternflow Bot
Date: Thu, 15 Oct 2026 08:34:40 +0900
Subject: [PATCH 09/82] docs: cleanup step 3 is planned
From Patternflow-lab/Patternflow#20
---
.../2026-10-cleanup-step3-plan.md | 295 ++++++++++++++++++
docs/rfc-cleanup-progress.md | 2 +-
2 files changed, 296 insertions(+), 1 deletion(-)
create mode 100644 docs/investigations/2026-10-cleanup-step3-plan.md
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/rfc-cleanup-progress.md b/docs/rfc-cleanup-progress.md
index 5fbeacf1b..3d4abac31 100644
--- a/docs/rfc-cleanup-progress.md
+++ b/docs/rfc-cleanup-progress.md
@@ -12,7 +12,7 @@ record.
| 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 | planned ([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 | planned ([fourteen PRs](investigations/2026-10-cleanup-step2-plan.md)) |
-| 3 | Code hygiene: lint/typecheck zero, legacy named or gone, HUB75 upstream patch, shared constants | — |
+| 3 | Code hygiene: lint/typecheck zero, legacy named or gone, HUB75 upstream patch, shared constants | planned ([twenty-seven PRs](investigations/2026-10-cleanup-step3-plan.md)) |
| 4 | Test spine: contract vectors ×5, virtual panel | — |
| 5 | RFC drafts: any-resolution, Linux player | — |
From 2d1029dd6ac3eecb5cae50e8760e394da5a8b6ca Mon Sep 17 00:00:00 2001
From: Patternflow Bot
Date: Thu, 15 Oct 2026 08:46:18 +0900
Subject: [PATCH 10/82] docs: the project's writing voice is written down
From Patternflow-lab/Patternflow#18
---
docs/voice-banned.txt | 34 ++++++++++++++
docs/voice.md | 105 ++++++++++++++++++++++++++++++++++++++++++
2 files changed, 139 insertions(+)
create mode 100644 docs/voice-banned.txt
create mode 100644 docs/voice.md
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.
From d91857dde910942c3372081f45eb193f304795df Mon Sep 17 00:00:00 2001
From: Patternflow Bot
Date: Thu, 15 Oct 2026 09:05:31 +0900
Subject: [PATCH 11/82] docs: cleanup step 4 is planned
From Patternflow-lab/Patternflow#21
---
.../2026-10-cleanup-step4-plan.md | 295 ++++++++++++++++++
docs/rfc-cleanup-progress.md | 2 +-
2 files changed, 296 insertions(+), 1 deletion(-)
create mode 100644 docs/investigations/2026-10-cleanup-step4-plan.md
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/rfc-cleanup-progress.md b/docs/rfc-cleanup-progress.md
index 3d4abac31..a4c576bbb 100644
--- a/docs/rfc-cleanup-progress.md
+++ b/docs/rfc-cleanup-progress.md
@@ -13,7 +13,7 @@ record.
| 1 | Document structure: root, legacy guide, CHANGELOG split, ko/en parity list | planned ([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 | planned ([fourteen PRs](investigations/2026-10-cleanup-step2-plan.md)) |
| 3 | Code hygiene: lint/typecheck zero, legacy named or gone, HUB75 upstream patch, shared constants | planned ([twenty-seven PRs](investigations/2026-10-cleanup-step3-plan.md)) |
-| 4 | Test spine: contract vectors ×5, virtual panel | — |
+| 4 | Test spine: contract vectors ×5, virtual panel | planned ([twenty-four PRs](investigations/2026-10-cleanup-step4-plan.md)) |
| 5 | RFC drafts: any-resolution, Linux player | — |
Gate for any-resolution (§2.12): not met.
From 338bd35708b90f5ca63281202cce34314e58ce2f Mon Sep 17 00:00:00 2001
From: Patternflow Bot
Date: Thu, 15 Oct 2026 09:14:51 +0900
Subject: [PATCH 12/82] docs: the docs index and the repository map catch up
with step 1 of the
From Patternflow-lab/Patternflow#23
---
docs/README.md | 7 +-
docs/REPOSITORY.md | 4 +-
docs/investigations/2026-10-doc-map.csv | 184 +++++++++++++++---------
3 files changed, 123 insertions(+), 72 deletions(-)
diff --git a/docs/README.md b/docs/README.md
index 2e19b448e..2858bb91f 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -1,6 +1,6 @@
# 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`, `PATTERN_GUIDE.md`, `FEATURE_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
@@ -42,6 +42,7 @@ The interactive guide at [patternflow.work/guide](https://patternflow.work/guide
- [`../CONTRIBUTING.md`](../CONTRIBUTING.md) — one table: what you have → where it goes → what CI runs on it
- [`../.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
@@ -50,7 +51,7 @@ The interactive guide at [patternflow.work/guide](https://patternflow.work/guide
## 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
+- [`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/REPOSITORY.md b/docs/REPOSITORY.md
index 6071a2b16..ea4963b7b 100644
--- a/docs/REPOSITORY.md
+++ b/docs/REPOSITORY.md
@@ -9,13 +9,13 @@ A map for people. (AI coding agents get the same map with more rules in [`../AGE
| `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) |
-| `docs/` | Contracts other software is built against, how the firmware is put together, walk-throughs, the audio guide (`AUDIO_GUIDE.md`), the assembly map, the legacy v2.x build guide (`legacy/`), project records and media | [`README.md`](README.md) |
+| `docs/` | Contracts other software is built against, 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 | [`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 (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, 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
diff --git a/docs/investigations/2026-10-doc-map.csv b/docs/investigations/2026-10-doc-map.csv
index 9b1f35940..9fe378789 100644
--- a/docs/investigations/2026-10-doc-map.csv
+++ b/docs/investigations/2026-10-doc-map.csv
@@ -1,36 +1,27 @@
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/config.yml,SUPPORT.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
-AUDIO_GUIDE.md,/editions,site,,yes
-AUDIO_GUIDE.md,/guide/audio,site,,yes
-AUDIO_GUIDE.md,AGENTS.md,relative,hard-rules-do-not-violate,yes
-AUDIO_GUIDE.md,BUILD_GUIDE.md,relative,8-firmware,yes
-AUDIO_GUIDE.md,CONTRIBUTING.md,relative,,yes
-AUDIO_GUIDE.md,FEATURE_GUIDE.md,relative,,yes
-AUDIO_GUIDE.md,docs/audio-ws-spec.md,relative,,yes
-AUDIO_GUIDE.md,docs/director-midi.md,relative,,yes
-AUDIO_GUIDE.md,docs/images/audio-guide/pdm-mic-wiring.jpg,relative,,yes
-AUDIO_GUIDE.md,docs/midi-ableton.md,relative,,yes
-AUDIO_GUIDE.md,docs/midi-spec.md,relative,,yes
-AUDIO_GUIDE.md,docs/osc-spec.md,relative,,yes
-AUDIO_GUIDE.md,docs/rest-api.md,relative,,yes
-AUDIO_GUIDE.md,docs/rest-api.md,relative,microphone-audio-edition,yes
-AUDIO_GUIDE.md,firmware/patternflow/console/README.md,relative,the-page-that-is-assembled-audio-in,yes
-AUDIO_GUIDE.md,firmware/patternflow/features/audio,relative,,yes
-AUDIO_GUIDE.md,firmware/patternflow/features/audio_in,relative,,yes
-AUDIO_GUIDE.md,firmware/patternflow/features/midi,relative,,yes
-AUDIO_GUIDE.md,integrations/ableton,relative,,yes
-AUDIO_GUIDE.md,tools/patternflow-audio-android,relative,,yes
-AUDIO_GUIDE.md,tools/patternflow-audio-android/README.md,relative,,yes
-AUDIO_GUIDE.md,tools/patternflow-audio-extension,relative,,yes
-AUDIO_GUIDE.md,tools/patternflow-audio-extension/README.md,relative,,yes
BUILD_GUIDE.md,/,site,,yes
BUILD_GUIDE.md,/editions,site,,yes
BUILD_GUIDE.md,/guide/build,site,,yes
@@ -66,28 +57,44 @@ 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,/panel-tuner.html,site,,yes
-CHANGELOG.md,/roadmap,site,,yes
-CHANGELOG.md,/update,site,,yes
-CHANGELOG.md,/variants,site,,yes
-CHANGELOG.md,BUILD_GUIDE.md,relative,,yes
-CHANGELOG.md,docs/investigations/2026-08-core2-heap-and-the-5s-stall.md,relative,,yes
-CHANGELOG.md,docs/investigations/2026-08-the-panel-clock-and-the-wifi-radio.md,relative,,yes
-CHANGELOG.md,docs/investigations/2026-09-firmware-runtime.md,relative,,yes
-CHANGELOG.md,docs/panel-compatibility.md,relative,,yes
-CHANGELOG.md,docs/pfst-v2-spec.md,relative,,yes
-CODE_OF_CONDUCT.md,SECURITY.md,relative,,yes
-CODE_OF_CONDUCT.md,docs/manifesto.md,relative,6-how-we-treat-each-other,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,CODE_OF_CONDUCT.md,relative,,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,SUPPORT.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
@@ -142,6 +149,7 @@ 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
@@ -159,7 +167,6 @@ README.md,/inside,site,,yes
README.md,/journal,site,,yes
README.md,/pattern,site,,yes
README.md,/pattern-lab,site,,yes
-README.md,AUDIO_GUIDE.md,relative,,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
@@ -172,7 +179,7 @@ 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,SUPPORT.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
@@ -210,18 +217,29 @@ 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
-SECURITY.md,/editions,site,,yes
-SECURITY.md,/pattern,site,,yes
-SECURITY.md,/update,site,,yes
-SECURITY.md,docs/rest-api.md,relative,transport,yes
-SUPPORT.md,/guide,site,,yes
-SUPPORT.md,/guide/play,site,flash,yes
-SUPPORT.md,/inside,site,,yes
-SUPPORT.md,/pattern,site,,yes
-SUPPORT.md,/pattern-lab,site,,yes
-SUPPORT.md,BUILD_GUIDE.md,relative,8-firmware,yes
-SUPPORT.md,CONTRIBUTING.md,relative,,yes
-SUPPORT.md,SECURITY.md,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
@@ -238,6 +256,7 @@ 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
@@ -248,7 +267,7 @@ 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,SUPPORT.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
@@ -274,6 +293,8 @@ docs/README.md,docs/releases,relative,,yes
docs/README.md,docs/rest-api.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/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
@@ -284,13 +305,13 @@ 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,AUDIO_GUIDE.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
@@ -338,6 +359,10 @@ docs/investigations/2026-10-cleanup-baseline.md,docs/rfc-cleanup-progress.md,rel
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/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
@@ -398,17 +423,32 @@ 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.md,/,site,,yes
-docs/releases/v3.0.0.md,BUILD_GUIDE.md,absolute-blob,2-power-input--use-the-screw-terminal,yes
-docs/releases/v3.0.0.md,v3.0.0:BUILD_GUIDE.md,absolute-blob,,yes
-docs/releases/v3.0.0.md,v3.0.0:BUILD_GUIDE_v2.md,absolute-blob,,yes
-docs/releases/v3.0.0.md,v3.0.0:CHANGELOG.md,absolute-blob,,yes
-docs/releases/v3.0.0.md,v3.0.0:hardware/bom/bom_v3.0.csv,absolute-blob,,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.1.0.md,v3.1.0:BUILD_GUIDE.md,absolute-blob,,yes
-docs/releases/v3.1.0.md,v3.1.0:BUILD_GUIDE.md,absolute-blob,2-power-input--use-the-screw-terminal,yes
-docs/releases/v3.1.0.md,v3.1.0:CHANGELOG.md,absolute-blob,,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.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
@@ -416,6 +456,9 @@ docs/rest-api.md,docs/midi-spec.md,relative,,yes
docs/rest-api.md,docs/mqtt-spec.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-doc-map.csv,relative,,yes
docs/rfc-cleanup-progress.md,docs/rfc-cleanup.md,relative,,yes
docs/rfc-core-and-variants-progress.md,docs/EDITIONS.md,relative,,yes
@@ -428,6 +471,12 @@ 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/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
@@ -435,9 +484,9 @@ firmware/CUSTOM_PATTERNS.md,firmware/README.md,relative,required-board-package,y
firmware/README.md,/editions,site,,yes
firmware/README.md,/guide/play,site,flash,yes
firmware/README.md,/update,site,,yes
-firmware/README.md,AUDIO_GUIDE.md,relative,,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
@@ -686,7 +735,7 @@ tools/README.md,tools/rtpmidi-probe/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,AUDIO_GUIDE.md,relative,,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
@@ -845,8 +894,8 @@ web/src/app/editions/editions-data.ts,firmware/bundles/performance,absolute-blob
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,AUDIO_GUIDE.md,absolute-blob,,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
@@ -907,6 +956,7 @@ 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
@@ -950,7 +1000,7 @@ 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,AUDIO_GUIDE.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
From 58827ffeb4178e4ef38badfc3084181874ea655b Mon Sep 17 00:00:00 2001
From: Patternflow Bot
Date: Thu, 15 Oct 2026 09:23:02 +0900
Subject: [PATCH 13/82] docs: cleanup step 5 is planned
From Patternflow-lab/Patternflow#24
---
.../2026-10-cleanup-step5-plan.md | 289 ++++++++++++++++++
docs/rfc-cleanup-progress.md | 2 +-
2 files changed, 290 insertions(+), 1 deletion(-)
create mode 100644 docs/investigations/2026-10-cleanup-step5-plan.md
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/rfc-cleanup-progress.md b/docs/rfc-cleanup-progress.md
index a4c576bbb..854ac76fa 100644
--- a/docs/rfc-cleanup-progress.md
+++ b/docs/rfc-cleanup-progress.md
@@ -14,7 +14,7 @@ record.
| 2 | Drift checks: links, BOM triad, features, editions, presets, roadmap, changelog; the human-text lock and the voice lint | planned ([fourteen PRs](investigations/2026-10-cleanup-step2-plan.md)) |
| 3 | Code hygiene: lint/typecheck zero, legacy named or gone, HUB75 upstream patch, shared constants | planned ([twenty-seven PRs](investigations/2026-10-cleanup-step3-plan.md)) |
| 4 | Test spine: contract vectors ×5, virtual panel | planned ([twenty-four PRs](investigations/2026-10-cleanup-step4-plan.md)) |
-| 5 | RFC drafts: any-resolution, Linux player | — |
+| 5 | RFC drafts: any-resolution, Linux player | planned ([four PRs](investigations/2026-10-cleanup-step5-plan.md)) |
Gate for any-resolution (§2.12): not met.
From c46ffb1cf9d1d24451f287936426801495e4cfd2 Mon Sep 17 00:00:00 2001
From: Patternflow Bot
Date: Thu, 15 Oct 2026 09:31:25 +0900
Subject: [PATCH 14/82] web: `check:features` fails when /features and the
feature folders
From Patternflow-lab/Patternflow#25
---
web/package.json | 15 ++++++-
web/scripts/drift.ts | 39 +++++++++++++++++++
web/scripts/drift/_lib.ts | 73 +++++++++++++++++++++++++++++++++++
web/scripts/drift/features.ts | 29 ++++++++++++++
4 files changed, 154 insertions(+), 2 deletions(-)
create mode 100644 web/scripts/drift.ts
create mode 100644 web/scripts/drift/_lib.ts
create mode 100644 web/scripts/drift/features.ts
diff --git a/web/package.json b/web/package.json
index a462f844d..ec4bc6b2a 100644
--- a/web/package.json
+++ b/web/package.json
@@ -39,10 +39,21 @@
"check:pfstvectors": "tsx scripts/pfst-v2-vectors-smoke.ts",
"check:ramp": "tsx scripts/ramp-oklab-smoke.ts",
"check:panels": "vitest run",
- "check:ci": "npm run check:license && npm run check:moderation && npm run check:headermod && npm run check:hidemod && npm run check:retention && npm run check:deck && npm run check:pack && npm run check:sharing && npm run check:publish && npm run check:notify && npm run check:ports && npm run check:workshop && npm run check:sandbox && npm run check:capture && npm run check:thumbs && npm run check:flatten && npm run check:labedit && npm run check:performance && npm run check:pixelcode && npm run check:director && npm run check:pfstvectors && npm run check:ramp && npm run check:panels && npm run check:sandbox-sync && npm run check:modcache",
+ "check:ci": "npm run check:license && npm run check:moderation && npm run check:headermod && npm run check:hidemod && npm run check:retention && npm run check:deck && npm run check:pack && npm run check:sharing && npm run check:publish && npm run check:notify && npm run check:ports && npm run check:workshop && npm run check:sandbox && npm run check:capture && npm run check:thumbs && npm run check:flatten && npm run check:labedit && npm run check:performance && npm run check:pixelcode && npm run check:director && npm run check:pfstvectors && npm run check:ramp && npm run check:panels && npm run check:sandbox-sync && npm run check:modcache && npm run check:drift",
"build:sandbox": "tsx scripts/build-sandbox.ts",
"check:sandbox-sync": "tsx scripts/build-sandbox.ts --check",
- "doc-map": "tsx scripts/doc-map.ts"
+ "doc-map": "tsx scripts/doc-map.ts",
+ "check:drift": "tsx scripts/drift.ts",
+ "check:links-absolute": "tsx scripts/drift.ts links-absolute",
+ "check:bom": "tsx scripts/drift.ts bom",
+ "check:bom-schematic": "tsx scripts/drift.ts bom-schematic",
+ "check:features": "tsx scripts/drift.ts features",
+ "check:editions": "tsx scripts/drift.ts editions",
+ "check:presets": "tsx scripts/drift.ts presets",
+ "check:roadmap": "tsx scripts/drift.ts roadmap",
+ "check:changelog": "tsx scripts/drift.ts changelog",
+ "check:human-lock": "tsx scripts/human-lock.ts",
+ "check:voice": "tsx scripts/voice-lint.ts"
},
"dependencies": {
"@google/genai": "^2.9.0",
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/_lib.ts b/web/scripts/drift/_lib.ts
new file mode 100644
index 000000000..455975753
--- /dev/null
+++ b/web/scripts/drift/_lib.ts
@@ -0,0 +1,73 @@
+/**
+ * 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 and passes. 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; passes, or fails 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/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();
From e521f5405a2ec1f9a1590c7ab743b02728658872 Mon Sep 17 00:00:00 2001
From: Patternflow Bot
Date: Thu, 15 Oct 2026 09:37:22 +0900
Subject: [PATCH 15/82] web: `check:links-absolute` fails on a GitHub link into
the repository
From Patternflow-lab/Patternflow#26
---
web/scripts/doc-map.ts | 58 +++++++++++++++++++++++++++--
web/scripts/drift/links-absolute.ts | 15 ++++++++
2 files changed, 70 insertions(+), 3 deletions(-)
create mode 100644 web/scripts/drift/links-absolute.ts
diff --git a/web/scripts/doc-map.ts b/web/scripts/doc-map.ts
index d4dbae1c5..ff718c7dc 100644
--- a/web/scripts/doc-map.ts
+++ b/web/scripts/doc-map.ts
@@ -30,6 +30,13 @@
* 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. 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:
@@ -61,6 +68,11 @@ 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"] });
@@ -132,7 +144,8 @@ for (const f of tracked.filter((f) => /^firmware\/patternflow\/.*\.(h|ino|cpp)$/
// ── Reading links out of a file ──
-type Row = { from: string; to: string; kind: string; anchor: string; resolves: "yes" | "no" };
+// 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;
@@ -147,7 +160,7 @@ function classify(from: string, raw: string, markdown: boolean): Row | null {
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);
+ 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))));
@@ -208,13 +221,52 @@ 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 : 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(` ${r.from} → ${r.to}${r.anchor ? "#" + r.anchor : ""} (${r.kind})`);
+for (const r of broken) console.log(show(r));
// ── The parity list ──
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);
From 008fb81ea0bd6608c0ebdc550e8a5a9e6c42906a Mon Sep 17 00:00:00 2001
From: Patternflow Bot
Date: Thu, 15 Oct 2026 09:46:00 +0900
Subject: [PATCH 16/82] hardware: `check:bom` and `check:bom-schematic` hold
the BOM to the
From Patternflow-lab/Patternflow#28
---
web/scripts/drift/_bom.ts | 56 +++++++++++++++++++++
web/scripts/drift/bom-schematic.ts | 37 ++++++++++++++
web/scripts/drift/bom.ts | 78 ++++++++++++++++++++++++++++++
3 files changed, 171 insertions(+)
create mode 100644 web/scripts/drift/_bom.ts
create mode 100644 web/scripts/drift/bom-schematic.ts
create mode 100644 web/scripts/drift/bom.ts
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/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();
From 2848040347f78a569a763ce52dc4b86490bc2060 Mon Sep 17 00:00:00 2001
From: Patternflow Bot
Date: Thu, 15 Oct 2026 09:52:07 +0900
Subject: [PATCH 17/82] web: `check:editions` fails when the shelf and the
bundles disagree
From Patternflow-lab/Patternflow#29
---
web/scripts/drift/editions.ts | 82 +++++++++++++++++++++++++++++++++++
1 file changed, 82 insertions(+)
create mode 100644 web/scripts/drift/editions.ts
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();
From 383ab393877b6cbd8c234730854ac26ea741dac7 Mon Sep 17 00:00:00 2001
From: Patternflow Bot
Date: Thu, 15 Oct 2026 10:02:58 +0900
Subject: [PATCH 18/82] web: `check:presets` fails when a web preset cannot
reach a panel
From Patternflow-lab/Patternflow#30
---
web/scripts/drift/presets.ts | 69 ++++++++++++++++++++++++++++++++++++
1 file changed, 69 insertions(+)
create mode 100644 web/scripts/drift/presets.ts
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();
From 2b2c084f247ba7aee0c53a7b45cc61bc01b6ce8e Mon Sep 17 00:00:00 2001
From: Patternflow Bot
Date: Thu, 15 Oct 2026 10:03:00 +0900
Subject: [PATCH 19/82] web: `check:human-lock` fails a pull request that
changes a word of the
From Patternflow-lab/Patternflow#27
---
docs/.human-written | 1 +
web/scripts/human-lock.ts | 245 +++++++++++++++++++++++++++++++++++++
web/scripts/lib/pr-base.ts | 47 +++++++
3 files changed, 293 insertions(+)
create mode 100644 docs/.human-written
create mode 100644 web/scripts/human-lock.ts
create mode 100644 web/scripts/lib/pr-base.ts
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/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/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);
From c10db85a32cc7902317e22e434714b815620cf32 Mon Sep 17 00:00:00 2001
From: Patternflow Bot
Date: Thu, 15 Oct 2026 10:10:31 +0900
Subject: [PATCH 20/82] web: `check:changelog` fails when `CHANGELOG.md` and
`docs/releases/`
From Patternflow-lab/Patternflow#31
---
web/scripts/drift/changelog.ts | 92 ++++++++++++++++++++++++++++++++++
1 file changed, 92 insertions(+)
create mode 100644 web/scripts/drift/changelog.ts
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();
From 4a5599b6d0eebd25b745f053c8910210026e3715 Mon Sep 17 00:00:00 2001
From: Patternflow Bot
Date: Thu, 15 Oct 2026 10:16:48 +0900
Subject: [PATCH 21/82] web: every pull request runs the drift checks and the
text lock
From Patternflow-lab/Patternflow#32
---
.github/workflows/drift.yml | 70 +++++++++++++++++++++++++++++++++++++
1 file changed, 70 insertions(+)
create mode 100644 .github/workflows/drift.yml
diff --git a/.github/workflows/drift.yml b/.github/workflows/drift.yml
new file mode 100644
index 000000000..afff5d8d2
--- /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: 20
+ 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 }}
From 85d261bb82beee5d6a9485895adec98bdc172be4 Mon Sep 17 00:00:00 2001
From: Patternflow Bot
Date: Thu, 15 Oct 2026 10:34:55 +0900
Subject: [PATCH 22/82] web: `check:roadmap` fails when a /roadmap node and its
issues disagree
From Patternflow-lab/Patternflow#34
---
web/scripts/drift/roadmap.ts | 78 ++++++++++++++++++++++++++++++++++++
1 file changed, 78 insertions(+)
create mode 100644 web/scripts/drift/roadmap.ts
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();
From 01b262162ac635c69286833759929337f62183c7 Mon Sep 17 00:00:00 2001
From: Patternflow Bot
Date: Thu, 15 Oct 2026 10:46:30 +0900
Subject: [PATCH 23/82] docs: the contributor docs say what CI runs on every
pull request
From Patternflow-lab/Patternflow#35
---
CONTRIBUTING.md | 4 +++-
docs/REPOSITORY.md | 6 +++---
web/ARCHITECTURE.md | 4 ++--
web/README.md | 14 ++++++++------
4 files changed, 16 insertions(+), 12 deletions(-)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 157ffb339..243adfbba 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -15,11 +15,13 @@ Patternflow is a device, a firmware, a website and a pile of documentation, and
| 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` |
+| 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/…`).
diff --git a/docs/REPOSITORY.md b/docs/REPOSITORY.md
index ea4963b7b..fa6dfd3e8 100644
--- a/docs/REPOSITORY.md
+++ b/docs/REPOSITORY.md
@@ -12,7 +12,7 @@ A map for people. (AI coding agents get the same map with more rules in [`../AGE
| `docs/` | Contracts other software is built against, 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 | [`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 (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, community folders carrying their README, console-page sync, firmware release assets, Discord dev-log) and their helper scripts | the header comment of each workflow |
+| `.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: **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.**
@@ -41,11 +41,11 @@ 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` (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`
- `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`, `firmware/README.md`, `hardware/bom/bom_v3.9.csv`
- `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.
+`.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/web/ARCHITECTURE.md b/web/ARCHITECTURE.md
index 0668203d4..abd5a30e8 100644
--- a/web/ARCHITECTURE.md
+++ b/web/ARCHITECTURE.md
@@ -118,6 +118,6 @@ PostHog (`src/providers/PostHogProvider.tsx`, event helpers in `src/lib/posthogE
- Styling: Tailwind v4 (`@import "tailwindcss"` in `globals.css`) + global custom CSS; larger components use CSS Modules (`*.module.css`).
- **Layering:** `lib/` is the bottom. `app/` and `components/` import from it; it never imports from them (ESLint enforces it). A type a component and a serializer share belongs in `lib/` — `lib/community/cardTypes.ts` is the precedent.
- Adding a preset: add the JS file under `src/lib/presets/`, register it in `index.ts`, then generate the firmware `.h` with the Pattern Lab's **To hardware** → *Copy the conversion prompt* flow.
-- Tests are the `check:*` scripts in `package.json` — bespoke smoke suites under `scripts/*-smoke.ts`, one per subsystem — plus `check:panels`, a Vitest + jsdom + Testing Library harness (`vitest.config.ts`, `test/setup.ts`) for the Pattern Lab panels, where pointer gestures, keyboard shortcuts, undo and the Director's keyframes need a DOM. Those tests live beside the panels (`panels/*.test.tsx`) and read the store's buffers rather than the canvas, which jsdom does not have. `npm run check:ci` runs every one that needs nothing a CI runner lacks; `check:module` wants the Xtensa toolchain and stays local.
-- CI (`.github/workflows/web-ci.yml`) lints, typechecks (`npm run typecheck`), builds and runs `check:ci` on every PR touching `web/`.
+- Tests are the `check:*` scripts in `package.json` — bespoke smoke suites under `scripts/*-smoke.ts`, one per subsystem — plus `check:panels`, a Vitest + jsdom + Testing Library harness (`vitest.config.ts`, `test/setup.ts`) for the Pattern Lab panels, where pointer gestures, keyboard shortcuts, undo and the Director's keyframes need a DOM. Those tests live beside the panels (`panels/*.test.tsx`) and read the store's buffers rather than the canvas, which jsdom does not have. `npm run check:ci` runs every one that needs nothing a CI runner lacks; `check:module` wants the Xtensa toolchain and stays local. The drift checks are `check:*` scripts too, one file each in `scripts/drift/`. Each compares two things the tree keeps by hand, the BOM and the build guide's parts table for one, and `check:drift`, the last step of `check:ci`, runs them all.
+- CI (`.github/workflows/web-ci.yml`) lints, typechecks (`npm run typecheck`), builds and runs `check:ci` on every PR touching `web/`. `.github/workflows/drift.yml` runs `check:drift` on every PR, whatever it touches, and `check:human-lock` against the PR's base. It sets `PF_DRIFT_STRICT=1`: at a desk a check skips a side it cannot read (a tag the clone lacks, no `pdftotext`), and in CI that skip fails.
- **Deployment weight:** Vercel meters storage per *retained deployment* — every one counts at its full output size, static files and function bundles both, with nothing shared between deployments — so size × push count is what the Hobby allowance (10 GB each) is spent on. Only `main` deploys: `vercel.json` turns deployments off for `dev`, which was three quarters of them and whose previews nobody opened. `public/` is ~90 % of a deployment's static weight (journal and build-map photos). Server functions must carry none of it: `outputFileTracingExcludes` in `next.config.ts` keeps `public/` out of every function, and a build that prints "Dynamic filesystem access causes tracing of the whole project" has found a new way in — the comment there has the history.
diff --git a/web/README.md b/web/README.md
index 1fc908921..3ac0be4f6 100644
--- a/web/README.md
+++ b/web/README.md
@@ -14,11 +14,13 @@ npm run dev # http://localhost:3000
Other scripts:
```bash
-npm run build # production build — must pass before merging (CI hard gate)
-npm run lint # eslint
-npm run typecheck # tsc --noEmit
-npm run check:ci # every smoke suite CI runs (check:module wants the Xtensa toolchain and stays local)
-npm start # serve the production build
+npm run build # production build — must pass before merging (CI hard gate)
+npm run lint # eslint
+npm run typecheck # tsc --noEmit
+npm run check:ci # every smoke suite CI runs, then the drift checks (check:module wants the Xtensa toolchain and stays local)
+npm run check:drift # the drift checks alone; check:bom, check:editions and the others run one each
+npm run check:human-lock # fails if a word of the maintainer's own writing changed since the merge-base with origin/dev
+npm start # serve the production build
```
Requires Node 20+ (matches CI).
@@ -44,6 +46,6 @@ None are required — the site runs fully without them. The community host needs
## Contributing
-See the repo-level [CONTRIBUTING.md](../CONTRIBUTING.md). Web changes go through a PR into `main`; CI runs lint, typecheck, the `check:*` suites and `next build` on anything under `web/`.
+See the repo-level [CONTRIBUTING.md](../CONTRIBUTING.md). Web changes go through a PR into `main`; CI runs `lint`, `typecheck`, `check:ci` and `build` on anything under `web/`, and the drift checks and `check:human-lock` on every pull request.
Code in `web/` is MIT licensed ([LICENSE-MIT](../LICENSE-MIT)).
From d81870c1b70e900a8117d4974359e7a4ccbbd6ac Mon Sep 17 00:00:00 2001
From: Patternflow Bot
Date: Thu, 15 Oct 2026 10:52:06 +0900
Subject: [PATCH 24/82] web: `check:voice` points at the habits of generated
prose, line by line
From Patternflow-lab/Patternflow#33
---
web/scripts/voice-lint.ts | 271 ++++++++++++++++++++++++++++++++++++++
1 file changed, 271 insertions(+)
create mode 100644 web/scripts/voice-lint.ts
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;
+}
From d2d6e54473bc2b8d219086193e185026acdabcb7 Mon Sep 17 00:00:00 2001
From: Patternflow Bot
Date: Thu, 15 Oct 2026 11:26:05 +0900
Subject: [PATCH 25/82] docs: `AGENTS.md` carries the writing-voice rule and
the step 2 check
From Patternflow-lab/Patternflow#36
---
AGENTS.md | 32 +++++++++++++++++++++++++++++++-
1 file changed, 31 insertions(+), 1 deletion(-)
diff --git a/AGENTS.md b/AGENTS.md
index ea069424f..248332557 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -27,7 +27,10 @@ It is standalone (not Eurorack), powered from a 5 V power bank through a screw t
## 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.
+- 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` or `changelog`. `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.
+- 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).
@@ -43,3 +46,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.
From f7d005b52d3d94aa96a6fc9062341403195e5d93 Mon Sep 17 00:00:00 2001
From: Patternflow Bot
Date: Thu, 15 Oct 2026 11:40:53 +0900
Subject: [PATCH 26/82] docs: the doc map is regenerated at the close of step 2
of the cleanup
From Patternflow-lab/Patternflow#37
---
docs/investigations/2026-10-doc-map.csv | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/docs/investigations/2026-10-doc-map.csv b/docs/investigations/2026-10-doc-map.csv
index 9fe378789..230223726 100644
--- a/docs/investigations/2026-10-doc-map.csv
+++ b/docs/investigations/2026-10-doc-map.csv
@@ -105,6 +105,7 @@ 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,web/ARCHITECTURE.md,relative,,yes
@@ -363,6 +364,9 @@ docs/investigations/2026-10-cleanup-step2-plan.md,docs/rfc-cleanup.md,relative,,
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/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
@@ -459,6 +463,7 @@ docs/rfc-cleanup-progress.md,docs/investigations/2026-10-cleanup-step1-plan.md,r
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/rfc-cleanup.md,relative,,yes
docs/rfc-core-and-variants-progress.md,docs/EDITIONS.md,relative,,yes
@@ -877,6 +882,7 @@ 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
From 9aa69210074025d9397b5a76fddaaaa9dcdbb2e8 Mon Sep 17 00:00:00 2001
From: Patternflow Bot
Date: Thu, 15 Oct 2026 12:02:06 +0900
Subject: [PATCH 27/82] web: five of the site's twelve lint warnings are gone
From Patternflow-lab/Patternflow#38
---
web/scripts/performance-smoke.ts | 1 -
web/src/app/editions/EditionCard.tsx | 9 ---------
web/src/app/roadmap/RoadmapMap.tsx | 21 ++++----------------
web/src/components/community/PatternCard.tsx | 1 -
4 files changed, 4 insertions(+), 28 deletions(-)
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/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/roadmap/RoadmapMap.tsx b/web/src/app/roadmap/RoadmapMap.tsx
index cca6cee5f..065a5d024 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,
@@ -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) => {
dragRef.current = {
diff --git a/web/src/components/community/PatternCard.tsx b/web/src/components/community/PatternCard.tsx
index f797b5bdc..bcd5d8ff8 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 {
From 5e8a22368448fe9a2e8d217276113c24210205ec Mon Sep 17 00:00:00 2001
From: Patternflow Bot
Date: Thu, 15 Oct 2026 12:04:49 +0900
Subject: [PATCH 28/82] web: the seven `` lint warnings carry a named
exception
From Patternflow-lab/Patternflow#39
---
web/src/app/build/breadboard/page.tsx | 5 +++++
web/src/components/journal/JournalLightbox.tsx | 1 +
web/src/components/sections/Hero.tsx | 1 +
3 files changed, 7 insertions(+)
diff --git a/web/src/app/build/breadboard/page.tsx b/web/src/app/build/breadboard/page.tsx
index e28fce333..f5c2fc433 100644
--- a/web/src/app/build/breadboard/page.tsx
+++ b/web/src/app/build/breadboard/page.tsx
@@ -391,6 +391,7 @@ 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 */}
+ {/* eslint-disable-next-line @next/next/no-img-element -- a fixed build photo, shown as is */}
+ {/* eslint-disable-next-line @next/next/no-img-element -- a fixed build photo, shown as is */}
+ {/* eslint-disable-next-line @next/next/no-img-element -- a fixed build photo, shown as is */}
(
+ {/* eslint-disable-next-line @next/next/no-img-element -- a fixed build photo, shown as is */}
event.stopPropagation()}>
+ {/* eslint-disable-next-line @next/next/no-img-element -- whichever journal image was clicked, at the src it already has */}
{image.caption && {image.caption}}
diff --git a/web/src/components/sections/Hero.tsx b/web/src/components/sections/Hero.tsx
index b108a34a5..3dae1cf9a 100644
--- a/web/src/components/sections/Hero.tsx
+++ b/web/src/components/sections/Hero.tsx
@@ -52,6 +52,7 @@ export default function Hero() {
height: 0
}}>
{/* Static fallback and loading placeholder */}
+ {/* eslint-disable-next-line @next/next/no-img-element -- a fixed poster under the video, shown as is */}
Date: Thu, 15 Oct 2026 12:10:19 +0900
Subject: [PATCH 29/82] docs: where the panel size lives, line by line
From Patternflow-lab/Patternflow#40
---
docs/investigations/2026-10-panel-geometry.md | 254 ++++++++++++++++++
1 file changed, 254 insertions(+)
create mode 100644 docs/investigations/2026-10-panel-geometry.md
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`.
From 3f4903d4c20647e580ec9c1c89c9c5b5487c51f3 Mon Sep 17 00:00:00 2001
From: Patternflow Bot
Date: Thu, 15 Oct 2026 14:32:19 +0900
Subject: [PATCH 30/82] web: pattern Lab's July storage keys say who wrote them
and when
From Patternflow-lab/Patternflow#41
---
web/src/lib/lab/director/bake.ts | 6 ++++--
web/src/lib/lab/legacyDraft.ts | 28 ++++++++++++----------------
web/src/lib/lab/persist.ts | 28 +++++++++++++++++++++-------
web/src/lib/lab/serialize.ts | 20 ++++++++++++--------
web/src/lib/lab/store/project.ts | 4 +++-
5 files changed, 52 insertions(+), 34 deletions(-)
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/legacyDraft.ts b/web/src/lib/lab/legacyDraft.ts
index fbd71b394..446c29cc2 100644
--- a/web/src/lib/lab/legacyDraft.ts
+++ b/web/src/lib/lab/legacyDraft.ts
@@ -1,18 +1,19 @@
-// ── 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.
-//
-// 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:
+// ── Pattern Lab: the July draft, and the gallery ─────────────────────────────
+// The lab's autosave (e19fe19, 25 July 2026) stored the working session under
+// 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.
+// draft down with it, hence the split.
+//
+// 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 never moved, and loadGallery and saveGallery
+// are its live reader and writer. 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";
@@ -21,7 +22,7 @@ import { isValidMatrix, type MatrixSize } from "@/lib/pattern/matrix";
import { LAB_STORAGE, readJson, removeStorage as remove, writeJson } from "./persist";
export const DRAFT_STORAGE = LAB_STORAGE.legacyDraft;
-export const GALLERY_STORAGE = LAB_STORAGE.legacyGallery;
+export const GALLERY_STORAGE = LAB_STORAGE.gallery;
// Guard rails so one runaway value can't blow the whole quota.
const MAX_CODE_CHARS = 200_000;
@@ -141,11 +142,6 @@ export function loadDraft(knobCount: number): Partial | null {
};
}
-export function saveDraft(draft: Omit): 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);
diff --git a/web/src/lib/lab/persist.ts b/web/src/lib/lab/persist.ts
index 6be03e56a..3157fe486 100644
--- a/web/src/lib/lab/persist.ts
+++ b/web/src/lib/lab/persist.ts
@@ -6,9 +6,10 @@
// 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. The gallery was never migrated. It still lives under
+// its _v1 key.
export const LAB_STORAGE = {
/** The whole layered project — layers, knobs, ramps, director, gen settings. serialize.ts */
@@ -21,11 +22,24 @@ export const LAB_STORAGE = {
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 generated gallery. The lab's autosave has written it since 25 July
+ * 2026 (e19fe19) and still does, because the gallery never moved into the
+ * project. legacyDraft.ts
+ */
+ gallery: "patternflow_lab_gallery_v1",
+ /**
+ * 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/serialize.ts b/web/src/lib/lab/serialize.ts
index 555b21c25..b09dbd24f 100644
--- a/web/src/lib/lab/serialize.ts
+++ b/web/src/lib/lab/serialize.ts
@@ -4,9 +4,10 @@
// 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";
@@ -76,7 +77,7 @@ 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. */
+/** 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;
@@ -533,10 +534,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 +586,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/store/project.ts b/web/src/lib/lab/store/project.ts
index 2a4668893..d32f06a71 100644
--- a/web/src/lib/lab/store/project.ts
+++ b/web/src/lib/lab/store/project.ts
@@ -17,6 +17,8 @@ export function createProjectSlice(set: SliceSet, get: SliceGet): ProjectSlice {
return {
hydrate: () => {
if (get().hydrated) return;
+ // 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;
@@ -33,7 +35,7 @@ export function createProjectSlice(set: SliceSet, get: SliceGet): ProjectSlice {
discardProject: () => {
clearProject();
- clearDraft(); // also clears the legacy draft + gallery keys
+ clearDraft(); // the July draft's key, and the gallery's
set({
...defaultProject(),
restoredAt: null,
From 85989c10121bea459976abb64dc658eac5fbc78d Mon Sep 17 00:00:00 2001
From: Patternflow Bot
Date: Thu, 15 Oct 2026 14:32:20 +0900
Subject: [PATCH 31/82] web: the site's redirects and old routes say what links
them
From Patternflow-lab/Patternflow#42
---
web/next.config.ts | 15 +++++++++++++--
web/src/app/business/page.tsx | 4 ++++
web/src/app/community/layout.tsx | 13 ++++++++++---
web/src/lib/campaignRoutes.ts | 6 ++++++
web/src/proxy.ts | 5 +++++
5 files changed, 38 insertions(+), 5 deletions(-)
diff --git a/web/next.config.ts b/web/next.config.ts
index 56ad253ee..0fd9de6f4 100644
--- a/web/next.config.ts
+++ b/web/next.config.ts
@@ -79,8 +79,9 @@ const nextConfig: NextConfig = {
async redirects() {
return [
// The shelf was /variants until 2026-09. The word the docs settled on is
- // "edition" (docs/EDITIONS.md); the old URL is baked into every 3.8.0
- // console page and printed in release notes, so it stays forever.
+ // "edition" (docs/EDITIONS.md); the old URL is baked into every console
+ // page from 3.7.0 to 3.8.0 and printed in release notes, so it stays
+ // forever.
{ source: "/variants", destination: "/editions", permanent: true },
// There is no /flash page: the flasher is a button (the Pattern tab, the
// Play guide), and /flash only holds its manifest and images. But the
@@ -89,6 +90,16 @@ const nextConfig: NextConfig = {
// to the guide's Flash chapter, where the button and the steps are. Only
// the exact path: /flash/manifest.json and /flash/bin/* are untouched.
{ source: "/flash", destination: "/guide/play#flash", permanent: false },
+ // From the journal's first post on 29 April 2026 until 11 May, its own
+ // links asked for English with ?lang=en. English moved to /en paths that
+ // day (c44b0d7a), and these two send the old links there. The query goes
+ // along, and proxy.ts takes it off and sets the language cookie.
+ //
+ // The first one reads /journal/en?lang=en as a post called "en" and
+ // answers /journal/en/en, which proxy.ts collapses back to /journal/en.
+ // /journal/en?lang=en is where the second one sends /journal?lang=en,
+ // and the language switch on the Korean index has linked it since
+ // 17 May.
{
source: "/journal/:slug",
has: [{ type: "query", key: "lang", value: "en" }],
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..487b88ee5 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;
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/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);
From e009c14b92c01671c02af82301c4c27e09969ca2 Mon Sep 17 00:00:00 2001
From: Patternflow Bot
Date: Thu, 15 Oct 2026 14:32:21 +0900
Subject: [PATCH 32/82] web: web CI fails on a lint warning
From Patternflow-lab/Patternflow#43
---
.github/workflows/web-ci.yml | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/web-ci.yml b/.github/workflows/web-ci.yml
index 87717e892..628f67924 100644
--- a/.github/workflows/web-ci.yml
+++ b/.github/workflows/web-ci.yml
@@ -40,9 +40,10 @@ jobs:
- 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
From e3ee779e5ef08d3ee890a398663126e7ad5af085 Mon Sep 17 00:00:00 2001
From: Patternflow Bot
Date: Thu, 15 Oct 2026 14:47:15 +0900
Subject: [PATCH 33/82] firmware: the MIDI page counts 24 detents a turn, as
the encoder has
From Patternflow-lab/Patternflow#46
---
docs/midi-spec.md | 2 +-
firmware/patternflow/console/midi.html | 8 +-
.../patternflow/features/midi/core_midi.h | 4 +-
.../patternflow/features/midi/midi_index.h | 891 +++++++++---------
4 files changed, 453 insertions(+), 452 deletions(-)
diff --git a/docs/midi-spec.md b/docs/midi-spec.md
index 68e0124d7..dde8499a1 100644
--- a/docs/midi-spec.md
+++ b/docs/midi-spec.md
@@ -116,7 +116,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
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/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){