mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 03:54:17 +02:00
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:
File diff suppressed because one or more lines are too long
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user