Units draw their real arrangement; suggestions learn the owner's plan

Three requests from Eric plus his reviewer's ranking doctrine.

The unit renders its PHYSICAL shape: openings group into rows by
their label letters and each cell's width is proportional to its
interior width — the Kallax reads as two double-wides spanning the
top, then four-across cube rows. A diagram of the wall, not a list.

The toggle buttons died: "Add a unit" and "Add an opening" are
labeled, always-visible forms — the button that merely revealed the
form below it was ceremony.

Suggestions got their relevance doctrine, three rules deep:
(1) Reunification beats geometry — an opening holding a series-mate
or base game (kinship = the normalized pre-colon name stem, plus the
series field where enrich has one; no taxonomy invented) ranks first,
marked ♥. Every placement the owner confirms teaches the suggester
their organization by example; expansions chase their base games
automatically during the move. (2) "Fits" means the REMAINING
opening: the stack budget already spent is subtracted before
offering, so the suggester's honesty holds precisely as shelves fill
— the direction it used to degrade. (3) Then tightest verified fit,
so a small box is offered cubes and never the oversize row. Nothing
matching says "no matching openings yet" instead of offering
everything the wall has.

One composed test pins the interplay: a full shelf isn't offered even
to a series-mate (honesty outranks reunification), and freeing the
space flips the same suggestion to ♥-first. 382 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
This commit is contained in:
Eric Wagoner
2026-08-09 14:20:16 -04:00
co-authored by Claude Fable 5
parent 446579e96c
commit f4f3877651
5 changed files with 232 additions and 42 deletions
+16
View File
@@ -124,6 +124,22 @@ def fits_opening(entry: dict, opening: dict) -> bool | None:
) )
def family_stem(entry: dict) -> str:
"""The taxonomy-free kinship signal: the normalized pre-colon stem of
the name ("Castle Panic: The Wizard's Tower" -> "castle panic"). Base
games and their expansions share it without anyone defining a series."""
name = (entry.get("name") or "").split(":")[0]
return normalize_title(name)
def related(entry: dict, other: dict) -> bool:
"""Same family stem, or overlapping series field where enrich has one."""
if family_stem(entry) and family_stem(entry) == family_stem(other):
return True
mine = set(entry.get("series") or [])
return bool(mine and mine & set(other.get("series") or []))
def stack_thickness(entry: dict) -> float | None: def stack_thickness(entry: dict) -> float | None:
"""A box on a shelf stack contributes its thinnest axis.""" """A box on a shelf stack contributes its thinnest axis."""
dims = game_dims(entry) dims = game_dims(entry)
+11 -2
View File
@@ -549,13 +549,21 @@ select {
.card.stack { display: block; } .card.stack { display: block; }
.unit .unithead { display: flex; align-items: center; gap: .7rem; .unit .unithead { display: flex; align-items: center; gap: .7rem;
justify-content: space-between; flex-wrap: wrap; } justify-content: space-between; flex-wrap: wrap; }
.openings { display: grid; gap: .5rem; /* rows mirror the unit's PHYSICAL arrangement: one .orow per label
grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr)); } row (A, B, ...), cells sized by flex-grow proportional to interior
width — a double-wide reads double wide */
.openings { display: flex; flex-direction: column; gap: .5rem;
margin-bottom: .8rem; }
.orow { display: flex; gap: .5rem; }
.formlabel { margin: 1.2rem 0 .35rem; }
.formlabel.small { font-weight: 700; font-size: .85rem;
color: var(--ink-soft); margin: .8rem 0 .3rem; }
button.opening { button.opening {
font: inherit; text-align: left; cursor: pointer; font: inherit; text-align: left; cursor: pointer;
background: #fff; border: 2px solid var(--board-edge); background: #fff; border: 2px solid var(--board-edge);
border-radius: var(--radius); padding: .5rem .6rem; border-radius: var(--radius); padding: .5rem .6rem;
display: flex; flex-direction: column; gap: .25rem; min-height: 5.2rem; display: flex; flex-direction: column; gap: .25rem; min-height: 5.2rem;
flex-basis: 0; min-width: 0; overflow: hidden;
} }
button.opening:hover, button.opening:focus-visible { button.opening:hover, button.opening:focus-visible {
outline: 3px solid var(--accent); } outline: 3px solid var(--accent); }
@@ -576,6 +584,7 @@ button.suggest { font: inherit; font-size: .8rem; cursor: pointer;
border: 2px solid var(--board-edge); border-radius: 1rem; border: 2px solid var(--board-edge); border-radius: 1rem;
background: var(--board); padding: .15rem .6rem; } background: var(--board); padding: .15rem .6rem; }
button.suggest:hover { border-color: var(--accent); } button.suggest:hover { border-color: var(--accent); }
button.suggest.reunites { border-color: var(--accent); font-weight: 700; }
/* the opening sheet: modal on desktop, bottom sheet on phones */ /* the opening sheet: modal on desktop, bottom sheet on phones */
#opensheet { position: fixed; inset: 0; background: rgba(42, 36, 56, .45); #opensheet { position: fixed; inset: 0; background: rgba(42, 36, 56, .45);
+29 -34
View File
@@ -1,9 +1,8 @@
<h1>Shelves</h1> <h1>Shelves</h1>
<div class="pagebar"><span id="shelfcount"></span> <div class="pagebar"><span id="shelfcount"></span></div>
<button id="addunit" aria-expanded="false">add a unit</button>
</div>
<form id="unitform" class="editform card" hidden> <h2 class="formlabel">Add a unit</h2>
<form id="unitform" class="editform card">
<label>Unit name <input name="name" required placeholder="Living room Kallax"></label> <label>Unit name <input name="name" required placeholder="Living room Kallax"></label>
<label>Preset <label>Preset
<select name="preset"> <select name="preset">
@@ -22,7 +21,6 @@
<label>Zone <input name="zone" placeholder="party games"></label> <label>Zone <input name="zone" placeholder="party games"></label>
<span class="editactions"> <span class="editactions">
<button type="submit" class="primary">create</button> <button type="submit" class="primary">create</button>
<button type="button" id="unitcancel">cancel</button>
</span> </span>
<span class="edithint">grid mode labels openings A1, A2… by row; you can edit, <span class="edithint">grid mode labels openings A1, A2… by row; you can edit,
add, or remove openings afterward — repeat with another grid to stack add, or remove openings afterward — repeat with another grid to stack
@@ -92,7 +90,8 @@ function openingCell(o) {
const count = o.games const count = o.games
? `${o.games}${o.unmeasured ? ` · ${o.unmeasured} unmeasured` : ""}` ? `${o.games}${o.unmeasured ? ` · ${o.unmeasured} unmeasured` : ""}`
: "—"; : "—";
return `<button class="opening" data-id="${esc(o.id)}"> return `<button class="opening" data-id="${esc(o.id)}"
style="flex-grow:${Math.round((o.width_in || 13.25) * 4)}">
<span class="olabel">${esc(o.label)}${warn}</span> <span class="olabel">${esc(o.label)}${warn}</span>
${o.zone ? `<span class="ozone">${esc(o.zone)}</span>` : ""} ${o.zone ? `<span class="ozone">${esc(o.zone)}</span>` : ""}
<span class="ocount">${count}</span> <span class="ocount">${count}</span>
@@ -105,11 +104,11 @@ function unitBlock(u) {
return `<section class="card stack unit"> return `<section class="card stack unit">
<div class="unithead"><h2>${esc(u.name)}</h2> <div class="unithead"><h2>${esc(u.name)}</h2>
<span class="editactions"> <span class="editactions">
<button class="addopening" data-unit="${esc(u.name)}"
aria-expanded="false">add opening</button>
<button class="delunit danger" data-unit="${esc(u.name)}">remove unit</button> <button class="delunit danger" data-unit="${esc(u.name)}">remove unit</button>
</span></div> </span></div>
<form class="editform addopeningform" data-unit="${esc(u.name)}" hidden> ${unitRows(u.openings)}
<p class="formlabel small">Add an opening</p>
<form class="editform addopeningform" data-unit="${esc(u.name)}">
<label>Rows <input name="rows" inputmode="numeric" size="3" value="1"></label> <label>Rows <input name="rows" inputmode="numeric" size="3" value="1"></label>
<label>Columns <input name="cols" inputmode="numeric" size="3" value="1"></label> <label>Columns <input name="cols" inputmode="numeric" size="3" value="1"></label>
<label>Label <input name="label" placeholder="wide top — for a single opening"></label> <label>Label <input name="label" placeholder="wide top — for a single opening"></label>
@@ -122,10 +121,26 @@ function unitBlock(u) {
3 × 4 under an existing A row becomes B1…D4; or set 1 × 1 and a label 3 × 4 under an existing A row becomes B1…D4; or set 1 × 1 and a label
for a single opening. All three sizes, or none for a no-limit spot.</span> for a single opening. All three sizes, or none for a no-limit spot.</span>
</form> </form>
<div class="openings">${u.openings.map(openingCell).join("")}</div>
</section>`; </section>`;
} }
// the unit draws its ACTUAL arrangement: openings group into rows by
// their label letters (A1 A2 | B1..B4), and each cell's width is
// proportional to its interior width — a double-wide reads double wide
function unitRows(openings) {
const rows = [];
let key = null;
for (const o of openings) {
const m = /^([A-Za-z]+)\d+$/.exec(o.label || "");
const rowKey = m ? m[1].toUpperCase() : `single-${o.id}`;
if (rowKey !== key) { rows.push([]); key = rowKey; }
rows[rows.length - 1].push(o);
}
return `<div class="openings">` + rows.map(row =>
`<div class="orow">${row.map(openingCell).join("")}</div>`
).join("") + `</div>`;
}
function unshelvedRow(g) { function unshelvedRow(g) {
const dims = g.measured ? "" : ` <span class="chip open" title="no trusted size — fit can't be verified">unmeasured</span>`; const dims = g.measured ? "" : ` <span class="chip open" title="no trusted size — fit can't be verified">unmeasured</span>`;
const gos = g.suggestions.map(id => { const gos = g.suggestions.map(id => {
@@ -133,12 +148,14 @@ function unshelvedRow(g) {
return o ? `<button class="suggest" data-key="${esc(g.key)}" data-id="${esc(id)}" return o ? `<button class="suggest" data-key="${esc(g.key)}" data-id="${esc(id)}"
title="fits, with room to spare">→ ${esc(o.unit)} · ${esc(o.label)}</button>` : ""; title="fits, with room to spare">→ ${esc(o.unit)} · ${esc(o.label)}</button>` : "";
}).join(""); }).join("");
const gosOut = gos
|| (g.measured ? `<span class="meta">no matching openings yet</span>` : "");
return `<div class="unshelved-row"> return `<div class="unshelved-row">
<a href="/library/game/${encodeURIComponent(g.key)}">${esc(g.name)}</a>${dims} <a href="/library/game/${encodeURIComponent(g.key)}">${esc(g.name)}</a>${dims}
${g.lost_home ? `<span class="chip no" ${g.lost_home ? `<span class="chip no"
title="its assigned opening no longer exists — the shelves changed">shelf gone</span>` : ""} title="its assigned opening no longer exists — the shelves changed">shelf gone</span>` : ""}
${g.note ? `<span class="meta">${esc(g.note)}</span>` : ""} ${g.note ? `<span class="meta">${esc(g.note)}</span>` : ""}
<span class="gos">${gos}</span> <span class="gos">${gosOut}</span>
</div>`; </div>`;
} }
@@ -194,8 +211,6 @@ let SHEET_OPENER = null; // the button to hand focus back to on close
function openSheet(id) { function openSheet(id) {
OPEN = id; OPEN = id;
SHEET_OPENER = document.activeElement; SHEET_OPENER = document.activeElement;
document.getElementById("unitform").hidden = true; // one layer at a time
document.querySelectorAll(".addopeningform").forEach(f => f.hidden = true);
document.getElementById("sheetsearch").value = ""; document.getElementById("sheetsearch").value = "";
document.getElementById("sheetmatches").innerHTML = ""; document.getElementById("sheetmatches").innerHTML = "";
document.getElementById("opensheet").hidden = false; document.getElementById("opensheet").hidden = false;
@@ -245,14 +260,6 @@ function wire() {
if (confirm(`Remove "${b.dataset.unit}" and its openings? Its games become unshelved.`)) if (confirm(`Remove "${b.dataset.unit}" and its openings? Its games become unshelved.`))
post("/api/furniture/delete-unit", {name: b.dataset.unit}); post("/api/furniture/delete-unit", {name: b.dataset.unit});
}); });
document.querySelectorAll(".addopening").forEach(b =>
b.onclick = () => {
const form = document.querySelector(
`.addopeningform[data-unit="${CSS.escape(b.dataset.unit)}"]`);
form.hidden = !form.hidden;
b.setAttribute("aria-expanded", String(!form.hidden));
if (!form.hidden) form.elements.label.focus();
});
document.querySelectorAll(".addopeningform").forEach(form => document.querySelectorAll(".addopeningform").forEach(form =>
form.onsubmit = async e => { form.onsubmit = async e => {
e.preventDefault(); e.preventDefault();
@@ -358,18 +365,7 @@ const PRESETS = {
virtual: {width_in: null, height_in: null, depth_in: null}, virtual: {width_in: null, height_in: null, depth_in: null},
}; };
const unitForm = document.getElementById("unitform"); const unitForm = document.getElementById("unitform");
const addUnitBtn = document.getElementById("addunit"); syncDims();
addUnitBtn.addEventListener("click", () => {
unitForm.hidden = !unitForm.hidden;
addUnitBtn.setAttribute("aria-expanded", String(!unitForm.hidden));
syncDims();
if (!unitForm.hidden) unitForm.elements.name.focus();
});
document.getElementById("unitcancel").addEventListener("click", () => {
unitForm.hidden = true;
unitForm.reset();
syncDims();
});
function syncDims() { function syncDims() {
const v = unitForm.elements.preset.value; const v = unitForm.elements.preset.value;
document.getElementById("customdims").hidden = v !== "custom"; document.getElementById("customdims").hidden = v !== "custom";
@@ -411,7 +407,6 @@ unitForm.addEventListener("submit", async e => {
or add more openings for its other sections`); or add more openings for its other sections`);
unitForm.reset(); unitForm.reset();
syncDims(); syncDims();
unitForm.hidden = true;
} finally { } finally {
createBtn.disabled = false; createBtn.disabled = false;
} }
+30 -6
View File
@@ -71,8 +71,10 @@ from bggpipe.shelves import (
new_opening_id, new_opening_id,
opening_index, opening_index,
opening_report, opening_report,
related,
save_furniture, save_furniture,
save_locations, save_locations,
stack_thickness,
) )
@@ -1125,13 +1127,35 @@ def create_app(
if key in located_keys or entry.get("stored_in"): if key in located_keys or entry.get("stored_in"):
continue continue
note = (locations.get(key) or {}).get("note", "") note = (locations.get(key) or {}).get("note", "")
# relevance gates, in rank order. (1) Reunification beats
# geometry: an opening already holding a series-mate or base
# game ranks first — every confirmed placement teaches the
# suggester the owner's organization by example. (2) "Fits"
# means the REMAINING opening, not the empty one: the stack
# budget already spent is subtracted before offering, so
# honesty holds as shelves fill. Then tightest fit, so a
# small box is offered cubes, never the oversize row.
thickness = stack_thickness(entry)
fitting = []
for oid, opening in openings.items():
if not opening.get("width_in"):
continue
if fits_opening(entry, opening) is not True:
continue
here = residents.get(oid, [])
report = opening_report(opening, here)
remaining = opening["height_in"] - report["stacked_in"]
if thickness is None or thickness > remaining:
continue
reunites = any(related(entry, e) for _, e in here)
volume = (
opening["width_in"] * opening["height_in"] * opening["depth_in"]
)
fitting.append((0 if reunites else 1, volume, oid, reunites))
fitting.sort(key=lambda item: item[:2])
suggestions = [ suggestions = [
oid {"id": oid, "reunites": reunites} for _, _, oid, reunites in fitting[:3]
for oid, opening in openings.items() ]
if opening.get("width_in")
and fits_opening(entry, opening) is True
and not opening_report(opening, residents.get(oid, []))["overfull"]
][:3]
unshelved.append( unshelved.append(
{ {
"key": key, "key": key,
+146
View File
@@ -617,3 +617,149 @@ def test_move_opening_boundaries_no_op(tmp_path):
web.post("/api/furniture/move-opening", json={"id": ids[0], "direction": -1}) web.post("/api/furniture/move-opening", json={"id": ids[0], "direction": -1})
after = [o["id"] for o in web.get("/api/shelves").json()["units"][0]["openings"]] after = [o["id"] for o in web.get("/api/shelves").json()["units"][0]["openings"]]
assert after == ids # first can't move earlier: quiet no-op assert after == ids # first can't move earlier: quiet no-op
def test_suggestions_prefer_the_tightest_verified_fit(tmp_path):
"""The relevance gate: a small box is offered cubes, never the
oversize row; only a box too big for cubes gets the double-wide."""
games = {
"1": {
"bgg_id": 1,
"name": "Small",
"dims": {
"width_in": 10,
"length_in": 10,
"depth_in": 2,
"source": "version",
},
},
"2": {
"bgg_id": 2,
"name": "Wide Boi",
"dims": {
"width_in": 11.5,
"length_in": 17,
"depth_in": 2.25,
"source": "version",
},
},
}
web, cfg = _web(tmp_path, games)
web.post("/api/furniture/add-unit", json={"name": "Wall"})
# oversize row FIRST in list order — the gate must not care
web.post(
"/api/furniture/add-openings",
json={
"unit": "Wall",
"label": "A1",
"zone": "oversize",
"width_in": 26.5,
"height_in": 13.25,
"depth_in": 15.4,
},
)
web.post(
"/api/furniture/add-openings",
json={
"unit": "Wall",
"rows": 1,
"cols": 2,
"zone": "cubes",
"width_in": 13.25,
"height_in": 13.25,
"depth_in": 15.4,
},
)
state = web.get("/api/shelves").json()
ids = {o["label"]: o["id"] for o in state["units"][0]["openings"]}
by_name = {g["name"]: g for g in state["unshelved"]}
# the small box: cubes first (tightest fit), the wide never leads
assert by_name["Small"]["suggestions"][0]["id"] in (ids["B1"], ids["B2"])
# the 17" box fits ONLY the double-wide
assert [s["id"] for s in by_name["Wide Boi"]["suggestions"]] == [ids["A1"]]
def test_reunification_outranks_tightest_fit_and_full_shelves_stop_lying(tmp_path):
"""The two ranking rules together: an opening holding a series-mate
ranks above a geometrically tighter empty cube, and an opening whose
stack budget is spent stops being offered at all."""
games = {
"base": {
"bgg_id": 1,
"name": "Castle Panic",
"dims": {
"width_in": 10,
"length_in": 10,
"depth_in": 3,
"source": "version",
},
},
"exp": {
"bgg_id": 2,
"name": "Castle Panic: The Wizard's Tower",
"dims": {
"width_in": 10,
"length_in": 10,
"depth_in": 2,
"source": "version",
},
},
"fat": {
"bgg_id": 3,
"name": "Shelf Hog",
"dims": {
"width_in": 12,
"length_in": 12,
"depth_in": 11,
"source": "version",
},
},
}
web, cfg = _web(tmp_path, games)
web.post("/api/furniture/add-unit", json={"name": "Wall"})
# C1 (holds the base game), C2 (empty, geometrically identical),
# and a BIGGER wide opening — reunification must beat both
web.post(
"/api/furniture/add-openings",
json={
"unit": "Wall",
"rows": 1,
"cols": 2,
"zone": "cubes",
"width_in": 13.25,
"height_in": 13.25,
"depth_in": 15.4,
},
)
web.post(
"/api/furniture/add-openings",
json={
"unit": "Wall",
"label": "wide",
"zone": "oversize",
"width_in": 26.5,
"height_in": 13.25,
"depth_in": 15.4,
},
)
state = web.get("/api/shelves").json()
ids = {o["label"]: o["id"] for o in state["units"][0]["openings"]}
web.post("/api/locate", json={"key": "base", "opening_id": ids["A1"]})
# nearly fill A1: the 11"-thick hog leaves ~0 budget behind the base
web.post("/api/locate", json={"key": "fat", "opening_id": ids["A1"]})
state = web.get("/api/shelves").json()
exp = next(g for g in state["unshelved"] if g["name"].endswith("Tower"))
# A1 holds the series-mate BUT its remaining height (13.25 - 3 - 11 < 2)
# can't take the expansion: honesty beats reunification, so the empty
# twin cube leads and A1 is not offered at all
offered = [s["id"] for s in exp["suggestions"]]
assert ids["A1"] not in offered
assert offered[0] == ids["A2"]
# free the space: reunification now wins over the identical empty cube
web.post("/api/locate", json={"key": "fat", "opening_id": ids["wide"]})
state = web.get("/api/shelves").json()
exp = next(g for g in state["unshelved"] if g["name"].endswith("Tower"))
first = exp["suggestions"][0]
assert first["id"] == ids["A1"] and first["reunites"] is True