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 @@
{#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 +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"); + }); +});