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 196ce5ce62
commit 67b6aba6eb
7 changed files with 52 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 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. 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 - **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 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 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 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. 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) ## 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. - 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 .row { display: flex; align-items: center; gap: 8px; }
#selcard .name { font-weight: 600; font-size: 1.05em; } #selcard .name { font-weight: 600; font-size: 1.05em; }
#selcard .sub { color: var(--ash); font-size: 0.88em; } #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); } .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 { display: flex; align-items: center; gap: 6px; }
.cycle .grow { flex: 1; min-width: 0; } .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 { 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 { border-color: var(--melt-deep); padding: 10px 16px; }
.toast.big b { color: var(--melt-hot); display: block; font-size: 1.1em; } .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; } .toast.out { opacity: 0; transition: opacity 0.4s; }
@keyframes toastIn { from { transform: translateY(-6px); opacity: 0; } to { transform: none; opacity: 1; } } @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); msg = interactAt(ax + dx, ay + dy);
if (msg) break outer; 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 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 // 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() { function renderSelCard() {
const t = toolId(), c = $('selcard'); const t = toolId(), c = $('selcard');
let h = ''; let h = '';
const status = performance.now() - GAME.statusT < 3200 ? GAME.status : '';
const sel = GAME.selDrag ? GAME.selDrag.mode : selModeHeld(); const sel = GAME.selDrag ? GAME.selDrag.mode : selModeHeld();
c.hidden = false; c.hidden = false;
const ng = ghostBlocks.size + ghostMachines.length + ghostMolds.length; const ng = ghostBlocks.size + ghostMachines.length + ghostMolds.length;
@@ -193,7 +192,6 @@ function renderSelCard() {
} else if (t === 'ignite') { } 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="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; if (c.innerHTML !== h) c.innerHTML = h;
} }
let lastBagKey = ''; let lastBagKey = '';
+13 -5
View File
@@ -1,5 +1,5 @@
/* Game shell (src/ui/): browser only. This file: settings, the GAME state object, DOM helpers, /* 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 ---------- // ---------- Settings ----------
const SET_DEFAULT = { const SET_DEFAULT = {
@@ -57,11 +57,19 @@ function fmtT(t) { return t >= 999.5 ? `${Math.round(t).toLocaleString('en-US')}
// ---------- Toasts ---------- // ---------- Toasts ----------
function toast(text, opts = {}) { function toast(text, opts = {}) {
const box = $('toasts'); const box = $('toasts');
while (box.children.length > 4) box.firstChild.remove(); let t = [...box.children].find(e => !e.classList.contains('out') && e._text === text && e._title === (opts.title || ''));
const t = el('div', { class: 'toast plate' + (opts.big ? ' big' : '') }); 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); t.innerHTML = opts.title ? `<b>${esc(opts.title)}</b>${esc(text)}` : esc(text);
box.appendChild(t); box.appendChild(t);
}
clearTimeout(t._fade);
const life = opts.big ? 5200 : 2600; const life = opts.big ? 5200 : 2600;
setTimeout(() => { t.classList.add('out'); setTimeout(() => t.remove(), 450); }, life); t._fade = setTimeout(() => { t.classList.add('out'); t._remove = setTimeout(() => t.remove(), 450); }, life);
} }
function setStatus(s, kind) { if (s) { GAME.status = s; GAME.statusKind = kind || 'err'; GAME.statusT = performance.now(); hudDirty = true; } } 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' }); } }
+19 -2
View File
@@ -306,9 +306,10 @@ with sync_playwright() as p:
ore = find_ore() ore = find_ore()
suck_at(*ore, ms=500, shift=True) 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]')}") 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) 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 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')") ev("closeModal('m-dialog')")
# zoom jumps straight to the bot # zoom jumps straight to the bot
pg.mouse.move(640, 400); settle(600) 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('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('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.'; })()")) 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; })()")) 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 # Shift + right click blows an even mix of everything loose in the bag, without changing the choice
refill('bag[COAL] = 60; bag[CUORE] = 60') refill('bag[COAL] = 60; bag[CUORE] = 60')
@@ -482,8 +496,9 @@ with sync_playwright() as p:
pg.keyboard.press(key); settle(150) pg.keyboard.press(key); settle(150)
check(f'tool {key} restores the top-left card', pg.is_visible('#selcard')) check(f'tool {key} restores the top-left card', pg.is_visible('#selcard'))
pg.keyboard.press('3'); settle(150) pg.keyboard.press('3'); settle(150)
ev("$('toasts').innerHTML = ''")
pg.mouse.click(*cell_to_screen(bx, gy - 20), button='middle'); settle(120) 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)') c0 = ev('GAME.buildCat != null ? GAME.buildCat : catOf(piece().id)')
pg.keyboard.press('Shift+E') 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)')) 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) 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')") ev("closeModal('m-dialog')")
check('F with vacuum still uses what is in reach', ev("""(() => { check('F with vacuum still uses what is in reach', ev("""(() => {
$('toasts').innerHTML = '';
const old = interactAt; interactAt = () => 'Gate opened.'; const old = interactAt; interactAt = () => 'Gate opened.';
try { doInteract(); } finally { interactAt = old; } try { doInteract(); } finally { interactAt = old; }
return $('m-dialog').hidden && GAME.status === 'Gate opened.'; 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) settle(1500)
pg.screenshot(path=str(out / '06-bars.png')) pg.screenshot(path=str(out / '06-bars.png'))
pg.keyboard.press('2'); settle(60) pg.keyboard.press('2'); settle(60)