Tool messages show as toasts, the card status line is gone (t27)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 15:14:46 -04:00
co-authored by Claude Opus 5.5
parent 318de3ed76
commit efabbfb6a8
8 changed files with 74 additions and 15 deletions
+6
View File
@@ -235,6 +235,12 @@ above, including:
the bag; Ctrl+click a bag name adds it to the blow mix and Ctrl+right-click to pick-up; the menu
is one line per material because the first version was too large.
- **Tool messages are toasts (t27, 2026-10-24).** Every tool's messages ("Nothing selected", "Out
of reach.", "Nothing to use there.") show as toasts at the top of the screen, and the red line in
the tool card is gone. The owner asked for it for the vacuum and chose every tool. Errors have a
red edge, info a patina one. A message that repeats every tick while a button is held keeps one
toast alive instead of stacking more.
- **Pick up what you click, blow out what's in hand (t20, 2026-10-24).** Replaces t16's pick-up
marks and the vacuum menu; the owner wanted something simpler and more intuitive. A left drag
takes only the material it starts on (or the first loose grain it reaches); Shift takes
+9
View File
@@ -4,6 +4,15 @@ What agents have done, newest first. Add an entry for every piece of work: the d
note or request it was for, what changed, the files touched, the tests run, and anything left
open or not verified. Keep each entry short; the git history has the details.
## 2026-10-24: t27: tool messages as toasts (Claude Code, Codex)
- `setStatus` still records `GAME.status` and now also shows a toast (`err` or `info` class), unless
told to stay quiet (the "New drill bit/bag" F messages, which have their own card). `toast`
restarts the timer of a showing toast with the same text instead of adding another, so per-tick
messages make one toast. The card's status line and its CSS are gone; `doInteract` no longer
toasts twice. Files: `src/ui/{state,selection,actions}.js`, `style.css`, `test/play.py`.
- Tests: `run_all.py --browser` all passed (play.py 237). Screenshot `shots/t27-toasts.png`.
## 2026-10-24: t20 follow-up: bag hint and Clear stay visible (Claude Code)
- The hint and Clear were inside the scrolling `#baglist`, so a long Ctrl list scrolled them away.
+2 -3
View File
@@ -118,9 +118,6 @@ kbd {
#selcard .row { display: flex; align-items: center; gap: 8px; }
#selcard .name { font-weight: 600; font-size: 1.05em; }
#selcard .sub { color: var(--ash); font-size: 0.88em; }
#selcard .status { color: var(--danger); font-size: 0.88em; min-height: 0; }
#selcard .status:empty { display: none; }
#selcard .status.info { color: var(--patina); }
.sw { width: 14px; height: 14px; border-radius: 2px; border: 1px solid rgba(0, 0, 0, 0.5); flex: none; box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08); }
.cycle { display: flex; align-items: center; gap: 6px; }
.cycle .grow { flex: 1; min-width: 0; }
@@ -215,6 +212,8 @@ table.mt td.dip { color: var(--melt); }
.toast { padding: 7px 12px; font-size: 0.94em; animation: toastIn 0.25s ease-out; text-align: center; }
.toast.big { border-color: var(--melt-deep); padding: 10px 16px; }
.toast.big b { color: var(--melt-hot); display: block; font-size: 1.1em; }
.toast.err { border-color: var(--danger); border-left-width: 3px; }
.toast.info { border-color: var(--patina); border-left-width: 3px; }
.toast.out { opacity: 0; transition: opacity 0.4s; }
@keyframes toastIn { from { transform: translateY(-6px); opacity: 0; } to { transform: none; opacity: 1; } }
+1 -1
View File
@@ -43,7 +43,7 @@ function doInteract() {
msg = interactAt(ax + dx, ay + dy);
if (msg) break outer;
}
if (msg) { setStatus(msg, 'info'); if (!/^New (drill bit|bag)/.test(msg)) toast(msg); }
if (msg) setStatus(msg, 'info', /^New (drill bit|bag)/.test(msg));
else if ((moldAt(ax, ay) || {}).kind !== 'ingot') setStatus('Nothing to use there.', 'info'); // an ingot mold needs no F: say nothing
}
// Pick (middle mouse): the piece under the cursor becomes the one you build, with its
-2
View File
@@ -154,7 +154,6 @@ function pieceCostNow(P, m = GAME.wallMat) {
function renderSelCard() {
const t = toolId(), c = $('selcard');
let h = '';
const status = performance.now() - GAME.statusT < 3200 ? GAME.status : '';
const sel = GAME.selDrag ? GAME.selDrag.mode : selModeHeld();
c.hidden = false;
const ng = ghostBlocks.size + ghostMachines.length + ghostMolds.length;
@@ -193,7 +192,6 @@ function renderSelCard() {
} else if (t === 'ignite') {
h = `<div class="row"><span class="name">Igniter</span><span class="chip hot">up to 640°C</span></div><div class="sub">Hold on fuel to light it. Coal catches around 420°C, wood around 300°C. Fuel needs open air next to it.</div>`;
}
h += `<div class="status${GAME.statusKind === 'info' ? ' info' : ''}">${esc(status)}</div>`;
if (c.innerHTML !== h) c.innerHTML = h;
}
let lastBagKey = '';
+14 -6
View File
@@ -1,5 +1,5 @@
/* Game shell (src/ui/): browser only. This file: settings, the GAME state object, DOM helpers,
toasts and the status line. */
toasts and tool status. */
// ---------- Settings ----------
const SET_DEFAULT = {
@@ -57,11 +57,19 @@ function fmtT(t) { return t >= 999.5 ? `${Math.round(t).toLocaleString('en-US')}
// ---------- Toasts ----------
function toast(text, opts = {}) {
const box = $('toasts');
while (box.children.length > 4) box.firstChild.remove();
const t = el('div', { class: 'toast plate' + (opts.big ? ' big' : '') });
let t = [...box.children].find(e => !e.classList.contains('out') && e._text === text && e._title === (opts.title || ''));
if (!t) {
while (box.children.length > 4) {
clearTimeout(box.firstChild._fade); clearTimeout(box.firstChild._remove);
box.firstChild.remove();
}
t = el('div', { class: 'toast plate' + (opts.big ? ' big' : '') + (opts.kind ? ' ' + opts.kind : '') });
t._text = text; t._title = opts.title || '';
t.innerHTML = opts.title ? `<b>${esc(opts.title)}</b>${esc(text)}` : esc(text);
box.appendChild(t);
const life = opts.big ? 5200 : 2600;
setTimeout(() => { t.classList.add('out'); setTimeout(() => t.remove(), 450); }, life);
}
function setStatus(s, kind) { if (s) { GAME.status = s; GAME.statusKind = kind || 'err'; GAME.statusT = performance.now(); hudDirty = true; } }
clearTimeout(t._fade);
const life = opts.big ? 5200 : 2600;
t._fade = setTimeout(() => { t.classList.add('out'); t._remove = setTimeout(() => t.remove(), 450); }, life);
}
function setStatus(s, kind, quiet) { if (s) { GAME.status = s; GAME.statusKind = kind || 'err'; GAME.statusT = performance.now(); hudDirty = true; if (!quiet) toast(s, { kind: kind || 'err' }); } }
+22
View File
@@ -0,0 +1,22 @@
---
id: t27
status: Review
order: 4
priority: P3
created: 2026-10-24
updated: 2026-10-24T19:14:46Z
title: add toast for vac/blow tool
report: |
(agent) Every tool's messages now show as toasts at the top of the screen; the red line in the
tool card is gone. Errors have a red edge, info messages a teal one. Holding a button that keeps
repeating a message (blowing with nothing selected, the Igniter out of reach) keeps one toast up
instead of piling them. Please check the toasts are readable and don't get in the way.
---
## Subtasks
- [ ] when blowing without any items selected there is a message in red in the box on the top left, instead move it to a toast.
- [ ] same for any other messages from the tool
- [ ] s
- [x] Every tool's messages show as toasts, and the red line in the tool card goes away
- [x] A message that repeats while a button is held refreshes one toast instead of stacking up
+19 -2
View File
@@ -306,9 +306,10 @@ with sync_playwright() as p:
ore = find_ore()
suck_at(*ore, ms=500, shift=True)
check('Shift held standing still takes everything', ev('bag[RSTONE]') > 0 and ev('bag[CUORE]') > 0, f"ore {ev('bag[CUORE]')}, stone {ev('bag[RSTONE]')}")
ev("GAME.aim = { x: Math.floor(bot.x), y: 1 }")
ev("$('toasts').innerHTML = ''; GAME.aim = { x: Math.floor(bot.x), y: 1 }")
pg.keyboard.press('f'); settle(100)
check('F away from usable things says nothing to use', ev("$('m-dialog').hidden && GAME.status === 'Nothing to use there.'"))
check('F away from usable things shows exactly one info toast', ev("[...document.querySelectorAll('#toasts .toast.info')].filter(e => e.textContent === 'Nothing to use there.').length === 1"))
ev("closeModal('m-dialog')")
# zoom jumps straight to the bot
pg.mouse.move(640, 400); settle(600)
@@ -338,6 +339,19 @@ with sync_playwright() as p:
check('a material selected before you have any stays selected', ev("(() => { GAME.blow = new Set([COAL]); const n = bagTotal, f = flyers.length; GAME.rmb = true; secondaryPress(false); toolTick(); GAME.rmb = false; return GAME.blow.has(COAL); })()"))
check('selected but none in bag blows nothing', ev("(() => { const n = bagTotal, f = flyers.length; GAME.rmb = true; secondaryPress(false); toolTick(); GAME.rmb = false; renderSelCard(); return bagTotal === n && flyers.length === f && GAME.status === 'None of the selected materials are in the bag.' && $('selcard').querySelector('.vacchoice').textContent === 'none in bag'; })()"))
check('nothing selected blows nothing and explains how to choose', ev("(() => { GAME.blow.clear(); const n = bagTotal, f = flyers.length; GAME.rmb = true; secondaryPress(false); toolTick(); GAME.rmb = false; return bagTotal === n && flyers.length === f && GAME.status === 'Nothing selected: click or Q/E in the bag.'; })()"))
ev("$('toasts').innerHTML = ''; GAME.blow.clear(); GAME.rmb = true; secondaryPress(false); for (let i = 0; i < 30; i++) toolTick(); GAME.rmb = false; renderSelCard()")
check('holding blow with nothing selected shows exactly one error toast', ev("[...document.querySelectorAll('#toasts .toast.err')].filter(e => e.textContent.includes('Nothing selected')).length === 1"))
check('the tool card has no status line', ev("document.querySelector('#selcard .status') === null"))
pg.wait_for_timeout(300)
pg.screenshot(path=str(out / '03b-empty-selection-toast.png'))
ev("""(() => {
$('toasts').innerHTML = '';
const tool = GAME.tool, aim = GAME.aim;
GAME.tool = 3; GAME.aim = { x: bot.x + 500, y: bot.y }; GAME.lmb = true;
for (let i = 0; i < 30; i++) toolTick();
GAME.lmb = false; GAME.tool = tool; GAME.aim = aim;
})()""")
check('holding the Igniter out of reach shows exactly one error toast', ev("[...document.querySelectorAll('#toasts .toast.err')].filter(e => e.textContent === 'Out of reach.').length === 1"))
check('a mix drops exhausted coal and continues blowing ore', ev("(() => { const b = bag.slice(), total = bagTotal, f = flyers.length; bag.fill(0); bag[COAL] = 1; bag[CUORE] = 100; bagTotal = 101; GAME.blow = new Set([COAL, CUORE]); GAME.rmb = true; secondaryPress(false); toolTick(); const ore = bag[CUORE]; toolTick(); GAME.rmb = false; const ok = !bag[COAL] && ore > 0 && bag[CUORE] < ore && GAME.blow.size === 1 && GAME.blow.has(CUORE); bag.set(b); bagTotal = total; flyers.length = f; return ok; })()"))
# Shift + right click blows an even mix of everything loose in the bag, without changing the choice
refill('bag[COAL] = 60; bag[CUORE] = 60')
@@ -482,8 +496,9 @@ with sync_playwright() as p:
pg.keyboard.press(key); settle(150)
check(f'tool {key} restores the top-left card', pg.is_visible('#selcard'))
pg.keyboard.press('3'); settle(150)
ev("$('toasts').innerHTML = ''")
pg.mouse.click(*cell_to_screen(bx, gy - 20), button='middle'); settle(120)
check('empty middle-click pick shows its status in the top-left card', 'Nothing built here to pick.' in pg.inner_text('#selcard'))
check('empty middle-click pick shows its status in one info toast', ev("GAME.status === 'Nothing built here to pick.' && [...document.querySelectorAll('#toasts .toast.info')].filter(e => e.textContent === GAME.status).length === 1"))
c0 = ev('GAME.buildCat != null ? GAME.buildCat : catOf(piece().id)')
pg.keyboard.press('Shift+E')
check('Shift+E switches build tabs and selects a piece in the tab', ev('GAME.buildCat') == (c0 + 1) % ev('BUILD_CATS.length') and ev('BUILD_CATS[GAME.buildCat][1].includes(piece().id)'))
@@ -554,10 +569,12 @@ with sync_playwright() as p:
check('F on an automatic ingot mold stays quiet without a toast', ev("$('m-dialog').hidden") and ev("$('toasts').children.length") == n_toasts)
ev("closeModal('m-dialog')")
check('F with vacuum still uses what is in reach', ev("""(() => {
$('toasts').innerHTML = '';
const old = interactAt; interactAt = () => 'Gate opened.';
try { doInteract(); } finally { interactAt = old; }
return $('m-dialog').hidden && GAME.status === 'Gate opened.';
})()"""))
check('a successful F interaction shows its message exactly once', ev("[...$('toasts').children].filter(e => e.textContent === 'Gate opened.').length === 1"))
settle(1500)
pg.screenshot(path=str(out / '06-bars.png'))
pg.keyboard.press('2'); settle(60)