From 1c73c5d8180de302dd6d4833f02baad4c1b15ca6 Mon Sep 17 00:00:00 2001 From: Brett Gentile Date: Fri, 14 Aug 2026 19:09:10 -0400 Subject: [PATCH 1/2] feat(web): the beat engine, keyboard controls and layer catalog (#41) The keyboard becomes the primary driver. One pair of keys means the same thing all four days, and every state is one keypress from anywhere. The beat engine lives in state.js because the beat index is app state. `setBeat` resets every derived field before applying the beat, so no beat can depend on having passed through its predecessor -- which is what makes a beat render identically whether it was stepped into, stepped back to, or jumped at cold, and what forces the prefetch rule the spec keeps arriving at: a spine is installed complete or not at all. Ends never wrap; an out-of-range digit is a silent no-op rather than a clamp. Views are jumpable in any order and each remembers its own beat and layer set, so Flow & Cut stays parked on augmentation 3 while the instructor detours to answer a question. `R` is a full view reset -- beat 0, default layers, threat cleared -- leaving view and dataset untouched. The layer catalog is one global set with per-view entry states, never view-owned: the unit's strongest moments are cross-day mashups. The old single lane label splits into `caps` and `costrisk`, because Flow & Cut wants a capacity beside its flow and Cost & Risk wants cost and risk instead. All-layers-on is illegible in every scheme and is not a legal preset. The scaffold tier dims untouched lanes to 30% and owns opacity alone, made safe by the rule that an empty hot set means every lane is hot -- so the pristine network, whose subject is the graph itself, is never dimmed. Labels only on hot lanes falls out of that rather than being a special case. The token block gains the back-row smear as ONE authored quantity with a derivation per pattern. Two floors apply to every dashed mark and they are orthogonal: pitch (is it still broken?) is exactly independent of stroke weight, and presence (is it there?) is `weight x duty x opacity`. The failure mode is the whole argument for machine-checking them -- a dash that loses does not vanish, it becomes a dimmer solid line. The residual arc goes to stroke 4 / `22 18` and the min cut to `16 24`; the synthetic machinery and the removed lane stay under the presence floor deliberately, on a recorded and closed exemption list. tests/test_web_assets.py re-derives the 3.16 dashed-stroke floor from the smear rather than restating it, so retuning the room moves the floor instead of leaving a constant nobody can re-justify. Closes #41 --- src/clopt/web/graph.js | 37 ++-- src/clopt/web/index.html | 76 +++++-- src/clopt/web/render.js | 75 +++++-- src/clopt/web/state.js | 253 ++++++++++++++++++++++- src/clopt/web/style.css | 246 +++++++++++++++++++++-- src/clopt/web/views.js | 96 ++++++--- tests/test_web_assets.py | 419 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 1106 insertions(+), 96 deletions(-) diff --git a/src/clopt/web/graph.js b/src/clopt/web/graph.js index 8b12c21..776c846 100644 --- a/src/clopt/web/graph.js +++ b/src/clopt/web/graph.js @@ -602,19 +602,32 @@ export function buildGraph(svg, payload) { for (const edge of network.edges) { const bundle = handles.edges.get(edge.id); - /* Two texts share one anchor and one plate: the lane's static statistics - * (capacity, cost, risk) and the flow the solver put on it. They are - * separate elements rather than one because the layers that show them are - * switched independently. `flow` gets its content in a later ticket; the - * element exists from here so that nothing structural changes when it - * does. */ - const plate = element("rect", { class: "anchor-plate" }); - const stats = element("text", { class: "lane-label" }); + /* Three texts on two rows, sharing one frozen anchor. + * + * The flow numeral takes the upper row and the lane's statistics the lower + * one. `caps` and `costrisk` are two elements on the SAME row rather than + * one text carrying all three numbers, because the catalog switches them + * independently: Flow & Cut wants a capacity beside its flow and Cost & + * Risk wants cost and risk instead, and a combined label put two unasked-for + * numbers on screen in both. They are never both on by default -- and if + * both are switched on by hand they overlap, which is one of the reasons + * all-layers-on is not a legal preset. + * + * Each row carries its own plate, so a row that is switched off leaves no + * unbacked rectangle behind. The whole lot lives in one group per lane so + * that the scaffold tier can dim a lane and its numerals together, from one + * class write. */ + const group = element("g", { class: "lane-marks", "data-edge": edge.id }); + const plateFlow = element("rect", { class: "anchor-plate plate-flow" }); const flow = element("text", { class: "anchor-label" }); - marks.appendChild(plate); - marks.appendChild(stats); - marks.appendChild(flow); - bundle.canvasLabel = { plate, stats, flow }; + const plateStat = element("rect", { class: "anchor-plate plate-stat" }); + const caps = element("text", { class: "lane-label lane-caps" }); + const costrisk = element("text", { class: "lane-label lane-costrisk" }); + for (const part of [plateFlow, flow, plateStat, caps, costrisk]) { + group.appendChild(part); + } + marks.appendChild(group); + bundle.canvasLabel = { group, plateFlow, flow, plateStat, caps, costrisk }; } return handles; diff --git a/src/clopt/web/index.html b/src/clopt/web/index.html index bc65f44..9186ab0 100644 --- a/src/clopt/web/index.html +++ b/src/clopt/web/index.html @@ -110,17 +110,21 @@

Lanes

-->