diff --git a/client_v2/locales/en/common.json b/client_v2/locales/en/common.json
index 70099752..69e8cdc3 100644
--- a/client_v2/locales/en/common.json
+++ b/client_v2/locales/en/common.json
@@ -147,6 +147,7 @@
"filament.fields.material": "Material",
"filament.fields.multiColor": "Multi",
"filament.fields.name": "Name",
+ "filament.fields.opacity": "Opacity",
"filament.fields.price": "Price",
"filament.fields.registered": "Registered",
"filament.fields.removeColor": "Remove color",
diff --git a/client_v2/src/lib/components/ColorEditor.svelte b/client_v2/src/lib/components/ColorEditor.svelte
index 2f345885..67b173bc 100644
--- a/client_v2/src/lib/components/ColorEditor.svelte
+++ b/client_v2/src/lib/components/ColorEditor.svelte
@@ -3,11 +3,15 @@
import X from '@lucide/svelte/icons/x';
import type { MultiColorDirection } from '$lib/types';
import * as m from '$lib/paraglide/messages';
+ import { ALPHA_CHECKER, alphaOf, normalizeHex, withAlpha } from '$lib/utils/color';
+ import NumberInput from './NumberInput.svelte';
// Editor for a filament's colour(s): a single colour, or a multi-colour list
- // with a coaxial/longitudinal direction. Emits the full colour state on every
- // change via `onchange`. It keeps its own working list, so the parent should
- // remount it (`{#key ...}`) when switching to a different filament.
+ // with a coaxial/longitudinal direction. Each colour also carries an opacity,
+ // stored as the hex code's alpha channel (#RRGGBBAA) for translucent
+ // filaments. Emits the full colour state on every change via `onchange`. It
+ // keeps its own working list, so the parent should remount it (`{#key ...}`)
+ // when switching to a different filament.
interface Props {
colors: string[];
direction?: MultiColorDirection;
@@ -69,9 +73,35 @@
emit();
}
function pick(i: number, v: string) {
- list[i] = v.replace(/^#/, '').toUpperCase();
+ // The native picker only knows RGB, so carry the row's alpha over rather
+ // than silently making a translucent filament opaque.
+ list[i] = withAlpha(v, alphaOf(list[i])).replace(/^#/, '');
emit();
}
+
+ // Opacity is expressed as a percentage in the UI but lives in the hex code's
+ // alpha byte; a partial or empty row has no alpha to speak of and reads as
+ // fully opaque.
+ function opacityOf(h: string): number {
+ return Math.round((alphaOf(h) / 255) * 100);
+ }
+ function hasColor(h: string): boolean {
+ return normalizeHex(h) !== null;
+ }
+ function setOpacity(i: number, pct: number) {
+ if (!Number.isFinite(pct) || !hasColor(list[i])) return;
+ const clamped = Math.max(0, Math.min(100, pct));
+ list[i] = withAlpha(list[i], (clamped / 100) * 255).replace(/^#/, '');
+ emit();
+ }
+ // Transparent-to-opaque gradient over a checkerboard, so the slider previews
+ // what it controls. Falls back to grey until the row holds a real colour.
+ // `border-box no-repeat` keeps the gradient from tiling a hairline of full
+ // colour under the border at the transparent end — see ALPHA_CHECKER.
+ function alphaTrack(h: string): string {
+ const rgb = normalizeHex(h)?.slice(0, 6) ?? '9AA0A6';
+ return `background:linear-gradient(to right,#${rgb}00,#${rgb}FF) border-box no-repeat,${ALPHA_CHECKER}`;
+ }
function addColor() {
list.push('');
emit();
@@ -100,29 +130,62 @@
@@ -214,6 +277,15 @@
display: flex;
align-items: center;
gap: 8px;
+ /* The opacity group drops to its own line rather than squeezing the hex
+ field when the inspector pane is narrow. */
+ flex-wrap: wrap;
+ }
+ .color-main {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ flex: 1 1 auto;
}
.color-pick {
flex: none;
@@ -237,7 +309,7 @@
border-radius: 4px;
}
.hex {
- flex: 1;
+ flex: 0 1 110px;
min-width: 0;
max-width: 180px;
border: 1px solid var(--border-strong);
@@ -251,6 +323,54 @@
border-color: var(--accent);
outline: none;
}
+ .alpha {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ flex: 1 1 140px;
+ min-width: 140px;
+ }
+ .alpha-slider {
+ appearance: none;
+ -webkit-appearance: none;
+ flex: 1;
+ min-width: 48px;
+ /* Tall enough to be a comfortable tap target, and to line up with the hex
+ field next to it, rather than the hairline track of a typical alpha
+ slider. The track gradient itself is set inline, per colour. */
+ height: 24px;
+ padding: 0;
+ border: 1px solid var(--border-strong);
+ border-radius: 7px;
+ cursor: pointer;
+ }
+ .alpha-slider:disabled {
+ opacity: 0.4;
+ cursor: default;
+ }
+ .alpha-slider::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ width: 16px;
+ height: 16px;
+ border-radius: 50%;
+ background: #fff;
+ border: 1px solid rgba(0, 0, 0, 0.5);
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.35);
+ cursor: inherit;
+ }
+ .alpha-slider::-moz-range-thumb {
+ width: 16px;
+ height: 16px;
+ border-radius: 50%;
+ background: #fff;
+ border: 1px solid rgba(0, 0, 0, 0.5);
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.35);
+ cursor: inherit;
+ }
+ .alpha-slider:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+ }
.rm {
flex: none;
display: inline-flex;
diff --git a/client_v2/src/lib/utils/color.test.ts b/client_v2/src/lib/utils/color.test.ts
new file mode 100644
index 00000000..5fbecc27
--- /dev/null
+++ b/client_v2/src/lib/utils/color.test.ts
@@ -0,0 +1,103 @@
+import { describe, expect, it } from 'vitest';
+import { alphaOf, hasAlpha, hexRgb, normalizeHex, swatchStyle, withAlpha } from './color';
+
+// Translucent filaments are stored as an 8-digit #RRGGBBAA code (issue #1059).
+// The alpha channel has to survive a round trip through the editor untouched,
+// and an opaque colour must stay 6-digit — that is what the API validator and
+// every ecosystem consumer expect.
+
+describe('normalizeHex', () => {
+ it('accepts 6- and 8-digit codes, with or without a hash', () => {
+ expect(normalizeHex('#ff8800')).toBe('FF8800');
+ expect(normalizeHex('ff880080')).toBe('FF880080');
+ expect(normalizeHex(' #FF880080 ')).toBe('FF880080');
+ });
+
+ it('rejects partial and malformed codes', () => {
+ expect(normalizeHex('')).toBeNull();
+ expect(normalizeHex('#ff88')).toBeNull();
+ expect(normalizeHex('#ff88000')).toBeNull();
+ expect(normalizeHex('#gg8800')).toBeNull();
+ expect(normalizeHex(undefined)).toBeNull();
+ });
+});
+
+describe('alphaOf', () => {
+ it('reads the alpha byte of an 8-digit code', () => {
+ expect(alphaOf('#FF880080')).toBe(128);
+ expect(alphaOf('#FF880000')).toBe(0);
+ });
+
+ it('treats a colour without an alpha channel as opaque', () => {
+ expect(alphaOf('#FF8800')).toBe(255);
+ expect(alphaOf('')).toBe(255);
+ expect(alphaOf('nonsense')).toBe(255);
+ });
+});
+
+describe('withAlpha', () => {
+ it('appends the alpha channel for a translucent colour', () => {
+ expect(withAlpha('#FF8800', 128)).toBe('#FF880080');
+ expect(withAlpha('ff8800', 0)).toBe('#FF880000');
+ });
+
+ it('drops the alpha channel again at full opacity', () => {
+ expect(withAlpha('#FF880080', 255)).toBe('#FF8800');
+ });
+
+ it('clamps out-of-range alpha', () => {
+ expect(withAlpha('#FF8800', 400)).toBe('#FF8800');
+ expect(withAlpha('#FF8800', -5)).toBe('#FF880000');
+ });
+
+ it('leaves a partial code alone rather than inventing a colour', () => {
+ expect(withAlpha('#ff88', 128)).toBe('#ff88');
+ });
+
+ it('round-trips every whole opacity percentage', () => {
+ for (let pct = 0; pct <= 100; pct++) {
+ const hex = withAlpha('#FF8800', (pct / 100) * 255);
+ expect(Math.round((alphaOf(hex) / 255) * 100)).toBe(pct);
+ }
+ });
+});
+
+describe('hasAlpha', () => {
+ it('spots a translucent colour anywhere in the list', () => {
+ expect(hasAlpha(['#FF8800', '#00FF0080'])).toBe(true);
+ });
+
+ it('is false for an all-opaque or empty list', () => {
+ expect(hasAlpha(['#FF8800', '#00FF00'])).toBe(false);
+ expect(hasAlpha([])).toBe(false);
+ expect(hasAlpha(undefined)).toBe(false);
+ });
+});
+
+describe('swatchStyle', () => {
+ it('paints an opaque colour as a plain background', () => {
+ expect(swatchStyle(['#FF8800'])).toBe('background:#FF8800');
+ });
+
+ it('layers a translucent colour over a checkerboard', () => {
+ const style = swatchStyle(['#FF880080']);
+ expect(style).toContain('#FF880080');
+ expect(style).toContain('conic-gradient');
+ });
+
+ it('keeps the checkerboard under a partly translucent multi-colour gradient', () => {
+ const style = swatchStyle(['#FF8800', '#00FF0080'], 'coaxial');
+ expect(style).toContain('linear-gradient(90deg');
+ expect(style).toContain('conic-gradient');
+ });
+
+ it('leaves an all-opaque gradient checkerboard-free', () => {
+ expect(swatchStyle(['#FF8800', '#00FF00'], 'coaxial')).not.toContain('conic-gradient');
+ });
+});
+
+describe('hexRgb', () => {
+ it('ignores the alpha channel when reading the rgb triplet', () => {
+ expect(hexRgb('#FF880080')).toEqual([255, 136, 0]);
+ });
+});
diff --git a/client_v2/src/lib/utils/color.ts b/client_v2/src/lib/utils/color.ts
index ab61ffbe..dc1b3338 100644
--- a/client_v2/src/lib/utils/color.ts
+++ b/client_v2/src/lib/utils/color.ts
@@ -3,6 +3,60 @@
import type { MultiColorDirection } from '$lib/types';
+/** Matches a 6- or 8-digit hex color code, with or without a leading '#'. */
+const HEX_RE = /^#?([0-9a-fA-F]{6}([0-9a-fA-F]{2})?)$/;
+
+/** Uppercase 6/8-digit hex body without the '#', or null if `h` isn't a complete color code. */
+export function normalizeHex(h: string | undefined | null): string | null {
+ const match = HEX_RE.exec((h ?? '').trim());
+ return match ? match[1].toUpperCase() : null;
+}
+
+/**
+ * Alpha byte (0-255) of a hex color. Codes without an alpha channel — and
+ * anything unparseable — count as fully opaque, which is how the API treats a
+ * 6-digit `color_hex`.
+ */
+export function alphaOf(h: string | undefined | null): number {
+ const hex = normalizeHex(h);
+ return hex && hex.length === 8 ? parseInt(hex.slice(6), 16) : 255;
+}
+
+/** True when at least one of the colors is translucent (alpha below FF). */
+export function hasAlpha(colors: string[] | undefined): boolean {
+ return !!colors?.some((c) => alphaOf(c) < 255);
+}
+
+/**
+ * Replaces a color's alpha channel. The channel is dropped again at full
+ * opacity so plain colors stay 6-digit, which is what the API and every
+ * ecosystem consumer expect for an opaque filament.
+ */
+export function withAlpha(h: string, alpha: number): string {
+ const hex = normalizeHex(h);
+ if (!hex) return h;
+ const a = Math.max(0, Math.min(255, Math.round(alpha)));
+ const rgb = hex.slice(0, 6);
+ return a >= 255 ? `#${rgb}` : `#${rgb}${a.toString(16).padStart(2, '0').toUpperCase()}`;
+}
+
+/**
+ * The checkerboard painted behind a translucent color, so "washed out" reads as
+ * transparent instead of as a different color. Deliberately theme-independent
+ * (the usual white/grey of every color picker) and composited over white, which
+ * also previews how the filament looks against a printed label.
+ *
+ * Every layer is anchored to the border box. A `background` shorthand resets
+ * `background-origin` to the padding box, so on a bordered element the layers
+ * would be sized to the padding box but painted out under the border — and
+ * since backgrounds repeat, the strip under the border shows the far edge of
+ * the next tile. On a colour gradient that is a hairline of full-opacity colour
+ * down the transparent end, visible wherever the border doesn't perfectly cover
+ * it (Firefox at fractional display scaling).
+ */
+export const ALPHA_CHECKER =
+ 'conic-gradient(#cfcfcf 0 25%, #0000 0 50%, #cfcfcf 0 75%, #0000 0) 0 0/6px 6px border-box, #fff';
+
export function hexRgb(h: string): [number, number, number] {
let s = (h || '#888').replace('#', '');
if (s.length === 3) {
@@ -24,9 +78,14 @@ export function hexRgb(h: string): [number, number, number] {
* - coaxial (coextruded): colours run side-by-side → split left↔right (90deg)
* - longitudinal: colours change along the strand → stacked bottom↔top (0deg)
* - unknown: a neutral diagonal (135deg), also used for generic gradients
+ *
+ * Translucent colors are layered over a checkerboard so the alpha is visible.
*/
export function swatchStyle(colors: string[] | undefined, direction?: MultiColorDirection): string {
if (!colors || !colors.length) return 'background:#555';
+ // Painted underneath the color layer(s); the empty string keeps opaque
+ // swatches on a plain single-layer background.
+ const under = hasAlpha(colors) ? ',' + ALPHA_CHECKER : '';
if (colors.length > 1) {
const angle = direction === 'coaxial' ? '90deg' : direction === 'longitudinal' ? '0deg' : '135deg';
// Each colour gets its own band, but boundaries are softened by a narrow
@@ -43,9 +102,11 @@ export function swatchStyle(colors: string[] | undefined, direction?: MultiColor
return `${c} ${pct(start)}%,${c} ${pct(end)}%`;
})
.join(',');
- return `background:linear-gradient(${angle},${stops})`;
+ return `background:linear-gradient(${angle},${stops})${under}`;
}
- return `background:${colors[0]}`;
+ // A single translucent color still needs to be a layer of its own so the
+ // checkerboard can sit under it, hence the degenerate gradient.
+ return under ? `background:linear-gradient(${colors[0]},${colors[0]})${under}` : `background:${colors[0]}`;
}
export function hue(h: string): number {
diff --git a/tests_frontend_v2/tests/transparency.spec.ts b/tests_frontend_v2/tests/transparency.spec.ts
new file mode 100644
index 00000000..624af82a
--- /dev/null
+++ b/tests_frontend_v2/tests/transparency.spec.ts
@@ -0,0 +1,74 @@
+import { test, expect } from "./fixtures";
+import { createSpoolViaModal, openApp, unique } from "./helpers";
+
+/**
+ * Translucent filaments (#1059). The legacy client could set a filament's alpha
+ * channel and client_v2 could not, even though the API and the database have
+ * stored an 8-digit #RRGGBBAA code since the "color_hex alpha" migration. The
+ * inspector's colour editor now has an opacity control per colour, and the
+ * alpha has to survive the whole round trip: editor → PATCH → reload.
+ */
+test("a filament's colour can be made translucent, and stays translucent", async ({ page }) => {
+ const vendorName = unique("Vendor");
+ const filamentName = unique("Filament");
+ const locationName = unique("Shelf");
+
+ await openApp(page);
+ await createSpoolViaModal(page, { vendorName, filamentName, locationName });
+
+ await test.step("open the new filament in the inspector", async () => {
+ await page.getByRole("link", { name: `Open ${filamentName}` }).click();
+ // The inspector's title is a styled div, not a heading.
+ await expect(page.locator(".head .title")).toContainText(filamentName);
+ });
+
+ // The colour editor: a native picker, the hex code, and the opacity slider
+ // plus its percentage field. Both opacity controls carry the same accessible
+ // name, so they are told apart by role.
+ const colorRow = page.locator(".color-row").first();
+ const hex = colorRow.getByPlaceholder("hex");
+ const opacityPct = colorRow.getByRole("textbox", { name: "Opacity" });
+ const opacitySlider = colorRow.getByRole("slider", { name: "Opacity" });
+
+ await test.step("a plain colour starts out fully opaque", async () => {
+ await hex.fill("1E90FF");
+ await expect(opacityPct).toHaveValue("100");
+ await expect(opacitySlider).toHaveValue("100");
+ });
+
+ await test.step("dialling the opacity down writes an alpha channel", async () => {
+ // 40% of 255 is 102 (0x66). Typing in the percentage field commits on blur.
+ const saved = page.waitForResponse(
+ (r) => r.request().method() === "PATCH" && /\/filament\/\d+$/.test(r.url()) && r.ok(),
+ );
+ await opacityPct.fill("40");
+ await opacityPct.blur();
+ await saved;
+ await expect(hex).toHaveValue("1E90FF66");
+ });
+
+ await test.step("the swatch shows the transparency rather than a washed-out colour", async () => {
+ // A translucent colour is layered over a checkerboard so it doesn't read as
+ // a different, lighter colour.
+ const swatch = page.locator(".swatch").first();
+ await expect(swatch).toHaveAttribute("style", /conic-gradient/);
+ });
+
+ await test.step("the alpha survives a reload", async () => {
+ await page.reload();
+ await expect(page.locator(".head .title")).toContainText(filamentName);
+ await expect(hex).toHaveValue("1E90FF66");
+ await expect(opacityPct).toHaveValue("40");
+ });
+
+ await test.step("going back to full opacity drops the alpha channel again", async () => {
+ const saved = page.waitForResponse(
+ (r) => r.request().method() === "PATCH" && /\/filament\/\d+$/.test(r.url()) && r.ok(),
+ );
+ await opacitySlider.fill("100");
+ await saved;
+ await expect(hex).toHaveValue("1E90FF");
+ await page.reload();
+ await expect(hex).toHaveValue("1E90FF");
+ });
+});