Activity Overview

Commits and releases over time

  • Commits
  • Releases
  • Authors

Repository Explorer

70 commits in all time May 29, 2026 16:37 – Aug 27, 2026 16:37 UTC
knonode The-Sound-of-AVM
Merge pull request #12 from knonode/main
much update many wow
Git Commit 97d51dd5 Branch main Document 106/14,191 ++ 25,285 --
knonode The-Sound-of-AVM
White for the Visualize menu
It inherited the cyan the old viz buttons used, which is harder to read
at eleven pixels than the white on the buttons beside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3U3qYRnw6NPt5BRj81S9X
Git Commit b0dd08b7 Branch main Document 1/3 ++ 1 --
knonode The-Sound-of-AVM
One menu where the visualization buttons were
Eight buttons across the left of the second row, only ever one of them in
use. They were the widest thing in the bar and pushed everything else
onto a second line at any ordinary window size. The bar is one row again
at 1100 pixels.

The three unbuilt ones keep their slots and their order in the menu,
greyed out, because the order is the roadmap and not an accident of when
each was written.

The menu's own name is the "none" entry, so picking Visualize again
closes what is open, alongside Escape and a click on the overlay itself.
Escape and the click reset the menu, or it would go on naming a
visualization that had already gone.

The guide loses its screenshot for this section: the set is in a dropdown
now, and a closed dropdown shows none of it. The text names the menu
instead, and the capture run drops the stale PNG on its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3U3qYRnw6NPt5BRj81S9X
Git Commit ba74623d Branch main Document 6/40 ++ 28 --
knonode The-Sound-of-AVM
bal. and lat., on one line
Three lines of height off the card, and a label that stops throwing my
prose at you.

"round trip" was a phrase, not a reading. It is lat. now, next to bal.,
and the two share one line: at a card's width they fit even at the worst
case of a four-digit latency, measured down to a 241px card with four to
a row. The tooltip spells both out, along with the escrow address.

The resolved address under Play as answered "which account did that name
mean" once and then charged for the answer forever. It clears itself
after four seconds, and the row collapses rather than sitting empty. A
failed lookup stays, because it is still true.

Play as had a sixty-pixel label column to line up with rows that no
longer exist — device and channel left for the header. The label takes
the width of its own text now, so the input keeps what it gains as the
card narrows.

Card height: 893 to 865.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3U3qYRnw6NPt5BRj81S9X
Git Commit 944174a7 Branch main Document 4/32 ++ 12 --
knonode The-Sound-of-AVM
Put the pickers in the header, and the address within reach
Two problems, one of which had no answer at all.

Nothing anywhere told you where to send money. The card showed a balance
draining and no way to fill it — you would have had to read the TEAL to
find the address. The balance line now carries it in its tooltip and
copies it on a click, which costs no height, because a line that says
"escrow: 0.141 ALGO" is already the line about the escrow.

Device and channel were two rows in the parameter area. They are now two
dropdowns in the header, where Type and Sub sit on every other card and
where the room already existed. The MIDI card is the same height as an
ordinary synth card again: 892 pixels against 892, measured rather than
assumed.

The device picker reads MIDI until a device is chosen, so the header
still names the card. Device names are longer than the select is wide;
the tooltip carries the full one.

The guide follows both: where the pickers are, and that clicking the
balance copies the address to top it up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3U3qYRnw6NPt5BRj81S9X
Git Commit 0c5172cb Branch main Document 4/72 ++ 39 --
knonode The-Sound-of-AVM
Say MIDI device, not keyboard
Nothing in this takes a keyboard. It takes note-on with velocity, which
is what pads, MPCs, wind controllers, guitar pickups, sequencers and
anything with a MIDI out send. The copy said keyboard throughout because
the first device to play it was one.

It read worst in the 0.11.0 note — "one keyboard can send up to eight
parts" excludes exactly the multi-track devices that feature exists for.

Two uses of the word are left. One is the computer keyboard that reorders
cards, which is what it says. The other is the list of examples in the
guide, where a keyboard is one of the things named.

Copy only, so no version bump: a card that read the release notes once is
not worth interrupting again over a wording fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3U3qYRnw6NPt5BRj81S9X
Git Commit d0d8c9c0 Branch main Document 4/16 ++ 14 --
knonode The-Sound-of-AVM
Document the parts, and release them as 0.11.0
The guide gains a Channel paragraph in the MIDI section: leave it on all
for one keyboard, add a card per track and set a channel each for a
device that sends several at once.

The release notes lead with the part that needs saying loudest, which is
that everyone has to update. Notes on channels 2 to 8 are dropped by
0.10.0 rather than sounding wrong, so a friend still on the old build
hears a quarter of a four-track performance and no error.

MINOR: a 0.10.0 preset still loads, and channel is a new key with a
default. The break is in what goes over the network, not in what a preset
means, and the compatibility surface here is presets.

The MIDI card screenshot is recaptured for the new row. The other
twenty-one are left alone — a full run rewrites them all, but only this
one changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3U3qYRnw6NPt5BRj81S9X
Git Commit fbb8a2aa Branch main Document 4/16 ++ 1 --
knonode The-Sound-of-AVM
Your edits to the release notes and the guide
Rewritten in your voice, plus four typos.

A tab had got into "Plug in your MIDI keyboard", and "plug in ... into"
doubled the preposition. "Through relay node" wanted an article.

In the guide, the Play as paragraph had lost the clause between "an NFD
name.algo" and "Leave it empty", which left a semicolon in front of a
capital letter. The paragraph after it kept a dash that no longer had
anything on the other side of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3U3qYRnw6NPt5BRj81S9X
Git Commit de4173b7 Branch main Document 2/14 ++ 15 --
knonode The-Sound-of-AVM
Let one player send several parts at once
A groovebox is not a keyboard. Its tracks are separate instruments, and
arriving as one merged stream in one voice is the wrong answer to what
the device is for.

The separation could have come from addresses — a different "Play as" per
track, four tracks arriving as four strangers. That works today with no
change at all, and it is wrong. Identity is load-bearing here: it carries
your NFD, and it is what "hear only this player" asks about. Four tracks
of one performance are not four people, and answering a filter for one of
them with a quarter of the music is a bug wearing a feature's clothes.

So the part rides in the asset ID, next to the pitch. Each note has
always owned a thousand IDs and a keypress only ever needed a velocity,
so nine tenths of every slot was empty: it now holds eight parts of a
hundred steps each. The top two hundred stay unreachable from a keyboard,
which is where the voice announcement already lives. Nothing about the
escrow changes — the range check still holds, with two hundred to spare
at the top — so the instrument keeps its address.

Velocity drops from 128 levels to 100. It was only ever read as a gain
between 0.05 and 1, so the loss is arithmetic rather than audible.

This is a change to what goes on the wire. A listener on 0.10.0 will hear
part 0 and silently drop the rest, since their decoder rejects a velocity
above 127 — which is the correct behaviour for them and a reason for this
to go out as a release rather than sit on a branch.

The rest follows from the encoding. Voices are filed under player and
part rather than player alone, so one performer's tracks stop overwriting
each other's sound. A card knows which part it plays, so the card that
sent a note is the one that sounds it, instead of all four playing every
line. And a device now feeds as many cards as want it, each on its own
channel — binding was one-at-a-time before, on the reasoning that two
cards on one keyboard would pay twice for every keypress. They would not:
they listen on different channels, and a keypress only lands on one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3U3qYRnw6NPt5BRj81S9X
Git Commit 38ffe4a3 Branch main Document 4/196 ++ 73 --
knonode The-Sound-of-AVM
Show what your own playing has cost
The escrow readout says what the hat holds, which is everyone's. It does
not say what you have spent, and those are different questions the moment
more than one person is playing.

They are different for a second reason, which is the one that prompted
this. An arpeggiator bills you a note at a time without a finger moving,
and a sequencer left running does it indefinitely — the first you would
otherwise know of either is an empty hat. The count is worth its line on
the card while it is happening rather than afterwards.

Notes dropped by the rate cap get a line too. They cost nothing, but you
pressed a key and heard silence, and that is worth saying rather than
leaving you to wonder whether it went out.

The readout is four short lines now instead of two long ones. A card is
about twenty-eight characters wide at this size, so a line carrying two
facts wraps mid-phrase and reads as neither.

window.SOA_MIDI.spend() gives the same numbers from the console, next to
voices() and playing().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3U3qYRnw6NPt5BRj81S9X
Git Commit b057f49c Branch main Document 2/32 ++ 8 --
knonode The-Sound-of-AVM
Let the hat empty in front of you
The escrow balance was read once when the card was built and never again,
so the one number that tells you how much playing is left sat still while
you spent it. A reload was the only way to find out.

It is now driven by what is heard rather than by asking. Every note is a
transaction that passes this browser on its way back from the relay, and
every one of them costs the same minimum fee, so a keypress anywhere in
the world moves the number here at the moment you hear the note that
spent it. That covers other people's playing too, which is the point: the
hat is shared, and watching it drain is part of knowing whether to top it
up.

Counting what you hear drifts — the stream can be stopped, a relay can
miss a transaction, and someone who is not playing can top the hat up —
so the count is corrected against the chain every forty-five seconds, and
not at all while the tab is hidden, since nobody is watching a number
they cannot see. A failed read is left alone rather than treated as a
balance of nothing.

The probe's floor drops from 0.2 ALGO to 0.05. The old figure was picked
out of the air and refused to play a hat that could still afford a
hundred and sixty notes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3U3qYRnw6NPt5BRj81S9X
Git Commit 2f572f9e Branch main Document 2/51 ++ 8 --
knonode The-Sound-of-AVM
Document the keyboard, and release it as 0.10.0
The guide gains a last section, after presets, because the keyboard is the
one part of this app that does not listen. It says what a note actually
is, that you will not hear a key when you press it, that the instrument
belongs to nobody, and what Play as buys you — which is not identity for
its own sake but the thing that lets your sound travel with your notes.

The second row's section gains the two controls beside + MIDI. Others is
described where the reader meets it rather than in the MIDI section,
since it governs what you hear whether or not you ever plug a keyboard in.

MINOR by the rule in the README: settings keys were added with sensible
defaults and nothing was renamed or repurposed, so a preset written by
0.9.0 sounds the same here.

Only two screenshots are committed. A full capture run rewrites all
twenty-two, but the other twenty differ only in font rendering — this
machine's Chromium against whoever captured them last — and the presets
modal cannot have been changed by any of this work. Committing them would
be twenty binary files of noise around the two that mean something.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3U3qYRnw6NPt5BRj81S9X
Git Commit 6cf3ec85 Branch main Document 7/71 ++ 2 --
knonode The-Sound-of-AVM
Let a MIDI keyboard play into the mempool, and be heard
A note is a transaction: a zero-amount transfer of an asset that does not
exist. The AVM allows this because a zero transfer returns before it
looks up any holding, so no opt-in is needed, no supply is needed, and
the asset ID is free to mean something other than an asset. It means the
note — 2^40 + midiNote*1000 + velocity, three orders of magnitude above
the highest real asset ID so it can never be mistaken for a transfer of
something that exists.

The amount cannot carry anything, which is the one thing that had to be
designed around rather than chosen. A non-zero transfer needs a holding
the sender does not have, so every note is aamt 0 and the pitch lives
somewhere else. Sending to yourself is out for the same family of
reasons: aamt 0 to your own address is the opt-in branch, which does look
the asset up and fails.

The instrument is an escrow with no private key. tools/midi/keyboard.teal
authorises exactly one shape of transaction — axfer, amount zero, fee at
most the minimum, no clawback, no close-out, no rekey, asset ID inside
the note range — so the only thing that can ever leave the account is
fees. Anyone holding the program can sign for it, which is the point:
several people play the same instrument at once with no server, no
session and nothing to coordinate, because Algorand has no nonces and
simultaneous senders never collide. The receiver field carries who
played, since the sender is the escrow for everybody. It costs nothing,
a zero transfer never touching the receiving account, and it can be
typed as an NFD.

Nothing is heard when it is sent. A note is heard when it comes back
through the relay, most of a second later, in whatever order gossip
chose. That is not a defect to be papered over: the mempool is the delay
line, and the instrument is one whose latency is a live measurement of
the network. Slow, sparse, chordal playing sounds deliberate. Fast runs
sound like a fax machine, and should.

A player's sound travels with them, in the note field of the notes they
are already sending. A fully designed voice serialises to about 460
bytes against the 1024 a note field allows, and mainnet's fee-per-byte is
zero, so those bytes are free space rather than a cost — every note
carries the whole sound, a change is heard on the very next keypress, and
someone who joins mid-jam has your voice on the first note they hear. No
transaction is ever sent for a voice alone.

That voice is a stranger's data applied to your audio graph, so
services/midi/voice.ts forces every value into a range Tone can take
before it reaches a node, refuses a payload from a format version it does
not know rather than voicing it with today's reader, and drops the fields
that are a card's state rather than a sound. It cannot mute your synth or
move your sequencer. It also cannot move the shared reverb, which belongs
to everybody's hall rather than one player's patch.

Applying a voice is cheap in the case that matters. Only the engine is a
different class of object; waveform, envelope, detune, cutoff, delay, pan
and LFO are parameters on nodes that already exist, so applyVoice sets
them on a running graph and rebuilds only on an engine change. Since
every note carries its player's whole sound, a fingerprint comparison on
the way in means Tone is touched only when the sound actually differs —
on a knob turn, never on a keypress. Nothing needs rate limiting.

Four voices, evicted least-recently-heard, kept out of activeSynths on
purpose: they are not cards, and must never be saved into a preset,
reordered, or matched against ordinary transaction rules. Your own notes
go to your own card, where the sound you are designing lives and where
the LED tells you the mempool heard you. Everyone else goes to the pool.
A card never plays another player's notes — hearing four people through
one card's voice was only ever a stand-in for not having their voices.
Anonymous players, having no address to file a sound under, share one
slot and the house default rather than being silent.

Two things had to be learned by encoding a transaction and reading the
bytes back, because neither is visible in this code.

Canonical msgpack omits zero-valued fields. So aamt is absent rather than
0 on every note, and arcv is absent entirely when the receiver is the
zero address. Both guards are written around that.

And gossip dedup was dropping notes. A logic-signed transaction has no
sig, so the fallback key was a composite of type, sender, fee and
validity window — every one of which is identical for every note this
escrow plays in a given minute. The first note of each window would have
sounded and the rest would have been swallowed in the ingest layer,
before any synth saw them. The key is now built at normalize time, while
the raw bytes are still in hand, from what actually differs: the asset ID
that carries the note, the receiver that says who played it, and a hash
of the bytes the sender added to keep two identical notes from being one
transaction. This was never MIDI-specific; all lsig and msig traffic was
under-deduplicated the same way, and MIDI only made it total.

One bug is left alone deliberately. axfer-opt-in tests (aamt ?? -1) === 0
against a field msgpack drops, so that subtype has never matched
anything. Fixing it changes what an existing preset hears, which is a
release decision rather than a drive-by.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3U3qYRnw6NPt5BRj81S9X
Git Commit 5da9be9e Branch main Document 15/1,733 ++ 13 --
Give every control a focus ring you can see
Tabbing through a synth card showed nothing at all on the sliders. The
app had no focus style of its own, so every control was falling back on
the browser's, which is drawn in a colour chosen for a white page and
hugs the thumb of a five-pixel track on a dark teal card.

One mark for everything reachable by Tab, and :focus-visible rather than
:focus so a mouse click on a slider does not leave a ring sitting there.
The whole control is ringed rather than the knob: outlining the knob
means taking over the slider's appearance wholesale, and then owning the
look of every slider in the app forever.

Two things had to be found by tabbing the card and reading back what each
control actually computed, because neither is visible in this file.

Most of the value boxes carry no type attribute — they are text inputs by
default — and an attribute selector matches the attribute rather than the
behaviour, so a list of input[type=...] silently skips exactly the
controls you spend the most time in. Range then has to be named on top of
that: a bare input:focus-visible reaches every other control and is
beaten on the sliders alone, since the Tailwind and daisyUI form layer
claims [type=range] by attribute and outranks a plain element selector.

The value boxes also had hover and focus sharing one rule that cleared
the outline, so a box you had tabbed to looked identical to one the mouse
merely happened to be over. The border stays as the hover tell; focus
keeps the ring.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Git Commit 0da2d39d Branch main Document 1/44 ++ 1 --
knonode The-Sound-of-AVM
Let the synth cards be reordered, by pointer and by keyboard
The grab strip has been sitting between the header and the parameter area
promising a gesture that was never written. This is the gesture.

A layout is read left to right and top to bottom, so the order of the
cards is the order of the piece: which voice you reach for first, which
three belong together. Until now that was the order you happened to add
them in, and the only way to change it was to delete a card and build it
again. The order of activeSynths follows the cards, because that array is
what a preset is written from and rebuilt into — if the two disagree, a
layout saved after a drag comes back in the order it had before.

The card moves in the DOM as you drag rather than floating under the
cursor while a placeholder holds its slot. The grid is auto-fit, so a
card lifted out of flow changes the column count for everything else and
the layout you were dragging over would not be the layout you dropped
into. Moving the real node means the preview cannot lie.

Two rules keep that from thrashing: a press does not become a drag until
the pointer has travelled a few pixels, and a card only gives up its
place once the pointer has properly arrived in it. Across a row that is
the midpoint. Between rows it cannot be — a synth card is routinely
taller than the window it is being dragged in, so the middle of the row
below is off the bottom of the screen, and dragging down was asking for a
position the pointer could not reach while dragging up worked fine. It is
measured against the part of the target actually on screen instead:
whatever is visible, cross half of it.

The drag is followed on the window rather than by capturing the pointer.
Moving a node is a removal and an insertion, and a capture target that
leaves the document loses the capture — so capturing delivers events
right up until the first card is displaced and then silently stops, which
looks exactly like a drag that moves one slot and never lets go.

Marked while dragging by an outline and nothing else. Cards are colour
coded by transaction type and every one of those colours is a translucent
wash over the page, so dimming a card takes its own colour with it and
for a moment you are dragging a card of a type you did not pick.

The strip is no longer aria-hidden, because it is a control now. Arrow
keys move the focused card one place, Home and End take it to the ends,
and every move is announced: what moved, the position it now holds, and
which synth it is now behind and which it is now in front of. Neighbours
are what a layout is made of — "third of seven" is a fact you have to
hold, "after the pay drone, before the block bell" is a place — and
reordering by keyboard is worthless if the only way to know where a card
landed is to have counted. Enter repeats it without moving anything.

Plain arrows rather than Ctrl-arrows: Ctrl with Left or Right is how
macOS switches desktops and the browser is never told it happened. No
pick-up-and-drop mode either — it is a state you can be stranded in, it
needs a cancel key, and Space here is already play/stop everywhere.

A keyboard move shifts the NEIGHBOUR past the card rather than the card
past the neighbour. Both produce the same order and only one survives: a
node holding the focus is blurred the moment it leaves the document, and
reinserting it does not give the focus back, so moving the card itself
fired once and then swallowed every arrow key after it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Git Commit 4a49a765 Branch main Document 3/474 ++ 7 --
knonode The-Sound-of-AVM
Add a grab strip between header and parameter area
Groundwork for drag-to-reorder: the cards need somewhere to be grabbed
that isn't already occupied by a control, and the header is crowded once
a preset has more than five synths in it.

The space was already there and doing nothing. .mini-synth is a flex
column, so the header's 5px margin-bottom and the parameter area's 8px
margin-top never collapsed into each other the way they would in a block
container — they added up to 13px of dead band. The strip takes over
exactly that 13px and both margins are zeroed, so card height is
unchanged (verified: header-to-parameter distance is 13px before and
after).

Three flat 1px rules, no light-under-dark highlight pass — this reads as
a groove without pulling a 3D bevel into a UI that has none. The 9px
band centres on whole pixels; a 10px one lands at 1.5px and blurs, and
crowds the first rule against the header border until the two read as
one doubled line.

Master keeps its original margins and gets no strip: it isn't going to
be draggable, so the affordance would be a lie.

No drag behaviour yet, and deliberately no cursor:grab — the strip
shouldn't promise a gesture that isn't wired up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Git Commit cd7dd456 Branch main Document 2/28 ++ 0 --
knonode The-Sound-of-AVM
Fix the README, and stop pinning counts that will change
The local-run instructions were wrong in a way that cost a new contributor their
first command: they said to run `npx tsx api-dev-server.ts`, which exists nowhere
in this repo, and nothing in src/ makes a single /api call. There is no backend —
the gossip endpoint is a public relay — so `npm install && npm run dev` is the
whole setup, with .env needed only for the wallet and NFPreset features.

Counts are out, in the README and the guide both. "Two visualizations" had been
wrong since Kintsugi landed, and naming five would have been wrong again the next
time one is added or dropped. The guide now points at the viz bar in its own
screenshot for the current set — which the capture script keeps accurate for free
— and names Score and Territory as examples rather than reciting a roster. Same
treatment for the engine list, since a metal engine has been on the cards.

Added a Versioning section covering the release steps and, more usefully, the two
judgements behind them: that the compatibility surface is presets rather than
code, and that a bump interrupts every returning user with a card, so docs
changes ride along with the next real release instead of earning one.

No version bump: this changes nothing anyone runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Git Commit ea6baf71 Branch main Document 2/52 ++ 19 --
knonode The-Sound-of-AVM
Read unstamped presets as 0.9.0, and drop stpf.json
Unstamped presets now resolve to a baseline version rather than null, so callers
always have something to compare and no null case to handle. 0.9.0 is the right
baseline as a matter of fact rather than convenience: a preset without a stamp
necessarily predates stamping, stamping landed in 0.9.0, and everything in the
wild was therefore authored against 0.9.0-era synths and sounds correct on them
today. `inferred: true` keeps the distinction for diagnostics.

Nothing on disk is rewritten. Migrating on load would mean a read silently
mutating stored data, and it buys nothing the read-side default doesn't already
give; presets pick up a real stamp the next time they are saved.

stpf.json deleted: a block payload captured during development that happened to
live in presets/. Nothing referenced it, and state proof overlays run from live
transactions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Git Commit 7606797e Branch main Document 2/22 ++ 6,531 --
knonode The-Sound-of-AVM
Stamp presets with the version that wrote them
A preset was just { activeSynths }. Nothing recorded which build produced it, so
the usual way of absorbing a breaking change — keep the old code path and route
old presets to it — was not available: there was no way to tell an old preset
from a new one.

Two fields, because they answer different questions:

  formatVersion  the shape of the object. Bump only when a reader has to behave
                 differently. Adding a settings key with a default is not a bump.
  appVersion     which build wrote it. This is the one that matters for sound.
                 Swapping a synth engine or the reverb changes how an existing
                 preset renders without touching the schema at all, so
                 formatVersion would not move.

An absent formatVersion means the preset predates stamping — which is exactly the
set written before any future behaviour change. The absence is the signal, so
nothing already saved needs migrating. getLoadedPresetProvenance() is the hook
for branching on it later.

Urgent only because NFPresets are immutable on-chain: anything minted before this
can never be stamped, so every day without it adds permanently ambiguous assets.

The five shipped presets are stamped 0.9.0, which is what they were voiced
against. stpf.json is left alone — despite living in presets/ it is a captured
block payload, not a preset.

app-version.js now warns when it falls back to 0.0.0-dev, since that string would
otherwise be stamped into presets saved against a stale dev server without
anything saying so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Git Commit 9e2d39c3 Branch main Document 7/75 ++ 0 --
knonode The-Sound-of-AVM
Show the intro and the release notes on the same card
The card branched on whether localStorage had ever seen a version: brand-new
visitors got the description, returning ones got the changelog. That split meant
anyone upgrading never saw what the app was, and anyone new never learned a
release had just landed.

Both now appear together on one card, for anyone who isn't already on the current
version. Two buttons instead of one relabelled button, so either destination is
one click away. Still capped at the newest three highlights — the full list is
behind Full changelog.

Verified the card fits without scrolling at 1280x900, 1366x768 and 390x780.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Git Commit f6990e85 Branch main Document 4/31 ++ 23 --
knonode The-Sound-of-AVM
Make the guide readable: narrow shots, text alongside, dash markers
Three presentation fixes.

The screenshots were far too wide. .synth-grid is auto-fit, so in a 1280px window
two cards stretch to half the screen each and the crop came out a wide sparse band
that shrank to an illegible sliver in the modal. Captured at 700px a card gets its
natural ~337px, which is both readable and narrow enough to sit beside its
explanation rather than above it — so .guide-step is now a flex row. It wraps
rather than using a media query, so on a phone the text drops below the image on
its own. The full-width bars opt out via `viewport` and keep spanning the row.

Lists rendered with no markers at all: @tailwind base resets list-style on every
ul, and the legacy rules set padding without restoring it, so the release notes
and every list in the guide ran together as unmarked paragraphs. Dashes rather
than bullets, with a hanging indent so wrapped lines align under the text.

Also removed the slashes from the prose. Berkeley Mono Trial draws "/" almost
exactly like "\", which turned "Min / Max" into "Min \ Max" (the same quirk makes
the existing rate hint read "1-100\s"). Not worth a font change; just not worth
writing slashes either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Git Commit eb965e6e Branch main Document 23/65 ++ 16 --
knonode The-Sound-of-AVM
Make the first-visit notice a centred card
It was an inline strip above the actions bar. A centred overlay card, the same
shape as the preset modals, is what actually reads as "read me first" — and it
inherits the close X, the backdrop click and the dark surround for free.

Reworded, because the old copy was wrong about the first step: a bare page load
brings up only the master bus, so there is nothing to hear from pressing Start on
its own. It now says to load a preset first.

Escape dismisses it, and Space is ignored while it's up, for the same reason the
info modal ignores it: the shortcut deliberately doesn't exempt focused buttons.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Git Commit 7af88889 Branch main Document 3/47 ++ 55 --
knonode The-Sound-of-AVM
Add the (i) button: how-to guide and what's-new banner
One entry point beside the title opens both the guide and the changelog. The
banner appears once per version and then never again — on a first visit it says
what the app is and points at the guide, since a changelog means nothing without
a baseline; on an upgrade it lists the top three highlights. Dismissing it or
opening the modal records the version in localStorage under
txSynthLastSeenVersion.

Modal and banner ship together because neither works alone: the banner's only
action is opening the modal, and opening the modal is what dismisses the banner.

Details worth knowing:

- The title and its (i) sit in a .title-group wrapper. .top-bar is a
  space-between flex expecting exactly two children; a third would float the
  button into the middle.
- Panes are built on first open, so the 21 guide screenshots cost nothing to
  visitors who never open it.
- Escape now checks the info modal before falling through to closeViz(), which
  it previously called unconditionally — otherwise reading the guide would blow
  away a fullscreen viz the user can't see.
- Space is ignored while the modal is open. The play/stop shortcut deliberately
  does not exempt focused buttons, and the modal is full of them.
- New class names throughout (.info-tab-btn, .info-pane — not .tab-btn or
  .tab-content) to stay clear of the synth card's own tab strip and DaisyUI.
- .info-modal widens the shared 450px .modal-content cap to 760px; the guide
  screenshots are unreadable at the narrower width.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Git Commit e58a73d7 Branch main Document 4/359 ++ 1 --
knonode The-Sound-of-AVM
Write the how-to guide, and a script that screenshots it
The app had no in-app documentation at all. A newcomer landed on a grid of
unlabelled sliders with no way to learn that a synth is built as a ladder:
transaction type, then subtype, then filter, then engine, on down to the LFO.

guide-sections.js is that ladder written out, and is deliberately the single
source of truth for both halves of the guide: the modal renders `title` + `body`
and points an <img> at /guide/<id>.png, while capture-guide-shots.mjs reads
`selector` + `state` to produce exactly that file. There's no second list to keep
in sync, the capture run deletes orphaned PNGs, and adding a section is enough to
get it captured.

Screenshots are generated rather than hand-taken so they can be regenerated after
any UI change (npm run guide:shots) instead of quietly going stale. Full-width
bars declare a narrower `viewport` — a 1280px crop scaled into a 760px modal is
illegible.

Prose seeds from the existing title= tooltips where they were already good. It
also documents what has never had a tooltip: the waveform and LFO destination
selects, the tab strip, and every Master control.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Git Commit 1151b5fb Branch main Document 24/629 ++ 0 --
knonode The-Sound-of-AVM
Give the app a version number
Nothing carried a version: package.json said 0.1.0, nothing read it, and there
were no tags. Testers had no way to say which build they were on.

package.json is now the single source of truth, injected as __APP_VERSION__ by a
define in vite.config.ts and shown in the footer. Read with readFileSync rather
than imported, because tsconfig.node.json (which covers vite.config.ts) doesn't
enable resolveJsonModule and doesn't extend the root config.

Starting at 0.9.0 — feature-complete enough to hand to testers, not 1.0 yet.

To ship from here: bump package.json, add a changelog entry, commit, tag.
Note that vite reads package.json once at config load, so a version bump needs a
dev server restart to show up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Git Commit dbdbb76b Branch main Document 6/29 ++ 1 --