From 948c95866d3b0a85a59432326b1cfca42e0e26b6 Mon Sep 17 00:00:00 2001 From: Donkie Date: Mon, 10 Aug 2026 19:38:16 +0200 Subject: [PATCH 1/2] client_v2: bring back translucent filament colors The legacy client could set a filament's alpha channel, and the API and database have stored an 8-digit #RRGGBBAA code ever since the color_hex alpha migration, but client_v2 had no way to edit it (#1059). Each row of the colour editor gets an opacity slider and a percentage field, both backed by the hex code's alpha byte, so a single- or multi-colour filament can be marked translucent. Full opacity drops the alpha channel again, keeping plain colours 6-digit for the API and for ecosystem consumers, and the native RGB picker no longer wipes an alpha that is already set. Swatches layer a translucent colour over a checkerboard so it reads as transparent rather than as a different, lighter colour. --- client_v2/locales/en/common.json | 1 + .../src/lib/components/ColorEditor.svelte | 174 +++++++++++++++--- client_v2/src/lib/utils/color.test.ts | 103 +++++++++++ client_v2/src/lib/utils/color.ts | 57 +++++- tests_frontend_v2/tests/transparency.spec.ts | 74 ++++++++ 5 files changed, 379 insertions(+), 30 deletions(-) create mode 100644 client_v2/src/lib/utils/color.test.ts create mode 100644 tests_frontend_v2/tests/transparency.spec.ts diff --git a/client_v2/locales/en/common.json b/client_v2/locales/en/common.json index bf0e2116..0b1336f4 100644 --- a/client_v2/locales/en/common.json +++ b/client_v2/locales/en/common.json @@ -146,6 +146,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..cb2206b6 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,33 @@ 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. + function alphaTrack(h: string): string { + const rgb = normalizeHex(h)?.slice(0, 6) ?? '9AA0A6'; + return `background:linear-gradient(to right,#${rgb}00,#${rgb}FF),${ALPHA_CHECKER}`; + } function addColor() { list.push(''); emit(); @@ -100,29 +128,62 @@
{#each list as c, i (i)}
- pick(i, e.currentTarget.value)} - aria-label={m['add.pickColor']()} - /> - setColor(i, e.currentTarget.value)} - placeholder="hex" - maxlength="9" - /> - {#if multi} - - {/if} + + + pick(i, e.currentTarget.value)} + aria-label={m['add.pickColor']()} + /> + setColor(i, e.currentTarget.value)} + placeholder="hex" + maxlength="9" + /> + {#if multi} + + {/if} + + + setOpacity(i, e.currentTarget.valueAsNumber)} + aria-label={m['filament.fields.opacity']()} + title={m['filament.fields.opacity']()} + /> + setOpacity(i, v)} + onclear={() => setOpacity(i, 100)} + /> +
{/each}
@@ -214,6 +275,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 +307,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 +321,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..5c10e5d5 100644 --- a/client_v2/src/lib/utils/color.ts +++ b/client_v2/src/lib/utils/color.ts @@ -3,6 +3,52 @@ 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. + */ +export const ALPHA_CHECKER = + 'conic-gradient(#cfcfcf 0 25%, #0000 0 50%, #cfcfcf 0 75%, #0000 0) 0 0/6px 6px, #fff'; + export function hexRgb(h: string): [number, number, number] { let s = (h || '#888').replace('#', ''); if (s.length === 3) { @@ -24,9 +70,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 +94,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"); + }); +}); From fb723f0af89cf5c8331fdf037f03d3d5e2eabd84 Mon Sep 17 00:00:00 2001 From: Donkie Date: Tue, 11 Aug 2026 22:35:15 +0200 Subject: [PATCH 2/2] client_v2: stop the alpha slider tiling colour under its border MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `background` shorthand resets background-origin to the padding box, so the slider's gradient was sized to the padding box but painted out under its 1px border — and backgrounds repeat, so the strip under the border showed the far edge of the next tile: a hairline of full-opacity colour down the transparent end. It surfaced wherever the border doesn't cover that strip exactly, i.e. Firefox at fractional display scaling. Anchor every layer to the border box, and keep the colour gradient from repeating at all. --- client_v2/src/lib/components/ColorEditor.svelte | 4 +++- client_v2/src/lib/utils/color.ts | 10 +++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/client_v2/src/lib/components/ColorEditor.svelte b/client_v2/src/lib/components/ColorEditor.svelte index cb2206b6..67b173bc 100644 --- a/client_v2/src/lib/components/ColorEditor.svelte +++ b/client_v2/src/lib/components/ColorEditor.svelte @@ -96,9 +96,11 @@ } // 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),${ALPHA_CHECKER}`; + return `background:linear-gradient(to right,#${rgb}00,#${rgb}FF) border-box no-repeat,${ALPHA_CHECKER}`; } function addColor() { list.push(''); diff --git a/client_v2/src/lib/utils/color.ts b/client_v2/src/lib/utils/color.ts index 5c10e5d5..dc1b3338 100644 --- a/client_v2/src/lib/utils/color.ts +++ b/client_v2/src/lib/utils/color.ts @@ -45,9 +45,17 @@ export function withAlpha(h: string, alpha: number): string { * 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, #fff'; + '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('#', '');