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:
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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' }); } }
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user