Sfoglia il codice sorgente

Paint an AMS slot card with the spool's colours, not the tray's (issue #2967)

A Ziro "Colorful Mist" -- yellow, cyan and pink, effect Tri Color --
hovered on the printer card as a single flat pink rectangle. A printer
reports exactly one tray_color hex per tray and nothing else, so
telemetry cannot describe a gradient or a surface effect and never will.

The header now paints the bound spool's own swatch whenever that spool
declares extra colour stops or an effect, through buildFilamentBackground
-- the builder the Inventory swatches already use, so the two surfaces
cannot drift apart. A plain single-colour spool keeps the flat
backgroundColor it has always had, and a slot with nothing bound is
untouched, so the common case goes nowhere near the gradient path.

The gate is "any stop at all", not "more than one". buildColorLayer
ignores rgba the moment stops exist, so a one-stop spool renders that
stop rather than the slot hex; skipping it would leave this card showing
a different colour from the Inventory row for the same spool, which is
the class of disagreement the shared builder exists to prevent.

isLightColor now tests the colour actually on screen. Once the spool's
swatch is painted the base is no longer the slot hex -- a single stop
replaces it outright, and an effect-only spool paints the spool's own
rgba -- so testing the slot hex would pick the text colour for a
background that is not there.

Above one band no single hex can decide legibility, and the name sits
dead centre where a multi-stop background is likeliest to change under
it. So a genuinely multi-band header puts the name on the same scrim the
vendor badge already uses. One stop, or an effect over one colour, still
vendor badge already uses. One stop, or an effect over one colour, still
leaves a real base colour to test and keeps the contrast rule it had.

Spoolman mode gains the gradient in the process. Spoolman has held the
stops in filament.multi_color_hexes all along and the label renderer has
been reading them for releases, but _map_spoolman_spool never returned
them -- so the identical roll registered in Spoolman rendered flat while
the internally-managed one did not. Both now share one parser rather
than reading the same field two ways.

What stays asymmetric is Spoolman's own limitation, and it is pinned by
a test rather than left to be rediscovered: Spoolman has no field for a
surface effect at all. Its only neighbouring field,
multi_color_direction, says how the stops are laid out, not that the
roll is silk or glitter. effect_type is therefore None for a Spoolman
spool instead of guessed at, and silk/sparkle/wood remain internal-only.

The other two halves of the report -- the header naming the colour
"White" instead of "Colorful Mist", and the print dialog offering
"A3: PLA (White)" -- were already fixed on dev by #2875 and by the
slot-naming change that landed the day after this was filed. Neither is
in 1.2.5.3, which is what the reporter is running.
maziggy 1 settimana fa
parent
commit
699fc419fe

File diff suppressed because it is too large
+ 0 - 0
CHANGELOG.md


+ 35 - 0
backend/app/api/routes/_spoolman_helpers.py

@@ -28,6 +28,8 @@ class MappedSpoolFields(TypedDict):
     color_name: str | None
     color_name_is_synthesized: bool
     rgba: str | None
+    extra_colors: str | None
+    effect_type: None
     label_weight: int | None
     core_weight: int | None
     core_weight_catalog_id: None
@@ -156,6 +158,30 @@ def _extract_extra_str(extra: dict, key: str) -> str:
     return decoded if isinstance(decoded, str) else ""
 
 
+def parse_spoolman_multi_colors(filament: dict) -> list[str]:
+    """Spoolman's ``multi_color_hexes`` as a list of bare 6/8-char hex tokens.
+
+    Spoolman stores the extra stops of a gradient / dual / multi-colour
+    filament here, and writes the field as a comma-separated string in some
+    releases and a list in others -- both shapes are accepted. Tokens keep the
+    case they arrived in and lose any leading ``#``, which is the form
+    ``Spool.extra_colors`` stores and ``parseStops`` on the client expects.
+
+    Shared with the label renderer rather than parsed twice: the two read the
+    same field for the same purpose, and a swatch on a printer card that
+    disagreed with the swatch on the printed label would be worse than either
+    being wrong on its own.
+    """
+    raw = filament.get("multi_color_hexes")
+    if isinstance(raw, str):
+        tokens = raw.split(",")
+    elif isinstance(raw, list):
+        tokens = [str(token) for token in raw]
+    else:
+        return []
+    return [cleaned for token in tokens if (cleaned := token.strip().lstrip("#"))]
+
+
 def _map_spoolman_spool(spool: dict) -> MappedSpoolFields:
     """Convert a raw Spoolman spool dict to the InventorySpool-compatible format.
 
@@ -206,6 +232,13 @@ def _map_spoolman_spool(spool: dict) -> MappedSpoolFields:
     raw_color = (filament.get("color_hex") or "").upper().removeprefix("#")
     color_hex: str = raw_color if _COLOR_HEX_RE.match(raw_color) else "808080"
     rgba: str = color_hex if len(color_hex) == 8 else color_hex + "FF"
+    # Spoolman carries the extra stops but has no concept of a surface effect
+    # -- its only neighbouring field is `multi_color_direction`, which says how
+    # the stops are laid out, not that the filament is silk or glitter. So a
+    # Spoolman spool can render its gradient and never an effect overlay, and
+    # `effect_type` is pinned to None rather than guessed at.
+    extra_stops = parse_spoolman_multi_colors(filament)
+    extra_colors: str | None = ",".join(extra_stops) if extra_stops else None
 
     label_weight: int = _safe_int(filament.get("weight"), 1000)
     real_used_weight: float = _safe_float(spool.get("used_weight"), 0.0)
@@ -270,6 +303,8 @@ def _map_spoolman_spool(spool: dict) -> MappedSpoolFields:
         "color_name": color_name,
         "color_name_is_synthesized": color_name_is_synthesized,
         "rgba": rgba,
+        "extra_colors": extra_colors,
+        "effect_type": None,
         "brand": vendor.get("name") or None,
         "label_weight": label_weight,
         "core_weight": _safe_int(

+ 4 - 6
backend/app/api/routes/labels.py

@@ -23,6 +23,7 @@ from pydantic import BaseModel, Field, model_validator
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.api.routes._spoolman_helpers import parse_spoolman_multi_colors
 from backend.app.api.routes.settings import get_setting
 from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.database import get_db
@@ -127,12 +128,9 @@ def _spoolman_dict_to_label_data(s: dict, deeplink_base: str) -> LabelData:
     color_hex = filament.get("color_hex")
     rgba = color_hex.lstrip("#") if isinstance(color_hex, str) else None
 
-    multi_colors = filament.get("multi_color_hexes")
-    extra: list[str] | None = None
-    if isinstance(multi_colors, str) and multi_colors.strip():
-        extra = [tok.strip().lstrip("#") for tok in multi_colors.split(",") if tok.strip()]
-    elif isinstance(multi_colors, list):
-        extra = [str(t).strip().lstrip("#") for t in multi_colors if str(t).strip()]
+    # Shared with `_map_spoolman_spool`, so the swatch printed on a label and
+    # the swatch drawn on an AMS slot card cannot read the same field two ways.
+    extra: list[str] | None = parse_spoolman_multi_colors(filament) or None
 
     return LabelData(
         spool_id=int(s.get("id", 0)),

+ 102 - 0
backend/tests/unit/test_spoolman_multi_colors_2967.py

@@ -0,0 +1,102 @@
+"""Spoolman spools carry their gradient stops into the app (#2967).
+
+An AMS slot card paints the colour of the spool bound to it. Telemetry cannot
+supply that for anything with more than one colour -- a tray record carries a
+single ``tray_color`` hex -- so the card reads the spool's own
+``extra_colors``. Internal inventory has stored that for releases;
+``_map_spoolman_spool`` never returned it, so the identical roll registered in
+Spoolman rendered as one flat band.
+
+Spoolman does hold the data, in ``filament.multi_color_hexes``. What it has no
+field for is a surface *effect*: its only neighbouring field is
+``multi_color_direction``, which describes how the stops are laid out, not that
+the filament is silk or glitter. So ``effect_type`` is pinned to None for a
+Spoolman spool rather than guessed at, and these tests pin that asymmetry so it
+reads as a decision rather than an omission.
+"""
+
+import pytest
+
+from backend.app.api.routes._spoolman_helpers import (
+    _map_spoolman_spool,
+    parse_spoolman_multi_colors,
+)
+
+pytestmark = pytest.mark.unit
+
+
+def _spool(**filament) -> dict:
+    return {"id": 7, "filament": {"name": "PLA Matte", "material": "PLA", **filament}}
+
+
+class TestTheParser:
+    def test_reads_the_comma_separated_string_form(self):
+        assert parse_spoolman_multi_colors({"multi_color_hexes": "FFFF00,00FFFF,FFB6C1"}) == [
+            "FFFF00",
+            "00FFFF",
+            "FFB6C1",
+        ]
+
+    def test_reads_the_list_form(self):
+        # Spoolman writes the field as a list in some releases.
+        assert parse_spoolman_multi_colors({"multi_color_hexes": ["AABBCC", "DDEEFF"]}) == [
+            "AABBCC",
+            "DDEEFF",
+        ]
+
+    def test_strips_hashes_and_whitespace(self):
+        # `Spool.extra_colors` and the client's `parseStops` both want bare hex.
+        assert parse_spoolman_multi_colors({"multi_color_hexes": " #FFFF00 , #00FFFF "}) == [
+            "FFFF00",
+            "00FFFF",
+        ]
+
+    def test_keeps_the_case_it_was_given(self):
+        # CSS does not care, and rewriting it would make a stored value differ
+        # from the one Spoolman shows next to it.
+        assert parse_spoolman_multi_colors({"multi_color_hexes": "ffb6c1"}) == ["ffb6c1"]
+
+    def test_drops_empty_tokens_rather_than_emitting_blanks(self):
+        assert parse_spoolman_multi_colors({"multi_color_hexes": "FFFF00,,  ,00FFFF"}) == [
+            "FFFF00",
+            "00FFFF",
+        ]
+
+    @pytest.mark.parametrize("value", [None, "", "   ", 42, {}, [], [" ", ""]])
+    def test_anything_unusable_reads_as_no_stops(self, value):
+        assert parse_spoolman_multi_colors({"multi_color_hexes": value}) == []
+
+    def test_a_filament_without_the_field_reads_as_no_stops(self):
+        assert parse_spoolman_multi_colors({}) == []
+
+
+class TestTheMappedSpool:
+    def test_multi_colour_stops_reach_extra_colors(self):
+        mapped = _map_spoolman_spool(_spool(multi_color_hexes="FFFF00,00FFFF,FFB6C1"))
+        assert mapped["extra_colors"] == "FFFF00,00FFFF,FFB6C1"
+
+    def test_a_single_colour_spool_has_no_extra_colors(self):
+        # None rather than "": the client tests the field for truthiness before
+        # it goes anywhere near the gradient builder.
+        assert _map_spoolman_spool(_spool(color_hex="FFB6C1"))["extra_colors"] is None
+
+    def test_effect_type_is_none_because_spoolman_has_no_such_field(self):
+        mapped = _map_spoolman_spool(_spool(multi_color_hexes="FFFF00,00FFFF"))
+        assert mapped["effect_type"] is None
+
+    def test_multi_color_direction_is_not_mistaken_for_an_effect(self):
+        # It describes the layout of the stops, not the surface of the roll.
+        mapped = _map_spoolman_spool(_spool(multi_color_hexes="FFFF00,00FFFF", multi_color_direction="longitudinal"))
+        assert mapped["effect_type"] is None
+
+    def test_the_base_colour_still_maps_alongside_the_stops(self):
+        mapped = _map_spoolman_spool(_spool(color_hex="FFB6C1", multi_color_hexes="FFFF00,00FFFF"))
+        assert mapped["rgba"] == "FFB6C1FF"
+        assert mapped["extra_colors"] == "FFFF00,00FFFF"
+
+    def test_both_keys_are_always_present_so_the_shape_never_varies(self):
+        # The frontend InventorySpool type declares them non-optional; a spool
+        # that omitted them would read as `undefined` rather than `null`.
+        mapped = _map_spoolman_spool(_spool())
+        assert "extra_colors" in mapped
+        assert "effect_type" in mapped

+ 151 - 0
frontend/src/__tests__/components/FilamentHoverCard.test.tsx

@@ -731,3 +731,154 @@ describe('FilamentHoverCard assigned spool name', () => {
     );
   });
 });
+
+/**
+ * The header swatch (#2967).
+ *
+ * A tray record carries one `tray_color` hex and nothing else, so telemetry can
+ * never describe a gradient or a surface effect. The reporter's Ziro "Colorful
+ * Mist" -- yellow, cyan and pink, effect Tri Color -- painted the header as one
+ * flat band of the single hex the slot happened to be configured with. The
+ * spool knows better, and the header now paints the spool's own swatch through
+ * the same builder the Inventory swatches use.
+ */
+describe('FilamentHoverCard — assigned spool swatch (#2967)', () => {
+  beforeEach(() => {
+    vi.useFakeTimers({ shouldAdvanceTime: true });
+  });
+
+  const triColorSpool = {
+    id: 191,
+    material: 'PLA',
+    subtype: 'Matte',
+    brand: 'Ziro',
+    color_name: 'Colorful Mist',
+    rgba: 'FFB6C1FF',
+    extra_colors: 'ffff00,00ffff,ffb6c1',
+    effect_type: 'tri-color',
+  };
+
+  // The swatch header is the card's first child: a fixed-height block carrying
+  // the colour and the name. Queried off `document` rather than the render
+  // container because the card is portaled into document.body.
+  function header(): HTMLElement {
+    const el = document.querySelector('.h-12') as HTMLElement | null;
+    expect(el, 'header block not found').not.toBeNull();
+    return el as HTMLElement;
+  }
+
+  it('paints the gradient for a multi-colour spool', async () => {
+    renderWithHover(
+      <FilamentHoverCard data={baseFilamentData} inventory={{ assignedSpool: triColorSpool }}>
+        <div>trigger</div>
+      </FilamentHoverCard>
+    );
+    vi.advanceTimersByTime(100);
+    await waitFor(() => expect(screen.getByText('Colorful Mist')).toBeInTheDocument());
+    expect(header().style.backgroundImage).toContain('gradient');
+  });
+
+  it('carries every stop the spool declared', async () => {
+    renderWithHover(
+      <FilamentHoverCard data={baseFilamentData} inventory={{ assignedSpool: triColorSpool }}>
+        <div>trigger</div>
+      </FilamentHoverCard>
+    );
+    vi.advanceTimersByTime(100);
+    await waitFor(() => expect(screen.getByText('Colorful Mist')).toBeInTheDocument());
+    const image = header().style.backgroundImage.toLowerCase();
+    expect(image).toContain('#ffff00');
+    expect(image).toContain('#00ffff');
+    expect(image).toContain('#ffb6c1');
+  });
+
+  it('leaves a plain single-colour spool on the flat slot colour', async () => {
+    // The common case must not go anywhere near the gradient machinery.
+    renderWithHover(
+      <FilamentHoverCard
+        data={baseFilamentData}
+        inventory={{
+          assignedSpool: { ...triColorSpool, extra_colors: null, effect_type: null },
+        }}
+      >
+        <div>trigger</div>
+      </FilamentHoverCard>
+    );
+    vi.advanceTimersByTime(100);
+    await waitFor(() => expect(screen.getByText('Colorful Mist')).toBeInTheDocument());
+    expect(header().style.backgroundImage).toBe('');
+  });
+
+  it('paints the swatch for an effect with no extra colours', async () => {
+    // A silk roll is one colour with a surface the hex cannot express.
+    renderWithHover(
+      <FilamentHoverCard
+        data={baseFilamentData}
+        inventory={{ assignedSpool: { ...triColorSpool, extra_colors: null, effect_type: 'silk' } }}
+      >
+        <div>trigger</div>
+      </FilamentHoverCard>
+    );
+    vi.advanceTimersByTime(100);
+    await waitFor(() => expect(screen.getByText('Colorful Mist')).toBeInTheDocument());
+    expect(header().style.backgroundImage).not.toBe('');
+  });
+
+  it('puts the name on a scrim when the background has several bands', async () => {
+    // One hex cannot decide legibility across yellow, cyan and pink, and the
+    // label sits dead centre where the background is most likely to change.
+    renderWithHover(
+      <FilamentHoverCard data={baseFilamentData} inventory={{ assignedSpool: triColorSpool }}>
+        <div>trigger</div>
+      </FilamentHoverCard>
+    );
+    vi.advanceTimersByTime(100);
+    await waitFor(() => expect(screen.getByText('Colorful Mist')).toBeInTheDocument());
+    expect(screen.getByText('Colorful Mist').className).toContain('bg-black/60');
+  });
+
+  it('does not scrim a single-colour spool', async () => {
+    renderWithHover(
+      <FilamentHoverCard
+        data={baseFilamentData}
+        inventory={{
+          assignedSpool: { ...triColorSpool, extra_colors: null, effect_type: null },
+        }}
+      >
+        <div>trigger</div>
+      </FilamentHoverCard>
+    );
+    vi.advanceTimersByTime(100);
+    await waitFor(() => expect(screen.getByText('Colorful Mist')).toBeInTheDocument());
+    expect(screen.getByText('Colorful Mist').className).not.toContain('bg-black/60');
+  });
+
+  it('keeps a slot with no assigned spool exactly as it was', async () => {
+    renderWithHover(
+      <FilamentHoverCard data={baseFilamentData} inventory={{ assignedSpool: null }}>
+        <div>trigger</div>
+      </FilamentHoverCard>
+    );
+    vi.advanceTimersByTime(100);
+    await waitFor(() => expect(screen.getByText('Red')).toBeInTheDocument());
+    expect(header().style.backgroundImage).toBe('');
+  });
+
+  it('tolerates a spool that predates the swatch fields', async () => {
+    // Older callers send neither rgba nor extra_colors; the optional fields
+    // must not turn that into a blank or a crash.
+    renderWithHover(
+      <FilamentHoverCard
+        data={baseFilamentData}
+        inventory={{
+          assignedSpool: { id: 1, material: 'PLA', subtype: null, brand: 'Ziro', color_name: 'Mist' },
+        }}
+      >
+        <div>trigger</div>
+      </FilamentHoverCard>
+    );
+    vi.advanceTimersByTime(100);
+    await waitFor(() => expect(screen.getByText('Mist')).toBeInTheDocument());
+    expect(header().style.backgroundImage).toBe('');
+  });
+});

+ 78 - 10
frontend/src/components/FilamentHoverCard.tsx

@@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom';
 import { useTranslation } from 'react-i18next';
 import { Droplets, Copy, Check, Settings2, Package, Unlink } from 'lucide-react';
 import { isLightColor, resolveSpoolColorName } from '../utils/colors';
+import { buildFilamentBackground, parseStops } from './filamentSwatchHelpers';
 
 interface FilamentData {
   vendor: 'Bambu Lab' | 'Generic';
@@ -36,7 +37,20 @@ interface InventoryConfig {
   // `subtype` is part of the spool's name, not decoration: "PLA" and "PLA Wood"
   // are different filaments, and a card that prints only the material tells the
   // user their wood-filled roll is plain PLA (the display-side half of #2902).
-  assignedSpool?: { id: number; material: string; subtype: string | null; brand: string | null; color_name: string | null; remainingWeightGrams?: number | null } | null;
+  // `rgba` / `extra_colors` / `effect_type` are the spool's own swatch. The
+  // slot's telemetry colour is a single hex and can never describe a gradient
+  // or a surface effect, so a tri-colour roll read as one flat band (#2967).
+  assignedSpool?: {
+    id: number;
+    material: string;
+    subtype: string | null;
+    brand: string | null;
+    color_name: string | null;
+    rgba?: string | null;
+    extra_colors?: string | null;
+    effect_type?: string | null;
+    remainingWeightGrams?: number | null;
+  } | null;
   isAssigned?: boolean;
 }
 
@@ -205,6 +219,52 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
   const displayColorName = assignedColorName || data.colorName;
   const assignedRemainingWeight = inventory?.assignedSpool?.remainingWeightGrams ?? null;
 
+  // The header paints the spool's own swatch whenever the spool describes more
+  // than one colour, or any surface effect (#2967). Telemetry cannot: a tray
+  // record carries a single `tray_color` hex, so a Tri Color roll of yellow,
+  // cyan and pink read as one flat band of whichever hex the slot was
+  // configured with.
+  //
+  // Only when the spool actually says something extra. A plain single-colour
+  // spool keeps the flat `backgroundColor` it has always had, so the common
+  // case is untouched and the gradient machinery cannot regress it.
+  const assignedSwatch = inventory?.assignedSpool ?? null;
+  const swatchStops = parseStops(assignedSwatch?.extra_colors);
+  const hasSwatchEffect = Boolean(assignedSwatch?.effect_type);
+  // Any stop at all, not just two: `buildColorLayer` ignores `rgba` the moment
+  // stops exist, so a one-stop spool renders that stop's colour and not the
+  // slot hex. Honouring it here is what keeps this header agreeing with the
+  // Inventory swatch, which is the whole point of sharing the builder.
+  const useSpoolSwatch = Boolean(assignedSwatch) && (swatchStops.length > 0 || hasSwatchEffect);
+  // Built from the spool end to end when used. Mixing the spool's stops over
+  // the slot's base hex would render a gradient the user never configured if
+  // the two ever disagreed.
+  const spoolSwatchStyle = useSpoolSwatch
+    ? buildFilamentBackground({
+        effectSize: 'card',
+        rgba: assignedSwatch?.rgba ?? colorHex,
+        extraColors: assignedSwatch?.extra_colors,
+        effectType: assignedSwatch?.effect_type,
+        subtype: assignedSwatch?.subtype,
+      })
+    : null;
+  // A single hex cannot decide legibility across several bands, and the header
+  // label sits dead centre where a multi-stop background is most likely to
+  // change under it. So a genuinely multi-band swatch puts the name on the same
+  // scrim the vendor badge already uses rather than betting on one of the
+  // stops. One stop, or an effect over one colour, leaves the base colour
+  // intact -- the contrast test still has a real answer there, and scrimming
+  // every effect spool would put a black pill on cards that never needed one.
+  const swatchNeedsScrim = swatchStops.length > 1;
+  // Which colour the contrast test should actually run against. Not always the
+  // slot hex any more: once the spool's swatch is painted, a single stop
+  // replaces the base entirely, and an effect-only spool paints the spool's own
+  // rgba rather than the slot's. Testing the slot hex in either case would pick
+  // the text colour for a background that is no longer on screen.
+  const contrastBaseHex = useSpoolSwatch
+    ? (swatchStops.length === 1 ? swatchStops[0] : assignedSwatch?.rgba ?? colorHex)
+    : colorHex;
+
   return (
     <div
       ref={triggerRef}
@@ -243,20 +303,28 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
             {/* Color swatch header - the hero element */}
             <div
               className="h-12 relative overflow-hidden"
-              style={{
-                backgroundColor: colorHex || '#3d3d3d',
-              }}
+              style={
+                spoolSwatchStyle
+                  ? { ...spoolSwatchStyle, backgroundColor: colorHex || '#3d3d3d' }
+                  : { backgroundColor: colorHex || '#3d3d3d' }
+              }
             >
               {/* Subtle gradient overlay for depth */}
               <div className="absolute inset-0 bg-gradient-to-b from-white/10 to-transparent" />
 
               {/* Color name on swatch */}
-              <div className={`
-                absolute inset-0 flex items-center justify-center
-                font-semibold text-sm tracking-wide
-                ${isLightColor(colorHex) ? 'text-black/80' : 'text-white/90'}
-              `}>
-                {displayColorName}
+              <div className="absolute inset-0 flex items-center justify-center">
+                <span
+                  className={
+                    swatchNeedsScrim
+                      ? 'px-2 py-0.5 rounded bg-black/60 text-white font-semibold text-sm tracking-wide'
+                      : `font-semibold text-sm tracking-wide ${
+                          isLightColor(contrastBaseHex) ? 'text-black/80' : 'text-white/90'
+                        }`
+                  }
+                >
+                  {displayColorName}
+                </span>
               </div>
 
               {/* Vendor badge - solid background for visibility on any color */}

+ 30 - 0
frontend/src/pages/PrintersPage.tsx

@@ -5645,6 +5645,12 @@ function PrinterCard({
                                                 subtype: spoolmanSpool.subtype,
                                                 brand: spoolmanSpool.brand ?? null,
                                                 color_name: spoolmanSpool.color_name ?? null,
+                                                // The spool's own swatch (#2967). Spoolman carries the
+                                                // extra stops but has no effect field at all, so those
+                                                // rolls gradient and never shimmer.
+                                                rgba: spoolmanSpool.rgba ?? null,
+                                                extra_colors: spoolmanSpool.extra_colors ?? null,
+                                                effect_type: spoolmanSpool.effect_type ?? null,
                                                 remainingWeightGrams: spoolmanSpool.label_weight
                                                   ? Math.max(0, Math.round(spoolmanSpool.label_weight - spoolmanSpool.weight_used))
                                                   : undefined,
@@ -5673,6 +5679,10 @@ function PrinterCard({
                                               subtype: assignment.spool.subtype,
                                               brand: assignment.spool.brand,
                                               color_name: assignment.spool.color_name,
+                                              // The spool's own swatch (#2967).
+                                              rgba: assignment.spool.rgba ?? null,
+                                              extra_colors: assignment.spool.extra_colors ?? null,
+                                              effect_type: assignment.spool.effect_type ?? null,
                                               remainingWeightGrams: Math.max(0, Math.round(assignment.spool.label_weight - assignment.spool.weight_used)),
                                             } : null,
                                             onAssignSpool: () => setAssignSpoolModal({
@@ -6035,6 +6045,12 @@ function PrinterCard({
                                             subtype: spoolmanSpool.subtype,
                                             brand: spoolmanSpool.brand ?? null,
                                             color_name: spoolmanSpool.color_name ?? null,
+                                            // The spool's own swatch (#2967). Spoolman carries the
+                                            // extra stops but has no effect field at all, so those
+                                            // rolls gradient and never shimmer.
+                                            rgba: spoolmanSpool.rgba ?? null,
+                                            extra_colors: spoolmanSpool.extra_colors ?? null,
+                                            effect_type: spoolmanSpool.effect_type ?? null,
                                             remainingWeightGrams: spoolmanSpool.label_weight
                                               ? Math.max(0, Math.round(spoolmanSpool.label_weight - spoolmanSpool.weight_used))
                                               : undefined,
@@ -6063,6 +6079,10 @@ function PrinterCard({
                                           subtype: assignment.spool.subtype,
                                           brand: assignment.spool.brand,
                                           color_name: assignment.spool.color_name,
+                                          // The spool's own swatch (#2967).
+                                          rgba: assignment.spool.rgba ?? null,
+                                          extra_colors: assignment.spool.extra_colors ?? null,
+                                          effect_type: assignment.spool.effect_type ?? null,
                                           remainingWeightGrams: Math.max(0, Math.round(assignment.spool.label_weight - assignment.spool.weight_used)),
                                         } : null,
                                         onAssignSpool: () => setAssignSpoolModal({
@@ -6310,6 +6330,12 @@ function PrinterCard({
                                               subtype: spoolmanSpool.subtype,
                                               brand: spoolmanSpool.brand ?? null,
                                               color_name: spoolmanSpool.color_name ?? null,
+                                              // The spool's own swatch (#2967). Spoolman carries the
+                                              // extra stops but has no effect field at all, so those
+                                              // rolls gradient and never shimmer.
+                                              rgba: spoolmanSpool.rgba ?? null,
+                                              extra_colors: spoolmanSpool.extra_colors ?? null,
+                                              effect_type: spoolmanSpool.effect_type ?? null,
                                               remainingWeightGrams: spoolmanSpool.label_weight
                                                 ? Math.max(0, Math.round(spoolmanSpool.label_weight - spoolmanSpool.weight_used))
                                                 : undefined,
@@ -6338,6 +6364,10 @@ function PrinterCard({
                                             subtype: assignment.spool.subtype,
                                             brand: assignment.spool.brand,
                                             color_name: assignment.spool.color_name,
+                                            // The spool's own swatch (#2967).
+                                            rgba: assignment.spool.rgba ?? null,
+                                            extra_colors: assignment.spool.extra_colors ?? null,
+                                            effect_type: assignment.spool.effect_type ?? null,
                                             remainingWeightGrams: Math.max(0, Math.round(assignment.spool.label_weight - assignment.spool.weight_used)),
                                           } : null,
                                           onAssignSpool: () => setAssignSpoolModal({

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


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-CS9PSj5u.js"></script>
+    <script type="module" crossorigin src="/assets/index-DUUtee0j.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-CexGkv-b.css">
   </head>
   <body>

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