Browse Source

feat(labels): scannable QR on 203 dpi thermal printers + monochrome mode (#1870)

The 40x30 mm box label rendered its QR too densely for low-res thermal
printers — the modules bled together and wouldn't scan. Two causes: the QR
was 20% of inner width (~7.5 mm on the narrowest template, half of the
others) and used ERROR_CORRECT_M. Fix adaptively so all templates benefit:
give the roomy-layout QR a 12 mm minimum size (box_40x30 -> 12 mm, ~3.5
dots/module at 203 dpi) and switch label QRs to ERROR_CORRECT_L (same
payload, chunkier modules; a label needs no M-level recovery). Keep the
quiet-zone border at 2 — the size+L gains suffice without risking scans.

Also add a Monochrome (black & white printer) option to the label dialog:
drops the colour swatch (a useless grey block on B&W) and widens the text;
the hex-code line still carries the colour. Threaded through the renderer,
route, API client, and modal, with translations in all 11 locales.
maziggy 2 months ago
parent
commit
917bfd7666

+ 3 - 0
CHANGELOG.md

@@ -4,6 +4,9 @@ All notable changes to Bambuddy will be documented in this file.
 
 ## [0.2.5b2] - Unreleased
 
+### Added
+- **Spool labels: low-resolution / black-and-white thermal-printer support (#1870, reporter @fireboyff, monochrome requested by @Geoff-S)** — The QR code on the small 40 × 30 mm box label rendered too densely to print reliably on 203 dpi thermal printers — its lines bled together and it wouldn't scan. Two causes: the QR was sized at 20% of the label's inner width, which on the narrowest template came out at just ~7.5 mm (half of every other template), and it used `ERROR_CORRECT_M`. Fixes, applied adaptively so every template benefits with no configuration: the roomy layout now gives the QR a 12 mm minimum size (box_40x30 goes 7.5 → 12 mm, ~1.8 → ~3.5 dots per module at 203 dpi) while still capping it by height/width, and label QRs now use `ERROR_CORRECT_L` (same payload, fewer/chunkier modules — a label doesn't need M-level damage recovery). Also added a **Monochrome (black & white printer)** option in the label print dialog: it drops the colour swatch (a useless grey block on a B&W printer) and widens the text column, with the colour still conveyed by the hex-code line.
+
 ### Fixed
 - **Queued prints never dispatched to FINISH-state printers when "Require plate-clear confirmation" was disabled (#1865, reporter @PurseChicken)** — On installs that had never explicitly saved the plate-clear setting, the scheduler enforced the plate-clear gate even though the Settings toggle (and the printer card's plate badge/button) correctly showed it as OFF, so a finished printer sat at `awaiting_plate_clear=True` forever with no UI path to clear it and the queue item stuck in "Waiting". **Root cause (pinpointed by the reporter):** two code paths disagreed on the default. `SettingsSchema.require_plate_clear` defaults `False` and the entire frontend treats a missing value as off, but `print_scheduler.check_queue()` read the setting with `_get_bool_setting(..., default=True)` — and `_get_bool_setting` returns its default when no DB row exists. With no settings row, the UI showed the gate off while the scheduler kept it on. Both defaults shipped in the same commit (#752), so this was a latent inconsistency, not a #1625 regression. **Fix:** the scheduler now reads `require_plate_clear` with `default=False`, matching the schema and the UI. Added regression tests pinning the call-site default and the `_get_bool_setting` no-row behavior.
 - **Light theme: low-contrast washed-out text on status/warning banners and colored badges (#1909, reporter @AntonPalmqvist)** — The app was built dark-first: hundreds of hardcoded Tailwind semantic-color text utilities at light shades (`text-amber-400`, `text-blue-300`, `text-green-200`, etc.) had no `dark:` variant, so with `darkMode: 'class'` they applied in the light theme too — light-amber/blue/green text on pale `bg-*-500/10` tints or white cards, barely readable. The three reporter-cited spots were all this bug: the AMS **Drying** status banner (Printers page), the **Archives** no-3MF-thumbnail warning, and the **System/debug-logging** banner. Fixed app-wide by giving every affected semantic text/icon/tint a theme-aware pair — a readable darker shade in light theme (e.g. `text-amber-700`/`-800`/`-900`) with the original light shade pinned to `dark:` so the dark theme is unchanged — across ~100 files (banners, status pills, permission/role badges, method chips, validation errors, stat-card icons, delete-action hovers, and more). The custom CSS-variable `bambu-*` palette (which already self-corrects per theme) and the SpoolBuddy kiosk (dark-theme-only) were deliberately left untouched.

+ 5 - 2
backend/app/api/routes/labels.py

@@ -60,6 +60,9 @@ class LabelRequest(BaseModel):
         "avery_5160",
         "avery_l7160",
     ]
+    # Black-and-white thermal printers: drop the colour swatch (prints as a
+    # muddy grey block) and widen the text column instead (#1870).
+    monochrome: bool = False
 
 
 def _split_extra_colors(raw: str | None) -> list[str] | None:
@@ -170,7 +173,7 @@ async def render_local_inventory_labels(
     deeplink_base = await _resolve_deeplink_base(request, db)
     data_list = [_spool_to_label_data(s, deeplink_base) for s in ordered]
 
-    pdf = render_labels(body.template, data_list)
+    pdf = render_labels(body.template, data_list, monochrome=body.monochrome)
     filename = f"bambuddy-labels-{body.template}.pdf"
     return _stream_pdf(pdf, filename)
 
@@ -214,6 +217,6 @@ async def render_spoolman_labels(
     deeplink_base = await _resolve_deeplink_base(request, db)
     data_list = [_spoolman_dict_to_label_data(by_id[sid], deeplink_base) for sid in body.spool_ids]
 
-    pdf = render_labels(body.template, data_list)
+    pdf = render_labels(body.template, data_list, monochrome=body.monochrome)
     filename = f"bambuddy-labels-spoolman-{body.template}.pdf"
     return _stream_pdf(pdf, filename)

+ 58 - 22
backend/app/services/label_renderer.py

@@ -130,7 +130,13 @@ def _qr_png_bytes(payload: str, *, box_size: int = 4, border: int = 2) -> bytes:
         return b""
     qr = qrcode.QRCode(
         version=None,
-        error_correction=qrcode.constants.ERROR_CORRECT_M,
+        # ERROR_CORRECT_L (7% recovery) rather than M (15%): a label QR only
+        # needs to survive being scanned off clean stock, not physical damage,
+        # and L encodes the same payload in a lower version (fewer, chunkier
+        # modules). That extra module size is what makes the code printable on
+        # low-resolution 203 dpi thermal printers, where M-level density bled
+        # the modules together on small labels (#1870).
+        error_correction=qrcode.constants.ERROR_CORRECT_L,
         box_size=box_size,
         border=border,
     )
@@ -168,6 +174,19 @@ def _draw_swatch(c: rl_canvas.Canvas, x: float, y: float, w: float, h: float, da
     c.rect(x, y, w, h, stroke=1, fill=0)
 
 
+def _roomy_qr_size(inner_w: float, inner_h: float) -> float:
+    """QR edge length (points) for the roomy layout.
+
+    Historically a flat 20% of inner width, which on the narrowest label
+    (box_40x30, ~37.6 mm inner) rendered a ~7.5 mm QR — at 203 dpi each module
+    fell below ~2 dots and the code bled into itself on thermal printers
+    (#1870). A 12 mm floor keeps small labels scannable; the code is still
+    capped by the inner height, an 18 mm absolute max, and ~45% of inner width
+    so it can't crowd out the text column on an ultra-narrow label.
+    """
+    return min(max(inner_w * 0.20, 12 * mm), inner_h, 18 * mm, inner_w * 0.45)
+
+
 def _draw_qr(c: rl_canvas.Canvas, x: float, y: float, size: float, payload: str) -> None:
     """Embed a square QR at (x, y) with edge length ``size`` (in points)."""
     png = _qr_png_bytes(payload)
@@ -189,7 +208,9 @@ def _truncate_to_width(c: rl_canvas.Canvas, text: str, font: str, size: float, m
     return text + ell if text else ell
 
 
-def _draw_label(c: rl_canvas.Canvas, x: float, y: float, w: float, h: float, data: LabelData) -> None:
+def _draw_label(
+    c: rl_canvas.Canvas, x: float, y: float, w: float, h: float, data: LabelData, monochrome: bool = False
+) -> None:
     """Render one label inside the box (x, y, w, h). Origin is bottom-left.
 
     Two layouts, picked by available height:
@@ -219,9 +240,9 @@ def _draw_label(c: rl_canvas.Canvas, x: float, y: float, w: float, h: float, dat
     is_tight = h < 20 * mm
 
     if is_tight:
-        _draw_label_tight(c, x, y, w, h, inner_x, inner_y, inner_w, inner_h, pad, data)
+        _draw_label_tight(c, x, y, w, h, inner_x, inner_y, inner_w, inner_h, pad, data, monochrome)
     else:
-        _draw_label_roomy(c, x, y, w, h, inner_x, inner_y, inner_w, inner_h, pad, data)
+        _draw_label_roomy(c, x, y, w, h, inner_x, inner_y, inner_w, inner_h, pad, data, monochrome)
 
 
 def _draw_label_tight(
@@ -236,11 +257,17 @@ def _draw_label_tight(
     inner_h: float,
     pad: float,
     data: LabelData,
+    monochrome: bool = False,
 ) -> None:
     """Tight layout (h < 20 mm). Swatch + brand/material/hex/ID, no QR."""
-    swatch_w = min(inner_h, inner_w * 0.35)
-    swatch_y = inner_y + (inner_h - swatch_w) / 2
-    _draw_swatch(c, inner_x, swatch_y, swatch_w, swatch_w, data)
+    # Monochrome: drop the colour swatch (see _draw_label_roomy) and give the
+    # width to the text column (#1870).
+    if monochrome:
+        swatch_w = 0.0
+    else:
+        swatch_w = min(inner_h, inner_w * 0.35)
+        swatch_y = inner_y + (inner_h - swatch_w) / 2
+        _draw_swatch(c, inner_x, swatch_y, swatch_w, swatch_w, data)
 
     text_x = inner_x + swatch_w + pad
     text_w = inner_w - swatch_w - pad
@@ -296,17 +323,22 @@ def _draw_label_roomy(
     inner_h: float,
     pad: float,
     data: LabelData,
+    monochrome: bool = False,
 ) -> None:
     """Box-label / Avery layout. Swatch left, QR right, text middle."""
     # Swatch: full inner height, ~18% of inner width but capped so we never
-    # eat the text column on extreme aspect ratios.
-    swatch_w = min(inner_w * 0.18, inner_h, 16 * mm)
-    swatch_h = inner_h
-    _draw_swatch(c, inner_x, inner_y, swatch_w, swatch_h, data)
-
-    # QR: square, capped at the smaller of (a fraction of width, the inner
-    # height, or 18 mm — beyond that the QR is overkill for the print size).
-    qr_size = min(inner_w * 0.20, inner_h, 18 * mm)
+    # eat the text column on extreme aspect ratios. Omitted entirely in
+    # monochrome mode — on a B&W thermal printer a colour block prints as a
+    # muddy grey that conveys nothing, so we reclaim the space for text and
+    # rely on the hex-code line to carry the colour (#1870, requested by
+    # @Geoff-S). The hex code already renders below whenever rgba is set.
+    if monochrome:
+        swatch_w = 0.0
+    else:
+        swatch_w = min(inner_w * 0.18, inner_h, 16 * mm)
+        _draw_swatch(c, inner_x, inner_y, swatch_w, inner_h, data)
+
+    qr_size = _roomy_qr_size(inner_w, inner_h)
     qr_x = x + w - pad - qr_size
     qr_y = inner_y + (inner_h - qr_size) / 2
     _draw_qr(c, qr_x, qr_y, qr_size, data.deeplink_url)
@@ -394,7 +426,7 @@ _SHEET_TEMPLATES: dict[str, tuple] = {
 }
 
 
-def _render_single_label_pdf(template: TemplateName, data_list: list[LabelData]) -> bytes:
+def _render_single_label_pdf(template: TemplateName, data_list: list[LabelData], monochrome: bool = False) -> bytes:
     w_mm, h_mm = _SINGLE_LABEL_SIZES_MM[template]
     page_w, page_h = w_mm * mm, h_mm * mm
 
@@ -403,14 +435,14 @@ def _render_single_label_pdf(template: TemplateName, data_list: list[LabelData])
     c.setTitle(f"Bambuddy spool labels ({template})")
 
     for data in data_list:
-        _draw_label(c, 0, 0, page_w, page_h, data)
+        _draw_label(c, 0, 0, page_w, page_h, data, monochrome)
         c.showPage()
 
     c.save()
     return buf.getvalue()
 
 
-def _render_sheet_pdf(template: TemplateName, data_list: list[LabelData]) -> bytes:
+def _render_sheet_pdf(template: TemplateName, data_list: list[LabelData], monochrome: bool = False) -> bytes:
     page_size, w_mm, h_mm, cols, rows, top_mm, left_mm, col_gap_mm, row_gap_mm = _SHEET_TEMPLATES[template]
     page_w, page_h = page_size
 
@@ -433,23 +465,27 @@ def _render_sheet_pdf(template: TemplateName, data_list: list[LabelData]) -> byt
             col = idx % cols
             x = left_margin + col * (label_w + col_gap)
             y = page_h - top_margin - (row + 1) * label_h - row * row_gap
-            _draw_label(c, x, y, label_w, label_h, data)
+            _draw_label(c, x, y, label_w, label_h, data, monochrome)
         c.showPage()
 
     c.save()
     return buf.getvalue()
 
 
-def render_labels(template: TemplateName, data_list: list[LabelData]) -> bytes:
+def render_labels(template: TemplateName, data_list: list[LabelData], *, monochrome: bool = False) -> bytes:
     """Render ``data_list`` to a PDF using the named template. Returns bytes.
 
     Empty ``data_list`` still produces a valid (empty) PDF — callers should
     short-circuit beforehand if that's not desired.
+
+    ``monochrome`` drops the colour swatch (which prints as a useless grey block
+    on black-and-white thermal printers) and reclaims the space for text; the
+    hex-code line still carries the colour. See #1870.
     """
     if template in _SINGLE_LABEL_SIZES_MM:
-        return _render_single_label_pdf(template, data_list)
+        return _render_single_label_pdf(template, data_list, monochrome)
     if template in _SHEET_TEMPLATES:
-        return _render_sheet_pdf(template, data_list)
+        return _render_sheet_pdf(template, data_list, monochrome)
     raise ValueError(f"Unknown label template: {template!r}")
 
 

+ 2 - 2
backend/tests/integration/test_labels.py

@@ -129,9 +129,9 @@ class TestLocalInventoryLabels:
 
         original = labels_module.render_labels
 
-        def _capture(template, data_list):
+        def _capture(template, data_list, **kwargs):
             captured["ids"] = [d.spool_id for d in data_list]
-            return original(template, data_list)
+            return original(template, data_list, **kwargs)
 
         with patch.object(labels_module, "render_labels", side_effect=_capture):
             resp = await async_client.post(

+ 70 - 2
backend/tests/unit/services/test_label_renderer.py

@@ -112,7 +112,7 @@ def test_qr_payload_is_present_in_pdf_stream():
 # ── Regression tests for the two render bugs found in the first cut ──
 
 
-def _render_uncompressed(template, data):
+def _render_uncompressed(template, data, monochrome=False):
     """Render with pageCompression=0 so the resulting PDF contains text as
     ASCII bytes. Lets tests assert "X is on the label" by grepping the PDF.
 
@@ -140,7 +140,7 @@ def _render_uncompressed(template, data):
         buf = _io.BytesIO()
         c = _rl_canvas.Canvas(buf, pagesize=(page_w, page_h), pageCompression=0)
         for d in data:
-            _draw_label(c, 0, 0, page_w, page_h, d)
+            _draw_label(c, 0, 0, page_w, page_h, d, monochrome)
             c.showPage()
         c.save()
         return buf.getvalue()
@@ -303,3 +303,71 @@ def test_box_template_does_not_truncate_normal_brand_or_name():
     assert b"Shelf 3, slot B" in pdf, "box template must render the storage location"
     # Big spool ID at bottom.
     assert b"#7" in pdf or (b"7" in pdf and b"#" in pdf), "box template must render the spool ID"
+
+
+# ── #1870: low-res thermal-printer optimisations ──
+
+
+@pytest.mark.parametrize("template", ALL_TEMPLATES)
+def test_monochrome_renders_valid_pdf_for_each_template(template):
+    """Monochrome mode must render a valid PDF for every template (#1870)."""
+    pdf = render_labels(template, [_sample(7), _sample(8)], monochrome=True)
+    assert pdf.startswith(b"%PDF"), f"{template} monochrome did not produce a PDF header"
+
+
+def test_monochrome_omits_colour_swatch():
+    """Monochrome drops the colour swatch (useless grey block on a B&W printer)
+    while the default keeps it (#1870, requested by @Geoff-S)."""
+    from unittest.mock import patch
+
+    import backend.app.services.label_renderer as lr
+
+    with patch.object(lr, "_draw_swatch") as mock_swatch:
+        render_labels("box_40x30", [_sample(1)], monochrome=True)
+        assert mock_swatch.call_count == 0, "monochrome must not draw the colour swatch"
+
+    with patch.object(lr, "_draw_swatch") as mock_swatch:
+        render_labels("box_40x30", [_sample(1)], monochrome=False)
+        assert mock_swatch.call_count == 1, "colour mode must draw the swatch"
+
+
+def test_monochrome_still_renders_text_and_hex():
+    """Dropping the swatch must not lose the colour info — the hex code line and
+    the text fields still render (the hex is how colour is conveyed in B&W)."""
+    data = [
+        LabelData(
+            spool_id=42,
+            name="Polymaker Ivory",
+            material="PLA",
+            brand="Polymaker",
+            subtype="Matte",
+            rgba="F5E6D3FF",
+            deeplink_url="https://example.test/inventory?spool=42",
+        )
+    ]
+    pdf = _render_uncompressed("box_40x30", data, monochrome=True)
+    assert b"Polymaker" in pdf, "monochrome label must still render the brand"
+    assert b"#F5E6D3" in pdf, "monochrome label must still render the hex colour code"
+    assert b"#42" in pdf or (b"42" in pdf and b"#" in pdf), "monochrome label must render the spool ID"
+
+
+def test_roomy_qr_size_has_floor_for_narrow_labels():
+    """#1870 regression: box_40x30's QR must not shrink below a scannable size.
+    The pre-fix ``inner_w * 0.20`` gave ~7.5 mm on that label; the floor keeps
+    it at 12 mm so each module clears ~3 dots on a 203 dpi thermal head.
+    """
+    from reportlab.lib.units import mm
+
+    from backend.app.services.label_renderer import _roomy_qr_size
+
+    pad = 1.2 * mm
+    # box_40x30 inner dimensions.
+    inner_w = 40 * mm - 2 * pad
+    inner_h = 30 * mm - 2 * pad
+    assert _roomy_qr_size(inner_w, inner_h) >= 12 * mm - 0.01
+
+    # Larger templates are unaffected (already above the floor) and still capped.
+    inner_w_big = 75 * mm - 2 * pad
+    inner_h_big = 55 * mm - 2 * pad
+    size_big = _roomy_qr_size(inner_w_big, inner_h_big)
+    assert 12 * mm <= size_big <= 18 * mm

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

@@ -207,6 +207,7 @@ describe('LabelTemplatePickerModal', () => {
       expect(api.printSpoolLabels).toHaveBeenCalledWith({
         spool_ids: [1, 3],
         template: 'box_62x29',
+        monochrome: false,
       });
     });
     await waitFor(() => expect(onClose).toHaveBeenCalled());
@@ -232,6 +233,7 @@ describe('LabelTemplatePickerModal', () => {
       expect(api.printSpoolmanSpoolLabels).toHaveBeenCalledWith({
         spool_ids: [1],
         template: 'ams_holder_75x55',
+        monochrome: false,
       });
     });
     expect(api.printSpoolLabels).not.toHaveBeenCalled();
@@ -361,6 +363,7 @@ describe('LabelTemplatePickerModal', () => {
       expect(api.printSpoolLabels).toHaveBeenCalledWith({
         spool_ids: [1, 4, 2, 3],
         template: 'box_62x29',
+        monochrome: false,
       });
     });
   });
@@ -385,6 +388,31 @@ describe('LabelTemplatePickerModal', () => {
       expect(api.printSpoolLabels).toHaveBeenCalledWith({
         spool_ids: [1, 2, 3, 4],
         template: 'box_40x30',
+        monochrome: false,
+      });
+    });
+  });
+
+  it('sends monochrome:true when the black & white checkbox is ticked (#1870)', async () => {
+    vi.mocked(api.printSpoolLabels).mockResolvedValue(PDF_BLOB);
+    render(
+      <LabelTemplatePickerModal
+        isOpen={true}
+        onClose={vi.fn()}
+        availableSpools={SPOOLS}
+        initialSelectedIds={[1]}
+        spoolmanMode={false}
+      />,
+    );
+
+    fireEvent.click(screen.getByText(/black & white printer/i));
+    fireEvent.click(screen.getByText(/Box label \(40 × 30 mm\)/i));
+
+    await waitFor(() => {
+      expect(api.printSpoolLabels).toHaveBeenCalledWith({
+        spool_ids: [1],
+        template: 'box_40x30',
+        monochrome: true,
       });
     });
   });

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

@@ -5351,7 +5351,7 @@ export const api = {
   // ── Spool label printing (#809) ──────────────────────────────────────────
   // Both endpoints return application/pdf. Frontend opens the resulting Blob
   // in a new tab so the user can print or save from the browser's PDF viewer.
-  printSpoolLabels: async (data: { spool_ids: number[]; template: SpoolLabelTemplate }): Promise<Blob> => {
+  printSpoolLabels: async (data: { spool_ids: number[]; template: SpoolLabelTemplate; monochrome?: boolean }): Promise<Blob> => {
     const headers: Record<string, string> = { 'Content-Type': 'application/json' };
     if (authToken) headers['Authorization'] = `Bearer ${authToken}`;
     const response = await fetch(`${API_BASE}/inventory/labels`, {
@@ -5365,7 +5365,7 @@ export const api = {
     }
     return response.blob();
   },
-  printSpoolmanSpoolLabels: async (data: { spool_ids: number[]; template: SpoolLabelTemplate }): Promise<Blob> => {
+  printSpoolmanSpoolLabels: async (data: { spool_ids: number[]; template: SpoolLabelTemplate; monochrome?: boolean }): Promise<Blob> => {
     const headers: Record<string, string> = { 'Content-Type': 'application/json' };
     if (authToken) headers['Authorization'] = `Bearer ${authToken}`;
     const response = await fetch(`${API_BASE}/spoolman/labels`, {

+ 28 - 3
frontend/src/components/LabelTemplatePickerModal.tsx

@@ -174,6 +174,7 @@ export function LabelTemplatePickerModal({
   const [search, setSearch] = useState('');
   const [materialFilter, setMaterialFilter] = useState<string>('');
   const [sortMode, setSortMode] = useState<SortMode>('id');
+  const [monochrome, setMonochrome] = useState(false);
 
   // Sync from caller and reset transient state on open. Intentionally not
   // reactive to props while open — once the user starts editing we don't want
@@ -185,6 +186,7 @@ export function LabelTemplatePickerModal({
       setSearch('');
       setMaterialFilter('');
       setSortMode('id');
+      setMonochrome(false);
       setPending(null);
     }
     // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -273,8 +275,8 @@ export function LabelTemplatePickerModal({
     setPending(template);
     try {
       const blob = spoolmanMode
-        ? await api.printSpoolmanSpoolLabels({ spool_ids: ids, template })
-        : await api.printSpoolLabels({ spool_ids: ids, template });
+        ? await api.printSpoolmanSpoolLabels({ spool_ids: ids, template, monochrome })
+        : await api.printSpoolLabels({ spool_ids: ids, template, monochrome });
       openBlobInNewTab(blob);
       onClose();
     } catch (err) {
@@ -464,10 +466,33 @@ export function LabelTemplatePickerModal({
           )}
         </div>
 
+        {/* Print options */}
+        <div className="px-4 pt-2 pb-1 border-t border-bambu-dark-tertiary">
+          <label className="inline-flex items-center gap-2 cursor-pointer select-none">
+            {monochrome ? (
+              <CheckSquare className="w-4 h-4 text-bambu-green shrink-0" />
+            ) : (
+              <Square className="w-4 h-4 text-bambu-gray shrink-0" />
+            )}
+            <input
+              type="checkbox"
+              checked={monochrome}
+              onChange={(e) => setMonochrome(e.target.checked)}
+              className="sr-only"
+            />
+            <span className="text-sm text-white">
+              {t('inventory.labels.monochrome', 'Monochrome (black & white printer)')}
+            </span>
+            <span className="text-xs text-bambu-gray">
+              {t('inventory.labels.monochromeHint', 'Drops the colour swatch and widens the text')}
+            </span>
+          </label>
+        </div>
+
         {/* Templates — 2x2 grid on >= sm so all 4 plus the Cancel footer fit
             inside max-h-[90vh] even when browser chrome eats into the viewport
             (#1230). Stacked single column on mobile widths. */}
-        <div className="px-3 pt-2 pb-2 grid grid-cols-1 sm:grid-cols-2 gap-2 border-t border-bambu-dark-tertiary">
+        <div className="px-3 pt-1 pb-2 grid grid-cols-1 sm:grid-cols-2 gap-2">
           {TEMPLATE_OPTIONS.map((opt) => {
             const isPending = pending === opt.value;
             const label = t(`inventory.labels.templates.${opt.i18nKey}.label`, opt.fallbackLabel);

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

@@ -4191,6 +4191,8 @@ export default {
       title: 'Spulen-Etiketten drucken',
       selectedCount: '{{count}} ausgewählt',
       pickSpools: 'Wählen Sie, für welche Spulen Etiketten gedruckt werden sollen:',
+      monochrome: 'Monochrom (Schwarz-Weiß-Drucker)',
+      monochromeHint: 'Entfernt das Farbfeld und verbreitert den Text',
       searchPlaceholder: 'Name, Marke oder #ID suchen',
       filterByMaterial: 'Material:',
       allMaterials: 'Alle',

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

@@ -4225,6 +4225,8 @@ export default {
       title: 'Print spool labels',
       selectedCount: '{{count}} selected',
       pickSpools: 'Pick which spools to print labels for:',
+      monochrome: 'Monochrome (black & white printer)',
+      monochromeHint: 'Drops the colour swatch and widens the text',
       searchPlaceholder: 'Search name, brand, or #ID',
       filterByMaterial: 'Material:',
       allMaterials: 'All',

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

@@ -4194,6 +4194,8 @@ export default {
       title: 'Imprimir etiquetas de bobinas',
       selectedCount: '{{count}} seleccionadas',
       pickSpools: 'Elija para qué bobinas imprimir etiquetas:',
+      monochrome: 'Monocromo (impresora en blanco y negro)',
+      monochromeHint: 'Elimina la muestra de color y amplía el texto',
       searchPlaceholder: 'Buscar nombre, marca o n.º de ID',
       filterByMaterial: 'Material:',
       allMaterials: 'Todos',

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

@@ -4180,6 +4180,8 @@ export default {
       title: 'Imprimer étiquettes de bobines',
       selectedCount: '{{count}} sélectionné',
       pickSpools: 'Choisissez les bobines pour lesquelles imprimer des étiquettes :',
+      monochrome: 'Monochrome (imprimante noir et blanc)',
+      monochromeHint: 'Supprime la pastille de couleur et élargit le texte',
       searchPlaceholder: 'Rechercher par nom, marque ou #ID',
       filterByMaterial: 'Matériau :',
       allMaterials: 'Tout',

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

@@ -4179,6 +4179,8 @@ export default {
       title: 'Stampa etichette bobine',
       selectedCount: '{{count}} selezionato',
       pickSpools: 'Scegli per quali bobine stampare le etichette:',
+      monochrome: 'Monocromatico (stampante in bianco e nero)',
+      monochromeHint: 'Rimuove il campione di colore e allarga il testo',
       searchPlaceholder: 'Cerca per nome, marca o #ID',
       filterByMaterial: 'Materiale:',
       allMaterials: 'Tutto',

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

@@ -4191,6 +4191,8 @@ export default {
       title: 'スプールラベルを印刷',
       selectedCount: '{{count}}件選択中',
       pickSpools: 'ラベルを印刷するスプールを選択:',
+      monochrome: 'モノクロ(白黒プリンター)',
+      monochromeHint: 'カラースウォッチを削除してテキストを広げます',
       searchPlaceholder: '名前、ブランド、#IDで検索',
       filterByMaterial: '素材:',
       allMaterials: 'すべて',

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

@@ -3980,6 +3980,8 @@ export default {
       title: '스풀 라벨 인쇄',
       selectedCount: '{{count}}개 선택됨',
       pickSpools: '라벨을 인쇄할 스풀 선택:',
+      monochrome: '흑백 (흑백 프린터)',
+      monochromeHint: '색상 견본을 제거하고 텍스트를 넓힙니다',
       searchPlaceholder: '이름, 브랜드 또는 #ID 검색',
       filterByMaterial: '재료:',
       allMaterials: '전체',

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

@@ -4179,6 +4179,8 @@ export default {
       title: 'Imprimir etiquetas de bobinas',
       selectedCount: '{{count}} selecionado',
       pickSpools: 'Escolha para quais bobinas imprimir etiquetas:',
+      monochrome: 'Monocromático (impressora preto e branco)',
+      monochromeHint: 'Remove a amostra de cor e amplia o texto',
       searchPlaceholder: 'Buscar por nome, marca ou #ID',
       filterByMaterial: 'Material:',
       allMaterials: 'Tudo',

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

@@ -4181,6 +4181,8 @@ export default {
       title: 'Makara etiketleri yazdır',
       selectedCount: '{{count}} seçildi',
       pickSpools: 'Hangi makaralar için etiket yazdırılacağını seçin:',
+      monochrome: 'Tek renk (siyah beyaz yazıcı)',
+      monochromeHint: 'Renk örneğini kaldırır ve metni genişletir',
       searchPlaceholder: 'Ad, marka veya #ID ara',
       filterByMaterial: 'Malzeme:',
       allMaterials: 'Tümü',

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

@@ -4179,6 +4179,8 @@ export default {
       title: '打印线材标签',
       selectedCount: '已选 {{count}} 项',
       pickSpools: '选择要打印标签的线材:',
+      monochrome: '单色(黑白打印机)',
+      monochromeHint: '移除颜色色块并加宽文本',
       searchPlaceholder: '按名称、品牌或 #ID 搜索',
       filterByMaterial: '材料:',
       allMaterials: '全部',

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

@@ -4179,6 +4179,8 @@ export default {
       title: '列印線材標籤',
       selectedCount: '已選 {{count}} 項',
       pickSpools: '選擇要列印標籤的線材:',
+      monochrome: '單色(黑白印表機)',
+      monochromeHint: '移除顏色色塊並加寬文字',
       searchPlaceholder: '按名稱、品牌或 #ID 搜尋',
       filterByMaterial: '材料:',
       allMaterials: '全部',

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

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