Browse Source

refactor(colors): color_catalog is the single source of truth (#857)

  The Printer tab AMS popup and spool auto-provisioner resolved color
  names from hardcoded tray_id_name tables with a suffix-code fallback —
  and suffix codes like "R1" are not globally unique across material
  families. A17-R1 (PLA Translucent Cherry Pink) fell through the
  fallback and resolved to "Scarlet Red" (A01-R1, PLA Matte), baking
  the wrong name into auto-created inventory spools.

  The fix removes the hardcoded tables entirely. Backend resolves color
  names via the existing color_catalog table by hex; frontend fetches a
  compact {hex: name} map once per session via a new
  GET /inventory/colors/map endpoint (auth-gated but not on
  inventory:read — read-only views need it too) and stores it in a
  ColorCatalogProvider context. A useSyncExternalStore hook cascades a
  re-render into pages mounted before the fetch completes so they
  refresh from HSL-fallback names once the catalog loads.

  Existing auto-provisioned spools keep their stored names; only new
  provisioning and live display benefit. Co-Authored-By is intentionally
  omitted here per project convention — set it via git config if needed.
maziggy 1 month ago
parent
commit
99c193b535

+ 1 - 0
CHANGELOG.md

@@ -14,6 +14,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **LDAP Default Fallback Group** — Settings → Authentication → LDAP → Advanced now has a "Default group" selector. When an LDAP user authenticates but is not listed in any mapped LDAP group, they are automatically assigned to this fallback group instead of being left without permissions. Previously such users could log in successfully but landed on empty pages because every permission check failed. Leave the setting empty to preserve the old behavior. A warning is logged each time the fallback is applied so administrators can spot missing group assignments.
 - **LDAP Default Fallback Group** — Settings → Authentication → LDAP → Advanced now has a "Default group" selector. When an LDAP user authenticates but is not listed in any mapped LDAP group, they are automatically assigned to this fallback group instead of being left without permissions. Previously such users could log in successfully but landed on empty pages because every permission check failed. Leave the setting empty to preserve the old behavior. A warning is logged each time the fallback is applied so administrators can spot missing group assignments.
 
 
 ### Fixed
 ### Fixed
+- **Wrong Filament Color Name Shown on Printer Tab AMS Popup** ([#857](https://github.com/maziggy/bambuddy/issues/857)) — PLA Translucent Cherry Pink (and other colors outside a small hand-maintained list) appeared as "Scarlet Red" on the Printer tab AMS slot popup, and was also auto-provisioned into the inventory under the wrong name on the first RFID read. Root cause: both the backend spool auto-provisioner and the frontend AMS popup resolved color names by looking up the Bambu `tray_id_name` code (e.g. `A17-R1`) in a hardcoded table, and when the exact code wasn't listed they fell back to a suffix-only lookup (`R1 → Scarlet Red`). The suffix half of that code is **not** globally unique across material families — `A17-R1` is PLA Translucent Cherry Pink, while `A01-R1` is PLA Matte Scarlet Red — so the fallback was structurally guaranteed to produce wrong names for any color the hand-maintained list didn't happen to cover. The resolver has been rewritten to use the existing `color_catalog` table (seeded from `catalog_defaults.py` plus the FilamentColors.xyz sync) as the single source of truth. Backend lookup is now by hex color against the catalog; the frontend fetches a compact `{hex: name}` map once per session via a new `GET /api/inventory/colors/map` endpoint (available to any authenticated user, not gated on `inventory:read`), stores it in a `ColorCatalogProvider` context, and uses it for all `getColorName()` calls. The hardcoded tables in `backend/app/core/bambu_colors.py`, `frontend/src/utils/colors.ts`, and `frontend/src/pages/PrintersPage.tsx` have been removed entirely. Existing spools that were auto-created with a wrong name before this fix need to be renamed manually — the fix only affects new auto-provisioning and live display. Thanks to @lightmaster for reporting.
 - **LDAP Auto-Provisioning Fails on Upgraded SQLite Installs** ([#794](https://github.com/maziggy/bambuddy/issues/794)) — First LDAP login on an upgraded SQLite install hit `sqlite3.IntegrityError: NOT NULL constraint failed: users.password_hash` and fell through to a 500 response, because the `users` table on disk had been created before LDAP support landed with `password_hash VARCHAR(255) NOT NULL`. The model was already `nullable=True` and the migration to drop the constraint existed, but only ran on PostgreSQL — SQLite was skipped entirely because it has no `ALTER COLUMN ... DROP NOT NULL`. The migration now patches `sqlite_master` directly via `PRAGMA writable_schema` and bumps `PRAGMA schema_version` so the current connection reloads the table definition without requiring a restart. Fresh installs were never affected (they go through `Base.metadata.create_all` which uses the current nullable model). Thanks to @DylanBrass for reporting.
 - **LDAP Auto-Provisioning Fails on Upgraded SQLite Installs** ([#794](https://github.com/maziggy/bambuddy/issues/794)) — First LDAP login on an upgraded SQLite install hit `sqlite3.IntegrityError: NOT NULL constraint failed: users.password_hash` and fell through to a 500 response, because the `users` table on disk had been created before LDAP support landed with `password_hash VARCHAR(255) NOT NULL`. The model was already `nullable=True` and the migration to drop the constraint existed, but only ran on PostgreSQL — SQLite was skipped entirely because it has no `ALTER COLUMN ... DROP NOT NULL`. The migration now patches `sqlite_master` directly via `PRAGMA writable_schema` and bumps `PRAGMA schema_version` so the current connection reloads the table definition without requiring a restart. Fresh installs were never affected (they go through `Base.metadata.create_all` which uses the current nullable model). Thanks to @DylanBrass for reporting.
 - **Energy Statistics Empty for Week/Month/Day in Total Consumption Mode** ([#941](https://github.com/maziggy/bambuddy/issues/941)) — With "Total consumption" selected as the energy tracking mode, the Statistics page showed the correct kWh total for All Time but zero for every time-filtered range (Today, This Week, This Month, …). The backend fell back to summing per-print archive energy whenever a date filter was active, but in total-consumption mode the per-print column was often empty for two reasons: (1) the starting-kWh value was held in an in-memory dict (`_print_energy_start`) that was lost on any backend restart mid-print, so prints that spanned a restart never got an energy delta computed; (2) historical prints from before a smart plug was added had no value at all. The fix replaces the in-memory dict with a persisted `energy_start_kwh` column on the archive row, and adds an hourly snapshot loop (`smart_plug_energy_snapshots` table) that captures each plug's lifetime counter. The `/archives/stats` endpoint now computes date-range totals via per-plug `(last-in-range − baseline)` deltas from those snapshots, clamping counter resets to zero. A warming-up flag is returned (and rendered as a tooltip next to the Energy stats on StatsPage) when the query runs on incomplete snapshot history — e.g. right after upgrade, before the hourly loop has built up a baseline before the selected range — so the "low" values during the first hours after upgrading are explained in-product rather than misread as a bug. Fully localized across all 7 UI languages. Per-print energy tracking is now restart-resilient in all modes as a side-effect. Thanks to Mike (@TheMadMike23) for reporting.
 - **Energy Statistics Empty for Week/Month/Day in Total Consumption Mode** ([#941](https://github.com/maziggy/bambuddy/issues/941)) — With "Total consumption" selected as the energy tracking mode, the Statistics page showed the correct kWh total for All Time but zero for every time-filtered range (Today, This Week, This Month, …). The backend fell back to summing per-print archive energy whenever a date filter was active, but in total-consumption mode the per-print column was often empty for two reasons: (1) the starting-kWh value was held in an in-memory dict (`_print_energy_start`) that was lost on any backend restart mid-print, so prints that spanned a restart never got an energy delta computed; (2) historical prints from before a smart plug was added had no value at all. The fix replaces the in-memory dict with a persisted `energy_start_kwh` column on the archive row, and adds an hourly snapshot loop (`smart_plug_energy_snapshots` table) that captures each plug's lifetime counter. The `/archives/stats` endpoint now computes date-range totals via per-plug `(last-in-range − baseline)` deltas from those snapshots, clamping counter resets to zero. A warming-up flag is returned (and rendered as a tooltip next to the Energy stats on StatsPage) when the query runs on incomplete snapshot history — e.g. right after upgrade, before the hourly loop has built up a baseline before the selected range — so the "low" values during the first hours after upgrading are explained in-product rather than misread as a bug. Fully localized across all 7 UI languages. Per-print energy tracking is now restart-resilient in all modes as a side-effect. Thanks to Mike (@TheMadMike23) for reporting.
 - **Virtual Printer "Synchronizing device information" Times Out in Orca** ([#927](https://github.com/maziggy/bambuddy/issues/927)) — OrcaSlicer's "Send job" flow sat on "Synchronizing device information…" until it gave up, even though the FTP upload itself worked when the user clicked "Send job anyway". The virtual printer's MQTT server gated all incoming command handling on `f"device/{self.serial}/request" in topic` — if the slicer's cached serial for the VP didn't exactly equal the VP's computed `self.serial` (which depends on model prefix + per-VP `serial_suffix`), every `get_version`, `pushall`, and `project_file` publish was silently dropped. Nothing was logged past the initial "MQTT publish to …" line, so the slicer never received a `push_status` or `get_version` response on its subscribed `device/{serial}/report` topic and hit its sync timeout. Status pushes, version responses, and project_file acknowledgments were *also* being published on `device/{self.serial}/report`, so even when the incoming check happened to pass, replies targeted a topic the slicer wasn't listening on if its serial had drifted. Both directions are now serial-adaptive: the handler accepts any authenticated publish on a `device/*/request` topic, extracts the serial the slicer is actually using from the topic, stores it per-connection, and uses it for every outgoing status report, version response, print acknowledgment, and periodic push so responses always land on the topic the slicer subscribed to. The client's serial is cleared when the connection closes and when the server stops. Regression tests cover the mismatched-serial publish path, the non-request-topic rejection path, the pushall→status_report routing, and the client-serial lifecycle.
 - **Virtual Printer "Synchronizing device information" Times Out in Orca** ([#927](https://github.com/maziggy/bambuddy/issues/927)) — OrcaSlicer's "Send job" flow sat on "Synchronizing device information…" until it gave up, even though the FTP upload itself worked when the user clicked "Send job anyway". The virtual printer's MQTT server gated all incoming command handling on `f"device/{self.serial}/request" in topic` — if the slicer's cached serial for the VP didn't exactly equal the VP's computed `self.serial` (which depends on model prefix + per-VP `serial_suffix`), every `get_version`, `pushall`, and `project_file` publish was silently dropped. Nothing was logged past the initial "MQTT publish to …" line, so the slicer never received a `push_status` or `get_version` response on its subscribed `device/{serial}/report` topic and hit its sync timeout. Status pushes, version responses, and project_file acknowledgments were *also* being published on `device/{self.serial}/report`, so even when the incoming check happened to pass, replies targeted a topic the slicer wasn't listening on if its serial had drifted. Both directions are now serial-adaptive: the handler accepts any authenticated publish on a `device/*/request` topic, extracts the serial the slicer is actually using from the topic, stores it per-connection, and uses it for every outgoing status report, version response, print acknowledgment, and periodic push so responses always land on the topic the slicer subscribed to. The client's serial is cleared when the connection closes and when the server stops. Regression tests cover the mismatched-serial publish path, the non-request-topic rejection path, the pushall→status_report routing, and the client-serial lifecycle.

+ 40 - 1
backend/app/api/routes/inventory.py

@@ -9,7 +9,7 @@ from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 from sqlalchemy.orm import selectinload
 
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import RequirePermissionIfAuthEnabled, require_auth_if_enabled
 from backend.app.core.catalog_defaults import DEFAULT_COLOR_CATALOG, DEFAULT_SPOOL_CATALOG
 from backend.app.core.catalog_defaults import DEFAULT_COLOR_CATALOG, DEFAULT_SPOOL_CATALOG
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
@@ -234,6 +234,45 @@ async def get_color_catalog(
     return list(result.scalars().all())
     return list(result.scalars().all())
 
 
 
 
+@router.get("/colors/map")
+async def get_color_name_map(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_auth_if_enabled),
+):
+    """Compact {hex: name} map for frontend color-name resolution.
+
+    Not gated on INVENTORY_READ — every page that renders a spool color needs
+    this, including read-only views available to users without inventory access.
+    Normalized to lowercase 6-char hex without '#'. When multiple catalog entries
+    share the same hex (different materials or manufacturers), Bambu Lab wins,
+    then default entries, then the first encountered.
+    """
+    result = await db.execute(
+        select(
+            ColorCatalogEntry.hex_color,
+            ColorCatalogEntry.color_name,
+            ColorCatalogEntry.manufacturer,
+            ColorCatalogEntry.is_default,
+        )
+    )
+    mapping: dict[str, tuple[str, int]] = {}  # hex → (name, priority); higher priority wins
+    for hex_color, color_name, manufacturer, is_default in result.all():
+        if not hex_color or not color_name:
+            continue
+        key = hex_color.lstrip("#").lower()[:6]
+        if len(key) != 6:
+            continue
+        priority = 0
+        if manufacturer and manufacturer.strip().lower() == "bambu lab":
+            priority += 2
+        if is_default:
+            priority += 1
+        existing = mapping.get(key)
+        if existing is None or priority > existing[1]:
+            mapping[key] = (color_name, priority)
+    return {"colors": {k: v[0] for k, v in mapping.items()}}
+
+
 @router.post("/colors", response_model=ColorEntryResponse)
 @router.post("/colors", response_model=ColorEntryResponse)
 async def add_color_entry(
 async def add_color_entry(
     entry: ColorEntryCreate,
     entry: ColorEntryCreate,

+ 0 - 317
backend/app/core/bambu_colors.py

@@ -1,317 +0,0 @@
-"""Bambu Lab filament color code to color name mapping.
-
-Source: https://github.com/queengooborg/Bambu-Lab-RFID-Library
-
-Maps tray_id_name codes (e.g. "A06-D0") to human-readable color names (e.g. "Titan Gray").
-"""
-
-# Full color code → name mapping by material prefix
-BAMBU_FILAMENT_COLORS: dict[str, str] = {
-    # PLA Basic (A00)
-    "A00-W1": "Jade White",
-    "A00-P0": "Beige",
-    "A00-D2": "Light Gray",
-    "A00-Y0": "Yellow",
-    "A00-Y2": "Sunflower Yellow",
-    "A00-A1": "Pumpkin Orange",
-    "A00-A0": "Orange",
-    "A00-Y4": "Gold",
-    "A00-G3": "Bright Green",
-    "A00-G1": "Bambu Green",
-    "A00-G2": "Mistletoe Green",
-    "A00-R3": "Hot Pink",
-    "A00-P6": "Magenta",
-    "A00-R0": "Red",
-    "A00-R2": "Maroon Red",
-    "A00-P5": "Purple",
-    "A00-P2": "Indigo Purple",
-    "A00-B5": "Turquoise",
-    "A00-B8": "Cyan",
-    "A00-B3": "Cobalt Blue",
-    "A00-N0": "Brown",
-    "A00-N1": "Cocoa Brown",
-    "A00-Y3": "Bronze",
-    "A00-D0": "Gray",
-    "A00-D1": "Silver",
-    "A00-B1": "Blue Grey",
-    "A00-D3": "Dark Gray",
-    "A00-K0": "Black",
-    # PLA Basic Gradient (A00-M*)
-    "A00-M3": "Pink Citrus",
-    "A00-M6": "Dusk Glare",
-    "A00-M0": "Arctic Whisper",
-    "A00-M1": "Solar Breeze",
-    "A00-M5": "Blueberry Bubblegum",
-    "A00-M4": "Mint Lime",
-    "A00-M2": "Ocean to Meadow",
-    "A00-M7": "Cotton Candy Cloud",
-    # PLA Lite (A18)
-    "A18-K0": "Black",
-    "A18-D0": "Gray",
-    "A18-W0": "White",
-    "A18-R0": "Red",
-    "A18-Y0": "Yellow",
-    "A18-B0": "Cyan",
-    "A18-B1": "Blue",
-    "A18-P0": "Matte Beige",
-    # PLA Matte (A01)
-    "A01-W2": "Ivory White",
-    "A01-W3": "Bone White",
-    "A01-Y2": "Lemon Yellow",
-    "A01-A2": "Mandarin Orange",
-    "A01-P3": "Sakura Pink",
-    "A01-P4": "Lilac Purple",
-    "A01-R3": "Plum",
-    "A01-R1": "Scarlet Red",
-    "A01-R4": "Dark Red",
-    "A01-G0": "Apple Green",
-    "A01-G1": "Grass Green",
-    "A01-G7": "Dark Green",
-    "A01-B4": "Ice Blue",
-    "A01-B0": "Sky Blue",
-    "A01-B3": "Marine Blue",
-    "A01-B6": "Dark Blue",
-    "A01-Y3": "Desert Tan",
-    "A01-N1": "Latte Brown",
-    "A01-N3": "Caramel",
-    "A01-R2": "Terracotta",
-    "A01-N2": "Dark Brown",
-    "A01-N0": "Dark Chocolate",
-    "A01-D3": "Ash Gray",
-    "A01-D0": "Nardo Gray",
-    "A01-K1": "Charcoal",
-    # PLA Glow (A12)
-    "A12-G0": "Green",
-    "A12-R0": "Pink",
-    "A12-A0": "Orange",
-    "A12-Y0": "Yellow",
-    "A12-B0": "Blue",
-    # PLA Marble (A07)
-    "A07-R5": "Red Granite",
-    "A07-D4": "White Marble",
-    # PLA Aero (A11)
-    "A11-W0": "White",
-    "A11-K0": "Black",
-    # PLA Sparkle (A08)
-    "A08-G3": "Alpine Green Sparkle",
-    "A08-D5": "Slate Gray Sparkle",
-    "A08-B7": "Royal Purple Sparkle",
-    "A08-R2": "Crimson Red Sparkle",
-    "A08-K2": "Onyx Black Sparkle",
-    "A08-Y1": "Classic Gold Sparkle",
-    # PLA Metal (A02)
-    "A02-B2": "Cobalt Blue Metallic",
-    "A02-G2": "Oxide Green Metallic",
-    "A02-Y1": "Iridium Gold Metallic",
-    "A02-D2": "Iron Gray Metallic",
-    # PLA Translucent (A17)
-    "A17-B1": "Blue",
-    "A17-A0": "Orange",
-    "A17-P0": "Purple",
-    # PLA Silk+ (A06)
-    "A06-Y1": "Gold",
-    "A06-D0": "Titan Gray",
-    "A06-D1": "Silver",
-    "A06-W0": "White",
-    "A06-R0": "Candy Red",
-    "A06-G0": "Candy Green",
-    "A06-G1": "Mint",
-    "A06-B1": "Blue",
-    "A06-B0": "Baby Blue",
-    "A06-P0": "Purple",
-    "A06-R1": "Rose Gold",
-    "A06-R2": "Pink",
-    "A06-Y0": "Champagne",
-    # PLA Silk Multi-Color (A05)
-    "A05-M8": "Dawn Radiance",
-    "A05-M4": "Aurora Purple",
-    "A05-M1": "South Beach",
-    "A05-T3": "Neon City",
-    "A05-T2": "Midnight Blaze",
-    "A05-T1": "Gilded Rose",
-    "A05-T4": "Blue Hawaii",
-    "A05-T5": "Velvet Eclipse",
-    # PLA Galaxy (A15)
-    "A15-B0": "Purple",
-    "A15-G0": "Green",
-    "A15-G1": "Nebulae",
-    "A15-R0": "Brown",
-    # PLA Wood (A16)
-    "A16-K0": "Black Walnut",
-    "A16-R0": "Rosewood",
-    "A16-N0": "Clay Brown",
-    "A16-G0": "Classic Birch",
-    "A16-W0": "White Oak",
-    "A16-Y0": "Ochre Yellow",
-    # PLA-CF (A50)
-    "A50-D6": "Lava Gray",
-    "A50-K0": "Black",
-    "A50-B6": "Royal Blue",
-    # PLA Tough+ (A10)
-    "A10-W0": "White",
-    "A10-D0": "Gray",
-    # PLA Tough (A09)
-    "A09-B5": "Lavender Blue",
-    "A09-B4": "Light Blue",
-    "A09-A0": "Orange",
-    "A09-D1": "Silver",
-    "A09-R3": "Vermilion Red",
-    "A09-Y0": "Yellow",
-    # PETG HF (G02)
-    "G02-K0": "Black",
-    "G02-W0": "White",
-    "G02-R0": "Red",
-    "G02-D0": "Gray",
-    "G02-D1": "Dark Gray",
-    "G02-Y1": "Cream",
-    "G02-Y0": "Yellow",
-    "G02-A0": "Orange",
-    "G02-N1": "Peanut Brown",
-    "G02-G1": "Lime Green",
-    "G02-G0": "Green",
-    "G02-G2": "Forest Green",
-    "G02-B1": "Lake Blue",
-    "G02-B0": "Blue",
-    # PETG Translucent (G01)
-    "G01-G1": "Translucent Teal",
-    "G01-B0": "Translucent Light Blue",
-    "G01-C0": "Clear",
-    "G01-D0": "Translucent Gray",
-    "G01-G0": "Translucent Olive",
-    "G01-N0": "Translucent Brown",
-    "G01-A0": "Translucent Orange",
-    "G01-P1": "Translucent Pink",
-    "G01-P0": "Translucent Purple",
-    # PETG-CF (G50)
-    "G50-P7": "Violet Purple",
-    "G50-K0": "Black",
-    # ABS (B00)
-    "B00-D1": "Silver",
-    "B00-K0": "Black",
-    "B00-W0": "White",
-    "B00-G6": "Bambu Green",
-    "B00-G7": "Olive",
-    "B00-Y1": "Tangerine Yellow",
-    "B00-A0": "Orange",
-    "B00-R0": "Red",
-    "B00-B4": "Azure",
-    "B00-B0": "Blue",
-    "B00-B6": "Navy Blue",
-    # ABS-GF (B50)
-    "B50-A0": "Orange",
-    "B50-K0": "Black",
-    # ASA (B01)
-    "B01-W0": "White",
-    "B01-K0": "Black",
-    "B01-D0": "Gray",
-    # ASA Aero (B02)
-    "B02-W0": "White",
-    # PC (C00)
-    "C00-C1": "Transparent",
-    "C00-C0": "Clear Black",
-    "C00-K0": "Black",
-    "C00-W0": "White",
-    # PC FR (C01)
-    "C01-K0": "Black",
-    # TPU for AMS (U02)
-    "U02-B0": "Blue",
-    "U02-D0": "Gray",
-    "U02-K0": "Black",
-    # PAHT-CF (N04)
-    "N04-K0": "Black",
-    # PA6-GF (N08)
-    "N08-K0": "Black",
-    # Support for PLA/PETG (S02, S05)
-    "S02-W0": "Nature",
-    "S02-W1": "White",
-    "S05-C0": "Black",
-    # Support for ABS (S06)
-    "S06-W0": "White",
-    # Support for PA/PET (S03)
-    "S03-G1": "Green",
-    # PVA (S04)
-    "S04-Y0": "Clear",
-}
-
-# Fallback: color code suffix → name (for unknown material prefixes)
-BAMBU_COLOR_CODE_FALLBACK: dict[str, str] = {
-    "W0": "White",
-    "W1": "Jade White",
-    "W2": "Ivory White",
-    "W3": "Bone White",
-    "Y0": "Yellow",
-    "Y1": "Gold",
-    "Y2": "Sunflower Yellow",
-    "Y3": "Bronze",
-    "Y4": "Gold",
-    "A0": "Orange",
-    "A1": "Pumpkin Orange",
-    "A2": "Mandarin Orange",
-    "R0": "Red",
-    "R1": "Scarlet Red",
-    "R2": "Maroon Red",
-    "R3": "Hot Pink",
-    "R4": "Dark Red",
-    "R5": "Red Granite",
-    "P0": "Beige",
-    "P1": "Pink",
-    "P2": "Indigo Purple",
-    "P3": "Sakura Pink",
-    "P4": "Lilac Purple",
-    "P5": "Purple",
-    "P6": "Magenta",
-    "P7": "Violet Purple",
-    "B0": "Blue",
-    "B1": "Blue Grey",
-    "B2": "Cobalt Blue",
-    "B3": "Cobalt Blue",
-    "B4": "Ice Blue",
-    "B5": "Turquoise",
-    "B6": "Navy Blue",
-    "B7": "Royal Purple",
-    "B8": "Cyan",
-    "G0": "Green",
-    "G1": "Grass Green",
-    "G2": "Mistletoe Green",
-    "G3": "Bright Green",
-    "G6": "Bambu Green",
-    "G7": "Dark Green",
-    "N0": "Brown",
-    "N1": "Peanut Brown",
-    "N2": "Dark Brown",
-    "N3": "Caramel",
-    "D0": "Gray",
-    "D1": "Silver",
-    "D2": "Light Gray",
-    "D3": "Dark Gray",
-    "D4": "White Marble",
-    "D5": "Slate Gray",
-    "D6": "Lava Gray",
-    "K0": "Black",
-    "K1": "Charcoal",
-    "K2": "Onyx Black",
-    "C0": "Clear Black",
-    "C1": "Transparent",
-}
-
-
-def resolve_bambu_color_name(tray_id_name: str) -> str | None:
-    """Resolve a Bambu Lab tray_id_name code to a human-readable color name.
-
-    Tries exact match first, then falls back to color code suffix lookup.
-    Returns None if the code cannot be resolved.
-    """
-    if not tray_id_name:
-        return None
-
-    # Exact match
-    name = BAMBU_FILAMENT_COLORS.get(tray_id_name)
-    if name:
-        return name
-
-    # Fallback: use color code suffix (e.g. "D0" from "A06-D0")
-    parts = tray_id_name.split("-")
-    if len(parts) >= 2:
-        return BAMBU_COLOR_CODE_FALLBACK.get(parts[1])
-
-    return None

+ 17 - 15
backend/app/services/spool_tag_matcher.py

@@ -86,24 +86,15 @@ async def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool:
         elif color_code and color_code[0] == "T":
         elif color_code and color_code[0] == "T":
             subtype = "Tri Color"
             subtype = "Tri Color"
 
 
-    # Resolve color name from tray_id_name code, hex catalog, or raw tray_id_name
-    from backend.app.core.bambu_colors import resolve_bambu_color_name
-
+    # Resolve color name from the color catalog by hex. The catalog is the single
+    # source of truth — tray_id_name codes (e.g. "A17-R1") are NOT globally unique
+    # across material families (A17-R1 is PLA Translucent Cherry Pink; A01-R1 is
+    # PLA Matte Scarlet Red), so a suffix-based fallback would pick the wrong name.
+    # See #857.
     rgba = tray_color if tray_color else None
     rgba = tray_color if tray_color else None
     color_name = None
     color_name = None
 
 
-    # 1. Try Bambu color code mapping (e.g. "A06-D0" → "Titan Gray")
-    if tray_id_name:
-        color_name = resolve_bambu_color_name(tray_id_name)
-        logger.info("Color resolve: tray_id_name=%r → resolved=%r", tray_id_name, color_name)
-        # If not a known code, use tray_id_name directly (it may be a readable name)
-        if not color_name and "-" not in tray_id_name:
-            color_name = tray_id_name
-    else:
-        logger.info("Color resolve: tray_id_name is empty, rgba=%r", rgba)
-
-    # 2. Try color catalog lookup by hex color
-    if not color_name and rgba and len(rgba) >= 6:
+    if rgba and len(rgba) >= 6:
         hex_prefix = f"#{rgba[:6].upper()}"
         hex_prefix = f"#{rgba[:6].upper()}"
         cat_result = await db.execute(
         cat_result = await db.execute(
             select(ColorCatalogEntry)
             select(ColorCatalogEntry)
@@ -115,6 +106,17 @@ async def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool:
         if entry:
         if entry:
             color_name = entry.color_name
             color_name = entry.color_name
 
 
+    # If tray_id_name is a human-readable name (no "-" code), fall back to it.
+    if not color_name and tray_id_name and "-" not in tray_id_name:
+        color_name = tray_id_name
+
+    logger.info(
+        "Color resolve: tray_id_name=%r rgba=%r → resolved=%r",
+        tray_id_name,
+        rgba,
+        color_name,
+    )
+
     # Look up core weight from spool catalog
     # Look up core weight from spool catalog
     core_weight = 250  # Default for Bambu Lab plastic spools
     core_weight = 250  # Default for Bambu Lab plastic spools
     cat_result = await db.execute(select(SpoolCatalogEntry).where(SpoolCatalogEntry.name.ilike("Bambu Lab%")).limit(10))
     cat_result = await db.execute(select(SpoolCatalogEntry).where(SpoolCatalogEntry.name.ilike("Bambu Lab%")).limit(10))

+ 165 - 0
backend/tests/integration/test_color_map_api.py

@@ -0,0 +1,165 @@
+"""Integration tests for GET /api/v1/inventory/colors/map — the lean color-name
+lookup endpoint the frontend uses to resolve hex → name synchronously (see #857).
+
+Regression guards for the behaviors the fix relies on:
+ - Not gated on INVENTORY_READ (anyone authenticated can call it, otherwise the
+   login page and read-only views would fail to render color names).
+ - Keys are normalized to lowercase 6-char hex without the '#' prefix.
+ - When multiple catalog rows share a hex, Bambu Lab wins over generic brands so
+   the display name matches what users see in the slicer.
+ - Default-seeded rows outrank user-added non-default rows on the same hex.
+ - A17-R1 / F5B6CD resolves to "Cherry Pink" when catalog is seeded, the exact
+   scenario that triggered #857 on @lightmaster's install.
+"""
+
+import pytest
+from httpx import AsyncClient
+
+from backend.app.models.color_catalog import ColorCatalogEntry
+
+
+async def _seed(db_session, entries):
+    for kwargs in entries:
+        db_session.add(ColorCatalogEntry(**kwargs))
+    await db_session.commit()
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_color_map_empty_catalog(async_client: AsyncClient):
+    """Returns an empty mapping when the catalog has no rows."""
+    response = await async_client.get("/api/v1/inventory/colors/map")
+    assert response.status_code == 200
+    body = response.json()
+    assert body == {"colors": {}}
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_color_map_returns_lowercase_hex_without_hash(async_client: AsyncClient, db_session):
+    """Catalog rows can store hex with or without '#' and in any case; the map
+    endpoint always emits lowercase 6-char hex without the '#' prefix so the
+    frontend can do direct dict lookups."""
+    await _seed(
+        db_session,
+        [
+            {
+                "manufacturer": "Bambu Lab",
+                "color_name": "Cherry Pink",
+                "hex_color": "#F5B6CD",
+                "material": "PLA Translucent",
+                "is_default": True,
+            },
+            {
+                "manufacturer": "Bambu Lab",
+                "color_name": "Scarlet Red",
+                "hex_color": "#DE4343",
+                "material": "PLA Matte",
+                "is_default": True,
+            },
+        ],
+    )
+    response = await async_client.get("/api/v1/inventory/colors/map")
+    assert response.status_code == 200
+    colors = response.json()["colors"]
+    assert "f5b6cd" in colors
+    assert "de4343" in colors
+    assert colors["f5b6cd"] == "Cherry Pink"
+    assert colors["de4343"] == "Scarlet Red"
+    # No uppercase, no '#' keys
+    assert "F5B6CD" not in colors
+    assert "#f5b6cd" not in colors
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_color_map_bambu_wins_over_generic_on_same_hex(async_client: AsyncClient, db_session):
+    """When a generic brand happens to share a hex with Bambu Lab, Bambu wins —
+    the canonical Bambu name is what the user expects to see on the AMS popup."""
+    await _seed(
+        db_session,
+        [
+            {
+                "manufacturer": "Generic",
+                "color_name": "Pinkish",
+                "hex_color": "#F5B6CD",
+                "material": "PLA",
+                "is_default": False,
+            },
+            {
+                "manufacturer": "Bambu Lab",
+                "color_name": "Cherry Pink",
+                "hex_color": "#F5B6CD",
+                "material": "PLA Translucent",
+                "is_default": True,
+            },
+        ],
+    )
+    response = await async_client.get("/api/v1/inventory/colors/map")
+    assert response.status_code == 200
+    assert response.json()["colors"]["f5b6cd"] == "Cherry Pink"
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_color_map_default_wins_over_user_added(async_client: AsyncClient, db_session):
+    """Within the same manufacturer, default-seeded rows outrank user-added rows
+    — the defaults are trusted and a user's custom alias shouldn't shadow the
+    canonical catalog entry."""
+    await _seed(
+        db_session,
+        [
+            {
+                "manufacturer": "Bambu Lab",
+                "color_name": "My Custom Name",
+                "hex_color": "#F5B6CD",
+                "material": "PLA",
+                "is_default": False,
+            },
+            {
+                "manufacturer": "Bambu Lab",
+                "color_name": "Cherry Pink",
+                "hex_color": "#F5B6CD",
+                "material": "PLA Translucent",
+                "is_default": True,
+            },
+        ],
+    )
+    response = await async_client.get("/api/v1/inventory/colors/map")
+    assert response.status_code == 200
+    assert response.json()["colors"]["f5b6cd"] == "Cherry Pink"
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_color_map_skips_invalid_entries(async_client: AsyncClient, db_session):
+    """Rows with missing hex or name must be silently dropped rather than crashing
+    the endpoint. Malformed data shouldn't take down every color name in the UI."""
+    await _seed(
+        db_session,
+        [
+            # Too short to normalize to 6-char hex
+            {
+                "manufacturer": "Bambu Lab",
+                "color_name": "Weird",
+                "hex_color": "#FFF",
+                "material": None,
+                "is_default": False,
+            },
+            # Valid row that must still appear
+            {
+                "manufacturer": "Bambu Lab",
+                "color_name": "Cherry Pink",
+                "hex_color": "#F5B6CD",
+                "material": "PLA Translucent",
+                "is_default": True,
+            },
+        ],
+    )
+    response = await async_client.get("/api/v1/inventory/colors/map")
+    assert response.status_code == 200
+    colors = response.json()["colors"]
+    assert "f5b6cd" in colors
+    assert colors["f5b6cd"] == "Cherry Pink"
+    # 3-char hex was dropped
+    assert "fff" not in colors

+ 68 - 0
backend/tests/unit/services/test_spool_tag_matcher.py

@@ -3,6 +3,7 @@
 import pytest
 import pytest
 from sqlalchemy import inspect
 from sqlalchemy import inspect
 
 
+from backend.app.models.color_catalog import ColorCatalogEntry
 from backend.app.models.spool import Spool
 from backend.app.models.spool import Spool
 from backend.app.models.spool_assignment import SpoolAssignment
 from backend.app.models.spool_assignment import SpoolAssignment
 from backend.app.services.spool_tag_matcher import (
 from backend.app.services.spool_tag_matcher import (
@@ -807,6 +808,73 @@ async def test_create_spool_standard_not_affected(db_session):
     assert spool.subtype == "Basic"
     assert spool.subtype == "Basic"
 
 
 
 
+# -- color resolution (#857) -------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_color_resolves_from_catalog_not_suffix_fallback(db_session):
+    """Regression for #857 — A17-R1 (PLA Translucent Cherry Pink) must NOT resolve
+    to 'Scarlet Red' just because 'R1' also appears in PLA Matte.
+
+    The old resolver fell back to a suffix lookup table when the exact tray_id_name
+    wasn't mapped, which produced wrong names across material families. Cross-family
+    suffix codes are not globally unique, so only the catalog hex lookup is safe.
+    """
+    # Seed the catalog with the entry that the Cherry Pink hex should hit.
+    db_session.add(
+        ColorCatalogEntry(
+            manufacturer="Bambu Lab",
+            color_name="Cherry Pink",
+            hex_color="#F5B6CD",
+            material="PLA Translucent",
+            is_default=True,
+        )
+    )
+    await db_session.flush()
+
+    tray = {
+        **SAMPLE_TRAY,
+        "tray_type": "PLA",
+        "tray_sub_brands": "PLA Translucent",
+        "tray_color": "F5B6CDFF",
+        "tray_id_name": "A17-R1",
+    }
+    spool = await create_spool_from_tray(db_session, tray)
+    assert spool.color_name == "Cherry Pink"
+
+
+@pytest.mark.asyncio
+async def test_color_name_is_none_when_catalog_miss_and_code_unreadable(db_session):
+    """When the hex isn't in the catalog and tray_id_name is a code ('X##-Y#'),
+    color_name must stay None rather than falling through to a wrong suffix match.
+    A missing name is preferable to a confidently-wrong one.
+    """
+    tray = {
+        **SAMPLE_TRAY,
+        "tray_type": "PLA",
+        "tray_sub_brands": "PLA Translucent",
+        "tray_color": "F5B6CDFF",  # not seeded
+        "tray_id_name": "A17-R1",
+    }
+    spool = await create_spool_from_tray(db_session, tray)
+    assert spool.color_name is None
+
+
+@pytest.mark.asyncio
+async def test_color_name_falls_back_to_readable_tray_id_name(db_session):
+    """If tray_id_name is a human-readable label (no code pattern), use it when the
+    catalog has no entry for the hex. Preserves behavior for third-party spools whose
+    firmware puts a readable string in tray_id_name instead of a Bambu code.
+    """
+    tray = {
+        **SAMPLE_TRAY,
+        "tray_color": "123456FF",  # not in catalog
+        "tray_id_name": "Custom Purple",  # no '-', readable
+    }
+    spool = await create_spool_from_tray(db_session, tray)
+    assert spool.color_name == "Custom Purple"
+
+
 @pytest.mark.asyncio
 @pytest.mark.asyncio
 async def test_find_matching_untagged_gradient_spool(db_session):
 async def test_find_matching_untagged_gradient_spool(db_session):
     """find_matching_untagged_spool matches gradient subtype from tray_id_name."""
     """find_matching_untagged_spool matches gradient subtype from tray_id_name."""

+ 3 - 0
frontend/src/App.tsx

@@ -26,6 +26,7 @@ import { useStreamTokenSync } from './hooks/useCameraStreamToken';
 import { ThemeProvider } from './contexts/ThemeContext';
 import { ThemeProvider } from './contexts/ThemeContext';
 import { ToastProvider } from './contexts/ToastContext';
 import { ToastProvider } from './contexts/ToastContext';
 import { AuthProvider, useAuth } from './contexts/AuthContext';
 import { AuthProvider, useAuth } from './contexts/AuthContext';
+import { ColorCatalogProvider } from './contexts/ColorCatalogContext';
 import { SpoolBuddyLayout } from './components/spoolbuddy/SpoolBuddyLayout';
 import { SpoolBuddyLayout } from './components/spoolbuddy/SpoolBuddyLayout';
 import { SpoolBuddyDashboard } from './pages/spoolbuddy/SpoolBuddyDashboard';
 import { SpoolBuddyDashboard } from './pages/spoolbuddy/SpoolBuddyDashboard';
 import { SpoolBuddyAmsPage } from './pages/spoolbuddy/SpoolBuddyAmsPage';
 import { SpoolBuddyAmsPage } from './pages/spoolbuddy/SpoolBuddyAmsPage';
@@ -149,6 +150,7 @@ function App() {
       <ToastProvider>
       <ToastProvider>
         <QueryClientProvider client={queryClient}>
         <QueryClientProvider client={queryClient}>
           <AuthProvider>
           <AuthProvider>
+            <ColorCatalogProvider>
             <StreamTokenSync />
             <StreamTokenSync />
             <BrowserRouter>
             <BrowserRouter>
               <Routes>
               <Routes>
@@ -197,6 +199,7 @@ function App() {
                 </Route>
                 </Route>
               </Routes>
               </Routes>
             </BrowserRouter>
             </BrowserRouter>
+            </ColorCatalogProvider>
           </AuthProvider>
           </AuthProvider>
         </QueryClientProvider>
         </QueryClientProvider>
       </ToastProvider>
       </ToastProvider>

+ 127 - 0
frontend/src/__tests__/contexts/ColorCatalogContext.test.tsx

@@ -0,0 +1,127 @@
+/**
+ * Tests for ColorCatalogProvider — the provider that fetches the backend color
+ * catalog once per session and pushes it into the module-level store used by
+ * getColorName / resolveSpoolColorName. See #857.
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import { render, waitFor } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { BrowserRouter } from 'react-router-dom';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+import { ColorCatalogProvider } from '../../contexts/ColorCatalogContext';
+import { AuthProvider } from '../../contexts/AuthContext';
+import { ThemeProvider } from '../../contexts/ThemeContext';
+import { ToastProvider } from '../../contexts/ToastContext';
+import { getColorName, __resetColorCatalogForTests } from '../../utils/colors';
+
+function createWrapper() {
+  const queryClient = new QueryClient({
+    defaultOptions: {
+      queries: { retry: false, gcTime: 0 },
+    },
+  });
+
+  return function Wrapper({ children }: { children: React.ReactNode }) {
+    return (
+      <QueryClientProvider client={queryClient}>
+        <BrowserRouter>
+          <ThemeProvider>
+            <ToastProvider>
+              <AuthProvider>
+                <ColorCatalogProvider>{children}</ColorCatalogProvider>
+              </AuthProvider>
+            </ToastProvider>
+          </ThemeProvider>
+        </BrowserRouter>
+      </QueryClientProvider>
+    );
+  };
+}
+
+describe('ColorCatalogProvider', () => {
+  beforeEach(() => {
+    __resetColorCatalogForTests();
+    // Default auth handler: auth disabled so the provider's query is allowed
+    // to fire immediately without requiring a login.
+    server.use(
+      http.get('/api/v1/auth/status', () =>
+        HttpResponse.json({ auth_enabled: false, requires_setup: false })
+      )
+    );
+  });
+
+  it('populates the runtime catalog from /inventory/colors/map', async () => {
+    server.use(
+      http.get('/api/v1/inventory/colors/map', () =>
+        HttpResponse.json({
+          colors: {
+            f5b6cd: 'Cherry Pink',
+            de4343: 'Scarlet Red',
+            '8344b0': 'Purple',
+          },
+        })
+      )
+    );
+
+    const Wrapper = createWrapper();
+    render(
+      <Wrapper>
+        <div data-testid="child">ok</div>
+      </Wrapper>
+    );
+
+    await waitFor(() => {
+      // Regression for #857: before the fix, F5B6CD resolved to 'Scarlet Red'
+      // via the suffix fallback. After the fix, it resolves from the catalog.
+      expect(getColorName('f5b6cd')).toBe('Cherry Pink');
+    });
+    expect(getColorName('de4343')).toBe('Scarlet Red');
+    expect(getColorName('8344b0')).toBe('Purple');
+  });
+
+  it('still renders children even when the catalog fetch fails', async () => {
+    server.use(
+      http.get('/api/v1/inventory/colors/map', () =>
+        HttpResponse.json({ detail: 'kaboom' }, { status: 500 })
+      )
+    );
+
+    const Wrapper = createWrapper();
+    const { getByTestId } = render(
+      <Wrapper>
+        <div data-testid="child">ok</div>
+      </Wrapper>
+    );
+
+    // The provider must not block rendering on a failed fetch — if it did,
+    // the whole app would white-screen whenever the backend /colors/map route
+    // 500'd. The catalog is load-bearing for cosmetics, not correctness.
+    expect(getByTestId('child').textContent).toBe('ok');
+  });
+
+  it('falls back to HSL-bucket name when catalog miss', async () => {
+    server.use(
+      http.get('/api/v1/inventory/colors/map', () =>
+        HttpResponse.json({ colors: { f5b6cd: 'Cherry Pink' } })
+      )
+    );
+
+    const Wrapper = createWrapper();
+    render(
+      <Wrapper>
+        <div>ok</div>
+      </Wrapper>
+    );
+
+    // Wait for the provider to load the catalog at least once.
+    await waitFor(() => {
+      expect(getColorName('f5b6cd')).toBe('Cherry Pink');
+    });
+
+    // A hex that isn't in the (limited) catalog must fall through to HSL so
+    // unknown colors still get *some* name rather than the raw hex code.
+    expect(getColorName('123456')).toBe('Blue');
+  });
+});

+ 35 - 12
frontend/src/__tests__/utils/colors.test.ts

@@ -1,5 +1,11 @@
-import { describe, it, expect } from 'vitest';
-import { hexToColorName, getColorName, resolveSpoolColorName } from '../../utils/colors';
+import { describe, it, expect, beforeEach } from 'vitest';
+import {
+  hexToColorName,
+  getColorName,
+  resolveSpoolColorName,
+  setColorCatalog,
+  __resetColorCatalogForTests,
+} from '../../utils/colors';
 
 
 describe('hexToColorName', () => {
 describe('hexToColorName', () => {
   it('returns "Unknown" for null/empty input', () => {
   it('returns "Unknown" for null/empty input', () => {
@@ -23,20 +29,18 @@ describe('hexToColorName', () => {
 });
 });
 
 
 describe('getColorName', () => {
 describe('getColorName', () => {
-  it('looks up Bambu hex colors before HSL fallback', () => {
-    // 5f6367 is in BAMBU_HEX_COLORS as "Titan Gray"
-    expect(getColorName('5f6367')).toBe('Titan Gray');
-    // Also with uppercase
-    expect(getColorName('5F6367')).toBe('Titan Gray');
+  beforeEach(() => {
+    __resetColorCatalogForTests();
   });
   });
 
 
-  it('looks up alternative Titan Gray hex', () => {
-    // 565656 is also mapped to "Titan Gray" in BAMBU_HEX_COLORS
-    expect(getColorName('565656')).toBe('Titan Gray');
+  it('looks up the runtime color catalog before HSL fallback', () => {
+    setColorCatalog({ '5f6367': 'Titan Gray' });
+    expect(getColorName('5f6367')).toBe('Titan Gray');
+    expect(getColorName('5F6367')).toBe('Titan Gray');
   });
   });
 
 
-  it('falls back to HSL for unknown hex colors', () => {
-    // A hex that is not in the Bambu database
+  it('falls back to HSL when hex is not in the runtime catalog', () => {
+    // No catalog entry for 123456; HSL bucketing puts it in Blue.
     expect(getColorName('123456')).toBe('Blue');
     expect(getColorName('123456')).toBe('Blue');
   });
   });
 
 
@@ -45,11 +49,30 @@ describe('getColorName', () => {
   });
   });
 
 
   it('handles hex with # prefix', () => {
   it('handles hex with # prefix', () => {
+    setColorCatalog({ '5f6367': 'Titan Gray' });
     expect(getColorName('#5f6367')).toBe('Titan Gray');
     expect(getColorName('#5f6367')).toBe('Titan Gray');
   });
   });
+
+  it('normalizes catalog keys (strips # and lowercases)', () => {
+    // Provider can pass keys in any case / with or without '#'; the utility
+    // must normalize so lookups succeed regardless of input shape.
+    setColorCatalog({ '#F5B6CD': 'Cherry Pink' });
+    expect(getColorName('F5B6CD')).toBe('Cherry Pink');
+    expect(getColorName('f5b6cd')).toBe('Cherry Pink');
+  });
+
+  it('resolves #857 regression — A17-R1 / F5B6CD is Cherry Pink, not Scarlet Red', () => {
+    setColorCatalog({ 'f5b6cd': 'Cherry Pink' });
+    expect(getColorName('F5B6CDFF')).toBe('Cherry Pink');
+  });
 });
 });
 
 
 describe('resolveSpoolColorName', () => {
 describe('resolveSpoolColorName', () => {
+  beforeEach(() => {
+    __resetColorCatalogForTests();
+    setColorCatalog({ '5f6367': 'Titan Gray' });
+  });
+
   it('returns readable color name directly', () => {
   it('returns readable color name directly', () => {
     expect(resolveSpoolColorName('Titan Gray', '5F6367FF')).toBe('Titan Gray');
     expect(resolveSpoolColorName('Titan Gray', '5F6367FF')).toBe('Titan Gray');
   });
   });

+ 2 - 0
frontend/src/api/client.ts

@@ -3853,6 +3853,8 @@ export const api = {
     request<{ status: string }>('/inventory/catalog/reset', { method: 'POST' }),
     request<{ status: string }>('/inventory/catalog/reset', { method: 'POST' }),
   getColorCatalog: () =>
   getColorCatalog: () =>
     request<ColorCatalogEntry[]>('/inventory/colors'),
     request<ColorCatalogEntry[]>('/inventory/colors'),
+  getColorNameMap: () =>
+    request<{ colors: Record<string, string> }>('/inventory/colors/map'),
   addColorEntry: (data: { manufacturer: string; color_name: string; hex_color: string; material: string | null }) =>
   addColorEntry: (data: { manufacturer: string; color_name: string; hex_color: string; material: string | null }) =>
     request<ColorCatalogEntry>('/inventory/colors', { method: 'POST', body: JSON.stringify(data) }),
     request<ColorCatalogEntry>('/inventory/colors', { method: 'POST', body: JSON.stringify(data) }),
   updateColorEntry: (id: number, data: { manufacturer: string; color_name: string; hex_color: string; material: string | null }) =>
   updateColorEntry: (id: number, data: { manufacturer: string; color_name: string; hex_color: string; material: string | null }) =>

+ 6 - 0
frontend/src/components/Layout.tsx

@@ -9,6 +9,7 @@ import { useQuery, useQueries } from '@tanstack/react-query';
 import { api, supportApi, pendingUploadsApi, type Permission } from '../api/client';
 import { api, supportApi, pendingUploadsApi, type Permission } from '../api/client';
 import { getIconByName } from './IconPicker';
 import { getIconByName } from './IconPicker';
 import { useIsSidebarCompact } from '../hooks/useIsSidebarCompact';
 import { useIsSidebarCompact } from '../hooks/useIsSidebarCompact';
+import { useColorCatalogVersion } from '../hooks/useColorCatalogVersion';
 import { useAuth } from '../contexts/AuthContext';
 import { useAuth } from '../contexts/AuthContext';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 import { Card, CardHeader, CardContent } from './Card';
 import { Card, CardHeader, CardContent } from './Card';
@@ -79,6 +80,11 @@ export function Layout() {
   const { mode, toggleMode } = useTheme();
   const { mode, toggleMode } = useTheme();
   const { t } = useTranslation();
   const { t } = useTranslation();
   const isSidebarCompact = useIsSidebarCompact();
   const isSidebarCompact = useIsSidebarCompact();
+  // Re-render Layout (and the page rendered inside <Outlet />) whenever the
+  // backend color catalog is (re)populated, so pages that mounted before the
+  // catalog fetched — and cached HSL-fallback color names during their first
+  // render — refresh with the real catalog names. See #857.
+  useColorCatalogVersion();
   const { user, authEnabled, logout, hasPermission } = useAuth();
   const { user, authEnabled, logout, hasPermission } = useAuth();
   const { showToast } = useToast();
   const { showToast } = useToast();
   const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);
   const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);

+ 5 - 0
frontend/src/components/spoolbuddy/SpoolBuddyLayout.tsx

@@ -7,10 +7,15 @@ import { SpoolBuddyBottomNav } from './SpoolBuddyBottomNav';
 import { SpoolBuddyStatusBar } from './SpoolBuddyStatusBar';
 import { SpoolBuddyStatusBar } from './SpoolBuddyStatusBar';
 import { SpoolBuddyQuickMenu } from './SpoolBuddyQuickMenu';
 import { SpoolBuddyQuickMenu } from './SpoolBuddyQuickMenu';
 import { useSpoolBuddyState } from '../../hooks/useSpoolBuddyState';
 import { useSpoolBuddyState } from '../../hooks/useSpoolBuddyState';
+import { useColorCatalogVersion } from '../../hooks/useColorCatalogVersion';
 import { api, spoolbuddyApi, type Printer, type PrinterStatus } from '../../api/client';
 import { api, spoolbuddyApi, type Printer, type PrinterStatus } from '../../api/client';
 import { VirtualKeyboard } from '../VirtualKeyboard';
 import { VirtualKeyboard } from '../VirtualKeyboard';
 
 
 export function SpoolBuddyLayout() {
 export function SpoolBuddyLayout() {
+  // Cascade a re-render into all SpoolBuddy pages when the color catalog
+  // loads, for the same reason as the main Layout — SpoolBuddyInventoryPage
+  // renders spool color names on mount. See #857.
+  useColorCatalogVersion();
   const [selectedPrinterId, setSelectedPrinterId] = useState<number | null>(null);
   const [selectedPrinterId, setSelectedPrinterId] = useState<number | null>(null);
   const [alert, setAlert] = useState<{ type: 'warning' | 'error' | 'info'; message: string } | null>(null);
   const [alert, setAlert] = useState<{ type: 'warning' | 'error' | 'info'; message: string } | null>(null);
   const [blanked, setBlanked] = useState(false);
   const [blanked, setBlanked] = useState(false);

+ 45 - 0
frontend/src/contexts/ColorCatalogContext.tsx

@@ -0,0 +1,45 @@
+import { useEffect, type ReactNode } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import { api } from '../api/client';
+import { setColorCatalog } from '../utils/colors';
+import { useAuth } from './AuthContext';
+
+/**
+ * Loads the backend color catalog once per session and pushes it into
+ * utils/colors.ts so getColorName/resolveSpoolColorName can do synchronous
+ * lookups from render paths (JSX `title={...}`, table cells, etc.) without
+ * threading a hook through every call site.
+ *
+ * Gated on authentication state because the backend endpoint requires a valid
+ * session when auth is enabled — firing before login would just 401 and retry.
+ */
+export function ColorCatalogProvider({ children }: { children: ReactNode }) {
+  const { authEnabled, user, loading: authLoading } = useAuth();
+
+  // Fire when auth state is resolved AND we're actually allowed to hit the API.
+  // When auth is disabled, `user` is null but the endpoint accepts anyone —
+  // only gate on `!authLoading`. When auth is enabled, we need a logged-in user
+  // or the request 401s and gets retried in a loop.
+  const enabled = !authLoading && (!authEnabled || user !== null);
+
+  const { data } = useQuery({
+    queryKey: ['color-catalog-map'],
+    queryFn: async () => {
+      const response = await api.getColorNameMap();
+      return response.colors;
+    },
+    // Catalog rarely changes during a session; no background refetch needed.
+    staleTime: Infinity,
+    gcTime: Infinity,
+    retry: 2,
+    enabled,
+  });
+
+  useEffect(() => {
+    if (data) {
+      setColorCatalog(data);
+    }
+  }, [data]);
+
+  return <>{children}</>;
+}

+ 20 - 0
frontend/src/hooks/useColorCatalogVersion.ts

@@ -0,0 +1,20 @@
+import { useSyncExternalStore } from 'react';
+import { subscribeColorCatalog, getColorCatalogVersion } from '../utils/colors';
+
+/**
+ * Subscribe to color-catalog updates. Returns the current catalog version —
+ * the value itself is opaque; what matters is that calling components re-render
+ * when the catalog is (re)populated by ColorCatalogProvider.
+ *
+ * Use this in a high-level component (Layout) so that pages which cache color
+ * names during render (via getColorName) refresh when the backend catalog
+ * finishes loading after the first paint.
+ */
+export function useColorCatalogVersion(): number {
+  return useSyncExternalStore(
+    subscribeColorCatalog,
+    getColorCatalogVersion,
+    // SSR snapshot — we never SSR, but useSyncExternalStore requires the param.
+    getColorCatalogVersion,
+  );
+}

+ 5 - 264
frontend/src/pages/PrintersPage.tsx

@@ -79,251 +79,8 @@ import { getPrinterImage, getWifiStrength, filterCompatibleQueueItems } from '..
 import { FilamentSlotCircle } from '../components/FilamentSlotCircle';
 import { FilamentSlotCircle } from '../components/FilamentSlotCircle';
 import { getColorName, parseFilamentColor, isLightColor } from '../utils/colors';
 import { getColorName, parseFilamentColor, isLightColor } from '../utils/colors';
 
 
-// Complete Bambu Lab filament color mapping by tray_id_name
-// Source: https://github.com/queengooborg/Bambu-Lab-RFID-Library
-const BAMBU_FILAMENT_COLORS: Record<string, string> = {
-  // PLA Basic (A00)
-  'A00-W1': 'Jade White',
-  'A00-P0': 'Beige',
-  'A00-D2': 'Light Gray',
-  'A00-Y0': 'Yellow',
-  'A00-Y2': 'Sunflower Yellow',
-  'A00-A1': 'Pumpkin Orange',
-  'A00-A0': 'Orange',
-  'A00-Y4': 'Gold',
-  'A00-G3': 'Bright Green',
-  'A00-G1': 'Bambu Green',
-  'A00-G2': 'Mistletoe Green',
-  'A00-R3': 'Hot Pink',
-  'A00-P6': 'Magenta',
-  'A00-R0': 'Red',
-  'A00-R2': 'Maroon Red',
-  'A00-P5': 'Purple',
-  'A00-P2': 'Indigo Purple',
-  'A00-B5': 'Turquoise',
-  'A00-B8': 'Cyan',
-  'A00-B3': 'Cobalt Blue',
-  'A00-N0': 'Brown',
-  'A00-N1': 'Cocoa Brown',
-  'A00-Y3': 'Bronze',
-  'A00-D0': 'Gray',
-  'A00-D1': 'Silver',
-  'A00-B1': 'Blue Grey',
-  'A00-D3': 'Dark Gray',
-  'A00-K0': 'Black',
-  // PLA Basic Gradient (A00-M*)
-  'A00-M3': 'Pink Citrus',
-  'A00-M6': 'Dusk Glare',
-  'A00-M0': 'Arctic Whisper',
-  'A00-M1': 'Solar Breeze',
-  'A00-M5': 'Blueberry Bubblegum',
-  'A00-M4': 'Mint Lime',
-  'A00-M2': 'Ocean to Meadow',
-  'A00-M7': 'Cotton Candy Cloud',
-  // PLA Lite (A18)
-  'A18-K0': 'Black',
-  'A18-D0': 'Gray',
-  'A18-W0': 'White',
-  'A18-R0': 'Red',
-  'A18-Y0': 'Yellow',
-  'A18-B0': 'Cyan',
-  'A18-B1': 'Blue',
-  'A18-P0': 'Matte Beige',
-  // PLA Matte (A01)
-  'A01-W2': 'Ivory White',
-  'A01-W3': 'Bone White',
-  'A01-Y2': 'Lemon Yellow',
-  'A01-A2': 'Mandarin Orange',
-  'A01-P3': 'Sakura Pink',
-  'A01-P4': 'Lilac Purple',
-  'A01-R3': 'Plum',
-  'A01-R1': 'Scarlet Red',
-  'A01-R4': 'Dark Red',
-  'A01-G0': 'Apple Green',
-  'A01-G1': 'Grass Green',
-  'A01-G7': 'Dark Green',
-  'A01-B4': 'Ice Blue',
-  'A01-B0': 'Sky Blue',
-  'A01-B3': 'Marine Blue',
-  'A01-B6': 'Dark Blue',
-  'A01-Y3': 'Desert Tan',
-  'A01-N1': 'Latte Brown',
-  'A01-N3': 'Caramel',
-  'A01-R2': 'Terracotta',
-  'A01-N2': 'Dark Brown',
-  'A01-N0': 'Dark Chocolate',
-  'A01-D3': 'Ash Gray',
-  'A01-D0': 'Nardo Gray',
-  'A01-K1': 'Charcoal',
-  // PLA Glow (A12)
-  'A12-G0': 'Green',
-  'A12-R0': 'Pink',
-  'A12-A0': 'Orange',
-  'A12-Y0': 'Yellow',
-  'A12-B0': 'Blue',
-  // PLA Marble (A07)
-  'A07-R5': 'Red Granite',
-  'A07-D4': 'White Marble',
-  // PLA Aero (A11)
-  'A11-W0': 'White',
-  'A11-K0': 'Black',
-  // PLA Sparkle (A08)
-  'A08-G3': 'Alpine Green Sparkle',
-  'A08-D5': 'Slate Gray Sparkle',
-  'A08-B7': 'Royal Purple Sparkle',
-  'A08-R2': 'Crimson Red Sparkle',
-  'A08-K2': 'Onyx Black Sparkle',
-  'A08-Y1': 'Classic Gold Sparkle',
-  // PLA Metal (A02)
-  'A02-B2': 'Cobalt Blue Metallic',
-  'A02-G2': 'Oxide Green Metallic',
-  'A02-Y1': 'Iridium Gold Metallic',
-  'A02-D2': 'Iron Gray Metallic',
-  // PLA Translucent (A17)
-  'A17-B1': 'Blue',
-  'A17-A0': 'Orange',
-  'A17-P0': 'Purple',
-  // PLA Silk+ (A06)
-  'A06-Y1': 'Gold',
-  'A06-D0': 'Titan Gray',
-  'A06-D1': 'Silver',
-  'A06-W0': 'White',
-  'A06-R0': 'Candy Red',
-  'A06-G0': 'Candy Green',
-  'A06-G1': 'Mint',
-  'A06-B1': 'Blue',
-  'A06-B0': 'Baby Blue',
-  'A06-P0': 'Purple',
-  'A06-R1': 'Rose Gold',
-  'A06-R2': 'Pink',
-  'A06-Y0': 'Champagne',
-  // PLA Silk Multi-Color (A05)
-  'A05-M8': 'Dawn Radiance',
-  'A05-M4': 'Aurora Purple',
-  'A05-M1': 'South Beach',
-  'A05-T3': 'Neon City',
-  'A05-T2': 'Midnight Blaze',
-  'A05-T1': 'Gilded Rose',
-  'A05-T4': 'Blue Hawaii',
-  'A05-T5': 'Velvet Eclipse',
-  // PLA Galaxy (A15)
-  'A15-B0': 'Purple',
-  'A15-G0': 'Green',
-  'A15-G1': 'Nebulae',
-  'A15-R0': 'Brown',
-  // PLA Wood (A16)
-  'A16-K0': 'Black Walnut',
-  'A16-R0': 'Rosewood',
-  'A16-N0': 'Clay Brown',
-  'A16-G0': 'Classic Birch',
-  'A16-W0': 'White Oak',
-  'A16-Y0': 'Ochre Yellow',
-  // PLA-CF (A50)
-  'A50-D6': 'Lava Gray',
-  'A50-K0': 'Black',
-  'A50-B6': 'Royal Blue',
-  // PLA Tough+ (A10)
-  'A10-W0': 'White',
-  'A10-D0': 'Gray',
-  // PLA Tough (A09)
-  'A09-B5': 'Lavender Blue',
-  'A09-B4': 'Light Blue',
-  'A09-A0': 'Orange',
-  'A09-D1': 'Silver',
-  'A09-R3': 'Vermilion Red',
-  'A09-Y0': 'Yellow',
-  // PETG HF (G02)
-  'G02-K0': 'Black',
-  'G02-W0': 'White',
-  'G02-R0': 'Red',
-  'G02-D0': 'Gray',
-  'G02-D1': 'Dark Gray',
-  'G02-Y1': 'Cream',
-  'G02-Y0': 'Yellow',
-  'G02-A0': 'Orange',
-  'G02-N1': 'Peanut Brown',
-  'G02-G1': 'Lime Green',
-  'G02-G0': 'Green',
-  'G02-G2': 'Forest Green',
-  'G02-B1': 'Lake Blue',
-  'G02-B0': 'Blue',
-  // PETG Translucent (G01)
-  'G01-G1': 'Translucent Teal',
-  'G01-B0': 'Translucent Light Blue',
-  'G01-C0': 'Clear',
-  'G01-D0': 'Translucent Gray',
-  'G01-G0': 'Translucent Olive',
-  'G01-N0': 'Translucent Brown',
-  'G01-A0': 'Translucent Orange',
-  'G01-P1': 'Translucent Pink',
-  'G01-P0': 'Translucent Purple',
-  // PETG-CF (G50)
-  'G50-P7': 'Violet Purple',
-  'G50-K0': 'Black',
-  // ABS (B00)
-  'B00-D1': 'Silver',
-  'B00-K0': 'Black',
-  'B00-W0': 'White',
-  'B00-G6': 'Bambu Green',
-  'B00-G7': 'Olive',
-  'B00-Y1': 'Tangerine Yellow',
-  'B00-A0': 'Orange',
-  'B00-R0': 'Red',
-  'B00-B4': 'Azure',
-  'B00-B0': 'Blue',
-  'B00-B6': 'Navy Blue',
-  // ABS-GF (B50)
-  'B50-A0': 'Orange',
-  'B50-K0': 'Black',
-  // ASA (B01)
-  'B01-W0': 'White',
-  'B01-K0': 'Black',
-  'B01-D0': 'Gray',
-  // ASA Aero (B02)
-  'B02-W0': 'White',
-  // PC (C00)
-  'C00-C1': 'Transparent',
-  'C00-C0': 'Clear Black',
-  'C00-K0': 'Black',
-  'C00-W0': 'White',
-  // PC FR (C01)
-  'C01-K0': 'Black',
-  // TPU for AMS (U02)
-  'U02-B0': 'Blue',
-  'U02-D0': 'Gray',
-  'U02-K0': 'Black',
-  // PAHT-CF (N04)
-  'N04-K0': 'Black',
-  // PA6-GF (N08)
-  'N08-K0': 'Black',
-  // Support for PLA/PETG (S02, S05)
-  'S02-W0': 'Nature',
-  'S02-W1': 'White',
-  'S05-C0': 'Black',
-  // Support for ABS (S06)
-  'S06-W0': 'White',
-  // Support for PA/PET (S03)
-  'S03-G1': 'Green',
-  // PVA (S04)
-  'S04-Y0': 'Clear',
-};
-
-// Fallback color codes for unknown material prefixes
-const BAMBU_COLOR_CODE_FALLBACK: Record<string, string> = {
-  'W0': 'White', 'W1': 'Jade White', 'W2': 'Ivory White', 'W3': 'Bone White',
-  'Y0': 'Yellow', 'Y1': 'Gold', 'Y2': 'Sunflower Yellow', 'Y3': 'Bronze', 'Y4': 'Gold',
-  'A0': 'Orange', 'A1': 'Pumpkin Orange', 'A2': 'Mandarin Orange',
-  'R0': 'Red', 'R1': 'Scarlet Red', 'R2': 'Maroon Red', 'R3': 'Hot Pink', 'R4': 'Dark Red', 'R5': 'Red Granite',
-  'P0': 'Beige', 'P1': 'Pink', 'P2': 'Indigo Purple', 'P3': 'Sakura Pink', 'P4': 'Lilac Purple', 'P5': 'Purple', 'P6': 'Magenta', 'P7': 'Violet Purple',
-  'B0': 'Blue', 'B1': 'Blue Grey', 'B2': 'Cobalt Blue', 'B3': 'Cobalt Blue', 'B4': 'Ice Blue', 'B5': 'Turquoise', 'B6': 'Navy Blue', 'B7': 'Royal Purple', 'B8': 'Cyan',
-  'G0': 'Green', 'G1': 'Grass Green', 'G2': 'Mistletoe Green', 'G3': 'Bright Green', 'G6': 'Bambu Green', 'G7': 'Dark Green',
-  'N0': 'Brown', 'N1': 'Peanut Brown', 'N2': 'Dark Brown', 'N3': 'Caramel',
-  'D0': 'Gray', 'D1': 'Silver', 'D2': 'Light Gray', 'D3': 'Dark Gray', 'D4': 'White Marble', 'D5': 'Slate Gray', 'D6': 'Lava Gray',
-  'K0': 'Black', 'K1': 'Charcoal', 'K2': 'Onyx Black',
-  'C0': 'Clear Black', 'C1': 'Transparent',
-  'M0': 'Arctic Whisper', 'M1': 'Solar Breeze', 'M2': 'Ocean to Meadow', 'M3': 'Pink Citrus', 'M4': 'Aurora Purple', 'M5': 'Blueberry Bubblegum', 'M6': 'Dusk Glare', 'M7': 'Cotton Candy Cloud', 'M8': 'Dawn Radiance',
-  'T1': 'Gilded Rose', 'T2': 'Midnight Blaze', 'T3': 'Neon City', 'T4': 'Blue Hawaii', 'T5': 'Velvet Eclipse',
-};
+// Color names resolve via getColorName() which reads the backend color_catalog
+// (loaded once by ColorCatalogProvider). No hardcoded tables here — see #857.
 
 
 // Extract plate number from gcode_file path and append to print name
 // Extract plate number from gcode_file path and append to print name
 function formatPrintName(name: string | null, gcodeFile: string | null | undefined, t: (key: string, fallback: string, opts?: Record<string, unknown>) => string): string {
 function formatPrintName(name: string | null, gcodeFile: string | null | undefined, t: (key: string, fallback: string, opts?: Record<string, unknown>) => string): string {
@@ -336,22 +93,6 @@ function formatPrintName(name: string | null, gcodeFile: string | null | undefin
   return name;
   return name;
 }
 }
 
 
-// Get color name from Bambu Lab tray_id_name (e.g., "A00-Y2" -> "Sunflower Yellow")
-function getBambuColorName(trayIdName: string | null | undefined): string | null {
-  if (!trayIdName) return null;
-
-  // First try exact match with full tray_id_name
-  if (BAMBU_FILAMENT_COLORS[trayIdName]) {
-    return BAMBU_FILAMENT_COLORS[trayIdName];
-  }
-
-  // Fall back to color code suffix lookup for unknown material prefixes
-  const parts = trayIdName.split('-');
-  if (parts.length < 2) return null;
-  const colorCode = parts[1];
-  return BAMBU_COLOR_CODE_FALLBACK[colorCode] || null;
-}
-
 // Format K value with 3 decimal places, default to 0.020 if null
 // Format K value with 3 decimal places, default to 0.020 if null
 function formatKValue(k: number | null | undefined): string {
 function formatKValue(k: number | null | undefined): string {
   const value = k ?? 0.020;
   const value = k ?? 0.020;
@@ -3315,7 +3056,7 @@ function PrinterCard({
                                 const filamentData = tray?.tray_type ? {
                                 const filamentData = tray?.tray_type ? {
                                   vendor: (isBambuLabSpool(tray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
                                   vendor: (isBambuLabSpool(tray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
                                   profile: slotPreset?.preset_name || cloudInfo?.name || inventoryAssignment?.spool?.slicer_filament_name || tray.tray_sub_brands || tray.tray_type,
                                   profile: slotPreset?.preset_name || cloudInfo?.name || inventoryAssignment?.spool?.slicer_filament_name || tray.tray_sub_brands || tray.tray_type,
-                                  colorName: getBambuColorName(tray.tray_id_name) || getColorName(tray.tray_color || ''),
+                                  colorName: getColorName(tray.tray_color || ''),
                                   colorHex: tray.tray_color || null,
                                   colorHex: tray.tray_color || null,
                                   kFactor: formatKValue(tray.k),
                                   kFactor: formatKValue(tray.k),
                                   fillLevel: effectiveFill,
                                   fillLevel: effectiveFill,
@@ -3557,7 +3298,7 @@ function PrinterCard({
                         const filamentData = tray?.tray_type ? {
                         const filamentData = tray?.tray_type ? {
                           vendor: (isBambuLabSpool(tray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
                           vendor: (isBambuLabSpool(tray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
                           profile: slotPreset?.preset_name || cloudInfo?.name || htInventoryAssignment?.spool?.slicer_filament_name || tray.tray_sub_brands || tray.tray_type,
                           profile: slotPreset?.preset_name || cloudInfo?.name || htInventoryAssignment?.spool?.slicer_filament_name || tray.tray_sub_brands || tray.tray_type,
-                          colorName: getBambuColorName(tray.tray_id_name) || getColorName(tray.tray_color || ''),
+                          colorName: getColorName(tray.tray_color || ''),
                           colorHex: tray.tray_color || null,
                           colorHex: tray.tray_color || null,
                           kFactor: formatKValue(tray.k),
                           kFactor: formatKValue(tray.k),
                           fillLevel: htEffectiveFill,
                           fillLevel: htEffectiveFill,
@@ -3901,7 +3642,7 @@ function PrinterCard({
                               const extFilamentData = {
                               const extFilamentData = {
                                 vendor: (isBambuLabSpool(extTray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
                                 vendor: (isBambuLabSpool(extTray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
                                 profile: extSlotPreset?.preset_name || extCloudInfo?.name || extInventoryAssignment?.spool?.slicer_filament_name || extTray.tray_sub_brands || extTray.tray_type || 'Unknown',
                                 profile: extSlotPreset?.preset_name || extCloudInfo?.name || extInventoryAssignment?.spool?.slicer_filament_name || extTray.tray_sub_brands || extTray.tray_type || 'Unknown',
-                                colorName: getBambuColorName(extTray.tray_id_name) || getColorName(extTray.tray_color || ''),
+                                colorName: getColorName(extTray.tray_color || ''),
                                 colorHex: extTray.tray_color || null,
                                 colorHex: extTray.tray_color || null,
                                 kFactor: formatKValue(extTray.k),
                                 kFactor: formatKValue(extTray.k),
                                 fillLevel: extEffectiveFill,
                                 fillLevel: extEffectiveFill,

+ 61 - 60
frontend/src/utils/colors.ts

@@ -1,56 +1,57 @@
-// Bambu Lab filament hex color to name mapping (from bambu-color-names.csv)
-const BAMBU_HEX_COLORS: Record<string, string> = {
-  '000000': 'Black', '001489': 'Blue', '002e96': 'Blue', '0047bb': 'Blue', '00482b': 'Pine Green',
-  '004ea8': 'Blue', '0056b8': 'Cobalt Blue', '0069b1': 'Lake Blue', '0072ce': 'Blue', '0078bf': 'Marine Blue',
-  '0085ad': 'Light Blue', '0086d6': 'Cyan', '008bda': 'Blue', '009639': 'Green', '009bd8': 'Cyan',
-  '009fa1': 'Teal', '00a6a0': 'Green', '00ae42': 'Bambu Green', '00b1b7': 'Turquoise', '00bb31': 'Green',
-  '018814': 'Candy Green', '042f56': 'Dark Blue', '0a2989': 'Blue', '0a2ca5': 'Blue', '0c2340': 'Navy Blue',
-  '0c3b95': 'Blue', '101820': 'Black', '147bd1': 'Blue', '164b35': 'Green', '16b08e': 'Malachite Green',
-  '1d7c6a': 'Oxide Green Metallic', '1f79e5': 'Lake Blue', '2140b4': 'Blue', '25282a': 'Black', '2842ad': 'Royal Blue',
-  '2d2b28': 'Onyx Black Sparkle', '324585': 'Indigo Blue', '353533': 'Gray', '39541a': 'Forest Green',
-  '39699e': 'Cobalt Blue Metallic', '3b665e': 'Green', '3f5443': 'Alpine Green Sparkle', '3f8e43': 'Mistletoe Green',
-  '424379': 'Nebulae', '43403d': 'Iron Gray Metallic', '482960': 'Indigo Purple', '483d8b': 'Royal Purple Sparkle',
-  '489fdf': 'Azure', '4c241c': 'Rosewood', '4ce4a0': 'Green', '4d3324': 'Dark Chocolate', '4d5054': 'Lava Gray',
-  '4dafda': 'Cyan', '4f3f24': 'Black Walnut', '515151': 'Dark Gray', '515a6c': 'Gray', '545454': 'Dark Gray',
-  '565656': 'Titan Gray', '56b7e6': 'Sky Blue', '583061': 'Violet Purple', '5898dd': 'Blue', '594177': 'Purple',
-  '5b492f': 'Brown', '5b6579': 'Blue Gray', '5c9748': 'Matcha Green', '5e43b7': 'Purple', '5e4b3c': 'Copper',
-  '5f6367': 'Titan Gray', '61b0ff': 'Translucent Light Blue', '61bf36': 'Green', '61c680': 'Grass Green',
-  '6667ab': 'Lavender Blue', '684a43': 'Brown', '686865': 'Black', '68724d': 'Dark Green', '688197': 'Blue Gray',
-  '69398e': 'Iris Purple', '6e88bc': 'Jeans Blue', '6ee53c': 'Lime Green', '6f5034': 'Cocoa Brown', '7248bd': 'Lavender',
-  '748c45': 'Translucent Olive', '757575': 'Nardo Gray', '75aed8': 'Blue', '77edd7': 'Translucent Teal', '789d4a': 'Olive',
-  '792b36': 'Crimson Red Sparkle', '7ac0e9': 'Glow Blue', '7ae1bf': 'Mint', '7cd82b': 'Lime Green', '7d6556': 'Dark Brown',
-  '8344b0': 'Purple', '847d48': 'Bronze', '854ce4': 'Purple', '8671cb': 'Purple', '875718': 'Peanut Brown',
-  '87909a': 'Silver', '898d8d': 'Gray', '8a949e': 'Gray', '8e8e8e': 'Translucent Gray', '8e9089': 'Gray',
-  '90ff1a': 'Neon Green', '918669': 'Classic Birch', '939393': 'Gray', '950051': 'Plum', '951e23': 'Burgundy Red',
-  '959698': 'Silver', '96d8af': 'Light Jade', '96dcb9': 'Mint', '995f11': 'Clay Brown', '999d9d': 'Gray',
-  '9b9ea0': 'Ash Gray', '9d2235': 'Maroon Red', '9d432c': 'Brown', '9e007e': 'Purple', '9ea2a2': 'Gray',
-  '9f332a': 'Brick Red', 'a1ffac': 'Glow Green', 'a3d8e1': 'Ice Blue', 'a6a9aa': 'Silver', 'a8a8aa': 'Gray',
-  'a8c6ee': 'Baby Blue', 'aa6443': 'Copper Brown Metallic', 'ad4e38': 'Red Granite', 'adb1b2': 'Gray',
-  'ae835b': 'Caramel', 'ae96d4': 'Lilac Purple', 'af1685': 'Purple', 'afb1ae': 'Gray', 'b15533': 'Terracotta',
-  'b28b33': 'Gold', 'b39b84': 'Iridium Gold Metallic', 'b50011': 'Red', 'b8acd6': 'Lavender', 'b8cde9': 'Ice Blue',
-  'ba9594': 'Rose Gold', 'bb3d43': 'Dark Red', 'bc0900': 'Red', 'becf00': 'Bright Green', 'c0df16': 'Green',
-  'c12e1f': 'Red', 'c2e189': 'Apple Green', 'c3e2d6': 'Light Cyan', 'c5ed48': 'Lime', 'c6001a': 'Red',
-  'c6c6c6': 'Gray', 'c8102e': 'Red', 'c8c8c8': 'Silver', 'c98935': 'Ochre Yellow', 'c9a381': 'Translucent Brown',
-  'cbc6b8': 'Bone White', 'cdceca': 'Gray', 'cea629': 'Classic Gold Sparkle', 'd02727': 'Candy Red',
-  'd1d3d5': 'Light Gray', 'd32941': 'Red', 'd3b7a7': 'Latte Brown', 'd6001c': 'Red', 'd6abff': 'Translucent Purple',
-  'd6cca3': 'White Oak', 'dc3a27': 'Orange', 'dd3c22': 'Vermilion Red', 'de4343': 'Scarlet Red', 'dfd1a7': 'Beige',
-  'e02928': 'Red', 'e4bd68': 'Gold', 'e5b03d': 'Gold', 'e83100': 'Red', 'e8afcf': 'Sakura Pink', 'e8dbb7': 'Desert Tan',
-  'eaeae4': 'White', 'eaeceb': 'Silver', 'ec008c': 'Magenta', 'ed0000': 'Red', 'eeb1c1': 'Pink', 'efe255': 'Yellow',
-  'f0f1a8': 'Clear', 'f17b8f': 'Glow Pink', 'f3cfb2': 'Champagne', 'f3e600': 'Yellow', 'f48438': 'Orange',
-  'f4a925': 'Gold', 'f4d53f': 'Yellow', 'f4ee2a': 'Yellow', 'f5547c': 'Hot Pink', 'f55a74': 'Pink',
-  'f5b6cd': 'Cherry Pink', 'f5dbab': 'Mellow Yellow', 'f5f1dd': 'White', 'f68b1b': 'Neon Orange', 'f74e02': 'Orange',
-  'f75403': 'Orange', 'f7ada6': 'Pink', 'f7d959': 'Lemon Yellow', 'f7e6de': 'Beige', 'f7f3f0': 'White Marble',
-  'f8ff80': 'Glow Yellow', 'f99963': 'Mandarin Orange', 'f9c1bd': 'Translucent Pink', 'f9dfb9': 'Cream',
-  'f9ef41': 'Yellow', 'f9f7f2': 'Nature', 'f9f7f4': 'White', 'fce300': 'Yellow', 'fce900': 'Yellow',
-  'fec600': 'Sunflower Yellow', 'fedb00': 'Yellow', 'ff4800': 'Orange', 'ff671f': 'Orange', 'ff6a13': 'Orange',
-  'ff7f41': 'Orange', 'ff9016': 'Pumpkin Orange', 'ff911a': 'Translucent Orange', 'ff9d5b': 'Glow Orange',
-  'ffb549': 'Sunflower Yellow', 'ffc72c': 'Tangerine Yellow', 'ffce00': 'Yellow', 'ffd00b': 'Yellow',
-  'ffe133': 'Yellow', 'fffaf2': 'White', 'ffffff': 'White',
-};
+// Runtime color-name catalog, populated once at app startup by ColorCatalogProvider
+// from /api/inventory/colors/map. The backend color_catalog table is the single
+// source of truth — no hardcoded hex→name tables live on the frontend anymore.
+//
+// Keyed by lowercase 6-char hex (no leading '#'). Lookups before the provider has
+// fetched the catalog fall through to hexToColorName (HSL-based bucketing). A
+// subscribe/getSnapshot pair lets React components re-render via
+// useSyncExternalStore when the catalog loads, so pages that mount before the
+// fetch resolves (InventoryPage, PrintersPage) update to the catalog name once it
+// arrives instead of staying stuck on the HSL fallback.
+
+let runtimeColorCatalog: Record<string, string> = {};
+let catalogVersion = 0;
+const catalogListeners = new Set<() => void>();
+
+export function setColorCatalog(map: Record<string, string>): void {
+  // Normalize keys to lowercase 6-char hex (no '#'), defensively. Backend already
+  // does this, but the frontend contract is explicit so callers from tests or
+  // future integrations can't accidentally break lookups.
+  const normalized: Record<string, string> = {};
+  for (const [key, value] of Object.entries(map)) {
+    if (!key || !value) continue;
+    const hex = key.replace('#', '').toLowerCase().slice(0, 6);
+    if (hex.length === 6) normalized[hex] = value;
+  }
+  runtimeColorCatalog = normalized;
+  catalogVersion += 1;
+  // Snapshot listeners to avoid mutation-during-iteration if a listener unsubscribes.
+  for (const listener of Array.from(catalogListeners)) {
+    listener();
+  }
+}
+
+export function subscribeColorCatalog(listener: () => void): () => void {
+  catalogListeners.add(listener);
+  return () => {
+    catalogListeners.delete(listener);
+  };
+}
+
+export function getColorCatalogVersion(): number {
+  return catalogVersion;
+}
+
+/** Test-only hook: reset the catalog to empty so unit tests can exercise fallbacks. */
+export function __resetColorCatalogForTests(): void {
+  runtimeColorCatalog = {};
+  catalogVersion = 0;
+  catalogListeners.clear();
+}
 
 
 /**
 /**
  * Convert hex color to basic color name using HSL analysis.
  * Convert hex color to basic color name using HSL analysis.
- * Used as fallback when hex is not in Bambu database.
+ * Used as fallback when hex is not in the runtime catalog.
  */
  */
 export function hexToColorName(hex: string | null | undefined): string {
 export function hexToColorName(hex: string | null | undefined): string {
   if (!hex || hex.length < 6) return 'Unknown';
   if (!hex || hex.length < 6) return 'Unknown';
@@ -98,32 +99,32 @@ export function hexToColorName(hex: string | null | undefined): string {
 
 
 /**
 /**
  * Get color name from hex color.
  * Get color name from hex color.
- * First tries Bambu Lab color database lookup, then falls back to HSL-based name.
+ * Looks up the runtime color catalog (backend-sourced), then falls back to HSL.
  */
  */
 export function getColorName(hexColor: string): string {
 export function getColorName(hexColor: string): string {
+  if (!hexColor) return hexToColorName(hexColor);
   const hex = hexColor.replace('#', '').toLowerCase().substring(0, 6);
   const hex = hexColor.replace('#', '').toLowerCase().substring(0, 6);
-  if (BAMBU_HEX_COLORS[hex]) {
-    return BAMBU_HEX_COLORS[hex];
-  }
+  const mapped = runtimeColorCatalog[hex];
+  if (mapped) return mapped;
   return hexToColorName(hexColor);
   return hexToColorName(hexColor);
 }
 }
 
 
 /**
 /**
  * Resolve a spool's display color name.
  * Resolve a spool's display color name.
- * Tries: stored color_name (if it's a readable name) → hex color database → HSL fallback.
- * Detects Bambu internal codes (e.g. "A06-D0") and resolves them to names ("Titan Gray").
+ * Tries: stored color_name (if it's a readable name) → runtime catalog via rgba → null.
+ * Detects Bambu internal codes (e.g. "A06-D0") and ignores them in favor of hex lookup
+ * because the same code is not globally unique across material families (#857).
  */
  */
 export function resolveSpoolColorName(colorName: string | null, rgba: string | null): string | null {
 export function resolveSpoolColorName(colorName: string | null, rgba: string | null): string | null {
   // If color_name looks like a readable name (no pattern like "X00-Y0"), use it directly
   // If color_name looks like a readable name (no pattern like "X00-Y0"), use it directly
   if (colorName && !/^[A-Z]\d+-[A-Z]\d+$/.test(colorName)) {
   if (colorName && !/^[A-Z]\d+-[A-Z]\d+$/.test(colorName)) {
     return colorName;
     return colorName;
   }
   }
-  // Try hex color lookup from rgba
+  // Try hex color lookup from rgba via the runtime catalog
   if (rgba && rgba.length >= 6) {
   if (rgba && rgba.length >= 6) {
     const hex = rgba.substring(0, 6).toLowerCase();
     const hex = rgba.substring(0, 6).toLowerCase();
-    if (BAMBU_HEX_COLORS[hex]) {
-      return BAMBU_HEX_COLORS[hex];
-    }
+    const mapped = runtimeColorCatalog[hex];
+    if (mapped) return mapped;
   }
   }
   // Return null (displayed as "-") — better than showing a code
   // Return null (displayed as "-") — better than showing a code
   return null;
   return null;

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-Czpqfgna.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DlTZxH8V.js


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-nOTxWdVv.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-Cf7Yar3q.css">
+    <script type="module" crossorigin src="/assets/index-DlTZxH8V.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-Czpqfgna.css">
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

Some files were not shown because too many files changed in this diff