diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 38fa730..f65b817 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -230,6 +230,9 @@ above, including: the Vacuum (when nothing in reach can be used) or a click on the card opens a menu with ⬇ Pick up and ⬆ Blow toggles per material; the bag rows lose their buttons. T no longer does anything for the vacuum. The owner found t6's version had too many modes. + Later the same day the owner added: Shift + right click blows an even mix of everything loose in + 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. ## Ideas not built yet diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index c280d91..61a9d17 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -395,16 +395,16 @@ The notebook (N): milestones, market, wallet, blueprints, controls. - functions: openNotebook, renderNotebook, fmtClock, orderName, orderLeft, renderMarketTab, fmtPrice, itemsText, renderWallet, renderBlueprintTab, copyCode, controlsHtml - variables: nbTab, MARKET_GROUPS -### src/ui/hud.js (37 lines) +### src/ui/hud.js (39 lines) The tool hotbar and what is selected. -- functions: renderHotbar, toolId, steering, modShift, grainAt, selectTool, bagMaterials, blowList, ensureBlow, piece +- functions: renderHotbar, toolId, steering, modShift, grainAt, selectTool, bagMaterials, blowList, blowAllList, ensureBlow, piece ### src/ui/build-menu.js (102 lines) The build menu along the bottom: categories, piece tiles and their icons. - functions: pieceIcon, catOf, selectBuildCat, cycleBuildCat, buildVariantChips, renderBuildBar, setupBuildBar - variables: BUILD_CATS, BUILD_ORDER, PIECE_ICONS, lastBuildKey, lastBuildMenu -### src/ui/selection.js (270 lines) +### src/ui/selection.js (276 lines) The selected tool and piece: R / Q / E, drill width, filter picker, the selection card, the bag panel. - functions: pieceUsesWall, setDrillWidth, tuneUnderCursor, cycleItem, cycleWall, rotate, toggleCollect, toggleBlow, pickFilterUnderCursor, toggleFilterMat, toggleCollectUnderCursor, setBrush, pieceV, pieceExtra, filterChoices, matList, filterParts, filterSentence, applyFilterRun, openFilterPicker, openVacuumMenu, openWallPicker, filterBlockAt, chipCost, pieceCostNow, renderSelCard, renderBag - variables: lastBagKey diff --git a/docs/agent-log.md b/docs/agent-log.md index c250451..1a11730 100644 --- a/docs/agent-log.md +++ b/docs/agent-log.md @@ -4,6 +4,19 @@ 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: t16 follow-up: Shift blows all, Ctrl+click in the bag, compact menu (Claude Code) + +- The owner's subtask "shift to blow all" and two chat requests. Codex was out of credits, so + Claude made the edits. +- `input.js`/`actions.js`: `secondaryPress(shift)` sets `GAME.blowAll`; `hud.js` `blowAllList()` + (loose bag materials, no bars); `previews.js` shows it. `selection.js`: Ctrl+click a bag name + toggles blow, Ctrl+right-click toggles pick-up; the vacuum menu is one `.vrow` per material + (CSS in `style.css`). Text in the card, bag hint and `notebook.js`. +- `test/play.py`: Shift blow-all (replaces the old "Shift still blows only coal" check, per the + owner's change), Ctrl+click and Ctrl+right-click, plain right click does nothing, one line per + menu row at 1280 and 900. +- Tests: `run_all.py --browser` all pass (215 `play.py` checks). Screenshots checked. + ## 2026-10-24: t16 Vacuum tool rework, again (Claude Code, Codex) - The owner: too many modes; chose an F menu with two pick-up states. diff --git a/src/styles/style.css b/src/styles/style.css index 484b69e..8cee95c 100644 --- a/src/styles/style.css +++ b/src/styles/style.css @@ -315,14 +315,14 @@ input.text { background: var(--soot-0); border: 1px solid var(--soot-4); border- .toast { animation: none; } } /* wall material picker */ -:is(.wpick, .vpick) { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; max-height: min(60vh, 490px); overflow-y: auto; padding: 2px; } -:is(.wpick, .vpick) .wp { display: flex; align-items: flex-start; gap: 8px; text-align: left; padding: 8px; border-radius: 6px; border: 1px solid var(--soot-4); background: var(--soot-2); color: var(--bone); font: inherit; font-size: 12px; cursor: pointer; min-width: 0; } -:is(.wpick, .vpick) .wp > span:last-child { min-width: 0; overflow-wrap: anywhere; } -:is(.wpick, .vpick) .wp span span { display: block; color: var(--ash); margin-top: 3px; } -:is(.wpick, .vpick) .wp.short { opacity: 0.55; } -:is(.wpick, .vpick) .wp:hover { background: #2d2724; } -:is(.wpick, .vpick) .wp.on { border-color: #e59a52; background: #33261c; } -:is(.wpick, .vpick) .wp:focus-visible, [data-wall-picker]:focus-visible { outline: 2px solid #e59a52; outline-offset: 1px; } +.wpick { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; max-height: min(60vh, 490px); overflow-y: auto; padding: 2px; } +.wpick .wp { display: flex; align-items: flex-start; gap: 8px; text-align: left; padding: 8px; border-radius: 6px; border: 1px solid var(--soot-4); background: var(--soot-2); color: var(--bone); font: inherit; font-size: 12px; cursor: pointer; min-width: 0; } +.wpick .wp > span:last-child { min-width: 0; overflow-wrap: anywhere; } +.wpick .wp span span { display: block; color: var(--ash); margin-top: 3px; } +.wpick .wp.short { opacity: 0.55; } +.wpick .wp:hover { background: #2d2724; } +.wpick .wp.on { border-color: #e59a52; background: #33261c; } +.wpick .wp:focus-visible, [data-wall-picker]:focus-visible { outline: 2px solid #e59a52; outline-offset: 1px; } [data-wall-picker] { cursor: pointer; } /* filter picker */ .fpick { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 6px; max-height: min(52vh, 420px); overflow-y: auto; padding: 2px; } @@ -362,8 +362,14 @@ input.text { background: var(--soot-0); border: 1px solid var(--soot-4); border- #m-dialog { z-index: 21; } #continue-meta { max-width: 72%; overflow-wrap: anywhere; text-align: right; } -.vpick { max-height: min(48vh, 390px); } -.vtoggles { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; } -.vtoggles button { padding: 4px 7px; font-size: 11px; } -.vtoggles button.on { border-color: #e59a52; background: #533820; color: var(--bone); } +.vpick { display: grid; gap: 1px; max-height: min(56vh, 460px); overflow-y: auto; padding: 2px; } +.vrow { display: grid; grid-template-columns: 12px minmax(0, 1fr) auto 52px 52px; align-items: center; gap: 8px; padding: 2px 6px; border-radius: 4px; font-size: 12px; } +.vrow:not(.vhead):hover { background: var(--soot-2); } +.vrow .sw { width: 12px; height: 12px; border-radius: 2px; } +.vrow .nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.vrow .num { color: var(--ash); } +.vrow.vhead { color: var(--ash-dim); white-space: nowrap; font-size: 10px; text-transform: uppercase; letter-spacing: 0.04em; } +.vrow.vhead span:nth-child(n+4) { text-align: center; } +.vrow button { padding: 1px 0; min-height: 0; font-size: 12px; justify-content: center; } +.vrow button.on { border-color: #e59a52; background: #533820; color: var(--bone); } .vacchoice { display: flex; align-items: center; gap: 4px; text-align: left; min-width: 0; background: transparent; border: 0; padding: 0; cursor: pointer; } diff --git a/src/ui/actions.js b/src/ui/actions.js index 23cd30b..8fd76cc 100644 --- a/src/ui/actions.js +++ b/src/ui/actions.js @@ -188,9 +188,9 @@ function primaryRelease(shift) { } if (t === 'vacuum') { GAME.vacFilter = 0; hudDirty = true; } } -function secondaryPress() { +function secondaryPress(shift) { const t = toolId(); - GAME.rmbSeen = new Set(); + GAME.rmbSeen = new Set(); GAME.blowAll = !!shift; if (GAME.selDrag) { GAME.selDrag = null; hudDirty = true; GAME.rmbConsumed = true; } else if (GAME.paste) { GAME.paste = false; hudDirty = true; playSfx('ui'); GAME.rmbConsumed = true; } else if (t === 'build' && GAME.drag) { GAME.drag = null; GAME.dragDir = -1; GAME.rmbConsumed = true; } @@ -216,8 +216,8 @@ function toolTick() { if (GAME.rmb && !GAME.rmbConsumed) { if (t === 'vacuum') { ensureBlow(); - const list = blowList(); - const r = toolBlow(ax, ay, GAME.brush[1], list[0] || 0, list.length > 1 ? list : null); + const list = GAME.blowAll ? blowAllList() : blowList(); + const r = GAME.blowAll && !list.length ? { placed: 0, status: 'Nothing loose in the bag to blow.' } : toolBlow(ax, ay, GAME.brush[1], list[0] || 0, list.length > 1 ? list : null); if (r.placed) { playSfx('blow'); hudDirty = true; } else if (r.status) setStatus(r.status); } else if (t === 'build') { const key = `${(ax / BS) | 0},${(ay / BS) | 0}`; diff --git a/src/ui/hud.js b/src/ui/hud.js index ff21aa4..39d70d1 100644 --- a/src/ui/hud.js +++ b/src/ui/hud.js @@ -29,6 +29,8 @@ function selectTool(k) { } function bagMaterials() { return BAG_ORDER.filter(m => bag[m] > 0); } function blowList() { return [...GAME.blow]; } +// Shift + right click: an even mix of everything loose in the bag (bars never mix) +function blowAllList() { return bagMaterials().filter(m => PH[m] !== PH_ITEM && (PH[m] !== PH_LIQUID || player.flask)); } // Empty materials stay chosen until the player changes the marks. function ensureBlow() { if (GAME.blow.size) return; diff --git a/src/ui/input.js b/src/ui/input.js index efbc083..47594d4 100644 --- a/src/ui/input.js +++ b/src/ui/input.js @@ -53,7 +53,7 @@ canvas.addEventListener('mousedown', e => { auInit(); GAME.usingPad = false; if (e.button === 0) { GAME.lmb = true; primaryPress(e.shiftKey && !steering()); } - else if (e.button === 2) { GAME.rmb = true; secondaryPress(); } + else if (e.button === 2) { GAME.rmb = true; secondaryPress(e.shiftKey && !steering()); } else if (e.button === 1) { e.preventDefault(); pickUnderCursor(); } }); window.addEventListener('mouseup', e => { diff --git a/src/ui/notebook.js b/src/ui/notebook.js index 4bdf045..fc19c72 100644 --- a/src/ui/notebook.js +++ b/src/ui/notebook.js @@ -170,7 +170,7 @@ function controlsHtml() { const kb = [ ['Fly', 'W A S D'], ['Boost while flying', 'Shift'], ['Tool modifier (while not flying)', 'Shift'], ['Use tool', 'Left mouse'], ['Second action', 'Right mouse'], ['Tools', '1 – 4'], ['Cycle what you blow · piece', 'Q / E'], ['Switch build tab', 'Shift + Q / E'], ['Variant: material · direction · flip', 'R'], ['Wall or mold material menu (Build)', 'F'], - ['Mark/unmark what is under the cursor (Vacuum)', 'R'], ['Vacuum menu: what to pick up and blow', 'F (with the Vacuum, away from anything to use)'], ['Nozzle or brush size', '[ ] or Shift + wheel'], + ['Mark/unmark what is under the cursor (Vacuum)', 'R'], ['Vacuum menu: what to pick up and blow', 'F (with the Vacuum, away from anything to use)'], ['Add to blow · to pick up (Vacuum)', 'Ctrl+click · Ctrl+right-click a bag name'], ['Blow everything loose in the bag', 'Shift + right click'], ['Nozzle or brush size', '[ ] or Shift + wheel'], ['Replace what is built (Build)', 'Hold G'], ['Copy a box of what you built', 'Hold C, drag'], ['Cut a box (refunded, paste it back)', 'Hold X, drag'], ['Paste again', 'V'], ['Undo building', 'Ctrl + Z'], ['Blueprint library', 'B'], ['Zoom', 'Mouse wheel'], ['Interact (gates, molds, fans, filters)', 'F'], ['Thermal lens', 'H'], ['Info overlay: filters, belt and tube directions', 'I'], ['Bot lamp', 'L'], ['Pick the piece under the cursor (with its direction and settings)', 'Middle mouse'], ['Notebook', 'N'], ['Clear all blueprint ghosts', 'Delete'], ['Save', 'Ctrl + S'], ['Pause · menu', 'Esc'], ['Mute', 'M'], ]; @@ -181,7 +181,7 @@ function controlsHtml() { ]; const tools = [ ['1 Resonance drill', 'Hold left click to send pulses from the bot toward the cursor: rock they shake hard enough shatters and falls as rubble, ore, coal or scrap, and trees come apart into wood chunks and acorns. Q and E set the pulse width: narrow bores a tunnel fast, wide clears a big cone slowly, medium is in between. R over a material tunes the drill to it, so only that shatters (great for pulling a vein out of the rock); R again untunes. Built blocks are never harmed. Better bits dig harder rock, faster and further.'], - ['2 Vacuum', 'Left click picks up everything, or only marked materials once you mark some. F opens the vacuum menu to mark what to pick up (⬇) and blow (⬆). R over a grain marks it. Hold Shift while not flying to take everything. Right click blows the ⬆ materials as an even mix, bars one at a time. Click a bag name or use Q/E to blow only one; it stays chosen when empty.'], + ['2 Vacuum', 'Left click picks up everything, or only marked materials once you mark some. F opens the vacuum menu to mark what to pick up (⬇) and blow (⬆). R over a grain marks it. Hold Shift while not flying to take everything. Right click blows the ⬆ materials as an even mix, bars one at a time; Shift + right click blows an even mix of everything loose in the bag. Click a bag name or use Q/E to blow only one; it stays chosen when empty. Ctrl+click a bag name adds it to what you blow; Ctrl+right-click adds it to what you pick up.'], ['3 Build', 'Places pieces on a grid of 4×4 blocks: wall blocks, belts, tubes, filters, grates, gates, pipes, pumps and outlets, machines (fans, the Refiner) and molds. Drag for a line, hold Shift while dragging for a filled rectangle. Right click takes a piece back. Your bot flies through anything you build.'], ['4 Igniter', 'Heats a small spot to 640°C, and its flame reaches a few grains into a pile. Enough to light coal and wood that touch air.'], ['Copy, cut and paste', 'Hold C (copy) or X (cut) and drag a box over what you built; let go and it is picked up, ready to paste. Cut takes it down and refunds it to your bag. Click to place ghosts: your bot builds them from your bag when it is close. R flips, V pastes again, right click or Esc stops. Ctrl+Z undoes building. The notebook (B) keeps saved blueprints and a few starter designs.'], diff --git a/src/ui/previews.js b/src/ui/previews.js index ffa34b4..67d3b67 100644 --- a/src/ui/previews.js +++ b/src/ui/previews.js @@ -153,7 +153,7 @@ function drawPreviews() { if (t === 'vacuum') { // what the right button would blow, next to the cursor; what the left is sucking, while it sucks const sz = Math.max(6, zp * 1.6), qx = p.x + (r + 1.2) * zp, qy = p.y - (r + 1.2) * zp; - const swatches = GAME.lmb ? GAME.vacFilter >= 0 && GAME.collect.size ? [...GAME.collect] : [] : blowList(); + const swatches = GAME.lmb ? GAME.vacFilter >= 0 && GAME.collect.size ? [...GAME.collect] : [] : GAME.rmb && GAME.blowAll ? blowAllList() : blowList(); swatches.slice(0, 4).forEach((m, k) => { ctx.fillStyle = rgbOf(m); ctx.fillRect(qx + k * (sz + 2), qy, sz, sz); }); if (blowing || GAME.lmb) { // faint line from the nozzle: the path the grains take diff --git a/src/ui/selection.js b/src/ui/selection.js index f81ec52..f3c5f8b 100644 --- a/src/ui/selection.js +++ b/src/ui/selection.js @@ -163,7 +163,10 @@ function openVacuumMenu() { const blow = blowList(); box.innerHTML = `

${esc(GAME.collect.size ? 'Picking up only ' + matList([...GAME.collect]) + '.' : 'Picking up everything.')} Blowing out ${blow.length > 1 ? `an even mix of ${blow.length}` : blow.length ? esc(MD[blow[0]].name.toLowerCase()) : 'nothing'}.

` + (!GAME.collect.size ? '

Mark materials to pick up only those.

' : '') - + (list.length ? `
${list.map(m => `
${esc(MD[m].name)}${bag[m] ? `you have ${bag[m].toLocaleString('en-US')}` : 'none in bag'}
`).join('')}
` : '

Find something loose first.

'); + + (list.length ? `
Pick upBlow
${list.map(m => { + const n = esc(MD[m].name); + return `
${n}${bag[m] ? bag[m].toLocaleString('en-US') : 'none'}
`; + }).join('')}
` : '

Find something loose first.

'); box.querySelectorAll('[data-vm-pick]').forEach(b => b.onclick = () => { toggleCollect(+b.dataset.vmPick); sync(); }); box.querySelectorAll('[data-vm-blow]').forEach(b => b.onclick = () => { toggleBlow(+b.dataset.vmBlow); sync(); }); } @@ -231,7 +234,7 @@ function renderSelCard() { const count = list.reduce((n, m) => n + bag[m], 0); h = `
Pick up${sws(marks)}
Blow outQ${count ? count.toLocaleString('en-US') : 'none left'}E
-
Left picks up, right blows out. F chooses what. R marks what is under the cursor. Shift takes everything. nozzle ${GAME.brush[1]}
`; +
Left picks up, right blows out. F chooses what. R marks what is under the cursor. Shift takes or blows everything. nozzle ${GAME.brush[1]}
`; } else if (t === 'ignite') { h = `
Igniterup to 640°C
Hold on fuel to light it. Coal catches around 420°C, wood around 300°C. Fuel needs open air next to it.
`; } @@ -257,7 +260,10 @@ function renderBag() { if (t === 'vacuum' && GAME.collect.has(m)) row.appendChild(el('span', { class: 'tag', title: 'Marked to pick up', 'aria-label': 'Marked to pick up' }, '⬇')); const b = el('button', { class: 'pick', title: WALL_OF[m] && t === 'build' ? `Build with ${MD[m].name}` : `Blow out ${MD[m].name}` }); b.innerHTML = `${esc(MD[m].name)}${bag[m].toLocaleString('en-US')}`; - b.onclick = () => { + // Ctrl+click adds to (or removes from) what you blow; Ctrl+right-click, what you pick up + b.oncontextmenu = e => { e.preventDefault(); if (!(e.ctrlKey || e.metaKey)) return; toggleCollect(m); if (t !== 'vacuum') selectTool(1); }; + b.onclick = e => { + if (e.ctrlKey || e.metaKey) { toggleBlow(m); if (t !== 'vacuum') selectTool(1); return; } if (t === 'build' && piece().filter && filterable(m)) { toggleFilterMat(m); return; } else if (t === 'build' && WALL_OF[m]) { GAME.wallMat = m; if (!pieceUsesWall(piece())) GAME.pieceIdx = 0; } else { GAME.blow = new Set([m]); if (t !== 'vacuum') selectTool(1); } @@ -266,5 +272,5 @@ function renderBag() { row.appendChild(b); box.appendChild(row); } - if (t === 'vacuum') box.appendChild(el('div', { class: 'baghint' }, 'F with the Vacuum: choose what to pick up and blow')); + if (t === 'vacuum') box.appendChild(el('div', { class: 'baghint' }, 'Click: blow only this · Ctrl+click: add to blow · Ctrl+right-click: add to pick up · F: vacuum menu')); } diff --git a/test/play.py b/test/play.py index 0e02e30..7841980 100644 --- a/test/play.py +++ b/test/play.py @@ -327,15 +327,28 @@ with sync_playwright() as p: refill('bag[COAL] = 3') hold(dx_, dy_, 400, 'right'); settle(400) check('chosen material stays chosen after it runs out', ev('bag[COAL] === 0 && GAME.blow.size === 1 && GAME.blow.has(COAL)'), ev('[bag[COAL], ...GAME.blow]')) - # mark coal and ore: Shift no longer changes what is blown, the selection does + # Shift + right click blows an even mix of everything loose in the bag, without changing the choice refill('bag[COAL] = 60; bag[CUORE] = 60') ev('GAME.collect = new Set([COAL, CUORE]); GAME.blow = new Set([COAL]); lastBagKey = ""; hudDirty = true;') mx_, my_ = cell_to_screen(bx - 26, gy - 6) pg.mouse.move(mx_, my_); pg.keyboard.down('Shift'); pg.mouse.down(button='right'); settle(400); pg.mouse.up(button='right'); pg.keyboard.up('Shift'); settle(600) - check('Shift + right click still blows only the chosen coal', ev('bag[CUORE]') == 60 and ev('bag[COAL]') < 60, ev('[bag[COAL], bag[CUORE]]')) + check('Shift + right click blows everything loose in the bag', ev('bag[CUORE]') < 60 and ev('bag[COAL]') < 60 and ev('GAME.blow.size === 1 && GAME.blow.has(COAL)'), ev('[bag[COAL], bag[CUORE]]')) refill('bag[COAL] = 60; bag[CUORE] = 60'); settle(200) pg.locator('#baglist .pick').filter(has_text='Coal').click(); settle(80) check('clicking a bag name chooses one blow material', ev('GAME.blow.size === 1 && GAME.blow.has(COAL)')) + ore_row = pg.locator('#baglist .pick').filter(has_text='Copper ore') + ore_row.click(modifiers=['Control']); settle(80) + check('Ctrl+click adds a bag item to the blow mix', ev('GAME.blow.size === 2 && GAME.blow.has(COAL) && GAME.blow.has(CUORE)') and 'Mix of 2' in pg.inner_text('#selcard')) + ore_row.click(modifiers=['Control']); settle(80) + check('Ctrl+click again takes it out of the mix', ev('GAME.blow.size === 1 && GAME.blow.has(COAL)')) + ev('GAME.collect = new Set(); lastBagKey = ""; hudDirty = true'); settle(80) + pg.locator('#baglist .pick').filter(has_text='Copper ore').click(button='right', modifiers=['Control']); settle(80) + check('Ctrl+right-click marks a bag item for pick-up', ev('GAME.collect.size === 1 && GAME.collect.has(CUORE) && GAME.blow.size === 1')) + pg.locator('#baglist .pick').filter(has_text='Copper ore').click(button='right', modifiers=['Control']); settle(80) + check('Ctrl+right-click again unmarks it', ev('!GAME.collect.size')) + pg.locator('#baglist .pick').filter(has_text='Copper ore').click(button='right'); settle(80) + check('a plain right click on the bag changes nothing', ev('!GAME.collect.size && GAME.blow.size === 1 && GAME.blow.has(COAL)')) + ev('GAME.collect = new Set([COAL, CUORE]); lastBagKey = ""; hudDirty = true'); settle(80) pick_before = ev('[...GAME.collect]') ev('GAME.blow = new Set([RSTONE]); hudDirty = true') pg.click('#selcard [data-vac-menu]'); settle(80) @@ -368,6 +381,7 @@ with sync_playwright() as p: pg.click('#selcard [data-vac-menu]'); settle(80) pg.screenshot(path=str(out / f'vacuum-menu{suffix}.png')) check(f'vacuum menu fits at {width}', ev("(() => { const e = $('dlg-body'), r = e.closest('.plate').getBoundingClientRect(); return e.scrollWidth <= e.clientWidth && document.querySelector('.vpick').scrollWidth <= document.querySelector('.vpick').clientWidth && r.left >= 0 && r.right <= innerWidth && r.bottom <= innerHeight; })()")) + check(f'vacuum menu has one line per material at {width}', ev("(() => { const rows = [...document.querySelectorAll('.vpick .vrow:not(.vhead)')]; return rows.length > 3 && rows.every(r => r.getBoundingClientRect().height <= 28); })()")) pg.get_by_role('button', name='Blow one at a time', exact=True).click(); settle(80) check(f'Blow one at a time keeps menu open at {width}', ev("GAME.blow.size === 1 && !$('m-dialog').hidden")) pg.get_by_role('button', name='Done', exact=True).click(); settle(80)