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).
This commit is contained in:
maziggy
2026-05-29 10:15:53 +02:00
parent b663605318
commit 632334953c
24 changed files with 759 additions and 575 deletions
+1
View File
File diff suppressed because one or more lines are too long
+16 -9
View File
@@ -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
View File
@@ -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
View File
@@ -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", "")
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
@@ -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 {
@@ -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');
@@ -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)
@@ -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')}
@@ -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
@@ -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}
/>
@@ -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)
@@ -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
@@ -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">
@@ -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 || '-'}
@@ -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
View File
@@ -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
View File
@@ -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);
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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>