diff --git a/src/bggpipe/shelves.py b/src/bggpipe/shelves.py
index 25422c8..04e7dd6 100644
--- a/src/bggpipe/shelves.py
+++ b/src/bggpipe/shelves.py
@@ -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:
"""A box on a shelf stack contributes its thinnest axis."""
dims = game_dims(entry)
diff --git a/src/bggpipe/static/app.css b/src/bggpipe/static/app.css
index 52fa7f8..77425a2 100644
--- a/src/bggpipe/static/app.css
+++ b/src/bggpipe/static/app.css
@@ -549,13 +549,21 @@ select {
.card.stack { display: block; }
.unit .unithead { display: flex; align-items: center; gap: .7rem;
justify-content: space-between; flex-wrap: wrap; }
-.openings { display: grid; gap: .5rem;
- grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr)); }
+/* rows mirror the unit's PHYSICAL arrangement: one .orow per label
+ 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 {
font: inherit; text-align: left; cursor: pointer;
background: #fff; border: 2px solid var(--board-edge);
border-radius: var(--radius); padding: .5rem .6rem;
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 {
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;
background: var(--board); padding: .15rem .6rem; }
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 */
#opensheet { position: fixed; inset: 0; background: rgba(42, 36, 56, .45);
diff --git a/src/bggpipe/templates/pages/shelves.html b/src/bggpipe/templates/pages/shelves.html
index 11c8324..082724e 100644
--- a/src/bggpipe/templates/pages/shelves.html
+++ b/src/bggpipe/templates/pages/shelves.html
@@ -1,9 +1,8 @@
Shelves
-
-
-
+
-
-
${u.openings.map(openingCell).join("")}
`;
}
+// 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 `