diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1fe85a8..12956df 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,20 @@ Versioning follows [SemVer](https://semver.org/): **MAJOR.MINOR.PATCH**
---
+## [1.39.6] — 2026-09-28
+
+### Added
+- **Grouped inventory view.** The inventory report has a new selector next to the
+ search box: "Each spool" (unchanged, the default) or "Group by brand, material and
+ color". Grouped view shows one tile per brand/material/family/color with a count badge
+ (e.g. 5×), the combined remaining percentage and weight (unweighed spools count as
+ full), and the header shows groups and spools totals. Click a tile to list its spools
+ with links, remaining weight and location. Search works in both views.
+
+### Fixed
+- **Black spools no longer vanish on the dark theme.** Every spool donut now has a thin
+ outline on its outer and inner edges (black in the light theme, white in the dark one).
+
## [1.39.5] — 2026-09-28
### Added
diff --git a/VERSION b/VERSION
index 713e627..d163067 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.39.5
+1.39.6
diff --git a/routes/reports.py b/routes/reports.py
index 7607313..63ddb50 100644
--- a/routes/reports.py
+++ b/routes/reports.py
@@ -16,7 +16,40 @@ def report_stats():
def report_inventory():
q = request.args.get("q", "").strip()
items = db.list_inventory(q or None)
- return render_template("reports/inventory.html", items=items, q=q)
+ if request.args.get("view") == "grouped":
+ groups = _group_inventory(items)
+ return render_template("reports/inventory.html", groups=groups, items=items,
+ q=q, grouped=True)
+ return render_template("reports/inventory.html", items=items, q=q, grouped=False)
+
+
+def _group_inventory(items):
+ """Agrupa os spools ativos por (marca, material, família, cor, translúcido).
+
+ Regra do app: rolo não pesado conta como CHEIO (= nominal). Mantém a ordem de
+ `list_inventory` (material, marca, família). Devolve dicts prontos p/ o template."""
+ groups = {}
+ for s in items:
+ key = (s["brand"], s["material"], s["family"], s["color_hex"], bool(s["translucent"]))
+ g = groups.get(key)
+ if g is None:
+ g = groups[key] = {
+ "brand": s["brand"], "material": s["material"], "family": s["family"],
+ "color_hex": s["color_hex"], "translucent": bool(s["translucent"]),
+ "spools": [], "remaining_g": 0.0, "nominal_g": 0.0,
+ }
+ nominal = s["nominal_weight_g"] or 0
+ net = s["current_net_g"]
+ g["remaining_g"] += net if net is not None else nominal
+ g["nominal_g"] += nominal
+ g["spools"].append(s)
+ out = list(groups.values())
+ for g in out:
+ g["count"] = len(g["spools"])
+ g["pct"] = (min(round(g["remaining_g"] / g["nominal_g"] * 100, 1), 100)
+ if g["nominal_g"] > 0 else 100)
+ g["locations"] = " ".join(s["location"] or "" for s in g["spools"])
+ return out
@app.route("/reports/by-material")
diff --git a/static/spool.css b/static/spool.css
index 01312fd..72544a6 100644
--- a/static/spool.css
+++ b/static/spool.css
@@ -17,6 +17,7 @@
--sc-surface/--sc-border ficam próximos demais (contraste ~1.3:1, o quadrado
quase some) — usamos --sc-text-muted/--sc-bg de propósito: mesmo par de
tokens já existente, mas ~6.5:1 de contraste. */
+ --sc-donut-outline: #fff;
--sc-checker-a: var(--sc-text-muted);
--sc-checker-b: var(--sc-bg);
@@ -42,6 +43,7 @@
/* ── Design tokens — light ──────────────────────────────────────────────── */
[data-bs-theme="light"] {
+ --sc-donut-outline: #000;
--sc-bg: #F1F5F9;
--sc-surface: #FFFFFF;
--sc-surface-2: #F8FAFC;
@@ -419,6 +421,8 @@ h1, h2, h3, h4, h5, h6 { letter-spacing: -0.02em; }
/* ── Donuts ─────────────────────────────────────────────────────────────── */
.donut-track { stroke: var(--sc-border); }
+/* Contorno fino nas bordas externa/interna do anel (ver templates/_swatch.html) */
+.donut-outline { stroke: var(--sc-donut-outline); }
/* Filamento translúcido/transparente: a cor entra em opacidade parcial por
cima do xadrez global (#sc-checker, definido em base.html), em vez do
@@ -492,6 +496,8 @@ h1, h2, h3, h4, h5, h6 { letter-spacing: -0.02em; }
}
.sc-inv-tile:hover { background: var(--sc-accent-glow); transform: translateY(-1px); }
.sc-inv-name { max-width: 80px; margin: 4px auto 0; font-size: .72rem; line-height: 1.1; }
+.sc-inv-count { font-size: .68rem; margin: 3px; background: var(--sc-accent) !important; color: #000; }
+.sc-inv-total { font-weight: 600; }
.sc-inv-sub { max-width: 80px; margin: 0 auto; font-size: .66rem; line-height: 1.15; }
/* ── Brand logo (inline SVG, inherits theme color via currentColor) ─────── */
diff --git a/templates/_swatch.html b/templates/_swatch.html
index b7ec93c..417ed58 100644
--- a/templates/_swatch.html
+++ b/templates/_swatch.html
@@ -7,7 +7,9 @@
parcial, em vez de uma cor sólida — um "Transparent" (#FFFFFF) some de
Branco e ganha um padrão de transparência, com o tom (se houver) ainda
visível por cima. Nenhum recurso externo; funciona nos dois temas via
- tokens de `static/spool.css` (--sc-surface/--sc-border). #}
+ tokens de `static/spool.css` (--sc-surface/--sc-border).
+ Contorno fino nas bordas do anel (`.donut-outline`, preto no claro/branco no
+ escuro) para o rolo preto não sumir no tema escuro. #}
{% macro swatch(hex, translucent=false, size=12) %}
+