Browse Source

feat(print-modal): show each printer slot's colour in the filament mapping (issue #3159)

The Print / Schedule dialog's filament mapping is where the colour a slice
asked for is compared against the colour actually loaded, and only the
left-hand side of that comparison had a swatch. The slot, and every slot in
its dropdown, was text -- and the text cannot be trusted: a slot's colour name
is resolved from the Color Catalog or, failing that, from hue, so a
third-party beige is announced as "Orange". A "Color mismatch" warning then
gives no way to tell a real mismatch from two names for the same hex without
opening the printer card in another tab, which on a farm swapping twenty or
thirty non-Bambu colours between machines is a check made many times a day.

Each slot now carries its colour and its hex, and the slot whose colour is
exactly the one the slice asked for is ticked. This works for a slot bound to
an inventory spool and for one configured through Configure Slot or on the
printer itself: the second kind has no inventory row behind it, and the
printer's own tray colour is then what draws. A bound spool contributes what a
tray record cannot -- SlotSpoolIdentity gains extra_colors and effect_type, so
a two-tone or glittery spool draws as itself rather than as its base colour.

The same treatment goes to the filament-override picker used for model-based
assignment. It is the same choice on the other dispatch path, and leaving it
text-only would have made one decision read two ways.

Both controls stop being <select>s to do it, because an <option> renders text
and nothing else. SlotPicker keeps what the select gave for free -- arrow,
Home/End, Enter and Escape keys, listbox semantics, and the border colouring
that encodes match, same-type-different-colour and not-loaded -- and is
portaled with position:fixed so it is not clipped by the dialog's own scroll
container, flipping above the row when there is no room below.
maziggy 3 days ago
parent
commit
1a84dfea5b
33 changed files with 1030 additions and 169 deletions
  1. 1 0
      CHANGELOG.md
  2. 18 0
      backend/app/services/filament_deficit.py
  3. 44 0
      backend/tests/unit/services/test_filament_deficit.py
  4. 4 0
      backend/tests/unit/test_inventory_remain_endpoint.py
  5. 32 20
      frontend/src/__tests__/components/FilamentMapping.test.tsx
  6. 113 79
      frontend/src/__tests__/components/FilamentOverride.test.tsx
  7. 78 17
      frontend/src/__tests__/components/PrintModal.test.tsx
  8. 8 2
      frontend/src/__tests__/components/PrintModalCrossModel.test.tsx
  9. 183 0
      frontend/src/__tests__/components/SlotPicker.test.tsx
  10. 76 0
      frontend/src/__tests__/hooks/useFilamentMapping.test.ts
  11. 9 0
      frontend/src/api/client.ts
  12. 47 30
      frontend/src/components/PrintModal/FilamentMapping.tsx
  13. 34 20
      frontend/src/components/PrintModal/FilamentOverride.tsx
  14. 302 0
      frontend/src/components/PrintModal/SlotPicker.tsx
  15. 19 0
      frontend/src/components/PrintModal/displayHex.ts
  16. 16 0
      frontend/src/hooks/useFilamentMapping.ts
  17. 3 0
      frontend/src/i18n/locales/de.ts
  18. 3 0
      frontend/src/i18n/locales/en.ts
  19. 3 0
      frontend/src/i18n/locales/es.ts
  20. 3 0
      frontend/src/i18n/locales/fr.ts
  21. 3 0
      frontend/src/i18n/locales/it.ts
  22. 3 0
      frontend/src/i18n/locales/ja.ts
  23. 3 0
      frontend/src/i18n/locales/ko.ts
  24. 3 0
      frontend/src/i18n/locales/nl.ts
  25. 3 0
      frontend/src/i18n/locales/pt-BR.ts
  26. 3 0
      frontend/src/i18n/locales/ru.ts
  27. 3 0
      frontend/src/i18n/locales/sv.ts
  28. 3 0
      frontend/src/i18n/locales/tr.ts
  29. 3 0
      frontend/src/i18n/locales/uk.ts
  30. 3 0
      frontend/src/i18n/locales/zh-CN.ts
  31. 3 0
      frontend/src/i18n/locales/zh-TW.ts
  32. 0 0
      static/assets/index-CCYi0XHi.js
  33. 1 1
      static/index.html

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


+ 18 - 0
backend/app/services/filament_deficit.py

@@ -342,6 +342,15 @@ class SlotSpoolIdentity:
     subtype: str | None
     color_name: str | None
     rgba: str | None
+    # The rest of the spool's colour, for drawing it rather than naming it
+    # (#3159). A tray record carries one hex and nothing else, so a two-tone
+    # or glittery spool renders as its base colour unless the binding says
+    # otherwise -- and on a farm of third-party spools that is exactly the
+    # swatch somebody is comparing against the slicer's colour. Same shape the
+    # inventory row stores: ``extra_colors`` a comma-separated hex list,
+    # ``effect_type`` one of the client's FilamentEffect names.
+    extra_colors: str | None = None
+    effect_type: str | None = None
 
     def to_dict(self) -> dict:
         return {
@@ -350,6 +359,8 @@ class SlotSpoolIdentity:
             "subtype": self.subtype,
             "color_name": self.color_name,
             "rgba": self.rgba,
+            "extra_colors": self.extra_colors,
+            "effect_type": self.effect_type,
         }
 
 
@@ -367,6 +378,8 @@ def _identity_from_internal(spool) -> SlotSpoolIdentity:
         subtype=_clean(spool.subtype),
         color_name=_clean(spool.color_name),
         rgba=_clean(spool.rgba),
+        extra_colors=_clean(spool.extra_colors),
+        effect_type=_clean(spool.effect_type),
     )
 
 
@@ -408,6 +421,11 @@ def _identity_from_spoolman(spool_dict: dict) -> SlotSpoolIdentity | None:
         subtype=_clean(mapped.get("subtype")),
         color_name=color_name,
         rgba=_clean(mapped.get("rgba")),
+        # `_map_spoolman_spool` builds `extra_colors` from Spoolman's own
+        # multi-colour hexes and pins `effect_type` to None, because Spoolman
+        # has no field for it -- see the note there.
+        extra_colors=_clean(mapped.get("extra_colors")),
+        effect_type=_clean(mapped.get("effect_type")),
     )
 
 

+ 44 - 0
backend/tests/unit/services/test_filament_deficit.py

@@ -991,8 +991,52 @@ class TestSlotSpoolIdentity:
             # naming this slot from the hex is exactly the bug.
             "color_name": "Orange",
             "rgba": "FEC600FF",
+            # A plain single-colour spool: nothing extra to draw (#3159).
+            "extra_colors": None,
+            "effect_type": None,
         }
 
+    @pytest.mark.asyncio
+    async def test_internal_mode_carries_the_rest_of_the_colour(self, db_session, printer_factory):
+        """A tray record holds one hex, so the binding is the only source for the rest.
+
+        Without this the Print dialog's slot picker (#3159) draws a two-tone or
+        glittery spool as its base colour -- the one comparison the picker
+        exists to make, made against the wrong swatch.
+        """
+        from backend.app.services.filament_deficit import build_slot_materials
+
+        printer = await printer_factory(model="H2C")
+        spool = Spool(
+            brand="eSUN",
+            material="PLA",
+            subtype="Multicolor",
+            color_name="Rainbow",
+            rgba="FF0000FF",
+            extra_colors="00FF00,0000FF",
+            effect_type="Glitter",
+            label_weight=1000,
+            weight_used=0.0,
+        )
+        db_session.add(spool)
+        await db_session.commit()
+        await db_session.refresh(spool)
+        await _assign(db_session, printer_id=printer.id, spool_id=spool.id, ams_id=2, tray_id=0)
+
+        patches = TestFilamentDeficitBackupAware._patch_status(printer_id=printer.id, backup_on=False, model="H2C")
+        for p in patches:
+            p.start()
+        try:
+            slots = await build_slot_materials(db_session, printer.id)
+        finally:
+            for p in patches:
+                p.stop()
+
+        identity = slots[0].spool
+        assert identity is not None
+        assert identity.extra_colors == "00FF00,0000FF"
+        assert identity.effect_type == "Glitter"
+
     @pytest.mark.asyncio
     async def test_blank_fields_become_null_so_the_client_can_fall_back(self, db_session, printer_factory):
         """Per-field, not all-or-nothing: an unnamed colour still falls back to

+ 4 - 0
backend/tests/unit/test_inventory_remain_endpoint.py

@@ -206,4 +206,8 @@ class TestGetInventoryRemain:
             "subtype": "Basic",
             "color_name": "Orange",
             "rgba": "FEC600FF",
+            # The rest of the colour, for the slot picker's swatch (#3159).
+            # None here: this spool is a single flat colour.
+            "extra_colors": None,
+            "effect_type": None,
         }

+ 32 - 20
frontend/src/__tests__/components/FilamentMapping.test.tsx

@@ -10,13 +10,29 @@
  */
 
 import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
-import { screen, waitFor, cleanup, fireEvent } from '@testing-library/react';
+import { screen, waitFor, cleanup, fireEvent, within } from '@testing-library/react';
 import { http, HttpResponse } from 'msw';
 import { render } from '../utils';
 import { server } from '../mocks/server';
 import { FilamentMapping } from '../../components/PrintModal/FilamentMapping';
 import type { PrinterStatus } from '../../api/client';
 
+/**
+ * Open the slot picker and hand back its listbox.
+ *
+ * The picker replaced a native `<select>` (#3159). A `<select>` keeps every
+ * `<option>` mounted whether or not it is open, so these assertions used to
+ * read the closed control; a listbox renders its rows only while open. They
+ * are about what the user is *offered*, so they open it first — and scope to
+ * the listbox, because the chosen slot's label now also appears on the
+ * trigger.
+ */
+async function openSlotPicker(): Promise<HTMLElement> {
+  const trigger = await waitFor(() => screen.getAllByRole('combobox')[0]);
+  fireEvent.click(trigger);
+  return await waitFor(() => screen.getByRole('listbox'));
+}
+
 const mockFilamentReqs = {
   filaments: [
     // Required filament asks for the LEFT extruder (nozzle_id=1).
@@ -92,19 +108,18 @@ describe('FilamentMapping — FTS routing', () => {
       />,
     );
 
-    // Both PLA and PETG slots must appear in the dropdown despite ams_extruder_map
-    // being empty and the requirement asking for nozzle 1. Without the FTS guard
-    // the dropdown would render only the "-- Select slot --" placeholder.
-    await waitFor(() => {
-      expect(screen.getByText(/Bambu PLA/)).toBeInTheDocument();
-    });
-    expect(screen.getByText(/Bambu PETG/)).toBeInTheDocument();
+    // Both PLA and PETG slots must be offered despite ams_extruder_map being
+    // empty and the requirement asking for nozzle 1. Without the FTS guard the
+    // picker would offer only the "-- Select slot --" placeholder.
+    const listbox = await openSlotPicker();
+    expect(within(listbox).getByText(/Bambu PLA/)).toBeInTheDocument();
+    expect(within(listbox).getByText(/Bambu PETG/)).toBeInTheDocument();
 
     // Each slot is badged for the switch INLET its AMS is plumbed into, using
     // the same L-for-In-A lettering as the printer card. Both slots are in
     // AMS 0, which is on In-A.
-    expect(screen.getByText(/Bambu PETG/).textContent).toMatch(/\[L\]/);
-    expect(screen.getByText(/Bambu PLA/).textContent).toMatch(/\[L\]/);
+    expect(within(listbox).getByText(/Bambu PETG/).textContent).toMatch(/\[L\]/);
+    expect(within(listbox).getByText(/Bambu PLA/).textContent).toMatch(/\[L\]/);
   });
 
   it('does not badge slots whose AMS has no inlet binding yet', async () => {
@@ -135,10 +150,8 @@ describe('FilamentMapping — FTS routing', () => {
       />,
     );
 
-    await waitFor(() => {
-      expect(screen.getByText(/Bambu PETG/)).toBeInTheDocument();
-    });
-    expect(screen.getByText(/Bambu PETG/).textContent).not.toMatch(/\[[LR]\]/);
+    const listbox = await openSlotPicker();
+    expect(within(listbox).getByText(/Bambu PETG/).textContent).not.toMatch(/\[[LR]\]/);
   });
 
   it('renders the per-slot force-color-match checkbox in printer mode (#1717)', async () => {
@@ -258,12 +271,11 @@ describe('FilamentMapping — FTS routing', () => {
     );
 
     // Required nozzle is 1 (LEFT) and AMS 0 is wired to extruder 0 (RIGHT).
-    // Both slots must STILL appear so the user can pick them — explicitly the
-    // cross-extruder scenario the #1722 fix unblocks.
-    await waitFor(() => {
-      expect(screen.getByText(/Bambu PLA/)).toBeInTheDocument();
-    });
-    expect(screen.getByText(/Bambu PETG/)).toBeInTheDocument();
+    // Both slots must STILL be offered so the user can pick them — explicitly
+    // the cross-extruder scenario the #1722 fix unblocks.
+    const listbox = await openSlotPicker();
+    expect(within(listbox).getByText(/Bambu PLA/)).toBeInTheDocument();
+    expect(within(listbox).getByText(/Bambu PETG/)).toBeInTheDocument();
   });
 
   it('renders sub-brand + material-disambiguated colour on the required side (#1718)', async () => {

+ 113 - 79
frontend/src/__tests__/components/FilamentOverride.test.tsx

@@ -6,7 +6,7 @@
  */
 
 import { describe, it, expect, vi, afterEach } from 'vitest';
-import { screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
+import { screen, fireEvent, cleanup, waitFor, within } from '@testing-library/react';
 import { http, HttpResponse } from 'msw';
 import { render } from '../utils';
 import { server } from '../mocks/server';
@@ -27,6 +27,33 @@ const defaultAvailable = [
 
 const mockOnChange = vi.fn();
 
+/**
+ * Open an override picker and hand back its rows.
+ *
+ * These assertions used to read `select.querySelectorAll('option')`. The
+ * control gained a swatch per choice (#3159), which an `<option>` cannot
+ * render, so it is a listbox now: its rows exist only while it is open, and
+ * each carries its identity on `data-value` the way `<option value>` did.
+ */
+function openPicker(index = 0): HTMLElement[] {
+  fireEvent.click(screen.getAllByRole('combobox')[index]);
+  return within(screen.getByRole('listbox')).getAllByRole('option');
+}
+
+/** The identities the picker offers, in order — the old `option.value` list. */
+function optionValues(index = 0): (string | null)[] {
+  return openPicker(index).map((o) => o.getAttribute('data-value'));
+}
+
+/**
+ * What the closed control reads as. With nothing overridden this is the
+ * "original filament" row, which is where the #1718 label resolution lands —
+ * previously read off `option[value=""]`.
+ */
+function triggerText(index = 0): string {
+  return screen.getAllByRole('combobox')[index].textContent ?? '';
+}
+
 afterEach(() => {
   cleanup();
   vi.clearAllMocks();
@@ -122,15 +149,10 @@ describe('FilamentOverride', () => {
         />
       );
 
-      const select = screen.getByRole('combobox');
-      const options = select.querySelectorAll('option');
-
-      // 1 default "Original" option + 2 PLA options (not PETG)
-      expect(options).toHaveLength(3);
-
-      // Verify no PETG option values exist
-      const optionValues = Array.from(options).map((o) => o.getAttribute('value'));
-      expect(optionValues).not.toContain('PETG|#0000FF');
+      // 1 default "Original" row + 2 PLA rows (not PETG)
+      const values = optionValues();
+      expect(values).toHaveLength(3);
+      expect(values).not.toContain('PETG|#0000FF');
     });
 
     it('shows all same-type options regardless of color', () => {
@@ -149,11 +171,8 @@ describe('FilamentOverride', () => {
         />
       );
 
-      const select = screen.getByRole('combobox');
-      const options = select.querySelectorAll('option');
-
-      // 1 default "Original" option + 3 PLA color options
-      expect(options).toHaveLength(4);
+      // 1 default "Original" row + 3 PLA colour rows
+      expect(optionValues()).toHaveLength(4);
     });
   });
 
@@ -173,9 +192,7 @@ describe('FilamentOverride', () => {
         />
       );
 
-      const select = screen.getByRole('combobox');
-      const options = Array.from(select.querySelectorAll('option'));
-      const optionTexts = options.map((o) => o.textContent);
+      const optionTexts = openPicker().map((o) => o.textContent);
 
       // Should show "PLA Basic" and "PLA Matte", not just "PLA"
       expect(optionTexts.some((t) => t?.includes('PLA Basic'))).toBe(true);
@@ -196,11 +213,9 @@ describe('FilamentOverride', () => {
         />
       );
 
-      const select = screen.getByRole('combobox');
-      const options = Array.from(select.querySelectorAll('option'));
-      // Non-default option should show "PLA" as the type fallback
-      const nonDefaultOptions = options.filter((o) => o.getAttribute('value') !== '');
-      expect(nonDefaultOptions[0].textContent).toContain('PLA');
+      // Non-default row should show "PLA" as the type fallback
+      const nonDefault = openPicker().filter((o) => o.getAttribute('data-value') !== '');
+      expect(nonDefault[0].textContent).toContain('PLA');
     });
   });
 
@@ -226,15 +241,11 @@ describe('FilamentOverride', () => {
         />
       );
 
-      const select = screen.getByRole('combobox');
-      const options = select.querySelectorAll('option');
-
       // 1 default + 1 PLA with extruder_id=0 (extruder_id=1 is filtered out)
-      expect(options).toHaveLength(2);
-
-      const optionValues = Array.from(options).map((o) => o.getAttribute('value'));
-      expect(optionValues).toContain('PLA|#FF0000');
-      expect(optionValues).not.toContain('PLA|#00FF00');
+      const values = optionValues();
+      expect(values).toHaveLength(2);
+      expect(values).toContain('PLA|#FF0000');
+      expect(values).not.toContain('PLA|#00FF00');
     });
 
     it('shows all filaments when nozzle_id is undefined', () => {
@@ -258,11 +269,8 @@ describe('FilamentOverride', () => {
         />
       );
 
-      const select = screen.getByRole('combobox');
-      const options = select.querySelectorAll('option');
-
-      // 1 default + 2 PLA options (no nozzle filtering)
-      expect(options).toHaveLength(3);
+      // 1 default + 2 PLA rows (no nozzle filtering)
+      expect(optionValues()).toHaveLength(3);
     });
 
     it('includes filaments with null extruder_id', () => {
@@ -287,16 +295,12 @@ describe('FilamentOverride', () => {
         />
       );
 
-      const select = screen.getByRole('combobox');
-      const options = select.querySelectorAll('option');
-
       // 1 default + extruder_id=0 + extruder_id=null (extruder_id=1 filtered out)
-      expect(options).toHaveLength(3);
-
-      const optionValues = Array.from(options).map((o) => o.getAttribute('value'));
-      expect(optionValues).toContain('PLA|#FF0000');
-      expect(optionValues).toContain('PLA|#00FF00');
-      expect(optionValues).not.toContain('PLA|#FFFFFF');
+      const values = optionValues();
+      expect(values).toHaveLength(3);
+      expect(values).toContain('PLA|#FF0000');
+      expect(values).toContain('PLA|#00FF00');
+      expect(values).not.toContain('PLA|#FFFFFF');
     });
   });
 
@@ -311,8 +315,8 @@ describe('FilamentOverride', () => {
         />
       );
 
-      const select = screen.getByRole('combobox');
-      fireEvent.change(select, { target: { value: 'PLA|#00FF00' } });
+      const row = openPicker().find((o) => o.getAttribute('data-value') === 'PLA|#00FF00');
+      fireEvent.click(row as HTMLElement);
 
       expect(mockOnChange).toHaveBeenCalledWith({
         1: { type: 'PLA', color: '#00FF00' },
@@ -333,13 +337,60 @@ describe('FilamentOverride', () => {
         />
       );
 
-      const select = screen.getByRole('combobox');
-      fireEvent.change(select, { target: { value: '' } });
+      const row = openPicker().find((o) => o.getAttribute('data-value') === '');
+      fireEvent.click(row as HTMLElement);
 
       expect(mockOnChange).toHaveBeenCalledWith({});
     });
   });
 
+  describe('colour swatches (#3159)', () => {
+    it('draws the colour of every filament it offers, and of the original', () => {
+      render(
+        <FilamentOverride
+          filamentReqs={defaultFilamentReqs}
+          availableFilaments={defaultAvailable}
+          overrides={{}}
+          onChange={mockOnChange}
+        />
+      );
+
+      const rows = openPicker();
+      // The original-filament row plus the two same-type candidates: model
+      // mode pools across printers, so there is no slot binding behind an
+      // entry and each swatch is the plain tray colour.
+      expect(rows).toHaveLength(3);
+      for (const row of rows) {
+        expect(within(row).getByTestId('filament-swatch')).toBeInTheDocument();
+      }
+    });
+
+    it('prints each candidate hex and marks the one the slice asked for', () => {
+      render(
+        <FilamentOverride
+          filamentReqs={defaultFilamentReqs}
+          availableFilaments={defaultAvailable}
+          overrides={{}}
+          onChange={mockOnChange}
+        />
+      );
+
+      const rows = openPicker();
+      const listbox = screen.getByRole('listbox');
+      expect(within(listbox).getByText('#00FF00')).toBeInTheDocument();
+      // #FF0000 twice: the original-filament row states what the slice asked
+      // for, and one candidate carries it.
+      expect(within(listbox).getAllByText('#FF0000')).toHaveLength(2);
+
+      // The marker belongs on the candidate, not on the original — it answers
+      // "which of these is the colour the slice asked for", which is the
+      // judgement the colour names cannot be trusted to make.
+      const marked = rows.filter((r) => within(r).queryByTitle('Exact colour match'));
+      expect(marked).toHaveLength(1);
+      expect(marked[0].getAttribute('data-value')).toBe('PLA|#FF0000');
+    });
+  });
+
   describe('original-label SKU resolution (#1718)', () => {
     it('uses the builtin filament name when tray_info_idx maps to a known SKU', async () => {
       // Stamped by Bambu Studio when slicing with PLA Matte Charcoal: 3MF
@@ -367,15 +418,11 @@ describe('FilamentOverride', () => {
         />,
       );
 
-      // Wait for the queries to resolve and the resolved label to land in
-      // the dropdown's "original" placeholder option. The tooltip on the
-      // color swatch carries the same text, so we scope to the option to
-      // avoid the multi-match.
-      await waitFor(() => {
-        const select = screen.getByRole('combobox');
-        const placeholder = select.querySelector('option[value=""]');
-        expect(placeholder?.textContent).toMatch(/Bambu PLA Matte/);
-      });
+      // Wait for the queries to resolve and the resolved label to land on the
+      // control, which reads as the "original filament" row while nothing is
+      // overridden. Reading the closed trigger rather than the open list keeps
+      // this clear of the swatch tooltip, which carries the same text.
+      await waitFor(() => expect(triggerText()).toMatch(/Bambu PLA Matte/));
     });
 
     it('prefers the cloud user-preset name over the builtin entry for the same id', async () => {
@@ -405,11 +452,7 @@ describe('FilamentOverride', () => {
         />,
       );
 
-      await waitFor(() => {
-        const select = screen.getByRole('combobox');
-        const placeholder = select.querySelector('option[value=""]');
-        expect(placeholder?.textContent).toMatch(/My House PLA/);
-      });
+      await waitFor(() => expect(triggerText()).toMatch(/My House PLA/));
       // The builtin fallback must NOT bleed through anywhere — neither the
       // placeholder option nor the tooltip.
       expect(screen.queryByText(/Bambu PLA Basic/)).not.toBeInTheDocument();
@@ -452,11 +495,7 @@ describe('FilamentOverride', () => {
         />,
       );
 
-      await waitFor(() => {
-        const select = screen.getByRole('combobox');
-        const placeholder = select.querySelector('option[value=""]');
-        expect(placeholder?.textContent).toMatch(/Bambu PLA Matte \(Charcoal\)/);
-      });
+      await waitFor(() => expect(triggerText()).toMatch(/Bambu PLA Matte \(Charcoal\)/));
     });
 
     it('disambiguates per slot when two slots share a hex but differ in material', async () => {
@@ -497,10 +536,9 @@ describe('FilamentOverride', () => {
       );
 
       await waitFor(() => {
-        const selects = screen.getAllByRole('combobox');
-        expect(selects).toHaveLength(2);
-        expect(selects[0].querySelector('option[value=""]')?.textContent).toMatch(/Bambu PLA Matte \(Charcoal\)/);
-        expect(selects[1].querySelector('option[value=""]')?.textContent).toMatch(/Bambu PLA Basic \(Black\)/);
+        expect(screen.getAllByRole('combobox')).toHaveLength(2);
+        expect(triggerText(0)).toMatch(/Bambu PLA Matte \(Charcoal\)/);
+        expect(triggerText(1)).toMatch(/Bambu PLA Basic \(Black\)/);
       });
     });
 
@@ -536,10 +574,8 @@ describe('FilamentOverride', () => {
       // Wait for the builtin lookup to land so we know the row mounted; the
       // colour fallback to getColorName for #FF0000 produces "Red"-shaped text.
       await waitFor(() => {
-        const select = screen.getByRole('combobox');
-        const placeholder = select.querySelector('option[value=""]');
-        expect(placeholder?.textContent).toMatch(/Bambu PLA Matte/);
-        expect(placeholder?.textContent).not.toMatch(/null/);
+        expect(triggerText()).toMatch(/Bambu PLA Matte/);
+        expect(triggerText()).not.toMatch(/null/);
       });
     });
 
@@ -571,9 +607,7 @@ describe('FilamentOverride', () => {
       await waitFor(() => {
         expect(screen.getByText('(25g)')).toBeInTheDocument();
       });
-      const select = screen.getByRole('combobox');
-      const placeholder = select.querySelector('option[value=""]');
-      expect(placeholder?.textContent).toMatch(/PLA \(/);
+      expect(triggerText()).toMatch(/PLA \(/);
     });
   });
 });

+ 78 - 17
frontend/src/__tests__/components/PrintModal.test.tsx

@@ -8,7 +8,7 @@
 
 import { describe, it, expect, vi, beforeEach } from 'vitest';
 import type React from 'react';
-import { screen, waitFor, fireEvent, render as rtlRender } from '@testing-library/react';
+import { screen, waitFor, fireEvent, within, render as rtlRender } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
 import { BrowserRouter } from 'react-router-dom';
@@ -59,6 +59,32 @@ const createMockQueueItem = (overrides: Partial<PrintQueueItem> = {}): PrintQueu
   ...overrides,
 });
 
+/**
+ * Choose a row in a SlotPicker (#3159).
+ *
+ * The slot and override controls were native `<select>`s until a swatch had to
+ * go next to each choice, which an `<option>` cannot render. They are listboxes
+ * now, so a test picks by opening the control and clicking a row instead of
+ * calling `selectOptions` with a value. `triggerName` matches the control's
+ * aria-label ("Printer slot for …" / "Filament override for …"), which is a
+ * steadier handle than the old "find the select that has an option with this
+ * value".
+ */
+async function pickFromSlotPicker(
+  user: ReturnType<typeof userEvent.setup>,
+  triggerName: RegExp,
+  rowText: RegExp,
+): Promise<void> {
+  const trigger = await waitFor(() => {
+    const found = screen.getAllByRole('combobox', { name: triggerName });
+    if (found.length === 0) throw new Error(`no picker matching ${triggerName}`);
+    return found[0];
+  });
+  await user.click(trigger);
+  const listbox = await screen.findByRole('listbox');
+  await user.click(within(listbox).getByText(rowText));
+}
+
 describe('PrintModal', () => {
   const mockOnClose = vi.fn();
   const mockOnSuccess = vi.fn();
@@ -1778,15 +1804,10 @@ describe('PrintModal — per-plate filament mapping (#2551 follow-up)', () => {
     await waitFor(() => expect(screen.getByText(/Filament Mapping — Plate 1/)).toBeInTheDocument());
 
     // Force plate 1's slot 1 onto the black tray (1) instead of the auto-matched red (0).
+    // A2 is AMS 0 / tray 1, i.e. global tray id 1 — the value the old
+    // `selectOptions(..., '1')` picked.
     await user.click(screen.getByText(/Filament Mapping — Plate 1/));
-    const traySelects = await waitFor(() => {
-      const found = screen.getAllByRole('combobox').filter((el) =>
-        Array.from((el as HTMLSelectElement).options).some((o) => o.value === '1'),
-      );
-      if (found.length === 0) throw new Error('no tray select rendered');
-      return found;
-    });
-    await user.selectOptions(traySelects[0], '1');
+    await pickFromSlotPicker(user, /printer slot for/i, /^A2:/);
 
     // Now move the job to the other printer.
     await user.click(screen.getByText('X1 Carbon')); // deselect
@@ -2347,6 +2368,47 @@ describe('PrintModal — override survives "Any model" -> "Specific Printer" (#3
     await user.click(document.querySelector('button[type="submit"]') as HTMLElement);
   };
 
+  // The slot picker's list is portaled to <body>, outside the dialog's own
+  // DOM, and the dialog closes both on a click outside itself and on Escape.
+  // Either would be a bad regression: picking a slot, or dismissing the list,
+  // would throw away everything typed into the dialog (#3159).
+  it('does not close the dialog when a slot is picked from the portaled list', async () => {
+    const user = userEvent.setup();
+    render(<PrintModal mode="edit-queue-item" archiveId={1} archiveName="Job" queueItem={anyP2SItem()} onClose={mockOnClose} />);
+
+    await moveToPrinter01(user);
+    // The panel is collapsed by default; the picker only exists once open.
+    await user.click(await screen.findByText(/filament mapping/i));
+
+    const trigger = await screen.findByRole('combobox', { name: /printer slot for/i });
+    await user.click(trigger);
+    const listbox = await screen.findByRole('listbox');
+    await user.click(within(listbox).getAllByRole('option')[1]);
+
+    expect(mockOnClose).not.toHaveBeenCalled();
+    expect(screen.getByText(/filament mapping/i)).toBeInTheDocument();
+  });
+
+  it('closes only the slot list on Escape, not the dialog', async () => {
+    const user = userEvent.setup();
+    render(<PrintModal mode="edit-queue-item" archiveId={1} archiveName="Job" queueItem={anyP2SItem()} onClose={mockOnClose} />);
+
+    await moveToPrinter01(user);
+    await user.click(await screen.findByText(/filament mapping/i));
+
+    await user.click(await screen.findByRole('combobox', { name: /printer slot for/i }));
+    await screen.findByRole('listbox');
+    await user.keyboard('{Escape}');
+
+    await waitFor(() => expect(screen.queryByRole('listbox')).not.toBeInTheDocument());
+    expect(mockOnClose).not.toHaveBeenCalled();
+
+    // And a second Escape, with no list open, still closes the dialog — the
+    // picker must not have swallowed the key permanently.
+    await user.keyboard('{Escape}');
+    expect(mockOnClose).toHaveBeenCalled();
+  });
+
   it('matches the chosen printer against the override and keeps it on the item', async () => {
     const user = userEvent.setup();
     render(<PrintModal mode="edit-queue-item" archiveId={1} archiveName="Job" queueItem={anyP2SItem()} onClose={mockOnClose} />);
@@ -2486,14 +2548,13 @@ describe('PrintModal — override survives "Any model" -> "Specific Printer" (#3
       return select as HTMLSelectElement;
     });
     await user.selectOptions(modelSelect, 'P2S');
-    const overrideSelect = await waitFor(() => {
-      const select = screen
-        .getAllByRole('combobox')
-        .find((el) => [...(el as HTMLSelectElement).options].some((o) => o.value === `PLA|${BONE_WHITE}`));
-      if (!select) throw new Error('override select not rendered');
-      return select as HTMLSelectElement;
-    });
-    await user.selectOptions(overrideSelect, `PLA|${BONE_WHITE}`);
+    // Matched on the hex the row now prints (#3159) rather than on the option
+    // value, so the assertion names the colour the user is actually picking.
+    await pickFromSlotPicker(
+      user,
+      /filament override for/i,
+      new RegExp(BONE_WHITE.toUpperCase().replace('#', '#?')),
+    );
 
     await moveToPrinter01(user);
     await waitFor(() => expect(screen.getByText(/filament mapping/i)).toBeInTheDocument());

+ 8 - 2
frontend/src/__tests__/components/PrintModalCrossModel.test.tsx

@@ -9,7 +9,7 @@
  */
 
 import { describe, it, expect, beforeEach } from 'vitest';
-import { screen, waitFor, fireEvent } from '@testing-library/react';
+import { screen, waitFor, fireEvent, within } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { http, HttpResponse } from 'msw';
 import { render } from '../utils';
@@ -94,8 +94,14 @@ describe('PrintModal cross-model mode', () => {
 
     // PLA Matte is loaded only on the H2D. It has to be offered anyway: the job
     // can land there, and choosing it simply narrows which candidates match.
+    // The control is a listbox since it gained a swatch per choice (#3159), so
+    // its rows exist only while it is open.
+    const trigger = await screen.findByRole('combobox', { name: /filament override for/i });
+    fireEvent.click(trigger);
     await waitFor(() => {
-      const options = screen.getAllByRole('option').map((o) => o.textContent ?? '');
+      const options = within(screen.getByRole('listbox'))
+        .getAllByRole('option')
+        .map((o) => o.textContent ?? '');
       expect(options.some((o) => o.includes('PLA Matte'))).toBe(true);
       expect(options.some((o) => o.includes('PLA Basic'))).toBe(true);
     });

+ 183 - 0
frontend/src/__tests__/components/SlotPicker.test.tsx

@@ -0,0 +1,183 @@
+/**
+ * Tests for SlotPicker — the filament chooser that replaced the Print /
+ * Schedule dialog's native `<select>`s so each choice can carry its colour
+ * (#3159).
+ *
+ * The point of the control is the swatch, so these pin that a colour is drawn
+ * for every row and for the chosen one; that a slot with no inventory binding
+ * still gets the tray's own colour; and that the keyboard still works, because
+ * the `<select>` this replaced was operable without a mouse and losing that
+ * would be a regression dressed up as a feature.
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { screen, within, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { SlotPicker, type SlotPickerOption } from '../../components/PrintModal/SlotPicker';
+import { displayHex } from '../../components/PrintModal/displayHex';
+
+const OPTIONS: SlotPickerOption[] = [
+  {
+    value: '0',
+    label: 'A1: Devil Design PLA Basic (Orange)',
+    meta: ' - 820g left',
+    rgba: '#FEC600',
+    hex: '#FEC600',
+  },
+  {
+    value: '1',
+    // A slot configured on the printer with no inventory spool behind it: the
+    // tray colour is all there is, and it still has to draw.
+    label: 'A2: PLA (Black)',
+    rgba: '#000000',
+    hex: '#000000',
+  },
+  {
+    value: '2',
+    label: 'A3: eSUN PLA Silk (Rainbow)',
+    rgba: '#FF0000',
+    extraColors: '00FF00,0000FF',
+    subtype: 'Multicolor',
+    hex: '#FF0000',
+    exactMatch: true,
+  },
+];
+
+function renderPicker(props: Partial<React.ComponentProps<typeof SlotPicker>> = {}) {
+  const onChange = props.onChange ?? vi.fn();
+  render(
+    <SlotPicker
+      value=""
+      options={OPTIONS}
+      placeholder="-- Select slot --"
+      ariaLabel="Printer slot for PLA"
+      exactMatchLabel="Exact colour match"
+      {...props}
+      onChange={onChange}
+    />,
+  );
+  return { onChange };
+}
+
+async function open(user: ReturnType<typeof userEvent.setup>) {
+  await user.click(screen.getByRole('combobox', { name: /printer slot for pla/i }));
+  return await screen.findByRole('listbox');
+}
+
+describe('displayHex', () => {
+  it('prints six digits for an opaque colour and eight for a see-through one', () => {
+    // Alpha is kept only when it carries information: a Clear filament must
+    // not read as if it were solid, and an opaque spool must not be given a
+    // meaningless "FF" tail.
+    expect(displayHex('#FEC600')).toBe('#FEC600');
+    expect(displayHex('FEC600FF')).toBe('#FEC600');
+    expect(displayHex('#00AAFF80')).toBe('#00AAFF80');
+    expect(displayHex('#00000000')).toBe('#00000000');
+  });
+
+  it('prints nothing rather than something misleading', () => {
+    expect(displayHex(null)).toBeNull();
+    expect(displayHex('')).toBeNull();
+    expect(displayHex('#ABC')).toBeNull();
+    expect(displayHex('not-a-colour')).toBeNull();
+  });
+});
+
+describe('SlotPicker', () => {
+  it('draws a swatch for every offered slot', async () => {
+    const user = userEvent.setup();
+    renderPicker();
+
+    const listbox = await open(user);
+    // One per option. The placeholder has no colour and deliberately draws
+    // nothing rather than a grey swatch, which would read as a grey spool.
+    expect(within(listbox).getAllByTestId('filament-swatch')).toHaveLength(OPTIONS.length);
+  });
+
+  it('draws the chosen slot\'s colour on the closed control', async () => {
+    renderPicker({ value: '1' });
+
+    const trigger = screen.getByRole('combobox', { name: /printer slot for pla/i });
+    const swatch = within(trigger).getByTestId('filament-swatch');
+    expect(swatch).toBeInTheDocument();
+    expect(trigger).toHaveTextContent('A2: PLA (Black)');
+  });
+
+  it('renders a multi-colour spool as a gradient, not its base colour', async () => {
+    const user = userEvent.setup();
+    renderPicker();
+
+    const listbox = await open(user);
+    const rainbow = within(listbox).getByRole('option', { name: /eSUN PLA Silk/ });
+    const swatch = within(rainbow).getByTestId('filament-swatch');
+    // `Multicolor` is what turns the stops into a conic sweep; a slot with no
+    // extra stops would be a flat linear-gradient of one colour.
+    expect(swatch.style.backgroundImage).toContain('conic-gradient');
+    expect(swatch.style.backgroundImage).toContain('#00FF00');
+  });
+
+  it('shows each slot\'s hex and marks the one that matches exactly', async () => {
+    const user = userEvent.setup();
+    renderPicker();
+
+    const listbox = await open(user);
+    expect(within(listbox).getByText('#FEC600')).toBeInTheDocument();
+    // The marker is what answers "is this a real mismatch or two names for the
+    // same hex" without leaving the dialog.
+    const exact = within(listbox).getByTitle('Exact colour match');
+    expect(within(listbox).getByRole('option', { name: /eSUN PLA Silk/ })).toContainElement(exact);
+  });
+
+  it('picks a slot with the keyboard', async () => {
+    const user = userEvent.setup();
+    const { onChange } = renderPicker();
+
+    const trigger = screen.getByRole('combobox', { name: /printer slot for pla/i });
+    trigger.focus();
+    // Row 0 is the placeholder, so two presses land on the second slot.
+    await user.keyboard('{ArrowDown}');
+    await screen.findByRole('listbox');
+    await user.keyboard('{ArrowDown}{ArrowDown}{Enter}');
+
+    expect(onChange).toHaveBeenCalledWith('1');
+    await waitFor(() => expect(screen.queryByRole('listbox')).not.toBeInTheDocument());
+  });
+
+  it('closes on Escape without letting the dialog around it see the key', async () => {
+    const user = userEvent.setup();
+    const onDialogEscape = vi.fn();
+    document.addEventListener('keydown', onDialogEscape);
+    try {
+      renderPicker();
+      await open(user);
+      await user.keyboard('{Escape}');
+
+      await waitFor(() => expect(screen.queryByRole('listbox')).not.toBeInTheDocument());
+      // The picker stops propagation, so a dialog listening for Escape on the
+      // document never sees it and stays open (#3159).
+      expect(onDialogEscape).not.toHaveBeenCalled();
+    } finally {
+      document.removeEventListener('keydown', onDialogEscape);
+    }
+  });
+
+  it('reports the value the caller gave the row, not its position', async () => {
+    const user = userEvent.setup();
+    const { onChange } = renderPicker();
+
+    const listbox = await open(user);
+    await user.click(within(listbox).getByRole('option', { name: /Devil Design/ }));
+    expect(onChange).toHaveBeenCalledWith('0');
+  });
+
+  it('offers the placeholder as a real choice so a slot can be cleared', async () => {
+    const user = userEvent.setup();
+    const { onChange } = renderPicker({ value: '1' });
+
+    await user.click(screen.getByRole('combobox', { name: /printer slot for pla/i }));
+    const listbox = await screen.findByRole('listbox');
+    await user.click(within(listbox).getByRole('option', { name: /Select slot/ }));
+    expect(onChange).toHaveBeenCalledWith('');
+  });
+});

+ 76 - 0
frontend/src/__tests__/hooks/useFilamentMapping.test.ts

@@ -397,6 +397,82 @@ describe('computeAmsMapping', () => {
   });
 });
 
+describe('buildLoadedFilaments - slot colour for the picker (#3159)', () => {
+  it('carries the bound spool\'s extra stops and effect onto the slot', () => {
+    // A tray record holds one hex and nothing else, so a two-tone or glittery
+    // spool would draw as its base colour in the Print dialog's slot picker.
+    // The binding is the only place the rest of the colour exists.
+    const status = createPrinterStatus([
+      { id: 0, tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00' }] },
+    ]);
+    const slotSpools = new Map([
+      [
+        0,
+        {
+          brand: 'eSUN',
+          material: 'PLA',
+          subtype: 'Multicolor',
+          color_name: 'Rainbow',
+          rgba: 'FF0000FF',
+          extra_colors: '00FF00,0000FF',
+          effect_type: null,
+        },
+      ],
+    ]);
+
+    const [slot] = buildLoadedFilaments(status, slotSpools);
+
+    expect(slot).toMatchObject({
+      extraColors: '00FF00,0000FF',
+      spoolSubtype: 'Multicolor',
+      colorName: 'Rainbow',
+    });
+    expect(slot.effectType).toBeUndefined();
+  });
+
+  it('leaves a slot with no binding to its own tray colour', () => {
+    // Configure Slot / set-on-the-printer slots have no inventory spool behind
+    // them. The firmware reports no stops and never will, so the picker draws a
+    // solid swatch -- which is the honest one, not a missing feature.
+    const status = createPrinterStatus([
+      { id: 0, tray: [{ id: 0, tray_type: 'PLA', tray_color: '00AAFFFF', tray_info_idx: 'GFA00' }] },
+    ]);
+
+    const [slot] = buildLoadedFilaments(status);
+
+    expect(slot.color).toBe('#00AAFF');
+    expect(slot.extraColors).toBeUndefined();
+    expect(slot.effectType).toBeUndefined();
+    expect(slot.spoolSubtype).toBeUndefined();
+  });
+
+  it('carries them for the external spool holder too', () => {
+    const status = createPrinterStatus(
+      [],
+      [{ id: 254, tray_type: 'PLA', tray_color: 'AABBCCFF', tray_info_idx: 'GFA00' }] as PrinterStatus['vt_tray'],
+    );
+    const slotSpools = new Map([
+      [
+        254,
+        {
+          brand: 'Devil Design',
+          material: 'PLA',
+          subtype: 'Galaxy',
+          color_name: 'Galaxy Black',
+          rgba: 'AABBCCFF',
+          extra_colors: null,
+          effect_type: 'Glitter',
+        },
+      ],
+    ]);
+
+    const [slot] = buildLoadedFilaments(status, slotSpools);
+
+    expect(slot.effectType).toBe('Glitter');
+    expect(slot.spoolSubtype).toBe('Galaxy');
+  });
+});
+
 describe('buildLoadedFilaments - nozzle awareness', () => {
   it('sets extruderId from ams_extruder_map', () => {
     const status = createPrinterStatus([

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

@@ -3730,6 +3730,15 @@ export interface SlotSpoolIdentity {
   subtype: string | null;
   color_name: string | null;
   rgba: string | null;
+  /** Comma-separated hex stops for a multi-colour spool, and the effect
+   *  overlay — the two things a tray record cannot carry, so a slot swatch
+   *  can be drawn the way the inventory row draws it (#3159).
+   *
+   *  Optional rather than required: the backend sends both keys on every
+   *  binding, but a frontend running against an older one gets neither, and
+   *  every reader here already falls back to a solid swatch. */
+  extra_colors?: string | null;
+  effect_type?: string | null;
 }
 
 export interface InventoryRemainResponse {

+ 47 - 30
frontend/src/components/PrintModal/FilamentMapping.tsx

@@ -5,8 +5,10 @@ import { Circle, Check, AlertTriangle, RefreshCw, ChevronDown, ChevronUp, Palett
 import { api } from '../../api/client';
 import type { SlotSpoolIdentity } from '../../api/client';
 import { useFilamentMapping } from '../../hooks/useFilamentMapping';
-import { getGlobalTrayId, effectivePreferLowest, FTS_INLET_SIDE } from '../../utils/amsHelpers';
+import { getGlobalTrayId, effectivePreferLowest, FTS_INLET_SIDE, normalizeColorForCompare } from '../../utils/amsHelpers';
 import { disambiguateColorNames, getColorName } from '../../utils/colors';
+import { SlotPicker } from './SlotPicker';
+import { displayHex } from './displayHex';
 import { useFilamentLabels } from './useFilamentLabels';
 import { autoAssignRackPositions, rackOptionsForGroup } from '../../utils/nozzleRack';
 import type { FilamentMappingProps, RackGroupInfo } from './types';
@@ -490,11 +492,32 @@ export function FilamentMapping({
                 </span>
                 {/* Arrow */}
                 <span className="text-bambu-gray">→</span>
-                {/* Slot selector dropdown */}
-                <select
-                  value={item.loaded?.globalTrayId ?? ''}
-                  onChange={(e) => handleSlotChange(slotId, e.target.value)}
-                  className={`flex-1 px-2 py-1 rounded border text-xs bg-bambu-dark-secondary focus:outline-none focus:ring-1 focus:ring-bambu-green ${
+                {/*
+                  Slot picker. Not a <select>: an <option> renders text only,
+                  and the colour of the slot is the whole point of this row
+                  (#3159) -- colour names come from the catalogue or from hue,
+                  so a third-party beige reads as "Orange" and the mismatch
+                  warning cannot be judged without seeing both swatches.
+
+                  #1722: every loaded slot is offered for every filament row,
+                  regardless of which extruder the slot is wired to. Before that
+                  change a slot was only listed when its extruder matched the
+                  filament's slicer-assigned nozzle (item.nozzle_id), which
+                  locked users out of cross-extruder picks even when they'd
+                  intentionally loaded the required filament into the "other"
+                  AMS. The L/R badge on the filament row still tells the user
+                  what the slicer planned; the picker trusts the user to pick
+                  based on their physical setup. Printer firmware accepts or
+                  rejects the ams_mapping at start-print -- failure is loud,
+                  not silent.
+                */}
+                <SlotPicker
+                  value={item.loaded?.globalTrayId != null ? String(item.loaded.globalTrayId) : ''}
+                  onChange={(next) => handleSlotChange(slotId, next)}
+                  placeholder={`-- ${t('printModal.selectSlot')} --`}
+                  ariaLabel={t('printModal.slotForFilament', { name: resolvedName })}
+                  exactMatchLabel={t('printModal.exactColorMatch')}
+                  className={`${
                     item.status === 'match'
                       ? 'border-bambu-green/50 text-bambu-green'
                       : item.status === 'type_only'
@@ -502,24 +525,7 @@ export function FilamentMapping({
                       : 'border-orange-500 dark:border-orange-400/50 text-orange-700 dark:text-orange-400'
                   } ${item.isManual ? 'ring-1 ring-blue-400/50' : ''}`}
                   title={item.isManual ? t('printModal.manuallySelected') : t('printModal.autoMatched')}
-                >
-                  <option value="" className="bg-bambu-dark text-bambu-gray">
-                    -- {t('printModal.selectSlot')} --
-                  </option>
-                  {/*
-                    #1722: every loaded slot is offered for every filament row,
-                    regardless of which extruder the slot is wired to. Before this
-                    change a slot was only listed when its extruder matched the
-                    filament's slicer-assigned nozzle (item.nozzle_id), which
-                    locked users out of cross-extruder picks even when they'd
-                    intentionally loaded the required filament into the "other"
-                    AMS. The L/R badge on the filament row still tells the user
-                    what the slicer planned; the dropdown now trusts the user to
-                    pick based on their physical setup. Printer firmware accepts
-                    or rejects the ams_mapping at start-print — failure is loud,
-                    not silent.
-                  */}
-                  {loadedFilaments.map((f) => {
+                  options={loadedFilaments.map((f) => {
                       const remainingWeight = trayRemainingWeightMap.get(f.globalTrayId);
                       const remainingLabel = remainingWeight != null
                         ? t('printModal.slotRemainingShort', {
@@ -536,13 +542,24 @@ export function FilamentMapping({
                       // two views agree. Not translated: L and R are the letters on
                       // the machine.
                       const ftsBadge = ftsInlet == null ? '' : ` [${FTS_INLET_SIDE[ftsInlet]}]`;
-                      return (
-                        <option key={f.globalTrayId} value={f.globalTrayId} className="bg-bambu-dark text-white">
-                          {f.label}: {f.spoolName || f.traySubBrands || f.type} ({f.colorName}){remainingLabel}{ftsBadge}
-                        </option>
-                      );
+                      return {
+                        value: String(f.globalTrayId),
+                        label: `${f.label}: ${f.spoolName || f.traySubBrands || f.type} (${f.colorName})`,
+                        meta: `${remainingLabel}${ftsBadge}`,
+                        rgba: f.color,
+                        extraColors: f.extraColors,
+                        effectType: f.effectType,
+                        subtype: f.spoolSubtype,
+                        hex: displayHex(f.color),
+                        // The hex the slice asked for, against the hex the
+                        // printer reports -- the comparison the colour names
+                        // cannot be trusted to make.
+                        exactMatch:
+                          normalizeColorForCompare(item.color) !== '' &&
+                          normalizeColorForCompare(f.color) === normalizeColorForCompare(item.color),
+                      };
                   })}
-                </select>
+                />
                 {/* Status icon */}
                 {item.status === 'match' ? (
                   <Check className="w-3 h-3 text-bambu-green" />

+ 34 - 20
frontend/src/components/PrintModal/FilamentOverride.tsx

@@ -2,7 +2,9 @@ import { useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Circle, RotateCcw, Palette } from 'lucide-react';
 import { getColorName } from '../../utils/colors';
-import { canonicalFilamentType } from '../../utils/amsHelpers';
+import { canonicalFilamentType, normalizeColorForCompare } from '../../utils/amsHelpers';
+import { SlotPicker } from './SlotPicker';
+import { displayHex } from './displayHex';
 import { useFilamentLabels } from './useFilamentLabels';
 import type { FilamentReqsData } from './types';
 
@@ -123,30 +125,42 @@ export function FilamentOverride({
                 </span>
                 {/* Arrow */}
                 <span className="text-bambu-gray">→</span>
-                {/* Override dropdown — only compatible (same-type) filaments */}
-                <select
+                {/* Override picker — only compatible (same-type) filaments.
+                    Carries a swatch for the same reason the mapping row does
+                    (#3159): this is the model-mode half of the same choice,
+                    and leaving it text-only would make the two paths read
+                    differently for one decision. Model mode pools filaments
+                    across every printer of the model, so there is no slot
+                    binding behind an entry and the swatch is the plain tray
+                    colour -- solid, with no multi-colour stops to draw. */}
+                <SlotPicker
                   value={isOverridden ? `${override.type}|${override.color}` : ''}
-                  onChange={(e) => handleChange(req.slot_id, e.target.value)}
+                  onChange={(next) => handleChange(req.slot_id, next)}
+                  placeholder={`${t('printModal.originalFilament')}: ${resolvedName} (${colorLabel})`}
+                  placeholderOption={{ rgba: req.color, hex: displayHex(req.color) }}
                   disabled={compatible.length === 0}
-                  className={`flex-1 px-2 py-1 rounded border text-xs bg-bambu-dark-secondary focus:outline-none focus:ring-1 focus:ring-bambu-green ${
+                  ariaLabel={t('printModal.overrideForFilament', { name: resolvedName })}
+                  exactMatchLabel={t('printModal.exactColorMatch')}
+                  className={
                     isOverridden
                       ? 'border-blue-500/50 dark:border-blue-400/50 text-blue-700 dark:text-blue-400'
                       : 'border-bambu-gray/30 text-bambu-gray'
-                  }`}
-                >
-                  <option value="" className="bg-bambu-dark text-bambu-gray">
-                    {t('printModal.originalFilament')}: {resolvedName} ({colorLabel})
-                  </option>
-                  {compatible.map((f, idx) => (
-                    <option
-                    key={`${f.type}-${f.color}-${f.tray_sub_brands}-${idx}`}
-                      value={`${f.type}|${f.color}`}
-                      className="bg-bambu-dark text-white"
-                    >
-                    {f.tray_sub_brands || f.type} ({getColorName(f.color, f.tray_sub_brands)})
-                    </option>
-                  ))}
-                </select>
+                  }
+                  options={compatible.map((f, idx) => ({
+                    // The value has to stay `TYPE|COLOR` -- `handleChange`
+                    // parses it -- but two spools of the same type and colour
+                    // on different printers collapse to one value, so the key
+                    // keeps the sub-brand and the index as it did before.
+                    value: `${f.type}|${f.color}`,
+                    key: `${f.type}-${f.color}-${f.tray_sub_brands}-${idx}`,
+                    label: `${f.tray_sub_brands || f.type} (${getColorName(f.color, f.tray_sub_brands)})`,
+                    rgba: f.color,
+                    hex: displayHex(f.color),
+                    exactMatch:
+                      normalizeColorForCompare(req.color) !== '' &&
+                      normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color),
+                  }))}
+                />
                 {/* Reset button */}
                 {isOverridden ? (
                   <button

+ 302 - 0
frontend/src/components/PrintModal/SlotPicker.tsx

@@ -0,0 +1,302 @@
+import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
+import { createPortal } from 'react-dom';
+import { ChevronDown, Check } from 'lucide-react';
+import { FilamentSwatch } from '../FilamentSwatch';
+
+/**
+ * One-line filament picker that can show a colour, which a `<select>` cannot.
+ *
+ * The Print / Schedule dialog is where the slicer's colour is compared against
+ * what is actually in the machine, and until now the right-hand side of that
+ * comparison was text only (#3159). Colour names are not a substitute: they are
+ * resolved from the Color Catalog or from hue, so a third-party beige reads as
+ * "Orange" and a "Color mismatch" warning gives no way to tell a real mismatch
+ * from two names for the same hex. `<option>` renders text and nothing else, so
+ * the swatch means this cannot be a native select.
+ *
+ * What is kept from the select it replaces: full keyboard operation (arrows,
+ * Home/End, Enter, Escape), `role="listbox"` semantics, and the caller's own
+ * border colouring, which encodes match / type-only / mismatch on the mapping
+ * row and overridden / original on the override row.
+ *
+ * Deliberately not a shared "Select": the rows are a swatch, a name, a hex and
+ * a badge, and this is the second such control in the app after
+ * `spool-form/PresetPicker` — which documents the same reasoning. A shared
+ * abstraction over two call sites with different row shapes would be guessing
+ * at the third.
+ */
+
+export interface SlotPickerOption {
+  /** The value handed back to `onChange`; mirrors the old `<option value>`. */
+  value: string;
+  /** Main text of the row. */
+  label: string;
+  /** RRGGBBAA (or RRGGBB) without `#`. Absent renders no swatch at all rather
+   *  than a grey one, so "no colour known" never looks like a grey spool. */
+  rgba?: string | null;
+  /** Comma-separated extra hex stops for a multi-colour spool. */
+  extraColors?: string | null;
+  /** Effect overlay name, as `FilamentSwatch` understands it. */
+  effectType?: string | null;
+  /** Filament subtype; `Multicolor` renders the stops as a conic sweep. */
+  subtype?: string | null;
+  /** Muted suffix after the label — remaining grams, FTS inlet badge. */
+  meta?: string;
+  /** Shown in the dropdown row so two similar colours can be told apart by
+   *  their hex rather than by two names that resolved the same way. */
+  hex?: string | null;
+  /** Marks the row whose colour is exactly the one the slice asked for. */
+  exactMatch?: boolean;
+  /** React key, when `value` is not unique. Model-mode pools filaments across
+   *  every printer of a model, so two spools of the same type and colour
+   *  collapse to one `TYPE|COLOR` value while still being two rows. */
+  key?: string;
+}
+
+interface SlotPickerProps {
+  /** Currently chosen option value, or '' for the placeholder row. */
+  value: string;
+  options: SlotPickerOption[];
+  /** What the '' row reads as. */
+  placeholder: string;
+  /** Colour for the '' row, when it has one (the override panel's '' row is
+   *  the 3MF's own filament, which does have a colour). */
+  placeholderOption?: Omit<SlotPickerOption, 'value' | 'label'>;
+  onChange: (value: string) => void;
+  disabled?: boolean;
+  ariaLabel: string;
+  /** Native tooltip on the trigger. */
+  title?: string;
+  /** Border / text classes the caller uses to encode status. */
+  className?: string;
+  /** Tooltip for the exact-match marker. */
+  exactMatchLabel?: string;
+}
+
+const SWATCH_CLASS = 'w-3 h-3';
+
+export function SlotPicker({
+  value,
+  options,
+  placeholder,
+  placeholderOption,
+  onChange,
+  disabled = false,
+  ariaLabel,
+  title,
+  className = '',
+  exactMatchLabel,
+}: SlotPickerProps) {
+  const [open, setOpen] = useState(false);
+  const [activeIndex, setActiveIndex] = useState(0);
+  const [coords, setCoords] = useState<{ top: number; left: number; width: number } | null>(null);
+  const triggerRef = useRef<HTMLButtonElement>(null);
+  const listRef = useRef<HTMLDivElement>(null);
+
+  // The placeholder is row 0, so an index addresses both it and the options
+  // with one number — which is what the arrow keys and aria-activedescendant
+  // need.
+  const rows: SlotPickerOption[] = [
+    { ...(placeholderOption ?? {}), value: '', label: placeholder },
+    ...options,
+  ];
+  const selectedIndex = Math.max(
+    0,
+    rows.findIndex((r) => r.value === value),
+  );
+  const selected = rows[selectedIndex];
+
+  const place = useCallback(() => {
+    const el = triggerRef.current;
+    if (!el) return;
+    const rect = el.getBoundingClientRect();
+    // Flip above when the row sits low enough that a downward list would run
+    // off the viewport. The dialog body scrolls, so a mapping row near its
+    // bottom is the ordinary case, not the edge one.
+    const below = window.innerHeight - rect.bottom;
+    const height = Math.min(listRef.current?.scrollHeight ?? 0, 240);
+    const flip = below < height + 8 && rect.top > below;
+    setCoords({
+      top: flip ? Math.max(8, rect.top - height - 4) : rect.bottom + 4,
+      left: rect.left,
+      width: rect.width,
+    });
+  }, []);
+
+  useLayoutEffect(() => {
+    if (open) place();
+  }, [open, place]);
+
+  useEffect(() => {
+    if (!open) return;
+    const onPointerDown = (e: MouseEvent) => {
+      const target = e.target as Node;
+      if (triggerRef.current?.contains(target) || listRef.current?.contains(target)) return;
+      setOpen(false);
+    };
+    // Escape closes this control WITHOUT closing the dialog around it, which a
+    // bare document listener would otherwise let happen — same guard
+    // `PresetPicker` documents.
+    const onKeyDown = (e: KeyboardEvent) => {
+      if (e.key !== 'Escape') return;
+      e.stopPropagation();
+      setOpen(false);
+      triggerRef.current?.focus();
+    };
+    // The list is portaled to <body>, so it does not travel with the dialog's
+    // own scroll container: follow it rather than leaving it behind.
+    const reposition = () => place();
+    document.addEventListener('mousedown', onPointerDown);
+    document.addEventListener('keydown', onKeyDown, true);
+    window.addEventListener('scroll', reposition, true);
+    window.addEventListener('resize', reposition);
+    return () => {
+      document.removeEventListener('mousedown', onPointerDown);
+      document.removeEventListener('keydown', onKeyDown, true);
+      window.removeEventListener('scroll', reposition, true);
+      window.removeEventListener('resize', reposition);
+    };
+  }, [open, place]);
+
+  const choose = (index: number) => {
+    onChange(rows[index].value);
+    setOpen(false);
+    triggerRef.current?.focus();
+  };
+
+  const openAt = (index: number) => {
+    setActiveIndex(index);
+    setOpen(true);
+  };
+
+  const handleTriggerKey = (e: React.KeyboardEvent) => {
+    if (disabled) return;
+    if (!open) {
+      if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') {
+        e.preventDefault();
+        openAt(selectedIndex);
+      }
+      return;
+    }
+    if (e.key === 'ArrowDown') {
+      e.preventDefault();
+      setActiveIndex((i) => Math.min(rows.length - 1, i + 1));
+    } else if (e.key === 'ArrowUp') {
+      e.preventDefault();
+      setActiveIndex((i) => Math.max(0, i - 1));
+    } else if (e.key === 'Home') {
+      e.preventDefault();
+      setActiveIndex(0);
+    } else if (e.key === 'End') {
+      e.preventDefault();
+      setActiveIndex(rows.length - 1);
+    } else if (e.key === 'Enter' || e.key === ' ') {
+      e.preventDefault();
+      choose(activeIndex);
+    } else if (e.key === 'Tab') {
+      setOpen(false);
+    }
+  };
+
+  const listId = `slot-picker-${ariaLabel.replace(/\W+/g, '-')}`;
+
+  const renderSwatch = (row: SlotPickerOption) =>
+    row.rgba ? (
+      <FilamentSwatch
+        rgba={row.rgba}
+        extraColors={row.extraColors}
+        effectType={row.effectType}
+        subtype={row.subtype}
+        className={SWATCH_CLASS}
+        effectSize="table"
+      />
+    ) : null;
+
+  return (
+    <>
+      <button
+        ref={triggerRef}
+        type="button"
+        role="combobox"
+        aria-label={ariaLabel}
+        aria-haspopup="listbox"
+        aria-expanded={open}
+        aria-controls={open ? listId : undefined}
+        // On the combobox, not on the listbox: focus stays on the trigger
+        // while the list is open, and this is what tells a screen reader which
+        // row the arrow keys are on.
+        aria-activedescendant={open ? `${listId}-${activeIndex}` : undefined}
+        disabled={disabled}
+        title={title}
+        onClick={() => (open ? setOpen(false) : openAt(selectedIndex))}
+        onKeyDown={handleTriggerKey}
+        className={`flex-1 min-w-0 flex items-center gap-1.5 px-2 py-1 rounded border text-xs bg-bambu-dark-secondary text-left focus:outline-none focus:ring-1 focus:ring-bambu-green disabled:opacity-50 ${className}`}
+      >
+        {renderSwatch(selected)}
+        <span className="flex-1 min-w-0 truncate">
+          {selected.label}
+          {selected.meta ? <span className="text-bambu-gray">{selected.meta}</span> : null}
+        </span>
+        <ChevronDown className="w-3 h-3 shrink-0 opacity-70" />
+      </button>
+
+      {open &&
+        createPortal(
+          <div
+            ref={listRef}
+            id={listId}
+            role="listbox"
+            aria-label={ariaLabel}
+            className="fixed z-[60] max-h-60 overflow-y-auto rounded-lg border border-bambu-dark-tertiary bg-bambu-dark-secondary p-1 shadow-xl"
+            style={{
+              top: coords?.top ?? -9999,
+              left: coords?.left ?? -9999,
+              width: coords?.width,
+              // Hide until placed, so there is no flash at (-9999,-9999) —
+              // same trick FilamentHoverCard uses.
+              visibility: coords ? 'visible' : 'hidden',
+            }}
+          >
+            {rows.map((row, index) => (
+              <button
+                key={row.key ?? `${row.value}-${index}`}
+                id={`${listId}-${index}`}
+                type="button"
+                role="option"
+                // What `<option value>` carried before this was a listbox: the
+                // identity of the choice, separate from how it reads.
+                data-value={row.value}
+                aria-selected={index === selectedIndex}
+                onMouseEnter={() => setActiveIndex(index)}
+                onClick={() => choose(index)}
+                className={`w-full flex items-center gap-1.5 text-left px-2 py-1.5 rounded-md text-xs ${
+                  index === selectedIndex
+                    ? 'bg-bambu-green/15 text-bambu-green'
+                    : index === activeIndex
+                      ? 'bg-bambu-dark text-white'
+                      : 'text-white'
+                }`}
+              >
+                {renderSwatch(row)}
+                <span className="flex-1 min-w-0 truncate" title={row.label}>
+                  {row.label}
+                  {row.meta ? <span className="text-bambu-gray">{row.meta}</span> : null}
+                </span>
+                {row.hex ? (
+                  <span className="shrink-0 font-mono text-[10px] uppercase text-bambu-gray">{row.hex}</span>
+                ) : null}
+                {row.exactMatch ? (
+                  <span title={exactMatchLabel} className="shrink-0 text-bambu-green">
+                    <Check className="w-3 h-3" />
+                  </span>
+                ) : null}
+              </button>
+            ))}
+          </div>,
+          document.body,
+        )}
+    </>
+  );
+}
+
+export default SlotPicker;

+ 19 - 0
frontend/src/components/PrintModal/displayHex.ts

@@ -0,0 +1,19 @@
+/**
+ * The hex to print next to a swatch in the slot picker (#3159).
+ *
+ * Shown because two slots whose colour names resolved the same way -- both
+ * "Orange", one a beige -- are otherwise indistinguishable in the list, and
+ * comparing them against the slice's colour is the reason the picker exists.
+ *
+ * Alpha is kept only when it carries information: a translucent spool prints
+ * all eight digits, an opaque one prints six, so a Clear filament is not shown
+ * as if it were solid. Returns null for anything unreadable, and the caller
+ * then prints no hex rather than a misleading one.
+ */
+export function displayHex(color: string | null | undefined): string | null {
+  if (!color) return null;
+  const clean = color.trim().replace(/^#/, '');
+  if (!/^[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/.test(clean)) return null;
+  const keepAlpha = clean.length === 8 && clean.substring(6, 8).toLowerCase() !== 'ff';
+  return `#${(keepAlpha ? clean : clean.substring(0, 6)).toUpperCase()}`;
+}

+ 16 - 0
frontend/src/hooks/useFilamentMapping.ts

@@ -85,6 +85,9 @@ export function buildLoadedFilaments(
           trayInfoIdx: tray.tray_info_idx || '',
           traySubBrands: tray.tray_sub_brands || '',
           spoolName: spoolDisplayName(identity) ?? undefined,
+          extraColors: identity?.extra_colors ?? undefined,
+          effectType: identity?.effect_type ?? undefined,
+          spoolSubtype: identity?.subtype ?? undefined,
           extruderId: amsExtruderMap?.[String(amsUnit.id)],
           remain: tray.remain ?? -1,
         });
@@ -114,6 +117,9 @@ export function buildLoadedFilaments(
         trayInfoIdx: extTray.tray_info_idx || '',
         traySubBrands: extTray.tray_sub_brands || '',
         spoolName: spoolDisplayName(identity) ?? undefined,
+        extraColors: identity?.extra_colors ?? undefined,
+        effectType: identity?.effect_type ?? undefined,
+        spoolSubtype: identity?.subtype ?? undefined,
         extruderId: hasDualNozzle ? (255 - trayId) : undefined,
         remain: extTray.remain ?? -1,
       });
@@ -184,6 +190,16 @@ export interface LoadedFilament {
   extruderId?: number;
   /** Remaining filament percentage (0-100), -1 = unknown */
   remain: number;
+  /** The bound spool's remaining colour stops and effect overlay, when the
+   *  slot has a binding (#3159). Display only, like `spoolName`: a tray record
+   *  carries one hex, so without these a two-tone spool draws as its base
+   *  colour. Absent for a slot configured on the printer -- the firmware has
+   *  nothing to report, and a solid swatch is then the honest one. */
+  extraColors?: string;
+  effectType?: string;
+  /** The bound spool's subtype ("PLA Silk", "Multicolor"), which decides
+   *  whether the swatch renders its stops as a conic sweep. */
+  spoolSubtype?: string;
 }
 
 /**

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

@@ -5034,6 +5034,9 @@ export default {
     manuallySelected: 'Manuell ausgewählt',
     autoMatched: 'Automatisch zugeordnet',
     selectSlot: 'Slot wählen',
+    exactColorMatch: 'Exakte Farbübereinstimmung',
+    slotForFilament: 'Drucker-Slot für {{name}}',
+    overrideForFilament: 'Filament-Überschreibung für {{name}}',
     sameTypeDifferentColorDetail: 'Gleicher Typ, andere Farbe: benötigt {{required}}, im Slot ist {{loaded}}',
     requiredTypeNotInPrinter: 'Benötigter Filamenttyp im Drucker nicht gefunden.',
     whenToPrint: 'Wann drucken',

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

@@ -5079,6 +5079,9 @@ export default {
     manuallySelected: 'Manually selected',
     autoMatched: 'Auto-matched',
     selectSlot: 'Select slot',
+    exactColorMatch: 'Exact colour match',
+    slotForFilament: 'Printer slot for {{name}}',
+    overrideForFilament: 'Filament override for {{name}}',
     sameTypeDifferentColorDetail: 'Same type, different color: needs {{required}}, slot has {{loaded}}',
     requiredTypeNotInPrinter: 'Required filament type not found in printer.',
     whenToPrint: 'When to print',

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

@@ -5041,6 +5041,9 @@ export default {
     manuallySelected: 'Seleccionado manualmente',
     autoMatched: 'Emparejado automáticamente',
     selectSlot: 'Elegir ranura',
+    exactColorMatch: 'Coincidencia exacta de color',
+    slotForFilament: 'Ranura de impresora para {{name}}',
+    overrideForFilament: 'Anulación de filamento para {{name}}',
     sameTypeDifferentColorDetail: 'Mismo tipo, color distinto: necesita {{required}}, la ranura tiene {{loaded}}',
     requiredTypeNotInPrinter: 'El tipo de filamento requerido no se encuentra en la impresora.',
     whenToPrint: 'Cuándo imprimir',

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

@@ -5023,6 +5023,9 @@ export default {
     manuallySelected: 'Sélectionné manuellement',
     autoMatched: 'Associé automatiquement',
     selectSlot: 'Choisir un emplacement',
+    exactColorMatch: 'Correspondance exacte des couleurs',
+    slotForFilament: 'Emplacement imprimante pour {{name}}',
+    overrideForFilament: 'Remplacement de filament pour {{name}}',
     sameTypeDifferentColorDetail: 'Même type, couleur différente : requiert {{required}}, l’emplacement contient {{loaded}}',
     requiredTypeNotInPrinter: 'Type de filament requis introuvable dans l’imprimante.',
     whenToPrint: 'Quand imprimer',

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

@@ -5022,6 +5022,9 @@ export default {
     manuallySelected: 'Selezionato manualmente',
     autoMatched: 'Abbinato automaticamente',
     selectSlot: 'Scegli slot',
+    exactColorMatch: 'Corrispondenza esatta del colore',
+    slotForFilament: 'Slot stampante per {{name}}',
+    overrideForFilament: 'Sostituzione filamento per {{name}}',
     sameTypeDifferentColorDetail: 'Stesso tipo, colore diverso: richiede {{required}}, lo slot ha {{loaded}}',
     requiredTypeNotInPrinter: 'Tipo di filamento richiesto non trovato nella stampante.',
     whenToPrint: 'Quando stampare',

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

@@ -5034,6 +5034,9 @@ export default {
     manuallySelected: '手動で選択',
     autoMatched: '自動で一致',
     selectSlot: 'スロットを選択',
+    exactColorMatch: '色が完全に一致',
+    slotForFilament: '{{name}} のプリンタースロット',
+    overrideForFilament: '{{name}} のフィラメントオーバーライド',
     sameTypeDifferentColorDetail: '同じ種類、異なる色: 必要な色は {{required}}、スロットは {{loaded}}',
     requiredTypeNotInPrinter: '必要なフィラメントタイプがプリンターに見つかりません。',
     whenToPrint: '印刷タイミング',

+ 3 - 0
frontend/src/i18n/locales/ko.ts

@@ -4804,6 +4804,9 @@ export default {
     manuallySelected: '수동으로 선택됨',
     autoMatched: '자동으로 일치됨',
     selectSlot: '슬롯 선택',
+    exactColorMatch: '색상 정확히 일치',
+    slotForFilament: '{{name}}의 프린터 슬롯',
+    overrideForFilament: '{{name}}의 필라멘트 재정의',
     sameTypeDifferentColorDetail: '같은 유형, 다른 색상: {{required}} 필요, 슬롯에는 {{loaded}}',
     requiredTypeNotInPrinter: '필요한 필라멘트 유형을 프린터에서 찾을 수 없습니다.',
     whenToPrint: '인쇄 시점',

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

@@ -5079,6 +5079,9 @@ export default {
     manuallySelected: 'Handmatig geselecteerd',
     autoMatched: 'Automatisch gekoppeld',
     selectSlot: 'Sleuf selecteren',
+    exactColorMatch: 'Exacte kleurovereenkomst',
+    slotForFilament: 'Printersleuf voor {{name}}',
+    overrideForFilament: 'Filament overschrijven voor {{name}}',
     sameTypeDifferentColorDetail: 'Zelfde type, andere kleur: vereist {{required}}, sleuf bevat {{loaded}}',
     requiredTypeNotInPrinter: 'Vereist filamenttype niet gevonden in printer.',
     whenToPrint: 'Wanneer afdrukken',

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

@@ -5022,6 +5022,9 @@ export default {
     manuallySelected: 'Selecionado manualmente',
     autoMatched: 'Correspondido automaticamente',
     selectSlot: 'Escolher slot',
+    exactColorMatch: 'Correspondência exata de cor',
+    slotForFilament: 'Slot da impressora para {{name}}',
+    overrideForFilament: 'Substituição de filamento para {{name}}',
     sameTypeDifferentColorDetail: 'Mesmo tipo, cor diferente: precisa de {{required}}, o slot tem {{loaded}}',
     requiredTypeNotInPrinter: 'Tipo de filamento necessário não encontrado na impressora.',
     whenToPrint: 'Quando imprimir',

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

@@ -4793,6 +4793,9 @@ export default {
     manuallySelected: "Выбрано вручную",
     autoMatched: "Подобрано автоматически",
     selectSlot: "Выбрать слот",
+    exactColorMatch: "Точное совпадение цвета",
+    slotForFilament: "Слот принтера для {{name}}",
+    overrideForFilament: "Переопределение филамента для {{name}}",
     sameTypeDifferentColorDetail: "Тот же тип, другой цвет: нужен {{required}}, в слоте {{loaded}}",
     requiredTypeNotInPrinter: "Требуемый тип филамента не найден в принтере.",
     whenToPrint: "Когда печатать",

+ 3 - 0
frontend/src/i18n/locales/sv.ts

@@ -5079,6 +5079,9 @@ errors: {
     manuallySelected: 'Manuellt vald',
     autoMatched: 'Auto-matchad',
     selectSlot: 'Välj fack',
+    exactColorMatch: 'Exakt färgmatchning',
+    slotForFilament: 'Skrivarfack för {{name}}',
+    overrideForFilament: 'Åsidosätt filament för {{name}}',
     sameTypeDifferentColorDetail: 'Samma typ, annan färg: behöver {{required}}, facket har {{loaded}}',
     requiredTypeNotInPrinter: 'Krävd filamenttyp hittades inte i skrivaren.',
     whenToPrint: 'När utskriften ska starta',

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

@@ -5011,6 +5011,9 @@ export default {
     manuallySelected: 'Elle seçildi',
     autoMatched: 'Otomatik eşleşti',
     selectSlot: 'Yuva seç',
+    exactColorMatch: 'Tam renk eşleşmesi',
+    slotForFilament: '{{name}} için yazıcı yuvası',
+    overrideForFilament: '{{name}} için filament geçersiz kılma',
     sameTypeDifferentColorDetail: 'Aynı tür, farklı renk: {{required}} gerekiyor, yuvada {{loaded}} var',
     requiredTypeNotInPrinter: 'Gereken filament türü yazıcıda bulunamadı.',
     whenToPrint: 'Ne zaman yazdırılsın',

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

@@ -5076,6 +5076,9 @@ export default {
     manuallySelected: "Вибрано вручну",
     autoMatched: "Підібрано автоматично",
     selectSlot: "Вибрати слот",
+    exactColorMatch: "Точний збіг кольору",
+    slotForFilament: "Слот принтера для {{name}}",
+    overrideForFilament: "Перевизначення філаменту для {{name}}",
     sameTypeDifferentColorDetail: "Того самого типу, іншого кольору: потрібен {{required}}, у слоті {{loaded}}",
     requiredTypeNotInPrinter: "Потрібний тип філаменту не знайдено у принтері.",
     whenToPrint: "Коли друкувати",

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

@@ -5022,6 +5022,9 @@ export default {
     manuallySelected: '手动选择',
     autoMatched: '自动匹配',
     selectSlot: '选择槽位',
+    exactColorMatch: '颜色完全匹配',
+    slotForFilament: '{{name}} 的打印机槽位',
+    overrideForFilament: '{{name}} 的耗材覆盖',
     sameTypeDifferentColorDetail: '相同类型,不同颜色:需要 {{required}},槽位为 {{loaded}}',
     requiredTypeNotInPrinter: '打印机中未找到所需的耗材类型。',
     whenToPrint: '何时打印',

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

@@ -5022,6 +5022,9 @@ export default {
     manuallySelected: '手動選擇',
     autoMatched: '自動比對',
     selectSlot: '選擇插槽',
+    exactColorMatch: '顏色完全相符',
+    slotForFilament: '{{name}} 的印表機插槽',
+    overrideForFilament: '{{name}} 的耗材覆蓋',
     sameTypeDifferentColorDetail: '相同類型,不同顏色:需要 {{required}},插槽為 {{loaded}}',
     requiredTypeNotInPrinter: '印表機中找不到所需的耗材類型。',
     whenToPrint: '何時列印',

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-CCYi0XHi.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-CN5d-0KI.js"></script>
+    <script type="module" crossorigin src="/assets/index-CCYi0XHi.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-ChscM3lF.css">
   </head>
   <body>

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