Browse Source

UI/multi color handling (#1205)

Martha Augsburger 3 weeks ago
parent
commit
7baf1caf75

+ 88 - 18
frontend/src/__tests__/components/FilamentSwatch.test.tsx

@@ -13,7 +13,7 @@ import { buildFilamentBackground } from '../../components/filamentSwatchHelpers'
 
 describe('FilamentSwatch', () => {
   it('renders a solid swatch when only rgba is set', () => {
-    render(<FilamentSwatch rgba="ff0000ff" />);
+    render(<FilamentSwatch rgba="ff0000ff" effectSize='table' />);
     const el = screen.getByTestId('filament-swatch');
     // Solid swatches are emitted as a 1-stop linear-gradient so the
     // checkerboard layer below is still visible through alpha.
@@ -23,13 +23,13 @@ describe('FilamentSwatch', () => {
   });
 
   it('falls back to grey when nothing is set', () => {
-    render(<FilamentSwatch />);
+    render(<FilamentSwatch effectSize='table' />);
     const el = screen.getByTestId('filament-swatch');
     expect(el.style.backgroundImage.toLowerCase()).toContain('#808080');
   });
 
   it('renders a linear gradient when extraColors has multiple stops', () => {
-    render(<FilamentSwatch rgba="ff0000ff" extraColors="ec984c,6cd4bc,a66eb9,d87694" />);
+    render(<FilamentSwatch rgba="ff0000ff" extraColors="ec984c,6cd4bc,a66eb9,d87694" effectSize='table' />);
     const el = screen.getByTestId('filament-swatch');
     const bg = el.style.backgroundImage.toLowerCase();
     // Linear (not conic) for non-Multicolor subtype.
@@ -46,6 +46,7 @@ describe('FilamentSwatch', () => {
         rgba="ff0000ff"
         extraColors="ec984c,6cd4bc,a66eb9"
         subtype="Multicolor"
+        effectSize='table'
       />,
     );
     const el = screen.getByTestId('filament-swatch');
@@ -55,13 +56,13 @@ describe('FilamentSwatch', () => {
   it('also uses conic-gradient when effectType is multicolor (catalog path)', () => {
     // Catalog entries don't have a `subtype`, so the multicolor effect_type
     // value also has to trigger conic rendering for parity with the spool path.
-    render(<FilamentSwatch extraColors="ec984c,6cd4bc,a66eb9" effectType="multicolor" />);
+    render(<FilamentSwatch extraColors="ec984c,6cd4bc,a66eb9" effectType="multicolor"  effectSize='table' />);
     const el = screen.getByTestId('filament-swatch');
     expect(el.style.backgroundImage.toLowerCase()).toMatch(/conic-gradient/);
   });
 
   it('layers an effect overlay on top of the colour layer for sparkle', () => {
-    render(<FilamentSwatch rgba="ff0000ff" effectType="sparkle" />);
+    render(<FilamentSwatch rgba="ff0000ff" effectType="sparkle" effectSize='table' />);
     const el = screen.getByTestId('filament-swatch');
     // Sparkle overlay is built from radial-gradient layers — confirm at least
     // one is in the composed background, ahead of the colour layer.
@@ -70,7 +71,7 @@ describe('FilamentSwatch', () => {
 
   it('renders an overlay for silk variant', () => {
     // Silk gets a soft sheen overlay (added in #1154 follow-up).
-    render(<FilamentSwatch rgba="ff0000ff" effectType="silk" />);
+    render(<FilamentSwatch rgba="ff0000ff" effectType="silk" effectSize='table' />);
     const el = screen.getByTestId('filament-swatch');
     expect(el.style.backgroundImage).toMatch(/linear-gradient/);
   });
@@ -78,7 +79,7 @@ describe('FilamentSwatch', () => {
   it('treats categorical-only variants (gradient/dual-color) as labels without an overlay', () => {
     // No extra_colors set → swatch falls back to the solid colour layer; the
     // categorical effect value alone does not paint a sheen overlay.
-    render(<FilamentSwatch rgba="ff0000ff" effectType="gradient" />);
+    render(<FilamentSwatch rgba="ff0000ff" effectType="gradient" effectSize='table' />);
     const el = screen.getByTestId('filament-swatch');
     // No radial-gradient (sparkle/glow) and no rainbow/sheen overlay either —
     // gradient/dual-color/tri-color are pure labels until extra_colors is set.
@@ -86,13 +87,13 @@ describe('FilamentSwatch', () => {
   });
 
   it('ignores unknown effect types instead of throwing', () => {
-    render(<FilamentSwatch rgba="ff0000ff" effectType="not-a-real-variant" />);
+    render(<FilamentSwatch rgba="ff0000ff" effectType="not-a-real-variant" effectSize='table' />);
     const el = screen.getByTestId('filament-swatch');
     expect(el.style.backgroundImage).not.toMatch(/radial-gradient/);
   });
 
   it('renders a checkerboard underneath so alpha is visible', () => {
-    render(<FilamentSwatch rgba="ff000080" />);
+    render(<FilamentSwatch rgba="ff000080" effectSize='table' />);
     const el = screen.getByTestId('filament-swatch');
     // The component always appends a checkerboard layer last so semi-
     // transparent rgba values actually look transparent to the user.
@@ -100,7 +101,7 @@ describe('FilamentSwatch', () => {
   });
 
   it('skips invalid hex tokens in extraColors instead of throwing', () => {
-    render(<FilamentSwatch extraColors="ff0000,not-hex,00ff00" />);
+    render(<FilamentSwatch extraColors="ff0000,not-hex,00ff00" effectSize='table' />);
     const el = screen.getByTestId('filament-swatch');
     const bg = el.style.backgroundImage.toLowerCase();
     // The two valid stops survive; the garbage token is dropped.
@@ -110,7 +111,7 @@ describe('FilamentSwatch', () => {
   });
 
   it('uses extra_colors for the title fallback when provided', () => {
-    render(<FilamentSwatch extraColors="ff0000,00ff00" />);
+    render(<FilamentSwatch extraColors="ff0000,00ff00" effectSize='table' />);
     const el = screen.getByTestId('filament-swatch');
     // Tooltip should show the comma-joined hex stops, not the (unset) rgba.
     expect(el.title.toLowerCase()).toContain('#ff0000');
@@ -132,6 +133,7 @@ describe('dual-color / tri-color hard-split bars (#1154 follow-up)', () => {
     const bg = buildFilamentBackground({
       extraColors: '7f3696,006ec9',
       effectType: 'dual-color',
+      effectSize: 'table',
     });
     const lower = bg.backgroundImage.toLowerCase();
     // Hard split direction — ``to right`` (or ``90deg``), never ``135deg``.
@@ -150,6 +152,7 @@ describe('dual-color / tri-color hard-split bars (#1154 follow-up)', () => {
     const bg = buildFilamentBackground({
       extraColors: 'ff0000,00ff00,0000ff',
       effectType: 'tri-color',
+      effectSize: 'table',
     });
     const lower = bg.backgroundImage.toLowerCase();
     expect(lower).toContain('to right');
@@ -163,6 +166,7 @@ describe('dual-color / tri-color hard-split bars (#1154 follow-up)', () => {
     const bg = buildFilamentBackground({
       extraColors: '7f3696,006ec9',
       effectType: 'gradient',
+      effectSize: 'table',
     });
     const lower = bg.backgroundImage.toLowerCase();
     // Original visual preserved for non-dual / non-tri stops.
@@ -182,22 +186,23 @@ describe('dual-color / tri-color hard-split bars (#1154 follow-up)', () => {
     const dual = buildFilamentBackground({
       extraColors: '7f3696,006ec9',
       effectType: 'dual-color',
+      effectSize: 'table',
     });
     const grad = buildFilamentBackground({
       extraColors: '7f3696,006ec9',
       effectType: 'gradient',
+      effectSize: 'table',
     });
     expect(dual.backgroundImage).not.toBe(grad.backgroundImage);
   });
 });
 
 describe('Sparkle prominence + checkerboard density (#1154 follow-up cosmetic)', () => {
-  it('renders Sparkle with at least 10 distinct dots so it reads on card-sized swatches', () => {
+  it('renders dense sparkle on card preset (at least 10 dots)', () => {
     // The original Sparkle pattern was 4 dots — too subtle on a 200×60px
-    // banner. The fix bumps it to 13 mixed-size flecks. Pin the contract
-    // at "at least 10" so future tweaks have headroom without the test
-    // breaking on every adjustment.
-    render(<FilamentSwatch rgba="ff0000ff" effectType="sparkle" />);
+    // banner. Now we use situation-aware dot counts: more dots for larger presets. 
+    // Verify the card preset produces a dense pattern with at least 10 dots.
+    render(<FilamentSwatch rgba="ff0000ff" effectType="sparkle" effectSize="card" />);
     const el = screen.getByTestId('filament-swatch');
     const radialCount = (el.style.backgroundImage.match(/radial-gradient/g) ?? []).length;
     expect(radialCount).toBeGreaterThanOrEqual(10);
@@ -208,12 +213,76 @@ describe('Sparkle prominence + checkerboard density (#1154 follow-up cosmetic)',
     // gradient over the whole element and a card-sized banner only showed
     // 4 huge cells. Verify the checker layer carries an explicit pixel
     // tile size.
-    const bg = buildFilamentBackground({ rgba: 'ff0000ff' });
+    const bg = buildFilamentBackground({ rgba: 'ff0000ff', effectSize: 'table' });
     const sizes = bg.backgroundSize.split(',').map((s) => s.trim());
     // Last layer is the checker; should be a fixed pixel tile, not 'cover'.
     expect(sizes[sizes.length - 1]).toMatch(/^\d+px(\s+\d+px)?$/);
     expect(sizes[sizes.length - 1]).not.toContain('cover');
   });
+
+  it('limits sparkle dot count per size preset (table/card/bar)', () => {
+    const tableBg = buildFilamentBackground({
+      rgba: 'ff0000ff',
+      effectType: 'sparkle',
+      effectSize: 'table',
+    });
+    const cardBg = buildFilamentBackground({
+      rgba: 'ff0000ff',
+      effectType: 'sparkle',
+      effectSize: 'card',
+    });
+    const barBg = buildFilamentBackground({
+      rgba: 'ff0000ff',
+      effectType: 'sparkle',
+      effectSize: 'bar',
+    });
+
+    const countRadial = (css: string) => (css.match(/radial-gradient/g) ?? []).length;
+    expect(countRadial(tableBg.backgroundImage)).toBe(5);
+    expect(countRadial(cardBg.backgroundImage)).toBe(40);
+    expect(countRadial(barBg.backgroundImage)).toBe(20);
+  });
+
+  it('scales sparkle dot radii by size preset while keeping seeded output deterministic', () => {
+    const tableBg = buildFilamentBackground({
+      rgba: 'ff0000ff',
+      effectType: 'sparkle',
+      effectSize: 'table',
+    });
+    const barBg = buildFilamentBackground({
+      rgba: 'ff0000ff',
+      effectType: 'sparkle',
+      effectSize: 'bar',
+    });
+
+    const tableBgRepeat = buildFilamentBackground({
+      rgba: 'ff0000ff',
+      effectType: 'sparkle',
+      effectSize: 'table',
+    });
+
+    const tableBgOther = buildFilamentBackground({
+      rgba: '00ff00ff',
+      effectType: 'sparkle',
+      effectSize: 'table',
+    });
+
+    // Same seed must produce byte-identical overlay output.
+    expect(tableBg.backgroundImage).toBe(tableBgRepeat.backgroundImage);
+
+    // Different seeds must produce different overlay output.
+    expect(tableBg.backgroundImage).not.toBe(tableBgOther.backgroundImage);
+
+    // Radius grows for bar preset, preventing sparse-looking large banners.
+    // Extract the first radius from each CSS string by looking for "0 Xpx, transparent Ypx"
+    const tableR = tableBg.backgroundImage.match(/0[ ]+(\d+\.?\d*)px[,][ ]*transparent[ ]+(\d+\.?\d*)px/);
+    const barR = barBg.backgroundImage.match(/0[ ]+(\d+\.?\d*)px[,][ ]*transparent[ ]+(\d+\.?\d*)px/);
+    if (!tableR || !barR) {
+      throw new Error(`Failed to extract radii: tableR=${tableR}, barR=${barR}`);
+    }
+    expect(Number(tableR[1])).toBeLessThan(Number(barR[1]));
+    expect(Number(tableR[2])).toBeLessThan(Number(barR[2]));
+  });
 });
 
 describe('buildFilamentBackground', () => {
@@ -222,6 +291,7 @@ describe('buildFilamentBackground', () => {
       rgba: 'ff0000ff',
       extraColors: 'aabbcc,ddeeff',
       effectType: 'matte',
+      effectSize: 'table',
     });
     // Effect overlay → colour layer → checkerboard, in that order.
     expect(bg.backgroundImage).toMatch(/linear-gradient/);
@@ -237,7 +307,7 @@ describe('buildFilamentBackground', () => {
   });
 
   it('returns a usable solid background when only rgba is provided', () => {
-    const bg = buildFilamentBackground({ rgba: '00ff00ff' });
+    const bg = buildFilamentBackground({ rgba: '00ff00ff', effectSize: 'table' });
     expect(bg.backgroundImage.toLowerCase()).toContain('#00ff00ff');
     expect(bg.backgroundImage).toMatch(/repeating-conic-gradient/);
   });

+ 1 - 0
frontend/src/components/ColorCatalogSettings.tsx

@@ -640,6 +640,7 @@ export function ColorCatalogSettings() {
                               className="w-8 h-8"
                               shape="square"
                               title={entry.hex_color}
+                              effectSize="table"
                             />
                           </td>
                           <td className="px-3 py-2 text-white">{entry.manufacturer}</td>

+ 11 - 25
frontend/src/components/FilamentSwatch.tsx

@@ -1,11 +1,9 @@
 import React, { useMemo } from 'react';
 import {
-  CHECKERBOARD_BG,
-  CHECKERBOARD_TILE_SIZE,
-  EFFECT_OVERLAYS,
-  buildColorLayer,
   parseStops,
+  buildFilamentBackground,
   type FilamentEffect,
+  type SwatchType,
 } from './filamentSwatchHelpers';
 
 /** Shared filament-colour swatch. See `filamentSwatchHelpers.ts` for the
@@ -28,6 +26,8 @@ export interface FilamentSwatchProps {
   style?: React.CSSProperties;
   /** Native title attribute for hover tooltip. */
   title?: string;
+  /** Tune effect appearance based on target div size. */
+  effectSize: SwatchType;
 }
 
 export function FilamentSwatch({
@@ -39,30 +39,16 @@ export function FilamentSwatch({
   shape = 'circle',
   style,
   title,
+  effectSize,
 }: FilamentSwatchProps) {
   const stops = useMemo(() => parseStops(extraColors), [extraColors]);
-  const colorLayer = useMemo(
-    () => buildColorLayer(rgba, stops, subtype, effectType),
-    [rgba, stops, subtype, effectType],
-  );
-
-  const effectKey =
-    typeof effectType === 'string' && effectType in EFFECT_OVERLAYS
-      ? (effectType as FilamentEffect)
-      : null;
-  const effectLayer = effectKey ? EFFECT_OVERLAYS[effectKey] ?? null : null;
 
-  // Layer order (top → bottom): effect overlay → colour layer → checkerboard.
-  // Per-layer background-size: 'cover' on the painted layers, fixed tile on
-  // the checkerboard so its cell density doesn't scale with element size
-  // (a card-sized swatch with `cover` checker would render only 4 huge
-  // cells; #1154 follow-up).
-  const layers: { image: string; size: string }[] = [];
-  if (effectLayer) layers.push({ image: effectLayer, size: 'cover' });
-  layers.push({ image: colorLayer, size: 'cover' });
-  layers.push({ image: CHECKERBOARD_BG, size: CHECKERBOARD_TILE_SIZE });
-  const backgroundImage = layers.map((l) => l.image).join(', ');
-  const backgroundSize = layers.map((l) => l.size).join(', ');
+  const filamentBackground = useMemo(
+    () => buildFilamentBackground({ effectSize, rgba, extraColors, effectType, subtype }),
+    [effectSize, rgba, extraColors, effectType, subtype]
+  );
+  const backgroundImage = filamentBackground.backgroundImage;
+  const backgroundSize = filamentBackground.backgroundSize;
 
   const shapeClass =
     shape === 'circle' ? 'rounded-full' : shape === 'pill' ? 'rounded-full' : 'rounded';

+ 74 - 34
frontend/src/components/filamentSwatchHelpers.ts

@@ -1,3 +1,5 @@
+import { hash_fnv1a32, random_mulberry32 } from '../utils/random';
+
 /* Enhanced filament-colour rendering helpers (#1154).
  *
  * Pure (non-component) exports that drive `<FilamentSwatch>` and any caller
@@ -37,6 +39,22 @@ export type FilamentEffect =
   | 'tri-color'
   | 'multicolor';
 
+/** Intended target swatch type for effect rendering. */
+export type SwatchType = 'table' | 'preview' | 'card' | 'bar' | 'groupheader';
+export type EffectLayer = string | string[];
+
+/** Presets for the different swatch types */
+export const SWATCH_TYPE_PRESETS: Readonly<Record<SwatchType, {
+  dotCount: number;
+  dotScale: number;
+}>> = {
+  table: { dotCount: 5, dotScale: 1 },
+  preview: { dotCount: 8, dotScale: 1.5 },
+  card: { dotCount: 40, dotScale: 2 },
+  bar: { dotCount: 20, dotScale: 2 },
+  groupheader: { dotCount: 80, dotScale: 2 },
+};
+
 /** Public list of all known effect/variant values, in display order. Shared
  *  by the spool form's ColorSection dropdown and the colour-catalog editor
  *  so the two stay in lockstep. Each value pairs with an i18n key under
@@ -76,7 +94,7 @@ export const FILAMENT_EFFECT_OPTIONS: ReadonlyArray<{
 // follow-up reporter feedback). Per-layer sizing is supported by every
 // modern browser via comma-separated ``background-size``.
 export const CHECKERBOARD_BG =
-  'repeating-conic-gradient(#cbcbcb 0% 25%, #f5f5f5 0% 50%)';
+  'repeating-conic-gradient(#979797 0% 25%, #f5f5f5 0% 50%)';
 export const CHECKERBOARD_TILE_SIZE = '12px 12px';
 
 /** Optional CSS overlay layer for variants that have a visual treatment.
@@ -84,48 +102,47 @@ export const CHECKERBOARD_TILE_SIZE = '12px 12px';
  *  an overlay, just sit in the data. `multicolor` is special: its visual
  *  effect is to switch the colour layer to a conic-gradient (see
  *  `buildColorLayer`), not to add an overlay layer. */
-export const EFFECT_OVERLAYS: Partial<Record<FilamentEffect, string>> = {
-  // Sparkle: bright flecks scattered across the swatch. The original 4-dot
-  // pattern was too subtle on card-sized swatches (#1154 follow-up); 13
-  // dots in mixed sizes (1px / 1.5px / 2px) and opacities give depth and
-  // make the variant clearly distinguishable from solid + multicolor.
-  sparkle:
-    'radial-gradient(circle at 12% 18%, rgba(255,255,255,0.95) 0 1.5px, transparent 2px), ' +
-    'radial-gradient(circle at 28% 42%, rgba(255,255,255,0.85) 0 1px, transparent 1.5px), ' +
-    'radial-gradient(circle at 38% 78%, rgba(255,255,255,0.95) 0 1.5px, transparent 2px), ' +
-    'radial-gradient(circle at 52% 12%, rgba(255,255,255,0.80) 0 1px, transparent 1.5px), ' +
-    'radial-gradient(circle at 58% 55%, rgba(255,255,255,1) 0 2px, transparent 2.5px), ' +
-    'radial-gradient(circle at 68% 28%, rgba(255,255,255,0.75) 0 1px, transparent 1.5px), ' +
-    'radial-gradient(circle at 75% 88%, rgba(255,255,255,0.85) 0 1px, transparent 1.5px), ' +
-    'radial-gradient(circle at 82% 48%, rgba(255,255,255,0.95) 0 1.5px, transparent 2px), ' +
-    'radial-gradient(circle at 88% 18%, rgba(255,255,255,0.80) 0 1px, transparent 1.5px), ' +
-    'radial-gradient(circle at 92% 70%, rgba(255,255,255,0.85) 0 1px, transparent 1.5px), ' +
-    'radial-gradient(circle at 18% 62%, rgba(255,255,255,0.75) 0 1px, transparent 1.5px), ' +
-    'radial-gradient(circle at 45% 32%, rgba(255,255,255,0.65) 0 0.8px, transparent 1.2px), ' +
-    'radial-gradient(circle at 65% 72%, rgba(255,255,255,0.65) 0 0.8px, transparent 1.2px)',
+export const EFFECT_OVERLAYS: Partial<
+  Record<FilamentEffect, (effectSeed?: number, effectSize?: SwatchType) => EffectLayer>
+> = {
+  // Sparkle: bright flecks — positions seeded from spool color+extracolors+subtype+effectType.
+  // to give identical spools the same sparkle pattern while different spools get different patterns. 
+  sparkle: (spoolSeed = 0, effectSize = 'table') => {
+    const rand = random_mulberry32(spoolSeed);
+    const preset = SWATCH_TYPE_PRESETS[effectSize] ?? SWATCH_TYPE_PRESETS.table;
+    const sparks: string[] = [];
+    for (let i = 0; i < preset.dotCount; i++) {
+      const x = rand.intBetween(1, 99);
+      const y = rand.intBetween(1, 99);
+      const s = rand.floatBetween(1.0, preset.dotScale);
+      const a = rand.floatBetween(0.65, 1.0);
+      sparks.push(`radial-gradient(circle at ${x}% ${y}%, rgba(255,248,220,${a}) 0 ${s/2}px, transparent ${s}px)`);
+    }
+    return sparks;
+  },
   // Wood: subtle horizontal banding to mimic grain.
-  wood:
+  wood: () =>
     'repeating-linear-gradient(90deg, ' +
     'rgba(0,0,0,0.18) 0 1px, transparent 1px 6px, ' +
     'rgba(0,0,0,0.08) 6px 7px, transparent 7px 12px)',
   // Marble: soft diagonal swirls.
-  marble:
+  marble: () =>
     'repeating-linear-gradient(135deg, rgba(255,255,255,0.18) 0 2px, transparent 2px 8px), ' +
     'repeating-linear-gradient(45deg, rgba(0,0,0,0.10) 0 1px, transparent 1px 7px)',
   // Glow: bright center fade — visual hint for glow-in-the-dark filaments.
-  glow:
+  glow: () =>
     'radial-gradient(circle at 50% 50%, rgba(255,255,255,0.35) 0%, rgba(255,255,255,0) 70%)',
   // Matte: very subtle inset shadow to flatten the highlight.
-  matte:
+  matte: () =>
     'linear-gradient(180deg, rgba(0,0,0,0.10) 0%, rgba(0,0,0,0) 50%, rgba(0,0,0,0.10) 100%)',
   // Silk / Galaxy: diagonal sheen to suggest the lustrous finish those
   // filaments have. Galaxy uses a slightly stronger highlight.
-  silk:
+  silk: () =>
     'linear-gradient(110deg, rgba(255,255,255,0) 30%, rgba(255,255,255,0.30) 50%, rgba(255,255,255,0) 70%)',
-  galaxy:
+  galaxy: () =>
     'linear-gradient(110deg, rgba(255,255,255,0) 25%, rgba(255,255,255,0.40) 50%, rgba(255,255,255,0) 75%)',
   // Metal: brushed-metal look via tight horizontal striations + soft sheen.
-  metal:
+  metal: () =>
     'repeating-linear-gradient(90deg, rgba(255,255,255,0.10) 0 1px, transparent 1px 3px), ' +
     'linear-gradient(180deg, rgba(255,255,255,0.18) 0%, rgba(0,0,0,0.18) 100%)',
 };
@@ -174,7 +191,15 @@ export function buildColorLayer(
   const subtypeLower = (subtype ?? '').toLowerCase();
   const effectLower = (effectType ?? '').toLowerCase();
   if (subtypeLower === 'multicolor' || effectLower === 'multicolor') {
-    return `conic-gradient(from 0deg, ${allStops.join(', ')}, ${allStops[0]})`;
+    const n = allStops.length;
+    const segments = allStops
+      .map((c, i) => {
+        const start = ((i / n) * 360).toFixed(3);
+        const end = (((i + 1) / n) * 360).toFixed(3);
+        return `${c} ${start}deg ${end}deg`;
+      })
+      .join(', ');
+    return `conic-gradient(from 0deg, ${segments})`;
   }
   if (effectLower === 'dual-color' || effectLower === 'tri-color') {
     // Equal-width hard-split bars: each stop occupies its own contiguous
@@ -194,6 +219,16 @@ export function buildColorLayer(
   return `linear-gradient(135deg, ${allStops.join(', ')})`;
 }
 
+/** Resolve the CSS overlay string for an effect key. */
+export function resolveEffectOverlay(
+  effectKey: string,
+  effectSize: SwatchType,
+  effectSeed?: number,
+): EffectLayer | null {
+  const fn = EFFECT_OVERLAYS[effectKey as FilamentEffect];
+  return fn ? fn(effectSeed, effectSize) : null;
+}
+
 /** Public helper: produce a CSS background-image value (list of layered
  *  <image>s) for a filament, for callers that want to paint a banner or
  *  large area instead of using the swatch element. Returns a
@@ -203,6 +238,7 @@ export function buildColorLayer(
  *  card-sized banner only shows 4 huge checker cells.
  */
 export function buildFilamentBackground(opts: {
+  effectSize: SwatchType;
   rgba?: string | null;
   extraColors?: string | null;
   effectType?: FilamentEffect | string | null;
@@ -210,18 +246,22 @@ export function buildFilamentBackground(opts: {
 }): { backgroundImage: string; backgroundSize: string } {
   const stops = parseStops(opts.extraColors);
   const colorLayer = buildColorLayer(opts.rgba, stops, opts.subtype, opts.effectType);
-  const effectKey =
-    typeof opts.effectType === 'string' && opts.effectType in EFFECT_OVERLAYS
-      ? (opts.effectType as FilamentEffect)
+  const effectSeed = hash_fnv1a32(opts.rgba, opts.extraColors, opts.subtype, opts.effectType);
+  const effectLayer =
+    typeof opts.effectType === 'string'
+      ? resolveEffectOverlay(opts.effectType, opts.effectSize, effectSeed)
       : null;
-  const effectLayer = effectKey ? EFFECT_OVERLAYS[effectKey] ?? null : null;
-
   // Layer order (top → bottom): effect overlay → colour layer → checkerboard.
   // Per-layer background-size: 'cover' on the painted layers (so they fill
   // the element) and the fixed tile size on the checkerboard so the cell
   // count scales with the element rather than the element scaling the cells.
   const layers: { image: string; size: string }[] = [];
-  if (effectLayer) layers.push({ image: effectLayer, size: 'cover' });
+  if (effectLayer) {
+    const effectImages = Array.isArray(effectLayer) ? effectLayer : [effectLayer];
+    effectImages.forEach((image) => {
+      layers.push({ image, size: 'cover' });
+    });
+  }
   layers.push({ image: colorLayer, size: 'cover' });
   layers.push({ image: CHECKERBOARD_BG, size: CHECKERBOARD_TILE_SIZE });
 

+ 2 - 0
frontend/src/components/spool-form/ColorSection.tsx

@@ -193,6 +193,7 @@ export function ColorSection({
         extraColors: formData.extra_colors,
         effectType: formData.effect_type,
         subtype: formData.subtype,
+        effectSize: 'bar',
       }),
     [formData.rgba, formData.extra_colors, formData.effect_type, formData.subtype],
   );
@@ -443,6 +444,7 @@ export function ColorSection({
               extraColors={formData.extra_colors}
               effectType={formData.effect_type}
               subtype={formData.subtype}
+              effectSize="preview"
               className="w-10 h-10"
               shape="square"
             />

+ 3 - 0
frontend/src/pages/InventoryPage.tsx

@@ -189,6 +189,7 @@ const columnCells: Record<string, (ctx: CellCtx) => ReactNode> = {
         rgba={spool.rgba}
         extraColors={spool.extra_colors}
         effectType={spool.effect_type}
+        effectSize="table"
         subtype={spool.subtype}
       />
     </div>
@@ -1300,6 +1301,7 @@ function InventoryPage() {
                     extraColors: rep.extra_colors,
                     effectType: rep.effect_type,
                     subtype: rep.subtype,
+                    effectSize: 'groupheader',
                   });
                   const isExpanded = expandedGroups.has(key);
                   return (
@@ -1682,6 +1684,7 @@ function SpoolCard({
     extraColors: spool.extra_colors,
     effectType: spool.effect_type,
     subtype: spool.subtype,
+    effectSize: 'card',
   });
   return (
     <div

+ 80 - 0
frontend/src/utils/random.ts

@@ -0,0 +1,80 @@
+const FNV1A_32_OFFSET_BASIS = 0x811c9dc5;
+const FNV1A_32_PRIME = 0x01000193;
+
+/**
+ * Computes a fast 32-bit FNV-1a hash for deterministic, non-security tasks.
+ * Accepts any number of string/nullable-string inputs, takes measurements 
+ * to avoid collisions, and combines them into a 32-bit hash.
+ * Not cryptographically secure; use only for non-security-related use cases.
+*/
+export function hash_fnv1a32(...input: Array<string | null | undefined>): number {
+    let hash = FNV1A_32_OFFSET_BASIS;
+    const textEncoder = new TextEncoder();
+    const emptyElement = textEncoder.encode('__|');
+    for (const element of input) {
+        if (typeof element === 'string') {
+            hash = fnv1a32_update(hash, textEncoder.encode(element+'|'));
+        } else if (element === null || element === undefined) {
+            hash = fnv1a32_update(hash, emptyElement);
+        }
+    }
+    return hash >>> 0;
+}
+
+
+function fnv1a32_update(hash: number, value: Uint8Array): number {
+    for (const byte of value) {
+        hash ^= byte;
+        hash = Math.imul(hash, FNV1A_32_PRIME) >>> 0;
+    }
+    return hash;
+}
+
+export interface Mulberry32Sequence {
+    next(): number;
+    intBetween(from: number, to: number): number;
+    floatBetween(from: number, to: number): number;
+}
+
+/**
+ * Creates a fast deterministic PRNG sequence using Mulberry32.
+ * Same seed will always produce the same sequence. 
+ * Not cryptographically secure; use only for non-security-related use cases.
+ */
+export function random_mulberry32(seed: number): Mulberry32Sequence {
+    const nextUint32 = (): number => {
+        seed |= 0;
+        seed = seed + 0x6D2B79F5 | 0;
+        let imul = Math.imul(seed ^ seed >>> 15, 1 | seed);
+        imul = imul + Math.imul(imul ^ imul >>> 7, 61 | imul) ^ imul;
+        return (imul ^ imul >>> 14) >>> 0;
+    };
+
+    const nextNormalized = (from: number, to: number): number => {
+        if (!Number.isFinite(from) || !Number.isFinite(to)) {
+            throw new RangeError('from and to must be finite numbers');
+        }
+        if (from > to) {
+            throw new RangeError('from must be less than or equal to to');
+        }
+        if (from === to) {
+            return from;
+        }
+        return from + nextUint32() / 0xFFFFFFFF * (to - from);
+    };
+
+    return {
+        next: () => {
+            return nextUint32() / 0xFFFFFFFF;
+        },
+        floatBetween: (from: number, to: number): number => {
+            return nextNormalized(from, to);
+        },
+        intBetween: (from: number, to: number): number => {
+            if (!Number.isInteger(from) || !Number.isInteger(to)) {
+                throw new RangeError('from and to must be integers');
+            }
+            return Math.round(nextNormalized(from, to));
+        },
+    };
+}

+ 204 - 0
scripts/fill_spool_effects.py

@@ -0,0 +1,204 @@
+#!/usr/bin/env python3
+"""Seed Bambuddy with a quick visual set of filament effect spools.
+
+Usage:
+    python scripts/fill_spool_effects.py --bambuddy-url http://localhost:8000
+    python scripts/fill_spool_effects.py --bambuddy-url http://localhost:8000 --api-key YOUR_KEY
+
+This script creates stock spools for every effect type defined in the `SPOOLS` list below,
+using the bulk endpoint for creation:
+    POST /api/v1/inventory/spools/bulk
+"""
+
+from __future__ import annotations
+
+import argparse
+import sys
+from dataclasses import dataclass
+
+import requests
+
+
+API_PATH_BULK_CREATE = "/api/v1/inventory/spools/bulk"
+
+@dataclass(frozen=True)
+class TestSpool:
+    "Class representing a spool definition and count for the test spool set"
+    effect_type: str
+    colors: dict[str, str]
+    quantity: int = 1
+
+
+SPOOLS: list[TestSpool] = [
+    TestSpool(
+        effect_type="sparkle",
+        colors={"dodger blue": "1E90FFFF"},
+        quantity=2,
+    ),
+    TestSpool(
+        effect_type="sparkle",
+        colors={"dark red": "8B0000FF"},
+    ),
+    TestSpool(
+        effect_type="wood",
+        colors={"brown": "A47251FF"},
+    ),
+    TestSpool(
+        effect_type="marble",
+        colors={"slate": "4F5D75FF"},
+    ),
+    TestSpool(
+        effect_type="glow",
+        colors={"mint-pop": "6CD4BCFF"},
+    ),
+    TestSpool(
+        effect_type="matte",
+        colors={"charcoal": "2B2D42FF"},
+    ),
+    TestSpool(
+        effect_type="silk",
+        colors={"rose": "FF8FA3FF"},
+    ),
+    TestSpool(
+        effect_type="galaxy",
+        colors={"indigo": "4361EEFF"},
+    ),
+    TestSpool(
+        effect_type="rainbow",
+        colors={"amber": "FFBF69FF"},
+    ),
+    TestSpool(
+        effect_type="metal",
+        colors={"petrol-blue": "2D9CDBFF"},
+    ),
+    TestSpool(
+        effect_type="translucent",
+        colors={"cream": "FFF3E2AA"},
+    ),
+    TestSpool(
+        effect_type="gradient",
+        colors={
+            "dark olive green": "556B2FFF",
+            "goldenrod": "DAA520FF",
+        },
+    ),
+    TestSpool(
+        effect_type="dual-color",
+        colors={
+            "plum": "7B2CBFFF",
+            "saffron": "F2C94CFF",
+        },
+    ),
+    TestSpool(
+        effect_type="tri-color",
+        colors={
+            "coral": "FF6B6BFF",
+            "seafoam": "80ED99FF",
+            "indigo": "4361EEFF",
+        },
+    ),
+    TestSpool(
+        effect_type="multicolor",
+        colors={
+            "sunset-orange": "EC984CFF",
+            "mint-pop": "6CD4BCFF",
+            "violet-bloom": "A66EB9FF",
+            "raspberry-dawn": "D87694FF",
+        },
+    ),
+    TestSpool(
+        effect_type="multicolor",
+        colors={
+            "deep-navy": "1B1F3BFF",
+            "cream": "FFF3E2FF",
+            "rose": "FF8FA3FF",
+            "teal": "2EC4B6FF",
+            "amber": "FFBF69FF",
+        },
+    ),
+]
+
+
+def build_spool_payload(variant: TestSpool) -> dict:
+    "Function to build the payload for a single spool variant"
+    color_values = list(variant.colors.values())
+    color_names = list(variant.colors.keys())
+    return {
+        "material": "PLA",
+        "subtype": variant.effect_type,
+        "brand": "Generic",
+        "color_name": ", ".join(color_names),
+        "rgba": color_values[0],
+        "extra_colors": ",".join(color_values),
+        "effect_type": variant.effect_type,
+        "label_weight": 1000,
+        "core_weight": 250,
+        "weight_used": 0,
+        "core_weight_catalog_id": None,
+        "slicer_filament": None,
+        "slicer_filament_name": None,
+        "nozzle_temp_min": None,
+        "nozzle_temp_max": None,
+        "note": "Dev effect overview seed",
+        "cost_per_kg": None,
+        "category": None,
+        "low_stock_threshold_pct": None,
+    }
+
+
+def create_bulk_spools(
+    bambuddy_url: str,
+    spool_data: dict,
+    quantity: int,
+    api_key: str | None,
+    timeout: int,
+) -> list[dict]:
+    "Function that creates multiple spools using the bulk API endpoint and returns the list of created spools"
+    url = f"{bambuddy_url.rstrip('/')}{API_PATH_BULK_CREATE}"
+    headers: dict[str, str] = {}
+    if api_key:
+        headers["X-API-Key"] = api_key
+    payload = {"spool": spool_data, "quantity": quantity}
+    resp = requests.post(url, json=payload, headers=headers, timeout=timeout)
+    resp.raise_for_status()
+    data = resp.json()
+    if not isinstance(data, list):
+        raise ValueError("Bulk endpoint returned unexpected response format: expected a list of created spools")
+    return data
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description="Create development stock spools for every effect type")
+    parser.add_argument("--bambuddy-url", required=True, help="Bambuddy URL (e.g. http://localhost:8000)")
+    parser.add_argument("--api-key", help="Bambuddy API key (required if auth is enabled)")
+    parser.add_argument("--timeout", type=int, default=30, help="HTTP timeout in seconds (default: 30)")
+    args = parser.parse_args()
+
+    created = 0
+    failed = 0
+
+    for variant in SPOOLS:
+        payload = build_spool_payload(variant)
+        try:
+            created_spools = create_bulk_spools(
+                args.bambuddy_url,
+                payload,
+                quantity=variant.quantity,
+                api_key=args.api_key,
+                timeout=args.timeout,
+            )
+            ids = [str(item.get("id", "?")) for item in created_spools]
+            print(
+                f"  Created effect={variant.effect_type:<11} qty={len(created_spools)} "
+                f"ids={','.join(ids)}"
+            )
+            created += len(created_spools)
+        except (requests.RequestException, ValueError) as exc:
+            print(f"  FAILED effect={variant.effect_type}: {exc}", file=sys.stderr)
+            failed += variant.quantity
+
+    print(f"\nDone: {created} created, {failed} failed")
+
+
+if __name__ == "__main__":
+    main()

+ 0 - 11
test_backend.sh

@@ -1,11 +0,0 @@
-#!/bin/sh
-
-cd backend
-ruff check && ruff format --check
-
-#if [ "$1" = "--full" ]; then
-../venv/bin/python3 -m pytest tests/ -v -n 30
-#else
-#../venv/bin/python3 -m pytest tests/ -v -n 30 --ignore=tests/unit/services/test_bambu_ftp.py
-#fi
-#cd ..

+ 0 - 7
test_frontend.sh

@@ -1,7 +0,0 @@
-#!/bin/sh
-
-cd frontend
-npx tsc
-npm run lint
-npm run test:run
-cd ..