Loading files
07e931b46This file is pinned to the selected release. Download the original to open it in your design tool.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<script src="/pf-console.js"></script>
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Patternflow</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' fill='%230C0B09'/%3E%3Crect x='5' y='5' width='6' height='6' fill='%23FF5C2E'/%3E%3C/svg%3E">
<style>
/* No web fonts: the stacks fall back to what the device is showing on, and
the panel's own hotspot has no internet to fetch them from anyway. */
:root{--bg:#0C0B09;--panel:#131110;--ink:#EDE7DB;--muted:#8A8272;--faint:#5A5546;
--ghost:#221F18;--rule:#242118;--led:#FF5C2E;--ok:#57B87F;--warn:#D9A03F;
--sans:'Inter',ui-sans-serif,system-ui,sans-serif;
--mono:'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,monospace}
*{box-sizing:border-box;margin:0;padding:0}
body{min-height:100vh;background:var(--bg);color:var(--ink);
font:15px/1.55 var(--sans);-webkit-font-smoothing:antialiased}
/* The padding is the wrapper's, not body's, so the chrome's header (body's
first child) runs edge to edge like on every other page. max-width grows
by the side padding to keep the 560px column. */
.panel{width:100%;max-width:608px;margin:16px auto 0;padding:24px 24px 64px}
header{display:flex;align-items:center;gap:8px;padding-bottom:12px;border-bottom:1px solid var(--rule);margin-bottom:28px}
.dot{width:7px;height:7px;background:var(--led);border-radius:1px}
h1{font-size:15px;font-weight:600;letter-spacing:.01em;margin:0;flex:1}
.sub{font-family:var(--mono);font-size:11px;color:var(--faint)}
/* ── Device card (one small status poll — the device never streams pixels) ── */
.screen{background:var(--panel);border:1px solid var(--rule);padding:20px 20px 6px}
.now-hdr{display:flex;align-items:center;gap:10px}
.now-k{font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--faint);flex:1;white-space:nowrap}
.now-nav{display:flex;gap:5px}
.now-btn{
position:relative;background:transparent;border:1px solid var(--rule);color:var(--muted);
font-family:var(--mono);font-size:12px;line-height:1;width:28px;height:24px;
border-radius:2px;cursor:pointer;display:inline-flex;align-items:center;
justify-content:center;transition:all .15s ease
}
.now-btn:hover{color:var(--ink);border-color:var(--led)}
.now-btn:active{background:var(--ghost);transform:scale(0.92)}
/* Result lines, filled by PF.say next to the control that caused them. */
.msg{font-family:var(--mono);font-size:10.5px;color:var(--faint)}
.msg.pf-err{color:var(--led)}.msg.pf-ok{color:var(--ok)}
.now-hdr .msg{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.pf-offline .now,.pf-offline .stat .v{opacity:.5}
.now{font-size:25px;font-weight:600;letter-spacing:-.02em;line-height:1.15;margin:7px 0 3px;min-height:29px;overflow-wrap:anywhere}
.now-sub{font-family:var(--mono);font-size:11px;color:var(--muted);min-height:15px}
/* ── Panel switch ─────────────────────────────────────────────
A segmented pair, not a sliding toggle: /mqtt already picks between
states this way (.ch / .role with an .on class) and this is the same
gesture, so the console keeps one vocabulary. It also shows which state
you are in rather than asking you to read a label and infer it. */
.pwr{display:flex;align-items:center;gap:12px;margin-top:16px;
padding-top:13px;border-top:1px solid var(--rule)}
.pwr-k{font-family:var(--mono);font-size:10px;letter-spacing:.1em;
text-transform:uppercase;color:var(--faint);flex:1}
.pwr-btns{display:flex;gap:6px}
.pwr-b{position:relative;font-family:var(--mono);font-size:11px;letter-spacing:.08em;
text-transform:uppercase;padding:6px 14px;cursor:pointer;
background:transparent;color:var(--muted);border:1px solid var(--rule);
transition:color .15s ease,border-color .15s ease}
.pwr-b:hover{color:var(--ink);border-color:var(--muted)}
.pwr-b.on{color:var(--ink);border-color:var(--led);box-shadow:inset 0 0 0 1px var(--led)}
.pwr-note{min-height:14px;margin-top:7px}
/* ── Brightness ── under the switch: the other thing a hand at the panel
does (K1's long-press session), so the console has it too. */
.bri{display:flex;align-items:center;gap:12px;margin-top:10px}
.bri .pwr-k{flex:none}
.bri-v{font-family:var(--mono);font-size:11px;color:var(--muted);width:34px;text-align:right;flex:none}
/* ── Live parameter sliders (K1..K4) ────────────────────────── */
.ctrls{margin-top:16px;padding-top:14px;border-top:1px solid var(--rule)}
.ctrls-hdr{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:12px}
.ctrls-k{font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--faint)}
.slider-row{display:flex;align-items:center;gap:12px;margin-bottom:12px}
.slider-row:last-child{margin-bottom:0}
.s-label{font-family:var(--mono);font-size:11px;font-weight:600;color:var(--muted);width:20px;flex:none}
.slider-row input[type=range],.bri input[type=range]{
flex:1;height:6px;appearance:none;-webkit-appearance:none;background:var(--ghost);
border-radius:3px;outline:none;cursor:pointer;accent-color:var(--led);touch-action:pan-y
}
.slider-row input[type=range]::-webkit-slider-thumb,.bri input[type=range]::-webkit-slider-thumb{
appearance:none;-webkit-appearance:none;width:18px;height:18px;border-radius:50%;
background:var(--ink);border:2px solid var(--led);cursor:pointer;transition:transform .1s ease
}
.slider-row input[type=range]:active::-webkit-slider-thumb,.bri input[type=range]:active::-webkit-slider-thumb{
transform:scale(1.2);background:var(--led)
}
.slider-row input[type=range]::-moz-range-thumb,.bri input[type=range]::-moz-range-thumb{
width:18px;height:18px;border-radius:50%;background:var(--ink);border:2px solid var(--led);cursor:pointer
}
/* Fingers: a vertical swipe across a slider scrolls the page (pan-y), and
every control gets a bigger hit area drawn exactly as before. A slider's
band is 28px with the 6px track as a centred background, its thumb 28px
of which the outer 5px is transparent border (the LED ring moves to an
inset shadow); negative margins keep the rows where they were. The step
buttons reach 44px outward, away from each other, so they never overlap. */
@media(pointer:coarse){
.slider-row input[type=range],.bri input[type=range]{height:28px;margin:-5px 0;border-radius:0;
background:linear-gradient(var(--ghost),var(--ghost)) center/100% 6px no-repeat}
.slider-row input[type=range]::-webkit-slider-thumb,.bri input[type=range]::-webkit-slider-thumb{
box-sizing:border-box;width:28px;height:28px;border:5px solid transparent;
background-clip:padding-box;box-shadow:inset 0 0 0 2px var(--led)}
.slider-row input[type=range]::-moz-range-thumb,.bri input[type=range]::-moz-range-thumb{
box-sizing:border-box;width:28px;height:28px;border:5px solid transparent;
background-clip:padding-box;box-shadow:inset 0 0 0 2px var(--led)}
.now-btn::after,.pwr-b::after{content:"";position:absolute;top:-12px;bottom:-12px;left:-3px;right:-3px}
#pat-prev::after{left:-15px}#pat-next::after{right:-15px}
.pwr-b::after{top:-8px;bottom:-8px}
}
.stats{display:grid;grid-template-columns:1fr 1fr;gap:0 20px;margin-top:16px}
.stat{padding:10px 0 9px;border-top:1px solid var(--rule)}
.stat .k{font-family:var(--mono);font-size:10px;letter-spacing:.1em;text-transform:uppercase;color:var(--faint)}
.stat .v{font-family:var(--mono);font-size:12.5px;color:var(--ink);margin-top:4px;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.stat .bar{height:3px;background:var(--ghost);margin-top:7px}
/* The edition block is a link, so it must not inherit link styling and
must not look clickable in the middle of a list of readouts. The
sub-line is where the one thing worth saying goes. */
a.stat{display:block;text-decoration:none;color:inherit}
a.stat:hover .v{color:var(--led)}
a.stat:hover .sub{color:var(--ink)}
.stat .sub{font-size:11px;line-height:1.45;color:var(--faint);margin-top:5px;
white-space:normal}
.stat .bar i{display:block;height:100%;width:0;background:var(--led);transition:width .3s}
/* ── Update banner (hidden until the browser proves it) ───── */
#upd{display:none;margin-top:14px;border:1px solid var(--led);padding:11px 14px;
font-size:13px;color:var(--ink);text-decoration:none;
display:none;align-items:baseline;gap:10px}
#upd .tag{font-family:var(--mono);font-size:10px;letter-spacing:.08em;color:var(--led);
text-transform:uppercase;white-space:nowrap}
#upd:hover{background:rgba(255,92,46,.06)}
#upd .go{margin-left:auto;font-family:var(--mono);font-size:11px;color:var(--muted);white-space:nowrap}
/* On the panel's hotspot with no home network: the next step, up top. */
.callout{display:block;margin:-10px 0 24px;border:1px solid var(--led);padding:11px 14px;
font-size:13px;color:var(--ink);text-decoration:none}
.callout:hover{background:rgba(255,92,46,.06)}
.stat.wide{grid-column:1/-1}.stat.wide .v{white-space:normal}
/* Author display rules (.pf-kicker, .callout) would otherwise beat the UA's. */
[hidden]{display:none!important}
/* ── Feature rows ─────────────────────────────────────────── */
.rows{margin-top:36px}
.pf-kicker{display:block;font-family:var(--mono);font-size:11px;letter-spacing:.14em;text-transform:uppercase;
color:var(--faint);margin-bottom:6px}
.pf-kicker + nav{margin-bottom:28px}
nav{border-top:1px solid var(--rule)}
nav:last-of-type{margin-bottom:0}
.pf-row{display:block;position:relative;padding:16px 92px 16px 0;border-bottom:1px solid var(--rule);
color:var(--muted);text-decoration:none;overflow:hidden;transition:color .15s ease}
a.pf-row:hover{color:var(--ink)}
a.pf-row:hover .pf-ghost{color:var(--muted)}
.pf-ghost{position:absolute;right:-2px;top:50%;transform:translateY(-50%);color:var(--ghost);
font-size:52px;font-weight:300;letter-spacing:-.04em;line-height:1;pointer-events:none;transition:color .15s ease}
.pf-row-t{font-size:17px;font-weight:500;letter-spacing:-.01em;line-height:1.3;color:var(--ink)}
.pf-row.soon .pf-row-t{color:var(--muted)}
.pf-row-d{max-width:36ch;margin-top:3px;color:var(--muted);font-size:13px;line-height:1.45}
.pf-row.soon,.pf-row.soon .pf-row-d{color:var(--faint)}
.tag{display:inline-block;font-family:var(--mono);font-size:10px;letter-spacing:.08em;text-transform:uppercase;
border:1px solid var(--faint);color:var(--muted);padding:3px 8px 2px;margin-left:10px;vertical-align:2px}
footer{margin-top:34px;display:flex;justify-content:space-between;gap:16px;
font-family:var(--mono);font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--faint)}
footer a{color:var(--muted);text-decoration:none}
footer a:hover{color:var(--ink)}
@media(max-width:560px){.panel{padding:12px 14px 52px}
.pf-ghost{font-size:42px}.pf-row{padding-right:70px}
}
/* Desktop: the whole console on one screen — panel pinned left, rows
right — instead of a phone column floating in empty space. */
@media(min-width:960px){
.panel{max-width:1168px}
.grid{display:grid;grid-template-columns:500px minmax(0,1fr);gap:60px;align-items:start}
.left{position:sticky;top:44px}
.rows{margin-top:0}}
</style></head><body>
<main class="panel">
<header><span class="dot"></span><h1>Console</h1><span class="sub">device</span></header>
<a class="callout" id="hsCall" href="/wifi" hidden>You’re on the panel’s own network — add your Wi-Fi →</a>
<div class="grid">
<div class="left">
<div class="screen">
<div class="now-hdr">
<span class="now-k">Now playing</span>
<span class="msg" id="stepmsg"></span>
<div class="now-nav">
<button class="now-btn" id="pat-prev" title="Previous pattern" type="button">←</button>
<button class="now-btn" id="pat-next" title="Next pattern" type="button">→</button>
</div>
</div>
<div class="now" id="now">—</div>
<div class="now-sub" id="nowsub"></div>
<div class="now-sub" id="nowperf" hidden></div>
<div class="pwr">
<span class="pwr-k">Panel</span>
<div class="pwr-btns">
<button class="pwr-b" id="p-on" type="button">On</button>
<button class="pwr-b" id="p-sleep" type="button">Sleep</button>
</div>
</div>
<div class="pwr-note msg" id="pwrnote"></div>
<div class="bri">
<span class="pwr-k">Brightness</span>
<input type="range" class="pf-slider" id="bri" min="2" max="100" step="1" value="80" aria-label="Panel brightness">
<span class="bri-v" id="bri-v">—</span>
</div>
<div class="ctrls" id="ctrls">
<div class="ctrls-hdr">
<span class="ctrls-k">Live Knobs</span>
<span class="msg" id="ctrlmsg"></span>
</div>
<div class="slider-row">
<span class="s-label">K1</span>
<input type="range" class="pf-slider" id="sl-0" min="0" max="100" step="1" value="50">
</div>
<div class="slider-row">
<span class="s-label">K2</span>
<input type="range" class="pf-slider" id="sl-1" min="0" max="100" step="1" value="50">
</div>
<div class="slider-row">
<span class="s-label">K3</span>
<input type="range" class="pf-slider" id="sl-2" min="0" max="100" step="1" value="50">
</div>
<div class="slider-row">
<span class="s-label">K4</span>
<input type="range" class="pf-slider" id="sl-3" min="0" max="100" step="1" value="50">
</div>
</div>
<div class="stats">
<div class="stat"><span class="k">Patterns</span><div class="v" id="s-pat">—</div></div>
<div class="stat"><span class="k">Storage</span><div class="v" id="s-fs">—</div>
<div class="bar"><i id="s-fsbar"></i></div></div>
<div class="stat"><span class="k">Wi-Fi</span><div class="v" id="s-wifi">—</div></div>
<div class="stat"><span class="k">Address</span><div class="v" id="s-ip">—</div></div>
<!-- The panel's own access point, while it is up: a separate network
from the one above, so it gets its own line rather than a merge. -->
<div class="stat wide" id="s-hs-b" hidden><span class="k">Hotspot</span><div class="v" id="s-hs">—</div></div>
<div class="stat"><span class="k">Memory</span><div class="v" id="s-heap">—</div></div>
<div class="stat"><span class="k">Uptime</span><div class="v" id="s-up">—</div></div>
<div class="stat"><span class="k">Firmware</span><div class="v" id="s-fw">—</div></div>
<!-- Which edition this panel is running. It was a line of small print in
the footer, which is where you put something nobody needs — and this
is the first thing to know when a feature you expected is not here.
Same block as everything else, and a link either way: on an edition
it goes to that edition's card, on the default it goes to the shelf,
because "where are the other ones" is the question the default
raises. -->
<a class="stat" id="s-ed-block" href="https://patternflow.work/editions">
<span class="k">Edition</span>
<div class="v" id="s-ed">—</div>
<div class="sub" id="s-ed-sub"></div>
</a>
</div>
</div>
<!-- Points at the SITE, not this device's own /update: that page reads the
release manifest and hands the new image to this board over Wi-Fi, so
there is no .bin to find and nothing to download by hand. -->
<a id="upd" href="https://patternflow.work/update" target="_blank" rel="noopener">
<span class="tag">update</span>
<span id="updtext"></span><span class="go">how to update →</span></a>
</div>
<div class="rows">
<span class="pf-kicker">Patterns</span>
<nav>
<a class="pf-row" href="/patterns">
<span class="pf-ghost">01</span>
<div class="pf-row-t">Pattern library</div>
<div class="pf-row-d">Drop in a <b>.pfm</b> module or a whole <b>.zip</b> pack — play, arrange, delete.</div>
</a>
</nav>
<!-- Whatever features this firmware carries, one row each. The rows are
not written into this page: they arrive from /api/status featureNav,
contributed by the loaded features, so a page the core has never heard
of still gets a row and a build with none gets no group. This page
used to hand-write rows for /show, /mqtt and /weather - capability-
gated, so they degraded, but named in a core page all the same, and
the first feature page the list did not know about proved the point by
having no row at all. -->
<span class="pf-kicker" id="featKick" hidden>Features</span>
<nav id="featRows" hidden></nav>
<span class="pf-kicker">Setup</span>
<nav>
<a class="pf-row" href="/wifi">
<span class="pf-ghost">03</span>
<div class="pf-row-t">Wi-Fi</div>
<div class="pf-row-d">Remember several networks — the device joins whichever one it finds.</div>
</a>
<a class="pf-row" href="/knobs">
<span class="pf-ghost">04</span>
<div class="pf-row-t">Knobs</div>
<div class="pf-row-d">Which way each encoder counts, and how many edges make a click — for the parts that differ. Nothing to rebuild.</div>
</a>
</nav>
<span class="pf-kicker">Device</span>
<nav>
<a class="pf-row" href="/update">
<span class="pf-ghost">05</span>
<div class="pf-row-t">Firmware update</div>
<div class="pf-row-d">Drop a .bin — the device flashes itself over the LAN and reboots. Patterns and Wi-Fi are untouched.</div>
</a>
<a class="pf-row" href="/status">
<span class="pf-ghost">06</span>
<div class="pf-row-t">Status</div>
<div class="pf-row-d">Frame rate, memory, storage and network — what the device is actually doing.</div>
</a>
</nav>
</div>
</div>
<footer><span id="host"></span>
<a href="https://patternflow.work" target="_blank" rel="noopener">patternflow.work</a></footer>
</main>
<script>
function $(i){return document.getElementById(i)}
$('host').textContent=location.hostname;
// ── 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.
var navKey;
function gate(s){
var i,nav=(s&&s.featureNav)||[],k=JSON.stringify(nav);
if(k===navKey)return;
navKey=k;
var f=$('featRows'),fk=$('featKick');
if(f){
f.innerHTML='';
for(i=0;i<nav.length;i++){
var e=nav[i];if(!e||e.length<2)continue;
var a=document.createElement('a');
a.className='pf-row';a.href=e[0];
a.innerHTML='<span class="pf-ghost">00</span>'+
'<div class="pf-row-t"></div><div class="pf-row-d"></div>';
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');
}
var ghosts=document.querySelectorAll('.rows .pf-ghost');
for(i=0;i<ghosts.length;i++)
ghosts[i].textContent=(i+1<10?'0':'')+(i+1);
}
// ── Device card ───────────────────────────────────────────────
// Everything on the card comes out of /api/status: the chrome's copy
// (PF.status), kept fresh every 3 s (PF.watchStatus) on its one lane, paused
// while the tab is hidden. The device streams no pixels: a frame-preview
// endpoint existed for one day and captured the render loop — see the note
// in core_patterns_http.h.
function dur(s){
var d=Math.floor(s/86400),h=Math.floor(s%86400/3600),m=Math.floor(s%3600/60);
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=''}
}
var last=null;
function paint(s){
last=s;
gate(s);
// Three states, in the order the device resolves them. Asleep outranks
// paused: a sleeping device is asleep whatever else is going on, and the
// frame rate below would be a number from before it went dark either way.
if(s.sleep){
$('now').textContent='Asleep';
$('nowsub').textContent=s.active?(s.active+' resumes on wake'):'panel off';
}else if(s.consolePaused){
// Opening the console no longer pauses anything; this is an upload
// batch or a format holding the pattern's memory while it writes the
// volume — say so instead of showing a frame rate from the paused loop.
$('now').textContent='Paused';
$('nowsub').textContent='patterns are being written — resumes when that is done';
}else if(s.loading&&s.active==='-'){
// A module loads on the other core for a second or so and `active`
// reads "-" meanwhile: keep the name a step just painted.
$('nowsub').textContent='loading…';
}else{
$('now').textContent=s.active||'—';
$('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)';
if(s.fsMounted){
$('s-fs').textContent=Math.round((s.fsTotal-s.fsUsed)/1048576*10)/10+' MB free';
$('s-fsbar').style.width=(s.fsTotal?s.fsUsed/s.fsTotal*100:0)+'%';
}else{$('s-fs').textContent='not mounted'}
// The station (the network the panel joined) and the panel's own hotspot
// are two links that can be up together; each gets its own line.
var hs=s.hotspot||{},n=hs.clients|0;
$('s-wifi').textContent=s.wifi?(s.ssid+' · '+s.rssi+' dBm'):hs.up?'not connected':'offline';
$('s-ip').textContent=s.wifi?s.ip:'—';
$('s-hs-b').hidden=!hs.up;
if(hs.up)$('s-hs').textContent=hs.ssid+' · '+(hs.ip||'192.168.4.1')+' · '+n+(n===1?' client':' clients');
// Reached over that hotspot with no home network joined: the one useful
// next step is giving the panel one.
$('hsCall').hidden=!(s.viaHotspot&&!s.wifi);
$('s-heap').textContent=Math.round(s.heapInternal/1024)+'K + '+
Math.round(s.heapPsram/1048576)+'M psram';
$('s-up').textContent=dur(s.uptime);
$('s-fw').textContent='v'+s.version;
// Edition. The default build says what it is and where the others are,
// because "the feature I wanted is missing" is the question it provokes;
// an edition says which one and links to its own card.
var ed=s.variant&&s.variant!=='core'?s.variant:'';
if(ed){
$('s-ed').textContent=ed+(s.variantVersion?' '+s.variantVersion:'');
$('s-ed-sub').textContent='This panel runs the '+ed+' edition. '+
'Tap for what it carries, or to go back to the standard firmware.';
$('s-ed-block').href='https://patternflow.work/editions#'+encodeURIComponent(ed);
}else{
$('s-ed').textContent='Patternflow';
$('s-ed-sub').textContent='The standard firmware. Other editions carry '+
'what this one does not — tap to see them. Installing one is a click '+
'and keeps your patterns and settings.';
$('s-ed-block').href='https://patternflow.work/editions';
}
if(s.params)updateSliders(s.params);
checkUpdate(s.version,s.variant);
}
// ── Panel switch ──────────────────────────────────────────────
// The switch is a VIEW of the device's state, not its own truth: K1 on the
// device and an MQTT message change the same thing, and the 3 s status is
// what keeps this honest when they do.
//
// The one exception is the moment after a click. POST /api/sleep only queues
// the transition — loop() performs it on its next pass — so a status landing
// in between still reports the old value and would snap the switch back under
// the cursor. settleUntil suppresses that for a beat past the reply, which is
// long enough for the device to have actually moved; the next status after
// it confirms.
var settleUntil=0;
function paintPower(asleep,force){
if(!force&&Date.now()<settleUntil)return;
$('p-on').className='pwr-b'+(asleep?'':' on');
$('p-sleep').className='pwr-b'+(asleep?' on':'');
}
function note(t,k){PF.say(t,k||'info',$('pwrnote'))}
function setPower(asleep){
settleUntil=Date.now()+1500;
paintPower(asleep,true);
note(asleep?'sleeping…':'waking…');
fetch('/api/sleep',{method:'POST',cache:'no-store',
headers:{'Content-Type':'application/x-www-form-urlencoded'},
body:'on='+(asleep?'1':'0')})
.then(function(r){if(!r.ok)throw 0;return r.json()})
.then(function(){
settleUntil=Math.max(settleUntil,Date.now()+800);
note(asleep?'panel off — any knob or button wakes it':'');
})
.catch(function(){
// Back to what the panel last said, not what was clicked.
settleUntil=0;
if(last)paintPower(last.sleep);
note('not switched — the panel did not answer','err');
});
}
$('p-on').addEventListener('click',function(){setPower(false)});
$('p-sleep').addEventListener('click',function(){setPower(true)});
// ── Brightness ────────────────────────────────────────────────
// A view of the device's value like the switch above: K1's long-press
// session moves the same number, and the 3 s status keeps the slider honest
// unless a hand is on it. GET /api/display?brightness= is the write - one
// request in flight, the newest value waiting behind it, so a drag cannot
// queue up on the one-connection server.
var briActive=false,briInFlight=false,briQueued=-1,briSettleUntil=0;
function briPct(b){return Math.max(2,Math.min(100,Math.round(b*100/255)))}
function paintBrightness(b){
if(briActive||Date.now()<briSettleUntil||typeof b!=='number')return;
var p=briPct(b);$('bri').value=p;$('bri-v').textContent=p+'%';
}
function pumpBri(){
if(briInFlight||briQueued<0)return;
var p=briQueued;briQueued=-1;briInFlight=true;
var v=Math.max(5,Math.min(255,Math.round(p*255/100)));
function done(){briInFlight=false;if(briQueued>=0)pumpBri()}
PF.get('/api/display?brightness='+v).then(done,function(x){
if(!x||!x.nav)note('brightness not set — '+(x&&x.status?'refused':'the panel did not answer'),'err');
done();
});
}
$('bri').addEventListener('input',function(){
var p=parseInt(this.value,10);if(isNaN(p))return;
$('bri-v').textContent=p+'%';briQueued=p;briSettleUntil=Date.now()+1500;pumpBri();
});
// Pointer events, and pointercancel with them: with pan-y a vertical swipe
// that starts on a slider becomes a scroll, which ends in pointercancel (or
// touchcancel), never an up/end, and "a hand is on it" would stick.
function held(sl,on,off){
var down=false;
function up(){if(down){down=false;off()}}
sl.addEventListener('pointerdown',function(){down=true;on()});
window.addEventListener('pointerup',up);
window.addEventListener('pointercancel',up);
}
held($('bri'),function(){briActive=true},function(){briActive=false;briSettleUntil=Date.now()+1500});
// ── Update check (runs in the browser, never on the device) ───
var updChecked=false;
// The manifest at patternflow.work describes CORE releases. Offering one
// of those to a panel running somebody else's firmware would talk a
// person into flashing away the thing they deliberately chose, with a
// version comparison that means nothing across two release lines. So on
// a variant this stays quiet: that maintainer announces their own
// updates, and the footer already links to them.
function checkUpdate(deviceVersion,variant){
if(updChecked||!deviceVersion)return;
if(variant&&variant!=='core')return;
updChecked=true;
fetch('https://patternflow.work/flash/manifest.json',{cache:'no-store'})
.then(function(r){if(!r.ok)throw 0;return r.json()})
.then(function(m){
var latest=String(m.version||'').replace(/^v/,'');
if(latest&&latest!==deviceVersion){
$('updtext').textContent='v'+latest+' is out — this device runs v'+deviceVersion+'.';
$('upd').style.display='flex';
}
}).catch(function(){/* offline LAN or no CORS — say nothing */});
}
// ── Live parameter sliders (K1..K4 · 0..100) ──────────────────
var activeSlider = -1;
var inFlight = false;
var queuedParams = {};
var lastReported = [50, 50, 50, 50];
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);
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]));
}
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);
}
}
initSliders();
// ── Quick pattern stepping (← / →) ───────────────────────────
// The reply names the new pattern; the next status confirms it.
function stepPattern(dir) {
var prev = $('now').textContent;
$('now').textContent = dir > 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'));
});
}
$('pat-prev').addEventListener('click', function() { stepPattern(-1); });
$('pat-next').addEventListener('click', function() { stepPattern(1); });
PF.watchStatus(3000);
PF.status(paint);
</script>
</body></html>