Explorar el Código

fix(spoolbuddy): show the colour name the rest of Bambuddy shows (issue #3090)

SpoolBuddy said "Unknown color" under a correctly-coloured swatch for
spools the inventory page names without trouble.

The name was never in the spool record. Bambu's RFID tags frequently
carry no readable colour name -- some carry an internal code instead --
so Bambuddy has always resolved the swatch's own hex against the colour
catalog, and the kiosk was rendering the empty column. Exactly one
SpoolBuddy file already did it right, which is what marks this as an
inconsistency rather than a kiosk simplification.

Route every SpoolBuddy colour-name display through resolveSpoolColorName,
which also stops the spools that do carry a code from showing "A06-D0" at
the user. The write-tag edit form keeps the raw stored value on purpose:
offering a derived name for editing invites the user to save it as though
they had typed it.

Spoolman has no colour-name field at all, so _map_spoolman_spool puts the
spool's subtype there and sets color_name_is_synthesized. That flag now
travels on the tag-matched broadcast, and resolveSpoolColorName takes a
third argument to honour it -- a synthesised name loses to the catalog
and survives only as a last resort. Spoolman installs were reading
"Silk+" as a colour on the Inventory page and the AMS hover card too, so
those call sites pass the flag as well.

Searching by a colour you can read on screen now finds it, in the kiosk
and in Bambuddy: the shared inventory filter matches the resolved name as
well as the stored one. That makes the filter depend on the catalog,
which loads asynchronously, so the three memoised call sites take its
version as a dependency -- without that, a query typed before the catalog
arrives keeps its empty result and reproduces the very symptom being
fixed.

The fallback label was hardcoded English in components that already
import useTranslation; it is now spoolbuddy.spool.unknownColor in all 14
maziggy hace 22 horas
padre
commit
e4a9ef4550
Se han modificado 40 ficheros con 604 adiciones y 49 borrados
  1. 1 0
      CHANGELOG.md
  2. 8 0
      backend/app/api/routes/spoolbuddy.py
  3. 136 0
      backend/tests/integration/test_spoolbuddy_color_name_3090.py
  4. 42 0
      frontend/src/__tests__/components/LabelTemplatePickerModal.test.tsx
  5. 115 0
      frontend/src/__tests__/components/spoolbuddy/SpoolBuddyColorName3090.test.tsx
  6. 32 1
      frontend/src/__tests__/pages/SpoolBuddyInventorySearch.test.ts
  7. 25 0
      frontend/src/__tests__/utils/colors.test.ts
  8. 7 3
      frontend/src/components/AssignSpoolModal.tsx
  9. 20 2
      frontend/src/components/FilamentHoverCard.tsx
  10. 21 5
      frontend/src/components/LabelTemplatePickerModal.tsx
  11. 4 2
      frontend/src/components/spoolbuddy/AssignToAmsModal.tsx
  12. 6 2
      frontend/src/components/spoolbuddy/InventorySpoolInfoCard.tsx
  13. 2 2
      frontend/src/components/spoolbuddy/LinkSpoolModal.tsx
  14. 6 2
      frontend/src/components/spoolbuddy/SpoolInfoCard.tsx
  15. 6 2
      frontend/src/components/spoolbuddy/TagDetectedModal.tsx
  16. 4 0
      frontend/src/hooks/useSpoolBuddyState.ts
  17. 1 0
      frontend/src/i18n/locales/de.ts
  18. 1 0
      frontend/src/i18n/locales/en.ts
  19. 1 0
      frontend/src/i18n/locales/es.ts
  20. 1 0
      frontend/src/i18n/locales/fr.ts
  21. 1 0
      frontend/src/i18n/locales/it.ts
  22. 1 0
      frontend/src/i18n/locales/ja.ts
  23. 2 1
      frontend/src/i18n/locales/ko.ts
  24. 1 0
      frontend/src/i18n/locales/nl.ts
  25. 1 0
      frontend/src/i18n/locales/pt-BR.ts
  26. 1 0
      frontend/src/i18n/locales/ru.ts
  27. 1 0
      frontend/src/i18n/locales/tr.ts
  28. 1 0
      frontend/src/i18n/locales/uk.ts
  29. 1 0
      frontend/src/i18n/locales/zh-CN.ts
  30. 1 0
      frontend/src/i18n/locales/zh-TW.ts
  31. 20 5
      frontend/src/pages/InventoryPage.tsx
  32. 3 0
      frontend/src/pages/PrintersPage.tsx
  33. 20 3
      frontend/src/pages/spoolbuddy/SpoolBuddyAmsPage.tsx
  34. 18 2
      frontend/src/pages/spoolbuddy/SpoolBuddyDashboard.tsx
  35. 11 2
      frontend/src/pages/spoolbuddy/SpoolBuddyInventoryPage.tsx
  36. 43 7
      frontend/src/pages/spoolbuddy/SpoolBuddyWriteTagPage.tsx
  37. 28 7
      frontend/src/utils/colors.ts
  38. 10 0
      frontend/src/utils/inventorySearch.ts
  39. 0 0
      static/assets/index-Cxmp8Wcz.js
  40. 1 1
      static/index.html

+ 1 - 0
CHANGELOG.md

@@ -29,6 +29,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Every FTP session Bambuddy opens now records how it closed (#3009, reported by @grengojbo)** — the report traced a print completion that opened two FTP connections to the printer, deleted one file and then, as far as the log showed, did nothing else until the printer was powered off 21 minutes later, and concluded the connections were being left open. They were not: the post-print SD-card cleanup opens one connection per candidate filename and closes each in a `finally`, which a run against a real FTPS server confirms at the server end for both the delete and the 550 not-here case. The trouble is that nothing in the log could have said so. Neither the clean close nor the hard socket drop logged anything at any level, so a session closed properly and a socket genuinely abandoned produced the same output — none — and the only way to tell them apart was to read the source. Both now log one DEBUG line naming the printer, whether QUIT was acknowledged or the socket had to be dropped without it, why, and how long the session was held. Every connect in a debug log is now paired with a close, so the next person suspecting a leaked FTP connection can settle it from a support bundle rather than by inference. Nothing about the connection handling itself changed, and at default log level nothing new is printed. This does not explain the SD-card read/write error in that report or in #645; it only removes one theory from the list by making it checkable.
 
 ### Fixed
+- **SpoolBuddy said "Unknown color" for spools Bambuddy names perfectly well (#3090, reported by @Sawtaytoes)** — the reporter scanned a Bambu Lab PLA Silk+ spool that his inventory card calls Candy Red and the kiosk showed the right red swatch above the words "Unknown color". The name was never in the spool record: Bambu's RFID tags often carry none, so Bambuddy has always resolved the swatch's own hex against the colour catalog instead — and the kiosk was printing the empty column. Every screen in SpoolBuddy that shows a colour now resolves it the same way the rest of Bambuddy does, which also stops the ones that had a name on file from showing Bambu's internal code ("A06-D0") in place of it. Searching the inventory by a colour you can read on screen finds it now, in the kiosk and in Bambuddy, instead of matching only what was stored. On Spoolman-backed inventory the catalog also wins over the spool's subtype, which Spoolman installs were being shown as a colour name because Spoolman has no field for one — so those spools said "Silk+" where they now say Candy Red. The label the kiosk falls back to when nothing can name a colour is translated in all 14 languages rather than being English for everyone.
 - **An external RTSP camera could pass the connection test and still show a black live view (#3082, reported by @M1XZG)** — the reporter's Wyze cam, republished through go2rtc, captured a frame for the test button and played in VLC, but the live view produced no image and ended after a few seconds. The two RTSP paths were not asking ffmpeg for the same thing: the one-shot capture used ffmpeg's own probe defaults, while the live stream hard-coded a 32-byte probe with no analysis. That is enough for a camera that describes itself in its SDP, and not enough for one that sends its H.264 parameters in-band a moment later — ffmpeg then never starts a decoder and yields nothing at all. The live stream now probes on the defaults like the capture beside it, so the test button means what it appears to mean. The low-latency settings are unchanged, and so is the camera handling for Bambu's own printers, which is tuned per model against a camera we know.
 - **The jog API pushed an A1's nozzle at the plate when asked for clearance (#1334, reported by @AQU4R1U5)** — `POST /printers/{id}/bed-jog` takes a signed nozzle-bed gap: positive asks for more room between the nozzle and the plate. The reporter sent `distance=5` to his A1 and the toolhead came down instead. The endpoint had been flipping the sign on A1 models since the original report on this issue, where an A1 Mini owner clicked an arrow labelled "move the plate up" and watched the nozzle dive — but that flip was solving a labelling problem in the transport layer, and it turned a parameter documented as model-independent into one that meant the opposite thing on a quarter of the fleet. `Z` is the nozzle-to-bed distance on every Bambu model, whether the bed drops away from a fixed nozzle or the nozzle rises off a fixed bed, so the endpoint now sends `distance` through unchanged and one call means one physical outcome everywhere: positive is the safe direction on every printer. The arrows on the printer card are unchanged and still show you what you would expect to move — the card works out which gap change its own arrows stand for, which is where a question about the machine in front of you belongs. On an A1, A1 Mini or A2L those buttons now say *toolhead* rather than *plate*, since a bed-slinger's plate does not move in Z at all. **A2L owners had none of this**: the original fix listed the A1 models by name and the A2L, which slings its bed the same way, was never on the list — its up arrow has been sending the toolhead down since the machine was supported.
 - **A queue item pinned to one printer never said why it was waiting (#3074, reported by @Sawtaytoes)** — the reporter watched a job pinned to a specific X1C sit at Pending for fourteen minutes, with nothing in the UI or the API to say why, while that printer ran a print he had started from its own screen. The same job queued as "Any X1C" explains itself, because the model-based half of the scheduler builds a sentence for every way a job could not start and puts it on the row. The pinned half never wrote that field at all: the Home Assistant sensor interlock was its only writer, and it actively cleared the field whenever no sensor was holding the printer. Pinned items now say what they are waiting on in the same words — **Busy: X1C-01** while it prints, while it dries, or while another job on that printer goes first; **Offline, no Auto On smart plug: X1C-01** when nothing can switch it back on — and the row shows the purple **Waiting** badge instead of a silent Pending. "Finished, waiting for you to confirm the plate is clear" is split out from plain busy and named as itself, because it is the one case on that list that does not resolve on its own. A printer that is simply printing still sends no notification, exactly as before, and neither does a sensor interlock; only the cases that need you to go and do something do. On the queue timeline, a job that is merely next in line still draws its forecast bar as before, while one that is waiting for you now drops off it rather than promising a start time nobody can keep. Nothing about which job goes out, or when, has changed.

+ 8 - 0
backend/app/api/routes/spoolbuddy.py

@@ -421,6 +421,11 @@ async def nfc_tag_scanned(
                             "material": mapped["material"],
                             "subtype": mapped["subtype"],
                             "color_name": mapped["color_name"],
+                            # Spoolman stores no colour name, so `color_name`
+                            # here is usually the spool's subtype standing in
+                            # for one. The kiosk needs to know that to prefer
+                            # the colour catalog over "Silk+" (#3090).
+                            "color_name_is_synthesized": mapped["color_name_is_synthesized"],
                             "rgba": mapped["rgba"],
                             "brand": mapped["brand"],
                             "label_weight": mapped["label_weight"],
@@ -485,6 +490,9 @@ async def nfc_tag_scanned(
                         "material": spool.material,
                         "subtype": spool.subtype,
                         "color_name": spool.color_name,
+                        # Local inventory stores what the user or their tag
+                        # set, and nothing else — never a stand-in (#3090).
+                        "color_name_is_synthesized": False,
                         "rgba": spool.rgba,
                         "brand": spool.brand,
                         "label_weight": spool.label_weight,

+ 136 - 0
backend/tests/integration/test_spoolbuddy_color_name_3090.py

@@ -0,0 +1,136 @@
+"""The kiosk has to be told when a spool's colour name is only a stand-in (#3090).
+
+SpoolBuddy showed "Unknown color" for spools Bambuddy names perfectly well. The
+name is not in the spool record: Bambu's RFID tags frequently carry none, and
+Spoolman has no colour-name field at all, so the frontend resolves the swatch's
+hex against the colour catalog instead. The kiosk was reading the raw column.
+
+That is a frontend fix, except for one thing the frontend cannot work out on
+its own. In Spoolman mode ``_map_spoolman_spool`` puts the spool's *subtype*
+in ``color_name`` when nothing is stored, so the kiosk receives "Silk+" — a
+plausible-looking string that would beat the catalog if it were taken at face
+value. ``color_name_is_synthesized`` is how the backend already marks that,
+and these tests pin it onto the tag-matched broadcast, which is the one place
+the kiosk learns about a scanned spool.
+"""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.settings import Settings
+
+SPOOLBUDDY_API = "/api/v1/spoolbuddy"
+
+
+@pytest.fixture
+async def spoolman_enabled(db_session: AsyncSession):
+    db_session.add(Settings(key="spoolman_enabled", value="true"))
+    db_session.add(Settings(key="spoolman_url", value="http://spoolman.local:7912"))
+    await db_session.commit()
+
+
+@pytest.fixture
+async def spoolman_disabled(db_session: AsyncSession):
+    db_session.add(Settings(key="spoolman_enabled", value="false"))
+    await db_session.commit()
+
+
+def _spoolman_spool_without_a_colour_name() -> dict:
+    """A Silk+ roll as Spoolman holds it: a swatch, and nowhere to put a name."""
+    return {
+        "id": 38,
+        "filament": {
+            "material": "PLA",
+            "name": "PLA Silk+",
+            "color_hex": "D02727",  # Candy Red, in the colour catalog
+            "weight": 1000.0,
+            "spool_weight": 250.0,
+            "vendor": {"name": "Bambu Lab"},
+        },
+        "used_weight": 0.0,
+        "archived": False,
+        "registered": "2024-01-01T00:00:00Z",
+    }
+
+
+def _mock_spoolman_client(spool: dict) -> MagicMock:
+    client = MagicMock()
+    client.base_url = "http://spoolman.local:7912"
+    client.get_spools = AsyncMock(return_value=[spool])
+    client.find_spool_by_tag = AsyncMock(return_value=spool)
+    client.merge_spool_extra = AsyncMock(return_value={})
+    return client
+
+
+async def _scan(async_client: AsyncClient) -> dict:
+    """Scan a tag and return the broadcast the kiosk would receive."""
+    with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+        mock_ws.broadcast = AsyncMock()
+        resp = await async_client.post(
+            f"{SPOOLBUDDY_API}/nfc/tag-scanned",
+            json={
+                "device_id": "sb-test",
+                "tag_uid": "AABB1122334455FF",
+                "tray_uuid": "DEADBEEFDEADBEEFDEADBEEFDEADBEEF",
+            },
+        )
+    assert resp.status_code == 200
+    mock_ws.broadcast.assert_called_once()
+    return mock_ws.broadcast.call_args[0][0]
+
+
+class TestTheScanBroadcastSaysWhereTheNameCameFrom:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_spoolman_spool_is_marked_as_having_no_real_name(self, async_client: AsyncClient, spoolman_enabled):
+        """Spoolman keeps no colour name, so what arrives is the subtype."""
+        spool = _spoolman_spool_without_a_colour_name()
+        client = _mock_spoolman_client(spool)
+        with (
+            patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=client)),
+            patch("backend.app.services.spoolman.init_spoolman_client", AsyncMock(return_value=client)),
+        ):
+            msg = await _scan(async_client)
+
+        assert msg["type"] == "spoolbuddy_tag_matched"
+        # The stand-in is still sent — it is the only thing there, and a kiosk
+        # that cannot resolve the hex should show something.
+        assert msg["spool"]["color_name"] == "Silk+"
+        assert msg["spool"]["color_name_is_synthesized"] is True, (
+            'without this the kiosk shows "Silk+" where the catalog knows the colour'
+        )
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_local_spool_is_never_marked_synthesized(self, async_client: AsyncClient, spoolman_disabled):
+        """Local inventory stores what the user or their tag set, or nothing.
+
+        A name that is present is a real one, and an absent one must stay
+        absent rather than acquiring a stand-in — the kiosk resolves the empty
+        case from the swatch, and cannot do that for a name it is told to
+        trust.
+        """
+        spool = MagicMock()
+        spool.id = 38
+        spool.material = "PLA"
+        spool.subtype = "Silk+"
+        spool.color_name = None
+        spool.rgba = "D02727FF"
+        spool.brand = "Bambu Lab"
+        spool.label_weight = 1000
+        spool.core_weight = 250
+        spool.weight_used = 0
+
+        with patch(
+            "backend.app.api.routes.spoolbuddy.get_spool_by_tag",
+            new_callable=AsyncMock,
+            return_value=spool,
+        ):
+            msg = await _scan(async_client)
+
+        assert msg["type"] == "spoolbuddy_tag_matched"
+        assert msg["spool"]["color_name"] is None
+        assert msg["spool"]["color_name_is_synthesized"] is False

+ 42 - 0
frontend/src/__tests__/components/LabelTemplatePickerModal.test.tsx

@@ -1,6 +1,7 @@
 import { describe, it, expect, vi, beforeEach } from 'vitest';
 import { screen, waitFor, fireEvent, within } from '@testing-library/react';
 import { render } from '../utils';
+import { setColorCatalog, __resetColorCatalogForTests } from '../../utils/colors';
 import { LabelTemplatePickerModal } from '../../components/LabelTemplatePickerModal';
 import { api } from '../../api/client';
 
@@ -37,6 +38,47 @@ beforeEach(() => {
   vi.spyOn(window, 'open').mockImplementation(() => ({}) as Window);
 });
 
+describe('a colour name that only the catalog knows (#3090)', () => {
+  // The list labels a spool by its colour, and most Bambu spools carry no
+  // colour name — the name comes from resolving the swatch's hex against the
+  // catalog, which is fetched once at startup. The filter that builds this
+  // list is memoised, so it has to be told the catalog arrived; otherwise a
+  // search typed first keeps the empty result it computed without one.
+  const nameless = [{ id: 9, material: 'PLA', subtype: 'Silk+', brand: 'Bambu Lab', color_name: null, rgba: 'D02727FF' }];
+
+  beforeEach(() => {
+    __resetColorCatalogForTests();
+  });
+
+  const openModal = () =>
+    render(
+      <LabelTemplatePickerModal
+        isOpen
+        onClose={vi.fn()}
+        availableSpools={nameless}
+        initialSelectedIds={[]}
+        spoolmanMode={false}
+      />,
+    );
+
+  it('labels the spool from the catalog instead of falling back to its material', () => {
+    setColorCatalog({ d02727: 'Candy Red' });
+    openModal();
+
+    expect(screen.getByText(/Candy Red/)).toBeInTheDocument();
+  });
+
+  it('re-filters when the catalog arrives after the query was typed', async () => {
+    openModal();
+    fireEvent.change(screen.getByPlaceholderText(/Search/i), { target: { value: 'candy' } });
+    expect(screen.queryByText(/Candy Red/)).not.toBeInTheDocument();
+
+    setColorCatalog({ d02727: 'Candy Red' });
+
+    await waitFor(() => expect(screen.getByText(/Candy Red/)).toBeInTheDocument());
+  });
+});
+
 describe('LabelTemplatePickerModal', () => {
   it('does not render when closed', () => {
     render(

+ 115 - 0
frontend/src/__tests__/components/spoolbuddy/SpoolBuddyColorName3090.test.tsx

@@ -0,0 +1,115 @@
+/**
+ * The kiosk shows the colour the rest of Bambuddy shows (#3090).
+ *
+ * Reported by @Sawtaytoes: scan a Bambu RFID spool that Bambuddy's inventory
+ * calls "Candy Red" and SpoolBuddy's dialog says "Unknown color" — with the
+ * right red swatch beside it. The name was never in the spool record. Bambu's tags frequently carry none, so Bambuddy resolves the swatch's
+ * own hex against the colour catalog; the kiosk was printing the empty column.
+ *
+ * These render the real components against a catalog, because the bug was not
+ * in the resolver — which was already correct and already used one file away —
+ * but in which components called it.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen } from '@testing-library/react';
+import { render } from '../../utils';
+import { TagDetectedModal } from '../../../components/spoolbuddy/TagDetectedModal';
+import { SpoolInfoCard } from '../../../components/spoolbuddy/SpoolInfoCard';
+import type { MatchedSpool } from '../../../hooks/useSpoolBuddyState';
+import { setColorCatalog, __resetColorCatalogForTests } from '../../../utils/colors';
+
+vi.mock('../../../api/client', () => ({
+  api: {
+    getSettings: vi.fn().mockResolvedValue({}),
+    getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
+  },
+  spoolbuddyApi: {
+    updateSpoolWeight: vi.fn().mockResolvedValue({ status: 'ok', weight_used: 0 }),
+    unlinkTag: vi.fn().mockResolvedValue({ status: 'ok' }),
+  },
+}));
+
+// The reporter's spool: Bambu Lab PLA Silk+, Candy Red, no name on the tag.
+const candyRed: MatchedSpool = {
+  id: 38,
+  tag_uid: '00000100',
+  material: 'PLA',
+  subtype: 'Silk+',
+  color_name: null,
+  rgba: 'D02727FF',
+  brand: 'Bambu Lab',
+  label_weight: 1000,
+  core_weight: 250,
+  weight_used: 0,
+};
+
+const modalProps = {
+  isOpen: true,
+  onClose: vi.fn(),
+  spool: candyRed,
+  tagUid: '00000100',
+  scaleWeight: 1000,
+  weightStable: true,
+  onSyncWeight: vi.fn(),
+  onAssignToAms: vi.fn(),
+  onLinkSpool: vi.fn(),
+  onAddToInventory: vi.fn(),
+};
+
+describe('SpoolBuddy colour names (#3090)', () => {
+  beforeEach(() => {
+    __resetColorCatalogForTests();
+    setColorCatalog({ d02727: 'Candy Red' });
+  });
+
+  it('names the scanned spool from the catalog when the tag carried no name', () => {
+    render(<TagDetectedModal {...modalProps} />);
+
+    expect(screen.getByText('Candy Red')).toBeInTheDocument();
+    expect(screen.queryByText('Unknown color')).not.toBeInTheDocument();
+  });
+
+  it('names it on the dashboard card too, not only in the scan dialog', () => {
+    // Four components render a scanned spool. Fixing the dialog alone leaves
+    // the same spool nameless on the card behind it.
+    render(<SpoolInfoCard spool={candyRed} scaleWeight={1000} weightStable onSyncWeight={vi.fn()} />);
+
+    expect(screen.getByText('Candy Red')).toBeInTheDocument();
+  });
+
+  it('prefers the catalog over a name Spoolman never stored', () => {
+    // Spoolman has no colour-name field, so the backend sends the subtype with
+    // color_name_is_synthesized set. "Silk+" is not a colour.
+    render(
+      <TagDetectedModal
+        {...modalProps}
+        spool={{ ...candyRed, color_name: 'Silk+', color_name_is_synthesized: true }}
+      />,
+    );
+
+    expect(screen.getByText('Candy Red')).toBeInTheDocument();
+    expect(screen.queryByText('Silk+')).not.toBeInTheDocument();
+  });
+
+  it('keeps a real stored name exactly as stored', () => {
+    // The catalog is the fallback, not an override — a user who typed
+    // "My Favourite Red" sees it, whatever the hex says.
+    render(
+      <TagDetectedModal {...modalProps} spool={{ ...candyRed, color_name: 'My Favourite Red' }} />,
+    );
+
+    expect(screen.getByText('My Favourite Red')).toBeInTheDocument();
+    expect(screen.queryByText('Candy Red')).not.toBeInTheDocument();
+  });
+
+  it('falls back to a translated label when nothing can name the colour', () => {
+    // Unknown hex, no stored name: the string has to come from i18n, because
+    // the kiosk is used in every language Bambuddy ships.
+    render(
+      <TagDetectedModal {...modalProps} spool={{ ...candyRed, rgba: '123456FF' }} />,
+    );
+
+    expect(screen.getByText('Unknown color')).toBeInTheDocument();
+  });
+});

+ 32 - 1
frontend/src/__tests__/pages/SpoolBuddyInventorySearch.test.ts

@@ -6,9 +6,10 @@
  * fields.
  */
 
-import { describe, it, expect } from 'vitest';
+import { describe, it, expect, beforeEach } from 'vitest';
 import type { InventorySpool } from '../../api/client';
 import { filterSpoolsByQuery } from '../../utils/inventorySearch';
+import { setColorCatalog, __resetColorCatalogForTests } from '../../utils/colors';
 
 function makeSpool(overrides: Partial<InventorySpool> & { id: number }): InventorySpool {
   return {
@@ -85,4 +86,34 @@ describe('SpoolBuddyInventoryPage search filter (#1738)', () => {
     expect(filterSpoolsByQuery(spools, 'IKEA').map((s) => s.id)).toEqual([1]);
     expect(filterSpoolsByQuery(spools, 'Matte').map((s) => s.id)).toEqual([2]);
   });
+
+  // #3090 — the list shows a name resolved from the swatch's hex whenever the
+  // spool carries none of its own. Searching only the stored column meant
+  // typing what was plainly on screen returned nothing.
+  describe('the name on screen is the name you can search for (#3090)', () => {
+    beforeEach(() => {
+      __resetColorCatalogForTests();
+      setColorCatalog({ d02727: 'Candy Red' });
+    });
+
+    it('matches a catalog name the spool does not store', () => {
+      const spools = [
+        makeSpool({ id: 1, color_name: null, rgba: 'D02727FF' }),
+        makeSpool({ id: 2, color_name: null, rgba: '123456FF' }),
+      ];
+      expect(filterSpoolsByQuery(spools, 'candy').map((s) => s.id)).toEqual([1]);
+    });
+
+    it('matches a catalog name over a subtype Spoolman put in its place', () => {
+      const spools = [
+        makeSpool({ id: 1, color_name: 'Silk+', color_name_is_synthesized: true, rgba: 'D02727FF' }),
+      ];
+      expect(filterSpoolsByQuery(spools, 'candy red').map((s) => s.id)).toEqual([1]);
+    });
+
+    it('still matches the stored value, for anyone who knows the tag codes', () => {
+      const spools = [makeSpool({ id: 1, color_name: 'A06-D0', rgba: 'D02727FF' })];
+      expect(filterSpoolsByQuery(spools, 'a06-d0').map((s) => s.id)).toEqual([1]);
+    });
+  });
 });

+ 25 - 0
frontend/src/__tests__/utils/colors.test.ts

@@ -179,6 +179,31 @@ describe('resolveSpoolColorName', () => {
   it('returns Clear for transparent rgba even when color_name is a code', () => {
     expect(resolveSpoolColorName('A99-Z9', '00000000')).toBe('Clear');
   });
+
+  // #3090 — Spoolman has no colour-name field, so every Spoolman-backed spool
+  // arrives with its subtype sitting in color_name. It reads like a name and
+  // is not one.
+  describe('a name the backend synthesised from the subtype', () => {
+    it('loses to the catalog', () => {
+      expect(resolveSpoolColorName('Silk+', '5F6367FF', true)).toBe('Titan Gray');
+    });
+
+    it('still wins over nothing when the hex is unknown', () => {
+      // Better than "-": it at least says what is on the spool. The catalog
+      // only covers what someone put in it.
+      expect(resolveSpoolColorName('Silk+', '123456FF', true)).toBe('Silk+');
+    });
+
+    it('is not consulted when the flag is absent', () => {
+      // The flag defaults to false, so every existing caller keeps the old
+      // behaviour: a stored name is the user's and is used as given.
+      expect(resolveSpoolColorName('Silk+', '5F6367FF')).toBe('Silk+');
+    });
+
+    it('does not resurrect a Bambu internal code', () => {
+      expect(resolveSpoolColorName('A99-Z9', '123456FF', true)).toBeNull();
+    });
+  });
 });
 
 describe('colorSortKey (#2729)', () => {

+ 7 - 3
frontend/src/components/AssignSpoolModal.tsx

@@ -8,7 +8,7 @@ import { Button } from './Button';
 import { ConfirmModal } from './ConfirmModal';
 import { useToast } from '../contexts/ToastContext';
 import { filterSpoolsByQuery } from '../utils/inventorySearch';
-import { getSwatchStyle } from '../utils/colors';
+import { getSwatchStyle, resolveSpoolColorName } from '../utils/colors';
 
 interface AssignSpoolModalProps {
   isOpen: boolean;
@@ -431,7 +431,9 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
                           style={getSwatchStyle(spool.rgba)}
                         />
                       )}
-                      <span className="text-xs text-bambu-gray truncate">{spool.color_name || ''}</span>
+                      <span className="text-xs text-bambu-gray truncate">
+                        {resolveSpoolColorName(spool.color_name, spool.rgba, spool.color_name_is_synthesized) || ''}
+                      </span>
                     </div>
                     {spool.label_weight && (
                       <p className="text-xs text-bambu-gray mt-1">
@@ -511,7 +513,9 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
                                   style={getSwatchStyle(spool.rgba)}
                                 />
                               )}
-                              <span className="text-xs text-bambu-gray truncate">{spool.color_name || ''}</span>
+                              <span className="text-xs text-bambu-gray truncate">
+                                {resolveSpoolColorName(spool.color_name, spool.rgba, spool.color_name_is_synthesized) || ''}
+                              </span>
                             </div>
                             {spool.label_weight && (
                               <p className="text-xs text-bambu-gray mt-1">

+ 20 - 2
frontend/src/components/FilamentHoverCard.tsx

@@ -46,6 +46,9 @@ interface InventoryConfig {
     subtype: string | null;
     brand: string | null;
     color_name: string | null;
+    // Spoolman has no colour-name field, so `color_name` on a Spoolman-backed
+    // spool is usually its subtype standing in for one (#3090).
+    color_name_is_synthesized?: boolean;
     rgba?: string | null;
     extra_colors?: string | null;
     effect_type?: string | null;
@@ -215,8 +218,23 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
   // already resolved with the slot's material (#2875).
   // Trimmed, so a spool saved with a whitespace-only colour name leaves the
   // swatch reading the catalogue answer instead of reading blank.
-  const assignedColorName = resolveSpoolColorName(inventory?.assignedSpool?.color_name ?? null, null)?.trim();
+  // A synthesised name is dropped outright rather than passed through the
+  // helper: with a null rgba the helper has nothing to resolve against and
+  // would hand back "Silk+", displacing the catalogue answer the caller
+  // already worked out from the slot's material (#3090).
+  const assignedColorName = inventory?.assignedSpool?.color_name_is_synthesized
+    ? undefined
+    : resolveSpoolColorName(inventory?.assignedSpool?.color_name ?? null, null)?.trim();
   const displayColorName = assignedColorName || data.colorName;
+  // The ASSIGNED row has the spool's own swatch to hand, so unlike the header
+  // above it can resolve a missing or synthesised name against the catalogue.
+  const assignedRowColorName = inventory?.assignedSpool
+    ? resolveSpoolColorName(
+        inventory.assignedSpool.color_name,
+        inventory.assignedSpool.rgba ?? null,
+        inventory.assignedSpool.color_name_is_synthesized,
+      )
+    : null;
   const assignedRemainingWeight = inventory?.assignedSpool?.remainingWeightGrams ?? null;
 
   // The header paints the spool's own swatch whenever the spool describes more
@@ -473,7 +491,7 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
                           {inventory.assignedSpool.brand ? `${inventory.assignedSpool.brand} ` : ''}
                           {inventory.assignedSpool.material}
                           {inventory.assignedSpool.subtype ? ` ${inventory.assignedSpool.subtype}` : ''}
-                          {inventory.assignedSpool.color_name ? ` - ${inventory.assignedSpool.color_name}` : ''}
+                          {assignedRowColorName ? ` - ${assignedRowColorName}` : ''}
                         </p>
                         <span className="text-[10px] font-mono text-bambu-gray shrink-0">#{inventory.assignedSpool.id}</span>
                       </div>

+ 21 - 5
frontend/src/components/LabelTemplatePickerModal.tsx

@@ -4,12 +4,13 @@ import { X, Loader2, Printer, CheckSquare, Square, Search } from 'lucide-react';
 import { api, type SpoolLabelTemplate, type InventorySpool } from '../api/client';
 import { Button } from './Button';
 import { useToast } from '../contexts/ToastContext';
-import { getSwatchStyle } from '../utils/colors';
+import { getSwatchStyle, resolveSpoolColorName } from '../utils/colors';
+import { useColorCatalogVersion } from '../hooks/useColorCatalogVersion';
 
 /** Subset of InventorySpool the modal needs for checkbox rendering. */
 type SpoolForLabel = Pick<
   InventorySpool,
-  'id' | 'material' | 'subtype' | 'brand' | 'color_name' | 'rgba'
+  'id' | 'material' | 'subtype' | 'brand' | 'color_name' | 'color_name_is_synthesized' | 'rgba'
 >;
 
 interface LabelTemplatePickerModalProps {
@@ -109,15 +110,22 @@ function swatchStyle(rgba: string | null | undefined): React.CSSProperties {
   return getSwatchStyle(rgba);
 }
 
+function labelColorName(s: SpoolForLabel): string | null {
+  return resolveSpoolColorName(s.color_name, s.rgba, s.color_name_is_synthesized);
+}
+
 function spoolDisplayName(s: SpoolForLabel): string {
-  const head = s.color_name ?? `${s.material}${s.subtype ? ` ${s.subtype}` : ''}`;
+  // Resolved, not stored: most Bambu spools arrive with no colour name on the
+  // tag, and picking a label template for "PLA Silk" tells you nothing about
+  // which of six red spools you are looking at (#3090).
+  const head = labelColorName(s) ?? `${s.material}${s.subtype ? ` ${s.subtype}` : ''}`;
   const brand = s.brand ? ` · ${s.brand}` : '';
   return `${head}${brand}`;
 }
 
 /** Build a lowercased haystack that the search input matches against. */
 function searchableText(s: SpoolForLabel): string {
-  return [s.color_name, s.material, s.subtype, s.brand, `#${s.id}`]
+  return [labelColorName(s), s.color_name, s.material, s.subtype, s.brand, `#${s.id}`]
     .filter(Boolean)
     .join(' ')
     .toLowerCase();
@@ -175,6 +183,9 @@ export function LabelTemplatePickerModal({
   spoolmanMode,
 }: LabelTemplatePickerModalProps) {
   const { t } = useTranslation();
+  // The spool filter below resolves colour names through the catalog; its
+  // memo has to recompute when the catalog finishes loading (#3090).
+  const colorCatalogVersion = useColorCatalogVersion();
   const { showToast } = useToast();
   const [pending, setPending] = useState<SpoolLabelTemplate | null>(null);
   const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
@@ -230,13 +241,18 @@ export function LabelTemplatePickerModal({
   }, [sortedSpools]);
 
   const visibleSpools = useMemo(() => {
+    // Named so this memo depends on it: searchableText resolves colour names
+    // through the catalog, which resolveSpoolColorName reads from module state
+    // the linter cannot follow. Without it a query typed before the catalog
+    // loads keeps its empty result (#3090).
+    void colorCatalogVersion;
     const q = search.trim().toLowerCase();
     return sortedSpools.filter((s) => {
       if (materialFilter && (s.material || '').toUpperCase() !== materialFilter) return false;
       if (q && !searchableText(s).includes(q)) return false;
       return true;
     });
-  }, [sortedSpools, search, materialFilter]);
+  }, [sortedSpools, search, materialFilter, colorCatalogVersion]);
 
   const allVisibleChecked =
     visibleSpools.length > 0 && visibleSpools.every((s) => selectedIds.has(s.id));

+ 4 - 2
frontend/src/components/spoolbuddy/AssignToAmsModal.tsx

@@ -7,7 +7,7 @@ import { ConfirmModal } from '../ConfirmModal';
 import { AmsUnitCard, NozzleBadge } from './AmsUnitCard';
 import type { AmsThresholds } from './AmsUnitCard';
 import { getFillBarColor } from '../../utils/amsHelpers';
-import { getSwatchStyle } from '../../utils/colors';
+import { getSwatchStyle, resolveSpoolColorName } from '../../utils/colors';
 
 function getAmsName(id: number): string {
   if (id <= 3) return `AMS ${String.fromCharCode(65 + id)}`;
@@ -379,7 +379,9 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId, spoolmanMo
             <h2 className="text-sm font-semibold text-zinc-100 truncate">
               {t('spoolbuddy.modal.assignToAmsTitle', 'Assign to AMS')}
               <span className="font-normal text-zinc-500 ml-2">
-                {spool.color_name || 'Unknown'} &bull; {spool.brand} {spool.material}{spool.subtype && ` ${spool.subtype}`}
+                {resolveSpoolColorName(spool.color_name, spool.rgba, spool.color_name_is_synthesized) ||
+                  t('spoolbuddy.spool.unknownColor')}{' '}
+                &bull; {spool.brand} {spool.material}{spool.subtype && ` ${spool.subtype}`}
               </span>
               <span className="text-[10px] font-mono text-zinc-500 ml-2 shrink-0">#{spool.id}</span>
             </h2>

+ 6 - 2
frontend/src/components/spoolbuddy/InventorySpoolInfoCard.tsx

@@ -5,7 +5,7 @@ import { Check, AlertTriangle, RefreshCw, Unlink } from 'lucide-react';
 import type { InventorySpool } from '../../api/client';
 import { spoolbuddyApi, api } from '../../api/client';
 import { SpoolIcon } from './SpoolIcon';
-import { spoolColorString } from '../../utils/colors';
+import { resolveSpoolColorName, spoolColorString } from '../../utils/colors';
 
 const DEFAULT_CORE_WEIGHT_KEY = 'spoolbuddy-default-core-weight';
 
@@ -63,6 +63,10 @@ export function InventorySpoolInfoCard({
   const kProfiles = (spool.k_profiles && spool.k_profiles.length > 0) ? spool.k_profiles : fetchedKProfiles;
 
   const colorHex = spoolColorString(spool.rgba);
+  // The stored name is not the displayed one: Bambu tags often carry no
+  // colour name at all, or an internal code, and Spoolman has no field for
+  // one — the catalog resolves the swatch's hex instead (#3090, #857).
+  const colorName = resolveSpoolColorName(spool.color_name, spool.rgba, spool.color_name_is_synthesized);
 
   const coreWeight = (spool.core_weight && spool.core_weight > 0)
     ? spool.core_weight
@@ -152,7 +156,7 @@ export function InventorySpoolInfoCard({
         <div className="flex-1 min-w-0 pt-1">
           <div className="flex items-center gap-2">
             <h3 className="text-lg font-semibold text-zinc-100">
-              {spool.color_name || 'Unknown color'}
+              {colorName || t('spoolbuddy.spool.unknownColor')}
             </h3>
             <span className="text-xs font-mono text-zinc-500 shrink-0">#{spool.id}</span>
           </div>

+ 2 - 2
frontend/src/components/spoolbuddy/LinkSpoolModal.tsx

@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
 import { X } from 'lucide-react';
 import type { InventorySpool } from '../../api/client';
 import { SpoolIcon } from './SpoolIcon';
-import { spoolColorString } from '../../utils/colors';
+import { resolveSpoolColorName, spoolColorString } from '../../utils/colors';
 
 interface LinkSpoolModalProps {
   isOpen: boolean;
@@ -108,7 +108,7 @@ export function LinkSpoolModal({
                   <div className="flex-1 min-w-0">
                     <div className="flex items-center gap-2">
                       <div className="font-medium text-zinc-100 truncate">
-                        {spool.color_name || 'Unknown color'}
+                        {resolveSpoolColorName(spool.color_name, spool.rgba, spool.color_name_is_synthesized) || t('spoolbuddy.spool.unknownColor')}
                       </div>
                       <span className="text-[10px] font-mono text-zinc-500 shrink-0">#{spool.id}</span>
                     </div>

+ 6 - 2
frontend/src/components/spoolbuddy/SpoolInfoCard.tsx

@@ -4,7 +4,7 @@ import { Check, AlertTriangle, RefreshCw, Unlink } from 'lucide-react';
 import type { MatchedSpool } from '../../hooks/useSpoolBuddyState';
 import { spoolbuddyApi } from '../../api/client';
 import { SpoolIcon } from './SpoolIcon';
-import { spoolColorString } from '../../utils/colors';
+import { resolveSpoolColorName, spoolColorString } from '../../utils/colors';
 
 // Storage key for default core weight
 const DEFAULT_CORE_WEIGHT_KEY = 'spoolbuddy-default-core-weight';
@@ -38,6 +38,10 @@ export function SpoolInfoCard({ spool, scaleWeight, onClose, onSyncWeight, onAss
   const [synced, setSynced] = useState(false);
 
   const colorHex = spoolColorString(spool.rgba);
+  // The stored name is not the displayed one: Bambu tags often carry no
+  // colour name at all, or an internal code, and Spoolman has no field for
+  // one — the catalog resolves the swatch's hex instead (#3090, #857).
+  const colorName = resolveSpoolColorName(spool.color_name, spool.rgba, spool.color_name_is_synthesized);
 
   // Use spool's core_weight if set, otherwise fall back to default
   const coreWeight = (spool.core_weight && spool.core_weight > 0)
@@ -104,7 +108,7 @@ export function SpoolInfoCard({ spool, scaleWeight, onClose, onSyncWeight, onAss
         <div className="flex-1 min-w-0 pt-1">
           <div className="flex items-center gap-2">
             <h3 className="text-lg font-semibold text-zinc-100">
-              {spool.color_name || 'Unknown color'}
+              {colorName || t('spoolbuddy.spool.unknownColor')}
             </h3>
             <span className="text-xs font-mono text-zinc-500 shrink-0">#{spool.id}</span>
           </div>

+ 6 - 2
frontend/src/components/spoolbuddy/TagDetectedModal.tsx

@@ -4,7 +4,7 @@ import { Check, RefreshCw, AlertTriangle, X } from 'lucide-react';
 import type { MatchedSpool } from '../../hooks/useSpoolBuddyState';
 import { spoolbuddyApi } from '../../api/client';
 import { SpoolIcon } from './SpoolIcon';
-import { spoolColorString } from '../../utils/colors';
+import { resolveSpoolColorName, spoolColorString } from '../../utils/colors';
 
 // Storage key for default core weight (shared with SpoolInfoCard)
 const DEFAULT_CORE_WEIGHT_KEY = 'spoolbuddy-default-core-weight';
@@ -136,6 +136,10 @@ interface KnownSpoolViewProps {
 function KnownSpoolView({ spool, scaleWeight, weightStable, syncing, synced, onSyncWeight, onAssignToAms, onClose }: KnownSpoolViewProps) {
   const { t } = useTranslation();
   const colorHex = spoolColorString(spool.rgba);
+  // The stored name is not the displayed one: Bambu tags often carry no
+  // colour name at all, or an internal code, and Spoolman has no field for
+  // one — the catalog resolves the swatch's hex instead (#3090, #857).
+  const colorName = resolveSpoolColorName(spool.color_name, spool.rgba, spool.color_name_is_synthesized);
 
   const coreWeight = (spool.core_weight && spool.core_weight > 0)
     ? spool.core_weight
@@ -190,7 +194,7 @@ function KnownSpoolView({ spool, scaleWeight, weightStable, syncing, synced, onS
         <div className="flex-1 min-w-0 pt-1">
           <div className="flex items-center gap-2">
             <h3 className="text-lg font-semibold text-zinc-100">
-              {spool.color_name || 'Unknown color'}
+              {colorName || t('spoolbuddy.spool.unknownColor')}
             </h3>
             <span className="text-xs font-mono text-zinc-500 shrink-0">#{spool.id}</span>
           </div>

+ 4 - 0
frontend/src/hooks/useSpoolBuddyState.ts

@@ -6,6 +6,9 @@ export interface MatchedSpool {
   material: string;
   subtype: string | null;
   color_name: string | null;
+  // True when the backend had no stored colour name and put the subtype there
+  // instead — Spoolman-backed inventory only, which has no such field (#3090).
+  color_name_is_synthesized?: boolean;
   rgba: string | null;
   brand: string | null;
   label_weight: number;
@@ -122,6 +125,7 @@ export function useSpoolBuddyState() {
           material: spool.material ?? '',
           subtype: spool.subtype ?? null,
           color_name: spool.color_name ?? null,
+          color_name_is_synthesized: spool.color_name_is_synthesized ?? false,
           rgba: spool.rgba ?? null,
           brand: spool.brand ?? null,
           label_weight: spool.label_weight ?? 0,

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

@@ -6802,6 +6802,7 @@ export default {
       scaleWeight: 'Waage',
       netWeight: 'Netto',
       lastUsed: 'Zuletzt verwendet',
+      unknownColor: 'Unbekannte Farbe',
     },
     ams: {
       noData: 'Kein AMS erkannt',

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

@@ -6853,6 +6853,7 @@ export default {
       scaleWeight: 'Scale',
       netWeight: 'Net',
       lastUsed: 'Last used',
+      unknownColor: 'Unknown color',
     },
     ams: {
       noData: 'No AMS detected',

+ 1 - 0
frontend/src/i18n/locales/es.ts

@@ -6810,6 +6810,7 @@ export default {
       scaleWeight: 'Báscula',
       netWeight: 'Neto',
       lastUsed: 'Usada por última vez',
+      unknownColor: 'Color desconocido',
     },
     ams: {
       noData: 'No se detectó ningún AMS',

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

@@ -6792,6 +6792,7 @@ export default {
       scaleWeight: 'Balance',
       netWeight: 'Réseau',
       lastUsed: 'Dernière utilisation',
+      unknownColor: 'Couleur inconnue',
     },
     ams: {
       noData: 'Aucun AMS détecté',

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

@@ -6791,6 +6791,7 @@ export default {
       scaleWeight: 'Bilancia',
       netWeight: 'Netto',
       lastUsed: 'Ultimo utilizzo',
+      unknownColor: 'Colore sconosciuto',
     },
     ams: {
       noData: 'Nessun AMS rilevato',

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

@@ -6803,6 +6803,7 @@ export default {
       scaleWeight: '計量',
       netWeight: '正味',
       lastUsed: '最終使用',
+      unknownColor: '不明な色',
     },
     ams: {
       noData: 'AMSが検出されません',

+ 2 - 1
frontend/src/i18n/locales/ko.ts

@@ -6448,7 +6448,8 @@ export default {
       labelWeight: '라벨',
       scaleWeight: '저울',
       netWeight: '순 무게',
-      lastUsed: '마지막 사용'
+      lastUsed: '마지막 사용',
+      unknownColor: '알 수 없는 색상'
     },
     ams: {
       noData: 'AMS가 감지되지 않음',

+ 1 - 0
frontend/src/i18n/locales/nl.ts

@@ -6853,6 +6853,7 @@ export default {
       scaleWeight: 'Weegschaal',
       netWeight: 'Netto',
       lastUsed: 'Laatst gebruikt',
+      unknownColor: 'Onbekende kleur',
     },
     ams: {
       noData: 'Geen AMS gedetecteerd',

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

@@ -6791,6 +6791,7 @@ export default {
       scaleWeight: 'Balança',
       netWeight: 'Líquido',
       lastUsed: 'Último uso',
+      unknownColor: 'Cor desconhecida',
     },
     ams: {
       noData: 'Nenhum AMS detectado',

+ 1 - 0
frontend/src/i18n/locales/ru.ts

@@ -6437,6 +6437,7 @@ export default {
       scaleWeight: "По весам",
       netWeight: "Нетто",
       lastUsed: "Последнее использование",
+      unknownColor: "Неизвестный цвет",
     },
     ams: {
       noData: "AMS не обнаружена",

+ 1 - 0
frontend/src/i18n/locales/tr.ts

@@ -6746,6 +6746,7 @@ export default {
       scaleWeight: 'Tartı',
       netWeight: 'Net',
       lastUsed: 'Son kullanım',
+      unknownColor: 'Bilinmeyen renk',
     },
     ams: {
       noData: 'AMS algılanmadı',

+ 1 - 0
frontend/src/i18n/locales/uk.ts

@@ -6845,6 +6845,7 @@ export default {
       scaleWeight: "Показання ваг",
       netWeight: "Нетто",
       lastUsed: "Останнє використання",
+      unknownColor: "Невідомий колір",
     },
     ams: {
       noData: "AMS не виявлено",

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

@@ -6789,6 +6789,7 @@ export default {
       scaleWeight: '秤重',
       netWeight: '净重',
       lastUsed: '上次使用',
+      unknownColor: '未知颜色',
     },
     ams: {
       noData: '未检测到 AMS',

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

@@ -6789,6 +6789,7 @@ export default {
       scaleWeight: '磅秤重',
       netWeight: '淨重',
       lastUsed: '上次使用',
+      unknownColor: '未知顏色',
     },
     ams: {
       noData: '未偵測到 AMS',

+ 20 - 5
frontend/src/pages/InventoryPage.tsx

@@ -26,6 +26,7 @@ import { BulkEditSpoolsModal } from '../components/BulkEditSpoolsModal';
 import { useToast } from '../contexts/ToastContext';
 import { useAuth } from '../contexts/AuthContext';
 import { colorSortKey, resolveSpoolColorName } from '../utils/colors';
+import { useColorCatalogVersion } from '../hooks/useColorCatalogVersion';
 import { getCurrencySymbol } from '../utils/currency';
 import { formatDateInput, parseUTCDate, type DateFormat } from '../utils/date';
 import { formatSlotLabel } from '../utils/amsHelpers';
@@ -269,7 +270,7 @@ const columnCells: Record<string, (ctx: CellCtx) => ReactNode> = {
     <span className="text-sm text-bambu-gray">{spool.subtype || '-'}</span>
   ),
   color_name: ({ spool }) => (
-    <span className="text-sm text-bambu-gray">{resolveSpoolColorName(spool.color_name, spool.rgba) || '-'}</span>
+    <span className="text-sm text-bambu-gray">{resolveSpoolColorName(spool.color_name, spool.rgba, spool.color_name_is_synthesized) || '-'}</span>
   ),
   brand: ({ spool }) => (
     <span className="text-sm text-bambu-gray">{spool.brand || '-'}</span>
@@ -573,6 +574,9 @@ export default function InventoryPageRouter() {
 
 function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spoolmanMode?: boolean; spoolmanModeReady?: boolean }) {
   const { t } = useTranslation();
+  // The spool filter below resolves colour names through the catalog; its
+  // memo has to recompute when the catalog finishes loading (#3090).
+  const colorCatalogVersion = useColorCatalogVersion();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const { hasPermission, loading: authLoading } = useAuth();
@@ -1029,7 +1033,13 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
         await spoolbuddyApi.updateSpoolWeight(spool.id, spool.last_scale_weight);
       }
       queryClient.invalidateQueries({ queryKey: spoolsQueryKey });
-      const spoolName = [spool.brand, spool.material, spool.color_name].filter(Boolean).join(' ');
+      const spoolName = [
+        spool.brand,
+        spool.material,
+        resolveSpoolColorName(spool.color_name, spool.rgba, spool.color_name_is_synthesized),
+      ]
+        .filter(Boolean)
+        .join(' ');
       showToast(`Synced "${spoolName}" to scale weight`, 'success');
     } catch (e) {
       const is404 = e instanceof ApiError && e.status === 404;
@@ -1217,6 +1227,11 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
 
   // Filtering pipeline
   const filteredSpools = useMemo(() => {
+    // Named so this memo depends on it: the global search below resolves
+    // colour names through the catalog, which `resolveSpoolColorName` reads
+    // from module state the linter cannot follow. Without it a query typed
+    // before the catalog loads keeps its empty result (#3090).
+    void colorCatalogVersion;
     let filtered = spools || [];
 
     // Archive filter
@@ -1294,7 +1309,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
     }
 
     return filtered;
-  }, [spools, archiveFilter, usageFilter, materialFilter, brandFilter, categoryFilter, spoolFilter, stockFilter, storageLocationFilter, search, lowStockThreshold, storageLocations]);
+  }, [spools, archiveFilter, usageFilter, materialFilter, brandFilter, categoryFilter, spoolFilter, stockFilter, storageLocationFilter, search, lowStockThreshold, storageLocations, colorCatalogVersion]);
 
   // Reset page on filter changes
   const resetPage = () => setPageIndex(0);
@@ -2056,7 +2071,7 @@ function InventoryPage({ spoolmanMode = false, spoolmanModeReady = true }: { spo
                       >
                         <div className="h-10 flex items-center px-4 gap-3" style={groupBannerStyle}>
                           <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, rep.color_name_is_synthesized) || '-'}
                           </span>
                         </div>
                         <div className="px-4 py-3 flex items-center justify-between">
@@ -2617,7 +2632,7 @@ function SpoolCard({
     >
       <div className="h-14 flex items-center justify-center" style={bannerStyle}>
         <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, spool.color_name_is_synthesized) || '-'}
         </span>
         {onCopy && (
           <button

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

@@ -5658,6 +5658,7 @@ function PrinterCard({
                                                 subtype: spoolmanSpool.subtype,
                                                 brand: spoolmanSpool.brand ?? null,
                                                 color_name: spoolmanSpool.color_name ?? null,
+                                                color_name_is_synthesized: spoolmanSpool.color_name_is_synthesized,
                                                 // 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.
@@ -6064,6 +6065,7 @@ function PrinterCard({
                                             subtype: spoolmanSpool.subtype,
                                             brand: spoolmanSpool.brand ?? null,
                                             color_name: spoolmanSpool.color_name ?? null,
+                                            color_name_is_synthesized: spoolmanSpool.color_name_is_synthesized,
                                             // 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.
@@ -6355,6 +6357,7 @@ function PrinterCard({
                                               subtype: spoolmanSpool.subtype,
                                               brand: spoolmanSpool.brand ?? null,
                                               color_name: spoolmanSpool.color_name ?? null,
+                                              color_name_is_synthesized: spoolmanSpool.color_name_is_synthesized,
                                               // 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.

+ 20 - 3
frontend/src/pages/spoolbuddy/SpoolBuddyAmsPage.tsx

@@ -7,7 +7,24 @@ import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolB
 import { api } from '../../api/client';
 import type { PrinterStatus, AMSTray, SpoolAssignment } from '../../api/client';
 import { getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag, formatSlotLabel, isBambuLabSpool, resolveSlotNozzleDiameter } from '../../utils/amsHelpers';
-import { getSwatchStyle } from '../../utils/colors';
+import { getSwatchStyle, resolveSpoolColorName } from '../../utils/colors';
+
+/**
+ * " - Candy Red", or nothing when the colour has no name we can show.
+ *
+ * Not `spool.color_name`: a Bambu tag often carries no colour name, and
+ * Spoolman has no field for one at all, so the stored value is frequently
+ * empty or the spool's subtype standing in for it. The catalog resolves the
+ * swatch's own hex instead (#3090).
+ */
+function colorNameSuffix(spool: {
+  color_name: string | null;
+  rgba: string | null;
+  color_name_is_synthesized?: boolean;
+}): string {
+  const name = resolveSpoolColorName(spool.color_name, spool.rgba, spool.color_name_is_synthesized);
+  return name ? ` - ${name}` : '';
+}
 import { AmsUnitCard, HumidityIndicator, TemperatureIndicator, NozzleBadge } from '../../components/spoolbuddy/AmsUnitCard';
 import type { AmsThresholds } from '../../components/spoolbuddy/AmsUnitCard';
 import { ConfigureAmsSlotModal } from '../../components/ConfigureAmsSlotModal';
@@ -757,7 +774,7 @@ export function SpoolBuddyAmsPage() {
                       <span className="text-sm text-white">
                         {assignment.spool.brand ? `${assignment.spool.brand} ` : ''}{assignment.spool.material}
                         {assignment.spool.subtype ? ` ${assignment.spool.subtype}` : ''}
-                        {assignment.spool.color_name ? ` - ${assignment.spool.color_name}` : ''}
+                        {colorNameSuffix(assignment.spool)}
                       </span>
                       <span className="text-[10px] font-mono text-zinc-500 shrink-0 ml-auto">#{assignment.spool.id}</span>
                     </div>
@@ -791,7 +808,7 @@ export function SpoolBuddyAmsPage() {
                       <span className="text-sm text-white">
                         {spoolmanAssignedSpool.brand ? `${spoolmanAssignedSpool.brand} ` : ''}{spoolmanAssignedSpool.material}
                         {spoolmanAssignedSpool.subtype ? ` ${spoolmanAssignedSpool.subtype}` : ''}
-                        {spoolmanAssignedSpool.color_name ? ` - ${spoolmanAssignedSpool.color_name}` : ''}
+                        {colorNameSuffix(spoolmanAssignedSpool)}
                       </span>
                     </div>
                   </div>

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

@@ -293,6 +293,7 @@ export function SpoolBuddyDashboard() {
       material: m.material,
       subtype: m.subtype,
       color_name: m.color_name,
+      color_name_is_synthesized: m.color_name_is_synthesized,
       rgba: m.rgba,
       brand: m.brand,
       label_weight: m.label_weight,
@@ -362,8 +363,22 @@ export function SpoolBuddyDashboard() {
           showToast(t('spoolman.linkFailed'), 'error');
           return;
         }
-        const { id, material, subtype, color_name, rgba, brand, label_weight, core_weight, weight_used } = updated;
-        setJustLinkedSpool({ id, material, subtype, color_name, rgba, brand, label_weight, core_weight, weight_used });
+        const { id, material, subtype, color_name, color_name_is_synthesized, rgba, brand, label_weight, core_weight, weight_used } =
+          updated;
+        // color_name_is_synthesized travels with color_name or the card
+        // shows "Silk+" where the catalog knows the colour (#3090).
+        setJustLinkedSpool({
+          id,
+          material,
+          subtype,
+          color_name,
+          color_name_is_synthesized,
+          rgba,
+          brand,
+          label_weight,
+          core_weight,
+          weight_used,
+        });
         showToast(t('spoolman.linkSuccess'), 'success');
       } else {
         await api.linkTagToSpool(spool.id, {
@@ -619,6 +634,7 @@ export function SpoolBuddyDashboard() {
                       material: s.material,
                       subtype: s.subtype,
                       color_name: s.color_name,
+                      color_name_is_synthesized: s.color_name_is_synthesized,
                       rgba: s.rgba,
                       brand: s.brand,
                       label_weight: s.label_weight,

+ 11 - 2
frontend/src/pages/spoolbuddy/SpoolBuddyInventoryPage.tsx

@@ -8,6 +8,7 @@ import type { InventorySpool } from '../../api/client';
 import { resolveSpoolColorName, getSwatchStyle, spoolColorString } from '../../utils/colors';
 import { formatSlotLabel } from '../../utils/amsHelpers';
 import { filterSpoolsByQuery } from '../../utils/inventorySearch';
+import { useColorCatalogVersion } from '../../hooks/useColorCatalogVersion';
 import { InventorySpoolInfoCard } from '../../components/spoolbuddy/InventorySpoolInfoCard';
 import { AssignToAmsModal } from '../../components/spoolbuddy/AssignToAmsModal';
 import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolBuddyLayout';
@@ -65,6 +66,9 @@ function SpoolCircle({ color, size = 56 }: { color: string; size?: number }) {
 export function SpoolBuddyInventoryPage() {
   const { sbState, selectedPrinterId } = useOutletContext<SpoolBuddyOutletContext>();
   const { t } = useTranslation();
+  // The spool filter below resolves colour names through the catalog; its
+  // memo has to recompute when the catalog finishes loading (#3090).
+  const colorCatalogVersion = useColorCatalogVersion();
   const { showToast } = useToast();
   const queryClient = useQueryClient();
   const [searchQuery, setSearchQuery] = useState('');
@@ -137,6 +141,11 @@ export function SpoolBuddyInventoryPage() {
 
   // Filter and sort
   const filteredSpools = useMemo(() => {
+    // Named so this memo depends on it: the search below resolves colour
+    // names through the catalog, which `resolveSpoolColorName` reads from
+    // module state the linter cannot follow. Without it a query typed
+    // before the catalog loads keeps its empty result (#3090).
+    void colorCatalogVersion;
     let list = activeSpools;
 
     if (filterMode === 'in_ams') {
@@ -154,7 +163,7 @@ export function SpoolBuddyInventoryPage() {
       if (aAssigned !== bAssigned) return aAssigned - bAssigned;
       return new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime();
     });
-  }, [activeSpools, filterMode, searchQuery, assignedSpoolIds]);
+  }, [activeSpools, filterMode, searchQuery, assignedSpoolIds, colorCatalogVersion]);
 
   return (
     <div className="h-full flex flex-col">
@@ -306,7 +315,7 @@ function CatalogCard({ spool, assignment, onClick }: {
   const color = spoolColor(spool);
   const pct = spoolPct(spool);
   const remaining = spoolRemaining(spool);
-  const colorName = resolveSpoolColorName(spool.color_name, spool.rgba);
+  const colorName = resolveSpoolColorName(spool.color_name, spool.rgba, spool.color_name_is_synthesized);
 
   return (
     <button

+ 43 - 7
frontend/src/pages/spoolbuddy/SpoolBuddyWriteTagPage.tsx

@@ -14,7 +14,26 @@ import {
   type SpoolCatalogEntry,
 } from '../../api/client';
 import { getCurrencySymbol } from '../../utils/currency';
-import { getSwatchStyle } from '../../utils/colors';
+import { getSwatchStyle, resolveSpoolColorName } from '../../utils/colors';
+import { useColorCatalogVersion } from '../../hooks/useColorCatalogVersion';
+
+/**
+ * The colour name to show for a spool, which is not the one it stores.
+ *
+ * A Bambu tag often carries no colour name, or an internal code, and Spoolman
+ * has no field for one at all — so `color_name` is regularly empty or the
+ * subtype standing in for it, and the catalog resolves the swatch's hex
+ * instead (#3090, #857). The edit form below deliberately does NOT go through
+ * here: what it offers for editing has to be what is stored, or the user saves
+ * a name we made up as though they had typed it.
+ */
+function displayColorName(spool: {
+  color_name: string | null;
+  rgba: string | null;
+  color_name_is_synthesized?: boolean;
+}): string | null {
+  return resolveSpoolColorName(spool.color_name, spool.rgba, spool.color_name_is_synthesized);
+}
 import { FilamentSection } from '../../components/spool-form/FilamentSection';
 import { ColorSection } from '../../components/spool-form/ColorSection';
 import { AdditionalSection } from '../../components/spool-form/AdditionalSection';
@@ -40,6 +59,9 @@ const SIMPLE_COMMON_MATERIALS = ['PLA', 'PETG', 'ABS', 'ASA', 'TPU', 'PA', 'PC',
 
 export function SpoolBuddyWriteTagPage() {
   const { t } = useTranslation();
+  // The search below resolves colour names through the catalog, so its memo
+  // has to recompute when the catalog finishes loading (#3090).
+  const colorCatalogVersion = useColorCatalogVersion();
   const { showToast } = useToast();
   const { sbState } = useOutletContext<SpoolBuddyOutletContext>();
 
@@ -95,6 +117,11 @@ export function SpoolBuddyWriteTagPage() {
 
   // Filter spools based on tab
   const filteredSpools = useMemo(() => {
+    // Named here so the memo actually depends on it: the search below resolves
+    // colour names through the catalog, which `displayColorName` reads from
+    // module state the linter cannot follow. Without this the list keeps the
+    // names it resolved before the catalog finished loading (#3090).
+    void colorCatalogVersion;
     let list: InventorySpool[];
     if (activeTab === 'existing') {
       list = spools.filter(s => !s.tag_uid && !s.archived_at);
@@ -108,6 +135,9 @@ export function SpoolBuddyWriteTagPage() {
       const q = searchQuery.toLowerCase();
       list = list.filter(s =>
         (s.material?.toLowerCase().includes(q)) ||
+        // Both: the resolved name is what the list shows, the stored one is
+        // what a user who knows Bambu's internal codes might type (#3090).
+        (displayColorName(s)?.toLowerCase().includes(q)) ||
         (s.color_name?.toLowerCase().includes(q)) ||
         (s.brand?.toLowerCase().includes(q)) ||
         (s.subtype?.toLowerCase().includes(q))
@@ -115,7 +145,7 @@ export function SpoolBuddyWriteTagPage() {
     }
 
     return list;
-  }, [spools, activeTab, searchQuery]);
+  }, [spools, activeTab, searchQuery, colorCatalogVersion]);
 
   // Listen for tag events
   const handleUnknownTag = useCallback((e: Event) => {
@@ -394,7 +424,7 @@ function SpoolListItem({ spool, selected, showTag, onClick }: {
           <span className="text-[10px] font-mono text-zinc-500 shrink-0">#{spool.id}</span>
         </div>
         <div className="flex items-center gap-2 text-xs text-zinc-400">
-          {spool.color_name && <span>{spool.color_name}</span>}
+          {displayColorName(spool) && <span>{displayColorName(spool)}</span>}
           <span>{remaining}g / {spool.label_weight}g ({pct}%)</span>
         </div>
         {showTag && spool.tag_uid && (
@@ -805,7 +835,9 @@ function NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, spoolmanM
             <p className="text-white font-medium">
               {selectedSpool.brand ? `${selectedSpool.brand} ` : ''}{selectedSpool.material}
             </p>
-            {selectedSpool.color_name && <p className="text-zinc-400 text-sm">{selectedSpool.color_name}</p>}
+            {displayColorName(selectedSpool) && (
+              <p className="text-zinc-400 text-sm">{displayColorName(selectedSpool)}</p>
+            )}
             <p className="text-zinc-500 text-xs mt-1">{selectedSpool.label_weight}g</p>
             <p className="text-bambu-green text-sm mt-4">{t('spoolbuddy.writeTag.spoolCreated', 'Spool created! Ready to write.')}</p>
           </div>
@@ -996,7 +1028,9 @@ function NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, spoolmanM
           <p className="text-white font-medium">
             {selectedSpool.brand ? `${selectedSpool.brand} ` : ''}{selectedSpool.material}
           </p>
-          {selectedSpool.color_name && <p className="text-zinc-400 text-sm">{selectedSpool.color_name}</p>}
+          {displayColorName(selectedSpool) && (
+            <p className="text-zinc-400 text-sm">{displayColorName(selectedSpool)}</p>
+          )}
           <p className="text-zinc-500 text-xs mt-1">{selectedSpool.label_weight}g</p>
           <p className="text-bambu-green text-sm mt-4">{t('spoolbuddy.writeTag.spoolCreated', 'Spool created! Ready to write.')}</p>
         </div>
@@ -1037,7 +1071,7 @@ function NfcStatusPanel({ writeStatus, writeMessage, selectedSpool, tagOnReader,
           <p className="text-zinc-400 text-sm">
             {selectedSpool.brand ? `${selectedSpool.brand} ` : ''}{selectedSpool.material}
             {selectedSpool.subtype ? ` ${selectedSpool.subtype}` : ''}
-            {selectedSpool.color_name ? ` - ${selectedSpool.color_name}` : ''}
+            {displayColorName(selectedSpool) ? ` - ${displayColorName(selectedSpool)}` : ''}
           </p>
         )}
       </div>
@@ -1150,7 +1184,9 @@ function NfcStatusPanel({ writeStatus, writeMessage, selectedSpool, tagOnReader,
             <p className="text-white text-sm font-medium truncate">
               {selectedSpool.brand ? `${selectedSpool.brand} ` : ''}{selectedSpool.material}
             </p>
-            {selectedSpool.color_name && <p className="text-zinc-400 text-xs">{selectedSpool.color_name}</p>}
+            {displayColorName(selectedSpool) && (
+              <p className="text-zinc-400 text-xs">{displayColorName(selectedSpool)}</p>
+            )}
           </div>
         </div>
         <div className="text-xs text-zinc-500">{selectedSpool.label_weight}g</div>

+ 28 - 7
frontend/src/utils/colors.ts

@@ -293,14 +293,32 @@ export function disambiguateColorNames(
 
 /**
  * Resolve a spool's display color name.
- * Tries: stored color_name (if it's a readable name) → runtime catalog via rgba → null.
- * Detects Bambu internal codes (e.g. "A06-D0") and ignores them in favor of hex lookup
- * because the same code is not globally unique across material families (#857).
+ *
+ * Tries: stored color_name (if it is a readable name the user can be said to
+ * have chosen) → runtime catalog via rgba → the stored name after all → null.
+ *
+ * Two kinds of stored name are not answers, and both defer to the hex:
+ *
+ * - A Bambu internal code ("A06-D0"), which is what some RFID tags carry in
+ *   place of a name. The same code is not unique across material families, so
+ *   it cannot be translated on its own (#857).
+ * - A name Bambuddy synthesised from the spool's subtype because the inventory
+ *   backend had none. Spoolman has no `color_name` field at all, so every
+ *   Spoolman-backed spool arrives carrying its subtype as a stand-in, and
+ *   "Silk+" is not a colour (#3090). `colorNameIsSynthesized` is the flag the
+ *   backend already sets for exactly this; it is a weaker answer than the
+ *   catalog, not a wrong one, so it is still used when the hex resolves to
+ *   nothing.
  */
-export function resolveSpoolColorName(colorName: string | null, rgba: string | null): string | null {
-  // If color_name looks like a readable name (no pattern like "X00-Y0"), use it directly
-  if (colorName && !/^[A-Z]\d+-[A-Z]\d+$/.test(colorName)) {
-    return colorName;
+export function resolveSpoolColorName(
+  colorName: string | null,
+  rgba: string | null,
+  colorNameIsSynthesized = false,
+): string | null {
+  // A readable name the user (or their tag) actually set wins outright.
+  const readable = colorName && !/^[A-Z]\d+-[A-Z]\d+$/.test(colorName) ? colorName : null;
+  if (readable && !colorNameIsSynthesized) {
+    return readable;
   }
   if (rgba && rgba.length >= 6) {
     const clean = rgba.replace('#', '').toLowerCase();
@@ -311,6 +329,9 @@ export function resolveSpoolColorName(colorName: string | null, rgba: string | n
     const mapped = runtimeColorCatalog[hex];
     if (mapped) return mapped;
   }
+  // A synthesised subtype is a poor colour name and a fine last resort — it at
+  // least says what the spool is. A bare code never is.
+  if (readable) return readable;
   // Return null (displayed as "-") — better than showing a code
   return null;
 }

+ 10 - 0
frontend/src/utils/inventorySearch.ts

@@ -1,4 +1,5 @@
 import type { InventorySpool } from '../api/client';
+import { resolveSpoolColorName } from './colors';
 
 /**
  * Return true when spool matches the search query across all searchable text fields.
@@ -11,7 +12,16 @@ export function spoolMatchesQuery(spool: InventorySpool, query: string): boolean
     String(spool.id).includes(q) ||
     spool.material.toLowerCase().includes(q) ||
     (spool.brand?.toLowerCase().includes(q) ?? false) ||
+    // Both the stored name and the displayed one. They differ often: a Bambu
+    // tag may carry no colour name or an internal code, and Spoolman has no
+    // such field, so what the list shows is usually resolved from the swatch's
+    // hex. Searching only the stored value means typing what you can plainly
+    // read finds nothing (#3090).
     (spool.color_name?.toLowerCase().includes(q) ?? false) ||
+    (resolveSpoolColorName(spool.color_name, spool.rgba, spool.color_name_is_synthesized)
+      ?.toLowerCase()
+      .includes(q) ??
+      false) ||
     (spool.subtype?.toLowerCase().includes(q) ?? false) ||
     (spool.note?.toLowerCase().includes(q) ?? false) ||
     (spool.slicer_filament_name?.toLowerCase().includes(q) ?? false) ||

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/index-Cxmp8Wcz.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-CqvxbvRs.js"></script>
+    <script type="module" crossorigin src="/assets/index-Cxmp8Wcz.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-ChscM3lF.css">
   </head>
   <body>

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio