Procházet zdrojové kódy

feat(inventory): multi-colour gradients, transparency, visual effects (#1154)

  Spool and color_catalog rows carry extra_colors (comma-separated hex
  stops) and effect_type (14 visual variants: surface effects, sheen,
  structural). The shared FilamentSwatch component renders gradient,
  conic, effect overlay, and alpha-checkerboard consistently across the
  inventory grid, table, group banner, card, ColorSection preview, and
  catalog editor. Catalog hex_color accepts #RRGGBBAA so catalog entries
  can carry transparency too.

  The paste field accepts the exact format 3dfilamentprofiles.com puts on
  its filament details pages, so users can copy a multi-colour combo
  directly. The effect dropdown spans the full filament-variant
  vocabulary -- surface effects (sparkle/wood/marble/glow/matte), sheen
  variants (silk/galaxy/rainbow/metal/translucent), and structural
  variants (gradient/dual-color/tri-color/multicolor). None of these
  fields touch MQTT/firmware -- pure visual hint.

  Spool group-key extended to include extra_colors + effect_type so
  "Group similar" no longer collapses visually distinct spools.

  Migrations: 4 idempotent ALTER TABLE ADD COLUMN (Postgres-safe), plus
  ALTER COLUMN hex_color TYPE VARCHAR(9) on Postgres only (SQLite ignores
  VARCHAR length).

  Tests: 42 new backend (35 unit + 7 integration), 20 new frontend (14
  FilamentSwatch + 3 ColorCatalogSettings + 3 InventoryPageGrouping
  regression). 3522 backend + 1582 frontend tests pass; ruff clean.
  Localised across all 8 UI locales.
maziggy před 4 týdny
rodič
revize
a34beaa599
34 změnil soubory, kde provedl 1597 přidání a 35 odebrání
  1. 0 0
      CHANGELOG.md
  2. 1 0
      README.md
  3. 38 3
      backend/app/api/routes/inventory.py
  4. 17 0
      backend/app/core/database.py
  5. 4 1
      backend/app/models/color_catalog.py
  6. 8 0
      backend/app/models/spool.py
  7. 101 1
      backend/app/schemas/spool.py
  8. 159 0
      backend/tests/integration/test_color_catalog_extras.py
  9. 210 0
      backend/tests/unit/test_spool_schemas_colors.py
  10. 186 0
      frontend/src/__tests__/components/ColorCatalogSettings.test.tsx
  11. 140 0
      frontend/src/__tests__/components/FilamentSwatch.test.tsx
  12. 2 0
      frontend/src/__tests__/components/SpoolFormBulk.test.tsx
  13. 2 0
      frontend/src/__tests__/components/SpoolFormModal.test.tsx
  14. 29 2
      frontend/src/__tests__/pages/InventoryPageGrouping.test.ts
  15. 26 3
      frontend/src/api/client.ts
  16. 76 7
      frontend/src/components/ColorCatalogSettings.tsx
  17. 83 0
      frontend/src/components/FilamentSwatch.tsx
  18. 4 0
      frontend/src/components/SpoolFormModal.tsx
  19. 175 0
      frontend/src/components/filamentSwatchHelpers.ts
  20. 108 3
      frontend/src/components/spool-form/ColorSection.tsx
  21. 7 0
      frontend/src/components/spool-form/types.ts
  22. 26 0
      frontend/src/i18n/locales/de.ts
  23. 26 0
      frontend/src/i18n/locales/en.ts
  24. 23 0
      frontend/src/i18n/locales/fr.ts
  25. 23 0
      frontend/src/i18n/locales/it.ts
  26. 23 0
      frontend/src/i18n/locales/ja.ts
  27. 23 0
      frontend/src/i18n/locales/pt-BR.ts
  28. 23 0
      frontend/src/i18n/locales/zh-CN.ts
  29. 23 0
      frontend/src/i18n/locales/zh-TW.ts
  30. 26 14
      frontend/src/pages/InventoryPage.tsx
  31. 2 0
      frontend/src/pages/spoolbuddy/SpoolBuddyDashboard.tsx
  32. 2 0
      frontend/src/pages/spoolbuddy/SpoolBuddyWriteTagPage.tsx
  33. 0 0
      static/assets/index-DE-w2t-x.js
  34. 1 1
      static/index.html

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
CHANGELOG.md


+ 1 - 0
README.md

@@ -217,6 +217,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - **Per-spool cost tracking** — Set cost/kg on each spool; costs are automatically calculated at print completion and aggregated to archives. Print modal shows real-time cost preview. Configurable default cost and currency in Settings.
 - **Per-spool cost tracking** — Set cost/kg on each spool; costs are automatically calculated at print completion and aggregated to archives. Print modal shows real-time cost preview. Configurable default cost and currency in Settings.
 - **Bulk spool addition** — Add multiple identical spools at once (quantity 1–100) with a single form submission. Quick Add mode for stock spools that only need material, color, and weight.
 - **Bulk spool addition** — Add multiple identical spools at once (quantity 1–100) with a single form submission. Quick Add mode for stock spools that only need material, color, and weight.
 - Spool catalog, color catalog, PA profile matching, and low-stock alerts
 - Spool catalog, color catalog, PA profile matching, and low-stock alerts
+- **Multi-colour gradients, transparency, and visual effects** — Paste a comma-separated hex list (e.g. from 3dfilamentprofiles.com) to render a spool as a gradient or conic colour wheel; transparency shows through a checkerboard so the alpha you set is the alpha you see; pick a visual effect (sparkle, wood, marble, glow, matte) for the swatch overlay. Same fields are editable on the colour catalog so combos can be reused across spools.
 
 
 ### 🔧 Integrations
 ### 🔧 Integrations
 - [Spoolman](https://github.com/Donkie/Spoolman) filament sync with per-filament usage tracking and fill level display
 - [Spoolman](https://github.com/Donkie/Spoolman) filament sync with per-filament usage tracking and fill level display

+ 38 - 3
backend/app/api/routes/inventory.py

@@ -4,7 +4,7 @@ import logging
 import httpx
 import httpx
 from fastapi import APIRouter, Depends, HTTPException
 from fastapi import APIRouter, Depends, HTTPException
 from fastapi.responses import StreamingResponse
 from fastapi.responses import StreamingResponse
-from pydantic import BaseModel
+from pydantic import BaseModel, Field, field_validator
 from sqlalchemy import func, select
 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
@@ -30,6 +30,8 @@ from backend.app.schemas.spool import (
     SpoolKProfileResponse,
     SpoolKProfileResponse,
     SpoolResponse,
     SpoolResponse,
     SpoolUpdate,
     SpoolUpdate,
+    normalize_effect_type,
+    normalize_extra_colors,
 )
 )
 from backend.app.schemas.spool_usage import SpoolUsageHistoryResponse
 from backend.app.schemas.spool_usage import SpoolUsageHistoryResponse
 from backend.app.utils.filament_ids import filament_id_to_setting_id, normalize_slicer_filament
 from backend.app.utils.filament_ids import filament_id_to_setting_id, normalize_slicer_filament
@@ -95,23 +97,52 @@ class ColorEntryResponse(BaseModel):
     hex_color: str
     hex_color: str
     material: str | None
     material: str | None
     is_default: bool
     is_default: bool
+    extra_colors: str | None = None
+    effect_type: str | None = None
 
 
     class Config:
     class Config:
         from_attributes = True
         from_attributes = True
 
 
 
 
+_HEX_COLOR_PATTERN = r"^#[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$"
+
+
 class ColorEntryCreate(BaseModel):
 class ColorEntryCreate(BaseModel):
     manufacturer: str
     manufacturer: str
     color_name: str
     color_name: str
-    hex_color: str
+    hex_color: str = Field(..., pattern=_HEX_COLOR_PATTERN)
     material: str | None = None
     material: str | None = None
+    extra_colors: str | None = None
+    effect_type: str | None = None
+
+    @field_validator("extra_colors")
+    @classmethod
+    def _validate_extra_colors(cls, v: str | None) -> str | None:
+        return normalize_extra_colors(v)
+
+    @field_validator("effect_type")
+    @classmethod
+    def _validate_effect_type(cls, v: str | None) -> str | None:
+        return normalize_effect_type(v)
 
 
 
 
 class ColorEntryUpdate(BaseModel):
 class ColorEntryUpdate(BaseModel):
     manufacturer: str
     manufacturer: str
     color_name: str
     color_name: str
-    hex_color: str
+    hex_color: str = Field(..., pattern=_HEX_COLOR_PATTERN)
     material: str | None = None
     material: str | None = None
+    extra_colors: str | None = None
+    effect_type: str | None = None
+
+    @field_validator("extra_colors")
+    @classmethod
+    def _validate_extra_colors(cls, v: str | None) -> str | None:
+        return normalize_extra_colors(v)
+
+    @field_validator("effect_type")
+    @classmethod
+    def _validate_effect_type(cls, v: str | None) -> str | None:
+        return normalize_effect_type(v)
 
 
 
 
 class ColorLookupResult(BaseModel):
 class ColorLookupResult(BaseModel):
@@ -286,6 +317,8 @@ async def add_color_entry(
         hex_color=entry.hex_color,
         hex_color=entry.hex_color,
         material=entry.material,
         material=entry.material,
         is_default=False,
         is_default=False,
+        extra_colors=entry.extra_colors,
+        effect_type=entry.effect_type,
     )
     )
     db.add(row)
     db.add(row)
     await db.commit()
     await db.commit()
@@ -309,6 +342,8 @@ async def update_color_entry(
     row.color_name = entry.color_name
     row.color_name = entry.color_name
     row.hex_color = entry.hex_color
     row.hex_color = entry.hex_color
     row.material = entry.material
     row.material = entry.material
+    row.extra_colors = entry.extra_colors
+    row.effect_type = entry.effect_type
     await db.commit()
     await db.commit()
     await db.refresh(row)
     await db.refresh(row)
     return row
     return row

+ 17 - 0
backend/app/core/database.py

@@ -647,6 +647,15 @@ async def run_migrations(conn):
     await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN url VARCHAR(2048)")
     await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN url VARCHAR(2048)")
     await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN cover_image_filename VARCHAR(255)")
     await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN cover_image_filename VARCHAR(255)")
 
 
+    # Migration: enhanced filament colour handling on color_catalog (#1154).
+    # Mirrors the Spool columns added below; widens hex_color to VARCHAR(9)
+    # so catalog entries can store an alpha component (#RRGGBBAA). SQLite
+    # ignores VARCHAR length, so the widen only matters on PostgreSQL.
+    await _safe_execute(conn, "ALTER TABLE color_catalog ADD COLUMN extra_colors VARCHAR(255)")
+    await _safe_execute(conn, "ALTER TABLE color_catalog ADD COLUMN effect_type VARCHAR(20)")
+    if not is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE color_catalog ALTER COLUMN hex_color TYPE VARCHAR(9)")
+
     # Migration: Make printer_id nullable in print_queue for unassigned queue items
     # Migration: Make printer_id nullable in print_queue for unassigned queue items
     # SQLite doesn't support ALTER COLUMN, so we need to recreate the table
     # SQLite doesn't support ALTER COLUMN, so we need to recreate the table
     # PostgreSQL gets the correct schema from create_all(), so skip this
     # PostgreSQL gets the correct schema from create_all(), so skip this
@@ -1266,6 +1275,14 @@ async def run_migrations(conn):
     # falls back to the global low_stock_threshold setting.
     # falls back to the global low_stock_threshold setting.
     await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN category VARCHAR(50)")
     await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN category VARCHAR(50)")
     await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN low_stock_threshold_pct INTEGER")
     await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN low_stock_threshold_pct INTEGER")
+
+    # Migration: enhanced filament colour handling (#1154). `extra_colors` is
+    # a comma-separated list of 6- or 8-char hex tokens (no `#`) for multi-
+    # colour gradients; `effect_type` is one of {sparkle, wood, marble, glow,
+    # matte} as a visual rendering hint. Both nullable — NULL keeps the
+    # current single-rgba/no-effect behaviour.
+    await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN extra_colors VARCHAR(255)")
+    await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN effect_type VARCHAR(20)")
     # Migration: Add cost field to spool_usage_history table
     # Migration: Add cost field to spool_usage_history table
     await _safe_execute(conn, "ALTER TABLE spool_usage_history ADD COLUMN cost REAL")
     await _safe_execute(conn, "ALTER TABLE spool_usage_history ADD COLUMN cost REAL")
     # Migration: Add archive_id field to spool_usage_history table
     # Migration: Add archive_id field to spool_usage_history table

+ 4 - 1
backend/app/models/color_catalog.py

@@ -14,7 +14,10 @@ class ColorCatalogEntry(Base):
     id: Mapped[int] = mapped_column(primary_key=True)
     id: Mapped[int] = mapped_column(primary_key=True)
     manufacturer: Mapped[str] = mapped_column(String(200))
     manufacturer: Mapped[str] = mapped_column(String(200))
     color_name: Mapped[str] = mapped_column(String(200))
     color_name: Mapped[str] = mapped_column(String(200))
-    hex_color: Mapped[str] = mapped_column(String(7))  # #RRGGBB
+    hex_color: Mapped[str] = mapped_column(String(9))  # #RRGGBB or #RRGGBBAA
     material: Mapped[str | None] = mapped_column(String(100))
     material: Mapped[str | None] = mapped_column(String(100))
     is_default: Mapped[bool] = mapped_column(Boolean, default=False)
     is_default: Mapped[bool] = mapped_column(Boolean, default=False)
+    # Optional multi-colour stops + visual effect (#1154), mirrors Spool fields.
+    extra_colors: Mapped[str | None] = mapped_column(String(255))
+    effect_type: Mapped[str | None] = mapped_column(String(20))
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())

+ 8 - 0
backend/app/models/spool.py

@@ -16,6 +16,14 @@ class Spool(Base):
     subtype: Mapped[str | None] = mapped_column(String(50))  # Basic, Matte, Silk, etc.
     subtype: Mapped[str | None] = mapped_column(String(50))  # Basic, Matte, Silk, etc.
     color_name: Mapped[str | None] = mapped_column(String(100))  # "Jade White"
     color_name: Mapped[str | None] = mapped_column(String(100))  # "Jade White"
     rgba: Mapped[str | None] = mapped_column(String(8))  # RRGGBBAA hex
     rgba: Mapped[str | None] = mapped_column(String(8))  # RRGGBBAA hex
+    # Multi-colour gradient stops for filaments with more than one colour
+    # (e.g. tri-colour, multi-colour). Stored as comma-separated 6- or 8-char
+    # hex tokens without `#`. Empty/NULL means solid (uses `rgba`). Up to 8
+    # stops; combination mode is driven by `subtype` (Gradient, Multicolor).
+    extra_colors: Mapped[str | None] = mapped_column(String(255))
+    # Visual effect overlay independent of subtype: sparkle, wood, marble,
+    # glow, matte. Purely a rendering hint — does not affect MQTT/firmware.
+    effect_type: Mapped[str | None] = mapped_column(String(20))
     brand: Mapped[str | None] = mapped_column(String(100))  # "Polymaker"
     brand: Mapped[str | None] = mapped_column(String(100))  # "Polymaker"
     label_weight: Mapped[int] = mapped_column(Integer, default=1000)  # Advertised net weight (g)
     label_weight: Mapped[int] = mapped_column(Integer, default=1000)  # Advertised net weight (g)
     core_weight: Mapped[int] = mapped_column(Integer, default=250)  # Empty spool weight (g)
     core_weight: Mapped[int] = mapped_column(Integer, default=250)  # Empty spool weight (g)

+ 101 - 1
backend/app/schemas/spool.py

@@ -1,6 +1,80 @@
 from datetime import datetime
 from datetime import datetime
 
 
-from pydantic import BaseModel, Field
+from pydantic import BaseModel, Field, field_validator
+
+# Visual variant applied to a spool's swatch — purely cosmetic, does not
+# affect MQTT/firmware. Kept independent of `subtype` so users can override
+# the rendering hint without touching Bambu's categorical filament label.
+# Mirrors the visual variants the spool form's `KNOWN_VARIANTS` exposes so
+# the catalog and spool form share one vocabulary; structural variants like
+# gradient/dual-color/tri-color/multicolor combine with `extra_colors` for
+# rendering, surface effects (sparkle/wood/marble/glow/matte) layer overlays.
+ALLOWED_EFFECT_TYPES = frozenset(
+    {
+        # Surface effects
+        "sparkle",
+        "wood",
+        "marble",
+        "glow",
+        "matte",
+        # Sheen / finish variants
+        "silk",
+        "galaxy",
+        "rainbow",
+        "metal",
+        "translucent",
+        # Multi-colour structures (drive gradient rendering when paired with extra_colors)
+        "gradient",
+        "dual-color",
+        "tri-color",
+        "multicolor",
+    }
+)
+
+# Cap how many gradient stops we accept on input so a paste of arbitrary text
+# can't blow up the stored value or downstream rendering.
+MAX_EXTRA_COLOR_STOPS = 8
+
+
+def normalize_extra_colors(value: str | None) -> str | None:
+    """Parse comma-separated hex tokens into canonical lowercase form.
+
+    Accepts 6- or 8-char hex per token, with or without leading `#`. Returns
+    None for blank input, raises ValueError for malformed tokens or too many
+    stops. Output is the comma-joined canonical form (no `#`, lowercase).
+    """
+    if value is None:
+        return None
+    raw = value.strip()
+    if not raw:
+        return None
+    tokens = [tok.strip().lstrip("#").lower() for tok in raw.split(",") if tok.strip()]
+    if not tokens:
+        return None
+    if len(tokens) > MAX_EXTRA_COLOR_STOPS:
+        raise ValueError(f"extra_colors accepts at most {MAX_EXTRA_COLOR_STOPS} stops")
+    for tok in tokens:
+        if len(tok) not in (6, 8):
+            raise ValueError(f"extra_colors token '{tok}' must be 6 or 8 hex chars")
+        try:
+            int(tok, 16)
+        except ValueError as exc:
+            raise ValueError(f"extra_colors token '{tok}' is not valid hex") from exc
+    return ",".join(tokens)
+
+
+def normalize_effect_type(value: str | None) -> str | None:
+    if value is None:
+        return None
+    trimmed = value.strip().lower()
+    if not trimmed:
+        return None
+    # Tolerate "Dual Color" / "dual_color" / "dual color" → "dual-color" so
+    # users pasting from spool-subtype labels don't hit a validation wall.
+    canonical = trimmed.replace("_", "-").replace(" ", "-")
+    if canonical not in ALLOWED_EFFECT_TYPES:
+        raise ValueError(f"effect_type must be one of: {sorted(ALLOWED_EFFECT_TYPES)}")
+    return canonical
 
 
 
 
 class SpoolBase(BaseModel):
 class SpoolBase(BaseModel):
@@ -8,7 +82,20 @@ class SpoolBase(BaseModel):
     subtype: str | None = None
     subtype: str | None = None
     color_name: str | None = None
     color_name: str | None = None
     rgba: str | None = Field(None, pattern=r"^[0-9A-Fa-f]{8}$")
     rgba: str | None = Field(None, pattern=r"^[0-9A-Fa-f]{8}$")
+    extra_colors: str | None = None
+    effect_type: str | None = None
     brand: str | None = None
     brand: str | None = None
+
+    @field_validator("extra_colors")
+    @classmethod
+    def _validate_extra_colors(cls, v: str | None) -> str | None:
+        return normalize_extra_colors(v)
+
+    @field_validator("effect_type")
+    @classmethod
+    def _validate_effect_type(cls, v: str | None) -> str | None:
+        return normalize_effect_type(v)
+
     label_weight: int = 1000
     label_weight: int = 1000
     core_weight: int = 250
     core_weight: int = 250
     core_weight_catalog_id: int | None = None
     core_weight_catalog_id: int | None = None
@@ -45,7 +132,20 @@ class SpoolUpdate(BaseModel):
     subtype: str | None = None
     subtype: str | None = None
     color_name: str | None = None
     color_name: str | None = None
     rgba: str | None = Field(None, pattern=r"^[0-9A-Fa-f]{8}$")
     rgba: str | None = Field(None, pattern=r"^[0-9A-Fa-f]{8}$")
+    extra_colors: str | None = None
+    effect_type: str | None = None
     brand: str | None = None
     brand: str | None = None
+
+    @field_validator("extra_colors")
+    @classmethod
+    def _validate_extra_colors(cls, v: str | None) -> str | None:
+        return normalize_extra_colors(v)
+
+    @field_validator("effect_type")
+    @classmethod
+    def _validate_effect_type(cls, v: str | None) -> str | None:
+        return normalize_effect_type(v)
+
     label_weight: int | None = None
     label_weight: int | None = None
     core_weight: int | None = None
     core_weight: int | None = None
     core_weight_catalog_id: int | None = None
     core_weight_catalog_id: int | None = None

+ 159 - 0
backend/tests/integration/test_color_catalog_extras.py

@@ -0,0 +1,159 @@
+"""Integration tests for the multi-colour + effect extensions on the colour
+catalog routes (#1154).
+
+End-to-end coverage that the new fields on `ColorEntryCreate` / `ColorEntryUpdate`
+round-trip through the database, that catalog GET surfaces them in the response,
+and that paste-style values from 3dfilamentprofiles.com are normalized.
+"""
+
+import pytest
+from httpx import AsyncClient
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_create_color_entry_with_extras(async_client: AsyncClient):
+    """POST /inventory/colors stores extra_colors + effect_type."""
+    payload = {
+        "manufacturer": "3dfilamentprofiles",
+        "color_name": "Aurora Tetracolour",
+        "hex_color": "#EC984C",
+        "material": "PLA",
+        "extra_colors": "EC984C,#6CD4BC,A66EB9,D87694",
+        "effect_type": "Sparkle",
+    }
+    response = await async_client.post("/api/v1/inventory/colors", json=payload)
+    assert response.status_code == 200, response.text
+    body = response.json()
+    # Canonical form: lowercase, no `#`, comma-joined.
+    assert body["extra_colors"] == "ec984c,6cd4bc,a66eb9,d87694"
+    assert body["effect_type"] == "sparkle"
+    assert body["hex_color"] == "#EC984C"
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_create_color_entry_accepts_8char_hex(async_client: AsyncClient):
+    """Catalog hex_color may include alpha (#RRGGBBAA) post-#1154."""
+    payload = {
+        "manufacturer": "Bambu Lab",
+        "color_name": "Translucent Galaxy",
+        "hex_color": "#1A2B3C80",
+        "material": "PETG",
+    }
+    response = await async_client.post("/api/v1/inventory/colors", json=payload)
+    assert response.status_code == 200, response.text
+    assert response.json()["hex_color"] == "#1A2B3C80"
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_update_color_entry_clears_extras(async_client: AsyncClient):
+    """PUT with empty extra_colors clears the field (server normalizes "" → null)."""
+    create = await async_client.post(
+        "/api/v1/inventory/colors",
+        json={
+            "manufacturer": "Test",
+            "color_name": "Fade",
+            "hex_color": "#FF0000",
+            "extra_colors": "FF0000,00FF00",
+            "effect_type": "wood",
+        },
+    )
+    assert create.status_code == 200
+    entry_id = create.json()["id"]
+
+    update = await async_client.put(
+        f"/api/v1/inventory/colors/{entry_id}",
+        json={
+            "manufacturer": "Test",
+            "color_name": "Fade",
+            "hex_color": "#FF0000",
+            "extra_colors": "",
+            "effect_type": None,
+        },
+    )
+    assert update.status_code == 200, update.text
+    body = update.json()
+    assert body["extra_colors"] is None
+    assert body["effect_type"] is None
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_create_color_entry_rejects_bad_extra_colors(async_client: AsyncClient):
+    response = await async_client.post(
+        "/api/v1/inventory/colors",
+        json={
+            "manufacturer": "Test",
+            "color_name": "Bad",
+            "hex_color": "#FF0000",
+            "extra_colors": "not-hex,GGHHII",
+        },
+    )
+    assert response.status_code == 422
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_create_color_entry_rejects_bad_effect_type(async_client: AsyncClient):
+    response = await async_client.post(
+        "/api/v1/inventory/colors",
+        json={
+            "manufacturer": "Test",
+            "color_name": "Bad",
+            "hex_color": "#FF0000",
+            "effect_type": "not-a-real-variant",
+        },
+    )
+    assert response.status_code == 422
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_get_color_catalog_returns_extras(async_client: AsyncClient):
+    """GET /inventory/colors response shape includes the new fields."""
+    await async_client.post(
+        "/api/v1/inventory/colors",
+        json={
+            "manufacturer": "Test",
+            "color_name": "Glitter Black",
+            "hex_color": "#101010",
+            "extra_colors": "101010,303030",
+            "effect_type": "sparkle",
+        },
+    )
+    response = await async_client.get("/api/v1/inventory/colors")
+    assert response.status_code == 200
+    rows = response.json()
+    glitter = next((r for r in rows if r["color_name"] == "Glitter Black"), None)
+    assert glitter is not None
+    assert glitter["extra_colors"] == "101010,303030"
+    assert glitter["effect_type"] == "sparkle"
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_create_spool_with_color_extras(async_client: AsyncClient):
+    """POST /inventory/spools threads the new spool-side fields end-to-end."""
+    payload = {
+        "material": "PLA",
+        "subtype": "Multicolor",
+        "rgba": "EC984CFF",
+        "extra_colors": "#EC984C,#6CD4BC,#A66EB9,#D87694",
+        "effect_type": "matte",
+    }
+    response = await async_client.post("/api/v1/inventory/spools", json=payload)
+    assert response.status_code == 200, response.text
+    body = response.json()
+    assert body["extra_colors"] == "ec984c,6cd4bc,a66eb9,d87694"
+    assert body["effect_type"] == "matte"
+
+    # PATCH clears via empty string + null.
+    patch = await async_client.patch(
+        f"/api/v1/inventory/spools/{body['id']}",
+        json={"extra_colors": "", "effect_type": None},
+    )
+    assert patch.status_code == 200
+    assert patch.json()["extra_colors"] is None
+    assert patch.json()["effect_type"] is None

+ 210 - 0
backend/tests/unit/test_spool_schemas_colors.py

@@ -0,0 +1,210 @@
+"""Schema validation for enhanced filament colour fields (#1154).
+
+Two new fields land on Spool and ColorCatalogEntry:
+
+- `extra_colors`: comma-separated 6- or 8-char hex tokens. Stored canonical
+  form is lowercase, no `#`, no whitespace. Bounded to MAX_EXTRA_COLOR_STOPS
+  stops so a paste of arbitrary text can't blow up the column.
+- `effect_type`: one of {sparkle, wood, marble, glow, matte}. Independent of
+  Spool.subtype — purely a rendering hint.
+"""
+
+import pytest
+from pydantic import ValidationError
+
+from backend.app.schemas.spool import (
+    ALLOWED_EFFECT_TYPES,
+    MAX_EXTRA_COLOR_STOPS,
+    SpoolCreate,
+    SpoolUpdate,
+    normalize_effect_type,
+    normalize_extra_colors,
+)
+
+
+class TestNormalizeExtraColors:
+    """The shared helper that both Spool and ColorCatalog schemas delegate to."""
+
+    def test_none_passthrough(self):
+        assert normalize_extra_colors(None) is None
+
+    def test_empty_string_returns_none(self):
+        assert normalize_extra_colors("") is None
+        assert normalize_extra_colors("   ") is None
+
+    def test_strips_hash_prefix(self):
+        assert normalize_extra_colors("#FF0000,#00FF00") == "ff0000,00ff00"
+
+    def test_lowercases(self):
+        assert normalize_extra_colors("AABBCC") == "aabbcc"
+
+    def test_accepts_8char_alpha(self):
+        assert normalize_extra_colors("AABBCC80,DDEEFF40") == "aabbcc80,ddeeff40"
+
+    def test_mixed_6_and_8_char(self):
+        assert normalize_extra_colors("FF0000,00FF0080") == "ff0000,00ff0080"
+
+    def test_handles_whitespace_around_tokens(self):
+        # 3dfilamentprofiles.com paste sometimes has spaces after commas.
+        assert normalize_extra_colors("EC984C, #6CD4BC ,A66EB9, D87694") == "ec984c,6cd4bc,a66eb9,d87694"
+
+    def test_drops_empty_tokens(self):
+        # ",,FF0000," is a degenerate paste — keep what's valid.
+        assert normalize_extra_colors(",,FF0000,") == "ff0000"
+
+    def test_rejects_too_many_stops(self):
+        too_many = ",".join(["FF0000"] * (MAX_EXTRA_COLOR_STOPS + 1))
+        with pytest.raises(ValueError, match="at most"):
+            normalize_extra_colors(too_many)
+
+    def test_accepts_max_stops(self):
+        boundary = ",".join(["FF0000"] * MAX_EXTRA_COLOR_STOPS)
+        out = normalize_extra_colors(boundary)
+        assert out is not None
+        assert out.count(",") == MAX_EXTRA_COLOR_STOPS - 1
+
+    def test_rejects_non_hex(self):
+        with pytest.raises(ValueError, match="not valid hex"):
+            normalize_extra_colors("FF0000,GGHHII")
+
+    def test_rejects_wrong_length(self):
+        with pytest.raises(ValueError, match="6 or 8 hex"):
+            normalize_extra_colors("FFF")
+        with pytest.raises(ValueError, match="6 or 8 hex"):
+            normalize_extra_colors("FFFFF")
+        with pytest.raises(ValueError, match="6 or 8 hex"):
+            normalize_extra_colors("FFFFFFFFF")
+
+
+class TestNormalizeEffectType:
+    def test_none_passthrough(self):
+        assert normalize_effect_type(None) is None
+
+    def test_empty_string_returns_none(self):
+        assert normalize_effect_type("") is None
+        assert normalize_effect_type("  ") is None
+
+    def test_lowercases(self):
+        assert normalize_effect_type("SPARKLE") == "sparkle"
+        assert normalize_effect_type("Wood") == "wood"
+
+    def test_accepts_all_known_types(self):
+        for effect in ALLOWED_EFFECT_TYPES:
+            assert normalize_effect_type(effect) == effect
+
+    def test_canonicalizes_space_to_dash(self):
+        # User pastes the spool-subtype label "Dual Color" / "Tri Color".
+        assert normalize_effect_type("Dual Color") == "dual-color"
+        assert normalize_effect_type("Tri Color") == "tri-color"
+
+    def test_canonicalizes_underscore_to_dash(self):
+        assert normalize_effect_type("dual_color") == "dual-color"
+
+    def test_accepts_structural_variants(self):
+        # Gradient / Multicolor were added in #1154 follow-up so the catalog
+        # can express the full spool variant vocabulary.
+        for variant in ("gradient", "dual-color", "tri-color", "multicolor"):
+            assert normalize_effect_type(variant) == variant
+
+    def test_accepts_sheen_variants(self):
+        for sheen in ("silk", "galaxy", "rainbow", "metal", "translucent"):
+            assert normalize_effect_type(sheen) == sheen
+
+    def test_rejects_unknown_type(self):
+        # "neon" isn't in the allowed set — must reject.
+        with pytest.raises(ValueError, match="effect_type must be one of"):
+            normalize_effect_type("neon")
+
+
+class TestSpoolCreateColorExtensions:
+    def test_accepts_extra_colors_paste(self):
+        spool = SpoolCreate(material="PLA", extra_colors="EC984C,#6CD4BC,A66EB9,D87694")
+        assert spool.extra_colors == "ec984c,6cd4bc,a66eb9,d87694"
+
+    def test_accepts_effect_type(self):
+        spool = SpoolCreate(material="PLA", effect_type="sparkle")
+        assert spool.effect_type == "sparkle"
+
+    def test_defaults_to_none(self):
+        spool = SpoolCreate(material="PLA")
+        assert spool.extra_colors is None
+        assert spool.effect_type is None
+
+    def test_rejects_bad_extra_colors(self):
+        with pytest.raises(ValidationError, match="extra_colors"):
+            SpoolCreate(material="PLA", extra_colors="not-hex")
+
+    def test_rejects_bad_effect_type(self):
+        with pytest.raises(ValidationError, match="effect_type"):
+            SpoolCreate(material="PLA", effect_type="not-a-real-variant")
+
+
+class TestSpoolUpdateColorExtensions:
+    def test_clears_extra_colors_via_empty_string(self):
+        # Frontend sends "" to clear; normalizer maps that to None.
+        update = SpoolUpdate(extra_colors="")
+        assert update.extra_colors is None
+
+    def test_clears_effect_type_via_explicit_null(self):
+        update = SpoolUpdate(effect_type=None)
+        assert update.effect_type is None
+
+    def test_round_trips_canonical_form(self):
+        update = SpoolUpdate(extra_colors="FF0000,00FF00", effect_type="MATTE")
+        assert update.extra_colors == "ff0000,00ff00"
+        assert update.effect_type == "matte"
+
+
+class TestColorCatalogSchemas:
+    """The catalog Create/Update mirror the spool fields."""
+
+    def test_create_accepts_8char_hex_color(self):
+        from backend.app.api.routes.inventory import ColorEntryCreate
+
+        entry = ColorEntryCreate(
+            manufacturer="Bambu Lab",
+            color_name="Galaxy",
+            hex_color="#112233AA",
+        )
+        assert entry.hex_color == "#112233AA"
+
+    def test_create_still_accepts_6char_hex_color(self):
+        # Backward compat: existing #RRGGBB rows must keep working.
+        from backend.app.api.routes.inventory import ColorEntryCreate
+
+        entry = ColorEntryCreate(
+            manufacturer="Bambu Lab",
+            color_name="Jade White",
+            hex_color="#A1B2C3",
+        )
+        assert entry.hex_color == "#A1B2C3"
+
+    def test_create_rejects_hex_without_hash(self):
+        from backend.app.api.routes.inventory import ColorEntryCreate
+
+        with pytest.raises(ValidationError, match="hex_color"):
+            ColorEntryCreate(manufacturer="X", color_name="Y", hex_color="A1B2C3")
+
+    def test_create_threads_extra_colors_and_effect(self):
+        from backend.app.api.routes.inventory import ColorEntryCreate
+
+        entry = ColorEntryCreate(
+            manufacturer="3dfilamentprofiles",
+            color_name="Aurora",
+            hex_color="#EC984C",
+            extra_colors="EC984C,#6CD4BC,A66EB9,D87694",
+            effect_type="sparkle",
+        )
+        assert entry.extra_colors == "ec984c,6cd4bc,a66eb9,d87694"
+        assert entry.effect_type == "sparkle"
+
+    def test_update_validators_match_create(self):
+        from backend.app.api.routes.inventory import ColorEntryUpdate
+
+        with pytest.raises(ValidationError, match="extra_colors"):
+            ColorEntryUpdate(
+                manufacturer="X",
+                color_name="Y",
+                hex_color="#A1B2C3",
+                extra_colors="not-hex",
+            )

+ 186 - 0
frontend/src/__tests__/components/ColorCatalogSettings.test.tsx

@@ -0,0 +1,186 @@
+/**
+ * Tests for the colour catalog admin (ColorCatalogSettings).
+ *
+ * Pin the #1154 wiring contract:
+ * - The Add form sends `extra_colors` + `effect_type` alongside the legacy
+ *   manufacturer / color_name / hex_color / material fields.
+ * - Inline-edit hydrates the new fields from the existing entry and sends
+ *   them back through `updateColorEntry`.
+ * - Effect dropdown lists the full unified vocabulary (surface effects
+ *   + sheen variants + structural variants), not just the original 5.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, fireEvent, waitFor } from '@testing-library/react';
+import { render } from '../utils';
+import { api } from '../../api/client';
+import { ColorCatalogSettings } from '../../components/ColorCatalogSettings';
+
+vi.mock('../../api/client', async () => {
+  // Preserve every other method on `api` (ThemeContext / AuthContext call
+  // some on mount) and override only the catalog ones the component touches.
+  const actual: typeof import('../../api/client') = await vi.importActual('../../api/client');
+  return {
+    ...actual,
+    api: {
+      ...actual.api,
+      getColorCatalog: vi.fn(),
+      addColorEntry: vi.fn(),
+      updateColorEntry: vi.fn(),
+      deleteColorEntry: vi.fn(),
+      bulkDeleteColorEntries: vi.fn(),
+      resetColorCatalog: vi.fn(),
+    },
+    getAuthToken: vi.fn(() => null),
+  };
+});
+
+beforeEach(() => {
+  vi.clearAllMocks();
+});
+
+describe('ColorCatalogSettings — Add form (#1154)', () => {
+  it('sends extra_colors and effect_type when adding an entry', async () => {
+    vi.mocked(api.getColorCatalog).mockResolvedValueOnce([]);
+    vi.mocked(api.addColorEntry).mockResolvedValueOnce({
+      id: 1,
+      manufacturer: 'Test',
+      color_name: 'Aurora',
+      hex_color: '#EC984C',
+      material: null,
+      is_default: false,
+      extra_colors: 'ec984c,6cd4bc,a66eb9,d87694',
+      effect_type: 'sparkle',
+    });
+
+    render(<ColorCatalogSettings />);
+    // Wait for initial load to settle so the Add button is rendered.
+    await waitFor(() => expect(api.getColorCatalog).toHaveBeenCalled());
+    await waitFor(() =>
+      expect(screen.queryByText(/loading/i)).not.toBeInTheDocument(),
+    );
+
+    // Open the Add form via the toolbar's "Add" button — there's only one
+    // before the form opens, so this is unambiguous.
+    fireEvent.click(screen.getByRole('button', { name: /Add$/i }));
+
+    // The form now renders with manufacturer, color name, hex, material,
+    // extra_colors, and effect_type inputs.
+    fireEvent.change(screen.getByPlaceholderText('Manufacturer'), {
+      target: { value: 'Test' },
+    });
+    fireEvent.change(screen.getByPlaceholderText('Color Name'), {
+      target: { value: 'Aurora' },
+    });
+    // Hex has both a <input type="color"> and a <input type="text"
+    // placeholder="#FFFFFF">. Pick the text variant by placeholder.
+    fireEvent.change(screen.getByPlaceholderText('#FFFFFF'), {
+      target: { value: '#EC984C' },
+    });
+    fireEvent.change(screen.getByPlaceholderText('EC984C,#6CD4BC,A66EB9,D87694'), {
+      target: { value: 'EC984C,#6CD4BC,A66EB9,D87694' },
+    });
+    // Pick "Sparkle" from the effect-type combobox (the manufacturer filter
+    // is also a <select>, so disambiguate by looking for the one whose
+    // options include 'sparkle').
+    const effectSelectAdd = (
+      screen.getAllByRole('combobox') as HTMLSelectElement[]
+    ).find((s) => Array.from(s.options).some((o) => o.value === 'sparkle'));
+    expect(effectSelectAdd).toBeDefined();
+    fireEvent.change(effectSelectAdd!, { target: { value: 'sparkle' } });
+
+    // Submit. The form's submit "Add" button is now the second button with
+    // that label (toolbar Add still exists), so query inside the form
+    // container by clicking the last matching button.
+    const allAddButtons = screen.getAllByRole('button', { name: /Add$/i });
+    fireEvent.click(allAddButtons[allAddButtons.length - 1]);
+
+    await waitFor(() => expect(api.addColorEntry).toHaveBeenCalledTimes(1));
+    expect(api.addColorEntry).toHaveBeenCalledWith({
+      manufacturer: 'Test',
+      color_name: 'Aurora',
+      hex_color: '#EC984C',
+      material: null,
+      extra_colors: 'EC984C,#6CD4BC,A66EB9,D87694',
+      effect_type: 'sparkle',
+    });
+  });
+
+  it('lists every variant in the effect dropdown (not just the original 5)', async () => {
+    vi.mocked(api.getColorCatalog).mockResolvedValueOnce([]);
+    render(<ColorCatalogSettings />);
+    await waitFor(() => expect(api.getColorCatalog).toHaveBeenCalled());
+    await waitFor(() =>
+      expect(screen.queryByText(/loading/i)).not.toBeInTheDocument(),
+    );
+
+    fireEvent.click(screen.getByRole('button', { name: /Add$/i }));
+    // Disambiguate from the toolbar's manufacturer-filter <select>.
+    const effectSelect = (
+      screen.getAllByRole('combobox') as HTMLSelectElement[]
+    ).find((s) => Array.from(s.options).some((o) => o.value === 'sparkle'));
+    expect(effectSelect).toBeDefined();
+    const options = Array.from(effectSelect!.options).map((o) => o.value);
+
+    // Surface effects (V1).
+    expect(options).toContain('sparkle');
+    expect(options).toContain('wood');
+    expect(options).toContain('marble');
+    expect(options).toContain('glow');
+    expect(options).toContain('matte');
+    // Structural variants added in #1154 follow-up.
+    expect(options).toContain('gradient');
+    expect(options).toContain('dual-color');
+    expect(options).toContain('tri-color');
+    expect(options).toContain('multicolor');
+    // Sheen / finish variants.
+    expect(options).toContain('silk');
+    expect(options).toContain('galaxy');
+    expect(options).toContain('rainbow');
+    expect(options).toContain('metal');
+    expect(options).toContain('translucent');
+    // None / no-effect option.
+    expect(options).toContain('');
+  });
+});
+
+describe('ColorCatalogSettings — inline edit (#1154)', () => {
+  it('hydrates extra_colors and effect_type when entering edit mode', async () => {
+    const seed = {
+      id: 42,
+      manufacturer: 'Bambu Lab',
+      color_name: 'Galaxy',
+      hex_color: '#1A2B3C',
+      material: 'PLA',
+      is_default: true,
+      extra_colors: 'aabbcc,ddeeff',
+      effect_type: 'galaxy',
+    };
+    vi.mocked(api.getColorCatalog).mockResolvedValueOnce([seed]);
+
+    render(<ColorCatalogSettings />);
+    await waitFor(() => expect(screen.getByText('Galaxy')).toBeInTheDocument());
+
+    // Click the Edit button on the seeded row.
+    // The row's edit button has no accessible label so query by SVG-bearing
+    // button containing the Pencil icon — there's only one in the rendered tree.
+    const buttons = screen.getAllByRole('button');
+    const editButton = buttons.find((b) => b.querySelector('svg.lucide-pencil'));
+    expect(editButton).toBeDefined();
+    fireEvent.click(editButton!);
+
+    // The extra-colors input should now be populated with the seeded value.
+    const extraColorsInputs = screen.getAllByPlaceholderText(
+      'EC984C,#6CD4BC,A66EB9,D87694',
+    ) as HTMLInputElement[];
+    expect(extraColorsInputs[0].value).toBe('aabbcc,ddeeff');
+
+    // The effect dropdown reflects the seeded effect. The manufacturer
+    // filter is also a <select> at the toolbar level, so query all and
+    // pick the one whose value matches what we expect — the last one,
+    // since the filter never has 'galaxy' in its options.
+    const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
+    const effectSelect = selects.find((s) => s.value === 'galaxy');
+    expect(effectSelect).toBeDefined();
+  });
+});

+ 140 - 0
frontend/src/__tests__/components/FilamentSwatch.test.tsx

@@ -0,0 +1,140 @@
+/**
+ * Tests for the FilamentSwatch component (#1154).
+ *
+ * Covers the three independent inputs the swatch composes (rgba, extraColors,
+ * effectType) and the buildFilamentBackground helper used to paint banners.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { screen } from '@testing-library/react';
+import { render } from '../utils';
+import { FilamentSwatch } from '../../components/FilamentSwatch';
+import { buildFilamentBackground } from '../../components/filamentSwatchHelpers';
+
+describe('FilamentSwatch', () => {
+  it('renders a solid swatch when only rgba is set', () => {
+    render(<FilamentSwatch rgba="ff0000ff" />);
+    const el = screen.getByTestId('filament-swatch');
+    // Solid swatches are emitted as a 1-stop linear-gradient so the
+    // checkerboard layer below is still visible through alpha.
+    const bg = el.getAttribute('style') ?? '';
+    expect(bg).toMatch(/linear-gradient/);
+    expect(bg.toLowerCase()).toContain('#ff0000ff');
+  });
+
+  it('falls back to grey when nothing is set', () => {
+    render(<FilamentSwatch />);
+    const el = screen.getByTestId('filament-swatch');
+    expect(el.style.backgroundImage.toLowerCase()).toContain('#808080');
+  });
+
+  it('renders a linear gradient when extraColors has multiple stops', () => {
+    render(<FilamentSwatch rgba="ff0000ff" extraColors="ec984c,6cd4bc,a66eb9,d87694" />);
+    const el = screen.getByTestId('filament-swatch');
+    const bg = el.style.backgroundImage.toLowerCase();
+    // Linear (not conic) for non-Multicolor subtype.
+    expect(bg).toMatch(/linear-gradient/);
+    expect(bg).toContain('#ec984c');
+    expect(bg).toContain('#6cd4bc');
+    expect(bg).toContain('#a66eb9');
+    expect(bg).toContain('#d87694');
+  });
+
+  it('uses conic-gradient for Multicolor subtype', () => {
+    render(
+      <FilamentSwatch
+        rgba="ff0000ff"
+        extraColors="ec984c,6cd4bc,a66eb9"
+        subtype="Multicolor"
+      />,
+    );
+    const el = screen.getByTestId('filament-swatch');
+    expect(el.style.backgroundImage.toLowerCase()).toMatch(/conic-gradient/);
+  });
+
+  it('also uses conic-gradient when effectType is multicolor (catalog path)', () => {
+    // Catalog entries don't have a `subtype`, so the multicolor effect_type
+    // value also has to trigger conic rendering for parity with the spool path.
+    render(<FilamentSwatch extraColors="ec984c,6cd4bc,a66eb9" effectType="multicolor" />);
+    const el = screen.getByTestId('filament-swatch');
+    expect(el.style.backgroundImage.toLowerCase()).toMatch(/conic-gradient/);
+  });
+
+  it('layers an effect overlay on top of the colour layer for sparkle', () => {
+    render(<FilamentSwatch rgba="ff0000ff" effectType="sparkle" />);
+    const el = screen.getByTestId('filament-swatch');
+    // Sparkle overlay is built from radial-gradient layers — confirm at least
+    // one is in the composed background, ahead of the colour layer.
+    expect(el.style.backgroundImage).toMatch(/radial-gradient/);
+  });
+
+  it('renders an overlay for silk variant', () => {
+    // Silk gets a soft sheen overlay (added in #1154 follow-up).
+    render(<FilamentSwatch rgba="ff0000ff" effectType="silk" />);
+    const el = screen.getByTestId('filament-swatch');
+    expect(el.style.backgroundImage).toMatch(/linear-gradient/);
+  });
+
+  it('treats categorical-only variants (gradient/dual-color) as labels without an overlay', () => {
+    // No extra_colors set → swatch falls back to the solid colour layer; the
+    // categorical effect value alone does not paint a sheen overlay.
+    render(<FilamentSwatch rgba="ff0000ff" effectType="gradient" />);
+    const el = screen.getByTestId('filament-swatch');
+    // No radial-gradient (sparkle/glow) and no rainbow/sheen overlay either —
+    // gradient/dual-color/tri-color are pure labels until extra_colors is set.
+    expect(el.style.backgroundImage).not.toMatch(/radial-gradient/);
+  });
+
+  it('ignores unknown effect types instead of throwing', () => {
+    render(<FilamentSwatch rgba="ff0000ff" effectType="not-a-real-variant" />);
+    const el = screen.getByTestId('filament-swatch');
+    expect(el.style.backgroundImage).not.toMatch(/radial-gradient/);
+  });
+
+  it('renders a checkerboard underneath so alpha is visible', () => {
+    render(<FilamentSwatch rgba="ff000080" />);
+    const el = screen.getByTestId('filament-swatch');
+    // The component always appends a checkerboard layer last so semi-
+    // transparent rgba values actually look transparent to the user.
+    expect(el.style.backgroundImage).toMatch(/repeating-conic-gradient/);
+  });
+
+  it('skips invalid hex tokens in extraColors instead of throwing', () => {
+    render(<FilamentSwatch extraColors="ff0000,not-hex,00ff00" />);
+    const el = screen.getByTestId('filament-swatch');
+    const bg = el.style.backgroundImage.toLowerCase();
+    // The two valid stops survive; the garbage token is dropped.
+    expect(bg).toContain('#ff0000');
+    expect(bg).toContain('#00ff00');
+    expect(bg).not.toContain('not-hex');
+  });
+
+  it('uses extra_colors for the title fallback when provided', () => {
+    render(<FilamentSwatch extraColors="ff0000,00ff00" />);
+    const el = screen.getByTestId('filament-swatch');
+    // Tooltip should show the comma-joined hex stops, not the (unset) rgba.
+    expect(el.title.toLowerCase()).toContain('#ff0000');
+    expect(el.title.toLowerCase()).toContain('#00ff00');
+  });
+});
+
+describe('buildFilamentBackground', () => {
+  it('emits the same layered background string the component renders', () => {
+    const bg = buildFilamentBackground({
+      rgba: 'ff0000ff',
+      extraColors: 'aabbcc,ddeeff',
+      effectType: 'matte',
+    });
+    // Effect overlay → colour layer → checkerboard, in that order.
+    expect(bg).toMatch(/linear-gradient/);
+    expect(bg).toMatch(/repeating-conic-gradient/);
+    expect(bg.toLowerCase()).toContain('#aabbcc');
+    expect(bg.toLowerCase()).toContain('#ddeeff');
+  });
+
+  it('returns a usable solid background when only rgba is provided', () => {
+    const bg = buildFilamentBackground({ rgba: '00ff00ff' });
+    expect(bg.toLowerCase()).toContain('#00ff00ff');
+    expect(bg).toMatch(/repeating-conic-gradient/);
+  });
+});

+ 2 - 0
frontend/src/__tests__/components/SpoolFormBulk.test.tsx

@@ -61,6 +61,8 @@ const existingSpool: InventorySpool = {
   brand: 'Polymaker',
   brand: 'Polymaker',
   color_name: 'Red',
   color_name: 'Red',
   rgba: 'FF0000FF',
   rgba: 'FF0000FF',
+  extra_colors: null,
+  effect_type: null,
   label_weight: 1000,
   label_weight: 1000,
   core_weight: 250,
   core_weight: 250,
   core_weight_catalog_id: null,
   core_weight_catalog_id: null,

+ 2 - 0
frontend/src/__tests__/components/SpoolFormModal.test.tsx

@@ -60,6 +60,8 @@ const existingSpool: InventorySpool = {
   brand: 'Polymaker',
   brand: 'Polymaker',
   color_name: 'Red',
   color_name: 'Red',
   rgba: 'FF0000FF',
   rgba: 'FF0000FF',
+  extra_colors: null,
+  effect_type: null,
   label_weight: 1000,
   label_weight: 1000,
   core_weight: 250,
   core_weight: 250,
   core_weight_catalog_id: null,
   core_weight_catalog_id: null,

+ 29 - 2
frontend/src/__tests__/pages/InventoryPageGrouping.test.ts

@@ -11,9 +11,12 @@
 import { describe, it, expect } from 'vitest';
 import { describe, it, expect } from 'vitest';
 import type { InventorySpool, SpoolAssignment } from '../../api/client';
 import type { InventorySpool, SpoolAssignment } from '../../api/client';
 
 
-// Replicate the grouping key function from InventoryPage (not exported)
+// Replicate the grouping key function from InventoryPage (not exported).
+// Must stay in lockstep with InventoryPage.tsx::spoolGroupKey — extra_colors
+// and effect_type are part of the key (#1154) so multi-colour / effect
+// variants don't get collapsed under "Group similar".
 function spoolGroupKey(s: InventorySpool): string {
 function spoolGroupKey(s: InventorySpool): string {
-  return `${s.material}|${s.subtype || ''}|${s.brand || ''}|${s.color_name || ''}|${s.rgba || ''}|${s.label_weight}`;
+  return `${s.material}|${s.subtype || ''}|${s.brand || ''}|${s.color_name || ''}|${s.rgba || ''}|${s.extra_colors || ''}|${s.effect_type || ''}|${s.label_weight}`;
 }
 }
 
 
 type DisplayItem =
 type DisplayItem =
@@ -66,6 +69,8 @@ function makeSpool(overrides: Partial<InventorySpool> & { id: number }): Invento
     brand: 'Polymaker',
     brand: 'Polymaker',
     color_name: 'Red',
     color_name: 'Red',
     rgba: 'FF0000FF',
     rgba: 'FF0000FF',
+    extra_colors: null,
+    effect_type: null,
     label_weight: 1000,
     label_weight: 1000,
     core_weight: 250,
     core_weight: 250,
     core_weight_catalog_id: null,
     core_weight_catalog_id: null,
@@ -128,6 +133,28 @@ describe('spoolGroupKey', () => {
     expect(spoolGroupKey(a)).not.toBe(spoolGroupKey(b));
     expect(spoolGroupKey(a)).not.toBe(spoolGroupKey(b));
   });
   });
 
 
+  it('generates different key when extra_colors differs (#1154)', () => {
+    // Two spools that share the base hex but have different gradient stops
+    // are visually different — they must not collapse under "Group similar".
+    const a = makeSpool({ id: 1, extra_colors: 'ff0000,00ff00' });
+    const b = makeSpool({ id: 2, extra_colors: 'ff0000,0000ff' });
+    expect(spoolGroupKey(a)).not.toBe(spoolGroupKey(b));
+  });
+
+  it('generates different key when effect_type differs (#1154)', () => {
+    const a = makeSpool({ id: 1, effect_type: 'sparkle' });
+    const b = makeSpool({ id: 2, effect_type: 'matte' });
+    expect(spoolGroupKey(a)).not.toBe(spoolGroupKey(b));
+  });
+
+  it('still groups identical multi-colour spools (#1154)', () => {
+    // Same base + same stops + same effect → same group; the new fields
+    // join the key but don't break the existing identical-grouping case.
+    const a = makeSpool({ id: 1, extra_colors: 'ff0000,00ff00', effect_type: 'multicolor' });
+    const b = makeSpool({ id: 2, extra_colors: 'ff0000,00ff00', effect_type: 'multicolor' });
+    expect(spoolGroupKey(a)).toBe(spoolGroupKey(b));
+  });
+
   it('treats null and empty string subtype the same', () => {
   it('treats null and empty string subtype the same', () => {
     const a = makeSpool({ id: 1, subtype: null as unknown as string });
     const a = makeSpool({ id: 1, subtype: null as unknown as string });
     const b = makeSpool({ id: 2, subtype: '' });
     const b = makeSpool({ id: 2, subtype: '' });

+ 26 - 3
frontend/src/api/client.ts

@@ -1063,6 +1063,9 @@ export interface ColorCatalogEntry {
   hex_color: string;
   hex_color: string;
   material: string | null;
   material: string | null;
   is_default: boolean;
   is_default: boolean;
+  // #1154: optional multi-colour gradient stops + visual effect.
+  extra_colors?: string | null;
+  effect_type?: string | null;
 }
 }
 
 
 export interface ColorLookupResult {
 export interface ColorLookupResult {
@@ -2235,6 +2238,10 @@ export interface InventorySpool {
   subtype: string | null;
   subtype: string | null;
   color_name: string | null;
   color_name: string | null;
   rgba: string | null;
   rgba: string | null;
+  // Multi-colour gradient stops (#1154): comma-separated 6/8-char hex.
+  extra_colors: string | null;
+  // Visual effect overlay: sparkle | wood | marble | glow | matte.
+  effect_type: string | null;
   brand: string | null;
   brand: string | null;
   label_weight: number;
   label_weight: number;
   core_weight: number;
   core_weight: number;
@@ -4346,10 +4353,26 @@ export const api = {
     request<ColorCatalogEntry[]>('/inventory/colors'),
     request<ColorCatalogEntry[]>('/inventory/colors'),
   getColorNameMap: () =>
   getColorNameMap: () =>
     request<{ colors: Record<string, string> }>('/inventory/colors/map'),
     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;
+    extra_colors?: string | null;
+    effect_type?: 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 }) =>
-    request<ColorCatalogEntry>(`/inventory/colors/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
+  updateColorEntry: (
+    id: number,
+    data: {
+      manufacturer: string;
+      color_name: string;
+      hex_color: string;
+      material: string | null;
+      extra_colors?: string | null;
+      effect_type?: string | null;
+    },
+  ) => request<ColorCatalogEntry>(`/inventory/colors/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
   deleteColorEntry: (id: number) =>
   deleteColorEntry: (id: number) =>
     request<{ status: string }>(`/inventory/colors/${id}`, { method: 'DELETE' }),
     request<{ status: string }>(`/inventory/colors/${id}`, { method: 'DELETE' }),
   bulkDeleteColorEntries: (ids: number[]) =>
   bulkDeleteColorEntries: (ids: number[]) =>

+ 76 - 7
frontend/src/components/ColorCatalogSettings.tsx

@@ -1,4 +1,4 @@
-import { useState, useEffect, useCallback, useRef } from 'react';
+import { Fragment, useState, useEffect, useCallback, useRef } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { Palette, Plus, Trash2, RotateCcw, Loader2, Pencil, Check, X, Search, Download, Upload, Cloud } from 'lucide-react';
 import { Palette, Plus, Trash2, RotateCcw, Loader2, Pencil, Check, X, Search, Download, Upload, Cloud } from 'lucide-react';
 import { api, getAuthToken } from '../api/client';
 import { api, getAuthToken } from '../api/client';
@@ -6,6 +6,8 @@ import type { ColorCatalogEntry } from '../api/client';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 import { Card, CardHeader, CardContent } from './Card';
 import { Card, CardHeader, CardContent } from './Card';
 import { ConfirmModal } from './ConfirmModal';
 import { ConfirmModal } from './ConfirmModal';
+import { FilamentSwatch } from './FilamentSwatch';
+import { FILAMENT_EFFECT_OPTIONS } from './filamentSwatchHelpers';
 
 
 export function ColorCatalogSettings() {
 export function ColorCatalogSettings() {
   const { t } = useTranslation();
   const { t } = useTranslation();
@@ -23,6 +25,8 @@ export function ColorCatalogSettings() {
   const [formColorName, setFormColorName] = useState('');
   const [formColorName, setFormColorName] = useState('');
   const [formHexColor, setFormHexColor] = useState('#FFFFFF');
   const [formHexColor, setFormHexColor] = useState('#FFFFFF');
   const [formMaterial, setFormMaterial] = useState('');
   const [formMaterial, setFormMaterial] = useState('');
+  const [formExtraColors, setFormExtraColors] = useState('');
+  const [formEffectType, setFormEffectType] = useState('');
   const [saving, setSaving] = useState(false);
   const [saving, setSaving] = useState(false);
 
 
   // Selection state
   // Selection state
@@ -68,6 +72,8 @@ export function ColorCatalogSettings() {
     setFormColorName('');
     setFormColorName('');
     setFormHexColor('#FFFFFF');
     setFormHexColor('#FFFFFF');
     setFormMaterial('');
     setFormMaterial('');
+    setFormExtraColors('');
+    setFormEffectType('');
   };
   };
 
 
   const handleAdd = async () => {
   const handleAdd = async () => {
@@ -82,6 +88,8 @@ export function ColorCatalogSettings() {
         color_name: formColorName.trim(),
         color_name: formColorName.trim(),
         hex_color: formHexColor,
         hex_color: formHexColor,
         material: formMaterial.trim() || null,
         material: formMaterial.trim() || null,
+        extra_colors: formExtraColors.trim() || null,
+        effect_type: formEffectType.trim() || null,
       });
       });
       setCatalog(prev => [...prev, entry].sort((a, b) =>
       setCatalog(prev => [...prev, entry].sort((a, b) =>
         a.manufacturer.localeCompare(b.manufacturer) ||
         a.manufacturer.localeCompare(b.manufacturer) ||
@@ -104,6 +112,8 @@ export function ColorCatalogSettings() {
     setFormColorName(entry.color_name);
     setFormColorName(entry.color_name);
     setFormHexColor(entry.hex_color);
     setFormHexColor(entry.hex_color);
     setFormMaterial(entry.material || '');
     setFormMaterial(entry.material || '');
+    setFormExtraColors(entry.extra_colors || '');
+    setFormEffectType(entry.effect_type || '');
   };
   };
 
 
   const cancelEdit = () => {
   const cancelEdit = () => {
@@ -123,6 +133,8 @@ export function ColorCatalogSettings() {
         color_name: formColorName.trim(),
         color_name: formColorName.trim(),
         hex_color: formHexColor,
         hex_color: formHexColor,
         material: formMaterial.trim() || null,
         material: formMaterial.trim() || null,
+        extra_colors: formExtraColors.trim() || null,
+        effect_type: formEffectType.trim() || null,
       });
       });
       setCatalog(prev =>
       setCatalog(prev =>
         prev.map(e => e.id === id ? updated : e).sort((a, b) =>
         prev.map(e => e.id === id ? updated : e).sort((a, b) =>
@@ -254,8 +266,8 @@ export function ColorCatalogSettings() {
   };
   };
 
 
   const handleExport = () => {
   const handleExport = () => {
-    const exportData = catalog.map(({ manufacturer, color_name, hex_color, material }) => ({
-      manufacturer, color_name, hex_color, material,
+    const exportData = catalog.map(({ manufacturer, color_name, hex_color, material, extra_colors, effect_type }) => ({
+      manufacturer, color_name, hex_color, material, extra_colors, effect_type,
     }));
     }));
     const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
     const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
     const url = URL.createObjectURL(blob);
     const url = URL.createObjectURL(blob);
@@ -276,6 +288,7 @@ export function ColorCatalogSettings() {
       const text = await file.text();
       const text = await file.text();
       const data = JSON.parse(text) as Array<{
       const data = JSON.parse(text) as Array<{
         manufacturer: string; color_name: string; hex_color: string; material?: string | null;
         manufacturer: string; color_name: string; hex_color: string; material?: string | null;
+        extra_colors?: string | null; effect_type?: string | null;
       }>;
       }>;
       if (!Array.isArray(data)) throw new Error('Invalid format');
       if (!Array.isArray(data)) throw new Error('Invalid format');
 
 
@@ -295,6 +308,8 @@ export function ColorCatalogSettings() {
             color_name: item.color_name,
             color_name: item.color_name,
             hex_color: item.hex_color,
             hex_color: item.hex_color,
             material: item.material || null,
             material: item.material || null,
+            extra_colors: item.extra_colors || null,
+            effect_type: item.effect_type || null,
           });
           });
           setCatalog(prev => [...prev, entry].sort((a, b) =>
           setCatalog(prev => [...prev, entry].sort((a, b) =>
             a.manufacturer.localeCompare(b.manufacturer) ||
             a.manufacturer.localeCompare(b.manufacturer) ||
@@ -473,6 +488,27 @@ export function ColorCatalogSettings() {
                 </button>
                 </button>
               </div>
               </div>
             </div>
             </div>
+            {/* #1154: optional multi-colour stops + visual effect. */}
+            <div className="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-2">
+              <input
+                type="text"
+                className="px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white text-sm font-mono placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
+                placeholder={t('inventory.extraColorsPlaceholder')}
+                value={formExtraColors}
+                onChange={(e) => setFormExtraColors(e.target.value)}
+              />
+              <select
+                className="px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
+                value={formEffectType}
+                onChange={(e) => setFormEffectType(e.target.value)}
+              >
+                {FILAMENT_EFFECT_OPTIONS.map((opt) => (
+                  <option key={opt.value || 'none'} value={opt.value}>
+                    {t(opt.labelKey)}
+                  </option>
+                ))}
+              </select>
+            </div>
           </div>
           </div>
         )}
         )}
 
 
@@ -519,7 +555,8 @@ export function ColorCatalogSettings() {
                   </tr>
                   </tr>
                 ) : (
                 ) : (
                   filteredCatalog.map(entry => (
                   filteredCatalog.map(entry => (
-                    <tr key={entry.id} className={`border-t border-bambu-dark-tertiary hover:bg-bambu-dark ${selectedIds.has(entry.id) ? 'bg-bambu-dark' : ''}`}>
+                    <Fragment key={entry.id}>
+                    <tr className={`border-t border-bambu-dark-tertiary hover:bg-bambu-dark ${selectedIds.has(entry.id) ? 'bg-bambu-dark' : ''}`}>
                       {editingId === entry.id ? (
                       {editingId === entry.id ? (
                         <>
                         <>
                           <td className="px-2 py-2">
                           <td className="px-2 py-2">
@@ -596,9 +633,12 @@ export function ColorCatalogSettings() {
                             />
                             />
                           </td>
                           </td>
                           <td className="px-3 py-2">
                           <td className="px-3 py-2">
-                            <div
-                              className="w-8 h-8 rounded border border-bambu-dark-tertiary"
-                              style={{ backgroundColor: entry.hex_color }}
+                            <FilamentSwatch
+                              rgba={entry.hex_color.replace(/^#/, '') + (entry.hex_color.length === 7 ? 'FF' : '')}
+                              extraColors={entry.extra_colors}
+                              effectType={entry.effect_type}
+                              className="w-8 h-8"
+                              shape="square"
                               title={entry.hex_color}
                               title={entry.hex_color}
                             />
                             />
                           </td>
                           </td>
@@ -625,6 +665,35 @@ export function ColorCatalogSettings() {
                         </>
                         </>
                       )}
                       )}
                     </tr>
                     </tr>
+                    {editingId === entry.id && (
+                      <tr className="border-t border-bambu-dark-tertiary/50 bg-bambu-dark">
+                        <td colSpan={2}></td>
+                        <td className="px-3 py-2" colSpan={2}>
+                          <input
+                            type="text"
+                            className="w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm font-mono focus:border-bambu-green focus:outline-none"
+                            placeholder={t('inventory.extraColorsPlaceholder')}
+                            value={formExtraColors}
+                            onChange={(e) => setFormExtraColors(e.target.value)}
+                          />
+                        </td>
+                        <td className="px-3 py-2" colSpan={2}>
+                          <select
+                            className="w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
+                            value={formEffectType}
+                            onChange={(e) => setFormEffectType(e.target.value)}
+                          >
+                            {FILAMENT_EFFECT_OPTIONS.map((opt) => (
+                              <option key={opt.value || 'none'} value={opt.value}>
+                                {t(opt.labelKey)}
+                              </option>
+                            ))}
+                          </select>
+                        </td>
+                        <td></td>
+                      </tr>
+                    )}
+                    </Fragment>
                   ))
                   ))
                 )}
                 )}
               </tbody>
               </tbody>

+ 83 - 0
frontend/src/components/FilamentSwatch.tsx

@@ -0,0 +1,83 @@
+import React, { useMemo } from 'react';
+import {
+  CHECKERBOARD_BG,
+  EFFECT_OVERLAYS,
+  buildColorLayer,
+  parseStops,
+  type FilamentEffect,
+} from './filamentSwatchHelpers';
+
+/** Shared filament-colour swatch. See `filamentSwatchHelpers.ts` for the
+ *  pure helpers + constants this component composes. */
+
+export interface FilamentSwatchProps {
+  /** RRGGBBAA hex without `#` (Bambu/AMS canonical). Falls back to grey when null. */
+  rgba?: string | null;
+  /** Comma-separated 6/8-char hex tokens (no `#`). Empty/undefined → solid. */
+  extraColors?: string | null;
+  /** Visual effect overlay. */
+  effectType?: FilamentEffect | string | null;
+  /** When `Multicolor`, a conic gradient is used instead of linear. */
+  subtype?: string | null;
+  /** Tailwind size token applied to width/height (e.g. `w-5 h-5`). Default: `w-5 h-5`. */
+  className?: string;
+  /** Override the rounded shape — defaults to `rounded-full` (circular). */
+  shape?: 'circle' | 'pill' | 'square';
+  /** Optional inline style overrides (e.g. height of a card banner). */
+  style?: React.CSSProperties;
+  /** Native title attribute for hover tooltip. */
+  title?: string;
+}
+
+export function FilamentSwatch({
+  rgba,
+  extraColors,
+  effectType,
+  subtype,
+  className = 'w-5 h-5',
+  shape = 'circle',
+  style,
+  title,
+}: FilamentSwatchProps) {
+  const stops = useMemo(() => parseStops(extraColors), [extraColors]);
+  const colorLayer = useMemo(
+    () => buildColorLayer(rgba, stops, subtype, effectType),
+    [rgba, stops, subtype, effectType],
+  );
+
+  const effectKey =
+    typeof effectType === 'string' && effectType in EFFECT_OVERLAYS
+      ? (effectType as FilamentEffect)
+      : null;
+  const effectLayer = effectKey ? EFFECT_OVERLAYS[effectKey] ?? null : null;
+
+  // Layer order (top → bottom): effect overlay → colour layer → checkerboard.
+  // Set as `background-image` (not the `background` shorthand) so the value
+  // remains a pure list-of-images that browsers and test runners parse cleanly.
+  const backgroundImage = [effectLayer, colorLayer, CHECKERBOARD_BG]
+    .filter((layer): layer is string => Boolean(layer))
+    .join(', ');
+
+  const shapeClass =
+    shape === 'circle' ? 'rounded-full' : shape === 'pill' ? 'rounded-full' : 'rounded';
+
+  // Compute a sensible title fallback — solid hex or gradient summary.
+  const computedTitle =
+    title ??
+    (stops.length > 0
+      ? stops.join(', ')
+      : rgba
+        ? `#${rgba.substring(0, 6)}`
+        : undefined);
+
+  return (
+    <span
+      data-testid="filament-swatch"
+      className={`${className} ${shapeClass} border border-black/20 inline-block flex-shrink-0`}
+      style={{ backgroundImage, backgroundSize: 'cover', ...style }}
+      title={computedTitle}
+    />
+  );
+}
+
+export default FilamentSwatch;

+ 4 - 0
frontend/src/components/SpoolFormModal.tsx

@@ -269,6 +269,8 @@ export function SpoolFormModal({
           brand: spool.brand || '',
           brand: spool.brand || '',
           color_name: spool.color_name || '',
           color_name: spool.color_name || '',
           rgba: validRgba,
           rgba: validRgba,
+          extra_colors: spool.extra_colors || '',
+          effect_type: spool.effect_type || '',
           label_weight: spool.label_weight || 1000,
           label_weight: spool.label_weight || 1000,
           core_weight: spool.core_weight || 250,
           core_weight: spool.core_weight || 250,
           core_weight_catalog_id: spool.core_weight_catalog_id ?? null,
           core_weight_catalog_id: spool.core_weight_catalog_id ?? null,
@@ -518,6 +520,8 @@ export function SpoolFormModal({
       brand: formData.brand || null,
       brand: formData.brand || null,
       color_name: formData.color_name || null,
       color_name: formData.color_name || null,
       rgba: formData.rgba || null,
       rgba: formData.rgba || null,
+      extra_colors: formData.extra_colors || null,
+      effect_type: formData.effect_type || null,
       label_weight: formData.label_weight,
       label_weight: formData.label_weight,
       core_weight: formData.core_weight,
       core_weight: formData.core_weight,
       core_weight_catalog_id: formData.core_weight_catalog_id,
       core_weight_catalog_id: formData.core_weight_catalog_id,

+ 175 - 0
frontend/src/components/filamentSwatchHelpers.ts

@@ -0,0 +1,175 @@
+/* Enhanced filament-colour rendering helpers (#1154).
+ *
+ * Pure (non-component) exports that drive `<FilamentSwatch>` and any caller
+ * that needs the same composed background as a CSS string. Lives in its own
+ * file so `FilamentSwatch.tsx` can stay component-only and satisfy the
+ * `react-refresh/only-export-components` ESLint rule.
+ *
+ * Inputs the swatch composes:
+ *   1. `rgba`        — RRGGBBAA hex (the Bambu/AMS canonical form)
+ *   2. `extraColors` — comma-separated 6/8-char hex stops; turns the swatch
+ *                      into a gradient. Conic when either `subtype` or
+ *                      `effectType` is `multicolor`, otherwise linear.
+ *   3. `effectType`  — visual variant. Some carry a CSS overlay (sparkle,
+ *                      wood, marble, glow, matte, silk, galaxy, metal),
+ *                      others are categorical labels only.
+ *
+ * Alpha < 0xFF on any layer is shown against a checkerboard so the user can
+ * actually see the transparency they configured.
+ */
+
+export type FilamentEffect =
+  // Surface effects with their own CSS overlay
+  | 'sparkle'
+  | 'wood'
+  | 'marble'
+  | 'glow'
+  | 'matte'
+  // Sheen / finish variants (categorical labels; some carry an overlay)
+  | 'silk'
+  | 'galaxy'
+  | 'rainbow'
+  | 'metal'
+  | 'translucent'
+  // Multi-colour structures (mostly drive the colour-layer choice)
+  | 'gradient'
+  | 'dual-color'
+  | 'tri-color'
+  | 'multicolor';
+
+/** Public list of all known effect/variant values, in display order. Shared
+ *  by the spool form's ColorSection dropdown and the colour-catalog editor
+ *  so the two stay in lockstep. Each value pairs with an i18n key under
+ *  `inventory.colorEffect.<value>` (kebab → camel: `dualColor`/`triColor`). */
+export const FILAMENT_EFFECT_OPTIONS: ReadonlyArray<{
+  value: '' | FilamentEffect;
+  labelKey: string;
+}> = [
+  { value: '', labelKey: 'inventory.colorEffect.none' },
+  // Surface effects
+  { value: 'sparkle', labelKey: 'inventory.colorEffect.sparkle' },
+  { value: 'wood', labelKey: 'inventory.colorEffect.wood' },
+  { value: 'marble', labelKey: 'inventory.colorEffect.marble' },
+  { value: 'glow', labelKey: 'inventory.colorEffect.glow' },
+  { value: 'matte', labelKey: 'inventory.colorEffect.matte' },
+  // Sheen / finish
+  { value: 'silk', labelKey: 'inventory.colorEffect.silk' },
+  { value: 'galaxy', labelKey: 'inventory.colorEffect.galaxy' },
+  { value: 'rainbow', labelKey: 'inventory.colorEffect.rainbow' },
+  { value: 'metal', labelKey: 'inventory.colorEffect.metal' },
+  { value: 'translucent', labelKey: 'inventory.colorEffect.translucent' },
+  // Multi-colour structures
+  { value: 'gradient', labelKey: 'inventory.colorEffect.gradient' },
+  { value: 'dual-color', labelKey: 'inventory.colorEffect.dualColor' },
+  { value: 'tri-color', labelKey: 'inventory.colorEffect.triColor' },
+  { value: 'multicolor', labelKey: 'inventory.colorEffect.multicolor' },
+];
+
+// Checkerboard pattern shown beneath the colour layer so alpha < FF is
+// actually visible to the user. Kept as a pure gradient (no position/size)
+// so the value parses cleanly inside `background-image:` everywhere.
+export const CHECKERBOARD_BG =
+  'repeating-conic-gradient(#cbcbcb 0% 25%, #f5f5f5 0% 50%)';
+
+/** Optional CSS overlay layer for variants that have a visual treatment.
+ *  Variants without an entry are categorical labels only — they don't paint
+ *  an overlay, just sit in the data. `multicolor` is special: its visual
+ *  effect is to switch the colour layer to a conic-gradient (see
+ *  `buildColorLayer`), not to add an overlay layer. */
+export const EFFECT_OVERLAYS: Partial<Record<FilamentEffect, string>> = {
+  // Sparkle: fine bright dots scattered across the swatch.
+  sparkle:
+    'radial-gradient(circle at 30% 20%, rgba(255,255,255,0.85) 0 1px, transparent 1.5px), ' +
+    'radial-gradient(circle at 70% 60%, rgba(255,255,255,0.7) 0 1px, transparent 1.5px), ' +
+    'radial-gradient(circle at 45% 75%, rgba(255,255,255,0.6) 0 1px, transparent 1.5px), ' +
+    'radial-gradient(circle at 80% 30%, rgba(255,255,255,0.5) 0 1px, transparent 1.5px)',
+  // Wood: subtle horizontal banding to mimic grain.
+  wood:
+    'repeating-linear-gradient(90deg, ' +
+    'rgba(0,0,0,0.18) 0 1px, transparent 1px 6px, ' +
+    'rgba(0,0,0,0.08) 6px 7px, transparent 7px 12px)',
+  // Marble: soft diagonal swirls.
+  marble:
+    'repeating-linear-gradient(135deg, rgba(255,255,255,0.18) 0 2px, transparent 2px 8px), ' +
+    'repeating-linear-gradient(45deg, rgba(0,0,0,0.10) 0 1px, transparent 1px 7px)',
+  // Glow: bright center fade — visual hint for glow-in-the-dark filaments.
+  glow:
+    'radial-gradient(circle at 50% 50%, rgba(255,255,255,0.35) 0%, rgba(255,255,255,0) 70%)',
+  // Matte: very subtle inset shadow to flatten the highlight.
+  matte:
+    'linear-gradient(180deg, rgba(0,0,0,0.10) 0%, rgba(0,0,0,0) 50%, rgba(0,0,0,0.10) 100%)',
+  // Silk / Galaxy: diagonal sheen to suggest the lustrous finish those
+  // filaments have. Galaxy uses a slightly stronger highlight.
+  silk:
+    'linear-gradient(110deg, rgba(255,255,255,0) 30%, rgba(255,255,255,0.30) 50%, rgba(255,255,255,0) 70%)',
+  galaxy:
+    'linear-gradient(110deg, rgba(255,255,255,0) 25%, rgba(255,255,255,0.40) 50%, rgba(255,255,255,0) 75%)',
+  // Metal: brushed-metal look via tight horizontal striations + soft sheen.
+  metal:
+    'repeating-linear-gradient(90deg, rgba(255,255,255,0.10) 0 1px, transparent 1px 3px), ' +
+    'linear-gradient(180deg, rgba(255,255,255,0.18) 0%, rgba(0,0,0,0.18) 100%)',
+};
+
+/** Normalize a hex token (with or without `#`, 6 or 8 chars) → CSS hex string. */
+export function toCssHex(token: string): string | null {
+  const t = token.trim().replace(/^#/, '');
+  if (t.length !== 6 && t.length !== 8) return null;
+  if (!/^[0-9a-fA-F]+$/.test(t)) return null;
+  return `#${t}`;
+}
+
+/** Parse extra_colors string into an array of CSS hex strings. */
+export function parseStops(extra: string | null | undefined): string[] {
+  if (!extra) return [];
+  return extra
+    .split(',')
+    .map((s) => toCssHex(s))
+    .filter((s): s is string => Boolean(s));
+}
+
+/** Build the colour layer (gradient or solid) given rgba + stops + subtype/effect.
+ *  A conic gradient is used when either subtype OR effect_type is `Multicolor`,
+ *  giving the catalog editor a way to flag a multicolor variant directly. */
+export function buildColorLayer(
+  rgba: string | null | undefined,
+  stops: string[],
+  subtype: string | null | undefined,
+  effectType?: string | null,
+): string {
+  const baseHex = rgba ? toCssHex(rgba) : null;
+  // No stops → solid colour (or default grey when nothing is set at all).
+  if (stops.length === 0) {
+    return `linear-gradient(${baseHex ?? '#808080'}, ${baseHex ?? '#808080'})`;
+  }
+  // With stops we ignore the single rgba and gradient across the stops.
+  const allStops = stops.length === 1 ? [stops[0], stops[0]] : stops;
+  const isMulticolor =
+    (subtype ?? '').toLowerCase() === 'multicolor' ||
+    (effectType ?? '').toLowerCase() === 'multicolor';
+  if (isMulticolor) {
+    return `conic-gradient(from 0deg, ${allStops.join(', ')}, ${allStops[0]})`;
+  }
+  return `linear-gradient(135deg, ${allStops.join(', ')})`;
+}
+
+/** Public helper: produce a CSS background-image value (list of layered
+ *  <image>s) for a filament, for callers that want to paint a banner or
+ *  large area instead of using the swatch element. Pair with
+ *  `background-size: cover` and the swatch logic stays consistent. */
+export function buildFilamentBackground(opts: {
+  rgba?: string | null;
+  extraColors?: string | null;
+  effectType?: FilamentEffect | string | null;
+  subtype?: string | null;
+}): string {
+  const stops = parseStops(opts.extraColors);
+  const colorLayer = buildColorLayer(opts.rgba, stops, opts.subtype, opts.effectType);
+  const effectKey =
+    typeof opts.effectType === 'string' && opts.effectType in EFFECT_OVERLAYS
+      ? (opts.effectType as FilamentEffect)
+      : null;
+  const effectLayer = effectKey ? EFFECT_OVERLAYS[effectKey] ?? null : null;
+  return [effectLayer, colorLayer, CHECKERBOARD_BG]
+    .filter((layer): layer is string => Boolean(layer))
+    .join(', ');
+}

+ 108 - 3
frontend/src/components/spool-form/ColorSection.tsx

@@ -1,8 +1,30 @@
 import { useState, useMemo } from 'react';
 import { useState, useMemo } from 'react';
-import { Search, Clock, ChevronDown, ChevronUp } from 'lucide-react';
+import { Search, Clock, ChevronDown, ChevronUp, Sparkles } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import type { ColorSectionProps, CatalogDisplayColor } from './types';
 import type { ColorSectionProps, CatalogDisplayColor } from './types';
 import { QUICK_COLORS, ALL_COLORS } from './constants';
 import { QUICK_COLORS, ALL_COLORS } from './constants';
+import { FilamentSwatch } from '../FilamentSwatch';
+import { buildFilamentBackground, FILAMENT_EFFECT_OPTIONS } from '../filamentSwatchHelpers';
+
+/** Parse user paste from 3dfilamentprofiles.com etc.: split on commas/whitespace,
+ *  drop the leading `#`, accept 6/8-char hex, lowercase. Returns null when no
+ *  valid stops are found. Mirrors the server-side validator output. */
+function normalizeExtraColorsInput(raw: string): { value: string; invalid: string[] } {
+  const tokens = raw
+    .split(/[\s,]+/)
+    .map((t) => t.trim().replace(/^#/, ''))
+    .filter(Boolean);
+  const valid: string[] = [];
+  const invalid: string[] = [];
+  for (const tok of tokens) {
+    if ((tok.length === 6 || tok.length === 8) && /^[0-9a-fA-F]+$/.test(tok)) {
+      valid.push(tok.toLowerCase());
+    } else {
+      invalid.push(tok);
+    }
+  }
+  return { value: valid.join(','), invalid };
+}
 
 
 export function ColorSection({
 export function ColorSection({
   formData,
   formData,
@@ -143,12 +165,41 @@ export function ColorSection({
     return showAllColors ? ALL_COLORS : QUICK_COLORS;
     return showAllColors ? ALL_COLORS : QUICK_COLORS;
   }, [colorSearch, showAllColors]);
   }, [colorSearch, showAllColors]);
 
 
+  // #1154: editable buffer for the multi-colour paste field. We keep the raw
+  // text the user typed/pasted so they can still see invalid tokens — only
+  // commit the canonical form to formData on blur or when valid.
+  const [extraColorsDraft, setExtraColorsDraft] = useState<string>(formData.extra_colors);
+  const [extraColorsErrors, setExtraColorsErrors] = useState<string[]>([]);
+  const previewBackground = useMemo(
+    () =>
+      buildFilamentBackground({
+        rgba: formData.rgba,
+        extraColors: formData.extra_colors,
+        effectType: formData.effect_type,
+        subtype: formData.subtype,
+      }),
+    [formData.rgba, formData.extra_colors, formData.effect_type, formData.subtype],
+  );
+
+  const commitExtraColors = (text: string) => {
+    setExtraColorsDraft(text);
+    if (!text.trim()) {
+      setExtraColorsErrors([]);
+      updateField('extra_colors', '');
+      return;
+    }
+    const { value, invalid } = normalizeExtraColorsInput(text);
+    setExtraColorsErrors(invalid);
+    updateField('extra_colors', value);
+  };
+
   return (
   return (
     <div className="space-y-3">
     <div className="space-y-3">
-      {/* Color preview banner */}
+      {/* Color preview banner — shows gradient + effect overlay. */}
       <div
       <div
         className="h-10 rounded-lg border border-bambu-dark-tertiary"
         className="h-10 rounded-lg border border-bambu-dark-tertiary"
-        style={{ backgroundColor: `#${currentHex}` }}
+        style={{ backgroundImage: previewBackground, backgroundSize: 'cover' }}
+        data-testid="color-preview-banner"
       />
       />
 
 
       {/* Recently Used Colors */}
       {/* Recently Used Colors */}
@@ -326,6 +377,60 @@ export function ColorSection({
           </div>
           </div>
         </div>
         </div>
       </div>
       </div>
+
+      {/* #1154: Multi-colour gradient stops + visual effect. Optional —
+          empty values keep the spool rendering as a solid swatch. */}
+      <div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-2 border-t border-bambu-dark-tertiary/50">
+        <div>
+          <label className="block text-sm font-medium text-bambu-gray mb-1">
+            {t('inventory.extraColorsLabel')}
+          </label>
+          <input
+            type="text"
+            className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm font-mono placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green"
+            placeholder={t('inventory.extraColorsPlaceholder')}
+            value={extraColorsDraft}
+            onChange={(e) => commitExtraColors(e.target.value)}
+            data-testid="extra-colors-input"
+          />
+          {extraColorsErrors.length > 0 && (
+            <p className="text-xs text-red-400 mt-1">
+              {t('inventory.extraColorsInvalid', { tokens: extraColorsErrors.join(', ') })}
+            </p>
+          )}
+          {!extraColorsErrors.length && (
+            <p className="text-xs text-bambu-gray/70 mt-1">{t('inventory.extraColorsHint')}</p>
+          )}
+        </div>
+        <div>
+          <label className="block text-sm font-medium text-bambu-gray mb-1 flex items-center gap-1.5">
+            <Sparkles className="w-3.5 h-3.5" />
+            {t('inventory.colorEffectLabel')}
+          </label>
+          <div className="flex gap-2 items-stretch">
+            <select
+              className="flex-1 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
+              value={formData.effect_type}
+              onChange={(e) => updateField('effect_type', e.target.value)}
+              data-testid="effect-type-select"
+            >
+              {FILAMENT_EFFECT_OPTIONS.map((opt) => (
+                <option key={opt.value || 'none'} value={opt.value}>
+                  {t(opt.labelKey)}
+                </option>
+              ))}
+            </select>
+            <FilamentSwatch
+              rgba={formData.rgba}
+              extraColors={formData.extra_colors}
+              effectType={formData.effect_type}
+              subtype={formData.subtype}
+              className="w-10 h-10"
+              shape="square"
+            />
+          </div>
+        </div>
+      </div>
     </div>
     </div>
   );
   );
 }
 }

+ 7 - 0
frontend/src/components/spool-form/types.ts

@@ -16,6 +16,11 @@ export interface SpoolFormData {
   brand: string;
   brand: string;
   color_name: string;
   color_name: string;
   rgba: string;
   rgba: string;
+  // #1154: extra gradient stops + visual effect. Stored as the canonical
+  // server form ("ec984c,6cd4bc,..." — no `#`, lowercase). Empty string means
+  // solid (the default).
+  extra_colors: string;
+  effect_type: string;
   label_weight: number;
   label_weight: number;
   core_weight: number;
   core_weight: number;
   core_weight_catalog_id: number | null;
   core_weight_catalog_id: number | null;
@@ -34,6 +39,8 @@ export const defaultFormData: SpoolFormData = {
   brand: '',
   brand: '',
   color_name: '',
   color_name: '',
   rgba: '808080FF',
   rgba: '808080FF',
+  extra_colors: '',
+  effect_type: '',
   label_weight: 1000,
   label_weight: 1000,
   core_weight: 250,
   core_weight: 250,
   core_weight_catalog_id: null,
   core_weight_catalog_id: null,

+ 26 - 0
frontend/src/i18n/locales/de.ts

@@ -3423,6 +3423,32 @@ export default {
     showAll: 'Alle',
     showAll: 'Alle',
     noColorsFound: 'Keine Farben gefunden',
     noColorsFound: 'Keine Farben gefunden',
     noResults: 'Keine Ergebnisse',
     noResults: 'Keine Ergebnisse',
+    // Multi-Color-Verlauf + visueller Effekt (#1154)
+    extraColorsLabel: 'Zusätzliche Farben',
+    extraColorsPlaceholder: 'EC984C,#6CD4BC,A66EB9,D87694',
+    extraColorsHint: '2 bis 8 Hex-Stops, durch Kommas getrennt. Wird als Verlauf dargestellt.',
+    extraColorsInvalid: 'Ungültige Hex-Werte ignoriert: {{tokens}}',
+    colorEffectLabel: 'Effekt',
+    colorEffect: {
+      none: 'Keiner',
+      // Oberflächeneffekte
+      sparkle: 'Glitzer',
+      wood: 'Holz',
+      marble: 'Marmor',
+      glow: 'Leuchtend',
+      matte: 'Matt',
+      // Glanz- / Finish-Varianten
+      silk: 'Seide',
+      galaxy: 'Galaxy',
+      rainbow: 'Regenbogen',
+      metal: 'Metallic',
+      translucent: 'Lichtdurchlässig',
+      // Mehrfarbige Varianten
+      gradient: 'Verlauf',
+      dualColor: 'Zweifarbig',
+      triColor: 'Dreifarbig',
+      multicolor: 'Mehrfarbig',
+    },
     selectMaterialFirst: 'Bitte zuerst ein Material im Filament-Info Tab auswählen.',
     selectMaterialFirst: 'Bitte zuerst ein Material im Filament-Info Tab auswählen.',
     noPrintersConfigured: 'Keine Drucker konfiguriert. Fügen Sie Drucker hinzu.',
     noPrintersConfigured: 'Keine Drucker konfiguriert. Fügen Sie Drucker hinzu.',
     matchingFilter: 'Filter',
     matchingFilter: 'Filter',

+ 26 - 0
frontend/src/i18n/locales/en.ts

@@ -3430,6 +3430,32 @@ export default {
     showAll: 'Show all',
     showAll: 'Show all',
     noColorsFound: 'No colors match your search',
     noColorsFound: 'No colors match your search',
     noResults: 'No matches found',
     noResults: 'No matches found',
+    // Multi-colour gradient + visual effect (#1154)
+    extraColorsLabel: 'Extra colours',
+    extraColorsPlaceholder: 'EC984C,#6CD4BC,A66EB9,D87694',
+    extraColorsHint: 'Paste 2 to 8 hex stops, separated by commas. Renders as a gradient.',
+    extraColorsInvalid: 'Ignored invalid hex: {{tokens}}',
+    colorEffectLabel: 'Effect',
+    colorEffect: {
+      none: 'None',
+      // Surface effects
+      sparkle: 'Sparkle',
+      wood: 'Wood',
+      marble: 'Marble',
+      glow: 'Glow',
+      matte: 'Matte',
+      // Sheen / finish variants
+      silk: 'Silk',
+      galaxy: 'Galaxy',
+      rainbow: 'Rainbow',
+      metal: 'Metal',
+      translucent: 'Translucent',
+      // Multi-colour structural variants
+      gradient: 'Gradient',
+      dualColor: 'Dual Color',
+      triColor: 'Tri Color',
+      multicolor: 'Multicolor',
+    },
     // PA Profiles
     // PA Profiles
     selectMaterialFirst: 'Please select a material first in the Filament Info tab.',
     selectMaterialFirst: 'Please select a material first in the Filament Info tab.',
     noPrintersConfigured: 'No printers configured. Add printers to use PA profiles.',
     noPrintersConfigured: 'No printers configured. Add printers to use PA profiles.',

+ 23 - 0
frontend/src/i18n/locales/fr.ts

@@ -3347,6 +3347,29 @@ export default {
     showAll: 'Toutes',
     showAll: 'Toutes',
     noColorsFound: 'Aucune couleur correspondante',
     noColorsFound: 'Aucune couleur correspondante',
     noResults: 'Aucun résultat',
     noResults: 'Aucun résultat',
+    // Multi-colour gradient + visual effect (#1154) — English fallback.
+    extraColorsLabel: 'Extra colours',
+    extraColorsPlaceholder: 'EC984C,#6CD4BC,A66EB9,D87694',
+    extraColorsHint: 'Paste 2 to 8 hex stops, separated by commas. Renders as a gradient.',
+    extraColorsInvalid: 'Ignored invalid hex: {{tokens}}',
+    colorEffectLabel: 'Effect',
+    colorEffect: {
+      none: 'None',
+      sparkle: 'Sparkle',
+      wood: 'Wood',
+      marble: 'Marble',
+      glow: 'Glow',
+      matte: 'Matte',
+      silk: 'Silk',
+      galaxy: 'Galaxy',
+      rainbow: 'Rainbow',
+      metal: 'Metal',
+      translucent: 'Translucent',
+      gradient: 'Gradient',
+      dualColor: 'Dual Color',
+      triColor: 'Tri Color',
+      multicolor: 'Multicolor',
+    },
     // PA Profiles
     // PA Profiles
     selectMaterialFirst: 'Veuillez choisir un matériau dans l\'onglet Infos Filament.',
     selectMaterialFirst: 'Veuillez choisir un matériau dans l\'onglet Infos Filament.',
     noPrintersConfigured: 'Ajoutez une imprimante pour utiliser les profils PA.',
     noPrintersConfigured: 'Ajoutez une imprimante pour utiliser les profils PA.',

+ 23 - 0
frontend/src/i18n/locales/it.ts

@@ -3346,6 +3346,29 @@ export default {
     showAll: 'Mostra tutto',
     showAll: 'Mostra tutto',
     noColorsFound: 'Nessun colore corrisponde alla ricerca',
     noColorsFound: 'Nessun colore corrisponde alla ricerca',
     noResults: 'Nessun risultato trovato',
     noResults: 'Nessun risultato trovato',
+    // Multi-colour gradient + visual effect (#1154) — English fallback.
+    extraColorsLabel: 'Extra colours',
+    extraColorsPlaceholder: 'EC984C,#6CD4BC,A66EB9,D87694',
+    extraColorsHint: 'Paste 2 to 8 hex stops, separated by commas. Renders as a gradient.',
+    extraColorsInvalid: 'Ignored invalid hex: {{tokens}}',
+    colorEffectLabel: 'Effect',
+    colorEffect: {
+      none: 'None',
+      sparkle: 'Sparkle',
+      wood: 'Wood',
+      marble: 'Marble',
+      glow: 'Glow',
+      matte: 'Matte',
+      silk: 'Silk',
+      galaxy: 'Galaxy',
+      rainbow: 'Rainbow',
+      metal: 'Metal',
+      translucent: 'Translucent',
+      gradient: 'Gradient',
+      dualColor: 'Dual Color',
+      triColor: 'Tri Color',
+      multicolor: 'Multicolor',
+    },
     // PA Profiles
     // PA Profiles
     selectMaterialFirst: 'Selezionare prima un materiale nella scheda Info filamento.',
     selectMaterialFirst: 'Selezionare prima un materiale nella scheda Info filamento.',
     noPrintersConfigured: 'Nessuna stampante configurata. Aggiungi stampanti per usare i profili PA.',
     noPrintersConfigured: 'Nessuna stampante configurata. Aggiungi stampanti per usare i profili PA.',

+ 23 - 0
frontend/src/i18n/locales/ja.ts

@@ -3385,6 +3385,29 @@ export default {
     showAll: 'すべて表示',
     showAll: 'すべて表示',
     noColorsFound: '一致する色がありません',
     noColorsFound: '一致する色がありません',
     noResults: '結果なし',
     noResults: '結果なし',
+    // Multi-colour gradient + visual effect (#1154) — English fallback.
+    extraColorsLabel: 'Extra colours',
+    extraColorsPlaceholder: 'EC984C,#6CD4BC,A66EB9,D87694',
+    extraColorsHint: 'Paste 2 to 8 hex stops, separated by commas. Renders as a gradient.',
+    extraColorsInvalid: 'Ignored invalid hex: {{tokens}}',
+    colorEffectLabel: 'Effect',
+    colorEffect: {
+      none: 'None',
+      sparkle: 'Sparkle',
+      wood: 'Wood',
+      marble: 'Marble',
+      glow: 'Glow',
+      matte: 'Matte',
+      silk: 'Silk',
+      galaxy: 'Galaxy',
+      rainbow: 'Rainbow',
+      metal: 'Metal',
+      translucent: 'Translucent',
+      gradient: 'Gradient',
+      dualColor: 'Dual Color',
+      triColor: 'Tri Color',
+      multicolor: 'Multicolor',
+    },
     // PA Profiles
     // PA Profiles
     selectMaterialFirst: 'フィラメント情報タブで素材を選択してください。',
     selectMaterialFirst: 'フィラメント情報タブで素材を選択してください。',
     noPrintersConfigured: 'プリンターが設定されていません。プリンターを追加してください。',
     noPrintersConfigured: 'プリンターが設定されていません。プリンターを追加してください。',

+ 23 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -3360,6 +3360,29 @@ export default {
     showAll: 'Mostrar tudo',
     showAll: 'Mostrar tudo',
     noColorsFound: 'Nenhuma cor corresponde à sua pesquisa',
     noColorsFound: 'Nenhuma cor corresponde à sua pesquisa',
     noResults: 'Nenhum resultado encontrado',
     noResults: 'Nenhum resultado encontrado',
+    // Multi-colour gradient + visual effect (#1154) — English fallback.
+    extraColorsLabel: 'Extra colours',
+    extraColorsPlaceholder: 'EC984C,#6CD4BC,A66EB9,D87694',
+    extraColorsHint: 'Paste 2 to 8 hex stops, separated by commas. Renders as a gradient.',
+    extraColorsInvalid: 'Ignored invalid hex: {{tokens}}',
+    colorEffectLabel: 'Effect',
+    colorEffect: {
+      none: 'None',
+      sparkle: 'Sparkle',
+      wood: 'Wood',
+      marble: 'Marble',
+      glow: 'Glow',
+      matte: 'Matte',
+      silk: 'Silk',
+      galaxy: 'Galaxy',
+      rainbow: 'Rainbow',
+      metal: 'Metal',
+      translucent: 'Translucent',
+      gradient: 'Gradient',
+      dualColor: 'Dual Color',
+      triColor: 'Tri Color',
+      multicolor: 'Multicolor',
+    },
     // PA Profiles
     // PA Profiles
     selectMaterialFirst: 'Por favor, selecione um material primeiro na aba Informações do Filamento.',
     selectMaterialFirst: 'Por favor, selecione um material primeiro na aba Informações do Filamento.',
     noPrintersConfigured: 'Nenhuma impressora configurada. Adicione impressoras para usar perfis PA.',
     noPrintersConfigured: 'Nenhuma impressora configurada. Adicione impressoras para usar perfis PA.',

+ 23 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -3417,6 +3417,29 @@ export default {
     showAll: '显示全部',
     showAll: '显示全部',
     noColorsFound: '没有颜色匹配您的搜索',
     noColorsFound: '没有颜色匹配您的搜索',
     noResults: '未找到匹配项',
     noResults: '未找到匹配项',
+    // 多色渐变 + 视觉效果 (#1154)
+    extraColorsLabel: '附加颜色',
+    extraColorsPlaceholder: 'EC984C,#6CD4BC,A66EB9,D87694',
+    extraColorsHint: '粘贴 2 到 8 个十六进制色值,以逗号分隔。将渲染为渐变。',
+    extraColorsInvalid: '已忽略无效的十六进制值:{{tokens}}',
+    colorEffectLabel: '效果',
+    colorEffect: {
+      none: '无',
+      sparkle: '闪光',
+      wood: '木纹',
+      marble: '大理石',
+      glow: '夜光',
+      matte: '哑光',
+      silk: '丝光',
+      galaxy: '星空',
+      rainbow: '彩虹',
+      metal: '金属',
+      translucent: '半透明',
+      gradient: '渐变',
+      dualColor: '双色',
+      triColor: '三色',
+      multicolor: '多色',
+    },
     // PA Profiles
     // PA Profiles
     selectMaterialFirst: '请先在耗材信息选项卡中选择材料。',
     selectMaterialFirst: '请先在耗材信息选项卡中选择材料。',
     noPrintersConfigured: '未配置打印机。添加打印机以使用 PA 配置。',
     noPrintersConfigured: '未配置打印机。添加打印机以使用 PA 配置。',

+ 23 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -3417,6 +3417,29 @@ export default {
     showAll: '顯示全部',
     showAll: '顯示全部',
     noColorsFound: '沒有顏色匹配您的搜尋',
     noColorsFound: '沒有顏色匹配您的搜尋',
     noResults: '未找到匹配項',
     noResults: '未找到匹配項',
+    // 多色漸層 + 視覺效果 (#1154)
+    extraColorsLabel: '附加顏色',
+    extraColorsPlaceholder: 'EC984C,#6CD4BC,A66EB9,D87694',
+    extraColorsHint: '貼上 2 至 8 個十六進位色值,以逗號分隔。將以漸層方式呈現。',
+    extraColorsInvalid: '已略過無效的十六進位值:{{tokens}}',
+    colorEffectLabel: '效果',
+    colorEffect: {
+      none: '無',
+      sparkle: '閃光',
+      wood: '木紋',
+      marble: '大理石',
+      glow: '夜光',
+      matte: '霧面',
+      silk: '絲光',
+      galaxy: '星空',
+      rainbow: '彩虹',
+      metal: '金屬',
+      translucent: '半透明',
+      gradient: '漸層',
+      dualColor: '雙色',
+      triColor: '三色',
+      multicolor: '多色',
+    },
     // PA Profiles
     // PA Profiles
     selectMaterialFirst: '請先在耗材資訊分頁中選擇材料。',
     selectMaterialFirst: '請先在耗材資訊分頁中選擇材料。',
     noPrintersConfigured: '未設定印表機。新增印表機以使用 PA 設定。',
     noPrintersConfigured: '未設定印表機。新增印表機以使用 PA 設定。',

+ 26 - 14
frontend/src/pages/InventoryPage.tsx

@@ -10,6 +10,8 @@ import {
 import { api, spoolbuddyApi } from '../api/client';
 import { api, spoolbuddyApi } from '../api/client';
 import type { InventorySpool, SpoolAssignment, SpoolCatalogEntry } from '../api/client';
 import type { InventorySpool, SpoolAssignment, SpoolCatalogEntry } from '../api/client';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
+import { FilamentSwatch } from '../components/FilamentSwatch';
+import { buildFilamentBackground } from '../components/filamentSwatchHelpers';
 import { SpoolFormModal } from '../components/SpoolFormModal';
 import { SpoolFormModal } from '../components/SpoolFormModal';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { ColumnConfigModal, type ColumnConfig } from '../components/ColumnConfigModal';
 import { ColumnConfigModal, type ColumnConfig } from '../components/ColumnConfigModal';
@@ -30,7 +32,10 @@ type DisplayItem =
   | { type: 'group'; key: string; spools: InventorySpool[]; representative: InventorySpool };
   | { type: 'group'; key: string; spools: InventorySpool[]; representative: InventorySpool };
 
 
 function spoolGroupKey(s: InventorySpool): string {
 function spoolGroupKey(s: InventorySpool): string {
-  return `${s.material}|${s.subtype || ''}|${s.brand || ''}|${s.color_name || ''}|${s.rgba || ''}|${s.label_weight}`;
+  // Include extra_colors + effect_type so the "Group similar" toggle does
+  // not collapse two spools that share the base colour but differ on
+  // gradient stops or visual effect (#1154).
+  return `${s.material}|${s.subtype || ''}|${s.brand || ''}|${s.color_name || ''}|${s.rgba || ''}|${s.extra_colors || ''}|${s.effect_type || ''}|${s.label_weight}`;
 }
 }
 
 
 // Column definitions for the inventory table
 // Column definitions for the inventory table
@@ -180,10 +185,11 @@ const columnCells: Record<string, (ctx: CellCtx) => ReactNode> = {
   ),
   ),
   rgba: ({ spool }) => (
   rgba: ({ spool }) => (
     <div className="flex items-center justify-center">
     <div className="flex items-center justify-center">
-      <span
-        className="w-5 h-5 rounded-full border border-black/20 flex-shrink-0"
-        style={{ backgroundColor: spool.rgba ? `#${spool.rgba.substring(0, 6)}` : '#808080' }}
-        title={spool.rgba ? `#${spool.rgba.substring(0, 6)}` : undefined}
+      <FilamentSwatch
+        rgba={spool.rgba}
+        extraColors={spool.extra_colors}
+        effectType={spool.effect_type}
+        subtype={spool.subtype}
       />
       />
     </div>
     </div>
   ),
   ),
@@ -1289,7 +1295,12 @@ function InventoryPage() {
               {pagedItems.map((item) => {
               {pagedItems.map((item) => {
                 if (item.type === 'group') {
                 if (item.type === 'group') {
                   const { key, spools: groupSpools, representative: rep } = item;
                   const { key, spools: groupSpools, representative: rep } = item;
-                  const colorStyle = rep.rgba ? `#${rep.rgba.substring(0, 6)}` : '#808080';
+                  const groupBannerImage = buildFilamentBackground({
+                    rgba: rep.rgba,
+                    extraColors: rep.extra_colors,
+                    effectType: rep.effect_type,
+                    subtype: rep.subtype,
+                  });
                   const isExpanded = expandedGroups.has(key);
                   const isExpanded = expandedGroups.has(key);
                   return (
                   return (
                     <div key={`group-${key}`} className="col-span-full">
                     <div key={`group-${key}`} className="col-span-full">
@@ -1298,7 +1309,7 @@ function InventoryPage() {
                         className="bg-bambu-dark-secondary rounded-lg overflow-hidden border border-bambu-green/30 hover:border-bambu-green transition-colors cursor-pointer"
                         className="bg-bambu-dark-secondary rounded-lg overflow-hidden border border-bambu-green/30 hover:border-bambu-green transition-colors cursor-pointer"
                         onClick={() => toggleGroupExpand(key)}
                         onClick={() => toggleGroupExpand(key)}
                       >
                       >
-                        <div className="h-10 flex items-center px-4 gap-3" style={{ backgroundColor: colorStyle }}>
+                        <div className="h-10 flex items-center px-4 gap-3" style={{ backgroundImage: groupBannerImage, backgroundSize: 'cover' }}>
                           <span className="bg-white/90 text-gray-800 px-3 py-0.5 rounded-full text-sm font-medium">
                           <span className="bg-white/90 text-gray-800 px-3 py-0.5 rounded-full text-sm font-medium">
                             {resolveSpoolColorName(rep.color_name, rep.rgba) || '-'}
                             {resolveSpoolColorName(rep.color_name, rep.rgba) || '-'}
                           </span>
                           </span>
@@ -1325,14 +1336,12 @@ function InventoryPage() {
                           {groupSpools.map((spool) => {
                           {groupSpools.map((spool) => {
                             const remaining = Math.max(0, spool.label_weight - spool.weight_used);
                             const remaining = Math.max(0, spool.label_weight - spool.weight_used);
                             const pct = spool.label_weight > 0 ? (remaining / spool.label_weight) * 100 : 0;
                             const pct = spool.label_weight > 0 ? (remaining / spool.label_weight) * 100 : 0;
-                            const spoolColor = spool.rgba ? `#${spool.rgba.substring(0, 6)}` : '#808080';
                             return (
                             return (
                               <SpoolCard
                               <SpoolCard
                                 key={spool.id}
                                 key={spool.id}
                                 spool={spool}
                                 spool={spool}
                                 remaining={remaining}
                                 remaining={remaining}
                                 pct={pct}
                                 pct={pct}
-                                colorStyle={spoolColor}
                                 onClick={() => setFormModal({ spool })}
                                 onClick={() => setFormModal({ spool })}
                                 t={t}
                                 t={t}
                               />
                               />
@@ -1346,14 +1355,12 @@ function InventoryPage() {
                 const spool = item.spool;
                 const spool = item.spool;
                 const remaining = Math.max(0, spool.label_weight - spool.weight_used);
                 const remaining = Math.max(0, spool.label_weight - spool.weight_used);
                 const pct = spool.label_weight > 0 ? (remaining / spool.label_weight) * 100 : 0;
                 const pct = spool.label_weight > 0 ? (remaining / spool.label_weight) * 100 : 0;
-                const colorStyle = spool.rgba ? `#${spool.rgba.substring(0, 6)}` : '#808080';
                 return (
                 return (
                   <SpoolCard
                   <SpoolCard
                     key={spool.id}
                     key={spool.id}
                     spool={spool}
                     spool={spool}
                     remaining={remaining}
                     remaining={remaining}
                     pct={pct}
                     pct={pct}
-                    colorStyle={colorStyle}
                     onClick={() => setFormModal({ spool })}
                     onClick={() => setFormModal({ spool })}
                     t={t}
                     t={t}
                   />
                   />
@@ -1662,21 +1669,26 @@ function PaginationBar({
 
 
 /* Spool card for cards view */
 /* Spool card for cards view */
 function SpoolCard({
 function SpoolCard({
-  spool, remaining, pct, colorStyle, onClick, t,
+  spool, remaining, pct, onClick, t,
 }: {
 }: {
   spool: InventorySpool;
   spool: InventorySpool;
   remaining: number;
   remaining: number;
   pct: number;
   pct: number;
-  colorStyle: string;
   onClick: () => void;
   onClick: () => void;
   t: (key: string, opts?: Record<string, unknown>) => string;
   t: (key: string, opts?: Record<string, unknown>) => string;
 }) {
 }) {
+  const bannerImage = buildFilamentBackground({
+    rgba: spool.rgba,
+    extraColors: spool.extra_colors,
+    effectType: spool.effect_type,
+    subtype: spool.subtype,
+  });
   return (
   return (
     <div
     <div
       className={`bg-bambu-dark-secondary rounded-lg overflow-hidden border border-bambu-dark-tertiary hover:border-bambu-green transition-colors cursor-pointer ${spool.archived_at ? 'opacity-50' : ''}`}
       className={`bg-bambu-dark-secondary rounded-lg overflow-hidden border border-bambu-dark-tertiary hover:border-bambu-green transition-colors cursor-pointer ${spool.archived_at ? 'opacity-50' : ''}`}
       onClick={onClick}
       onClick={onClick}
     >
     >
-      <div className="h-14 flex items-center justify-center" style={{ backgroundColor: colorStyle }}>
+      <div className="h-14 flex items-center justify-center" style={{ backgroundImage: bannerImage, backgroundSize: 'cover' }}>
         <span className="bg-white/90 text-gray-800 px-3 py-0.5 rounded-full text-sm font-medium">
         <span className="bg-white/90 text-gray-800 px-3 py-0.5 rounded-full text-sm font-medium">
           {resolveSpoolColorName(spool.color_name, spool.rgba) || '-'}
           {resolveSpoolColorName(spool.color_name, spool.rgba) || '-'}
         </span>
         </span>

+ 2 - 0
frontend/src/pages/spoolbuddy/SpoolBuddyDashboard.tsx

@@ -287,6 +287,8 @@ export function SpoolBuddyDashboard() {
         subtype: null,
         subtype: null,
         color_name: null,
         color_name: null,
         rgba: null,
         rgba: null,
+        extra_colors: null,
+        effect_type: null,
         brand: null,
         brand: null,
         label_weight: 1000,
         label_weight: 1000,
         core_weight: 250,
         core_weight: 250,

+ 2 - 0
frontend/src/pages/spoolbuddy/SpoolBuddyWriteTagPage.tsx

@@ -637,6 +637,8 @@ function NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, t }: {
       brand: formData.brand || null,
       brand: formData.brand || null,
       color_name: formData.color_name || null,
       color_name: formData.color_name || null,
       rgba: formData.rgba || null,
       rgba: formData.rgba || null,
+      extra_colors: formData.extra_colors || null,
+      effect_type: formData.effect_type || null,
       label_weight: formData.label_weight,
       label_weight: formData.label_weight,
       core_weight: formData.core_weight,
       core_weight: formData.core_weight,
       core_weight_catalog_id: formData.core_weight_catalog_id,
       core_weight_catalog_id: formData.core_weight_catalog_id,

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/index-DE-w2t-x.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
 
     <!-- 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-8qP2vyrm.js"></script>
+    <script type="module" crossorigin src="/assets/index-DE-w2t-x.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-7GmlJb0k.css">
     <link rel="stylesheet" crossorigin href="/assets/index-7GmlJb0k.css">
   </head>
   </head>
   <body>
   <body>

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů