diff --git a/firmware/patternflow/console/home.html b/firmware/patternflow/console/home.html
index efdaaccd..0a094ec2 100644
--- a/firmware/patternflow/console/home.html
+++ b/firmware/patternflow/console/home.html
@@ -166,6 +166,7 @@
—
+
Panel
@@ -334,6 +335,18 @@
if(d)return d+'d '+h+'h';if(h)return h+'h '+m+'m';
if(m)return m+'m';return s+'s';
}
+// Optional status chips from feature appendStatus fields. Labels stay
+// feature-agnostic so this core page never names a feature (R5).
+function paintPerf(s){
+ var el=$('nowperf');if(!el)return;
+ var t=[];
+ if(s.mqttRole==='publisher')t.push('Pub');
+ else if(s.mqttRole==='subscriber')t.push('Sub');
+ if(typeof s.sequenceMode==='boolean')
+ t.push(s.sequenceMode?'Playlist':'Browse');
+ if(t.length){el.hidden=false;el.textContent=t.join(' · ')}
+ else{el.hidden=true;el.textContent=''}
+}
function tick(){
if(document.hidden)return;
fetch('/api/status',{cache:'no-store'}).then(function(r){return r.json()}).then(function(s){
@@ -356,6 +369,7 @@
$('nowsub').textContent=(s.frameUs?Math.round(1e6/s.frameUs)+' fps · ':'')+
(s.activeIsModule?'module':'built in');
}
+ paintPerf(s);
paintPower(s.sleep);
paintBrightness(s.brightness);
$('s-pat').textContent=s.patterns+' ('+s.presets+' built in + '+s.modules+' modules)';
@@ -388,7 +402,7 @@
}
if(s.params)updateSliders(s.params);
checkUpdate(s.version,s.variant);
- }).catch(function(){$('nowsub').textContent='cannot reach device'});
+ }).catch(function(){$('nowsub').textContent='cannot reach device';var p=$('nowperf');if(p){p.hidden=true;p.textContent=''}});
}
// ── Panel switch ──────────────────────────────────────────────
diff --git a/firmware/patternflow/features/pf_feature.h b/firmware/patternflow/features/pf_feature.h
index 4ae0d582..9b2a2385 100644
--- a/firmware/patternflow/features/pf_feature.h
+++ b/firmware/patternflow/features/pf_feature.h
@@ -172,6 +172,21 @@ struct PFFeature {
// a full pass over the frame is ~8 k pixels and must stay well under a
// millisecond.
const uint8_t* (*composeFrame)(const uint8_t* frame, int w, int h);
+
+ // SELECT screen (pattern browser). Optional — null on every feature leaves
+ // the core's default SELECT untouched. A feature that owns an alternate
+ // SELECT mode (Sequences playlist) implements these so the sketch never
+ // has to name that feature.
+ //
+ // handleSelectInput: consume SELECT knobs; return true to suppress the
+ // core's default K4 pattern browse for this frame.
+ // drawSelect: return true if the feature drew the entire SELECT UI
+ // (sketch skips drawSelectingMode).
+ // decorateSelect: called after the default SELECT draw when drawSelect
+ // returned false — add a chrome hint without replacing the browser.
+ bool (*handleSelectInput)(InputFrame&);
+ bool (*drawSelect)();
+ void (*decorateSelect)();
};
// ── Legacy name shim (2026-08-30) ───────────────────────────────────────
diff --git a/firmware/patternflow/features/pf_features.h b/firmware/patternflow/features/pf_features.h
index 88c460f2..b98b89e9 100644
--- a/firmware/patternflow/features/pf_features.h
+++ b/firmware/patternflow/features/pf_features.h
@@ -203,6 +203,34 @@ inline const char* capAt(size_t i) {
}
inline size_t count() { return PF_FEATURE_COUNT; }
+// SELECT mode. First feature that returns true from drawSelect owns the
+// whole SELECT frame. handleSelectInput runs for every feature; any true
+// suppresses default K4 browse. decorateSelect runs after the default
+// draw when nobody claimed the frame.
+inline bool handleSelectInput(InputFrame& input) {
+ bool suppress = false;
+ for (size_t i = 0; i < PF_FEATURE_COUNT; i++) {
+ if (PF_FEATURES[i]->handleSelectInput &&
+ PF_FEATURES[i]->handleSelectInput(input)) {
+ suppress = true;
+ }
+ }
+ return suppress;
+}
+
+inline bool drawSelect() {
+ for (size_t i = 0; i < PF_FEATURE_COUNT; i++) {
+ if (PF_FEATURES[i]->drawSelect && PF_FEATURES[i]->drawSelect()) return true;
+ }
+ return false;
+}
+
+inline void decorateSelect() {
+ for (size_t i = 0; i < PF_FEATURE_COUNT; i++) {
+ if (PF_FEATURES[i]->decorateSelect) PF_FEATURES[i]->decorateSelect();
+ }
+}
+
} // namespace PFFeatures
// ── Legacy name shim (2026-08-30) ───────────────────────────────────────
diff --git a/firmware/patternflow/features/show/core_show_select.h b/firmware/patternflow/features/show/core_show_select.h
new file mode 100644
index 00000000..cd6de206
--- /dev/null
+++ b/firmware/patternflow/features/show/core_show_select.h
@@ -0,0 +1,68 @@
+// ═══════════════════════════════════════════════════════════
+// PatternFlow - SELECT chrome for the Sequences feature
+//
+// The sketch must not name this feature. These helpers implement the
+// SELECT hooks so Performance can toggle Normal ↔ Sequence playlist
+// from K3 without putting PatternflowShow:: into patternflow.ino.
+//
+// License: MIT
+// ═══════════════════════════════════════════════════════════
+#pragma once
+
+#include "core_show.h"
+#include "../../src/core_ui_text.h"
+
+// dma_display is declared in the sketch / core_display; same pattern as
+// the weather clock overlay — features draw without including the HUB75 init.
+
+namespace PatternflowShowSelect {
+
+inline uint16_t ledC() { return dma_display->color565(232, 85, 46); }
+inline uint16_t dimC() { return dma_display->color565(80, 80, 80); }
+inline uint16_t greyC() { return dma_display->color565(160, 160, 160); }
+inline uint16_t softC() { return dma_display->color565(200, 200, 200); }
+
+inline bool handleSelectInput(InputFrame& input) {
+ if (input.knobDeltas[2] != 0) {
+ bool nowSeq = PatternflowShow::toggleSequenceMode();
+ Serial.printf(">>> SELECT mode → %s\n", nowSeq ? "SEQUENCE" : "NORMAL");
+ input.knobDeltas[2] = 0;
+ }
+ // In Sequence mode the playlist owns the panel — suppress K4 browse.
+ return PatternflowShow::isSequenceMode();
+}
+
+inline bool drawSelect() {
+ if (!PatternflowShow::isSequenceMode()) return false;
+ if (!dma_display) return true;
+
+ uint16_t screenH = dma_display->height();
+ PatternflowUiText::drawChromeLine("SEQUENCE", 10,
+ dma_display->color565(190, 190, 190));
+
+ char name[64];
+ if (PatternflowShow::isPlaying() && PatternflowShow::title()[0]) {
+ snprintf(name, sizeof(name), "%s", PatternflowShow::title());
+ } else if (PatternflowShow::storedSize() > 0) {
+ snprintf(name, sizeof(name), "%u SHOWS",
+ (unsigned)PatternflowShow::storedSize());
+ } else {
+ snprintf(name, sizeof(name), "NO PLAYLIST");
+ }
+ PatternflowUiText::drawWrappedName(name, screenH / 2,
+ dma_display->color565(255, 255, 255));
+ PatternflowUiText::drawChromeLine("HOLD TO SELECT", screenH - 22, softC());
+ PatternflowUiText::drawChromeLine("K3=NORMAL", screenH - 12, greyC());
+ PatternflowUiText::useDefaultFont();
+ return true;
+}
+
+inline void decorateSelect() {
+ if (PatternflowShow::isSequenceMode()) return;
+ if (!dma_display) return;
+ uint16_t screenH = dma_display->height();
+ PatternflowUiText::drawChromeLine("K3=SEQUENCE", screenH - 12, greyC());
+ PatternflowUiText::useDefaultFont();
+}
+
+} // namespace PatternflowShowSelect
diff --git a/firmware/patternflow/features/show/feature_show.h b/firmware/patternflow/features/show/feature_show.h
index 92dcd3c4..50b28410 100644
--- a/firmware/patternflow/features/show/feature_show.h
+++ b/firmware/patternflow/features/show/feature_show.h
@@ -18,6 +18,7 @@
#include "core_show.h"
#include "core_show_http.h"
#include "core_show_schedule.h"
+#include "core_show_select.h"
#include "core_library_http.h"
namespace PFFeatureShow {
@@ -68,6 +69,14 @@ inline void drawOverlay(const PFFeatureFrame& frame) {
}
}
+// Lean fields for /api/status (home card). Full show state stays on /api/show.
+inline void appendStatus(String& json) {
+ json += ",\"sequenceMode\":";
+ json += PatternflowShow::isSequenceMode() ? "true" : "false";
+ json += ",\"showPlaying\":";
+ json += PatternflowShow::isPlaying() ? "true" : "false";
+}
+
inline const PFFeature descriptor = {
"show", // name
"shows", // cap reported by /api/status
@@ -84,12 +93,16 @@ inline const PFFeature descriptor = {
nullptr, // shortName - not listed in the device menu
nullptr, // isRuntimeEnabled
nullptr, // setRuntimeEnabled
- nullptr, // appendStatus
+ appendStatus,
drawOverlay,
"/show", // navPath - the console header link
"Sequences", // navLabel
"Timed shows from .pfs tables — play one, chain a playlist, "
"schedule night and wake.",
+ nullptr, // composeFrame
+ PatternflowShowSelect::handleSelectInput,
+ PatternflowShowSelect::drawSelect,
+ PatternflowShowSelect::decorateSelect,
};
} // namespace PFFeatureShow
diff --git a/firmware/patternflow/patternflow.ino b/firmware/patternflow/patternflow.ino
index 3d4b7bae..394fe4a1 100644
--- a/firmware/patternflow/patternflow.ino
+++ b/firmware/patternflow/patternflow.ino
@@ -1881,12 +1881,16 @@ void loop() {
drawBrightnessNotice();
}
} else {
+ // Features may own an alternate SELECT mode (e.g. Sequences playlist).
+ // If any returns true, skip the default K4 pattern browse this frame.
+ bool selectFeatureOwnsBrowse = PFFeatures::handleSelectInput(input);
int selectSteps = 0;
- if (input.knobDeltas[3] != 0) {
+ if (!selectFeatureOwnsBrowse && input.knobDeltas[3] != 0) {
selectAccum += input.knobDeltas[3];
selectSteps = selectAccum / SELECT_DETENTS_PER_STEP; // truncates toward zero
selectAccum -= selectSteps * SELECT_DETENTS_PER_STEP;
}
+ if (selectFeatureOwnsBrowse) selectAccum = 0;
if (selectSteps != 0) {
currentPatternIdx += selectSteps;
// Floored modulo: OSC /knob/4/delta can deliver a delta more negative
@@ -1949,7 +1953,10 @@ void loop() {
}
dma_display->setRotation(1);
- drawSelectingMode();
+ if (!PFFeatures::drawSelect()) {
+ drawSelectingMode();
+ PFFeatures::decorateSelect();
+ }
if (brightnessAdjusting) {
drawBrightnessNotice(); // mode indicator, same as in RUNNING
}
diff --git a/firmware/patternflow/src/home_index.h b/firmware/patternflow/src/home_index.h
index 6a438e54..c7d50912 100644
--- a/firmware/patternflow/src/home_index.h
+++ b/firmware/patternflow/src/home_index.h
@@ -197,6 +197,7 @@ const char HOME_INDEX_HTML[] PROGMEM = R"HTML(
—
+
Panel
@@ -365,6 +366,18 @@ function dur(s){
if(d)return d+'d '+h+'h';if(h)return h+'h '+m+'m';
if(m)return m+'m';return s+'s';
}
+// Optional status chips from feature appendStatus fields. Labels stay
+// feature-agnostic so this core page never names a feature (R5).
+function paintPerf(s){
+ var el=$('nowperf');if(!el)return;
+ var t=[];
+ if(s.mqttRole==='publisher')t.push('Pub');
+ else if(s.mqttRole==='subscriber')t.push('Sub');
+ if(typeof s.sequenceMode==='boolean')
+ t.push(s.sequenceMode?'Playlist':'Browse');
+ if(t.length){el.hidden=false;el.textContent=t.join(' · ')}
+ else{el.hidden=true;el.textContent=''}
+}
function tick(){
if(document.hidden)return;
fetch('/api/status',{cache:'no-store'}).then(function(r){return r.json()}).then(function(s){
@@ -387,6 +400,7 @@ function tick(){
$('nowsub').textContent=(s.frameUs?Math.round(1e6/s.frameUs)+' fps · ':'')+
(s.activeIsModule?'module':'built in');
}
+ paintPerf(s);
paintPower(s.sleep);
paintBrightness(s.brightness);
$('s-pat').textContent=s.patterns+' ('+s.presets+' built in + '+s.modules+' modules)';
@@ -419,7 +433,7 @@ function tick(){
}
if(s.params)updateSliders(s.params);
checkUpdate(s.version,s.variant);
- }).catch(function(){$('nowsub').textContent='cannot reach device'});
+ }).catch(function(){$('nowsub').textContent='cannot reach device';var p=$('nowperf');if(p){p.hidden=true;p.textContent=''}});
}
// ── Panel switch ──────────────────────────────────────────────
@@ -625,588 +639,603 @@ document.addEventListener('visibilitychange',function(){if(!document.hidden)tick