Skip to content

Commit c8b652b

Browse files
Let the loose bin pick its layer, and add one, without leaving the page
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>
1 parent c88f512 commit c8b652b

9 files changed

Lines changed: 242 additions & 10 deletions

File tree

bins/index.html

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -583,7 +583,9 @@
583583

584584
/* A loose bin has no place in a drawer, so the controls that talk about one go: where
585585
it sits, whether it has been printed, and the way "back" to somewhere it never was. */
586-
#scratchAdd,#scratchDrop,#scratchWhy{display:none}
586+
#scratchAdd,#scratchDrop,#scratchWhy,#scratchTarget{display:none}
587+
body.binscratch #scratchTarget{display:flex;gap:8px;align-items:center;flex-basis:100%;flex-wrap:wrap}
588+
body.binscratch #scratchTarget label{font-size:11px;color:var(--dim)}
587589
body.binscratch #focusExit{display:none}
588590
body.binscratch #scratchAdd,body.binscratch #scratchDrop{display:inline-block}
589591
body.binscratch #scratchWhy{display:block;flex-basis:100%;font-size:11px;color:var(--dim)}
@@ -670,6 +672,14 @@ <h1>DRAWER<span class="tm">FORGE</span> <span class="tm" style="opacity:.55">·
670672
<button type="button" class="act" id="scratchAdd">Add to the drawer</button>
671673
<button type="button" class="ghost" id="scratchDrop">Discard</button>
672674
</div>
675+
<!-- Where the loose bin will land. The refusal below names two remedies -- pick a
676+
different layer, or add one -- and both of them used to live on the drawer page
677+
this mode hides, so taking either meant discarding the bin first. -->
678+
<div id="scratchTarget">
679+
<label for="scratchLayer">Land on</label>
680+
<select id="scratchLayer"></select>
681+
<button type="button" class="ghost" id="scratchAddLayer">Add a layer on top</button>
682+
</div>
673683
<div id="scratchWhy" class="mono"></div>
674684
</div>
675685
<div class="rail">
@@ -4872,6 +4882,7 @@ <h2 id="exportTitle">Download your bins</h2>
48724882
than in a message after. A bin that cannot land says why, and the reason changes
48734883
as you change the size — which is the thing that would fix it. */
48744884
if (scratch) {
4885+
drawScratchLayers();
48754886
const spot = scratchLanding();
48764887
$('scratchAdd').disabled = !!spot.why;
48774888
$('scratchWhy').classList.toggle('no', !!spot.why);
@@ -5181,6 +5192,43 @@ <h2 id="exportTitle">Download your bins</h2>
51815192
bar.appendChild(b);
51825193
});
51835194
}
5195+
/* The drawer's layer tabs live inside #s-layout, which focus hides wholesale, so a loose
5196+
bin had no way to answer the refusal that told it to pick another layer. This is the
5197+
same choice in the shape that fits a one-line bar. Rebuilt only when the layers or the
5198+
selection actually change: it is written on every refresh, and replacing the options
5199+
under a select the keyboard is inside would drop focus mid-choice. */
5200+
let scratchLayerKey = '';
5201+
function drawScratchLayers() {
5202+
const sel = $('scratchLayer'), key = layers.length + '/' + cur;
5203+
if (key === scratchLayerKey) return;
5204+
scratchLayerKey = key;
5205+
sel.innerHTML = '';
5206+
layers.forEach((L, i) => {
5207+
const o = document.createElement('option');
5208+
o.value = String(i);
5209+
o.textContent = `Layer ${i + 1}` + (L.bins.length ? ` · ${L.bins.length}` : ' · empty');
5210+
if (i === cur) o.selected = true;
5211+
sel.appendChild(o);
5212+
});
5213+
}
5214+
/* Switching the target layer is not an edit -- it changes where the bin WOULD go, and the
5215+
drawer's own layer tabs do not bank an undo entry for the same choice either. refresh()
5216+
re-runs scratchLanding, so the reason under the button answers the new layer straight
5217+
away rather than keeping the old one's. */
5218+
$('scratchLayer').addEventListener('change', (e) => {
5219+
const i = parseInt(e.target.value, 10);
5220+
if (!(i >= 0 && i < layers.length)) return;
5221+
cur = i;
5222+
scratchLayerKey = '';
5223+
readControls(); drawLayerTabs(); drawMap(); refresh();
5224+
});
5225+
$('scratchAddLayer').addEventListener('click', () => {
5226+
pushDrawerUndo();
5227+
layers.push({ bins: [] });
5228+
cur = layers.length - 1;
5229+
scratchLayerKey = '';
5230+
readControls(); drawLayerTabs(); drawMap(); refresh();
5231+
});
51845232
$('addLayer').addEventListener('click', () => {
51855233
pushUndo();
51865234
layers.push({ bins: [] });
@@ -5701,14 +5749,20 @@ <h2 id="exportTitle">Download your bins</h2>
57015749
const UNDO_MAX = 60;
57025750
const snapshot = () => (scratch ? JSON.stringify({ scratch })
57035751
: JSON.stringify({ layers, cur }));
5704-
function pushUndo() {
5705-
const snap = snapshot(), U = uStack(), R = rStack();
5752+
function pushOn(U, R, snap) {
57065753
if (U.length && U[U.length - 1] === snap) return;
57075754
U.push(snap);
57085755
if (U.length > UNDO_MAX) U.shift();
57095756
R.length = 0;
57105757
updateUndoButtons();
57115758
}
5759+
function pushUndo() { pushOn(uStack(), rStack(), snapshot()); }
5760+
/* Adding a layer while a loose bin is on screen edits the DRAWER, and the loose bin has
5761+
its own history that captures only itself. Routing this through pushUndo would file the
5762+
entry on the scratch stack, where undoing it restores the bin and leaves the new layer
5763+
standing -- and spends an Undo that appears to do nothing. It belongs on the drawer,
5764+
which is where it will be seen when you go back. Same push, named target. */
5765+
function pushDrawerUndo() { pushOn(undoStack, redoStack, JSON.stringify({ layers, cur })); }
57125766
function applySnap(snap) {
57135767
const o = JSON.parse(snap);
57145768
/* A loose bin's history holds only the bin. Nothing about the drawer is restored,

guide/drawer-sizes/index.html

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -573,7 +573,9 @@
573573

574574
/* A loose bin has no place in a drawer, so the controls that talk about one go: where
575575
it sits, whether it has been printed, and the way "back" to somewhere it never was. */
576-
#scratchAdd,#scratchDrop,#scratchWhy{display:none}
576+
#scratchAdd,#scratchDrop,#scratchWhy,#scratchTarget{display:none}
577+
body.binscratch #scratchTarget{display:flex;gap:8px;align-items:center;flex-basis:100%;flex-wrap:wrap}
578+
body.binscratch #scratchTarget label{font-size:11px;color:var(--dim)}
577579
body.binscratch #focusExit{display:none}
578580
body.binscratch #scratchAdd,body.binscratch #scratchDrop{display:inline-block}
579581
body.binscratch #scratchWhy{display:block;flex-basis:100%;font-size:11px;color:var(--dim)}

guide/index.html

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -573,7 +573,9 @@
573573

574574
/* A loose bin has no place in a drawer, so the controls that talk about one go: where
575575
it sits, whether it has been printed, and the way "back" to somewhere it never was. */
576-
#scratchAdd,#scratchDrop,#scratchWhy{display:none}
576+
#scratchAdd,#scratchDrop,#scratchWhy,#scratchTarget{display:none}
577+
body.binscratch #scratchTarget{display:flex;gap:8px;align-items:center;flex-basis:100%;flex-wrap:wrap}
578+
body.binscratch #scratchTarget label{font-size:11px;color:var(--dim)}
577579
body.binscratch #focusExit{display:none}
578580
body.binscratch #scratchAdd,body.binscratch #scratchDrop{display:inline-block}
579581
body.binscratch #scratchWhy{display:block;flex-basis:100%;font-size:11px;color:var(--dim)}

guide/split/index.html

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -585,7 +585,9 @@
585585

586586
/* A loose bin has no place in a drawer, so the controls that talk about one go: where
587587
it sits, whether it has been printed, and the way "back" to somewhere it never was. */
588-
#scratchAdd,#scratchDrop,#scratchWhy{display:none}
588+
#scratchAdd,#scratchDrop,#scratchWhy,#scratchTarget{display:none}
589+
body.binscratch #scratchTarget{display:flex;gap:8px;align-items:center;flex-basis:100%;flex-wrap:wrap}
590+
body.binscratch #scratchTarget label{font-size:11px;color:var(--dim)}
589591
body.binscratch #focusExit{display:none}
590592
body.binscratch #scratchAdd,body.binscratch #scratchDrop{display:inline-block}
591593
body.binscratch #scratchWhy{display:block;flex-basis:100%;font-size:11px;color:var(--dim)}

index.html

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -582,7 +582,9 @@
582582

583583
/* A loose bin has no place in a drawer, so the controls that talk about one go: where
584584
it sits, whether it has been printed, and the way "back" to somewhere it never was. */
585-
#scratchAdd,#scratchDrop,#scratchWhy{display:none}
585+
#scratchAdd,#scratchDrop,#scratchWhy,#scratchTarget{display:none}
586+
body.binscratch #scratchTarget{display:flex;gap:8px;align-items:center;flex-basis:100%;flex-wrap:wrap}
587+
body.binscratch #scratchTarget label{font-size:11px;color:var(--dim)}
586588
body.binscratch #focusExit{display:none}
587589
body.binscratch #scratchAdd,body.binscratch #scratchDrop{display:inline-block}
588590
body.binscratch #scratchWhy{display:block;flex-basis:100%;font-size:11px;color:var(--dim)}

src/bins/template.html

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,14 @@ <h1>DRAWER<span class="tm">FORGE</span> <span class="tm" style="opacity:.55">·
8383
<button type="button" class="act" id="scratchAdd">Add to the drawer</button>
8484
<button type="button" class="ghost" id="scratchDrop">Discard</button>
8585
</div>
86+
<!-- Where the loose bin will land. The refusal below names two remedies -- pick a
87+
different layer, or add one -- and both of them used to live on the drawer page
88+
this mode hides, so taking either meant discarding the bin first. -->
89+
<div id="scratchTarget">
90+
<label for="scratchLayer">Land on</label>
91+
<select id="scratchLayer"></select>
92+
<button type="button" class="ghost" id="scratchAddLayer">Add a layer on top</button>
93+
</div>
8694
<div id="scratchWhy" class="mono"></div>
8795
</div>
8896
<div class="rail">

src/bins/ui.js

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,7 @@ function applyFocus() {
358358
than in a message after. A bin that cannot land says why, and the reason changes
359359
as you change the size — which is the thing that would fix it. */
360360
if (scratch) {
361+
drawScratchLayers();
361362
const spot = scratchLanding();
362363
$('scratchAdd').disabled = !!spot.why;
363364
$('scratchWhy').classList.toggle('no', !!spot.why);
@@ -667,6 +668,43 @@ function drawLayerTabs() {
667668
bar.appendChild(b);
668669
});
669670
}
671+
/* The drawer's layer tabs live inside #s-layout, which focus hides wholesale, so a loose
672+
bin had no way to answer the refusal that told it to pick another layer. This is the
673+
same choice in the shape that fits a one-line bar. Rebuilt only when the layers or the
674+
selection actually change: it is written on every refresh, and replacing the options
675+
under a select the keyboard is inside would drop focus mid-choice. */
676+
let scratchLayerKey = '';
677+
function drawScratchLayers() {
678+
const sel = $('scratchLayer'), key = layers.length + '/' + cur;
679+
if (key === scratchLayerKey) return;
680+
scratchLayerKey = key;
681+
sel.innerHTML = '';
682+
layers.forEach((L, i) => {
683+
const o = document.createElement('option');
684+
o.value = String(i);
685+
o.textContent = `Layer ${i + 1}` + (L.bins.length ? ` · ${L.bins.length}` : ' · empty');
686+
if (i === cur) o.selected = true;
687+
sel.appendChild(o);
688+
});
689+
}
690+
/* Switching the target layer is not an edit -- it changes where the bin WOULD go, and the
691+
drawer's own layer tabs do not bank an undo entry for the same choice either. refresh()
692+
re-runs scratchLanding, so the reason under the button answers the new layer straight
693+
away rather than keeping the old one's. */
694+
$('scratchLayer').addEventListener('change', (e) => {
695+
const i = parseInt(e.target.value, 10);
696+
if (!(i >= 0 && i < layers.length)) return;
697+
cur = i;
698+
scratchLayerKey = '';
699+
readControls(); drawLayerTabs(); drawMap(); refresh();
700+
});
701+
$('scratchAddLayer').addEventListener('click', () => {
702+
pushDrawerUndo();
703+
layers.push({ bins: [] });
704+
cur = layers.length - 1;
705+
scratchLayerKey = '';
706+
readControls(); drawLayerTabs(); drawMap(); refresh();
707+
});
670708
$('addLayer').addEventListener('click', () => {
671709
pushUndo();
672710
layers.push({ bins: [] });
@@ -1187,14 +1225,20 @@ const rStack = () => (scratch ? sRedoStack : redoStack);
11871225
const UNDO_MAX = 60;
11881226
const snapshot = () => (scratch ? JSON.stringify({ scratch })
11891227
: JSON.stringify({ layers, cur }));
1190-
function pushUndo() {
1191-
const snap = snapshot(), U = uStack(), R = rStack();
1228+
function pushOn(U, R, snap) {
11921229
if (U.length && U[U.length - 1] === snap) return;
11931230
U.push(snap);
11941231
if (U.length > UNDO_MAX) U.shift();
11951232
R.length = 0;
11961233
updateUndoButtons();
11971234
}
1235+
function pushUndo() { pushOn(uStack(), rStack(), snapshot()); }
1236+
/* Adding a layer while a loose bin is on screen edits the DRAWER, and the loose bin has
1237+
its own history that captures only itself. Routing this through pushUndo would file the
1238+
entry on the scratch stack, where undoing it restores the bin and leaves the new layer
1239+
standing -- and spends an Undo that appears to do nothing. It belongs on the drawer,
1240+
which is where it will be seen when you go back. Same push, named target. */
1241+
function pushDrawerUndo() { pushOn(undoStack, redoStack, JSON.stringify({ layers, cur })); }
11981242
function applySnap(snap) {
11991243
const o = JSON.parse(snap);
12001244
/* A loose bin's history holds only the bin. Nothing about the drawer is restored,

src/shared-ui/style.css

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -555,7 +555,9 @@ body.binfocus #focusbar .fbacts{display:flex;gap:8px;margin-left:auto;flex-wrap:
555555

556556
/* A loose bin has no place in a drawer, so the controls that talk about one go: where
557557
it sits, whether it has been printed, and the way "back" to somewhere it never was. */
558-
#scratchAdd,#scratchDrop,#scratchWhy{display:none}
558+
#scratchAdd,#scratchDrop,#scratchWhy,#scratchTarget{display:none}
559+
body.binscratch #scratchTarget{display:flex;gap:8px;align-items:center;flex-basis:100%;flex-wrap:wrap}
560+
body.binscratch #scratchTarget label{font-size:11px;color:var(--dim)}
559561
body.binscratch #focusExit{display:none}
560562
body.binscratch #scratchAdd,body.binscratch #scratchDrop{display:inline-block}
561563
body.binscratch #scratchWhy{display:block;flex-basis:100%;font-size:11px;color:var(--dim)}

test/ui/scratch-layers.spec.js

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/* Choosing where a loose bin lands, from the page the loose bin is on.
2+
*
3+
* scratchLanding() refuses a bin with "no free U×V space on layer N — clear some cells,
4+
* or add a layer". Both remedies it names lived inside #s-layout, which focus hides
5+
* wholesale, so the only way to take either was to discard the bin, fix the drawer, and
6+
* start again. These cases pin the two controls that answer it, and the one thing about
7+
* them that is easy to get wrong.
8+
*/
9+
'use strict';
10+
const { test, expect } = require('@playwright/test');
11+
const { openBins, dragCells, bins, setField } = require('./helpers');
12+
13+
const start = async (page) => {
14+
await page.locator('#scratchBinMap').click();
15+
await page.waitForTimeout(250);
16+
};
17+
const why = (page) => page.evaluate(() => document.getElementById('scratchWhy').textContent);
18+
const layerCount = (page) => page.evaluate(() => layers.length);
19+
const curLayer = (page) => page.evaluate(() => cur);
20+
const undoDepths = (page) => page.evaluate(() => ({
21+
drawer: undoStack.length, loose: sUndoStack.length }));
22+
23+
test('the loose bin can see and choose the layer it will land on', async ({ page }) => {
24+
const errors = await openBins(page);
25+
await page.locator('#addLayer').click(); // two layers in the drawer
26+
await start(page);
27+
28+
await expect(page.locator('#scratchTarget')).toBeVisible();
29+
const opts = await page.locator('#scratchLayer option').allTextContents();
30+
expect(opts, 'one option per layer, named the way the tabs name them').toHaveLength(2);
31+
expect(opts[0]).toContain('Layer 1');
32+
expect(opts[1]).toContain('Layer 2');
33+
expect(errors).toEqual([]);
34+
});
35+
36+
test('choosing another layer re-answers the landing question straight away',
37+
async ({ page }) => {
38+
await openBins(page);
39+
/* Added from the drawer, because #addLayer is hidden inside the mode -- which is the
40+
whole reason this feature exists. The picker is the way in from here. */
41+
await page.locator('#addLayer').click();
42+
await start(page);
43+
await setField(page, 'u', 2);
44+
await setField(page, 'v', 2);
45+
46+
await page.locator('#scratchLayer').selectOption('0');
47+
await page.waitForTimeout(150);
48+
expect(await curLayer(page)).toBe(0);
49+
expect(await why(page), 'the reason follows the layer, not the one it was written for')
50+
.toContain('layer 1');
51+
52+
await page.locator('#scratchLayer').selectOption('1');
53+
await page.waitForTimeout(150);
54+
expect(await curLayer(page)).toBe(1);
55+
expect(await why(page)).toContain('layer 2');
56+
});
57+
58+
/* The refusal in full: fill layer 1, then resolve it without leaving the page. */
59+
test('adding a layer from here clears a refusal that had no answer on this page',
60+
async ({ page }) => {
61+
await openBins(page);
62+
const g = await page.evaluate(() => { const q = grid(); return { nx: q.nx, ny: q.ny }; });
63+
/* Layer 1 covered edge to edge by ONE bin. Full coverage is what makes adding a layer
64+
the remedy: an upper layer needs level, continuous support (seat().flat and
65+
.solidBelow), so a half-empty layer 1 refuses layer 2 just as firmly -- adding a
66+
layer is not a universal answer, and this is the case where it genuinely is one. */
67+
await dragCells(page, [0, 0], [g.nx - 1, g.ny - 1]);
68+
expect((await bins(page)).length).toBe(1);
69+
70+
await start(page);
71+
await setField(page, 'u', 2);
72+
await setField(page, 'v', 2);
73+
await expect(page.locator('#scratchAdd'),
74+
'layer 1 is full, so there is nowhere to land').toBeDisabled();
75+
expect(await why(page)).toContain('add a layer');
76+
77+
await page.locator('#scratchAddLayer').click();
78+
await page.waitForTimeout(250);
79+
expect(await layerCount(page)).toBe(2);
80+
expect(await curLayer(page), 'the new layer becomes the target, or the button did nothing')
81+
.toBe(1);
82+
await expect(page.locator('#scratchAdd'),
83+
'the full layer below supports the new one, so the bin can land').toBeEnabled();
84+
expect(await why(page)).toContain('layer 2');
85+
});
86+
87+
/* The one that is easy to get wrong. snapshot() in scratch mode captures ONLY the loose
88+
* bin, and pushUndo files it on the loose bin's stack. Adding a layer edits the DRAWER,
89+
* so routing it through pushUndo would bank an entry on the wrong stack: undoing it
90+
* restores the bin, leaves the new layer standing, and spends an Undo that appears to do
91+
* nothing at all. */
92+
test('adding a layer banks its undo on the drawer, not on the loose bin',
93+
async ({ page }) => {
94+
await openBins(page);
95+
await start(page);
96+
const before = await undoDepths(page);
97+
98+
await page.locator('#scratchAddLayer').click();
99+
await page.waitForTimeout(200);
100+
expect(await layerCount(page)).toBe(2);
101+
102+
const after = await undoDepths(page);
103+
expect(after.loose, 'the loose bin did not change, so its history must not have grown')
104+
.toBe(before.loose);
105+
expect(after.drawer, 'the drawer gained a layer, so the drawer is where it is undoable')
106+
.toBe(before.drawer + 1);
107+
108+
/* And it really undoes, once you are back in the drawer looking at it. */
109+
await page.locator('#scratchDrop').click();
110+
await page.waitForTimeout(200);
111+
expect(await layerCount(page)).toBe(2);
112+
await page.locator('#undoBtn').click();
113+
await page.waitForTimeout(200);
114+
expect(await layerCount(page), 'the drawer stack holds the layer, so Undo takes it back')
115+
.toBe(1);
116+
});

0 commit comments

Comments
 (0)