From 0f592341aff61ae7bd12dfb06d2abe195d0b6f49 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 31 Aug 2026 04:36:47 +0000 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20keep=20Manual=E2=80=A6=20visible=20a?= =?UTF-8?q?fter=20leaving=20the=20planner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simple view hid the fallback buttons behind .advanced-only, so a house already on Self (manual) had no selected strategy and no way back. The toggle and the button row stay on the Plan card, open when the live mode is a fallback, and mark the tap before the POST. Signed-off-by: Cursor Agent --- .changeset/keep-manual-mode-buttons.md | 5 ++++ web/app.js | 24 +++++++++++++++++ web/index.html | 16 ++++++++---- web/mode-picker.test.mjs | 36 ++++++++++++++++++++++++++ 4 files changed, 76 insertions(+), 5 deletions(-) create mode 100644 .changeset/keep-manual-mode-buttons.md create mode 100644 web/mode-picker.test.mjs diff --git a/.changeset/keep-manual-mode-buttons.md b/.changeset/keep-manual-mode-buttons.md new file mode 100644 index 000000000..96a9b7111 --- /dev/null +++ b/.changeset/keep-manual-mode-buttons.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Keep Manual… strategy buttons on the Plan card in simple view, open them when the live mode is a manual fallback, and mark a tap before the server confirms. diff --git a/web/app.js b/web/app.js index 799bb0c97..1d4ca2212 100644 --- a/web/app.js +++ b/web/app.js @@ -787,6 +787,7 @@ if (btn.dataset.mode === data.mode) btn.classList.add("active"); else btn.classList.remove("active"); }); + revealManualModes(data.mode); // When planner is driving, grey out the grid-target slider and show a hint. var plannerActive = (data.mode || "").indexOf("planner_") === 0; var gridSlider = document.getElementById("grid-target-slider"); @@ -2306,6 +2307,8 @@ } function setMode(mode) { + markModeActive(mode); + revealManualModes(mode); apiFetch("/api/mode", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -2321,6 +2324,26 @@ }); } + function markModeActive(mode) { + document.querySelectorAll("#mode-buttons-primary button, #mode-buttons button").forEach(function (btn) { + if (btn.dataset.mode === mode) btn.classList.add("active"); + else btn.classList.remove("active"); + }); + } + + // Open the manual drawer when the live mode lives there, so a reload + // (or a change made from the phone app / HA) never leaves the current + // setting with no button on screen. + function revealManualModes(mode) { + var panel = document.getElementById("mode-buttons"); + var advBtn = document.getElementById("mode-advanced-btn"); + if (!panel || !mode) return; + var match = panel.querySelector('button[data-mode="' + mode + '"]'); + if (!match) return; + panel.style.display = "flex"; + if (advBtn) advBtn.textContent = "Hide manual"; + } + // ---- Mode buttons, built from the server's canonical catalog ---- // The dashboard no longer hard-codes which modes exist or how they're // labelled. GET /api/modes returns every selectable mode with a label, @@ -2365,6 +2388,7 @@ advanced.replaceChildren(frags.advanced); primary.hidden = !primary.childElementCount; modeCatalogRendered = true; + revealManualModes(currentMode); return true; }) .catch(function () { diff --git a/web/index.html b/web/index.html index 4ca7cd248..80c4bca45 100644 --- a/web/index.html +++ b/web/index.html @@ -69,9 +69,10 @@
+ colocated with the diagnostics it reveals (twins, drivers, + models). The button still lives in the DOM as #ui-mode-toggle, + just rendered there. Manual strategy fallbacks stay on the + Plan card itself. -->
-
+ +
- +

Forecast-driven battery schedule for the next 48 h, recomputed every few minutes. diff --git a/web/mode-picker.test.mjs b/web/mode-picker.test.mjs new file mode 100644 index 000000000..b611c2547 --- /dev/null +++ b/web/mode-picker.test.mjs @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; + +const webRoot = dirname(fileURLToPath(import.meta.url)); +const html = readFileSync(join(webRoot, "index.html"), "utf8"); +const app = readFileSync(join(webRoot, "app.js"), "utf8"); + +describe("strategy mode picker", () => { + it("keeps Manual… on the Plan card in simple view", () => { + // The simple/advanced UI toggle hides diagnostics. Manual fallbacks + // used to ride that same class, so a house already on Self (manual) + // had no selected button and no labelled way back to a planner + // strategy until someone found ★ Advanced. + const strategy = html.match(/class="plan-strategy"[\s\S]*?class="plan-help"/)?.[0] || ""; + assert.match(strategy, /id="mode-advanced-btn"/); + assert.match(strategy, /id="mode-buttons"/); + assert.doesNotMatch(strategy, /class="[^"]*advanced-only/); + }); + + it("opens the manual drawer when the live mode lives there", () => { + assert.match(app, /function revealManualModes\(mode\)/); + assert.match(app, /revealManualModes\(data\.mode\)/); + assert.match(app, /revealManualModes\(currentMode\)/); + }); + + it("marks the tapped mode before the POST returns", () => { + assert.match(app, /function markModeActive\(mode\)/); + assert.match( + app, + /function setMode\(mode\) \{\s*markModeActive\(mode\);\s*revealManualModes\(mode\);/s, + ); + }); +}); From b1295110707426bd661d7b3a8fa1c7a5f3a3af45 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 31 Aug 2026 07:31:25 +0200 Subject: [PATCH 2/4] fix(web): auto-open the manual drawer only on a mode change The status poll runs every couple of seconds and carried the same mode each time, so revealManualModes forced the drawer back open a second after someone pressed Hide manual. Reveal now returns early when the mode has not changed since the last reveal, which leaves an explicit collapse alone while a move to a different manual mode still puts that button on screen. The tracker is recorded only once the mode catalog has painted: before that a missing button means "not rendered yet", and recording it would make the catalog's own call a no-op and leave a manual house with a closed drawer -- the bug this branch set out to fix. A tap also holds its optimistic paint for up to four seconds, so a status read already in flight with the previous mode can no longer flash the old button back. The hold clears on server confirmation, on expiry, or when the write fails. Verified in a browser against a local box: a manual live mode opens the drawer on load, Hide manual survives four polls, an external mode change reopens it, and a tapped strategy stays marked from the tap onwards. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01C1d4uknzs7QUv7nE9rW4fi --- .changeset/keep-manual-mode-buttons.md | 2 +- web/app.js | 30 ++++++++++++++++++++++---- web/mode-picker.test.mjs | 14 +++++++++++- 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/.changeset/keep-manual-mode-buttons.md b/.changeset/keep-manual-mode-buttons.md index 96a9b7111..6ea851c43 100644 --- a/.changeset/keep-manual-mode-buttons.md +++ b/.changeset/keep-manual-mode-buttons.md @@ -2,4 +2,4 @@ "ftw": patch --- -Keep Manual… strategy buttons on the Plan card in simple view, open them when the live mode is a manual fallback, and mark a tap before the server confirms. +Keep Manual… strategy buttons on the Plan card in simple view, open them when the live mode changes to a manual fallback, and mark a tap before the server confirms. Hide manual now stays hidden — the status poll no longer reopens the drawer — and a tap no longer flickers back to the previous strategy. diff --git a/web/app.js b/web/app.js index 1d4ca2212..3822e9513 100644 --- a/web/app.js +++ b/web/app.js @@ -34,6 +34,9 @@ const STATUS_DISPLAY_TAU_MS = 8 * 1000; let chartRange = "5m"; // current selected range let currentMode = null; + let lastRevealedMode = null; // last mode the manual drawer auto-opened for + let pendingMode = null; // mode tapped here, not yet confirmed by the server + let pendingModeUntil = 0; // browser-clock deadline for that optimistic paint let animating = !document.hidden; // 30fps redraw loop flag let lastDataTs = 0; // browser-clock timestamp of newest pushed point let lastPushAt = 0; // browser-clock timestamp of last push attempt — for dedupe (NEVER mix with server ts) @@ -782,12 +785,17 @@ // Buttons come from GET /api/modes; if that hasn't landed yet (offline at // first paint), this confirmed-live poll is the retry trigger. if (!modeCatalogRendered) renderModeCatalog(); + // A tap paints its button before the POST returns. A status read already + // in flight still answers with the old mode, so prefer the tapped one + // until the server confirms it or the short wait runs out. + if (pendingMode && (data.mode === pendingMode || Date.now() >= pendingModeUntil)) pendingMode = null; + var activeMode = pendingMode || data.mode; var allModeButtons = document.querySelectorAll("#mode-buttons-primary button, #mode-buttons button"); allModeButtons.forEach(function (btn) { - if (btn.dataset.mode === data.mode) btn.classList.add("active"); + if (btn.dataset.mode === activeMode) btn.classList.add("active"); else btn.classList.remove("active"); }); - revealManualModes(data.mode); + revealManualModes(activeMode); // When planner is driving, grey out the grid-target slider and show a hint. var plannerActive = (data.mode || "").indexOf("planner_") === 0; var gridSlider = document.getElementById("grid-target-slider"); @@ -2309,6 +2317,8 @@ function setMode(mode) { markModeActive(mode); revealManualModes(mode); + pendingMode = mode; + pendingModeUntil = Date.now() + 4000; apiFetch("/api/mode", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -2320,6 +2330,7 @@ fetchStatus(); }) .catch(function () { + pendingMode = null; // the write failed — show server truth again setConnected(false); }); } @@ -2334,13 +2345,24 @@ // Open the manual drawer when the live mode lives there, so a reload // (or a change made from the phone app / HA) never leaves the current // setting with no button on screen. + // + // Only on a transition. The status poll repeats the same mode every couple + // of seconds; re-opening on every repeat would undo an explicit "Hide + // manual" a second after the user pressed it. A move to a *different* + // manual mode still opens the drawer — that button has to be on screen. function revealManualModes(mode) { + if (!mode || mode === lastRevealedMode) return; var panel = document.getElementById("mode-buttons"); - var advBtn = document.getElementById("mode-advanced-btn"); - if (!panel || !mode) return; + if (!panel) return; var match = panel.querySelector('button[data-mode="' + mode + '"]'); + // Before the catalog paints, a missing button means "not rendered yet", + // not "not a manual mode" — don't record it, or the catalog's own call + // would come back as a repeat and never open the drawer. + if (!match && !modeCatalogRendered) return; + lastRevealedMode = mode; if (!match) return; panel.style.display = "flex"; + var advBtn = document.getElementById("mode-advanced-btn"); if (advBtn) advBtn.textContent = "Hide manual"; } diff --git a/web/mode-picker.test.mjs b/web/mode-picker.test.mjs index b611c2547..39b30f14b 100644 --- a/web/mode-picker.test.mjs +++ b/web/mode-picker.test.mjs @@ -22,15 +22,27 @@ describe("strategy mode picker", () => { it("opens the manual drawer when the live mode lives there", () => { assert.match(app, /function revealManualModes\(mode\)/); - assert.match(app, /revealManualModes\(data\.mode\)/); + assert.match(app, /revealManualModes\(activeMode\)/); assert.match(app, /revealManualModes\(currentMode\)/); }); + it("auto-opens only when the mode changes", () => { + // The status poll repeats the same mode every couple of seconds. Without + // the early return, each one would force the drawer back open and undo + // "Hide manual" a second after the user pressed it. + assert.match(app, /lastRevealedMode = null/); + assert.match(app, /if \(!mode \|\| mode === lastRevealedMode\) return;/); + }); + it("marks the tapped mode before the POST returns", () => { assert.match(app, /function markModeActive\(mode\)/); assert.match( app, /function setMode\(mode\) \{\s*markModeActive\(mode\);\s*revealManualModes\(mode\);/s, ); + // …and holds it, so a status read already in flight with the old mode + // can't flash the previous button back. + assert.match(app, /pendingMode = mode;\s*pendingModeUntil = Date\.now\(\)/); + assert.match(app, /var activeMode = pendingMode \|\| data\.mode;/); }); }); From 91940d66fa69fb991ed8ffb7d9398cbc7c6c65f6 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 31 Aug 2026 08:30:45 +0200 Subject: [PATCH 3/4] feat(web): give a manually-driven house a way back to the plan Master replaced the Passive/Active strategy pair with the household prefs on the Plan card -- the trust slider and the battery-sale permission -- and renderModeCatalog now skips every planner_ key, so the primary row renders empty and hides itself. That left no control anywhere in the dashboard that starts planning again: once a house sat in a manual mode it stayed there, while the card told it to "select a planning strategy" that no longer exists. Only HA, the phone app or the API could get it out. The phone app already shipped the answer (srcfl/ftw-webapp#57); the dashboard never got it. "Use the plan" appears on the card whenever a manual mode drives, and hands the house to the planner mode its own preference implies. The server owns that mapping -- GET /api/planner/prefs returns mapped_mode -- so the button never decides whether this battery may sell. A prefs read that fails or answers with anything else falls back to the passive mode: permission to sell is a deliberate household answer, never a default. The button routes through setMode, so the optimistic paint and the pending-mode hold behave as they do for any tap, and it is disabled with the planner's own reason when MPC cannot run, matching Replan. Both manual sentences now name the button that exists. Verified in a browser against a local box with the planner enabled: export allowed -> planner_arbitrage; export not allowed -> planner_passive_arbitrage (real mouse click); prefs read rejected -> passive even though permission was on file; the button hides itself as soon as the planner drives, and is dimmed with "not-allowed" when the planner is off. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01C1d4uknzs7QUv7nE9rW4fi --- .changeset/keep-manual-mode-buttons.md | 2 ++ web/app.js | 34 +++++++++++++++++++++++++ web/index.html | 10 ++++++++ web/mode-picker.test.mjs | 35 ++++++++++++++++++++++++++ web/plan-brief.js | 4 +-- web/plan-brief.test.mjs | 10 +++++--- web/plan-unavailable.test.mjs | 1 + web/plan.js | 8 ++++++ web/style.css | 8 ++++++ 9 files changed, 107 insertions(+), 5 deletions(-) diff --git a/.changeset/keep-manual-mode-buttons.md b/.changeset/keep-manual-mode-buttons.md index 6ea851c43..16ab5aace 100644 --- a/.changeset/keep-manual-mode-buttons.md +++ b/.changeset/keep-manual-mode-buttons.md @@ -3,3 +3,5 @@ --- Keep Manual… strategy buttons on the Plan card in simple view, open them when the live mode changes to a manual fallback, and mark a tap before the server confirms. Hide manual now stays hidden — the status poll no longer reopens the drawer — and a tap no longer flickers back to the previous strategy. + +A house left in a manual mode can start planning again: the Plan card shows "Use the plan" whenever the planner is not driving. It hands the battery to the planner mode this household's own prefs imply — the passive one unless battery export is allowed — and never grants export rights on its own. diff --git a/web/app.js b/web/app.js index 3822e9513..6a6900b51 100644 --- a/web/app.js +++ b/web/app.js @@ -798,6 +798,12 @@ revealManualModes(activeMode); // When planner is driving, grey out the grid-target slider and show a hint. var plannerActive = (data.mode || "").indexOf("planner_") === 0; + // "Use the plan" is the way out of a manual mode. It has nothing to + // offer while the planner already drives, so it only shows when it does. + var planUseRow = document.getElementById("plan-use-row"); + var planUseBtn = document.getElementById("plan-use-btn"); + if (planUseBtn) planUseBtn.hidden = plannerActive; + if (planUseRow) planUseRow.hidden = plannerActive; var gridSlider = document.getElementById("grid-target-slider"); var gridSend = document.getElementById("grid-target-send"); var gridHint = document.getElementById("grid-target-hint"); @@ -2503,6 +2509,34 @@ } }); } + // Permission to sell from the battery is a deliberate household answer, so + // a prefs read that fails or answers with nothing usable lands on the mode + // that never exports. + var PLANNER_FALLBACK_MODE = "planner_passive_arbitrage"; + // "Use the plan" — the one control that hands a manually-driven house back + // to the planner. Which planner mode that is follows from the household's + // own prefs, and the server already maps them (mapped_mode), so the two + // surfaces cannot drift. setMode() from here on, so the optimistic paint, + // the pending-mode hold and the drawer all behave as they do for a tap. + var planUseBtn = document.getElementById("plan-use-btn"); + if (planUseBtn) { + planUseBtn.addEventListener("click", function () { + apiFetch("/api/planner/prefs", { headers: { Accept: "application/json" } }) + .then(function (r) { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.json(); + }) + .then(function (prefs) { + var mapped = prefs && prefs.mapped_mode; + setMode(typeof mapped === "string" && mapped.indexOf("planner_") === 0 + ? mapped + : PLANNER_FALLBACK_MODE); + }) + .catch(function () { + setMode(PLANNER_FALLBACK_MODE); + }); + }); + } var advBtn = document.getElementById("mode-advanced-btn"); if (advBtn) { advBtn.addEventListener("click", function () { diff --git a/web/index.html b/web/index.html index 80c4bca45..0625cf3ea 100644 --- a/web/index.html +++ b/web/index.html @@ -567,6 +567,16 @@

Plan

+ + diff --git a/web/mode-picker.test.mjs b/web/mode-picker.test.mjs index 39b30f14b..89571bc57 100644 --- a/web/mode-picker.test.mjs +++ b/web/mode-picker.test.mjs @@ -34,6 +34,41 @@ describe("strategy mode picker", () => { assert.match(app, /if \(!mode \|\| mode === lastRevealedMode\) return;/); }); + it("offers one way back to the planner on the Plan card", () => { + // Household prefs replaced Passive/Active as the primary buttons, so a + // house already in a manual mode had nothing left to press to start + // planning again. + const strategy = html.match(/class="plan-strategy"[\s\S]*?class="plan-help"/)?.[0] || ""; + assert.match(strategy, /id="plan-use-btn"/); + assert.match(strategy, /Use the plan/); + }); + + it("shows Use the plan only while the planner is not driving", () => { + assert.match(app, /var plannerActive = \(data\.mode \|\| ""\)\.indexOf\("planner_"\) === 0;/); + assert.match(app, /planUseBtn\.hidden = plannerActive;/); + assert.match(app, /planUseRow\.hidden = plannerActive;/); + }); + + it("takes the planner mode from the household's own prefs", () => { + // The server maps prefs to a planner key; reading mapped_mode keeps the + // dashboard from deciding whether this battery may sell. + assert.match(app, /apiFetch\("\/api\/planner\/prefs"/); + assert.match(app, /var mapped = prefs && prefs\.mapped_mode;/); + assert.match(app, /setMode\(typeof mapped === "string"/); + }); + + it("falls back to the passive planner, never to selling", () => { + assert.match(app, /var PLANNER_FALLBACK_MODE = "planner_passive_arbitrage";/); + // Both the unusable answer and the failed read take that fallback. + assert.match(app, /\?\s*mapped\s*:\s*PLANNER_FALLBACK_MODE\);/); + assert.match(app, /\.catch\(function \(\) \{\s*setMode\(PLANNER_FALLBACK_MODE\);/); + // Whatever else the file grows, no path here may name the exporting + // mode outright: permission to sell comes from the household, through + // mapped_mode, or not at all. + const useBlock = app.slice(app.indexOf("PLANNER_FALLBACK_MODE"), app.indexOf("mode-advanced-btn")); + assert.doesNotMatch(useBlock, /"planner_arbitrage"/); + }); + it("marks the tapped mode before the POST returns", () => { assert.match(app, /function markModeActive\(mode\)/); assert.match( diff --git a/web/plan-brief.js b/web/plan-brief.js index ca69526d4..9fbfebb55 100644 --- a/web/plan-brief.js +++ b/web/plan-brief.js @@ -115,7 +115,7 @@ function manualBrief(status, hasBattery) { state: { key: "manual", label: "Manual", tone: "idle" }, next: { action: "Manual control is active", - time: "Choose a planning strategy to create a schedule", + time: "Use the plan to create a schedule", }, reason: "Planning is not controlling the battery", constraint: "FTW safety limits still apply to manual control", @@ -126,7 +126,7 @@ function manualBrief(status, hasBattery) { soc: batterySocNow(status, hasBattery, "Expected charge needs an active plan"), planner: { label: "Planner off", - detail: "Select a planning strategy to enable it", + detail: "Use the plan to enable it", }, }; } diff --git a/web/plan-brief.test.mjs b/web/plan-brief.test.mjs index 3fdb7d125..072ca13f0 100644 --- a/web/plan-brief.test.mjs +++ b/web/plan-brief.test.mjs @@ -30,7 +30,9 @@ describe("plan brief normalization", () => { tone: "idle", }); assert.equal(brief.next.action, "Manual control is active"); - assert.match(brief.next.time, /planning strategy/); + // Both manual sentences name the button that actually exists on the + // card. There is no strategy picker to send anyone to any more. + assert.match(brief.next.time, /Use the plan/); assert.equal(brief.soc, null); }); @@ -190,8 +192,10 @@ describe("plan brief normalization", () => { assert.equal(brief.state.label, "Cannot plan"); assert.match(brief.next.action, /controllable battery/); assert.match(brief.next.time, /Devices/); - assert.doesNotMatch(brief.next.time, /planning strategy/); - assert.doesNotMatch(brief.planner.detail, /Select a planning strategy/); + // A house with no controllable battery is not one button away from a + // plan, so it must not be told to press one. + assert.doesNotMatch(brief.next.time, /Use the plan/); + assert.doesNotMatch(brief.planner.detail, /Use the plan/); assert.equal(brief.soc.label, "40% now"); }); diff --git a/web/plan-unavailable.test.mjs b/web/plan-unavailable.test.mjs index a8cde43be..29bd0a18e 100644 --- a/web/plan-unavailable.test.mjs +++ b/web/plan-unavailable.test.mjs @@ -11,6 +11,7 @@ describe("plan unavailable reason", () => { assert.match(plan, /applyPlannerModeAvailability/); assert.match(plan, /btn\.disabled = !enabled/); assert.match(plan, /replan\.disabled = !enabled/); + assert.match(plan, /usePlan\.disabled = !enabled/); assert.doesNotMatch(plan, /MPC planner disabled/); }); }); diff --git a/web/plan.js b/web/plan.js index 0a56f94a4..95313899c 100644 --- a/web/plan.js +++ b/web/plan.js @@ -1231,6 +1231,14 @@ import { replan.disabled = !enabled; replan.title = enabled ? 'Force a fresh plan' : copy.summary; } + // "Use the plan" carries no data-mode, so the loop above never sees it. + // Offering it while the planner cannot run would hand the house to a + // mode that then has to explain why it is not planning. + const usePlan = document.getElementById('plan-use-btn'); + if (usePlan) { + usePlan.disabled = !enabled; + usePlan.title = enabled ? 'Hand the battery back to the plan' : copy.detail; + } } function renderStrategyHint() { diff --git a/web/style.css b/web/style.css index df0e04bf6..dc1813750 100644 --- a/web/style.css +++ b/web/style.css @@ -1676,6 +1676,14 @@ footer { padding: 8px 10px; font-weight: 500; } +/* "Use the plan" is the only thing to do on this card while a manual mode + drives, so it carries the accent the other buttons here do not. The row + selector above would otherwise win on specificity and grey it out. */ +.mode-buttons-primary button#plan-use-btn { + border: 1px solid var(--accent); + background: var(--accent); + color: #fff; +} .mode-advanced-toggle { margin-top: 8px; text-align: right; From 47542738e482aa509f7f5b3749becf7050c35f31 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 31 Aug 2026 10:19:35 +0200 Subject: [PATCH 4/4] style(web): keep the Plan card's controls to a readable column The card is as wide as the chart below it. A slider and four buttons stretched across that width read as a banner rather than as something to touch, and the help text under them ran to a line nobody wants to follow. Slider, export row, manual drawer and the rules between them now share a 560px column, prose caps at 70ch, and "Use the plan" takes the accent and only the width its label needs -- so the card has one obvious action instead of a full-width bar. Moves that button's accent rule from style.css to app.css, beside the Plan card's other prefs styling and modelled on #plan-export-allow; the disabled state is left to the mode buttons' existing opacity rule. The strategy hint is hidden while empty, so the planner no longer leaves a bordered blank strip where a manual mode's description goes. Checked in a browser at both themes, in a manual mode and under the planner. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01C1d4uknzs7QUv7nE9rW4fi --- web/app.css | 44 ++++++++++++++++++++++++++++++++++++++++++++ web/style.css | 8 -------- 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/web/app.css b/web/app.css index c72ffc582..e04662ce3 100644 --- a/web/app.css +++ b/web/app.css @@ -1025,6 +1025,11 @@ body.ftw-app .strategy-hint { color: var(--fg); line-height: 1.5; } +/* The hint describes the manual mode in use, so it is empty while the + planner drives — without this it leaves a bordered blank strip. */ +body.ftw-app .strategy-hint:empty { + display: none; +} body.ftw-app .forecast-trust { margin: 0 0 12px; } @@ -1124,6 +1129,45 @@ body.ftw-app .plan-export-sentence { font-size: 13px; line-height: 1.4; } +/* The Plan card is as wide as the chart below it. Controls stretched to + that width read as a banner, not as something to touch, and prose set + across it is hard to follow — so both get a comfortable measure and + the rest of the row stays empty. */ +body.ftw-app .plan-strategy .forecast-trust, +body.ftw-app .plan-strategy .plan-export, +body.ftw-app .plan-strategy .plan-export-banner, +body.ftw-app .plan-strategy .plan-export-row, +body.ftw-app .plan-strategy .mode-advanced-toggle, +body.ftw-app .plan-strategy #mode-buttons { + max-width: 560px; +} +body.ftw-app .plan-strategy .forecast-trust-help, +body.ftw-app .plan-strategy .forecast-trust-yaml, +body.ftw-app .plan-strategy .plan-export-help, +body.ftw-app .plan-strategy .plan-export-unknown, +body.ftw-app .plan-strategy .plan-export-sentence, +body.ftw-app .plan-strategy .strategy-hint { + max-width: 70ch; +} +/* "Use the plan" is the one action on this card while a manual mode + drives, so it takes the accent the mode buttons do not — and only the + width its label needs. */ +body.ftw-app #plan-use-row { + margin-top: 12px; +} +body.ftw-app .mode-buttons #plan-use-btn { + flex: 0 0 auto; + background: var(--accent-e); + border: 1px solid var(--accent-e); + color: var(--on-accent); + font-weight: 600; + padding: 8px 16px; +} +body.ftw-app .mode-buttons #plan-use-btn:hover { + background: var(--accent-e); + color: var(--on-accent); + opacity: 0.9; +} body.ftw-app .engine-details { margin-top: 16px; border-top: 1px solid var(--line); diff --git a/web/style.css b/web/style.css index dc1813750..df0e04bf6 100644 --- a/web/style.css +++ b/web/style.css @@ -1676,14 +1676,6 @@ footer { padding: 8px 10px; font-weight: 500; } -/* "Use the plan" is the only thing to do on this card while a manual mode - drives, so it carries the accent the other buttons here do not. The row - selector above would otherwise win on specificity and grey it out. */ -.mode-buttons-primary button#plan-use-btn { - border: 1px solid var(--accent); - background: var(--accent); - color: #fff; -} .mode-advanced-toggle { margin-top: 8px; text-align: right;