Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ It is standalone (not Eurorack), powered from a 5 V power bank through a screw t

## Versioning
- Project: v3.10.2 (current, released 2026-09-11), using unified semantic versioning across firmware, hardware, web, and docs — the firmware reports it as `PF_IMPROV_FW_VERSION` in `net_config.h`, and `CHANGELOG.md` gets a section per release. Current hardware is the v3.9 board — the v3.0 board with the USB-C footprint removed, same enclosure, pin map and guide; every v3.x release has been firmware/web on it.
- Editions carry their own version line, independent of the project version (`PF_VARIANT_VERSION` in `firmware/bundles/<name>/overrides.h`): Audio v0.6.2, Performance v0.2.7 at the time of writing. The shelf (`web/src/app/editions/editions-data.ts`) and the flasher manifest (`web/public/flash/manifest.json`) name the images that are live; only those images are kept in `web/public/flash/bin/` — older ones live on their release tags. `clock` and `midi` are bundles the tree keeps building (CI compiles every composition) but does not publish: the clock is a feature, and the MIDI bundle is the composition that proves the USB-OTG build. Neither is in the `EDITIONS` tuples of `release.py`/`check_versions.py`; an edition joins those, the cards and the line above only when it goes on the shelf, and its maintainer cuts it with `release.py edition <name> vA.B.C` (docs/RELEASING.md).
- Editions carry their own version line, independent of the project version (`PF_VARIANT_VERSION` in `firmware/bundles/<name>/overrides.h`): Audio v0.6.2, Performance v0.2.7 at the time of writing. The shelf (`web/src/app/editions/editions-data.ts`) and the flasher manifest (`web/public/flash/manifest.json`) name the images that are live; only those images are kept in `web/public/flash/bin/` — older ones live on their release tags. The one exception is a try-out image: a frozen copy of a composition that is not on the shelf, named by the features page (`tryOut` in `web/src/app/features/features-data.ts`) so people can install it to try, never bumped with the core, and retired only by a newer try-out of the same name. `clock` and `midi` are bundles the tree keeps building (CI compiles every composition) but does not put on the shelf: the clock is a feature, and the MIDI bundle is the composition that proves the USB-OTG build; each has a try-out image (`clock-v0.1.5`, `midi-v0.1.0`). Neither is in the `EDITIONS` tuples of `release.py`/`check_versions.py`; an edition joins those, the cards and the line above only when it goes on the shelf, and its maintainer cuts it with `release.py edition <name> vA.B.C` (docs/RELEASING.md).
- Firmware source lives in `firmware/patternflow/`; use release tags for versioning instead of encoding the release in the folder name. Tags are `vX.Y.Z`.
- Conventions: filenames lowercase with underscores; commit messages start with the area (`firmware:`, `web:`, `docs:`, `hardware:`) then a short present-tense summary.

Expand Down
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ All notable changes to Patternflow will be documented in this file, newest first

### 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's lede links here.
- **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

Expand Down
11 changes: 7 additions & 4 deletions docs/EDITIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -369,10 +369,13 @@ The shelf has two tiers, and the difference is who to ask when it breaks, not
quality.

A bundle in the tree is not on the shelf by itself. `firmware/bundles/clock`
and `firmware/bundles/midi` are compositions CI keeps compiling, with no card
and no image: the clock is a feature, and the MIDI bundle proves the USB-OTG
build. An edition is listed when its maintainer cuts it (`release.py edition`,
see [`RELEASING.md`](RELEASING.md)) and a card names the image.
and `firmware/bundles/midi` are compositions CI keeps compiling, with no card:
the clock is a feature, and the MIDI bundle proves the USB-OTG build. What
each has instead is a try-out image on the features page - a frozen copy,
installed the way the shelf installs but never bumped with the core, and
named in `web/src/app/features/features-data.ts` rather than by a card. An
edition is listed when its maintainer cuts it (`release.py edition`, see
[`RELEASING.md`](RELEASING.md)) and a card names the image.

**Official** — built from this repository. A core change has to compile against
it before that change lands, so it cannot silently rot. Its image is served
Expand Down
2 changes: 1 addition & 1 deletion docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ What the two commands do, step by step - and the way to do it by hand.
./firmware/bundles/shelf.sh core vX.Y.Z
```

then point `web/public/flash/manifest.json` at the new folder. An edition that also moved gets its own `shelf.sh <edition> vA.B.C` and a card update in `web/src/app/editions/editions-data.ts`. The shelf retires the previous folder of the same name — older images stay on their tags.
then point `web/public/flash/manifest.json` at the new folder. An edition that also moved gets its own `shelf.sh <edition> vA.B.C` and a card update in `web/src/app/editions/editions-data.ts`. The shelf retires the previous folder of the same name — older images stay on their tags. Try-out images (`clock-v0.1.5`, `midi-v0.1.0`, the ones the features page installs) are not part of a release and stay as they are; a fresh one is its own `shelf.sh <name> vA.B.C` and a row update in `web/src/app/features/features-data.ts`, done when somebody wants a newer one, not because the core moved.
7. Run the web checks from `web/`: `npm run lint && npm run typecheck && npm run check:ci && npm run build`.
8. Commit and tag:

Expand Down
8 changes: 6 additions & 2 deletions firmware/bundles/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,12 @@ know whether they are picking up something unfinished or something frozen.

A bundle can also be in the tree and not on the shelf at all — `clock` and
`midi` are. Then it is a composition CI keeps compiling so a core change
cannot break it silently, and there is no card, no image and nothing to cut.
The shelf is what its maintainer stands behind, and it is short on purpose.
cannot break it silently, and there is no card and nothing to cut. It can
still be tried: the features page carries a frozen image of it, staged with
`shelf.sh <name> vA.B.C` like a shelf image but named from
`web/src/app/features/features-data.ts`, and nobody bumps it when the core
moves. The shelf is what its maintainer stands behind, and it is short on
purpose.

## Graduating

Expand Down
8 changes: 6 additions & 2 deletions firmware/bundles/shelf.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@
# ./shelf.sh audio v0.3.0
# ./shelf.sh performance v0.2.1
# ./shelf.sh core v3.8.0 (the featureless default)
# ./shelf.sh midi v0.1.0 (a try-out image for the features page)
#
# Stages four files into web/public/flash/bin/<name>-<version>/ and stops.
# Editing variants-data.ts and deploying the site are separate, deliberate
# Editing editions-data.ts (features-data.ts for a try-out) and deploying the site are separate, deliberate
# steps: a staged image nobody has looked at should not become the thing a
# stranger flashes.
#
Expand Down Expand Up @@ -191,7 +192,9 @@ cp "$BOOT_APP0" "$OUT/boot_app0.bin"
# reads them from there). Keeping them in the tree meant 22 folders and 26 MB
# by 2026-09 for three that were served. So an older folder of the same name
# leaves when its successor arrives — including core's pre-shelf spelling,
# the bare "v3.x.y" folders.
# the bare "v3.x.y" folders. A try-out image - a composition the features
# page installs for trying, not a shelf entry - goes the same way, and only
# that way: nothing but a newer try-out of its name retires it.
shopt -s nullglob
for old in web/public/flash/bin/"$NAME"-v*; do
[ "$old" = "$OUT" ] && continue
Expand All @@ -212,4 +215,5 @@ ls -l "$OUT" | tail -4 | awk '{printf " %8s %s\n", $5, $9}'
echo ""
echo "next, by hand and on purpose:"
echo " - point the card at it in web/src/app/editions/editions-data.ts"
echo " (a try-out image: the row's tryOut in web/src/app/features/features-data.ts)"
echo " - deploy the site"
20 changes: 16 additions & 4 deletions firmware/toolchain/check_versions.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,19 +105,31 @@ def main() -> int:
if not (ROOT / "web/public/flash/bin" / folder / "patternflow.ino.bin").is_file():
problems.append(f"the {ident} card names an image that is not on the shelf: web/public/flash/bin/{folder}/")

# The shelf holds only what the cards name.
named = {f"core-{core_v}"} | {f"{n}-{v}" for n, v in editions.items() if v}
# Try-out images: frozen copies of compositions that are not on the shelf
# (the clock, the USB-MIDI build), which the features page installs for
# trying. release.py never touches them; they are named in features-data.ts
# and must exist, and they count as named below.
features = text("web/src/app/features/features-data.ts")
tryouts = set()
for path, folder in re.findall(r'url: "(/flash/bin/([^/"]+)/patternflow\.ino\.bin)"', features):
tryouts.add(folder)
if not (ROOT / "web/public" / path.lstrip("/")).is_file():
problems.append(f"features-data.ts names a try-out image that is not there: web/public{path}")

# The shelf holds only what the cards and the try-out rows name.
named = {f"core-{core_v}"} | {f"{n}-{v}" for n, v in editions.items() if v} | tryouts
on_shelf = {p.name for p in (ROOT / "web/public/flash/bin").iterdir() if p.is_dir()}
for extra in sorted(on_shelf - named):
problems.append(f"web/public/flash/bin/{extra} is on the shelf but no card or manifest names it")
problems.append(f"web/public/flash/bin/{extra} is on the shelf but no card, manifest or try-out row names it")

if problems:
print(f"{len(problems)} version problem(s):")
for p in problems:
print(f" - {p}")
return 1
print(f"versions agree: core {core_v}, " + ", ".join(f"{n} {v}" for n, v in editions.items())
+ " - in net_config.h, the overrides, AGENTS.md, the manifest and the /editions cards, images on the shelf")
+ " - in net_config.h, the overrides, AGENTS.md, the manifest and the /editions cards, images on the shelf"
+ (f"; try-out images: {', '.join(sorted(tryouts))}" if tryouts else ""))
return 0


Expand Down
2 changes: 1 addition & 1 deletion web/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ The `web/` app is the Patternflow site at [patternflow.work](https://patternflow
| `/pattern-lab` | The pattern workspace — see below |
| `/community/**` | The community: feed, pattern pages (`/p/[id]`), profiles (`/u/[username]`), decks (`/d/[id]`, `/decks`), the Workshop (`/workshop/[code]`), territories and the atlas, notifications, featured, reports. Renders a pointer to the community host unless `COMMUNITY_ENABLED=1` |
| `/editions` | The edition shelf: every firmware you can put on a panel, official and community, with one-click install. `/variants` (the URL until 2026-09, baked into shipped console pages) redirects here permanently |
| `/features` | The catalogue: every feature on its own — what it needs, which firmware carries it, and a reel of it running (Instagram embeds by permalink, lazy). The other axis of the shelf; hand-curated in `features-data.ts` |
| `/features` | The catalogue: every feature on its own — what it needs, which firmware carries it, and a reel of it running (Instagram embeds by permalink, lazy). The other axis of the shelf; hand-curated in `features-data.ts`. A feature that is not on the shelf can carry a frozen try-out image (`tryOut`), installed the way the shelf installs |
| `/update` | The device's firmware-update handoff: the browser downloads an image and POSTs it to the panel over the LAN, because the panel cannot fetch over TLS |
| `/flash` (static) | esp-web-tools flasher driven by `public/flash/manifest.json` + the images in `public/flash/bin/` — only the currently served ones are committed |
| `/journal` · `/journal/[slug]` (+ `/en`) | Bilingual (ko/en) MDX journal with per-article OG image generation |
Expand Down
Binary file added web/public/builds/slowrush/clock.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added web/public/builds/slowrush/doorstep.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added web/public/builds/slowrush/garden.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
21 changes: 21 additions & 0 deletions web/src/app/editions/Editions.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -318,3 +318,24 @@
line-height: 1.65;
color: var(--pf-ink-muted);
}

/* ── the two views, as tabs where the title was ──────────────────────────
"Firmware" is the shelf, "Features" the catalogue. The current one is
the h1; the other is a link in the same size, dimmed - so the other view
is one click away and impossible to miss, which a sentence in the lede
was not. */
.tabs {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 6px 26px;
margin: 0 0 14px;
}
.tabs .title { margin: 0; }
/* The link carries .title too, so it is set exactly like the h1 at every
width (the phone size above included); this only dims it. */
.tabLink {
color: var(--pf-ink-faint);
text-decoration: none;
}
.tabLink:hover { color: var(--pf-led); }
37 changes: 37 additions & 0 deletions web/src/app/editions/ShelfTabs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import Link from "next/link";
import styles from "./Editions.module.css";

// The two views of the same thing, as a pair of tabs where the page title
// would be: "Firmware" is the shelf (what you install), "Features" is the
// catalogue (what you are choosing between). The current one IS the h1; the
// other is a link in the same size, dimmed. Two routes still — deep links
// and search keep working — this just makes the other one impossible to
// miss, which a sentence in the lede was not.
const TABS = [
{ id: "firmware", href: "/editions", label: "Firmware" },
{ id: "features", href: "/features", label: "Features" },
] as const;

export type ShelfTab = (typeof TABS)[number]["id"];

export default function ShelfTabs({ active }: { active: ShelfTab }) {
return (
<nav className={styles.tabs} aria-label="Firmware and features">
{TABS.map((t) =>
t.id === active ? (
<h1 key={t.id} className={styles.title} aria-current="page">
{t.label}
</h1>
) : (
<Link
key={t.id}
href={t.href}
className={`${styles.title} ${styles.tabLink}`}
>
{t.label}
</Link>
),
)}
</nav>
);
}
7 changes: 3 additions & 4 deletions web/src/app/editions/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Metadata } from "next";
import Link from "next/link";
import styles from "./Editions.module.css";
import EditionCard from "./EditionCard";
import ShelfTabs from "./ShelfTabs";
import { EDITIONS } from "./editions-data";

const OFFICIAL = EDITIONS.filter((v) => v.tier === "official");
Expand Down Expand Up @@ -43,14 +44,12 @@ export default function EditionsPage() {
<Link href="/" className={styles.brand}>
Patternflow
</Link>
<h1 className={styles.title}>Firmware</h1>
<ShelfTabs active="firmware" />
<p className={styles.lede}>
One panel, more than one firmware. The first is what ships on the
board and does everything; the rest exist for what it cannot carry.
Switching is one click, and your patterns, Wi-Fi networks and
settings come with you. What each firmware carries, feature by
feature and with a reel of each, is on the{" "}
<Link href="/features">features page</Link>.
settings come with you.
</p>
</header>

Expand Down
16 changes: 16 additions & 0 deletions web/src/app/features/Features.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,22 @@
.facts dd a { color: var(--pf-ink); }
.facts dd a:hover { color: var(--pf-led); }

/* Trying one. A frozen image, installed the way the shelf installs a live
one; the button, the address row and the note are the shelf's own. */
.tryOut {
margin: 0 0 18px;
padding: 14px 16px 4px;
border: var(--pf-rule-w) solid var(--pf-rule);
}
.tryHead {
margin: 0 0 12px;
font-family: var(--pf-mono);
font-size: 10px;
letter-spacing: var(--pf-track-mono);
text-transform: uppercase;
color: var(--pf-ink-faint);
}

/* The reel. Instagram's embed page is tall; 560px shows the reel's frame
with its header and the post's own footer, and scrolls inside itself for
anything longer. It is only in the document once the row is open, so it
Expand Down
Loading
Loading