Merge pull request #1067 from Donkie/worktree-issue-1059-transparency

client_v2: bring back translucent filament colors
This commit is contained in:
Donkie authored and GitHub committed 2026-08-11 22:43:53 +02:00
commit e9b06d91c7
5 files changed
+389 -30

No files matched your search

+1
View File
@@ -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",
+148 -28
View File
@@ -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 @@
<div class="rows">
{#each list as c, i (i)}
<div class="color-row">
<input
class="color-pick"
type="color"
value={pickerValue(c)}
oninput={(e) => pick(i, e.currentTarget.value)}
aria-label={m['add.pickColor']()}
/>
<input
class="mono hex"
value={c}
oninput={(e) => setColor(i, e.currentTarget.value)}
placeholder="hex"
maxlength="9"
/>
{#if multi}
<button
class="rm"
type="button"
onclick={() => removeColor(i)}
disabled={list.length <= 1}
aria-label={m['filament.fields.removeColor']()}><X size={14} /></button
>
{/if}
<!-- Colour and opacity are grouped so that a narrow pane wraps the
opacity controls as a whole, instead of stranding the remove
button on a line of its own. -->
<span class="color-main">
<input
class="color-pick"
type="color"
value={pickerValue(c)}
oninput={(e) => pick(i, e.currentTarget.value)}
aria-label={m['add.pickColor']()}
/>
<input
class="mono hex"
value={c}
oninput={(e) => setColor(i, e.currentTarget.value)}
placeholder="hex"
maxlength="9"
/>
{#if multi}
<button
class="rm"
type="button"
onclick={() => removeColor(i)}
disabled={list.length <= 1}
aria-label={m['filament.fields.removeColor']()}><X size={14} /></button
>
{/if}
</span>
<span class="alpha">
<input
class="alpha-slider"
type="range"
min="0"
max="100"
step="1"
value={opacityOf(c)}
disabled={!hasColor(c)}
style={alphaTrack(c)}
oninput={(e) => setOpacity(i, e.currentTarget.valueAsNumber)}
aria-label={m['filament.fields.opacity']()}
title={m['filament.fields.opacity']()}
/>
<NumberInput
dense
width="84px"
unit="%"
min={0}
max={100}
step={5}
value={opacityOf(c)}
disabled={!hasColor(c)}
ariaLabel={m['filament.fields.opacity']()}
onchange={(v) => setOpacity(i, v)}
onclear={() => setOpacity(i, 100)}
/>
</span>
</div>
{/each}
</div>
@@ -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;
+103
View File
@@ -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]);
});
});
+63 -2
View File
@@ -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 {
@@ -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");
});
});