mirror of
https://github.com/Donkie/Spoolman.git
synced 2026-10-23 10:32:19 -05:00
Merge pull request #1067 from Donkie/worktree-issue-1059-transparency
client_v2: bring back translucent filament colors
This commit is contained in:
5 files changed
+389
-30
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user