Units get descriptions too — both levels of the furniture speak

Eric's correction: the opening-level description shipped, but the
unit deserved one as well (my own example sentence was unit-scale
prose). Units take a description at creation and edit it in place —
a linkish add/edit control under the unit's name with an inline
input — shown as quiet meta text on the card and stored in
furniture.json. Label addresses, zone matches, descriptions explain;
now at both scales.

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:30:58 -04:00
co-authored by Claude Fable 5
parent ce4b0e09d4
commit e58cc789f3
4 changed files with 79 additions and 3 deletions
+6
View File
@@ -556,6 +556,12 @@ select {
margin-bottom: .8rem; }
.orow { display: flex; gap: .5rem; }
.formlabel { margin: 1.2rem 0 .35rem; }
.unitdesc { margin: -.3rem 0 .7rem; display: flex; gap: .6rem;
align-items: center; flex-wrap: wrap; }
.unitdesc .descform:not([hidden]) { display: flex; gap: .4rem; flex: 1 1 16rem; }
.unitdesc .descform input { flex: 1 1 auto; font: inherit; font-size: .85rem;
border: 2px solid var(--board-edge); border-radius: var(--radius);
padding: .25rem .45rem; }
.formlabel.small { font-weight: 700; font-size: .85rem;
color: var(--ink-soft); margin: .8rem 0 .3rem; }
button.opening {
+27 -1
View File
@@ -19,6 +19,9 @@
<label>D <input name="depth_in" size="5"></label>
</span>
<label>Zone <input name="zone" placeholder="party games"></label>
<label class="wide">Description
<textarea name="description" rows="2"
placeholder="the wall behind the couch — main game storage"></textarea></label>
<span class="editactions">
<button type="submit" class="primary">create</button>
</span>
@@ -113,6 +116,16 @@ function unitBlock(u) {
<span class="editactions">
<button class="delunit danger" data-unit="${esc(u.name)}">remove unit</button>
</span></div>
<p class="unitdesc">
<span class="meta">${esc(u.description || "")}</span>
<button class="linkish editunitdesc" data-unit="${esc(u.name)}">
${u.description ? "edit description" : "add a description…"}</button>
<span class="descform" hidden>
<input class="unitdescinput" value="${esc(u.description || "")}"
placeholder="the wall behind the couch">
<button class="primary saveunitdesc" data-unit="${esc(u.name)}">save</button>
</span>
</p>
${unitRows(u.openings)}
<p class="formlabel small">Add an opening</p>
<form class="editform addopeningform" data-unit="${esc(u.name)}">
@@ -275,6 +288,17 @@ function wire() {
if (confirm(`Remove "${b.dataset.unit}" and its openings? Its games become unshelved.`))
post("/api/furniture/delete-unit", {name: b.dataset.unit});
});
document.querySelectorAll(".editunitdesc").forEach(b =>
b.onclick = () => {
const form = b.closest(".unitdesc").querySelector(".descform");
form.hidden = !form.hidden;
if (!form.hidden) form.querySelector("input").focus();
});
document.querySelectorAll(".saveunitdesc").forEach(b =>
b.onclick = () => post("/api/furniture/edit-unit", {
name: b.dataset.unit,
description: b.closest(".descform").querySelector("input").value,
}));
document.querySelectorAll(".addopeningform").forEach(form =>
form.onsubmit = async e => {
e.preventDefault();
@@ -410,7 +434,9 @@ unitForm.addEventListener("submit", async e => {
// half-failure) is fine to fill, so a 409 on step one does not stop us
const existing = SHELVES.units.find(u => u.name === f.name.value.trim());
if (!existing || existing.openings.length) {
const made = await post("/api/furniture/add-unit", {name: f.name.value});
const made = await post("/api/furniture/add-unit", {
name: f.name.value, description: f.description.value,
});
if (!made && !existing) return;
}
const filled = await post("/api/furniture/add-openings", {
+26 -2
View File
@@ -228,6 +228,7 @@ class ResearchBody(BaseModel):
class UnitBody(BaseModel):
name: str
description: str = "" # free text: "the wall behind the couch"
class OpeningsBody(BaseModel):
@@ -1098,6 +1099,7 @@ def create_app(
located_keys.add(key) # rides with its container
out_units = []
for unit in units:
unit = {**unit, "description": unit.get("description", "")}
out_openings = []
for opening in unit.get("openings", []):
here = residents.get(opening["id"], [])
@@ -1121,7 +1123,13 @@ def create_app(
],
}
)
out_units.append({"name": unit["name"], "openings": out_openings})
out_units.append(
{
"name": unit["name"],
"description": unit.get("description", ""),
"openings": out_openings,
}
)
unshelved = []
for key, entry in sorted(
games.items(), key=lambda p: (p[1].get("name") or "").casefold()
@@ -1198,10 +1206,26 @@ def create_app(
def add(units):
if any(u["name"] == name for u in units):
raise HTTPException(409, f"a unit named {name!r} already exists")
units.append({"name": name, "openings": []})
units.append(
{
"name": name,
"description": body.description.strip(),
"openings": [],
}
)
return _mutate_furniture(add)
@app.post("/api/furniture/edit-unit")
def api_edit_unit(body: UnitBody) -> dict:
def apply(units):
unit = next((u for u in units if u["name"] == body.name), None)
if unit is None:
raise HTTPException(404, "no such unit")
unit["description"] = body.description.strip()
return _mutate_furniture(apply)
@app.post("/api/furniture/delete-unit")
def api_delete_unit(body: UnitBody) -> dict:
def drop(units):
+20
View File
@@ -820,3 +820,23 @@ def test_opening_descriptions_round_trip(tmp_path):
saved = _json.loads(cfg.furniture_path.read_text())["units"][0]["openings"][0]
assert saved["description"] == "tall bookcase by the window"
def test_unit_descriptions_round_trip(tmp_path):
web, cfg = _web(tmp_path, {})
web.post(
"/api/furniture/add-unit",
json={"name": "Den", "description": "the wall behind the couch"},
)
assert (
web.get("/api/shelves").json()["units"][0]["description"]
== "the wall behind the couch"
)
web.post(
"/api/furniture/edit-unit",
json={"name": "Den", "description": "now in the study"},
)
import json as _json
saved = _json.loads(cfg.furniture_path.read_text())["units"][0]
assert saved["description"] == "now in the study"