Browse Source

fix(labels): replace incorrect ams_30x15 preset with correct AMS holder sizes (#1426)

  Reporter — the same person who originally requested the labels
  feature in #809 — discovered that the ams_30x15 preset's 30x15 mm
  dimension didn't actually fit any variant of the MakerWorld AMS
  Filament Label Holder (model 752566) it advertised. Two new
  presets replace it:

  - ams_holder_74x33 (74 x 33 mm) matches the printable label STL
    bundled in the MakerWorld project
  - ams_holder_75x55 (75 x 55 mm) fits the cardstock-insert variant
    the reporter validated on bench

  Both cross the 20 mm height threshold so they land in the roomy
  layout branch — swatch on the left, QR on the right, multi-line
  text (brand, material, hex code, spool ID) in the middle. The
  old 30x15 mm preset couldn't fit a QR code; the new ones do.

  No DB migration: the preset name was never persisted. Callers
  scripting the old ams_30x15 value get a clean 422 at the route's
  Literal validator with the new valid values listed.

  i18n: replaced inventory.labels.templates.ams.{label,hint} with
  amsHolderSmall and amsHolderLarge across all 8 locales with real
  translations; parity guard cleaned of the stale English-fallback
  cognate entries. Parity holds at 4856 leaves per locale.

  Tests: backend label renderer + integration tests cover both new
  presets; LabelTemplatePickerModal test updated for the 6-button
  grid and the new template value in the API-call assertion.
maziggy 1 week ago
parent
commit
1677efb2c6

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


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

@@ -37,7 +37,8 @@ logger = logging.getLogger(__name__)
 router = APIRouter(tags=["labels"])
 router = APIRouter(tags=["labels"])
 
 
 _VALID_TEMPLATES: tuple[TemplateName, ...] = (
 _VALID_TEMPLATES: tuple[TemplateName, ...] = (
-    "ams_30x15",
+    "ams_holder_74x33",
+    "ams_holder_75x55",
     "box_40x30",
     "box_40x30",
     "box_62x29",
     "box_62x29",
     "avery_5160",
     "avery_5160",
@@ -51,7 +52,14 @@ MAX_LABELS_PER_REQUEST = 500
 
 
 class LabelRequest(BaseModel):
 class LabelRequest(BaseModel):
     spool_ids: list[int] = Field(..., min_length=1, max_length=MAX_LABELS_PER_REQUEST)
     spool_ids: list[int] = Field(..., min_length=1, max_length=MAX_LABELS_PER_REQUEST)
-    template: Literal["ams_30x15", "box_40x30", "box_62x29", "avery_5160", "avery_l7160"]
+    template: Literal[
+        "ams_holder_74x33",
+        "ams_holder_75x55",
+        "box_40x30",
+        "box_62x29",
+        "avery_5160",
+        "avery_l7160",
+    ]
 
 
 
 
 def _split_extra_colors(raw: str | None) -> list[str] | None:
 def _split_extra_colors(raw: str | None) -> list[str] | None:

+ 32 - 17
backend/app/services/label_renderer.py

@@ -1,9 +1,12 @@
 """PDF spool label rendering.
 """PDF spool label rendering.
 
 
-Five fixed templates:
+Six fixed templates:
 
 
-- ``ams_30x15``  — 30×15 mm single label, fits the popular Makerworld AMS
-  Filament Label Holder (model 752566). One label per page.
+- ``ams_holder_74x33`` — 74×33 mm single label, matches the printable label
+  STL bundled with the Makerworld AMS Filament Label Holder (model 752566).
+  Smaller variant — the visible window in the holder. One label per page.
+- ``ams_holder_75x55`` — 75×55 mm single label, fits the cardstock-insert
+  variant of the same holder. Roomier — swatch + QR + full text column.
 - ``box_40x30``  — 40×30 mm single label, common DK/Brother roll size and a
 - ``box_40x30``  — 40×30 mm single label, common DK/Brother roll size and a
   good fit for filament-bag/storage-bin labels (#809 follow-up). Roomy
   good fit for filament-bag/storage-bin labels (#809 follow-up). Roomy
   layout — swatch, QR, full text column with hex code.
   layout — swatch, QR, full text column with hex code.
@@ -12,6 +15,10 @@ Five fixed templates:
 - ``avery_5160`` — US Letter sheet, 25.4×66.7 mm × 30 per sheet.
 - ``avery_5160`` — US Letter sheet, 25.4×66.7 mm × 30 per sheet.
 - ``avery_l7160`` — A4 sheet, 38.1×63.5 mm × 21 per sheet.
 - ``avery_l7160`` — A4 sheet, 38.1×63.5 mm × 21 per sheet.
 
 
+The legacy ``ams_30x15`` preset (#809) was incorrect — the original 30×15 mm
+dimension didn't fit any documented variant of model 752566. Replaced by the
+two ``ams_holder_*`` presets above (#1426).
+
 The renderer is decoupled from the Spool model: callers build a ``LabelData``
 The renderer is decoupled from the Spool model: callers build a ``LabelData``
 list from whatever source (local DB, Spoolman, future) so the same code path
 list from whatever source (local DB, Spoolman, future) so the same code path
 works in both modes.
 works in both modes.
@@ -34,7 +41,14 @@ from reportlab.lib.pagesizes import A4, letter
 from reportlab.lib.units import mm
 from reportlab.lib.units import mm
 from reportlab.pdfgen import canvas as rl_canvas
 from reportlab.pdfgen import canvas as rl_canvas
 
 
-TemplateName = Literal["ams_30x15", "box_40x30", "box_62x29", "avery_5160", "avery_l7160"]
+TemplateName = Literal[
+    "ams_holder_74x33",
+    "ams_holder_75x55",
+    "box_40x30",
+    "box_62x29",
+    "avery_5160",
+    "avery_l7160",
+]
 
 
 
 
 @dataclass
 @dataclass
@@ -180,17 +194,17 @@ def _draw_label(c: rl_canvas.Canvas, x: float, y: float, w: float, h: float, dat
 
 
     Two layouts, picked by available height:
     Two layouts, picked by available height:
 
 
-    - **Tight** (h < 20 mm — AMS holder): swatch on the left, three lines of
-      text on the right (brand, material+subtype, big spool ID). No QR — at
-      30×15 mm there is not enough horizontal room for swatch + text + QR
-      without truncating away the user-need fields, and the AMS holder is an
-      at-a-glance identifier where the spool ID is the killer field. The
-      box-label and Avery templates carry the QR for the other use cases.
-
-    - **Roomy** (h >= 20 mm — box label, Avery sheets): swatch on the left,
-      QR on the right, multi-line text in the middle column. Large spool ID
-      anchored at bottom-left under the swatch so it stays readable when the
-      label is on a box on a shelf at arm's length.
+    - **Tight** (h < 20 mm): swatch on the left, three lines of text on the
+      right (brand, material+subtype, big spool ID). No QR — at very small
+      heights there is not enough horizontal room for swatch + text + QR
+      without truncating away the user-need fields. Kept as the safety
+      branch for any future ultra-small preset; the shipped templates all
+      land in the roomy layout below.
+
+    - **Roomy** (h >= 20 mm — AMS holder, box label, Avery sheets): swatch
+      on the left, QR on the right, multi-line text in the middle column.
+      Large spool ID anchored at bottom-left under the swatch so it stays
+      readable at arm's length.
     """
     """
     pad = 1.2 * mm
     pad = 1.2 * mm
     inner_x, inner_y = x + pad, y + pad
     inner_x, inner_y = x + pad, y + pad
@@ -223,7 +237,7 @@ def _draw_label_tight(
     pad: float,
     pad: float,
     data: LabelData,
     data: LabelData,
 ) -> None:
 ) -> None:
-    """AMS-holder layout (e.g. 30×15 mm). Swatch + brand/material/hex/ID, no QR."""
+    """Tight layout (h < 20 mm). Swatch + brand/material/hex/ID, no QR."""
     swatch_w = min(inner_h, inner_w * 0.35)
     swatch_w = min(inner_h, inner_w * 0.35)
     swatch_y = inner_y + (inner_h - swatch_w) / 2
     swatch_y = inner_y + (inner_h - swatch_w) / 2
     _draw_swatch(c, inner_x, swatch_y, swatch_w, swatch_w, data)
     _draw_swatch(c, inner_x, swatch_y, swatch_w, swatch_w, data)
@@ -365,7 +379,8 @@ def _draw_label_roomy(
 
 
 # (label_w_mm, label_h_mm) for single-label-per-page templates.
 # (label_w_mm, label_h_mm) for single-label-per-page templates.
 _SINGLE_LABEL_SIZES_MM: dict[str, tuple[float, float]] = {
 _SINGLE_LABEL_SIZES_MM: dict[str, tuple[float, float]] = {
-    "ams_30x15": (30.0, 15.0),
+    "ams_holder_74x33": (74.0, 33.0),
+    "ams_holder_75x55": (75.0, 55.0),
     "box_40x30": (40.0, 30.0),
     "box_40x30": (40.0, 30.0),
     "box_62x29": (62.0, 29.0),
     "box_62x29": (62.0, 29.0),
 }
 }

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

@@ -66,7 +66,13 @@ class TestLocalInventoryLabels:
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_all_four_templates_succeed(self, async_client: AsyncClient, spool_factory):
     async def test_all_four_templates_succeed(self, async_client: AsyncClient, spool_factory):
         s = await spool_factory()
         s = await spool_factory()
-        for template in ("ams_30x15", "box_62x29", "avery_5160", "avery_l7160"):
+        for template in (
+            "ams_holder_74x33",
+            "ams_holder_75x55",
+            "box_62x29",
+            "avery_5160",
+            "avery_l7160",
+        ):
             resp = await async_client.post(
             resp = await async_client.post(
                 "/api/v1/inventory/labels",
                 "/api/v1/inventory/labels",
                 json={"spool_ids": [s.id], "template": template},
                 json={"spool_ids": [s.id], "template": template},
@@ -100,7 +106,7 @@ class TestLocalInventoryLabels:
         s = await spool_factory()
         s = await spool_factory()
         resp = await async_client.post(
         resp = await async_client.post(
             "/api/v1/inventory/labels",
             "/api/v1/inventory/labels",
-            json={"spool_ids": [s.id, 99999], "template": "ams_30x15"},
+            json={"spool_ids": [s.id, 99999], "template": "ams_holder_74x33"},
         )
         )
         assert resp.status_code == 404
         assert resp.status_code == 404
         assert "99999" in resp.text
         assert "99999" in resp.text

+ 17 - 9
backend/tests/unit/services/test_label_renderer.py

@@ -6,7 +6,14 @@ import pytest
 
 
 from backend.app.services.label_renderer import LabelData, render_labels
 from backend.app.services.label_renderer import LabelData, render_labels
 
 
-ALL_TEMPLATES = ("ams_30x15", "box_40x30", "box_62x29", "avery_5160", "avery_l7160")
+ALL_TEMPLATES = (
+    "ams_holder_74x33",
+    "ams_holder_75x55",
+    "box_40x30",
+    "box_62x29",
+    "avery_5160",
+    "avery_l7160",
+)
 
 
 
 
 def _sample(spool_id: int = 1, **overrides) -> LabelData:
 def _sample(spool_id: int = 1, **overrides) -> LabelData:
@@ -58,7 +65,7 @@ def test_missing_optional_fields_does_not_crash():
             deeplink_url="https://example.test/inventory?spool=42",
             deeplink_url="https://example.test/inventory?spool=42",
         )
         )
     ]
     ]
-    pdf = render_labels("ams_30x15", data)
+    pdf = render_labels("ams_holder_74x33", data)
     assert pdf.startswith(b"%PDF")
     assert pdf.startswith(b"%PDF")
 
 
 
 
@@ -74,7 +81,7 @@ def test_long_strings_are_truncated_not_overflowed():
     long_brand = "A" * 200
     long_brand = "A" * 200
     long_name = "B" * 300
     long_name = "B" * 300
     data = [_sample(brand=long_brand, name=long_name)]
     data = [_sample(brand=long_brand, name=long_name)]
-    pdf = render_labels("ams_30x15", data)
+    pdf = render_labels("ams_holder_74x33", data)
     assert pdf.startswith(b"%PDF")
     assert pdf.startswith(b"%PDF")
 
 
 
 
@@ -121,9 +128,10 @@ def _render_uncompressed(template, data):
     from backend.app.services.label_renderer import _draw_label  # noqa: PLC0415
     from backend.app.services.label_renderer import _draw_label  # noqa: PLC0415
 
 
     # Mirror the page-size choice from render_labels but force pageCompression=0.
     # Mirror the page-size choice from render_labels but force pageCompression=0.
-    if template in ("ams_30x15", "box_40x30", "box_62x29"):
+    if template in ("ams_holder_74x33", "ams_holder_75x55", "box_40x30", "box_62x29"):
         sizes = {
         sizes = {
-            "ams_30x15": (30.0, 15.0),
+            "ams_holder_74x33": (74.0, 33.0),
+            "ams_holder_75x55": (75.0, 55.0),
             "box_40x30": (40.0, 30.0),
             "box_40x30": (40.0, 30.0),
             "box_62x29": (62.0, 29.0),
             "box_62x29": (62.0, 29.0),
         }
         }
@@ -167,9 +175,9 @@ def _render_uncompressed(template, data):
 def test_ams_template_actually_renders_text():
 def test_ams_template_actually_renders_text():
     """Regression: the first cut of the AMS-holder layout produced labels with
     """Regression: the first cut of the AMS-holder layout produced labels with
     only swatch + QR and no text at all because the side-by-side layout left
     only swatch + QR and no text at all because the side-by-side layout left
-    <5 mm for the text column. The redesign drops the QR on this template and
-    gives the right side to brand + material + spool ID. This pins that the
-    rendered PDF contains all three fields.
+    <5 mm for the text column. The current AMS templates use the roomy layout
+    (swatch + QR + multi-line text); this pins that the rendered PDF contains
+    brand + material + spool ID for the smaller AMS preset.
     """
     """
     data = [
     data = [
         LabelData(
         LabelData(
@@ -182,7 +190,7 @@ def test_ams_template_actually_renders_text():
             deeplink_url="https://example.test/inventory?spool=42",
             deeplink_url="https://example.test/inventory?spool=42",
         )
         )
     ]
     ]
-    pdf = _render_uncompressed("ams_30x15", data)
+    pdf = _render_uncompressed("ams_holder_74x33", data)
     assert b"Polymaker" in pdf, "AMS template must render the brand"
     assert b"Polymaker" in pdf, "AMS template must render the brand"
     assert b"PLA" in pdf, "AMS template must render the material"
     assert b"PLA" in pdf, "AMS template must render the material"
     # The bracketed-hash style is what the renderer uses for the spool ID;
     # The bracketed-hash style is what the renderer uses for the spool ID;

+ 7 - 7
frontend/scripts/check-i18n-parity.mjs

@@ -160,7 +160,7 @@ const DE_COGNATES = [
   'Hex', 'Warm', 'Neutral', 'Navigation', 'Screenshot', 'Architecture',
   'Hex', 'Warm', 'Neutral', 'Navigation', 'Screenshot', 'Architecture',
   'Backend & Auth', 'Stream Overlay', 'Bambuddy Backend URL',
   'Backend & Auth', 'Stream Overlay', 'Bambuddy Backend URL',
   'Material (optional)', 'Custom Headers (JSON)', '({{count}}/8)',
   'Material (optional)', 'Custom Headers (JSON)', '({{count}}/8)',
-  'AMS holder (30 × 15 mm)', 'Box label (62 × 29 mm)',
+  'Box label (62 × 29 mm)',
   'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
   'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
   'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
   'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
   'China', 'Proxy', 'Start',
   'China', 'Proxy', 'Start',
@@ -192,7 +192,7 @@ const FR_COGNATES = [
   '{{count}} filament', '{{count}} filaments', '{{count}} permissions',
   '{{count}} filament', '{{count}} filaments', '{{count}} permissions',
   '{{count}} downloads', '{{count}} item', '{{count}} selected',
   '{{count}} downloads', '{{count}} item', '{{count}} selected',
   '({{count}} item)', 'Provisioning...', 'Pressure Advance',
   '({{count}} item)', 'Provisioning...', 'Pressure Advance',
-  'AMS holder (30 × 15 mm)', 'Box label (62 × 29 mm)',
+  'Box label (62 × 29 mm)',
   'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
   'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
   'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
   'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
   '({{count}}/8)', 'Custom Headers (JSON)', 'Permissions',
   '({{count}}/8)', 'Custom Headers (JSON)', 'Permissions',
@@ -223,7 +223,7 @@ const IT_COGNATES = [
   '{{name}} - Timelapse', 'Box label (62 × 29 mm)',
   '{{name}} - Timelapse', 'Box label (62 × 29 mm)',
   'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
   'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
   'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
   'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
-  'AMS holder (30 × 15 mm)', 'Hex: #{{hex}}',
+  'Hex: #{{hex}}',
   'EC984C,#6CD4BC,A66EB9,D87694',
   'EC984C,#6CD4BC,A66EB9,D87694',
   'Proxy', 'Designer',
   'Proxy', 'Designer',
 ];
 ];
@@ -234,7 +234,7 @@ const JA_COGNATES = [
   'OK', 'Bambu', 'Code',
   'OK', 'Bambu', 'Code',
   'EU (DD/MM/YYYY)', 'US (MM/DD/YYYY)', 'ON, true, 1',
   'EU (DD/MM/YYYY)', 'US (MM/DD/YYYY)', 'ON, true, 1',
   '({{count}}/8)', 'Custom Headers (JSON)',
   '({{count}}/8)', 'Custom Headers (JSON)',
-  'AMS holder (30 × 15 mm)', 'Box label (62 × 29 mm)',
+  'Box label (62 × 29 mm)',
   'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
   'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
   'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
   'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
   'EC984C,#6CD4BC,A66EB9,D87694',
   'EC984C,#6CD4BC,A66EB9,D87694',
@@ -257,7 +257,7 @@ const PT_BR_COGNATES = [
   'Base: {{name}}', 'ETA {{minutes}} min', '{{count}} item',
   'Base: {{name}}', 'ETA {{minutes}} min', '{{count}} item',
   '{{count}} downloads', '({{count}} item)', '(25%, 50%, 75%)',
   '{{count}} downloads', '({{count}} item)', '(25%, 50%, 75%)',
   '({{count}}/8)', 'Custom Headers (JSON)', '{{name}} - Timelapse',
   '({{count}}/8)', 'Custom Headers (JSON)', '{{name}} - Timelapse',
-  'AMS holder (30 × 15 mm)', 'Box label (62 × 29 mm)',
+  'Box label (62 × 29 mm)',
   'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
   'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
   'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
   'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
   'Cancelling upload...', 'EC984C,#6CD4BC,A66EB9,D87694',
   'Cancelling upload...', 'EC984C,#6CD4BC,A66EB9,D87694',
@@ -269,7 +269,7 @@ const PT_BR_COGNATES = [
 // Chinese (Simplified): very few cognates beyond brand names.
 // Chinese (Simplified): very few cognates beyond brand names.
 const ZH_CN_COGNATES = [
 const ZH_CN_COGNATES = [
   'OK', 'Bambu',
   'OK', 'Bambu',
-  '({{count}}/8)', 'Custom Headers (JSON)', 'AMS holder (30 × 15 mm)',
+  '({{count}}/8)', 'Custom Headers (JSON)',
   'Box label (62 × 29 mm)',
   'Box label (62 × 29 mm)',
   'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
   'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
   'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
   'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
@@ -278,7 +278,7 @@ const ZH_CN_COGNATES = [
 
 
 const ZH_TW_COGNATES = [
 const ZH_TW_COGNATES = [
   'OK', 'Bambu',
   'OK', 'Bambu',
-  '({{count}}/8)', 'Custom Headers (JSON)', 'AMS holder (30 × 15 mm)',
+  '({{count}}/8)', 'Custom Headers (JSON)',
   'Box label (62 × 29 mm)',
   'Box label (62 × 29 mm)',
   'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
   'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
   'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
   'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',

+ 16 - 7
frontend/src/__tests__/components/LabelTemplatePickerModal.test.tsx

@@ -176,7 +176,13 @@ describe('LabelTemplatePickerModal', () => {
         spoolmanMode={false}
         spoolmanMode={false}
       />,
       />,
     );
     );
-    expect(screen.getByText(/AMS holder/i).closest('button')).toBeDisabled();
+    // Two AMS holder variants exist (#1426). Both must be disabled when no
+    // spools are selected — the empty-selection guard is global, not per-template.
+    const amsButtons = screen.getAllByText(/AMS holder/i).map((el) => el.closest('button'));
+    expect(amsButtons).toHaveLength(2);
+    for (const btn of amsButtons) {
+      expect(btn).toBeDisabled();
+    }
   });
   });
 
 
   it('sends only the currently checked IDs to the local endpoint', async () => {
   it('sends only the currently checked IDs to the local endpoint', async () => {
@@ -218,12 +224,14 @@ describe('LabelTemplatePickerModal', () => {
       />,
       />,
     );
     );
 
 
-    fireEvent.click(screen.getByText(/AMS holder/i));
+    // Pick the larger AMS holder variant explicitly (#1426: two AMS templates
+     // exist now — pin which one the test sends so the assertion stays meaningful).
+    fireEvent.click(screen.getByText(/AMS holder — large \(75 × 55 mm\)/i));
 
 
     await waitFor(() => {
     await waitFor(() => {
       expect(api.printSpoolmanSpoolLabels).toHaveBeenCalledWith({
       expect(api.printSpoolmanSpoolLabels).toHaveBeenCalledWith({
         spool_ids: [1],
         spool_ids: [1],
-        template: 'ams_30x15',
+        template: 'ams_holder_75x55',
       });
       });
     });
     });
     expect(api.printSpoolLabels).not.toHaveBeenCalled();
     expect(api.printSpoolLabels).not.toHaveBeenCalled();
@@ -296,9 +304,10 @@ describe('LabelTemplatePickerModal', () => {
       />,
       />,
     );
     );
 
 
-    // All five templates must be in the DOM. Use the dimension suffix to
-    // disambiguate the two "Box label …" entries.
-    expect(screen.getByText(/AMS holder/i)).toBeInTheDocument();
+    // All six templates must be in the DOM (#1426 added two AMS variants).
+    // Use the dimension suffix to disambiguate same-family entries.
+    expect(screen.getByText(/AMS holder — small \(74 × 33 mm\)/i)).toBeInTheDocument();
+    expect(screen.getByText(/AMS holder — large \(75 × 55 mm\)/i)).toBeInTheDocument();
     expect(screen.getByText(/Box label \(40 × 30 mm\)/i)).toBeInTheDocument();
     expect(screen.getByText(/Box label \(40 × 30 mm\)/i)).toBeInTheDocument();
     expect(screen.getByText(/Box label \(62 × 29 mm\)/i)).toBeInTheDocument();
     expect(screen.getByText(/Box label \(62 × 29 mm\)/i)).toBeInTheDocument();
     expect(screen.getByText(/Avery L7160/i)).toBeInTheDocument();
     expect(screen.getByText(/Avery L7160/i)).toBeInTheDocument();
@@ -311,7 +320,7 @@ describe('LabelTemplatePickerModal', () => {
     const templatesSection = container.querySelector('div.grid.sm\\:grid-cols-2');
     const templatesSection = container.querySelector('div.grid.sm\\:grid-cols-2');
     expect(templatesSection).not.toBeNull();
     expect(templatesSection).not.toBeNull();
     expect(templatesSection!.className).toContain('grid-cols-1');
     expect(templatesSection!.className).toContain('grid-cols-1');
-    expect(templatesSection!.querySelectorAll('button').length).toBe(5);
+    expect(templatesSection!.querySelectorAll('button').length).toBe(6);
 
 
     // Spool list still uses min-h-0 so it can yield further on very tight viewports.
     // Spool list still uses min-h-0 so it can yield further on very tight viewports.
     const spoolListScroller = container.querySelector('div.flex-1.overflow-y-auto');
     const spoolListScroller = container.querySelector('div.flex-1.overflow-y-auto');

+ 7 - 1
frontend/src/api/client.ts

@@ -2415,7 +2415,13 @@ export interface SpoolmanFilamentEntry {
 
 
 // Inventory types
 // Inventory types
 // Label printing (#809). Mirror of backend.app.services.label_renderer.TemplateName.
 // Label printing (#809). Mirror of backend.app.services.label_renderer.TemplateName.
-export type SpoolLabelTemplate = 'ams_30x15' | 'box_40x30' | 'box_62x29' | 'avery_5160' | 'avery_l7160';
+export type SpoolLabelTemplate =
+  | 'ams_holder_74x33'
+  | 'ams_holder_75x55'
+  | 'box_40x30'
+  | 'box_62x29'
+  | 'avery_5160'
+  | 'avery_l7160';
 
 
 export interface InventorySpool {
 export interface InventorySpool {
   id: number;
   id: number;

+ 10 - 4
frontend/src/components/LabelTemplatePickerModal.tsx

@@ -33,10 +33,16 @@ interface TemplateOption {
 
 
 const TEMPLATE_OPTIONS: TemplateOption[] = [
 const TEMPLATE_OPTIONS: TemplateOption[] = [
   {
   {
-    value: 'ams_30x15',
-    i18nKey: 'ams',
-    fallbackLabel: 'AMS holder (30 × 15 mm)',
-    fallbackHint: 'Single label per page; fits the popular AMS filament label holder.',
+    value: 'ams_holder_74x33',
+    i18nKey: 'amsHolderSmall',
+    fallbackLabel: 'AMS holder — small (74 × 33 mm)',
+    fallbackHint: 'Single label per page; matches the printable label from MakerWorld model 752566 (AMS Filament Label Holder).',
+  },
+  {
+    value: 'ams_holder_75x55',
+    i18nKey: 'amsHolderLarge',
+    fallbackLabel: 'AMS holder — large (75 × 55 mm)',
+    fallbackHint: 'Single label per page; fits the cardstock-insert variant of the AMS Filament Label Holder. Roomy enough for swatch, brand, material, ID, and QR code.',
   },
   },
   {
   {
     value: 'box_40x30',
     value: 'box_40x30',

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

@@ -3542,9 +3542,13 @@ export default {
         color: 'Nach Farbe',
         color: 'Nach Farbe',
       },
       },
       templates: {
       templates: {
-        ams: {
-          label: 'AMS holder (30 × 15 mm)',
-          hint: 'Ein Etikett pro Seite; passt in den beliebten AMS-Filament-Etikettenhalter.',
+        amsHolderSmall: {
+          label: 'AMS-Halter — klein (74 × 33 mm)',
+          hint: 'Ein Etikett pro Seite; passt zum druckbaren Etikett aus dem MakerWorld-Modell 752566 (AMS-Filament-Etikettenhalter).',
+        },
+        amsHolderLarge: {
+          label: 'AMS-Halter — groß (75 × 55 mm)',
+          hint: 'Ein Etikett pro Seite; passt zur Kartoneinleger-Variante des AMS-Filament-Etikettenhalters. Genug Platz für Farbprobe, Marke, Material, ID und QR-Code.',
         },
         },
         box40x30: {
         box40x30: {
           label: 'Boxetikett (40 × 30 mm)',
           label: 'Boxetikett (40 × 30 mm)',

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

@@ -3545,9 +3545,13 @@ export default {
         color: 'By colour',
         color: 'By colour',
       },
       },
       templates: {
       templates: {
-        ams: {
-          label: 'AMS holder (30 × 15 mm)',
-          hint: 'Single label per page; fits the popular AMS filament label holder.',
+        amsHolderSmall: {
+          label: 'AMS holder — small (74 × 33 mm)',
+          hint: 'Single label per page; matches the printable label from MakerWorld model 752566 (AMS Filament Label Holder).',
+        },
+        amsHolderLarge: {
+          label: 'AMS holder — large (75 × 55 mm)',
+          hint: 'Single label per page; fits the cardstock-insert variant of the AMS Filament Label Holder. Roomy enough for swatch, brand, material, ID, and QR code.',
         },
         },
         box40x30: {
         box40x30: {
           label: 'Box label (40 × 30 mm)',
           label: 'Box label (40 × 30 mm)',

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

@@ -3531,9 +3531,13 @@ export default {
         color: 'Par couleur',
         color: 'Par couleur',
       },
       },
       templates: {
       templates: {
-        ams: {
-          label: 'AMS holder (30 × 15 mm)',
-          hint: 'Une étiquette par page ; convient au support d\'étiquettes AMS populaire.',
+        amsHolderSmall: {
+          label: 'Support AMS — petit (74 × 33 mm)',
+          hint: 'Une étiquette par page ; correspond à l\'étiquette imprimable du modèle MakerWorld 752566 (Support d\'étiquettes pour filament AMS).',
+        },
+        amsHolderLarge: {
+          label: 'Support AMS — grand (75 × 55 mm)',
+          hint: 'Une étiquette par page ; s\'adapte à la variante carton du support d\'étiquettes pour filament AMS. Assez d\'espace pour pastille, marque, matériau, ID et QR code.',
         },
         },
         box40x30: {
         box40x30: {
           label: 'Étiquette boîte (40 × 30 mm)',
           label: 'Étiquette boîte (40 × 30 mm)',

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

@@ -3530,9 +3530,13 @@ export default {
         color: 'Per colore',
         color: 'Per colore',
       },
       },
       templates: {
       templates: {
-        ams: {
-          label: 'AMS holder (30 × 15 mm)',
-          hint: 'Una etichetta per pagina; adatta al popolare portaetichette AMS.',
+        amsHolderSmall: {
+          label: 'Supporto AMS — piccolo (74 × 33 mm)',
+          hint: 'Un\'etichetta per pagina; corrisponde all\'etichetta stampabile del modello MakerWorld 752566 (Porta-etichette filamento AMS).',
+        },
+        amsHolderLarge: {
+          label: 'Supporto AMS — grande (75 × 55 mm)',
+          hint: 'Un\'etichetta per pagina; si adatta alla variante cartoncino del porta-etichette filamento AMS. Spazio sufficiente per campione colore, marca, materiale, ID e codice QR.',
         },
         },
         box40x30: {
         box40x30: {
           label: 'Etichetta scatola (40 × 30 mm)',
           label: 'Etichetta scatola (40 × 30 mm)',

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

@@ -3542,9 +3542,13 @@ export default {
         color: '色順',
         color: '色順',
       },
       },
       templates: {
       templates: {
-        ams: {
-          label: 'AMS holder (30 × 15 mm)',
-          hint: '1ページに1ラベル、人気のAMSフィラメントラベルホルダーに適合。',
+        amsHolderSmall: {
+          label: 'AMS ホルダー — 小 (74 × 33 mm)',
+          hint: '1ページにつき1枚;MakerWorld モデル 752566 (AMS フィラメントラベルホルダー) の印刷可能なラベルに対応します。',
+        },
+        amsHolderLarge: {
+          label: 'AMS ホルダー — 大 (75 × 55 mm)',
+          hint: '1ページにつき1枚;AMS フィラメントラベルホルダーのカード紙挿入バージョンに対応します。色見本、ブランド、素材、ID、QR コードを表示できる広さです。',
         },
         },
         box40x30: {
         box40x30: {
           label: 'ボックスラベル (40 × 30 mm)',
           label: 'ボックスラベル (40 × 30 mm)',

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

@@ -3530,9 +3530,13 @@ export default {
         color: 'Por cor',
         color: 'Por cor',
       },
       },
       templates: {
       templates: {
-        ams: {
-          label: 'AMS holder (30 × 15 mm)',
-          hint: 'Uma etiqueta por página; serve no porta-etiquetas AMS popular.',
+        amsHolderSmall: {
+          label: 'Suporte AMS — pequeno (74 × 33 mm)',
+          hint: 'Uma etiqueta por página; corresponde à etiqueta imprimível do modelo MakerWorld 752566 (Suporte de etiquetas de filamento AMS).',
+        },
+        amsHolderLarge: {
+          label: 'Suporte AMS — grande (75 × 55 mm)',
+          hint: 'Uma etiqueta por página; encaixa na variante com inserto em cartão do Suporte de etiquetas de filamento AMS. Espaço suficiente para amostra de cor, marca, material, ID e QR code.',
         },
         },
         box40x30: {
         box40x30: {
           label: 'Etiqueta de caixa (40 × 30 mm)',
           label: 'Etiqueta de caixa (40 × 30 mm)',

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

@@ -3530,9 +3530,13 @@ export default {
         color: '按颜色',
         color: '按颜色',
       },
       },
       templates: {
       templates: {
-        ams: {
-          label: 'AMS holder (30 × 15 mm)',
-          hint: '每页一个标签;适用于流行的 AMS 耗材标签托架。',
+        amsHolderSmall: {
+          label: 'AMS 支架 — 小 (74 × 33 mm)',
+          hint: '每页一个标签;适配 MakerWorld 模型 752566 (AMS 耗材标签支架) 中可打印的标签。',
+        },
+        amsHolderLarge: {
+          label: 'AMS 支架 — 大 (75 × 55 mm)',
+          hint: '每页一个标签;适配 AMS 耗材标签支架的卡片插入款。空间充足,可放置色板、品牌、材料、ID 与二维码。',
         },
         },
         box40x30: {
         box40x30: {
           label: '盒标签 (40 × 30 mm)',
           label: '盒标签 (40 × 30 mm)',

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

@@ -3530,9 +3530,13 @@ export default {
         color: '按顏色',
         color: '按顏色',
       },
       },
       templates: {
       templates: {
-        ams: {
-          label: 'AMS holder (30 × 15 mm)',
-          hint: '每頁一個標籤;適用於熱門的 AMS 耗材標籤托架。',
+        amsHolderSmall: {
+          label: 'AMS 支架 — 小 (74 × 33 mm)',
+          hint: '每頁一個標籤;對應 MakerWorld 模型 752566 (AMS 耗材標籤支架) 中可列印的標籤。',
+        },
+        amsHolderLarge: {
+          label: 'AMS 支架 — 大 (75 × 55 mm)',
+          hint: '每頁一個標籤;對應 AMS 耗材標籤支架的紙卡插入款。空間足夠放置色塊、品牌、材質、ID 與 QR 碼。',
         },
         },
         box40x30: {
         box40x30: {
           label: '盒標籤 (40 × 30 mm)',
           label: '盒標籤 (40 × 30 mm)',

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


+ 1 - 1
static/index.html

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

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