"It's here anyway" + the tape measure: the human overrules the packer

Eric, looking at a dense cube full of "no room" and "unmeasured":
how do I say it really IS here, and in what orientation? Two answers.

The packer models one flat pile and one standing row; real shelves are
3-D — boxes sit crosswise, ride on top of piles, stand proud of the
edge. Acknowledge generalizes from overhang-only to the human
overruling the model: "it's here anyway" on any resident the lanes
can't take. An acknowledged box takes a lane when one works (honoring
a flat/standing override; the spine-out case lands relaxed standing,
now tagged ", proud" by the packer itself instead of guessed from
misfit-ness) and otherwise is WEDGED IN: really there, in some
arrangement the model can't see, outside the capacity bars and never
warned about. The previous commit's ackable gate is gone — accepting
always does something now, so no placebo remains to guard against.

And "unmeasured" is no longer a dead end: the chip is a button. Tap,
enter width × length × depth from your own tape measure, and it saves
to data/measurements.json (a new committed curation store) with dims
source "measured" — overlaid on every games.json read (web, dims,
export, CSV import), because the owner's ruler outranks BGG's
database. All three fields empty clears one.

Verified live: Etherfields on the tower reads "standing, proud", an
acknowledged box in a width-starved cube reads "wedged in", the
measure form opens with three inputs, zero page errors.

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 16:10:35 -04:00
co-authored by Claude Fable 5
parent 4952e69920
commit 6c95953dc0
15 changed files with 1135 additions and 90 deletions
@@ -0,0 +1 @@
[ 20456ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_REFUSED @ http://localhost:8399/api/pipeline:0
@@ -0,0 +1,65 @@
- generic [active] [ref=e1]:
- link "Skip to content" [ref=e2] [cursor=pointer]:
- /url: "#main"
- generic [ref=e3]:
- link "bggpipe shelf → BGG pipeline" [ref=e4] [cursor=pointer]:
- /url: /
- generic [ref=e5]:
- text: bggpipe
- generic [ref=e6]: shelf → BGG pipeline
- navigation "Primary" [ref=e7]:
- link "Pipeline" [ref=e8] [cursor=pointer]:
- /url: /
- link "Photos 4" [ref=e9] [cursor=pointer]:
- /url: /photos
- text: Photos
- generic [ref=e10]: "4"
- link "Titles" [ref=e11] [cursor=pointer]:
- /url: /titles
- link "Review" [ref=e12] [cursor=pointer]:
- /url: /review
- link "Queue" [ref=e13] [cursor=pointer]:
- /url: /queue
- link "Library" [ref=e14] [cursor=pointer]:
- /url: /library
- link "Shelves" [ref=e15] [cursor=pointer]:
- /url: /shelves
- link "Help" [ref=e16] [cursor=pointer]:
- /url: /help
- figure "art by Juniper" [ref=e17]:
- img "the bggpipe piper — a bagpiper whose bag is a board game box" [ref=e18]
- generic [ref=e20]: BoardGameGeek and BGG are trademarks of BoardGameGeek, LLC. bggpipe is an independent project, not affiliated with or endorsed by BoardGameGeek.
- generic [ref=e21]:
- status
- main [ref=e22]:
- heading "Shelves" [level=1] [ref=e23]
- heading "Add a unit" [level=2] [ref=e24]
- generic [ref=e25]:
- generic [ref=e26]:
- text: Unit name
- textbox "Unit name" [ref=e27]:
- /placeholder: Living room Kallax
- generic [ref=e28]:
- text: Preset
- combobox "Preset" [ref=e29]:
- option "IKEA Kallax cube (13.25 × 13.25 × 15.4)" [selected]
- option "IKEA Billy shelf (30.75 × 13 × 11)"
- option "custom size"
- option "no size — travel case, lent out…"
- generic [ref=e30]:
- text: Rows
- textbox "Rows" [ref=e31]: "1"
- generic [ref=e32]:
- text: Columns
- textbox "Columns" [ref=e33]: "1"
- generic [ref=e34]:
- text: Zone
- textbox "Zone" [ref=e35]:
- /placeholder: party games
- generic [ref=e36]:
- text: Description
- textbox "Description" [ref=e37]:
- /placeholder: the wall behind the couch — main game storage
- button "create" [ref=e39] [cursor=pointer]
- generic [ref=e40]: grid mode labels openings A1, A2… by row; you can edit, add, or remove openings afterward — repeat with another grid to stack sections (double-wides above cubes, say) in the same unit
- paragraph [ref=e42]: Loading…
@@ -0,0 +1,407 @@
- generic [active] [ref=f1e1]:
- link "Skip to content" [ref=f1e2] [cursor=pointer]:
- /url: "#main"
- generic [ref=f1e3]:
- link "bggpipe shelf → BGG pipeline" [ref=f1e4] [cursor=pointer]:
- /url: /
- generic [ref=f1e5]:
- text: bggpipe
- generic [ref=f1e6]: shelf → BGG pipeline
- navigation "Primary" [ref=f1e7]:
- link "Pipeline" [ref=f1e8] [cursor=pointer]:
- /url: /
- link "Photos 4" [ref=f1e9] [cursor=pointer]:
- /url: /photos
- text: Photos
- generic [ref=f1e10]: "4"
- link "Titles" [ref=f1e11] [cursor=pointer]:
- /url: /titles
- link "Review" [ref=f1e12] [cursor=pointer]:
- /url: /review
- link "Queue" [ref=f1e13] [cursor=pointer]:
- /url: /queue
- link "Library" [ref=f1e14] [cursor=pointer]:
- /url: /library
- link "Shelves" [ref=f1e15] [cursor=pointer]:
- /url: /shelves
- link "Help" [ref=f1e16] [cursor=pointer]:
- /url: /help
- figure "art by Juniper" [ref=f1e17]:
- img "the bggpipe piper — a bagpiper whose bag is a board game box" [ref=f1e18]
- generic [ref=f1e20]: BoardGameGeek and BGG are trademarks of BoardGameGeek, LLC. bggpipe is an independent project, not affiliated with or endorsed by BoardGameGeek.
- generic [ref=f1e21]:
- status
- main [ref=f1e22]:
- heading "Shelves" [level=1] [ref=f1e23]
- generic [ref=f1e24]: 3 unit(s), 27 opening(s), 8 game(s) unshelved
- heading "Add a unit" [level=2] [ref=f1e26]
- generic [ref=f1e27]:
- generic [ref=f1e28]:
- text: Unit name
- textbox "Unit name" [ref=f1e29]:
- /placeholder: Living room Kallax
- generic [ref=f1e30]:
- text: Preset
- combobox "Preset" [ref=f1e31]:
- option "IKEA Kallax cube (13.25 × 13.25 × 15.4)" [selected]
- option "IKEA Billy shelf (30.75 × 13 × 11)"
- option "custom size"
- option "no size — travel case, lent out…"
- generic [ref=f1e32]:
- text: Rows
- textbox "Rows" [ref=f1e33]: "1"
- generic [ref=f1e34]:
- text: Columns
- textbox "Columns" [ref=f1e35]: "1"
- generic [ref=f1e36]:
- text: Zone
- textbox "Zone" [ref=f1e37]:
- /placeholder: party games
- generic [ref=f1e38]:
- text: Description
- textbox "Description" [ref=f1e39]:
- /placeholder: the wall behind the couch — main game storage
- button "create" [ref=f1e41] [cursor=pointer]
- generic [ref=f1e42]: grid mode labels openings A1, A2… by row; you can edit, add, or remove openings afterward — repeat with another grid to stack sections (double-wides above cubes, say) in the same unit
- generic [ref=f1e43]:
- generic [ref=f1e44]:
- generic [ref=f1e45]:
- heading "Library Kallax" [level=2] [ref=f1e46]
- generic [ref=f1e47]:
- button "duplicate" [ref=f1e48] [cursor=pointer]
- button "rename" [ref=f1e49] [cursor=pointer]
- button "remove unit" [ref=f1e50] [cursor=pointer]
- paragraph [ref=f1e51]:
- generic [ref=f1e52]: Centerpiece of the game wall in the library
- button "edit description" [ref=f1e53] [cursor=pointer]
- generic [ref=f1e54]:
- button "Top — flat stack 0% of height, standing lane 0% of width" [ref=f1e56] [cursor=pointer]:
- generic [ref=f1e57]: Top
- generic [ref=f1e58]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=f1e59]:
- 'generic "flat stack: 0% of interior height"'
- generic [ref=f1e61]:
- button "A1 oversize 7 · 1 unmeasured flat stack 99% of height, standing lane 30% of width, 1 unmeasured boxes not counted" [ref=f1e62] [cursor=pointer]:
- generic [ref=f1e63]: A1
- generic [ref=f1e64]: oversize
- generic [ref=f1e65]: 7 · 1 unmeasured
- img "flat stack 99% of height, standing lane 30% of width, 1 unmeasured boxes not counted" [ref=f1e66]:
- 'generic "flat stack: 99% of interior height" [ref=f1e67]'
- button "A2 oversize 6 · 1 unmeasured flat stack 89% of height, standing lane 67% of width, 1 unmeasured boxes not counted" [ref=f1e70] [cursor=pointer]:
- generic [ref=f1e71]: A2
- generic [ref=f1e72]: oversize
- generic [ref=f1e73]: 6 · 1 unmeasured
- img "flat stack 89% of height, standing lane 67% of width, 1 unmeasured boxes not counted" [ref=f1e74]:
- 'generic "flat stack: 89% of interior height" [ref=f1e75]'
- generic [ref=f1e78]:
- 'button "B1 no way to pack: Meteor party 7 flat stack 98% of height, standing lane 0% of width, overfull" [ref=f1e79] [cursor=pointer]':
- generic [ref=f1e80]:
- text: B1
- 'img "no way to pack: Meteor" [ref=f1e81]':
- generic [ref=f1e82]: party
- generic [ref=f1e83]: "7"
- img "flat stack 98% of height, standing lane 0% of width, overfull" [ref=f1e84]:
- 'generic "flat stack: 98% of interior height" [ref=f1e85]'
- 'button "B2 no way to pack: The Red Dragon Inn Smorgasbox party 5 flat stack 89% of height, standing lane 0% of width, overfull" [ref=f1e87] [cursor=pointer]':
- generic [ref=f1e88]:
- text: B2
- 'img "no way to pack: The Red Dragon Inn Smorgasbox" [ref=f1e89]':
- generic [ref=f1e90]: party
- generic [ref=f1e91]: "5"
- img "flat stack 89% of height, standing lane 0% of width, overfull" [ref=f1e92]:
- 'generic "flat stack: 89% of interior height" [ref=f1e93]'
- button "B3 party 16 · 8 unmeasured flat stack 36% of height, standing lane 100% of width, 8 unmeasured boxes not counted" [ref=f1e95] [cursor=pointer]:
- generic [ref=f1e96]: B3
- generic [ref=f1e97]: party
- generic [ref=f1e98]: 16 · 8 unmeasured
- img "flat stack 36% of height, standing lane 100% of width, 8 unmeasured boxes not counted" [ref=f1e99]:
- 'generic "flat stack: 36% of interior height" [ref=f1e100]'
- button "B4 two-player 6 · 1 unmeasured flat stack 39% of height, standing lane 94% of width, 1 unmeasured boxes not counted" [ref=f1e103] [cursor=pointer]:
- generic [ref=f1e104]: B4
- generic [ref=f1e105]: two-player
- generic [ref=f1e106]: 6 · 1 unmeasured
- img "flat stack 39% of height, standing lane 94% of width, 1 unmeasured boxes not counted" [ref=f1e107]:
- 'generic "flat stack: 39% of interior height" [ref=f1e108]'
- generic [ref=f1e111]:
- 'button "C1 no way to pack: Calico, A Gentle Rain, Castle Panic: The Dark Titan general 12 · 3 unmeasured flat stack 100% of height, standing lane 60% of width, overfull, 3 unmeasured boxes not counted" [ref=f1e112] [cursor=pointer]':
- generic [ref=f1e113]:
- text: C1
- 'img "no way to pack: Calico, A Gentle Rain, Castle Panic: The Dark Titan" [ref=f1e114]':
- generic [ref=f1e115]: general
- generic [ref=f1e116]: 12 · 3 unmeasured
- img "flat stack 100% of height, standing lane 60% of width, overfull, 3 unmeasured boxes not counted" [ref=f1e117]:
- 'generic "flat stack: 100% of interior height" [ref=f1e118]'
- 'button "C2 no way to pack: Evolution: The Beginning, Doctor Who: The Card Game, Dastardly Dirigibles, Exploding Kittens general 14 · 3 unmeasured flat stack 95% of height, standing lane 91% of width, overfull, 3 unmeasured boxes not counted" [ref=f1e121] [cursor=pointer]':
- generic [ref=f1e122]:
- text: C2
- 'img "no way to pack: Evolution: The Beginning, Doctor Who: The Card Game, Dastardly Dirigibles, Exploding Kittens" [ref=f1e123]':
- generic [ref=f1e124]: general
- generic [ref=f1e125]: 14 · 3 unmeasured
- img "flat stack 95% of height, standing lane 91% of width, overfull, 3 unmeasured boxes not counted" [ref=f1e126]:
- 'generic "flat stack: 95% of interior height" [ref=f1e127]'
- 'button "C3 no way to pack: Forbidden Island, Mantis Falls, Murder at the Manor, Hold Your Breath!, Guillotine general 14 · 3 unmeasured flat stack 97% of height, standing lane 100% of width, overfull, 3 unmeasured boxes not counted" [ref=f1e130] [cursor=pointer]':
- generic [ref=f1e131]:
- text: C3
- 'img "no way to pack: Forbidden Island, Mantis Falls, Murder at the Manor, Hold Your Breath!, Guillotine" [ref=f1e132]':
- generic [ref=f1e133]: general
- generic [ref=f1e134]: 14 · 3 unmeasured
- img "flat stack 97% of height, standing lane 100% of width, overfull, 3 unmeasured boxes not counted" [ref=f1e135]:
- 'generic "flat stack: 97% of interior height" [ref=f1e136]'
- 'button "C4 no way to pack: Skylanders Portal Master, Robot Turtles, Sorcerer: The Game of Magical Conflict, SPANC: Space Pirate Amazon Ninja Catgirls general 13 · 4 unmeasured flat stack 90% of height, standing lane 56% of width, overfull, 4 unmeasured boxes not counted" [ref=f1e139] [cursor=pointer]':
- generic [ref=f1e140]:
- text: C4
- 'img "no way to pack: Skylanders Portal Master, Robot Turtles, Sorcerer: The Game of Magical Conflict, SPANC: Space Pirate Amazon Ninja Catgirls" [ref=f1e141]':
- generic [ref=f1e142]: general
- generic [ref=f1e143]: 13 · 4 unmeasured
- img "flat stack 90% of height, standing lane 56% of width, overfull, 4 unmeasured boxes not counted" [ref=f1e144]:
- 'generic "flat stack: 90% of interior height" [ref=f1e145]'
- generic [ref=f1e148]:
- 'button "D1 no way to pack: The Simpsons Trivia Game, Völuspá, The Office Trivia Game, Walk the Plank! general 11 · 2 unmeasured flat stack 94% of height, standing lane 67% of width, overfull, 2 unmeasured boxes not counted" [ref=f1e149] [cursor=pointer]':
- generic [ref=f1e150]:
- text: D1
- 'img "no way to pack: The Simpsons Trivia Game, Völuspá, The Office Trivia Game, Walk the Plank!" [ref=f1e151]':
- generic [ref=f1e152]: general
- generic [ref=f1e153]: 11 · 2 unmeasured
- img "flat stack 94% of height, standing lane 67% of width, overfull, 2 unmeasured boxes not counted" [ref=f1e154]:
- 'generic "flat stack: 94% of interior height" [ref=f1e155]'
- button "D2 vintage wargames 7 flat stack 75% of height, standing lane 100% of width" [ref=f1e158] [cursor=pointer]:
- generic [ref=f1e159]: D2
- generic [ref=f1e160]: vintage wargames
- generic [ref=f1e161]: "7"
- img "flat stack 75% of height, standing lane 100% of width" [ref=f1e162]:
- 'generic "flat stack: 75% of interior height" [ref=f1e163]'
- button "D3 heavy 3 flat stack 42% of height, standing lane 94% of width" [ref=f1e166] [cursor=pointer]:
- generic [ref=f1e167]: D3
- generic [ref=f1e168]: heavy
- generic [ref=f1e169]: "3"
- img "flat stack 42% of height, standing lane 94% of width" [ref=f1e170]:
- 'generic "flat stack: 42% of interior height" [ref=f1e171]'
- button "D4 campaign 4 flat stack 83% of height, standing lane 92% of width" [ref=f1e174] [cursor=pointer]:
- generic [ref=f1e175]: D4
- generic [ref=f1e176]: campaign
- generic [ref=f1e177]: "4"
- img "flat stack 83% of height, standing lane 92% of width" [ref=f1e178]:
- 'generic "flat stack: 83% of interior height" [ref=f1e179]'
- paragraph [ref=f1e182]: Add an opening
- generic [ref=f1e183]:
- generic [ref=f1e184]:
- text: Rows
- textbox "Rows" [ref=f1e185]: "1"
- generic [ref=f1e186]:
- text: Columns
- textbox "Columns" [ref=f1e187]: "1"
- generic [ref=f1e188]:
- text: Label
- textbox "Label" [ref=f1e189]:
- /placeholder: wide top — for a single opening
- generic [ref=f1e190]:
- text: Zone
- textbox "Zone" [ref=f1e191]
- generic [ref=f1e192]:
- checkbox "at the top" [ref=f1e193]
- text: at the top
- generic [ref=f1e194]:
- text: W
- textbox "W" [ref=f1e195]:
- /placeholder: "13.25"
- generic [ref=f1e196]:
- text: H
- textbox "H" [ref=f1e197]:
- /placeholder: "13.25"
- generic [ref=f1e198]:
- text: D
- textbox "D" [ref=f1e199]:
- /placeholder: "15.4"
- button "add" [ref=f1e201] [cursor=pointer]
- generic [ref=f1e202]: a grid (rows × columns) continues the row letters — 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.
- generic [ref=f1e203]:
- generic [ref=f1e204]:
- heading "Library Right Shelving" [level=2] [ref=f1e205]
- generic [ref=f1e206]:
- button "duplicate" [ref=f1e207] [cursor=pointer]
- button "rename" [ref=f1e208] [cursor=pointer]
- button "remove unit" [ref=f1e209] [cursor=pointer]
- paragraph [ref=f1e210]:
- generic [ref=f1e211]: To the right of the Kallax
- button "edit description" [ref=f1e212] [cursor=pointer]
- generic [ref=f1e213]:
- button "Top — flat stack 0% of height, standing lane 0% of width" [ref=f1e215] [cursor=pointer]:
- generic [ref=f1e216]: Top
- generic [ref=f1e217]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=f1e218]:
- 'generic "flat stack: 0% of interior height"'
- button "A1 — flat stack 0% of height, standing lane 0% of width" [ref=f1e221] [cursor=pointer]:
- generic [ref=f1e222]: A1
- generic [ref=f1e223]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=f1e224]:
- 'generic "flat stack: 0% of interior height"'
- button "B1 — flat stack 0% of height, standing lane 0% of width" [ref=f1e227] [cursor=pointer]:
- generic [ref=f1e228]: B1
- generic [ref=f1e229]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=f1e230]:
- 'generic "flat stack: 0% of interior height"'
- button "C1 — flat stack 0% of height, standing lane 0% of width" [ref=f1e233] [cursor=pointer]:
- generic [ref=f1e234]: C1
- generic [ref=f1e235]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=f1e236]:
- 'generic "flat stack: 0% of interior height"'
- button "D1 — flat stack 0% of height, standing lane 0% of width" [ref=f1e239] [cursor=pointer]:
- generic [ref=f1e240]: D1
- generic [ref=f1e241]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=f1e242]:
- 'generic "flat stack: 0% of interior height"'
- button "E1 6 · 3 unmeasured flat stack 0% of height, standing lane 39% of width, 3 unmeasured boxes not counted" [ref=f1e245] [cursor=pointer]:
- generic [ref=f1e246]: E1
- generic [ref=f1e247]: 6 · 3 unmeasured
- img "flat stack 0% of height, standing lane 39% of width, 3 unmeasured boxes not counted" [ref=f1e248]:
- 'generic "flat stack: 0% of interior height"'
- paragraph [ref=f1e251]: Add an opening
- generic [ref=f1e252]:
- generic [ref=f1e253]:
- text: Rows
- textbox "Rows" [ref=f1e254]: "1"
- generic [ref=f1e255]:
- text: Columns
- textbox "Columns" [ref=f1e256]: "1"
- generic [ref=f1e257]:
- text: Label
- textbox "Label" [ref=f1e258]:
- /placeholder: wide top — for a single opening
- generic [ref=f1e259]:
- text: Zone
- textbox "Zone" [ref=f1e260]
- generic [ref=f1e261]:
- checkbox "at the top" [ref=f1e262]
- text: at the top
- generic [ref=f1e263]:
- text: W
- textbox "W" [ref=f1e264]:
- /placeholder: "13.25"
- generic [ref=f1e265]:
- text: H
- textbox "H" [ref=f1e266]:
- /placeholder: "13.25"
- generic [ref=f1e267]:
- text: D
- textbox "D" [ref=f1e268]:
- /placeholder: "15.4"
- button "add" [ref=f1e270] [cursor=pointer]
- generic [ref=f1e271]: a grid (rows × columns) continues the row letters — 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.
- generic [ref=f1e272]:
- generic [ref=f1e273]:
- heading "Library Left Shelving" [level=2] [ref=f1e274]
- generic [ref=f1e275]:
- button "duplicate" [ref=f1e276] [cursor=pointer]
- button "rename" [ref=f1e277] [cursor=pointer]
- button "remove unit" [ref=f1e278] [cursor=pointer]
- paragraph [ref=f1e279]:
- generic [ref=f1e280]: To the left of the Kallax
- button "edit description" [ref=f1e281] [cursor=pointer]
- generic [ref=f1e282]:
- button "Top — flat stack 0% of height, standing lane 0% of width" [ref=f1e284] [cursor=pointer]:
- generic [ref=f1e285]: Top
- generic [ref=f1e286]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=f1e287]:
- 'generic "flat stack: 0% of interior height"'
- button "A1 — flat stack 0% of height, standing lane 0% of width" [ref=f1e290] [cursor=pointer]:
- generic [ref=f1e291]: A1
- generic [ref=f1e292]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=f1e293]:
- 'generic "flat stack: 0% of interior height"'
- button "B1 — flat stack 0% of height, standing lane 0% of width" [ref=f1e296] [cursor=pointer]:
- generic [ref=f1e297]: B1
- generic [ref=f1e298]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=f1e299]:
- 'generic "flat stack: 0% of interior height"'
- button "C1 — flat stack 0% of height, standing lane 0% of width" [ref=f1e302] [cursor=pointer]:
- generic [ref=f1e303]: C1
- generic [ref=f1e304]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=f1e305]:
- 'generic "flat stack: 0% of interior height"'
- button "D1 4 · 4 unmeasured flat stack 0% of height, standing lane 0% of width, 4 unmeasured boxes not counted" [ref=f1e308] [cursor=pointer]:
- generic [ref=f1e309]: D1
- generic [ref=f1e310]: 4 · 4 unmeasured
- img "flat stack 0% of height, standing lane 0% of width, 4 unmeasured boxes not counted" [ref=f1e311]:
- 'generic "flat stack: 0% of interior height"'
- button "E1 — flat stack 0% of height, standing lane 0% of width" [ref=f1e314] [cursor=pointer]:
- generic [ref=f1e315]: E1
- generic [ref=f1e316]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=f1e317]:
- 'generic "flat stack: 0% of interior height"'
- paragraph [ref=f1e319]: Add an opening
- generic [ref=f1e320]:
- generic [ref=f1e321]:
- text: Rows
- textbox "Rows" [ref=f1e322]: "1"
- generic [ref=f1e323]:
- text: Columns
- textbox "Columns" [ref=f1e324]: "1"
- generic [ref=f1e325]:
- text: Label
- textbox "Label" [ref=f1e326]:
- /placeholder: wide top — for a single opening
- generic [ref=f1e327]:
- text: Zone
- textbox "Zone" [ref=f1e328]
- generic [ref=f1e329]:
- checkbox "at the top" [ref=f1e330]
- text: at the top
- generic [ref=f1e331]:
- text: W
- textbox "W" [ref=f1e332]:
- /placeholder: "13.25"
- generic [ref=f1e333]:
- text: H
- textbox "H" [ref=f1e334]:
- /placeholder: "13.25"
- generic [ref=f1e335]:
- text: D
- textbox "D" [ref=f1e336]:
- /placeholder: "15.4"
- button "add" [ref=f1e338] [cursor=pointer]
- generic [ref=f1e339]: a grid (rows × columns) continues the row letters — 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.
- generic [ref=f1e340]:
- heading "Unshelved" [level=2] [ref=f1e341]
- generic [ref=f1e342]:
- link "DUNGEONS & DRAGONS" [ref=f1e343] [cursor=pointer]:
- /url: /library/game/local%3Adungeons%20and%20dragons%3AIMG_4519.jpeg
- generic "no trusted size — fit can't be verified" [ref=f1e344]: unmeasured
- generic [ref=f1e345]:
- link "DUNGEONS & DRAGONS FANTASY GAME RULES" [ref=f1e346] [cursor=pointer]:
- /url: /library/game/local%3Adungeons%20and%20dragons%20fantasy%20game%20rules%3AIMG_4519.jpeg
- generic "no trusted size — fit can't be verified" [ref=f1e347]: unmeasured
- generic [ref=f1e348]:
- 'link "Etherfields: Awakened Items & Actions" [ref=f1e349] [cursor=pointer]':
- /url: /library/game/355873%3A597861
- generic [ref=f1e350]:
- button "♥ Library Right Shelving · E1" [ref=f1e351] [cursor=pointer]
- button "→ Library Right Shelving · A1" [ref=f1e352] [cursor=pointer]
- button "→ Library Left Shelving · A1" [ref=f1e353] [cursor=pointer]
- generic [ref=f1e354]:
- 'link "Etherfields: Search Mini Deck" [ref=f1e355] [cursor=pointer]':
- /url: /library/game/309202%3A507805
- generic [ref=f1e356]:
- button "♥ Library Right Shelving · E1" [ref=f1e357] [cursor=pointer]
- button "→ Library Right Shelving · A1" [ref=f1e358] [cursor=pointer]
- button "→ Library Left Shelving · A1" [ref=f1e359] [cursor=pointer]
- generic [ref=f1e360]:
- 'link "Etherfields: Shifting Story Promo Deck" [ref=f1e361] [cursor=pointer]':
- /url: /library/game/381209%3A651213
- generic [ref=f1e362]:
- button "♥ Library Right Shelving · E1" [ref=f1e363] [cursor=pointer]
- button "→ Library Right Shelving · A1" [ref=f1e364] [cursor=pointer]
- button "→ Library Left Shelving · A1" [ref=f1e365] [cursor=pointer]
- generic [ref=f1e366]:
- 'link "Etherfields: Story Dice Expansion" [ref=f1e367] [cursor=pointer]':
- /url: /library/game/386805%3A658656
- generic "no trusted size — fit can't be verified" [ref=f1e368]: unmeasured
- generic [ref=f1e369]:
- 'link "Etherfields: Thorn Knight" [ref=f1e370] [cursor=pointer]':
- /url: /library/game/291126%3A475383
- generic "no trusted size — fit can't be verified" [ref=f1e371]: unmeasured
- generic [ref=f1e372]:
- link "Frosthaven" [ref=f1e373] [cursor=pointer]:
- /url: /library/game/295770%3A484007
- generic [ref=f1e374]:
- button "→ Library Kallax · A1" [ref=f1e375] [cursor=pointer]
- button "→ Library Kallax · Top" [ref=f1e376] [cursor=pointer]
@@ -0,0 +1,407 @@
- generic [active] [ref=f2e1]:
- link "Skip to content" [ref=f2e2] [cursor=pointer]:
- /url: "#main"
- generic [ref=f2e3]:
- link "bggpipe shelf → BGG pipeline" [ref=f2e4] [cursor=pointer]:
- /url: /
- generic [ref=f2e5]:
- text: bggpipe
- generic [ref=f2e6]: shelf → BGG pipeline
- navigation "Primary" [ref=f2e7]:
- link "Pipeline" [ref=f2e8] [cursor=pointer]:
- /url: /
- link "Photos 4" [ref=f2e9] [cursor=pointer]:
- /url: /photos
- text: Photos
- generic [ref=f2e10]: "4"
- link "Titles" [ref=f2e11] [cursor=pointer]:
- /url: /titles
- link "Review" [ref=f2e12] [cursor=pointer]:
- /url: /review
- link "Queue" [ref=f2e13] [cursor=pointer]:
- /url: /queue
- link "Library" [ref=f2e14] [cursor=pointer]:
- /url: /library
- link "Shelves" [ref=f2e15] [cursor=pointer]:
- /url: /shelves
- link "Help" [ref=f2e16] [cursor=pointer]:
- /url: /help
- figure "art by Juniper" [ref=f2e17]:
- img "the bggpipe piper — a bagpiper whose bag is a board game box" [ref=f2e18]
- generic [ref=f2e20]: BoardGameGeek and BGG are trademarks of BoardGameGeek, LLC. bggpipe is an independent project, not affiliated with or endorsed by BoardGameGeek.
- generic [ref=f2e21]:
- status
- main [ref=f2e22]:
- heading "Shelves" [level=1] [ref=f2e23]
- generic [ref=f2e24]: 3 unit(s), 27 opening(s), 8 game(s) unshelved
- heading "Add a unit" [level=2] [ref=f2e26]
- generic [ref=f2e27]:
- generic [ref=f2e28]:
- text: Unit name
- textbox "Unit name" [ref=f2e29]:
- /placeholder: Living room Kallax
- generic [ref=f2e30]:
- text: Preset
- combobox "Preset" [ref=f2e31]:
- option "IKEA Kallax cube (13.25 × 13.25 × 15.4)" [selected]
- option "IKEA Billy shelf (30.75 × 13 × 11)"
- option "custom size"
- option "no size — travel case, lent out…"
- generic [ref=f2e32]:
- text: Rows
- textbox "Rows" [ref=f2e33]: "1"
- generic [ref=f2e34]:
- text: Columns
- textbox "Columns" [ref=f2e35]: "1"
- generic [ref=f2e36]:
- text: Zone
- textbox "Zone" [ref=f2e37]:
- /placeholder: party games
- generic [ref=f2e38]:
- text: Description
- textbox "Description" [ref=f2e39]:
- /placeholder: the wall behind the couch — main game storage
- button "create" [ref=f2e41] [cursor=pointer]
- generic [ref=f2e42]: grid mode labels openings A1, A2… by row; you can edit, add, or remove openings afterward — repeat with another grid to stack sections (double-wides above cubes, say) in the same unit
- generic [ref=f2e43]:
- generic [ref=f2e44]:
- generic [ref=f2e45]:
- heading "Library Kallax" [level=2] [ref=f2e46]
- generic [ref=f2e47]:
- button "duplicate" [ref=f2e48] [cursor=pointer]
- button "rename" [ref=f2e49] [cursor=pointer]
- button "remove unit" [ref=f2e50] [cursor=pointer]
- paragraph [ref=f2e51]:
- generic [ref=f2e52]: Centerpiece of the game wall in the library
- button "edit description" [ref=f2e53] [cursor=pointer]
- generic [ref=f2e54]:
- button "Top — flat stack 0% of height, standing lane 0% of width" [ref=f2e56] [cursor=pointer]:
- generic [ref=f2e57]: Top
- generic [ref=f2e58]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=f2e59]:
- 'generic "flat stack: 0% of interior height"'
- generic [ref=f2e61]:
- button "A1 oversize 7 · 1 unmeasured flat stack 99% of height, standing lane 30% of width, 1 unmeasured boxes not counted" [ref=f2e62] [cursor=pointer]:
- generic [ref=f2e63]: A1
- generic [ref=f2e64]: oversize
- generic [ref=f2e65]: 7 · 1 unmeasured
- img "flat stack 99% of height, standing lane 30% of width, 1 unmeasured boxes not counted" [ref=f2e66]:
- 'generic "flat stack: 99% of interior height" [ref=f2e67]'
- button "A2 oversize 6 · 1 unmeasured flat stack 89% of height, standing lane 67% of width, 1 unmeasured boxes not counted" [ref=f2e70] [cursor=pointer]:
- generic [ref=f2e71]: A2
- generic [ref=f2e72]: oversize
- generic [ref=f2e73]: 6 · 1 unmeasured
- img "flat stack 89% of height, standing lane 67% of width, 1 unmeasured boxes not counted" [ref=f2e74]:
- 'generic "flat stack: 89% of interior height" [ref=f2e75]'
- generic [ref=f2e78]:
- 'button "B1 no way to pack: Meteor party 7 flat stack 98% of height, standing lane 0% of width, overfull" [ref=f2e79] [cursor=pointer]':
- generic [ref=f2e80]:
- text: B1
- 'img "no way to pack: Meteor" [ref=f2e81]':
- generic [ref=f2e82]: party
- generic [ref=f2e83]: "7"
- img "flat stack 98% of height, standing lane 0% of width, overfull" [ref=f2e84]:
- 'generic "flat stack: 98% of interior height" [ref=f2e85]'
- 'button "B2 no way to pack: The Red Dragon Inn Smorgasbox party 5 flat stack 89% of height, standing lane 0% of width, overfull" [ref=f2e87] [cursor=pointer]':
- generic [ref=f2e88]:
- text: B2
- 'img "no way to pack: The Red Dragon Inn Smorgasbox" [ref=f2e89]':
- generic [ref=f2e90]: party
- generic [ref=f2e91]: "5"
- img "flat stack 89% of height, standing lane 0% of width, overfull" [ref=f2e92]:
- 'generic "flat stack: 89% of interior height" [ref=f2e93]'
- button "B3 party 16 · 8 unmeasured flat stack 36% of height, standing lane 100% of width, 8 unmeasured boxes not counted" [ref=f2e95] [cursor=pointer]:
- generic [ref=f2e96]: B3
- generic [ref=f2e97]: party
- generic [ref=f2e98]: 16 · 8 unmeasured
- img "flat stack 36% of height, standing lane 100% of width, 8 unmeasured boxes not counted" [ref=f2e99]:
- 'generic "flat stack: 36% of interior height" [ref=f2e100]'
- button "B4 two-player 6 · 1 unmeasured flat stack 39% of height, standing lane 94% of width, 1 unmeasured boxes not counted" [ref=f2e103] [cursor=pointer]:
- generic [ref=f2e104]: B4
- generic [ref=f2e105]: two-player
- generic [ref=f2e106]: 6 · 1 unmeasured
- img "flat stack 39% of height, standing lane 94% of width, 1 unmeasured boxes not counted" [ref=f2e107]:
- 'generic "flat stack: 39% of interior height" [ref=f2e108]'
- generic [ref=f2e111]:
- 'button "C1 no way to pack: Calico, A Gentle Rain, Castle Panic: The Dark Titan general 12 · 3 unmeasured flat stack 100% of height, standing lane 60% of width, overfull, 3 unmeasured boxes not counted" [ref=f2e112] [cursor=pointer]':
- generic [ref=f2e113]:
- text: C1
- 'img "no way to pack: Calico, A Gentle Rain, Castle Panic: The Dark Titan" [ref=f2e114]':
- generic [ref=f2e115]: general
- generic [ref=f2e116]: 12 · 3 unmeasured
- img "flat stack 100% of height, standing lane 60% of width, overfull, 3 unmeasured boxes not counted" [ref=f2e117]:
- 'generic "flat stack: 100% of interior height" [ref=f2e118]'
- 'button "C2 no way to pack: Evolution: The Beginning, Doctor Who: The Card Game, Dastardly Dirigibles, Exploding Kittens general 14 · 3 unmeasured flat stack 95% of height, standing lane 91% of width, overfull, 3 unmeasured boxes not counted" [ref=f2e121] [cursor=pointer]':
- generic [ref=f2e122]:
- text: C2
- 'img "no way to pack: Evolution: The Beginning, Doctor Who: The Card Game, Dastardly Dirigibles, Exploding Kittens" [ref=f2e123]':
- generic [ref=f2e124]: general
- generic [ref=f2e125]: 14 · 3 unmeasured
- img "flat stack 95% of height, standing lane 91% of width, overfull, 3 unmeasured boxes not counted" [ref=f2e126]:
- 'generic "flat stack: 95% of interior height" [ref=f2e127]'
- 'button "C3 no way to pack: Forbidden Island, Mantis Falls, Murder at the Manor, Hold Your Breath!, Guillotine general 14 · 3 unmeasured flat stack 97% of height, standing lane 100% of width, overfull, 3 unmeasured boxes not counted" [ref=f2e130] [cursor=pointer]':
- generic [ref=f2e131]:
- text: C3
- 'img "no way to pack: Forbidden Island, Mantis Falls, Murder at the Manor, Hold Your Breath!, Guillotine" [ref=f2e132]':
- generic [ref=f2e133]: general
- generic [ref=f2e134]: 14 · 3 unmeasured
- img "flat stack 97% of height, standing lane 100% of width, overfull, 3 unmeasured boxes not counted" [ref=f2e135]:
- 'generic "flat stack: 97% of interior height" [ref=f2e136]'
- 'button "C4 no way to pack: Skylanders Portal Master, Robot Turtles, Sorcerer: The Game of Magical Conflict, SPANC: Space Pirate Amazon Ninja Catgirls general 13 · 4 unmeasured flat stack 90% of height, standing lane 56% of width, overfull, 4 unmeasured boxes not counted" [ref=f2e139] [cursor=pointer]':
- generic [ref=f2e140]:
- text: C4
- 'img "no way to pack: Skylanders Portal Master, Robot Turtles, Sorcerer: The Game of Magical Conflict, SPANC: Space Pirate Amazon Ninja Catgirls" [ref=f2e141]':
- generic [ref=f2e142]: general
- generic [ref=f2e143]: 13 · 4 unmeasured
- img "flat stack 90% of height, standing lane 56% of width, overfull, 4 unmeasured boxes not counted" [ref=f2e144]:
- 'generic "flat stack: 90% of interior height" [ref=f2e145]'
- generic [ref=f2e148]:
- 'button "D1 no way to pack: The Simpsons Trivia Game, Völuspá, The Office Trivia Game, Walk the Plank! general 11 · 2 unmeasured flat stack 94% of height, standing lane 67% of width, overfull, 2 unmeasured boxes not counted" [ref=f2e149] [cursor=pointer]':
- generic [ref=f2e150]:
- text: D1
- 'img "no way to pack: The Simpsons Trivia Game, Völuspá, The Office Trivia Game, Walk the Plank!" [ref=f2e151]':
- generic [ref=f2e152]: general
- generic [ref=f2e153]: 11 · 2 unmeasured
- img "flat stack 94% of height, standing lane 67% of width, overfull, 2 unmeasured boxes not counted" [ref=f2e154]:
- 'generic "flat stack: 94% of interior height" [ref=f2e155]'
- button "D2 vintage wargames 7 flat stack 75% of height, standing lane 100% of width" [ref=f2e158] [cursor=pointer]:
- generic [ref=f2e159]: D2
- generic [ref=f2e160]: vintage wargames
- generic [ref=f2e161]: "7"
- img "flat stack 75% of height, standing lane 100% of width" [ref=f2e162]:
- 'generic "flat stack: 75% of interior height" [ref=f2e163]'
- button "D3 heavy 3 flat stack 42% of height, standing lane 94% of width" [ref=f2e166] [cursor=pointer]:
- generic [ref=f2e167]: D3
- generic [ref=f2e168]: heavy
- generic [ref=f2e169]: "3"
- img "flat stack 42% of height, standing lane 94% of width" [ref=f2e170]:
- 'generic "flat stack: 42% of interior height" [ref=f2e171]'
- button "D4 campaign 4 flat stack 83% of height, standing lane 92% of width" [ref=f2e174] [cursor=pointer]:
- generic [ref=f2e175]: D4
- generic [ref=f2e176]: campaign
- generic [ref=f2e177]: "4"
- img "flat stack 83% of height, standing lane 92% of width" [ref=f2e178]:
- 'generic "flat stack: 83% of interior height" [ref=f2e179]'
- paragraph [ref=f2e182]: Add an opening
- generic [ref=f2e183]:
- generic [ref=f2e184]:
- text: Rows
- textbox "Rows" [ref=f2e185]: "1"
- generic [ref=f2e186]:
- text: Columns
- textbox "Columns" [ref=f2e187]: "1"
- generic [ref=f2e188]:
- text: Label
- textbox "Label" [ref=f2e189]:
- /placeholder: wide top — for a single opening
- generic [ref=f2e190]:
- text: Zone
- textbox "Zone" [ref=f2e191]
- generic [ref=f2e192]:
- checkbox "at the top" [ref=f2e193]
- text: at the top
- generic [ref=f2e194]:
- text: W
- textbox "W" [ref=f2e195]:
- /placeholder: "13.25"
- generic [ref=f2e196]:
- text: H
- textbox "H" [ref=f2e197]:
- /placeholder: "13.25"
- generic [ref=f2e198]:
- text: D
- textbox "D" [ref=f2e199]:
- /placeholder: "15.4"
- button "add" [ref=f2e201] [cursor=pointer]
- generic [ref=f2e202]: a grid (rows × columns) continues the row letters — 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.
- generic [ref=f2e203]:
- generic [ref=f2e204]:
- heading "Library Right Shelving" [level=2] [ref=f2e205]
- generic [ref=f2e206]:
- button "duplicate" [ref=f2e207] [cursor=pointer]
- button "rename" [ref=f2e208] [cursor=pointer]
- button "remove unit" [ref=f2e209] [cursor=pointer]
- paragraph [ref=f2e210]:
- generic [ref=f2e211]: To the right of the Kallax
- button "edit description" [ref=f2e212] [cursor=pointer]
- generic [ref=f2e213]:
- button "Top — flat stack 0% of height, standing lane 0% of width" [ref=f2e215] [cursor=pointer]:
- generic [ref=f2e216]: Top
- generic [ref=f2e217]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=f2e218]:
- 'generic "flat stack: 0% of interior height"'
- button "A1 — flat stack 0% of height, standing lane 0% of width" [ref=f2e221] [cursor=pointer]:
- generic [ref=f2e222]: A1
- generic [ref=f2e223]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=f2e224]:
- 'generic "flat stack: 0% of interior height"'
- button "B1 — flat stack 0% of height, standing lane 0% of width" [ref=f2e227] [cursor=pointer]:
- generic [ref=f2e228]: B1
- generic [ref=f2e229]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=f2e230]:
- 'generic "flat stack: 0% of interior height"'
- button "C1 — flat stack 0% of height, standing lane 0% of width" [ref=f2e233] [cursor=pointer]:
- generic [ref=f2e234]: C1
- generic [ref=f2e235]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=f2e236]:
- 'generic "flat stack: 0% of interior height"'
- button "D1 — flat stack 0% of height, standing lane 0% of width" [ref=f2e239] [cursor=pointer]:
- generic [ref=f2e240]: D1
- generic [ref=f2e241]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=f2e242]:
- 'generic "flat stack: 0% of interior height"'
- button "E1 6 · 3 unmeasured flat stack 0% of height, standing lane 39% of width, 3 unmeasured boxes not counted" [ref=f2e245] [cursor=pointer]:
- generic [ref=f2e246]: E1
- generic [ref=f2e247]: 6 · 3 unmeasured
- img "flat stack 0% of height, standing lane 39% of width, 3 unmeasured boxes not counted" [ref=f2e248]:
- 'generic "flat stack: 0% of interior height"'
- paragraph [ref=f2e251]: Add an opening
- generic [ref=f2e252]:
- generic [ref=f2e253]:
- text: Rows
- textbox "Rows" [ref=f2e254]: "1"
- generic [ref=f2e255]:
- text: Columns
- textbox "Columns" [ref=f2e256]: "1"
- generic [ref=f2e257]:
- text: Label
- textbox "Label" [ref=f2e258]:
- /placeholder: wide top — for a single opening
- generic [ref=f2e259]:
- text: Zone
- textbox "Zone" [ref=f2e260]
- generic [ref=f2e261]:
- checkbox "at the top" [ref=f2e262]
- text: at the top
- generic [ref=f2e263]:
- text: W
- textbox "W" [ref=f2e264]:
- /placeholder: "13.25"
- generic [ref=f2e265]:
- text: H
- textbox "H" [ref=f2e266]:
- /placeholder: "13.25"
- generic [ref=f2e267]:
- text: D
- textbox "D" [ref=f2e268]:
- /placeholder: "15.4"
- button "add" [ref=f2e270] [cursor=pointer]
- generic [ref=f2e271]: a grid (rows × columns) continues the row letters — 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.
- generic [ref=f2e272]:
- generic [ref=f2e273]:
- heading "Library Left Shelving" [level=2] [ref=f2e274]
- generic [ref=f2e275]:
- button "duplicate" [ref=f2e276] [cursor=pointer]
- button "rename" [ref=f2e277] [cursor=pointer]
- button "remove unit" [ref=f2e278] [cursor=pointer]
- paragraph [ref=f2e279]:
- generic [ref=f2e280]: To the left of the Kallax
- button "edit description" [ref=f2e281] [cursor=pointer]
- generic [ref=f2e282]:
- button "Top — flat stack 0% of height, standing lane 0% of width" [ref=f2e284] [cursor=pointer]:
- generic [ref=f2e285]: Top
- generic [ref=f2e286]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=f2e287]:
- 'generic "flat stack: 0% of interior height"'
- button "A1 — flat stack 0% of height, standing lane 0% of width" [ref=f2e290] [cursor=pointer]:
- generic [ref=f2e291]: A1
- generic [ref=f2e292]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=f2e293]:
- 'generic "flat stack: 0% of interior height"'
- button "B1 — flat stack 0% of height, standing lane 0% of width" [ref=f2e296] [cursor=pointer]:
- generic [ref=f2e297]: B1
- generic [ref=f2e298]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=f2e299]:
- 'generic "flat stack: 0% of interior height"'
- button "C1 — flat stack 0% of height, standing lane 0% of width" [ref=f2e302] [cursor=pointer]:
- generic [ref=f2e303]: C1
- generic [ref=f2e304]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=f2e305]:
- 'generic "flat stack: 0% of interior height"'
- button "D1 4 · 4 unmeasured flat stack 0% of height, standing lane 0% of width, 4 unmeasured boxes not counted" [ref=f2e308] [cursor=pointer]:
- generic [ref=f2e309]: D1
- generic [ref=f2e310]: 4 · 4 unmeasured
- img "flat stack 0% of height, standing lane 0% of width, 4 unmeasured boxes not counted" [ref=f2e311]:
- 'generic "flat stack: 0% of interior height"'
- button "E1 — flat stack 0% of height, standing lane 0% of width" [ref=f2e314] [cursor=pointer]:
- generic [ref=f2e315]: E1
- generic [ref=f2e316]:
- img "flat stack 0% of height, standing lane 0% of width" [ref=f2e317]:
- 'generic "flat stack: 0% of interior height"'
- paragraph [ref=f2e319]: Add an opening
- generic [ref=f2e320]:
- generic [ref=f2e321]:
- text: Rows
- textbox "Rows" [ref=f2e322]: "1"
- generic [ref=f2e323]:
- text: Columns
- textbox "Columns" [ref=f2e324]: "1"
- generic [ref=f2e325]:
- text: Label
- textbox "Label" [ref=f2e326]:
- /placeholder: wide top — for a single opening
- generic [ref=f2e327]:
- text: Zone
- textbox "Zone" [ref=f2e328]
- generic [ref=f2e329]:
- checkbox "at the top" [ref=f2e330]
- text: at the top
- generic [ref=f2e331]:
- text: W
- textbox "W" [ref=f2e332]:
- /placeholder: "13.25"
- generic [ref=f2e333]:
- text: H
- textbox "H" [ref=f2e334]:
- /placeholder: "13.25"
- generic [ref=f2e335]:
- text: D
- textbox "D" [ref=f2e336]:
- /placeholder: "15.4"
- button "add" [ref=f2e338] [cursor=pointer]
- generic [ref=f2e339]: a grid (rows × columns) continues the row letters — 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.
- generic [ref=f2e340]:
- heading "Unshelved" [level=2] [ref=f2e341]
- generic [ref=f2e342]:
- link "DUNGEONS & DRAGONS" [ref=f2e343] [cursor=pointer]:
- /url: /library/game/local%3Adungeons%20and%20dragons%3AIMG_4519.jpeg
- generic "no trusted size — fit can't be verified" [ref=f2e344]: unmeasured
- generic [ref=f2e345]:
- link "DUNGEONS & DRAGONS FANTASY GAME RULES" [ref=f2e346] [cursor=pointer]:
- /url: /library/game/local%3Adungeons%20and%20dragons%20fantasy%20game%20rules%3AIMG_4519.jpeg
- generic "no trusted size — fit can't be verified" [ref=f2e347]: unmeasured
- generic [ref=f2e348]:
- 'link "Etherfields: Awakened Items & Actions" [ref=f2e349] [cursor=pointer]':
- /url: /library/game/355873%3A597861
- generic [ref=f2e350]:
- button "♥ Library Right Shelving · E1" [ref=f2e351] [cursor=pointer]
- button "→ Library Right Shelving · A1" [ref=f2e352] [cursor=pointer]
- button "→ Library Left Shelving · A1" [ref=f2e353] [cursor=pointer]
- generic [ref=f2e354]:
- 'link "Etherfields: Search Mini Deck" [ref=f2e355] [cursor=pointer]':
- /url: /library/game/309202%3A507805
- generic [ref=f2e356]:
- button "♥ Library Right Shelving · E1" [ref=f2e357] [cursor=pointer]
- button "→ Library Right Shelving · A1" [ref=f2e358] [cursor=pointer]
- button "→ Library Left Shelving · A1" [ref=f2e359] [cursor=pointer]
- generic [ref=f2e360]:
- 'link "Etherfields: Shifting Story Promo Deck" [ref=f2e361] [cursor=pointer]':
- /url: /library/game/381209%3A651213
- generic [ref=f2e362]:
- button "♥ Library Right Shelving · E1" [ref=f2e363] [cursor=pointer]
- button "→ Library Right Shelving · A1" [ref=f2e364] [cursor=pointer]
- button "→ Library Left Shelving · A1" [ref=f2e365] [cursor=pointer]
- generic [ref=f2e366]:
- 'link "Etherfields: Story Dice Expansion" [ref=f2e367] [cursor=pointer]':
- /url: /library/game/386805%3A658656
- generic "no trusted size — fit can't be verified" [ref=f2e368]: unmeasured
- generic [ref=f2e369]:
- 'link "Etherfields: Thorn Knight" [ref=f2e370] [cursor=pointer]':
- /url: /library/game/291126%3A475383
- generic "no trusted size — fit can't be verified" [ref=f2e371]: unmeasured
- generic [ref=f2e372]:
- link "Frosthaven" [ref=f2e373] [cursor=pointer]:
- /url: /library/game/295770%3A484007
- generic [ref=f2e374]:
- button "→ Library Kallax · A1" [ref=f2e375] [cursor=pointer]
- button "→ Library Kallax · Top" [ref=f2e376] [cursor=pointer]
+2 -2
View File
@@ -50,11 +50,11 @@ Full design lives in `docs/spec.md` (read it before changing pipeline semantics)
- Base game vs. expansion vs. new edition is the top failure mode — bias matching toward `ambiguous` over auto-match ("Wingspan Europe" must not match base Wingspan).
- Editions/versions matter: Eric owns multiple editions of some games — each is a separate collection entry (keyed by `collid` on BGG). Never guess a version: no legible cues → `version_unknown` and a version-less collection entry.
- Normalize titles (casefold, strip punctuation/articles, special chars like é/&/:) identically on both sides of a match; dedupe across photos but keep `source_photos` provenance.
- **Human curation is durable**: `data/title_splits.json` (photo-scoped split-into-copies decisions, honored by extract's dedupe AND resolve's dedupe), `data/title_edits.json` (corrected reads/cues, applied before dedupe on every titles.json rebuild), `data/title_removals.json` (lines removed from the catalog — filtered out of every rebuild; delete the record to undo), `data/title_additions.json` (games added without a photo — joined into every rebuild; a later photo sighting dedupe-merges with them), `data/local_games.json` + `data/local_art/` (hand-written facts and a cover photo for off-BGG games — the ONLY source for them, merged over the photo reads by enrich), and `data/furniture.json` + `data/locations.json` (the owner's shelf layout and each game's physical spot — local-only, never BGG's business) persist forever. Row-level decisions persist via the `dedupe_veto` column — edits never drop veto'd rows (a rename retitles them in place); removal drops them (explicitly discarding the line).
- **Human curation is durable**: `data/title_splits.json` (photo-scoped split-into-copies decisions, honored by extract's dedupe AND resolve's dedupe), `data/title_edits.json` (corrected reads/cues, applied before dedupe on every titles.json rebuild), `data/title_removals.json` (lines removed from the catalog — filtered out of every rebuild; delete the record to undo), `data/title_additions.json` (games added without a photo — joined into every rebuild; a later photo sighting dedupe-merges with them), `data/local_games.json` + `data/local_art/` (hand-written facts and a cover photo for off-BGG games — the ONLY source for them, merged over the photo reads by enrich), and `data/furniture.json` + `data/locations.json` (the owner's shelf layout and each game's physical spot — local-only, never BGG's business; a location can carry `lane` and `acknowledged` — the human overruling the packer), and `data/measurements.json` (hand-measured box dims, source `measured` — the owner's tape measure outranks BGG, overlaid on every games.json read) persist forever. Row-level decisions persist via the `dedupe_veto` column — edits never drop veto'd rows (a rename retitles them in place); removal drops them (explicitly discarding the line).
- **RPGs are local-only citizens**: when the board-game search runs dry, resolve falls back to `type=rpgitem` (same geekdo API/token). RPGGeek items carry their OWN link types (`rpgdesigner`, `rpgpublisher`, `rpggenre`, `rpgcategory`, `rpgmechanic`) — a board-game-only parser silently returns nothing for them. Matched rpgitems enrich into the library but diff routes them to `local_only` — they must never reach `to_add.csv`/upload (their collection lives on RPGGeek, out of scope).
- Detailed BGG API behavior (202 queueing, collection-endpoint quirks, endpoints): use the `bgg-api` skill. **If the spec's BGG behavior changes, update the `bgg-api` skill to match** — they must not drift.
## Git
- Remote is self-hosted Gitea 1.26 (`git.kestrelsnest.social/eric/bggpipe`), **not GitHub**`gh` CLI does not work here.
- Commit `data/matches.csv`, `data/to_add.csv`, `data/to_update.csv`, `data/upload_log.csv`, `data/titles.json`, `data/unidentified.json`, `data/unidentified_dismissed.json`, `data/title_splits.json`, `data/title_edits.json`, `data/title_removals.json`, `data/title_additions.json`, `data/local_games.json`, `data/local_art/`, `data/furniture.json`, `data/locations.json`, `data/games.json`, `data/STUB_DATA.marker` (while it applies), and the collection snapshot XMLs. Never commit `data/bgg_cache/`, `data/extract_raw/`, `photos/`, `data/.lan_key`, Playwright storage state, or `.env`.
- Commit `data/matches.csv`, `data/to_add.csv`, `data/to_update.csv`, `data/upload_log.csv`, `data/titles.json`, `data/unidentified.json`, `data/unidentified_dismissed.json`, `data/title_splits.json`, `data/title_edits.json`, `data/title_removals.json`, `data/title_additions.json`, `data/local_games.json`, `data/local_art/`, `data/furniture.json`, `data/locations.json`, `data/measurements.json`, `data/games.json`, `data/STUB_DATA.marker` (while it applies), and the collection snapshot XMLs. Never commit `data/bgg_cache/`, `data/extract_raw/`, `photos/`, `data/.lan_key`, Playwright storage state, or `.env`.
+21 -8
View File
@@ -162,14 +162,27 @@ reset: rows move their games, games absent from the plan keep their
spots, and hand-entered notes, lane choices, and acknowledgments survive
whenever the opening is unchanged.
Some placements are deliberate misfits — square boxes spine-out on a
shallow shelf, standing proud of the edge. The checker will honestly
refuse those forever, so accept the overhang on purpose: the **accept
overhang** button in the opening's view, or an `acknowledge` column
(`yes`) in the import CSV. The button appears only when accepting would
actually give the box a lane; a resident squeezed out of a width-starved
opening is marked "no room" instead — that's overfull, not overhung, and
acknowledging wouldn't change the math.
The packer is deliberately conservative — one flat pile plus one
standing row per opening — and real shelves are 3-D: boxes sit
crosswise, ride on top of piles, stand proud of a shallow shelf. When
the model refuses a placement you can see with your own eyes, overrule
it: the **it's here anyway** button in the opening's view (or an
`acknowledge` column, `yes`, in the import CSV) confirms the box is
really there. An acknowledged box takes a lane when one works — the
spine-out overhang case lands "standing, proud", skipping only the
depth check — and when even that fails it's marked **wedged in**:
really there, outside the capacity bars, never warned about. Tap
"here ✓" to retract. Assignment and capacity are separate truths:
a game listed in an opening IS there regardless of what the packer
thinks of the arrangement.
Unmeasured games can't join the capacity math at all — so measure
them. The "unmeasured — measure it" button in the opening's view takes
width × length × depth in inches, saved to `data/measurements.json`
(committed, like all curation). Your tape measure outranks BGG: hand
measurements overlay whatever enrich found, everywhere sizes are used
(fit checks, suggestions, `bggpipe dims`, the export). Save with all
three fields empty to clear one.
An acknowledged box takes the standing lane without the depth check —
the width budget stays honest — and stops counting as a warning. Once
acknowledged, later imports needn't re-state it. `bggpipe dims` reports per-opening capacity, overfull warnings, and
+4
View File
@@ -75,6 +75,10 @@ class Config:
def games_path(self) -> Path:
return self.data_dir / "games.json"
@property
def measurements_path(self) -> Path:
return self.data_dir / "measurements.json"
def tracked_data_warning(self) -> str | None:
"""The clone-and-run trap: pipeline data written at git-TRACKED
paths is one `git pull` + one panicked `git reset --hard` away
+5 -2
View File
@@ -16,6 +16,7 @@ import typer
from bggpipe.config import Config
from bggpipe.models import DIM_AXES
from bggpipe.shelves import (
apply_measurements,
load_furniture,
load_locations,
opening_index,
@@ -36,7 +37,9 @@ def run_dims_report(cfg: Config) -> dict:
if not cfg.games_path.exists():
typer.echo(f"{cfg.games_path} not found — run `bggpipe enrich` first.")
raise typer.Exit(code=1)
games: dict[str, dict] = json.loads(cfg.games_path.read_text())
games: dict[str, dict] = apply_measurements(
cfg, json.loads(cfg.games_path.read_text())
)
contained = [
entry.get("name") or "?" for entry in games.values() if entry.get("stored_in")
@@ -142,7 +145,7 @@ def run_dims_report(cfg: Config) -> dict:
typer.echo(f" ⚠ doesn't fit this opening at all: {name}")
for k, e in by_opening.get(oid, []):
if k in acked:
typer.echo(f" overhangs, acknowledged: {e.get('name') or '?'}")
typer.echo(f" here anyway (yours): {e.get('name') or '?'}")
homeless = [
entry.get("name") or "?"
for key, entry in games.items()
+4 -1
View File
@@ -27,6 +27,7 @@ import typer
from bggpipe.config import Config
from bggpipe.fsio import atomic_write_bytes, atomic_write_text
from bggpipe.shelves import (
apply_measurements,
effective_location,
load_furniture,
load_locations,
@@ -561,7 +562,9 @@ def run_export(
if not cfg.games_path.exists():
typer.echo(f"{cfg.games_path} not found — run `bggpipe enrich` first.")
raise typer.Exit(code=1)
games: dict[str, dict] = json.loads(cfg.games_path.read_text())
games: dict[str, dict] = apply_measurements(
cfg, json.loads(cfg.games_path.read_text())
)
out_dir.mkdir(parents=True, exist_ok=True)
manifest_path = out_dir / MANIFEST_NAME
prior: dict[str, str] = {}
+61 -11
View File
@@ -76,6 +76,36 @@ def save_locations(cfg: Config, locations: dict[str, dict]) -> None:
)
def load_measurements(cfg: Config) -> dict[str, dict]:
"""Hand-measured box dimensions, keyed like games.json. The owner's
tape measure outranks BGG: these overlay whatever enrich found."""
if not cfg.measurements_path.exists():
return {}
try:
return json.loads(cfg.measurements_path.read_text())
except json.JSONDecodeError as err:
raise ValueError(
f"{cfg.measurements_path} is corrupt ({err}) — it holds your "
"hand-measured box sizes, so check git history before deleting"
) from err
def save_measurements(cfg: Config, measurements: dict[str, dict]) -> None:
atomic_write_text(
cfg.measurements_path,
json.dumps(measurements, indent=2, ensure_ascii=False, sort_keys=True) + "\n",
)
def apply_measurements(cfg: Config, games: dict[str, dict]) -> dict[str, dict]:
"""Overlay hand measurements onto a loaded games dict, in place."""
for key, m in load_measurements(cfg).items():
entry = games.get(key)
if entry is not None and all(m.get(a) for a in _AXES):
entry["dims"] = {**{a: m[a] for a in _AXES}, "source": "measured"}
return games
def opening_index(units: list[dict]) -> dict[str, dict]:
"""opening_id -> opening (with its unit's name attached)."""
out: dict[str, dict] = {}
@@ -223,15 +253,18 @@ def lane_split(
physical: [(key, entry, override)] with override in "", "flat",
"standing". Unmeasured boxes take no lane but are counted.
An ACKNOWLEDGED key is a deliberate "doesn't fit, shelved anyway"
(a box standing proud of a shallow shelf): it takes the standing
lane without the height/depth check — the human has seen the
overhang — but the width budget stays honest."""
An ACKNOWLEDGED key is the human overruling the model: "it really
is here". It takes its lane where one exists (standing without the
height/depth check — the overhang case), and when even that fails
it is WEDGED: really there, in some arrangement the packer can't
model, outside the capacity math and never warned about."""
acknowledged = acknowledged or set()
height = opening.get("height_in")
width = opening.get("width_in")
lanes: dict[str, str] = {}
laneless: list[str] = []
wedged: list[str] = []
proud: list[str] = [] # standing via relaxed check: a real overhang
unmeasured = 0
flat_in = 0.0
flat_claim = 0.0
@@ -275,9 +308,19 @@ def lane_split(
max_fp = footprint(measured[0][3]) if measured else 0
for key, entry, override, dims in measured:
if key in acknowledged:
# the overhang is accepted: standing lane, width-honest only
if not try_standing(key, dims, relaxed=True):
laneless.append(entry.get("name") or "?")
# the human overrules: honor a lane override, else take any
# real lane, else relaxed standing (the overhang), else
# wedged — confirmed here, beyond the model
if override == "flat":
placed = try_flat(key, dims)
elif override == "standing":
placed = try_standing(key, dims, relaxed=True)
else:
placed = try_flat(key, dims) or try_standing(key, dims, relaxed=True)
if not placed:
wedged.append(entry.get("name") or "?")
elif lanes.get(key) == "standing" and not _stands(dims, opening):
proud.append(entry.get("name") or "?")
continue
if override == "flat":
placed = try_flat(key, dims)
@@ -294,9 +337,8 @@ def lane_split(
# the flat claim can grow after standing boxes were placed: re-check
while standing and sum(t for _, t in standing) > width - flat_claim:
key, _ = standing.pop()
laneless.append(
next((e.get("name") or "?" for k, e, _o in physical if k == key), "?")
)
name = next((e.get("name") or "?" for k, e, _o in physical if k == key), "?")
(wedged if key in acknowledged else laneless).append(name)
lanes.pop(key, None)
return {
"lanes": lanes,
@@ -304,6 +346,8 @@ def lane_split(
"flat_claim": round(flat_claim, 2),
"standing_in": round(sum(t for _, t in standing), 2),
"laneless": laneless,
"wedged": wedged,
"proud": proud,
"unmeasured": unmeasured,
}
@@ -337,6 +381,8 @@ def opening_report(
"overfull": False,
"misfits": [],
"laneless": [],
"wedged": [],
"proud": [],
}
split = lane_split(opening, physical, acknowledged)
acknowledged = acknowledged or set()
@@ -362,6 +408,8 @@ def opening_report(
"overfull": bool(split["laneless"]),
"misfits": misfits,
"laneless": split["laneless"],
"wedged": split["wedged"],
"proud": split["proud"],
}
@@ -380,7 +428,9 @@ def import_assignments(cfg: Config, csv_path: Path) -> dict:
if not cfg.games_path.exists():
typer.echo(f"{cfg.games_path} not found — run `bggpipe enrich` first.")
raise typer.Exit(code=1)
games: dict[str, dict] = json.loads(cfg.games_path.read_text())
games: dict[str, dict] = apply_measurements(
cfg, json.loads(cfg.games_path.read_text())
)
units = load_furniture(cfg)
if not units:
typer.echo(
+11
View File
@@ -596,6 +596,17 @@ button.opening:hover, button.opening:focus-visible {
.ackbtn.on { border-color: var(--board-edge); background: var(--felt-soft, #eef2ea);
color: var(--ink-soft); }
.ackbtn:hover { border-color: var(--accent); color: var(--accent); }
.measurebtn { font: inherit; font-size: .72rem; cursor: pointer;
border: 1px dashed var(--board-edge); border-radius: 1rem;
background: #fff; padding: .05rem .5rem; color: var(--ink-soft); }
.measurebtn:hover { border-color: var(--accent); color: var(--accent); }
.measureform { display: inline-flex; gap: .25rem; align-items: center;
font-size: .78rem; color: var(--ink-soft); }
.measureform input { width: 3.6rem; font: inherit; padding: .1rem .25rem;
border: 1px solid var(--board-edge); border-radius: .3rem; }
.measureform .measuresave { font: inherit; font-size: .72rem; cursor: pointer;
border: 1px solid var(--board-edge); border-radius: 1rem;
background: #fff; padding: .05rem .5rem; }
.unshelved-row { display: flex; gap: .6rem; align-items: center;
flex-wrap: wrap; padding: .4rem 0;
border-bottom: 1px solid var(--board-edge); }
+1 -1
View File
@@ -30,7 +30,7 @@
<p><b><a href="/titles">Titles</a></b> — every read off your shelves, alphabetized, with its status and photos. This is the proofread checkpoint: <a href="#curation">edit, split, remove</a>. Its badge counts <span class="chip shaky">shaky read</span> lines — the model wasn't sure and nothing has verified them; filter to them, then press <b>✓ looks right</b> or edit each one.</p>
<p><b><a href="/review">Review</a></b> — the decisions only you can make: which game a title is, which edition a copy is, whether two same-game reads are really one box (merges show a veto), and whether an unmatched title is a real game BGG simply doesn't have (<b>keep locally</b>: it joins the Library, never uploads). Keyboard-first; see <a href="#keys">shortcuts</a>.</p>
<p><b><a href="/queue">Queue</a></b> — exactly what upload will do (new entries and version upgrades) and the log of everything it has done. Nothing reaches BGG that isn't visible here first. A job that fails is skipped by later runs (so one broken game can't loop forever); when any exist, the Pipeline's upload card offers a <b>retry N failed</b> checkbox. Each queued row shows what upload did with it — <span class="chip open">pending</span>, <span class="chip ok">done</span>, <span class="chip no">failed</span>, or <span class="chip no">retired</span> (a review decision since the last diff withdrew it). Finished rows stay listed until the next <b>diff</b> rebuilds the queue; the log below them is the permanent record.</p>
<p><b><a href="/shelves">Shelves</a></b> — where everything physically lives, drawn as it really stands: openings group into rows by label letter with widths proportional to their interiors, so the page is a diagram of your wall. Describe furniture once (presets for Kallax cubes and Billy shelves, grids of rows × columns — a later grid continues the row letters — custom sizes, and no-size spots like a travel case); units and openings both take a free-text description beside the zone keyword. Suggestions rank an opening holding a <b>series-mate or base game first</b> (marked ♥ — every placement you confirm teaches the suggester your organization), always against the <i>remaining</i> room, then tightest verified fit; a measured game with nowhere suitable says "no matching openings yet", and unmeasured games get no suggestions because can't-verify ≠ fits. Assign by tapping suggestions, searching inside an opening, or from any game's detail page. Capacity is a two-lane model — a flat stack against interior height plus a standing lane against the remaining width; the largest boxes lie flat, smaller ones stand, and each box's lane is flippable in the opening's view. ⚠ means <i>no packing fits</i>, not merely "tall stack" — and when the misfit is deliberate (boxes spine-out, proud of a shallow shelf) the <b>accept overhang</b> button in the opening's view accepts it: the box takes the standing lane, the width budget stays honest, and the warning goes quiet. The button only appears when accepting would actually give the box a lane — an opening that's simply past its width budget says "no room" instead, because that's overfull, not overhung. Games stored inside another box ride along with their container, through whole chains. Your layout is local data only — never sent anywhere. Bulk-load a plan with <code>bggpipe shelve --import plan.csv</code> (name,opening, plus an optional acknowledge column for deliberate overhangs) — re-imports are upserts that keep notes, lanes, and acknowledgments — and check capacity anytime with <code>bggpipe dims</code>.</p>
<p><b><a href="/shelves">Shelves</a></b> — where everything physically lives, drawn as it really stands: openings group into rows by label letter with widths proportional to their interiors, so the page is a diagram of your wall. Describe furniture once (presets for Kallax cubes and Billy shelves, grids of rows × columns — a later grid continues the row letters — custom sizes, and no-size spots like a travel case); units and openings both take a free-text description beside the zone keyword. Suggestions rank an opening holding a <b>series-mate or base game first</b> (marked ♥ — every placement you confirm teaches the suggester your organization), always against the <i>remaining</i> room, then tightest verified fit; a measured game with nowhere suitable says "no matching openings yet", and unmeasured games get no suggestions because can't-verify ≠ fits. Assign by tapping suggestions, searching inside an opening, or from any game's detail page. Capacity is a two-lane model — a flat stack against interior height plus a standing lane against the remaining width; the largest boxes lie flat, smaller ones stand, and each box's lane is flippable in the opening's view. ⚠ means <i>no packing fits</i>, not merely "tall stack" — and the packer is deliberately conservative (one flat pile, one standing row) while real shelves are 3-D. When it refuses a placement you can see with your own eyes, the <b>it's here anyway</b> button overrules it: the box takes a lane where one works (spine-out overhangs land "standing, proud") and otherwise is marked <b>wedged in</b> — really there, outside the capacity bars, never warned about. Unmeasured boxes get an <b>unmeasured — measure it</b> button: enter your own tape-measure numbers and they outrank BGG's everywhere sizes are used. Games stored inside another box ride along with their container, through whole chains. Your layout is local data only — never sent anywhere. Bulk-load a plan with <code>bggpipe shelve --import plan.csv</code> (name,opening, plus an optional acknowledge column for deliberate overhangs) — re-imports are upserts that keep notes, lanes, and acknowledgments — and check capacity anytime with <code>bggpipe dims</code>.</p>
<p><b><a href="/library">Library</a></b> — your enriched collection. Search titles, designers, mechanics and categories at once; filter by kind (board games, RPGs, off-BGG) or by how many people are playing tonight; sort by name, year, BGG rank, weight, or playing time. Click any game for its full detail: art, the usual stats, designers and mechanics, <b>your</b> edition, the shelf photos it was read from, and a link to its BGG page. RPG and off-BGG games live here too — identified and enriched, never uploaded. RPGs pull their designers, publishers and genres from RPGGeek; an off-BGG game's detail page lets you write its facts yourself and add a cover photo, since nothing else will ever have them (both are saved under <code>data/</code> and folded in by the next <b>enrich</b>).</p>
</div>
+37 -17
View File
@@ -232,31 +232,30 @@ function sheetRender() {
<div class="sheetrow">
${g.thumbnail ? `<img src="${esc(g.thumbnail)}" alt="">` : ""}
<a href="/library/game/${encodeURIComponent(g.key)}">${esc(g.name)}</a>
${g.measured ? "" : `<span class="chip open"
title="no trusted size — not counted in the fill bar">unmeasured</span>`}
${g.acknowledged && g.lane
? `<span class="chip open" title="accepted overhang sits standing,
proud of the shelf edge">▮ standing, proud</span>` : ""}
${g.laneless && !g.acknowledged && !g.ackable
? (g.lane_override
${g.measured ? "" : `<button class="measurebtn" data-key="${esc(g.key)}"
title="no trusted size — tap to enter your own tape-measure numbers
(they outrank BGG's)">unmeasured — measure it</button>`}
${g.laneless && !g.acknowledged && g.lane_override
? `<button class="lanebtn" data-key="${esc(g.key)}" data-next=""
title="your ${esc(g.lane_override)} override left this box without
a lane — tap to undo and let the packer choose again">no room ✱ undo</button>`
: `<span class="chip open" title="no lane can take this box here
the opening is past its packing budget; acknowledging wouldn't help">no room</span>`) : ""}
${g.lane && !g.inherited && !g.acknowledged
a lane — tap to undo and let the packer choose again">no room ✱ undo</button>` : ""}
${g.wedged
? `<span class="chip open" title="confirmed here in some arrangement the
packer can't model (crosswise, on top of a pile…) — outside the capacity bars,
never warned about">wedged in</span>` : ""}
${g.lane && !g.inherited
? `<button class="lanebtn" data-key="${esc(g.key)}"
data-next="${g.lane === "flat" ? "standing" : "flat"}"
title="how this box sits — tap to flip${g.lane_override ? " (your override)" : ""}">
${g.lane === "flat" ? "▬ flat" : "▮ standing"}${g.lane_override ? " ✱" : ""}</button>`
${g.lane === "flat" ? "▬ flat" : "▮ standing"}${g.proud ? ", proud" : ""}${g.lane_override ? " ✱" : ""}</button>`
: ""}
${!g.inherited && (g.acknowledged || g.ackable)
${!g.inherited && (g.acknowledged || g.laneless)
? (g.acknowledged
? `<button class="ackbtn on" data-key="${esc(g.key)}" data-ack="false"
title="you accepted this overhang — tap to retract">overhang</button>`
title="you confirmed this is really here — tap to retract">here</button>`
: `<button class="ackbtn" data-key="${esc(g.key)}" data-ack="true"
title="doesn't fit by the numbers — accept it deliberately
(spine-out proud of the shelf edge) and it stops counting as a warning">accept overhang</button>`)
title="the packer can't place this box, but shelves are 3-D and
you can see yours — confirm it's really here and the warning stands down">it's here anyway</button>`)
: ""}
${g.inherited
? `<span class="meta">inside another box here</span>`
@@ -413,9 +412,30 @@ document.getElementById("sheetgames").addEventListener("click", e => {
key: lane.dataset.key, opening_id: OPEN, lane: lane.dataset.next,
}); return; }
const ack = e.target.closest(".ackbtn");
if (ack) post("/api/acknowledge", {
if (ack) { post("/api/acknowledge", {
key: ack.dataset.key, acknowledged: ack.dataset.ack === "true",
}); return; }
const mb = e.target.closest(".measurebtn");
if (mb) {
const span = document.createElement("span");
span.className = "measureform";
span.innerHTML = `
<input type="number" step="0.05" min="0.1" placeholder="W" aria-label="width, inches">
× <input type="number" step="0.05" min="0.1" placeholder="L" aria-label="length, inches">
× <input type="number" step="0.05" min="0.1" placeholder="D" aria-label="depth, inches"> in
<button class="measuresave" data-key="${mb.dataset.key}">save</button>`;
mb.replaceWith(span);
span.querySelector("input").focus();
return;
}
const ms = e.target.closest(".measuresave");
if (ms) {
const [w, l, d] = [...ms.parentElement.querySelectorAll("input")]
.map(i => parseFloat(i.value) || null);
if (w && l && d) post("/api/measure", {
key: ms.dataset.key, width_in: w, length_in: l, depth_in: d,
});
}
});
document.getElementById("sheetsearch").addEventListener("input", () => {
const q = document.getElementById("sheetsearch").value.trim().toLowerCase();
+38 -15
View File
@@ -64,17 +64,20 @@ from bggpipe.normalize import normalize_title
from bggpipe.resolve import MATCH_COLUMNS, TitleEntry, read_matches, write_matches
from bggpipe.review import _BGG_ERRORS, ReviewSession
from bggpipe.shelves import (
apply_measurements,
effective_location,
fits_opening,
game_dims,
load_furniture,
load_locations,
load_measurements,
new_opening_id,
opening_index,
opening_report,
related,
save_furniture,
save_locations,
save_measurements,
)
@@ -273,6 +276,13 @@ class LocateBody(BaseModel):
lane: str = "" # "", "flat", "standing": how THIS box sits in the opening
class MeasureBody(BaseModel):
key: str
width_in: float | None = None # all three set = save; all empty = clear
length_in: float | None = None
depth_in: float | None = None
class AcknowledgeBody(BaseModel):
key: str
acknowledged: bool # True = "doesn't fit, shelved anyway" is deliberate
@@ -593,7 +603,7 @@ def create_app(
if not cfg.games_path.exists():
return {}
try:
return json.loads(cfg.games_path.read_text())
return apply_measurements(cfg, json.loads(cfg.games_path.read_text()))
except (json.JSONDecodeError, OSError) as err:
warn_once(f"{cfg.games_path.name} unreadable ({err}) — library unavailable")
return {}
@@ -1116,15 +1126,6 @@ def create_app(
for opening in unit.get("openings", []):
here = residents.get(opening["id"], [])
report = opening_report(opening, here, lane_overrides, acked)
def ackable(k: str, name: str, opening=opening, here=here) -> bool:
# offer "accept overhang" only when accepting would
# actually give the box a lane — a width-starved
# opening is overfull, not overhung, and the button
# would be a placebo there
probe = opening_report(opening, here, lane_overrides, acked | {k})
return name not in probe["laneless"]
out_openings.append(
{
**opening,
@@ -1142,11 +1143,8 @@ def create_app(
"laneless": (e.get("name") or "?")
in report["laneless"],
"acknowledged": k in acked,
"ackable": (
(e.get("name") or "?") in report["laneless"]
and k not in acked
and ackable(k, e.get("name") or "?")
),
"wedged": (e.get("name") or "?") in report["wedged"],
"proud": (e.get("name") or "?") in report["proud"],
}
for k, e in sorted(
here,
@@ -1518,6 +1516,31 @@ def create_app(
save_locations(cfg, locations)
return _shelf_state()
@app.post("/api/measure")
def api_measure(body: MeasureBody) -> dict:
"""Record (or clear) hand-measured box dimensions. The owner's
tape measure outranks BGG this overlays enrich's data."""
with lock:
revision["n"] += 1
freshen()
games = read_games()
if body.key not in games:
raise HTTPException(404, "no such game in the library")
values = (body.width_in, body.length_in, body.depth_in)
measurements = load_measurements(cfg)
if all(v is None for v in values):
measurements.pop(body.key, None)
elif all(v and v > 0 for v in values):
measurements[body.key] = {
"width_in": body.width_in,
"length_in": body.length_in,
"depth_in": body.depth_in,
}
else:
raise HTTPException(400, "all three sizes (inches), or none to clear")
save_measurements(cfg, measurements)
return _shelf_state()
@app.post("/api/acknowledge")
def api_acknowledge(body: AcknowledgeBody) -> dict:
"""Accept (or retract accepting) a doesn't-fit placement — the
+68 -30
View File
@@ -1039,10 +1039,15 @@ def test_acknowledged_overhang(tmp_path):
assert report["lanes"] == {"g1": "standing"}
assert report["standing_in"] == pytest.approx(2.9)
# the width budget stays honest: nine acknowledged monoliths
# still overflow a 23.25" row
# overflow a 23.25" row — the surplus goes wedged (the human said
# they're here), never silently placed in a lane
crowd = [(f"g{i}", ether) for i in range(9)]
report = opening_report(tower, crowd, acknowledged={k for k, _ in crowd})
assert report["overfull"]
assert not report["overfull"]
assert report["wedged"]
assert len(report["wedged"]) + len(
[lane for lane in report["lanes"].values() if lane]
) == 9
def test_import_acknowledge_column(tmp_path):
@@ -1097,17 +1102,15 @@ def test_import_acknowledge_column(tmp_path):
}
def test_ackable_only_when_it_helps(tmp_path):
"""The accept-overhang button is offered only when acknowledging
would actually give the box a lane. A depth-blocked box on a shallow
shelf is ackable; a box squeezed out of a width-starved opening is
overfull, not overhung no placebo button."""
def test_here_anyway_wedges_the_unplaceable(tmp_path):
""""It's here anyway": acknowledging a box the lanes can't take
marks it wedged really there, outside the capacity math and the
opening stops warning about it."""
web, cfg = _web(
tmp_path,
{
"1": _entry("Wide Flat", 13.0, 13.0, 12.0),
"2": _entry("Squeezed Out", 10.0, 10.0, 3.0),
"3": _entry("Etherfields", 11.8, 11.8, 2.9),
},
)
save_furniture(
@@ -1123,15 +1126,7 @@ def test_ackable_only_when_it_helps(tmp_path):
"width_in": 13.25,
"height_in": 13.25,
"depth_in": 15.4,
},
{
"id": "tower",
"label": "B1",
"zone": "",
"width_in": 23.25,
"height_in": 19.0,
"depth_in": 9.5,
},
}
],
}
],
@@ -1141,18 +1136,61 @@ def test_ackable_only_when_it_helps(tmp_path):
{
"1": {"opening_id": "cube", "note": ""},
"2": {"opening_id": "cube", "note": ""},
"3": {"opening_id": "tower", "note": ""},
},
)
units = web.get("/api/shelves").json()["units"]
by_label = {o["label"]: o for o in units[0]["openings"]}
cube = {g["name"]: g for g in by_label["A1"]["resident_games"]}
# the wide flat box claims 13.0" of a 13.25" opening: 0.25" of
# standing lane left — acknowledging Squeezed Out changes nothing
assert cube["Squeezed Out"]["laneless"] is True
assert cube["Squeezed Out"]["ackable"] is False
tower = {g["name"]: g for g in by_label["B1"]["resident_games"]}
# the classic overhang: no lane takes 11.8" of depth on a 9.5"
# shelf, but standing proud works — offer the button
assert tower["Etherfields"]["laneless"] is True
assert tower["Etherfields"]["ackable"] is True
state = web.get("/api/shelves").json()
a1 = state["units"][0]["openings"][0]
squeezed = next(g for g in a1["resident_games"] if g["name"] == "Squeezed Out")
assert squeezed["laneless"] is True and squeezed["wedged"] is False
assert a1["overfull"] is True
state = web.post(
"/api/acknowledge", json={"key": "2", "acknowledged": True}
).json()
a1 = state["units"][0]["openings"][0]
squeezed = next(g for g in a1["resident_games"] if g["name"] == "Squeezed Out")
assert squeezed["wedged"] is True and squeezed["laneless"] is False
assert a1["overfull"] is False
def test_measurements_overlay(tmp_path):
"""The owner's tape measure outranks BGG: /api/measure records
dimensions for an unmeasured game, the library sees source
"measured", and clearing restores the unmeasured state."""
web, cfg = _web(tmp_path, {"1": {"name": "Mystery Box"}})
save_furniture(
cfg,
[
{
"name": "Mix",
"openings": [
{
"id": "cube",
"label": "A1",
"zone": "",
"width_in": 13.25,
"height_in": 13.25,
"depth_in": 15.4,
}
],
}
],
)
save_locations(cfg, {"1": {"opening_id": "cube", "note": ""}})
state = web.get("/api/shelves").json()
g = state["units"][0]["openings"][0]["resident_games"][0]
assert g["measured"] is False
state = web.post(
"/api/measure",
json={"key": "1", "width_in": 10.0, "length_in": 10.0, "depth_in": 2.5},
).json()
g = state["units"][0]["openings"][0]["resident_games"][0]
assert g["measured"] is True and g["lane"] == "flat"
# partial numbers are refused, never guessed
assert (
web.post("/api/measure", json={"key": "1", "width_in": 10.0}).status_code
== 400
)
# clearing (all empty) restores honest ignorance
state = web.post("/api/measure", json={"key": "1"}).json()
g = state["units"][0]["openings"][0]["resident_games"][0]
assert g["measured"] is False