Просмотр исходного кода

fix(inventory): support transparent / clear filament end-to-end (#1545)

  Reporter wanted to select a transparent filament colour in the spool
  editor; CMW-ISS confirmed on v0.2.5b1 that AMS-detected transparent
  spools were silently labelled "Black" in the filament-mapping dropdown
  because the colour name resolver dropped the alpha byte and the underlying
  RGB 000000 HSL-bucketed to "Black". Spoolman already supported 8-digit
  hex; the built-in inventory didn't.

  Eight collapsing sites fixed together so transparent reaches the user
  intact:

  - frontend/src/utils/colors.ts: hexToColorName / getColorName /
    resolveSpoolColorName / isLightColor short-circuit to "Clear" for
    alpha=00 before HSL bucketing or catalog lookup
  - frontend/src/utils/amsHelpers.ts::normalizeColor preserves the alpha
    byte when alpha < FF (normalizeColorForCompare unchanged so type/colour
    matching is unaffected)
  - frontend/src/components/spool-form/constants.ts: new
    { name: 'Clear', hex: '00000000' } preset in QUICK_COLORS
  - frontend/src/components/spool-form/ColorSection.tsx: hex draft accepts
    0-8 chars, commits at 6 (+FF) or 8 verbatim; blur pads 7-char to 8;
    selectColor passes 6-char as +FF / 8-char verbatim; isSelected matches
    on full rgba; swatch buttons paint a checkerboard for alpha=00
  - backend/app/api/routes/printers.py::get_available_filaments preserves
    the full rgba on both AMS and vt_tray branches (6-char dedup key
    unchanged)
  - backend/app/services/spoolman.py::parse_ams_tray drops the silent
    00000000 -> F5E6D3FF cream rewrite — the swatch renderer paints a
    checkerboard underlay for alpha < FF already (added in #1154), so the
    rewrite was hidden technical debt that made every AMS-detected
    transparent spool land in inventory as cream
  - backend/app/services/spool_tag_matcher.py::create_spool_from_tray
    short-circuits the colour-catalog lookup for alpha=00 and stores
    color_name="Clear" directly — otherwise an RFID-tagged transparent
    Bambu spool would resolve against the #000000 catalog row (or "Black"
    via the HSL fallback) before the frontend's resolver ever saw it
  - Two shared helpers in utils/colors.ts — getSwatchStyle(rgba) (style
    object: checkerboard for alpha=00) and spoolColorString(rgba)
    (8-char hex string for SVG fill) — applied to every simple-swatch
    site that would otherwise have rendered Clear spools as solid black:
    LabelTemplatePickerModal, SpoolBuddyInventoryPage (SpoolCircle + dot),
    SpoolBuddyAmsPage (both branches), SpoolBuddyWriteTagPage (4 sites),
    ForecastPanel, AssignToAmsModal, AssignSpoolModal (both branches),
    InventorySpoolInfoCard, TagDetectedModal, SpoolInfoCard, LinkSpoolModal,
    and the FilamentSwatch tooltip title fallback

  Intentionally NOT changed: native <input type="color"> keeps 6-char hex
  (can't pick alpha; onChange still emits +FF, correct); Spoolman's
  _find_or_create_filament strips alpha (Spoolman catalog is 6-char only);
  print_scheduler colour matching strips alpha (auto-mapping treats Clear
  as Black for slot compatibility); label_renderer prints "#RRGGBB" on the
  physical label (printers can't print transparency, swatch fill via
  _color_from_hex still honours alpha).
maziggy 1 день назад
Родитель
Сommit
632334953c

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
CHANGELOG.md


+ 16 - 9
backend/app/api/routes/printers.py

@@ -182,14 +182,19 @@ async def get_available_filaments(
                 tray_type = tray.get("tray_type")
                 if not tray_type:
                     continue
-                tray_color = tray.get("tray_color", "")
-                # Normalize color: remove alpha, add hash
-                hex_color = tray_color.replace("#", "")[:6] if tray_color else "808080"
-                color = f"#{hex_color}"
+                tray_color = tray.get("tray_color", "") or "808080"
+                # Preserve the full RRGGBBAA so transparent filament (alpha=00)
+                # reaches the frontend instead of collapsing to #000000 → black
+                # (#1545). Opaque colours still round-trip as #RRGGBB. The
+                # dedup key uses the 6-char RGB so two slots that share an RGB
+                # but differ only in alpha still merge.
+                stripped = tray_color.replace("#", "")
+                rgb = stripped[:6].lower() or "808080"
+                color = f"#{stripped}"
                 tray_info_idx = tray.get("tray_info_idx", "")
                 tray_sub_brands = tray.get("tray_sub_brands", "") or ""
 
-                key = (tray_type.upper(), hex_color.lower(), tray_sub_brands.upper(), extruder_id)
+                key = (tray_type.upper(), rgb, tray_sub_brands.upper(), extruder_id)
                 if key not in seen:
                     seen.add(key)
                     filaments.append(
@@ -207,15 +212,17 @@ async def get_available_filaments(
             vt_type = vt.get("tray_type")
             if not vt_type:
                 continue
-            vt_color = vt.get("tray_color", "")
-            hex_color = vt_color.replace("#", "")[:6] if vt_color else "808080"
-            color = f"#{hex_color}"
+            vt_color = vt.get("tray_color", "") or "808080"
+            # Same alpha-preserving handling as the AMS branch — see #1545.
+            stripped = vt_color.replace("#", "")
+            rgb = stripped[:6].lower() or "808080"
+            color = f"#{stripped}"
             tray_info_idx = vt.get("tray_info_idx", "")
             tray_sub_brands = vt.get("tray_sub_brands", "") or ""
             vt_id = int(vt.get("id", 254))
             extruder_id = (255 - vt_id) if ams_extruder_map else None
 
-            key = (vt_type.upper(), hex_color.lower(), tray_sub_brands.upper(), extruder_id)
+            key = (vt_type.upper(), rgb, tray_sub_brands.upper(), extruder_id)
             if key not in seen:
                 seen.add(key)
                 filaments.append(

+ 9 - 1
backend/app/services/spool_tag_matcher.py

@@ -100,7 +100,15 @@ async def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool:
     rgba = tray_color if tray_color else None
     color_name = None
 
-    if rgba and len(rgba) >= 6:
+    # Transparent filament (#1545): the AMS reports alpha=00 for clear spools.
+    # Skip the catalog lookup — the catalog only stores RGB so 000000 would
+    # resolve to "Black" (or whatever else lives at that RGB), which is exactly
+    # the bug the cream rewrite in parse_ams_tray used to paper over. Store
+    # "Clear" directly and let the frontend's resolveSpoolColorName +
+    # hexToColorName render the swatch as a checkerboard.
+    if rgba and len(rgba) == 8 and rgba[6:8].lower() == "00":
+        color_name = "Clear"
+    elif rgba and len(rgba) >= 6:
         hex_prefix = f"#{rgba[:6].upper()}"
         cat_query = (
             select(ColorCatalogEntry)

+ 7 - 4
backend/app/services/spoolman.py

@@ -909,10 +909,13 @@ class SpoolmanClient:
             logger.debug("Skipping tray with empty color")
             return None
 
-        # Handle transparent/natural filament (RRGGBBAA with alpha=00)
-        # Replace with cream color that represents how natural PLA actually looks
-        if tray_color == "00000000":
-            tray_color = "F5E6D3FF"  # Light cream/natural color
+        # Transparent filament (alpha=00) used to be rewritten to a cream
+        # "natural PLA" colour before being stored, because the swatch
+        # renderer couldn't show alpha. The swatch now paints a checkerboard
+        # underlay for translucent rgbas (see filamentSwatchHelpers.ts), so
+        # we pass `00000000` through verbatim — the inventory row keeps the
+        # AMS-reported colour and the frontend resolves the name to "Clear"
+        # via getColorName (#1545).
 
         # Get sub_brands, falling back to tray_type
         tray_sub_brands = tray_data.get("tray_sub_brands", "")

+ 30 - 17
frontend/src/__tests__/components/spool-form/ColorSectionHexInput.test.tsx

@@ -15,16 +15,17 @@
  * branch then truncated away. Every keystroke past the first was lost.
  *
  * Current contract:
- *   - The hex input has its own draft state (0–6 chars) decoupled from
+ *   - The hex input has its own draft state (0–8 chars) decoupled from
  *     `formData.rgba`. Typing one char at a time works naturally.
- *   - `updateField('rgba', ...)` is only called once the draft reaches a full
- *     6-char hex — at which point we append "FF" alpha for an 8-char result.
- *   - On blur, a partial draft (1–5 chars) is right-padded with '0' and
- *     committed. Preserves the #1055 invariant: anything the backend ever
- *     sees is exactly 8 hex chars.
- *   - Paste of 7/8-char strings (rare alpha-channel case) truncates to the
- *     leading 6-char RGB on input. Bambu filaments are opaque, so an alpha
- *     affordance was never exposed in the UI.
+ *   - `updateField('rgba', ...)` is called when the draft reaches a full
+ *     6-char RGB (alpha defaults to "FF") or a full 8-char RRGGBBAA.
+ *   - On blur, a 1–5 char partial draft is right-padded with '0' to RGB then
+ *     committed with "FF" alpha; a 7-char draft pads the alpha nibble to '0'.
+ *     Preserves the #1055 invariant: anything the backend ever sees is
+ *     exactly 8 hex chars.
+ *   - Pastes longer than 8 chars truncate to the leading 8. 8-char pastes
+ *     pass through verbatim — alpha=00 is what makes the "Clear" preset
+ *     work end-to-end (#1545).
  */
 
 import { describe, it, expect, vi } from 'vitest';
@@ -152,17 +153,29 @@ describe('ColorSection hex input — backend invariant (#1055)', () => {
     }
   });
 
-  it('truncates paste of 7–8 chars to the leading RGB triplet', () => {
-    // Pre-fix, an 8-char paste passed through and a 7-char paste dropped the
-    // last char. Both are rare alpha-channel cases; Bambu filaments are
-    // opaque and the UI exposes no alpha affordance, so we truncate to the
-    // leading 6-char RGB and force FF alpha. Loses the (undocumented) 8-char
-    // paste-with-alpha case, gains uniform commit-at-6 semantics.
+  it('accepts an 8-char paste verbatim (alpha byte preserved)', () => {
+    // #1545: the input now supports an alpha byte so users can paste e.g.
+    // `00000000` (Clear) or a translucent value from elsewhere. Anything
+    // longer than 8 hex chars is truncated to the leading 8.
     const { hexInput, updateField } = renderColorSection();
 
     fireEvent.change(hexInput, { target: { value: '0011223344' } });
-    expect(hexInput.value).toBe('001122');
-    expect(lastRgba(updateField)).toBe('001122FF');
+    expect(hexInput.value).toBe('00112233');
+    expect(lastRgba(updateField)).toBe('00112233');
+  });
+
+  it('pads a 7-char draft to 8 chars on blur (alpha nibble → 0)', () => {
+    // 7 chars is RGB + a single alpha nibble. Treat it as a partial alpha
+    // value the user was still typing and pad with `0` on blur so the
+    // committed rgba is the 8-char canonical form (#1055 invariant).
+    const { hexInput, updateField } = renderColorSection();
+
+    fireEvent.change(hexInput, { target: { value: '0011223' } });
+    fireEvent.blur(hexInput);
+
+    const rgba = lastRgba(updateField);
+    expect(rgba).toBe('00112230');
+    expect(rgba).toMatch(/^[0-9A-F]{8}$/);
   });
 
   it('strips non-hex characters', () => {

+ 29 - 1
frontend/src/__tests__/utils/colors.test.ts

@@ -26,6 +26,20 @@ describe('hexToColorName', () => {
   it('classifies white hex as White', () => {
     expect(hexToColorName('FFFFFF')).toBe('White');
   });
+
+  // #1545: transparent filament is reported as `00000000` (alpha=00).
+  // Without the alpha-aware short-circuit it would fall through to the HSL
+  // bucketing and resolve to "Black" because the RGB happens to be 000000.
+  it('classifies any alpha=00 rgba as Clear', () => {
+    expect(hexToColorName('00000000')).toBe('Clear');
+    expect(hexToColorName('FF000000')).toBe('Clear');
+    expect(hexToColorName('#abcdef00')).toBe('Clear');
+  });
+
+  it('still classifies fully opaque colors via HSL even when alpha is FF', () => {
+    expect(hexToColorName('000000FF')).toBe('Black');
+    expect(hexToColorName('FFFFFFFF')).toBe('White');
+  });
 });
 
 describe('getColorName', () => {
@@ -65,6 +79,14 @@ describe('getColorName', () => {
     setColorCatalog({ 'f5b6cd': 'Cherry Pink' });
     expect(getColorName('F5B6CDFF')).toBe('Cherry Pink');
   });
+
+  // #1545: alpha=00 must short-circuit catalog lookup too — otherwise a
+  // catalog entry on the underlying RGB would mislabel transparent filament.
+  it('returns Clear for transparent rgba regardless of catalog entry', () => {
+    setColorCatalog({ '000000': 'Inky Night' });
+    expect(getColorName('00000000')).toBe('Clear');
+    expect(getColorName('000000FF')).toBe('Inky Night');
+  });
 });
 
 describe('resolveSpoolColorName', () => {
@@ -82,6 +104,12 @@ describe('resolveSpoolColorName', () => {
   });
 
   it('returns null when color_name is a code and hex is unknown', () => {
-    expect(resolveSpoolColorName('A99-Z9', '12345600')).toBeNull();
+    // Opaque, not in catalog — must not be misread as transparent (#1545).
+    expect(resolveSpoolColorName('A99-Z9', '123456FF')).toBeNull();
+  });
+
+  // #1545
+  it('returns Clear for transparent rgba even when color_name is a code', () => {
+    expect(resolveSpoolColorName('A99-Z9', '00000000')).toBe('Clear');
   });
 });

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

@@ -8,6 +8,7 @@ import { Button } from './Button';
 import { ConfirmModal } from './ConfirmModal';
 import { useToast } from '../contexts/ToastContext';
 import { filterSpoolsByQuery } from '../utils/inventorySearch';
+import { getSwatchStyle } from '../utils/colors';
 
 interface AssignSpoolModalProps {
   isOpen: boolean;
@@ -406,7 +407,7 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
                       {spool.rgba && (
                         <span
                           className="w-3 h-3 rounded-full border border-black/20 flex-shrink-0"
-                          style={{ backgroundColor: `#${spool.rgba.substring(0, 6)}` }}
+                          style={getSwatchStyle(spool.rgba)}
                         />
                       )}
                       <span className="text-xs text-bambu-gray truncate">{spool.color_name || ''}</span>
@@ -481,7 +482,7 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
                               {spool.rgba && (
                                 <span
                                   className="w-3 h-3 rounded-full border border-black/20 flex-shrink-0"
-                                  style={{ backgroundColor: `#${spool.rgba.substring(0, 6)}` }}
+                                  style={getSwatchStyle(spool.rgba)}
                                 />
                               )}
                               <span className="text-xs text-bambu-gray truncate">{spool.color_name || ''}</span>

+ 11 - 3
frontend/src/components/FilamentSwatch.tsx

@@ -54,13 +54,21 @@ export function FilamentSwatch({
     shape === 'circle' ? 'rounded-full' : shape === 'pill' ? 'rounded-full' : 'rounded';
 
   // Compute a sensible title fallback — solid hex or gradient summary.
+  // Show the full 8-char rgba when alpha < FF so the tooltip on a Clear /
+  // translucent spool reflects what's actually painted (#1545).
+  const titleHex = (() => {
+    if (!rgba) return undefined;
+    const clean = rgba.replace(/^#/, '');
+    if (clean.length >= 8 && clean.substring(6, 8).toLowerCase() !== 'ff') {
+      return `#${clean.substring(0, 8)}`;
+    }
+    return `#${clean.substring(0, 6)}`;
+  })();
   const computedTitle =
     title ??
     (stops.length > 0
       ? stops.join(', ')
-      : rgba
-        ? `#${rgba.substring(0, 6)}`
-        : undefined);
+      : titleHex);
 
   return (
     <span

+ 5 - 2
frontend/src/components/ForecastPanel.tsx

@@ -13,6 +13,7 @@ import {
 } from 'recharts';
 import { api } from '../api/client';
 import type { InventorySpool, SpoolUsageRecord, FilamentSkuSettings, ShoppingListItem } from '../api/client';
+import { getSwatchStyle } from '../utils/colors';
 import { useToast } from '../contexts/ToastContext';
 import { useAuth } from '../contexts/AuthContext';
 
@@ -790,7 +791,9 @@ function ForecastRow({
   const snoozed = f.settings?.alerts_snoozed ?? false;
 
   const label = [f.group.brand, f.group.material, f.group.subtype].filter(Boolean).join(' ');
-  const colorStyle = f.group.spools[0]?.rgba ? `#${f.group.spools[0].rgba.substring(0, 6)}` : '#4B5563';
+  // Use getSwatchStyle so a Clear (alpha=00) lead spool renders as a
+  // checkerboard rather than collapsing to solid black (#1545).
+  const colorStyle = f.group.spools[0]?.rgba ? getSwatchStyle(f.group.spools[0].rgba) : { backgroundColor: '#4B5563' };
   const remainPct = f.totalLabelG > 0 ? Math.round((f.totalRemainingG / f.totalLabelG) * 100) : 0;
 
   const daysColor = snoozed ? 'text-bambu-gray'
@@ -827,7 +830,7 @@ function ForecastRow({
         <td className="px-4 py-3">
           <span
             className="block w-3 h-3 rounded-full border border-black/20"
-            style={{ backgroundColor: colorStyle }}
+            style={colorStyle}
           />
         </td>
 

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

@@ -4,6 +4,7 @@ import { X, Loader2, Printer, CheckSquare, Square, Search } from 'lucide-react';
 import { api, type SpoolLabelTemplate, type InventorySpool } from '../api/client';
 import { Button } from './Button';
 import { useToast } from '../contexts/ToastContext';
+import { getSwatchStyle } from '../utils/colors';
 
 /** Subset of InventorySpool the modal needs for checkbox rendering. */
 type SpoolForLabel = Pick<
@@ -84,10 +85,12 @@ function openBlobInNewTab(blob: Blob): void {
   setTimeout(() => window.URL.revokeObjectURL(url), 60_000);
 }
 
+// Thin wrapper over `getSwatchStyle` from utils/colors so the modal's render
+// sites keep their existing call shape. Transparent (alpha=00) spools now
+// render as a checkerboard pattern instead of collapsing to solid black
+// (#1545).
 function swatchStyle(rgba: string | null | undefined): React.CSSProperties {
-  if (!rgba) return { backgroundColor: '#808080' };
-  const cleaned = rgba.replace(/^#/, '').slice(0, 6);
-  return cleaned.length === 6 ? { backgroundColor: `#${cleaned}` } : { backgroundColor: '#808080' };
+  return getSwatchStyle(rgba);
 }
 
 function spoolDisplayName(s: SpoolForLabel): string {

+ 57 - 31
frontend/src/components/spool-form/ColorSection.tsx

@@ -5,6 +5,7 @@ import type { ColorSectionProps, CatalogDisplayColor } from './types';
 import { QUICK_COLORS, ALL_COLORS } from './constants';
 import { FilamentSwatch } from '../FilamentSwatch';
 import { buildFilamentBackground, FILAMENT_EFFECT_OPTIONS } from '../filamentSwatchHelpers';
+import { getSwatchStyle } from '../../utils/colors';
 
 /** Parse user paste from 3dfilamentprofiles.com etc.: split on commas/whitespace,
  *  drop the leading `#`, accept 6/8-char hex, lowercase. Returns null when no
@@ -37,26 +38,40 @@ export function ColorSection({
   const [showAllColors, setShowAllColors] = useState(false);
   const [colorSearch, setColorSearch] = useState('');
 
-  // Current hex without # prefix
-  const currentHex = formData.rgba.replace('#', '').substring(0, 6);
+  // Current rgba in canonical 8-char uppercase form (RRGGBBAA). Used both for
+  // preset selection matching and to derive the 6-char hex shown in the
+  // native picker / draft input. #1545: alpha is tracked end-to-end so the
+  // `Clear` preset (00000000) stays distinct from black (000000FF).
+  const currentRgba = (formData.rgba.replace('#', '') + 'FF').substring(0, 8).toUpperCase();
+  const currentHex = currentRgba.substring(0, 6);
+  const currentAlpha = currentRgba.substring(6, 8);
+  const isTransparent = currentAlpha === '00';
 
   // Draft state for the manual hex input. Decoupled from `formData.rgba` so
-  // mid-typing values (1–5 chars) don't trigger the immediate auto-pad-to-6
+  // mid-typing values (1–7 chars) don't trigger the immediate auto-pad
   // that used to drop every keystroke past the first (#1407). The draft is
-  // committed to `formData.rgba` once it reaches 6 chars, or on blur (where
-  // shorter drafts are padded with `0` so the backend never sees a malformed
-  // rgba — preserves the #1055 invariant). The useEffect re-syncs the draft
-  // whenever an external action (color picker, swatch click, edit-mode load)
-  // changes `currentHex`.
-  const [hexDraft, setHexDraft] = useState(currentHex);
+  // committed to `formData.rgba` once it reaches 6 or 8 chars, or on blur
+  // (where shorter drafts are padded with `0` so the backend never sees a
+  // malformed rgba — preserves the #1055 invariant). The useEffect re-syncs
+  // the draft whenever an external action (color picker, swatch click,
+  // edit-mode load) changes the rgba.
+  const [hexDraft, setHexDraft] = useState(isTransparent ? currentRgba : currentHex);
   useEffect(() => {
-    setHexDraft(currentHex);
-  }, [currentHex]);
+    setHexDraft(isTransparent ? currentRgba : currentHex);
+  }, [currentHex, currentRgba, isTransparent]);
 
+  // Match presets on the full rgba so 'Clear' (00000000) doesn't collide with
+  // 'Black' (000000FF). A 6-char preset hex is treated as alpha=FF.
   const isSelected = (hex: string) => {
-    return currentHex.toUpperCase() === hex.toUpperCase();
+    const presetRgba = (hex.replace('#', '') + 'FF').substring(0, 8).toUpperCase();
+    return currentRgba === presetRgba;
   };
 
+  // Visual style for a swatch button — delegates to the shared helper so the
+  // `Clear` preset (alpha=00) renders as a checkerboard everywhere the same
+  // way instead of vanishing under a transparent backgroundColor (#1545).
+  const swatchStyle = (hex: string) => getSwatchStyle(hex);
+
   const selectColor = (
     hex: string,
     name: string,
@@ -69,8 +84,12 @@ export function ColorSection({
     extraColors?: string | null,
     effectType?: string | null,
   ) => {
-    // Store as RRGGBBAA (with FF alpha)
-    updateField('rgba', hex.toUpperCase() + 'FF');
+    // Store as RRGGBBAA. 6-char presets get alpha=FF appended; 8-char presets
+    // (e.g. the `Clear` swatch, 00000000) are preserved as-is so the
+    // transparency reaches the backend (#1545).
+    const cleaned = hex.replace('#', '').toUpperCase();
+    const rgba = cleaned.length === 8 ? cleaned : cleaned + 'FF';
+    updateField('rgba', rgba);
     updateField('color_name', name);
     if (extraColors !== undefined) {
       const next = extraColors ?? '';
@@ -279,7 +298,7 @@ export function ColorSection({
                     ? 'border-bambu-green ring-1 ring-bambu-green/30 scale-110'
                     : 'border-bambu-dark-tertiary'
                 }`}
-                style={{ backgroundColor: `#${color.hex}` }}
+                style={swatchStyle(color.hex)}
                 title={color.name}
               />
             ))}
@@ -317,7 +336,7 @@ export function ColorSection({
                     ? 'border-bambu-green ring-1 ring-bambu-green/30 scale-110'
                     : 'border-bambu-dark-tertiary'
                 }`}
-                style={{ backgroundColor: `#${color.hex}` }}
+                style={swatchStyle(color.hex)}
                 title={
                   color.manufacturer && color.material
                     ? `${color.name} (${color.manufacturer} — ${color.material})`
@@ -370,7 +389,7 @@ export function ColorSection({
                     ? 'border-bambu-green ring-1 ring-bambu-green/30 scale-110'
                     : 'border-bambu-dark-tertiary'
                 }`}
-                style={{ backgroundColor: `#${color.hex}` }}
+                style={swatchStyle(color.hex)}
                 title={color.name}
               >
                 <span className="absolute -bottom-7 left-1/2 -translate-x-1/2 px-2 py-0.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-20 shadow-lg text-white">
@@ -408,30 +427,37 @@ export function ColorSection({
                 placeholder="RRGGBB"
                 value={hexDraft.toUpperCase()}
                 onChange={(e) => {
-                  // Sanitize: drop `#`, non-hex chars, uppercase. 7/8-char
-                  // pastes (with an alpha byte) truncate to the leading RGB —
-                  // Bambu filaments are opaque, so we never expose an alpha
-                  // affordance and discarding pasted alpha is fine. The draft
-                  // can hold 0–6 chars freely while the user types; we only
-                  // commit to `formData.rgba` (and through to the backend)
-                  // once the value is a complete 6-char hex, which keeps the
-                  // #1055 invariant intact without re-introducing the
-                  // mid-keystroke truncation that broke typing in #1407.
+                  // Sanitize: drop `#`, non-hex chars, uppercase. Accept up to
+                  // 8 chars so power users can type an alpha byte (e.g.
+                  // `00000000` for transparent / `00FF00FF` for opaque) —
+                  // #1545. The Clear preset button covers the common case; the
+                  // input handles arbitrary alphas. Draft holds 0–8 chars; we
+                  // commit when the value is a complete 6-char (alpha defaults
+                  // to FF) or 8-char hex, preserving the #1055/#1407
+                  // invariants.
                   const sanitized = e.target.value
                     .replace('#', '')
                     .replace(/[^0-9A-Fa-f]/g, '')
                     .toUpperCase();
-                  const next = sanitized.length > 6 ? sanitized.substring(0, 6) : sanitized;
+                  const next = sanitized.length > 8 ? sanitized.substring(0, 8) : sanitized;
                   setHexDraft(next);
                   if (next.length === 6) {
                     updateField('rgba', next + 'FF');
+                  } else if (next.length === 8) {
+                    updateField('rgba', next);
                   }
                 }}
                 onBlur={() => {
-                  // User left the field with a partial value — pad to 6 chars
-                  // and commit so the form state always carries a valid rgba
-                  // when submitted.
-                  if (hexDraft.length > 0 && hexDraft.length < 6) {
+                  // User left the field with a partial value — pad RGB to 6
+                  // chars (alpha defaults to FF) and commit so the form state
+                  // always carries a valid rgba when submitted. 7-char drafts
+                  // (RGB + 1 alpha digit) pad the alpha nibble to 0 to reach
+                  // a complete 8-char hex.
+                  if (hexDraft.length === 7) {
+                    const padded = hexDraft + '0';
+                    setHexDraft(padded);
+                    updateField('rgba', padded);
+                  } else if (hexDraft.length > 0 && hexDraft.length < 6) {
                     const padded = hexDraft.padEnd(6, '0');
                     setHexDraft(padded);
                     updateField('rgba', padded + 'FF');

+ 4 - 1
frontend/src/components/spool-form/constants.ts

@@ -30,7 +30,9 @@ export const KNOWN_VARIANTS = [
   'Gradient', 'Dual Color', 'Tri Color', 'Multicolor',
 ];
 
-// Quick color swatches - most common colors (shown by default)
+// Quick color swatches - most common colors (shown by default).
+// `Clear` is the only 8-char preset (alpha 00) — native `<input type="color">`
+// cannot pick alpha, so a dedicated preset is the only way to set it (#1545).
 export const QUICK_COLORS: ColorPreset[] = [
   { name: 'Black', hex: '000000' },
   { name: 'White', hex: 'FFFFFF' },
@@ -44,6 +46,7 @@ export const QUICK_COLORS: ColorPreset[] = [
   { name: 'Pink', hex: 'FF69B4' },
   { name: 'Brown', hex: '8B4513' },
   { name: 'Silver', hex: 'C0C0C0' },
+  { name: 'Clear', hex: '00000000' },
 ];
 
 // Extended color palette (shown when expanded)

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

@@ -7,6 +7,7 @@ import { ConfirmModal } from '../ConfirmModal';
 import { AmsUnitCard, NozzleBadge } from './AmsUnitCard';
 import type { AmsThresholds } from './AmsUnitCard';
 import { getFillBarColor } from '../../utils/amsHelpers';
+import { getSwatchStyle } from '../../utils/colors';
 
 function getAmsName(id: number): string {
   if (id <= 3) return `AMS ${String.fromCharCode(65 + id)}`;
@@ -352,7 +353,7 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId, spoolmanMo
 
   if (!isOpen) return null;
 
-  const colorHex = spool.rgba ? `#${spool.rgba.slice(0, 6)}` : '#808080';
+  const colorStyle = getSwatchStyle(spool.rgba);
 
   return (
     <>
@@ -360,7 +361,7 @@ export function AssignToAmsModal({ isOpen, onClose, spool, printerId, spoolmanMo
       {/* Header */}
       <div className="flex items-center justify-between px-5 py-3 border-b border-zinc-800 shrink-0">
         <div className="flex items-center gap-3 min-w-0">
-          <div className="w-7 h-7 rounded-full shrink-0" style={{ backgroundColor: colorHex }} />
+          <div className="w-7 h-7 rounded-full shrink-0" style={colorStyle} />
           <div className="min-w-0">
             <h2 className="text-sm font-semibold text-zinc-100 truncate">
               {t('spoolbuddy.modal.assignToAmsTitle', 'Assign to AMS')}

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

@@ -5,6 +5,7 @@ import { Check, AlertTriangle, RefreshCw, Unlink } from 'lucide-react';
 import type { InventorySpool } from '../../api/client';
 import { spoolbuddyApi, api } from '../../api/client';
 import { SpoolIcon } from './SpoolIcon';
+import { spoolColorString } from '../../utils/colors';
 
 const DEFAULT_CORE_WEIGHT_KEY = 'spoolbuddy-default-core-weight';
 
@@ -61,7 +62,7 @@ export function InventorySpoolInfoCard({
   // Use fetched k_profiles if available, otherwise use the ones from the spool object
   const kProfiles = (spool.k_profiles && spool.k_profiles.length > 0) ? spool.k_profiles : fetchedKProfiles;
 
-  const colorHex = spool.rgba ? `#${spool.rgba.slice(0, 6)}` : '#808080';
+  const colorHex = spoolColorString(spool.rgba);
 
   const coreWeight = (spool.core_weight && spool.core_weight > 0)
     ? spool.core_weight

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

@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
 import { X } from 'lucide-react';
 import type { InventorySpool } from '../../api/client';
 import { SpoolIcon } from './SpoolIcon';
+import { spoolColorString } from '../../utils/colors';
 
 interface LinkSpoolModalProps {
   isOpen: boolean;
@@ -100,7 +101,7 @@ export function LinkSpoolModal({
                   }`}
                 >
                   <SpoolIcon
-                    color={spool.rgba ? `#${spool.rgba.slice(0, 6)}` : '#808080'}
+                    color={spoolColorString(spool.rgba)}
                     isEmpty={false}
                     size={40}
                   />

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

@@ -4,6 +4,7 @@ import { Check, AlertTriangle, RefreshCw, Unlink } from 'lucide-react';
 import type { MatchedSpool } from '../../hooks/useSpoolBuddyState';
 import { spoolbuddyApi } from '../../api/client';
 import { SpoolIcon } from './SpoolIcon';
+import { spoolColorString } from '../../utils/colors';
 
 // Storage key for default core weight
 const DEFAULT_CORE_WEIGHT_KEY = 'spoolbuddy-default-core-weight';
@@ -36,7 +37,7 @@ export function SpoolInfoCard({ spool, scaleWeight, onClose, onSyncWeight, onAss
   const [syncing, setSyncing] = useState(false);
   const [synced, setSynced] = useState(false);
 
-  const colorHex = spool.rgba ? `#${spool.rgba.slice(0, 6)}` : '#808080';
+  const colorHex = spoolColorString(spool.rgba);
 
   // Use spool's core_weight if set, otherwise fall back to default
   const coreWeight = (spool.core_weight && spool.core_weight > 0)

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

@@ -4,6 +4,7 @@ import { Check, RefreshCw, AlertTriangle, X } from 'lucide-react';
 import type { MatchedSpool } from '../../hooks/useSpoolBuddyState';
 import { spoolbuddyApi } from '../../api/client';
 import { SpoolIcon } from './SpoolIcon';
+import { spoolColorString } from '../../utils/colors';
 
 // Storage key for default core weight (shared with SpoolInfoCard)
 const DEFAULT_CORE_WEIGHT_KEY = 'spoolbuddy-default-core-weight';
@@ -134,7 +135,7 @@ interface KnownSpoolViewProps {
 
 function KnownSpoolView({ spool, scaleWeight, weightStable, syncing, synced, onSyncWeight, onAssignToAms, onClose }: KnownSpoolViewProps) {
   const { t } = useTranslation();
-  const colorHex = spool.rgba ? `#${spool.rgba.slice(0, 6)}` : '#808080';
+  const colorHex = spoolColorString(spool.rgba);
 
   const coreWeight = (spool.core_weight && spool.core_weight > 0)
     ? spool.core_weight

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

@@ -7,6 +7,7 @@ import type { SpoolBuddyOutletContext } from '../../components/spoolbuddy/SpoolB
 import { api } from '../../api/client';
 import type { PrinterStatus, AMSTray, SpoolAssignment } from '../../api/client';
 import { getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag, formatSlotLabel, isBambuLabSpool } from '../../utils/amsHelpers';
+import { getSwatchStyle } from '../../utils/colors';
 import { AmsUnitCard, HumidityIndicator, TemperatureIndicator, NozzleBadge } from '../../components/spoolbuddy/AmsUnitCard';
 import type { AmsThresholds } from '../../components/spoolbuddy/AmsUnitCard';
 import { ConfigureAmsSlotModal } from '../../components/ConfigureAmsSlotModal';
@@ -748,7 +749,7 @@ export function SpoolBuddyAmsPage() {
                       {assignment.spool.rgba && (
                         <span
                           className="w-3 h-3 rounded-full border border-black/20 flex-shrink-0"
-                          style={{ backgroundColor: `#${assignment.spool.rgba.substring(0, 6)}` }}
+                          style={getSwatchStyle(assignment.spool.rgba)}
                         />
                       )}
                       <span className="text-sm text-white">
@@ -781,7 +782,7 @@ export function SpoolBuddyAmsPage() {
                       {spoolmanAssignedSpool.rgba && (
                         <span
                           className="w-3 h-3 rounded-full border border-black/20 flex-shrink-0"
-                          style={{ backgroundColor: `#${spoolmanAssignedSpool.rgba.substring(0, 6)}` }}
+                          style={getSwatchStyle(spoolmanAssignedSpool.rgba)}
                         />
                       )}
                       <span className="text-sm text-white">

+ 3 - 4
frontend/src/pages/spoolbuddy/SpoolBuddyInventoryPage.tsx

@@ -5,7 +5,7 @@ import { useOutletContext } from 'react-router-dom';
 import { Search, X, Package } from 'lucide-react';
 import { api } from '../../api/client';
 import type { InventorySpool } from '../../api/client';
-import { resolveSpoolColorName } from '../../utils/colors';
+import { resolveSpoolColorName, getSwatchStyle, spoolColorString } from '../../utils/colors';
 import { formatSlotLabel } from '../../utils/amsHelpers';
 import { InventorySpoolInfoCard } from '../../components/spoolbuddy/InventorySpoolInfoCard';
 import { AssignToAmsModal } from '../../components/spoolbuddy/AssignToAmsModal';
@@ -17,8 +17,7 @@ type SlotInfo = { ams_id: number; tray_id: number; printer_name?: string | null
 type FilterMode = 'all' | 'in_ams' | string; // string = material name
 
 function spoolColor(spool: InventorySpool): string {
-  if (spool.rgba) return `#${spool.rgba.substring(0, 6)}`;
-  return '#808080';
+  return spoolColorString(spool.rgba);
 }
 
 function spoolRemaining(spool: InventorySpool): number {
@@ -334,7 +333,7 @@ function CatalogCard({ spool, assignment, onClick }: {
       <div className="flex items-center gap-1 min-w-0 max-w-full">
         <span
           className="w-2.5 h-2.5 rounded-full shrink-0 border border-white/10"
-          style={{ backgroundColor: color }}
+          style={getSwatchStyle(spool.rgba)}
         />
         <span className="text-[11px] text-white/50 truncate">
           {colorName || '-'}

+ 11 - 8
frontend/src/pages/spoolbuddy/SpoolBuddyWriteTagPage.tsx

@@ -14,6 +14,7 @@ import {
   type SpoolCatalogEntry,
 } from '../../api/client';
 import { getCurrencySymbol } from '../../utils/currency';
+import { getSwatchStyle } from '../../utils/colors';
 import { FilamentSection } from '../../components/spool-form/FilamentSection';
 import { ColorSection } from '../../components/spool-form/ColorSection';
 import { AdditionalSection } from '../../components/spool-form/AdditionalSection';
@@ -362,7 +363,6 @@ function SpoolListItem({ spool, selected, showTag, onClick }: {
   showTag: boolean;
   onClick: () => void;
 }) {
-  const color = spool.rgba ? `#${spool.rgba.slice(0, 6)}` : '#666';
   const remaining = Math.max(0, spool.label_weight - spool.weight_used);
   const pct = spool.label_weight > 0 ? Math.round((remaining / spool.label_weight) * 100) : 0;
 
@@ -375,10 +375,11 @@ function SpoolListItem({ spool, selected, showTag, onClick }: {
           : 'bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary border border-transparent'
       }`}
     >
-      {/* Color dot */}
+      {/* Color dot — uses getSwatchStyle so transparent (Clear) spools render
+          a checkerboard instead of collapsing to solid black (#1545). */}
       <div
         className="w-8 h-8 rounded-full shrink-0 border border-white/10"
-        style={{ backgroundColor: color }}
+        style={spool.rgba ? getSwatchStyle(spool.rgba) : { backgroundColor: '#666' }}
       />
 
       {/* Info */}
@@ -769,7 +770,7 @@ function NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, spoolmanM
           <div className="flex flex-col items-center justify-center h-full p-6 text-center bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg">
             <div
               className="w-12 h-12 rounded-full mb-4 border border-white/10"
-              style={{ backgroundColor: selectedSpool.rgba ? `#${selectedSpool.rgba.slice(0, 6)}` : '#666' }}
+              style={selectedSpool.rgba ? getSwatchStyle(selectedSpool.rgba) : { backgroundColor: '#666' }}
             />
             <p className="text-white font-medium">
               {selectedSpool.brand ? `${selectedSpool.brand} ` : ''}{selectedSpool.material}
@@ -957,7 +958,7 @@ function NewSpoolTouchForm({ currencySymbol, onCreated, selectedSpool, spoolmanM
         <div className="flex flex-col items-center justify-center p-4 text-center bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg">
           <div
             className="w-12 h-12 rounded-full mb-4 border border-white/10"
-            style={{ backgroundColor: selectedSpool.rgba ? `#${selectedSpool.rgba.slice(0, 6)}` : '#666' }}
+            style={selectedSpool.rgba ? getSwatchStyle(selectedSpool.rgba) : { backgroundColor: '#666' }}
           />
           <p className="text-white font-medium">
             {selectedSpool.brand ? `${selectedSpool.brand} ` : ''}{selectedSpool.material}
@@ -1072,8 +1073,10 @@ function NfcStatusPanel({ writeStatus, writeMessage, selectedSpool, tagOnReader,
     );
   }
 
-  // Spool selected — show summary + write button
-  const spoolColor = selectedSpool.rgba ? `#${selectedSpool.rgba.slice(0, 6)}` : '#666';
+  // Spool selected — show summary + write button. Use getSwatchStyle so
+  // transparent (Clear) spools render a checkerboard rather than collapsing
+  // to solid black (#1545).
+  const spoolColorStyle = selectedSpool.rgba ? getSwatchStyle(selectedSpool.rgba) : { backgroundColor: '#666' };
 
   return (
     <div className="flex flex-col items-center text-center space-y-4 w-full">
@@ -1108,7 +1111,7 @@ function NfcStatusPanel({ writeStatus, writeMessage, selectedSpool, tagOnReader,
       {/* Selected spool summary */}
       <div className="w-full bg-bambu-dark-secondary rounded-lg p-3 space-y-2">
         <div className="flex items-center gap-3">
-          <div className="w-8 h-8 rounded-full border border-white/10 shrink-0" style={{ backgroundColor: spoolColor }} />
+          <div className="w-8 h-8 rounded-full border border-white/10 shrink-0" style={spoolColorStyle} />
           <div className="text-left min-w-0">
             <p className="text-white text-sm font-medium truncate">
               {selectedSpool.brand ? `${selectedSpool.brand} ` : ''}{selectedSpool.material}

+ 11 - 5
frontend/src/utils/amsHelpers.ts

@@ -6,15 +6,21 @@
 import { parseUTCDate } from './date';
 
 /**
- * Normalize color format from various sources.
+ * Normalize color format from various sources for CSS rendering.
  * API returns "RRGGBBAA" (8-char), 3MF uses "#RRGGBB" (7-char with hash).
- * This normalizes to "#RRGGBB" format.
+ * Result is "#RRGGBB" for opaque colors and "#RRGGBBAA" when alpha < FF —
+ * CSS accepts both forms on `fill` / `backgroundColor`, and preserving alpha
+ * lets transparent filaments render translucent instead of collapsing to
+ * solid black (#1545). Comparison helpers use normalizeColorForCompare which
+ * still strips alpha, so type/colour matching is unaffected.
  */
 export function normalizeColor(color: string | null | undefined): string {
   if (!color) return '#808080';
-  // Remove alpha channel if present (8-char hex to 6-char)
-  const hex = color.replace('#', '').substring(0, 6);
-  return `#${hex}`;
+  const clean = color.replace('#', '');
+  if (clean.length >= 8 && clean.substring(6, 8).toLowerCase() !== 'ff') {
+    return `#${clean.substring(0, 8)}`;
+  }
+  return `#${clean.substring(0, 6)}`;
 }
 
 /**

+ 69 - 3
frontend/src/utils/colors.ts

@@ -56,6 +56,12 @@ export function __resetColorCatalogForTests(): void {
 export function hexToColorName(hex: string | null | undefined): string {
   if (!hex || hex.length < 6) return 'Unknown';
   const cleanHex = hex.replace('#', '');
+  // Alpha=00 → fully transparent. Name it 'Clear' before falling through to
+  // RGB-based naming, otherwise #00000000 (Bambu's transparent code) would
+  // resolve to 'Black' via the HSL fallback (#1545).
+  if (cleanHex.length === 8 && cleanHex.substring(6, 8).toLowerCase() === '00') {
+    return 'Clear';
+  }
   const r = parseInt(cleanHex.substring(0, 2), 16);
   const g = parseInt(cleanHex.substring(2, 4), 16);
   const b = parseInt(cleanHex.substring(4, 6), 16);
@@ -103,7 +109,9 @@ export function hexToColorName(hex: string | null | undefined): string {
  */
 export function getColorName(hexColor: string): string {
   if (!hexColor) return hexToColorName(hexColor);
-  const hex = hexColor.replace('#', '').toLowerCase().substring(0, 6);
+  const clean = hexColor.replace('#', '').toLowerCase();
+  if (clean.length === 8 && clean.substring(6, 8) === '00') return 'Clear';
+  const hex = clean.substring(0, 6);
   const mapped = runtimeColorCatalog[hex];
   if (mapped) return mapped;
   return hexToColorName(hexColor);
@@ -120,9 +128,12 @@ export function resolveSpoolColorName(colorName: string | null, rgba: string | n
   if (colorName && !/^[A-Z]\d+-[A-Z]\d+$/.test(colorName)) {
     return colorName;
   }
-  // Try hex color lookup from rgba via the runtime catalog
   if (rgba && rgba.length >= 6) {
-    const hex = rgba.substring(0, 6).toLowerCase();
+    const clean = rgba.replace('#', '').toLowerCase();
+    // Transparent rgba: don't fall through to RGB-based lookup that would
+    // return 'Black' for #00000000 (#1545).
+    if (clean.length === 8 && clean.substring(6, 8) === '00') return 'Clear';
+    const hex = clean.substring(0, 6);
     const mapped = runtimeColorCatalog[hex];
     if (mapped) return mapped;
   }
@@ -130,6 +141,58 @@ export function resolveSpoolColorName(colorName: string | null, rgba: string | n
   return null;
 }
 
+/**
+ * Build a hex string suitable for SVG `fill=` / props that take a single
+ * colour value. Preserves the alpha byte when alpha < FF so a transparent
+ * spool renders translucent in SVG / CSS rather than collapsing to solid
+ * black (#1545). Null / malformed input falls back to `#808080`.
+ *
+ * Prefer `getSwatchStyle` for `style` objects that paint a div background —
+ * that helper paints a visible checkerboard under transparent fills.
+ */
+export function spoolColorString(rgba: string | null | undefined): string {
+  if (!rgba) return '#808080';
+  const clean = rgba.replace(/^#/, '');
+  if (clean.length < 6) return '#808080';
+  if (clean.length >= 8 && clean.substring(6, 8).toLowerCase() !== 'ff') {
+    return `#${clean.substring(0, 8)}`;
+  }
+  return `#${clean.substring(0, 6)}`;
+}
+
+/**
+ * Build an inline-style object for a simple filament swatch (a div / button
+ * background) given a spool's rgba. Opaque colours return a plain
+ * `backgroundColor`; transparent (alpha=00) returns a small checkerboard
+ * pattern so the user can see the swatch instead of an invisible element
+ * (#1545). Null / unparseable input falls back to the neutral `#808080` used
+ * elsewhere in the codebase.
+ *
+ * Use this anywhere a quick swatch was previously painted via
+ * `style={{ backgroundColor: '#' + rgba.slice(0, 6) }}` — alpha-stripping
+ * silently turned `Clear` spools into solid black.
+ *
+ * NOTE: `FilamentSwatch` already paints a richer checkerboard underlay
+ * automatically for translucent colours; prefer that for new code and use
+ * this helper only when retro-fitting an existing simple swatch site.
+ */
+export function getSwatchStyle(rgba: string | null | undefined): {
+  backgroundColor?: string;
+  backgroundImage?: string;
+  backgroundSize?: string;
+} {
+  if (!rgba) return { backgroundColor: '#808080' };
+  const clean = rgba.replace(/^#/, '');
+  if (clean.length < 6) return { backgroundColor: '#808080' };
+  if (clean.length >= 8 && clean.substring(6, 8).toLowerCase() === '00') {
+    return {
+      backgroundImage: 'repeating-conic-gradient(#979797 0% 25%, #f5f5f5 0% 50%)',
+      backgroundSize: '8px 8px',
+    };
+  }
+  return { backgroundColor: `#${clean.substring(0, 6)}` };
+}
+
 /**
  * Parse an RGBA hex string (e.g., "FF0000FF") to a CSS rgba() color.
  * Returns null for empty, all-zero, or fully transparent colors.
@@ -151,6 +214,9 @@ export function parseFilamentColor(rgba: string): string | null {
 export function isLightColor(hex: string | null): boolean {
   if (!hex || hex.length < 6) return false;
   const cleanHex = hex.replace('#', '');
+  // Transparent swatches are painted over the light/mid-gray checkerboard
+  // underlay, so treat them as light for text-contrast purposes (#1545).
+  if (cleanHex.length === 8 && cleanHex.slice(6, 8).toLowerCase() === '00') return true;
   const r = parseInt(cleanHex.slice(0, 2), 16);
   const g = parseInt(cleanHex.slice(2, 4), 16);
   const b = parseInt(cleanHex.slice(4, 6), 16);

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-KFJfWuRR.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-Yqo2QO0m.js"></script>
+    <script type="module" crossorigin src="/assets/index-KFJfWuRR.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-y4woBlMv.css">
   </head>
   <body>

Некоторые файлы не были показаны из-за большого количества измененных файлов