Let the loose bin pick its layer, and add one, without leaving the page - #8
Let the loose bin pick its layer, and add one, without leaving the page#8Oliver-Johnson wants to merge 2 commits into
Conversation
e30199d to
ceca96f
Compare
scratchLanding refuses a bin with "no free U×V space on layer N -- clear some cells, or add a layer". Both remedies it named lived inside #s-layout, which focus hides wholesale, so the only way to take either was to Discard the bin, go back to the drawer, fix it there, and start the bin again. The message told you what to do and hid the controls for doing it. A picker and an add-layer button now sit in the focus bar, shown for a loose bin only. What they hide and show is the CSS list under body.binscratch, not a JS assignment, because a restore made of twenty-five assignments eventually half-restores. Adding a layer had one trap in it. snapshot() in scratch mode captures ONLY the loose bin, and pushUndo files it on the loose bin's stack -- so routing this through pushUndo would bank an entry that restores the bin, leaves the new layer standing, and spends an Undo that appears to do nothing. It goes on the drawer's stack, where the change will be seen. pushOn now holds the one implementation and pushUndo and pushDrawerUndo name their target, rather than the push being written twice. Switching layers banks nothing: it changes where the bin WOULD go, and the drawer's own layer tabs do not file an entry for the same choice either. Both handlers run the full readControls/drawLayerTabs/drawMap/refresh sequence. applyFocus() -- which writes the landing reason -- runs at the tail of readControls, so calling refresh alone left the reason answering the layer it was written for rather than the one now chosen. The second case here caught that. Whether an upper layer will take a bin is decided per BIN, not per layer. seat(b, k) walks only the new bin's own footprint, so the rest of the layer below is never consulted: a mostly-empty lower layer takes a bin quite happily as long as the cells directly under it are covered on every layer beneath and come out level. What refuses a bin is a footprint larger than the support beneath it. The third case uses full coverage because it is the simplest way to guarantee a landing spot exists, not because partial layers would refuse one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ceca96f to
c8b652b
Compare
The comment claimed an upper layer needs continuous support beneath it and that a half-empty layer below would refuse a bin just as firmly. It does not. seat(b, k) walks only the new bin's own footprint, so the rest of the layer is never consulted -- with layer 1 four cells full out of sixty-three, a 2x2 lands on layer 2 directly above those cells, and only a 3x3 over the same bin is refused. The footprint outgrew its support; the empty layer had nothing to do with it. Full coverage is still the right setup for the case, because it guarantees a landing spot exists wherever the loose bin ends up. The comment now says that rather than a rule the code does not have. No behaviour changes and no assertion moves -- the case passed under the correct rule all along, it was just described by a wrong one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Oliver-Johnson
left a comment
There was a problem hiding this comment.
Verdict: one confirmed bug, otherwise good. (Comment rather than "request changes" only because GitHub refuses that event on a PR the authenticated account owns.)
Independent review — I did not write any of this.
This is the best-argued of the four PRs and the reasoning about the two undo stacks is exactly right. pushOn holding the single implementation, pushDrawerUndo naming its target, and routing the layer addition onto the drawer's stack are all correct, and I confirmed the trap is real rather than hypothetical. The scope call (scratch only, not focus) is the right one.
Both ablations reproduce exactly as claimed
I ran them rather than taking the table on trust. Copy-aside and copy-back, per the handover's warning about git checkout <file>.
| ablation | result |
|---|---|
pushDrawerUndo() → pushUndo() |
1 failed (banks its undo on the drawer), 3 passed |
drop readControls() from the layer-change handler |
1 failed (re-answers the landing question), 3 passed |
| neither | 4 passed |
Each kills exactly one case and a different one. These tests bite.
The bug: the layer picker goes stale and says a full layer is empty
drawScratchLayers() caches on layers.length + '/' + cur, but the option text carries L.bins.length. Bin counts change without either key component changing, and startScratch() never resets scratchLayerKey. So:
- Open the page, enter the loose bin → picker caches
"1/0", showsLayer 1 · empty. - Discard, drag a bin onto layer 1.
- Enter the loose bin again → key is still
"1/0", sodrawScratchLayers()returns early.
Measured in the browser:
OPTIONS on first entry : ["Layer 1 · empty"]
bins now on layer 1 : 1
OPTIONS on second entry: ["Layer 1 · empty"] <-- wrong
The count is the only thing the picker adds over a bare layer number, and how full a layer is is precisely what governs whether the bin can land — so the one piece of information worth having is the one that goes stale. The refusal text underneath stays correct, which makes the two disagree on screen.
A key that carries the counts fixes it and subsumes the length:
const key = cur + '/' + layers.map((L) => L.bins.length).join(',');Worth a fifth case, since nothing currently covers re-entry.
Smaller things
- The two
scratchLayerKey = ''lines are dead. Both handlers changecur(and one changeslayers.length), so the key differs on the next call regardless. Harmless, but they read as uncertainty about whether the cache is right — and with the count-carrying key above they become genuinely redundant. pushDrawerUndore-spellssnapshot()'s drawer branch.JSON.stringify({ layers, cur })now appears twice, four lines apart. The PR body citesjointKindcosting three bugs by being copied four times, and then makes a copy — small, but adrawerSnap()used by both would honour the rule the body invokes.- Switching the picker mutates
cur, which is drawer state, with no undo entry and no announcement. Discard after picking layer 3 and you land back in the drawer on layer 3 rather than where you left. Defensible (it is where the bin was going), but it is a silent side effect on the other document — worth a sentence in the focus bar or in the commit message. - Comment style. The new comments use ASCII
--where this codebase uses em dashes:src/bins/ui.jsonmainhas 142—and zero--;src/bins/template.htmlhas 50 and zero. Four new lines here break that. Trivial, but the prose voice in this repo is clearly deliberate. #scratchTarget labelis 11px, under the 12px floorexport.spec.jsenforces at 375px. It passes today only because the audit runs outside scratch mode, and the neighbouring#scratchWhyis already 11px — so this follows precedent rather than setting one. Flagging it as pre-existing, not as this PR's doing.
Merge order
No longer a concern: this branch is already rebased past #7, and it auto-merges with #11, #9 and #10 with node build.js --check clean on the result. I checked by merging all four. The generated pages stay in sync, so no hand-merge is needed.
Reviewed independently; I am not the approver.
| under a select the keyboard is inside would drop focus mid-choice. */ | ||
| let scratchLayerKey = ''; | ||
| function drawScratchLayers() { | ||
| const sel = $('scratchLayer'), key = layers.length + '/' + cur; |
There was a problem hiding this comment.
Confirmed stale-label bug. The key is layers.length + '/' + cur, but the option text below carries L.bins.length. A layer's bin count changes without either key component changing, and startScratch() does not reset scratchLayerKey — so re-entering the mode returns early on line 679 and leaves the old text standing.
Measured:
OPTIONS on first entry : ["Layer 1 · empty"]
bins now on layer 1 : 1
OPTIONS on second entry: ["Layer 1 · empty"]
Carry the counts and the length comes along for free:
const key = cur + '/' + layers.map((L) => L.bins.length).join(',');That also makes the two scratchLayerKey = '' resets (lines 698, 705) honestly redundant rather than defensively redundant.
| entry on the scratch stack, where undoing it restores the bin and leaves the new layer | ||
| standing -- and spends an Undo that appears to do nothing. It belongs on the drawer, | ||
| which is where it will be seen when you go back. Same push, named target. */ | ||
| function pushDrawerUndo() { pushOn(undoStack, redoStack, JSON.stringify({ layers, cur })); } |
There was a problem hiding this comment.
JSON.stringify({ layers, cur }) is now written twice — here and in snapshot() four lines up. The PR body cites jointKind costing three bugs by existing in four copies; this is the same shape of thing, one copy in.
const drawerSnap = () => JSON.stringify({ layers, cur });
const snapshot = () => (scratch ? JSON.stringify({ scratch }) : drawerSnap());
function pushDrawerUndo() { pushOn(undoStack, redoStack, drawerSnap()); }
Closes #4.
Design a bin on its own, size it past what the drawer has room for, and the landing check refuses it:
Both remedies it names lived inside
#s-layout, whichbody.binfocushides wholesale in one CSS rule. So the only way to take either was to Discard the bin, go back to the drawer, fix it there, and start the bin again. The message told you what to do and hid the controls for doing it.The change
A layer picker and an Add a layer on top button now sit in the focus bar, shown for a loose bin only. What shows and hides them is the CSS list under
body.binscratch— not JS assignments — per the handover's rule thatreadControls()already makes ~25 show/hide decisions and a restore made of 25 more eventually half-restores.Switching layers banks no undo entry: it changes where the bin would go, and the drawer's own layer tabs don't file one for the same choice either.
The trap this had in it
snapshot()in scratch mode captures only the loose bin — nothing aboutlayersorcur— andpushUndo()files to the loose bin's stack. Adding a layer edits the drawer. Routing it throughpushUndo()would bank an entry that restores the bin, leaves the new layer standing, and spends an Undo that appears to do nothing.It now goes on the drawer's stack, where the change will actually be seen.
pushOnholds the single implementation andpushUndo/pushDrawerUndoname their target — written once rather than twice, since the handover records thatjointKindcost three bugs by being copied four times.A bug the tests caught in my own work
I first called
refresh()alone. The landing reason is written byapplyFocus(), which runs at the tail ofreadControls()— so the message kept answering the layer it was written for rather than the one just chosen. Both handlers now run the fullreadControls / drawLayerTabs / drawMap / refreshsequence every other mutating handler uses.Worth knowing: support is judged per bin, not per layer
Correction to an earlier version of this description, which claimed an upper layer needs continuous support beneath it and that a half-empty layer below would refuse a bin just as firmly. That was wrong. Oliver flagged it and the code agrees with him.
seat(b, k)walks only the new bin's own footprint:The rest of the layer is never consulted.
support(k)marksok[y][x] = falseonly where a lower layer is empty at that coordinate — so what is required is an unbroken, level column of bins under the new bin's own cells, and nothing at all is asked of the rest of the drawer.Measured: with layer 1 holding one 2×2 bin out of 63 cells, a 2×2 placed on layer 2 directly over it returns
solidBelow: true, flat: true, z: 21. A 3×3 over the same bin returnssolidBelow: false— the footprint outgrew its support. A 2×2 over bare plate is likewise refused. The empty remainder of the layer is irrelevant either way.So the refusal message is fine, and I withdraw the concern that it misleads. It only fires when layer N has no free spot for that footprint, which generally means the layer is densely occupied — precisely the condition that makes the layer above well supported.
The third case still uses full coverage, because that guarantees a landing spot exists wherever the loose bin ends up — not because a partial layer would refuse one. Commit
034a60fcorrects the comment that said otherwise.Verification
Four new cases in
test/ui/scratch-layers.spec.js, each proven to bite:pushDrawerUndo()→pushUndo()(wrong stack)readControls()from the layer-change handlerEach ablation kills exactly one case, and a different one — so neither case is redundant.
--workers=1(166 previously + 4 new).build.js --checkclean. The id audit rose 114→116 referenced and the display-reachability audit passes, so the new controls are genuinely reachable.node build.js, never hand-edited.style.cssis embedded in every page, so all five outputs change.Note on merge order
This will conflict with #7 in
index.html— both regenerate built output. The resolution is to rebase and re-runnode build.js, not to hand-merge generated files. Happy to do that once #7 lands.Scope
Deliberately scratch mode only. In focus mode the bin already has a layer, so a picker there would mean moving a placed bin between layers — which needs its own fit and support checks and is a larger feature than the one asked for.
🤖 Generated with Claude Code