diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1730d480..9f32312c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,7 @@ All notable changes to Patternflow will be documented in this file, newest first
- **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.
+- **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 the same or slightly smaller, 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.
## [3.10.5] - 2026-10-02
diff --git a/firmware/patternflow/console/README.md b/firmware/patternflow/console/README.md
index 4b9a2ea3..502a80e6 100644
--- a/firmware/patternflow/console/README.md
+++ b/firmware/patternflow/console/README.md
@@ -68,6 +68,28 @@ is editable the same way. The chrome draws itself inside a shadow root
page, so do not style or query its insides. What a page may use is
`window.PF`, below.
+## The look
+
+The console is light unless someone picks dark. The choice is the toggle in
+the header, kept in `localStorage` as `pf-theme`, and the chrome sets
+`data-theme` on `` before the first paint. Every core page opens its
+`
Editions are listed at patternflow.work/editions; the panel’s own network has no internet.
+
Installing another keeps your patterns and settings.
+
- Device
-
+
Pages
+
+
@@ -330,85 +169,70 @@
function $(i){return document.getElementById(i)}
$('host').textContent=location.hostname;
+// A range's filled part (--p) follows its value.
+function fill(el){el.style.setProperty('--p',(el.value-el.min)/(el.max-el.min)*100+'%')}
+document.addEventListener('input',function(e){if(e.target.type==='range')fill(e.target)});
+
// ── What this build actually has ───────────────────────────────
-// Feature rows are not written into this page. Whatever features the firmware
-// carries report their pages in /api/status featureNav — path, label, one
-// line — and the Features group is built from that. A build with none gets
-// no group, and a page the core has never heard of still gets a row. This
-// used to work the other way round: rows for every feature in the markup,
-// deleted when the capability was absent, which meant a core page naming
-// features and a new feature's page getting no row at all.
-//
-// Rebuilt from scratch whenever featureNav changes (not on every status: the
-// card polls, and a rebuild under the pointer drops hover and focus), then
-// everything renumbers so the list still reads 01, 02, 03.
+// Feature rows, and the edition card's chips, come from /api/status
+// featureNav (path, label, one line): a build with none gets none, and a
+// page the core never heard of still gets a row. Rebuilt only when it
+// changes: a rebuild under the pointer drops hover and focus.
var navKey;
function gate(s){
- var i,nav=(s&&s.featureNav)||[],k=JSON.stringify(nav);
+ var i,nav=(s&&s.featureNav)||[],k=JSON.stringify(nav),f=$('featRows'),c=$('s-feat');
if(k===navKey)return;
navKey=k;
- var f=$('featRows'),fk=$('featKick');
- if(f){
- f.innerHTML='';
- for(i=0;i
';
- a.querySelector('.pf-row-t').textContent=e[1];
- a.querySelector('.pf-row-d').textContent=e[2]||'';
- f.appendChild(a);
- }
- f.hidden=fk.hidden=!f.querySelector('.pf-row');
+ f.innerHTML=c.innerHTML='';
+ for(i=0;i';
+ a.firstChild.textContent=e[1];
+ a.lastChild.textContent=e[2]||'';
+ f.appendChild(a);
+ a=document.createElement('a');
+ a.href=e[0];a.className='btn';a.textContent=e[1];
+ c.appendChild(a);
}
- var ghosts=document.querySelectorAll('.rows .pf-ghost');
- for(i=0;i 100) v = 100;
- var sl = $('sl-' + i);
- if (sl) {
- sl.value = v;
- lastReported[i] = v;
- }
- }
-}
-
-function pumpParams() {
- if (inFlight) return; // Drop redundant updates while in-flight; keep newest in queue
- var keys = Object.keys(queuedParams);
- if (!keys.length) return;
-
- var bodyParts = [];
- for (var i = 0; i < keys.length; i++) {
- var k = keys[i];
- bodyParts.push(k + '=' + encodeURIComponent(queuedParams[k]));
+// ── Knob sliders (K1..K4 · 0..100) ────────────────────────────
+var activeSlider=-1,inFlight=false,queuedParams={};
+function updateSliders(params){
+ if(!params||params.length<4)return;
+ for(var i=0;i<4;i++){
+ if(activeSlider===i)continue;
+ var v=Math.round(params[i]/10);
+ if(v<0)v=0;if(v>100)v=100;
+ var sl=$('sl-'+i);sl.value=v;fill(sl);
}
- queuedParams = {};
- inFlight = true;
-
- fetch('/api/params', {
- method: 'POST',
- cache: 'no-store',
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
- body: bodyParts.join('&')
- }).then(function(r) {
- if (!r.ok) throw Error('refused');
- $('ctrlmsg').textContent = '';
- }).catch(function(x) {
- // Next to the sliders, not swallowed: a drag that never arrived looks
- // exactly like a pattern ignoring its knobs.
- PF.say('not sent — ' + (x && x.message === 'refused' ? 'refused' : 'no answer'), 'err', $('ctrlmsg'));
- }).then(function() {
- inFlight = false;
- if (Object.keys(queuedParams).length) {
- pumpParams(); // Flush pending latest updates
- }
- });
}
-
-function onSliderInput(idx, val) {
- var v = parseInt(val, 10);
- if (isNaN(v)) return;
- lastReported[idx] = v;
-
- // pX on the 0..1000 bus. The device derives the clicks a legacy pattern
- // reads from the change in the held value, so no dX alongside — sending
- // both counted every move twice.
- queuedParams['p' + (idx + 1)] = v * 10;
-
- pumpParams();
-}
-
-function initSliders() {
- for (var i = 0; i < 4; i++) {
- (function(idx) {
- var sl = $('sl-' + idx);
- if (!sl) return;
- held(sl, function() { activeSlider = idx; }, function() {
- if (activeSlider === idx) activeSlider = -1;
- pumpParams();
- });
- sl.addEventListener('input', function() {
- var v = sl.value;
- onSliderInput(idx, v);
- });
- })(i);
- }
+function pumpParams(){
+ if(inFlight)return; // one in flight; the newest values wait in the queue
+ var keys=Object.keys(queuedParams),body=[],i;
+ if(!keys.length)return;
+ for(i=0;i 0 ? 'Next…' : 'Prev…';
- PF.get('/api/patterns/select?step=' + dir).then(function(d) {
- $('now').textContent = d.name || prev;
- $('nowsub').textContent = 'switched';
- $('stepmsg').textContent = '';
- }, function(x) {
- $('now').textContent = prev;
- if (!x || !x.nav)
- PF.say(x && x.status === 404 ? 'nothing to switch to' : 'not switched', 'err', $('stepmsg'));
+// ── Pattern stepping (← / →) ─────────────────────────────────
+// The reply names the new pattern; the next status confirms it. One step
+// in flight (?step is ±1); presses meanwhile add up in pend.
+var stepping=0,pend=0;
+function stepPattern(dir){
+ if(stepping){pend+=dir;return}
+ var prev=$('now').textContent;
+ $('now').textContent=dir>0?'Next…':'Prev…';
+ stepping=1;
+ PF.get('/api/patterns/select?step='+dir).then(function(d){
+ stepping=0;
+ $('now').textContent=d.name||prev;
+ $('nowsub').textContent='switched';
+ $('stepmsg').textContent='';
+ if(pend){dir=pend>0?1:-1;pend-=dir;stepPattern(dir)}
+ },function(x){
+ stepping=pend=0;
+ $('now').textContent=prev;
+ if(!x||!x.nav)
+ PF.say(x&&x.status===404?'nothing to switch to':'not switched','err',$('stepmsg'));
});
}
-$('pat-prev').addEventListener('click', function() { stepPattern(-1); });
-$('pat-next').addEventListener('click', function() { stepPattern(1); });
+$('pat-prev').addEventListener('click',function(){stepPattern(-1)});
+$('pat-next').addEventListener('click',function(){stepPattern(1)});
+// The arrow keys too, unless a field or slider has them or a modifier is
+// held (Alt+Left is Back).
+document.addEventListener('keydown',function(e){
+ var d=e.key=='ArrowLeft'?-1:e.key=='ArrowRight'?1:0;
+ if(!d||e.repeat||e.altKey||e.ctrlKey||e.metaKey||e.shiftKey||/^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName))return;
+ e.preventDefault();stepPattern(d);
+});
+[].forEach.call(document.querySelectorAll('input[type=range]'),fill);
PF.watchStatus(3000);
PF.status(paint);
diff --git a/firmware/patternflow/console/knobs.html b/firmware/patternflow/console/knobs.html
index 09f78bbe..944d5520 100644
--- a/firmware/patternflow/console/knobs.html
+++ b/firmware/patternflow/console/knobs.html
@@ -5,97 +5,82 @@
Patternflow - Knobs
-
Knobs
-
-
-
Direction and click size
-
-
Position
Direction
Edges per click
-
-
-
-
-
-
-
-
Turn a knob clockwise. Its position should go up by one per click.
- Goes down: set that knob to Reversed. Moves two per click, or needs two clicks to
- move one: change Edges per click until one click is one step (most encoders are 4;
- the edge counter under the position shows how many one click really makes).
- These are properties of the encoder soldered in, stored on the device — nothing to
- rebuild, and a saved change does not move the knob.
Turn a knob one click clockwise: its position should go up by one.
+
+
Position
Direction
Edges per click
+
All
+
+
+
+
+
+
+
+
Goes down: Reversed. Moves two, or needs two clicks to move one: change edges per click
+(most encoders are 4; the count under the position is what one click makes). Saved on the panel; saving does not move a knob.