[object Object]

← back to Dw Pairs Well

ai-coordinates: rebuild generator — 24 real seamless patterns (3 width tiers × multiple styles + plaids), lightest+darkest required, ?tile_in slider, /render endpoint for editor

56de022760ce5bcaa88f1e674783da01f85d312a · 2026-05-13 13:35:33 -0700 · Steve Abrams

Files touched

Diff

commit 56de022760ce5bcaa88f1e674783da01f85d312a
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed May 13 13:35:33 2026 -0700

    ai-coordinates: rebuild generator — 24 real seamless patterns (3 width tiers × multiple styles + plaids), lightest+darkest required, ?tile_in slider, /render endpoint for editor
---
 lib/ai-stripes.js | 492 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 server.js         | 110 +++++-------
 2 files changed, 537 insertions(+), 65 deletions(-)

diff --git a/lib/ai-stripes.js b/lib/ai-stripes.js
new file mode 100644
index 0000000..b3614b5
--- /dev/null
+++ b/lib/ai-stripes.js
@@ -0,0 +1,492 @@
+// AI-generated coordinate patterns — stripes, plaids, checks.
+//
+// Hard requirements (Steve 2026-05-13):
+//   • Use 2-3 colors max from the source palette, never solid blocks.
+//   • Cover three width tiers: 2″, 6″, 12″ stripes.
+//   • Include unique styles: pinstripe, wavy, cabana, awning, hairline, etc.
+//   • One pattern MUST use the lightest + darkest palette colors only.
+//   • Seamless tiling is structural — every variant uses SVG <pattern>.
+//   • Client can slide the repeat width (tile_in inches). Each variant snaps
+//     tile_in DOWN to the nearest multiple of its pitch so the repeat always
+//     divides evenly — no half-stripes at the seam.
+//
+// All units in inches at the spec level; px = inches × (CANVAS_PX / tile_in).
+
+const { hexToLab, hexToRgb } = require('./color');
+
+const CANVAS_PX = 600;
+
+function luminance(hex) {
+  const lab = hexToLab(hex);
+  return lab ? lab.L : null;
+}
+
+function dedupe(arr) {
+  const seen = new Set(); const out = [];
+  for (const h of arr) {
+    const k = String(h || '').toLowerCase();
+    if (!h || seen.has(k)) continue;
+    seen.add(k); out.push(h);
+  }
+  return out;
+}
+
+function lightestAndDarkest(palette) {
+  const ranked = dedupe(palette)
+    .map(h => ({ hex: h, L: luminance(h) }))
+    .filter(x => x.L !== null)
+    .sort((a, b) => a.L - b.L);
+  if (ranked.length < 2) return null;
+  return { darkest: ranked[0].hex, lightest: ranked[ranked.length - 1].hex };
+}
+
+// Pick N visually distinct colors from the palette (greedy by ΔL).
+function pickN(palette, n, fallback) {
+  const clean = dedupe(palette);
+  if (clean.length >= n) return clean.slice(0, n);
+  while (clean.length < n) clean.push(fallback || '#cccccc');
+  return clean;
+}
+
+// Snap requested repeat (inches) DOWN to nearest multiple of pitchIn so the
+// pattern tiles seamlessly. Floor at one full cycle.
+function snapTile(tileIn, pitchIn) {
+  const t = Math.max(pitchIn, Math.floor(tileIn / pitchIn) * pitchIn);
+  return t;
+}
+
+function px(inches, tileIn) {
+  return inches * (CANVAS_PX / tileIn);
+}
+
+function escHex(h) {
+  return String(h || '#000').replace(/[^#0-9a-fA-F]/g, '');
+}
+
+// ─── Pattern body builders ──────────────────────────────────────────────────
+// Each returns { body, patternW, patternH } in PIXEL units relative to a
+// repeating pattern tile (the <pattern> element). The body fills exactly one
+// tile and the SVG pattern engine handles seamless repetition.
+
+function evenStripeBody(colors, pitchPx) {
+  // Vertical alternating stripes: one full A/B cycle = 2 × pitchPx wide.
+  // For 3-color rotations the cycle is N × pitchPx.
+  const n = colors.length;
+  const w = pitchPx * n;
+  let body = '';
+  for (let i = 0; i < n; i++) {
+    body += `<rect x="${i * pitchPx}" y="0" width="${pitchPx}" height="${CANVAS_PX}" fill="${escHex(colors[i])}"/>`;
+  }
+  return { body, patternW: w, patternH: CANVAS_PX };
+}
+
+function pinstripeBody({ fieldColor, pinColor, fieldPx, pinPx }) {
+  // field color the wide band, pinPx the thin contrast line, one cycle wide.
+  const w = fieldPx + pinPx;
+  const body =
+    `<rect x="0" y="0" width="${fieldPx}" height="${CANVAS_PX}" fill="${escHex(fieldColor)}"/>` +
+    `<rect x="${fieldPx}" y="0" width="${pinPx}" height="${CANVAS_PX}" fill="${escHex(pinColor)}"/>`;
+  return { body, patternW: w, patternH: CANVAS_PX };
+}
+
+function pinstripeTriBody({ fieldColor, pinAColor, pinBColor, fieldPx, pinPx, gapPx }) {
+  // field — pinA — small gap — pinB cycle.
+  const w = fieldPx + pinPx + gapPx + pinPx;
+  let x = 0; let body = '';
+  body += `<rect x="${x}" y="0" width="${fieldPx}" height="${CANVAS_PX}" fill="${escHex(fieldColor)}"/>`; x += fieldPx;
+  body += `<rect x="${x}" y="0" width="${pinPx}" height="${CANVAS_PX}" fill="${escHex(pinAColor)}"/>`;   x += pinPx;
+  body += `<rect x="${x}" y="0" width="${gapPx}" height="${CANVAS_PX}" fill="${escHex(fieldColor)}"/>`;  x += gapPx;
+  body += `<rect x="${x}" y="0" width="${pinPx}" height="${CANVAS_PX}" fill="${escHex(pinBColor)}"/>`;
+  return { body, patternW: w, patternH: CANVAS_PX };
+}
+
+function cabanaBody({ wideColor, thinColor, accentColor, widePx, thinPx }) {
+  // wide — thin — wide — thin(accent) cycle for visual interest.
+  const w = widePx + thinPx + widePx + thinPx;
+  let x = 0; let body = '';
+  body += `<rect x="${x}" y="0" width="${widePx}" height="${CANVAS_PX}" fill="${escHex(wideColor)}"/>`;  x += widePx;
+  body += `<rect x="${x}" y="0" width="${thinPx}" height="${CANVAS_PX}" fill="${escHex(thinColor)}"/>`;  x += thinPx;
+  body += `<rect x="${x}" y="0" width="${widePx}" height="${CANVAS_PX}" fill="${escHex(wideColor)}"/>`;  x += widePx;
+  body += `<rect x="${x}" y="0" width="${thinPx}" height="${CANVAS_PX}" fill="${escHex(accentColor || thinColor)}"/>`;
+  return { body, patternW: w, patternH: CANVAS_PX };
+}
+
+function wavyStripeBody({ colorA, colorB, pitchPx, ampPx, wavelengthPx }) {
+  // A two-color wavy stripe: draw a stripe whose right edge undulates as a
+  // sine wave. The pattern tile is (2 × pitchPx) wide × wavelengthPx tall so
+  // it tiles seamlessly in both axes.
+  const w = pitchPx * 2;
+  const h = wavelengthPx;
+  // Build the boundary path: down the right edge of the colorA region with a
+  // sinusoidal wobble, then back up the inside of the colorB region.
+  const steps = 24;
+  let dA = `M0,0 L${pitchPx},0 `;
+  for (let i = 1; i <= steps; i++) {
+    const y = (i / steps) * h;
+    const xOff = ampPx * Math.sin((i / steps) * 2 * Math.PI);
+    dA += `L${(pitchPx + xOff).toFixed(2)},${y.toFixed(2)} `;
+  }
+  dA += `L0,${h} Z`;
+  let dB = `M${pitchPx},0 L${w},0 L${w},${h} L${pitchPx},${h} `;
+  for (let i = steps; i >= 1; i--) {
+    const y = (i / steps) * h;
+    const xOff = ampPx * Math.sin((i / steps) * 2 * Math.PI);
+    dB += `L${(pitchPx + xOff).toFixed(2)},${y.toFixed(2)} `;
+  }
+  dB += 'Z';
+  const body =
+    `<path d="${dA}" fill="${escHex(colorA)}"/>` +
+    `<path d="${dB}" fill="${escHex(colorB)}"/>`;
+  return { body, patternW: w, patternH: h };
+}
+
+function awningBody({ colorA, colorB, widePx, narrowPx }) {
+  // wide A — narrow B — wide A — narrow B cycle (classic awning)
+  return cabanaBody({ wideColor: colorA, thinColor: colorB, accentColor: colorB, widePx, thinPx: narrowPx });
+}
+
+function hairlineBody({ fieldColor, lineColor, fieldPx, linePx }) {
+  // Many thin hairlines on a field — field — line — field — line cycle.
+  const w = fieldPx + linePx;
+  const body =
+    `<rect x="0" y="0" width="${fieldPx}" height="${CANVAS_PX}" fill="${escHex(fieldColor)}"/>` +
+    `<rect x="${fieldPx}" y="0" width="${linePx}" height="${CANVAS_PX}" fill="${escHex(lineColor)}"/>`;
+  return { body, patternW: w, patternH: CANVAS_PX };
+}
+
+// ─── Plaid / check builders ────────────────────────────────────────────────
+// Plaids cross vertical + horizontal bands. To avoid muddy alpha mixing we
+// use multiply-mode by drawing v-bands at 100%, then h-bands with a slightly
+// darker shade where they cross, computed in JS rather than via opacity.
+
+function tintMix(a, b, t) {
+  const ra = hexToRgb(a), rb = hexToRgb(b);
+  if (!ra || !rb) return a;
+  const mix = (x, y) => Math.round(x * (1 - t) + y * t);
+  const r = mix(ra.r, rb.r), g = mix(ra.g, rb.g), bch = mix(ra.b, rb.b);
+  return '#' + [r, g, bch].map(v => v.toString(16).padStart(2, '0')).join('');
+}
+
+function buffaloCheckBody({ colorA, colorB, pitchPx }) {
+  // Classic two-color buffalo: A, B, A, B grid. The intersection of B-band
+  // (vertical) and B-band (horizontal) is a darker mix.
+  const w = pitchPx * 2;
+  const h = pitchPx * 2;
+  const mid = tintMix(colorA, colorB, 0.85);
+  // Quadrants of the unit cell:
+  //   (0,0) = A         (pitch,0)   = A+B blend (light intersection)
+  //   (0,p) = A+B blend (pitch,p)   = B
+  // Simpler: top-left = A, top-right = B, bottom-left = B, bottom-right = mid
+  const body =
+    `<rect x="0" y="0" width="${pitchPx}" height="${pitchPx}" fill="${escHex(colorA)}"/>` +
+    `<rect x="${pitchPx}" y="0" width="${pitchPx}" height="${pitchPx}" fill="${escHex(colorB)}"/>` +
+    `<rect x="0" y="${pitchPx}" width="${pitchPx}" height="${pitchPx}" fill="${escHex(colorB)}"/>` +
+    `<rect x="${pitchPx}" y="${pitchPx}" width="${pitchPx}" height="${pitchPx}" fill="${escHex(mid)}"/>`;
+  return { body, patternW: w, patternH: h };
+}
+
+function ginghamBody({ colorA, colorB, pitchPx }) {
+  // Gingham: light field, medium horizontals + verticals, dark intersection.
+  const w = pitchPx * 2;
+  const h = pitchPx * 2;
+  const light = colorA;
+  const mid = tintMix(colorA, colorB, 0.55);
+  const dark = colorB;
+  const body =
+    `<rect x="0" y="0" width="${pitchPx}" height="${pitchPx}" fill="${escHex(light)}"/>` +
+    `<rect x="${pitchPx}" y="0" width="${pitchPx}" height="${pitchPx}" fill="${escHex(mid)}"/>` +
+    `<rect x="0" y="${pitchPx}" width="${pitchPx}" height="${pitchPx}" fill="${escHex(mid)}"/>` +
+    `<rect x="${pitchPx}" y="${pitchPx}" width="${pitchPx}" height="${pitchPx}" fill="${escHex(dark)}"/>`;
+  return { body, patternW: w, patternH: h };
+}
+
+function tattersallBody({ fieldColor, lineA, lineB, cellPx, linePx }) {
+  // Open grid: thin alternating lines over a field. cellPx = grid spacing.
+  const w = cellPx * 2;
+  const h = cellPx * 2;
+  let body = `<rect x="0" y="0" width="${w}" height="${h}" fill="${escHex(fieldColor)}"/>`;
+  // vertical lines at x=0 and x=cellPx
+  body += `<rect x="0" y="0" width="${linePx}" height="${h}" fill="${escHex(lineA)}"/>`;
+  body += `<rect x="${cellPx}" y="0" width="${linePx}" height="${h}" fill="${escHex(lineB)}"/>`;
+  // horizontal lines
+  body += `<rect x="0" y="0" width="${w}" height="${linePx}" fill="${escHex(lineA)}"/>`;
+  body += `<rect x="0" y="${cellPx}" width="${w}" height="${linePx}" fill="${escHex(lineB)}"/>`;
+  return { body, patternW: w, patternH: h };
+}
+
+function windowpaneBody({ fieldColor, lineColor, cellPx, linePx }) {
+  // Large open grid, single line color.
+  const body =
+    `<rect x="0" y="0" width="${cellPx}" height="${cellPx}" fill="${escHex(fieldColor)}"/>` +
+    `<rect x="0" y="0" width="${linePx}" height="${cellPx}" fill="${escHex(lineColor)}"/>` +
+    `<rect x="0" y="0" width="${cellPx}" height="${linePx}" fill="${escHex(lineColor)}"/>`;
+  return { body, patternW: cellPx, patternH: cellPx };
+}
+
+function madrasBody({ colors, pitchPx }) {
+  // Asymmetric 3-color plaid. Bands at widths [2, 1, 3, 1, 2, 3] × unit/12.
+  const unit = pitchPx;
+  const widthsV = [3, 1, 4, 1, 3].map(n => n * unit / 3);
+  const widthsH = [2, 1, 3, 1, 5].map(n => n * unit / 3);
+  const w = widthsV.reduce((s, x) => s + x, 0);
+  const h = widthsH.reduce((s, x) => s + x, 0);
+  let body = '';
+  // vertical bands
+  let x = 0;
+  widthsV.forEach((bw, i) => {
+    body += `<rect x="${x}" y="0" width="${bw}" height="${h}" fill="${escHex(colors[i % colors.length])}"/>`;
+    x += bw;
+  });
+  // horizontal bands at 60% blend with bottom of stack
+  let y = 0;
+  widthsH.forEach((bh, i) => {
+    const c = colors[(i + 1) % colors.length];
+    body += `<rect x="0" y="${y}" width="${w}" height="${bh}" fill="${escHex(c)}" opacity="0.55"/>`;
+    y += bh;
+  });
+  return { body, patternW: w, patternH: h };
+}
+
+function tartanBody({ colors, pitchPx }) {
+  // Crossed tartan in 3 colors, symmetric. Bands [4,1,2,1,4] × unit/6.
+  const unit = pitchPx;
+  const widths = [4, 1, 2, 1, 4].map(n => n * unit / 6);
+  const w = widths.reduce((s, x) => s + x, 0);
+  const h = w;
+  let body = '';
+  let x = 0;
+  widths.forEach((bw, i) => {
+    body += `<rect x="${x}" y="0" width="${bw}" height="${h}" fill="${escHex(colors[i % colors.length])}"/>`;
+    x += bw;
+  });
+  let y = 0;
+  widths.forEach((bh, i) => {
+    const c = colors[i % colors.length];
+    body += `<rect x="0" y="${y}" width="${w}" height="${bh}" fill="${escHex(c)}" opacity="0.5"/>`;
+    y += bh;
+  });
+  return { body, patternW: w, patternH: h };
+}
+
+function glenPlaidBody({ colorA, colorB, pitchPx }) {
+  // Dense Glen plaid: 2px lines, 4-cell repeat with shifted offset.
+  const w = pitchPx * 4;
+  const h = pitchPx * 4;
+  let body = `<rect x="0" y="0" width="${w}" height="${h}" fill="${escHex(colorA)}"/>`;
+  const linePx = Math.max(1, Math.round(pitchPx * 0.25));
+  for (let i = 0; i < 4; i++) {
+    body += `<rect x="${i * pitchPx}" y="0" width="${linePx}" height="${h}" fill="${escHex(colorB)}"/>`;
+    body += `<rect x="0" y="${i * pitchPx}" width="${w}" height="${linePx}" fill="${escHex(colorB)}"/>`;
+  }
+  return { body, patternW: w, patternH: h };
+}
+
+function boxPlaidBody({ colors, pitchPx }) {
+  // 3-color uniform box plaid.
+  const w = pitchPx * 3;
+  const h = pitchPx * 3;
+  let body = '';
+  for (let i = 0; i < 3; i++) {
+    body += `<rect x="${i * pitchPx}" y="0" width="${pitchPx}" height="${h}" fill="${escHex(colors[i % colors.length])}"/>`;
+  }
+  for (let j = 0; j < 3; j++) {
+    body += `<rect x="0" y="${j * pitchPx}" width="${w}" height="${pitchPx}" fill="${escHex(colors[(j + 1) % colors.length])}" opacity="0.55"/>`;
+  }
+  return { body, patternW: w, patternH: h };
+}
+
+// ─── Top-level catalog ──────────────────────────────────────────────────────
+
+// Returns an array of variant SPECS — pure data, no SVG yet. The renderer
+// turns each into an SVG poster at a given tile_in.
+function variantCatalog(palette) {
+  const ld = lightestAndDarkest(palette);
+  // Always have something to fall back to even when palette has 1 color
+  const fallback = palette[0] || '#999999';
+  const darkest  = ld ? ld.darkest  : fallback;
+  const lightest = ld ? ld.lightest : (palette[1] || tintMix(fallback, '#ffffff', 0.7));
+  const top3     = pickN(palette, 3, fallback);
+  const top2     = pickN(palette, 2, fallback);
+
+  return [
+    // ─── Lightest+Darkest required slot ─────────────────────────────────────
+    { id: 1, motif: 'stripe', style: 'even',       label: '2″ Stripe — Lightest + Darkest', pitch_in: 2,  colors: [lightest, darkest], colorRoles: 'lightest+darkest' },
+    { id: 2, motif: 'stripe', style: 'even',       label: '6″ Stripe — Lightest + Darkest', pitch_in: 6,  colors: [lightest, darkest], colorRoles: 'lightest+darkest' },
+    { id: 3, motif: 'stripe', style: 'even',       label: '12″ Cabana — Lightest + Darkest', pitch_in: 12, colors: [lightest, darkest], colorRoles: 'lightest+darkest' },
+
+    // ─── Even stripes (palette-driven) ──────────────────────────────────────
+    { id: 4, motif: 'stripe', style: 'even',       label: '2″ Stripe — Top 2',  pitch_in: 2,  colors: top2 },
+    { id: 5, motif: 'stripe', style: 'even',       label: '6″ Stripe — Top 2',  pitch_in: 6,  colors: top2 },
+    { id: 6, motif: 'stripe', style: 'triple',     label: '2″ Triple — Top 3',  pitch_in: 2,  colors: top3 },
+
+    // ─── Pinstripes ─────────────────────────────────────────────────────────
+    { id: 7, motif: 'stripe', style: 'pinstripe',      label: 'Pinstripe — light field, dark pin',   pitch_in: 2, colors: [lightest, darkest], pinstripe: { field_in: 1.75, pin_in: 0.25 } },
+    { id: 8, motif: 'stripe', style: 'pinstripe-pair', label: 'Twin Pinstripe — 3 colors',           pitch_in: 3, colors: top3,                pinstripe: { field_in: 2.5,  pin_in: 0.125, gap_in: 0.25 } },
+
+    // ─── Cabana / awning ────────────────────────────────────────────────────
+    { id: 9,  motif: 'stripe', style: 'cabana',  label: 'Cabana — 6″ wide + 0.5″ accent',   pitch_in: 6,  colors: top3 },
+    { id: 10, motif: 'stripe', style: 'awning',  label: 'Awning — 12″ + 2″ alt.',           pitch_in: 12, colors: [lightest, darkest] },
+
+    // ─── Wavy / hairline / block ────────────────────────────────────────────
+    { id: 11, motif: 'stripe', style: 'wavy',     label: 'Wavy Stripe — 6″ undulating',  pitch_in: 6, colors: top2 },
+    { id: 12, motif: 'stripe', style: 'wavy',     label: 'Wavy Stripe — 2″ tight',       pitch_in: 2, colors: top2 },
+    { id: 13, motif: 'stripe', style: 'hairline', label: 'Hairline — 4″ field, 0.25″ line', pitch_in: 4, colors: top2 },
+    { id: 14, motif: 'stripe', style: 'block',    label: '12″ Block — Light + Dark',     pitch_in: 12, colors: [lightest, darkest] },
+    { id: 15, motif: 'stripe', style: 'even',     label: '6″ Triple — 3 colors',         pitch_in: 6, colors: top3 },
+    { id: 16, motif: 'stripe', style: 'pinstripe',label: 'Pinstripe — dark field, light pin', pitch_in: 2, colors: [darkest, lightest], pinstripe: { field_in: 1.75, pin_in: 0.25 } },
+
+    // ─── Plaids / checks ────────────────────────────────────────────────────
+    { id: 17, motif: 'plaid',   style: 'buffalo',     label: 'Buffalo Check 6″ — Light + Dark', pitch_in: 6,  colors: [lightest, darkest] },
+    { id: 18, motif: 'plaid',   style: 'gingham',     label: 'Gingham 2″ — Light + Dark',       pitch_in: 2,  colors: [lightest, darkest] },
+    { id: 19, motif: 'plaid',   style: 'tattersall',  label: 'Tattersall — 4″ grid, 3 colors',  pitch_in: 4,  colors: top3 },
+    { id: 20, motif: 'plaid',   style: 'windowpane',  label: 'Windowpane 12″',                  pitch_in: 12, colors: [lightest, darkest] },
+    { id: 21, motif: 'plaid',   style: 'madras',      label: 'Madras — 3 colors',               pitch_in: 6,  colors: top3 },
+    { id: 22, motif: 'tartan',  style: 'tartan',      label: 'Tartan 12″ — 3 colors',           pitch_in: 12, colors: top3 },
+    { id: 23, motif: 'plaid',   style: 'glen',        label: 'Glen Plaid 2″ — Light + Dark',    pitch_in: 2,  colors: [lightest, darkest] },
+    { id: 24, motif: 'plaid',   style: 'box',         label: 'Box Plaid 6″ — 3 colors',         pitch_in: 6,  colors: top3 }
+  ];
+}
+
+function renderVariant(v, requestedTileIn) {
+  // Snap repeat so the unit cell tiles evenly. Each pattern has a "cycle" of
+  // pitch_in that the tile_in must be a multiple of.
+  const cycleIn = (v.style === 'pinstripe-pair') ? v.pitch_in
+                : (v.style === 'cabana')          ? v.pitch_in * 2 + 1        // wide+thin+wide+thin
+                : (v.style === 'awning')          ? v.pitch_in + 2
+                : v.pitch_in * (v.colors ? v.colors.length : 2);
+  // Most patterns tile fine at any tile_in that's a multiple of pitch_in.
+  // Use pitch_in as the snap unit for simplicity and predictability.
+  const tileIn = snapTile(requestedTileIn, v.pitch_in);
+  const pitchPx = px(v.pitch_in, tileIn);
+
+  let body, patternW, patternH;
+  if (v.motif === 'stripe' && v.style === 'even') {
+    ({ body, patternW, patternH } = evenStripeBody(v.colors, pitchPx));
+  } else if (v.motif === 'stripe' && v.style === 'triple') {
+    ({ body, patternW, patternH } = evenStripeBody(v.colors.slice(0, 3), pitchPx));
+  } else if (v.motif === 'stripe' && v.style === 'pinstripe') {
+    const fieldPx = px(v.pinstripe.field_in, tileIn);
+    const pinPx   = px(v.pinstripe.pin_in,   tileIn);
+    ({ body, patternW, patternH } = pinstripeBody({
+      fieldColor: v.colors[0], pinColor: v.colors[1], fieldPx, pinPx
+    }));
+  } else if (v.motif === 'stripe' && v.style === 'pinstripe-pair') {
+    const fieldPx = px(v.pinstripe.field_in, tileIn);
+    const pinPx   = px(v.pinstripe.pin_in,   tileIn);
+    const gapPx   = px(v.pinstripe.gap_in,   tileIn);
+    ({ body, patternW, patternH } = pinstripeTriBody({
+      fieldColor: v.colors[0], pinAColor: v.colors[1], pinBColor: v.colors[2],
+      fieldPx, pinPx, gapPx
+    }));
+  } else if (v.motif === 'stripe' && v.style === 'cabana') {
+    ({ body, patternW, patternH } = cabanaBody({
+      wideColor: v.colors[0], thinColor: v.colors[1], accentColor: v.colors[2] || v.colors[1],
+      widePx: pitchPx, thinPx: px(0.5, tileIn)
+    }));
+  } else if (v.motif === 'stripe' && v.style === 'awning') {
+    ({ body, patternW, patternH } = awningBody({
+      colorA: v.colors[0], colorB: v.colors[1], widePx: pitchPx, narrowPx: px(2, tileIn)
+    }));
+  } else if (v.motif === 'stripe' && v.style === 'wavy') {
+    ({ body, patternW, patternH } = wavyStripeBody({
+      colorA: v.colors[0], colorB: v.colors[1],
+      pitchPx, ampPx: pitchPx * 0.18, wavelengthPx: pitchPx * 4
+    }));
+  } else if (v.motif === 'stripe' && v.style === 'hairline') {
+    ({ body, patternW, patternH } = hairlineBody({
+      fieldColor: v.colors[0], lineColor: v.colors[1],
+      fieldPx: pitchPx, linePx: Math.max(2, px(0.25, tileIn))
+    }));
+  } else if (v.motif === 'stripe' && v.style === 'block') {
+    ({ body, patternW, patternH } = evenStripeBody([v.colors[0], v.colors[1]], pitchPx));
+  } else if (v.style === 'buffalo') {
+    ({ body, patternW, patternH } = buffaloCheckBody({ colorA: v.colors[0], colorB: v.colors[1], pitchPx }));
+  } else if (v.style === 'gingham') {
+    ({ body, patternW, patternH } = ginghamBody({ colorA: v.colors[0], colorB: v.colors[1], pitchPx }));
+  } else if (v.style === 'tattersall') {
+    ({ body, patternW, patternH } = tattersallBody({
+      fieldColor: v.colors[0], lineA: v.colors[1], lineB: v.colors[2],
+      cellPx: pitchPx, linePx: Math.max(2, px(0.25, tileIn))
+    }));
+  } else if (v.style === 'windowpane') {
+    ({ body, patternW, patternH } = windowpaneBody({
+      fieldColor: v.colors[0], lineColor: v.colors[1],
+      cellPx: pitchPx, linePx: Math.max(2, px(0.375, tileIn))
+    }));
+  } else if (v.style === 'madras') {
+    ({ body, patternW, patternH } = madrasBody({ colors: v.colors, pitchPx }));
+  } else if (v.style === 'tartan') {
+    ({ body, patternW, patternH } = tartanBody({ colors: v.colors, pitchPx }));
+  } else if (v.style === 'glen') {
+    ({ body, patternW, patternH } = glenPlaidBody({ colorA: v.colors[0], colorB: v.colors[1], pitchPx }));
+  } else if (v.style === 'box') {
+    ({ body, patternW, patternH } = boxPlaidBody({ colors: v.colors, pitchPx }));
+  } else {
+    ({ body, patternW, patternH } = evenStripeBody(v.colors, pitchPx));
+  }
+
+  const patternId = 'p' + v.id + '_' + Math.round(tileIn);
+  const svg =
+    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${CANVAS_PX} ${CANVAS_PX}" width="${CANVAS_PX}" height="${CANVAS_PX}">` +
+      `<defs><pattern id="${patternId}" patternUnits="userSpaceOnUse" width="${patternW}" height="${patternH}">${body}</pattern></defs>` +
+      `<rect width="${CANVAS_PX}" height="${CANVAS_PX}" fill="url(#${patternId})"/>` +
+    `</svg>`;
+
+  return {
+    image_url: 'data:image/svg+xml;utf8,' + encodeURIComponent(svg),
+    tile_in_used: tileIn
+  };
+}
+
+function generateCoordinates({ palette, tile_in }) {
+  const t = Math.max(2, Math.min(60, Number(tile_in) || 24));
+  const cat = variantCatalog(palette);
+  return cat.map(v => {
+    const r = renderVariant(v, t);
+    return {
+      id: `ai-${v.id}`,
+      motif: v.motif,
+      style: v.style,
+      title: v.label,
+      pitch_in: v.pitch_in,
+      tile_in: r.tile_in_used,
+      colors: v.colors,
+      palette: v.colors,
+      color_roles: v.colorRoles || null,
+      image_url: r.image_url,
+      why: [v.colorRoles ? v.colorRoles : `${v.pitch_in}″ ${v.style}`],
+      provenance: 'ai-generated (seamless pattern)',
+      status: 'placeholder',
+      url: null
+    };
+  });
+}
+
+// Render a single arbitrary spec — used by the pattern editor (Wix-style
+// admin UI). Spec is the same shape as a catalog entry: { motif, style,
+// pitch_in, colors, pinstripe?, label? }. Returns { image_url, tile_in }.
+function renderCustom(spec, tile_in) {
+  const v = {
+    id: 'custom',
+    motif: spec.motif || 'stripe',
+    style: spec.style || 'even',
+    label: spec.label || (spec.style || 'even') + ' ' + (spec.pitch_in || 6) + '"',
+    pitch_in: Math.max(0.25, Math.min(36, Number(spec.pitch_in) || 6)),
+    colors: (Array.isArray(spec.colors) ? spec.colors : []).filter(Boolean).slice(0, 5),
+    pinstripe: spec.pinstripe || { field_in: 1.75, pin_in: 0.25, gap_in: 0.25 },
+    colorRoles: spec.colorRoles || null
+  };
+  if (v.colors.length === 0) v.colors = ['#888888'];
+  const t = Math.max(2, Math.min(60, Number(tile_in) || 24));
+  const r = renderVariant(v, t);
+  return {
+    motif: v.motif,
+    style: v.style,
+    pitch_in: v.pitch_in,
+    tile_in: r.tile_in_used,
+    colors: v.colors,
+    image_url: r.image_url
+  };
+}
+
+module.exports = { generateCoordinates, renderCustom };
diff --git a/server.js b/server.js
index 86a4f42..096c185 100644
--- a/server.js
+++ b/server.js
@@ -5,6 +5,7 @@ const path = require('path');
 const { parseTags, classify, score } = require('./lib/classify');
 const { hexToLab, deltaE2000, safeParseHexArray } = require('./lib/color');
 const { nearestPaintsBatch } = require('./lib/paint');
+const { generateCoordinates, renderCustom } = require('./lib/ai-stripes');
 
 const PORT = Number(process.env.PORT || 9799);
 const DATABASE_URL = process.env.DATABASE_URL;
@@ -396,50 +397,16 @@ app.get('/api/pairs', async (req, res) => {
 
 // ============================================================
 //  /api/ai-coordinates — AI-generated coordinate designs
-//  Returns N stripe/plaid concepts seeded from the source palette.
-//  In dev/preview these are gradient posters generated server-side from the
-//  palette (so the page never looks broken). When wallco-ai's
-//  stripe-generation endpoint is wired in, swap the SVG body for the
-//  generated PNG urls — the rest of the contract stays identical.
+//  Returns 24 stripe + plaid concepts seeded from the source palette using
+//  lib/ai-stripes.js. Three width tiers (2″, 6″, 12″) × multiple styles
+//  (even, pinstripe, cabana, wavy, awning, hairline, block) + 8 plaids
+//  (buffalo, gingham, tattersall, windowpane, madras, tartan, glen, box).
+//  Three slots use the lightest+darkest palette pair (Steve's standing rule).
+//  Seamless tiling is structural — every variant uses SVG <pattern>.
+//  Client can slide the repeat width via ?tile_in=N (12-48 inches); each
+//  variant snaps tile_in DOWN to its pitch so the repeat always fits.
 // ============================================================
 
-function svgPoster({ palette, motif, idx }) {
-  const colors = (palette || []).filter(Boolean).slice(0, 6);
-  if (!colors.length) colors.push('#cccccc');
-  function rotate(arr, n) { return arr.slice(n).concat(arr.slice(0, n)); }
-  const seq = rotate(colors, idx % colors.length);
-  const w = 600, h = 600;
-
-  let body = '';
-  if (motif === 'plaid' || motif === 'tartan' || motif === 'check') {
-    // Two-axis bands so they cross.
-    const bands = Math.max(4, Math.min(10, colors.length * 2));
-    const step = w / bands;
-    for (let i = 0; i < bands; i++) {
-      const c = seq[i % seq.length];
-      body += `<rect x="${i*step}" y="0" width="${step}" height="${h}" fill="${c}" opacity="0.65"/>`;
-    }
-    for (let j = 0; j < bands; j++) {
-      const c = seq[(j + 2) % seq.length];
-      body += `<rect x="0" y="${j*step}" width="${w}" height="${step}" fill="${c}" opacity="0.55"/>`;
-    }
-  } else {
-    // Stripe — vary stripe widths so 24 results don't look identical.
-    const widthSeed = (idx % 4) + 1;
-    let x = 0;
-    let i = 0;
-    while (x < w) {
-      const stripeW = (15 + ((i + widthSeed) % 5) * 18);
-      const c = seq[i % seq.length];
-      body += `<rect x="${x}" y="0" width="${stripeW}" height="${h}" fill="${c}"/>`;
-      x += stripeW;
-      i++;
-    }
-  }
-  const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}">${body}</svg>`;
-  return 'data:image/svg+xml;utf8,' + encodeURIComponent(svg);
-}
-
 app.get('/api/ai-coordinates', async (req, res) => {
   try {
     const dw_sku  = req.query.dw_sku && SAFE_KEY.test(req.query.dw_sku) ? req.query.dw_sku : null;
@@ -448,7 +415,7 @@ app.get('/api/ai-coordinates', async (req, res) => {
     if (!dw_sku && !handle && paletteParam.length === 0) {
       return res.status(400).json({ ok: false, error: 'pass dw_sku, handle, or palette' });
     }
-    const k = Math.max(1, Math.min(24, parseInt(req.query.limit, 10) || 24));
+    const tile_in = Math.max(2, Math.min(60, parseFloat(req.query.tile_in) || 24));
 
     let source, palette;
     if (paletteParam.length) {
@@ -463,32 +430,12 @@ app.get('/api/ai-coordinates', async (req, res) => {
     }
     if (!palette.length) return res.json({ ok: true, source: { dw_sku: source.dw_sku, handle: source.handle }, items: [] });
 
-    // 16 stripes + 8 plaids = 24 by default; ordering shuffles so the strongest
-    // colorway-permutations land first.
-    const MOTIFS = [
-      'stripe','stripe','stripe','stripe','stripe','stripe','stripe','stripe',
-      'stripe','stripe','stripe','stripe','stripe','stripe','stripe','stripe',
-      'plaid','plaid','plaid','plaid','tartan','tartan','check','check'
-    ].slice(0, k);
-
-    const items = MOTIFS.map((motif, idx) => {
-      // Cycle the palette so each card uses a different starting color.
-      const cardPalette = palette.slice(idx % palette.length).concat(palette.slice(0, idx % palette.length));
-      return {
-        id: `ai-${idx + 1}`,
-        motif: motif,
-        title: `AI ${motif} • colorway ${idx + 1}`,
-        palette: cardPalette,
-        image_url: svgPoster({ palette: cardPalette, motif, idx }),
-        url: null,                       // not a product yet — needs Steve approval before catalog ingest
-        provenance: 'ai-generated (preview poster)',
-        status: 'placeholder'
-      };
-    });
+    const items = generateCoordinates({ palette, tile_in });
 
     res.set('Cache-Control', 'public, max-age=300');
     res.json({
       ok: true,
+      tile_in: tile_in,
       source: {
         dw_sku: source.dw_sku,
         handle: source.handle,
@@ -504,6 +451,39 @@ app.get('/api/ai-coordinates', async (req, res) => {
   }
 });
 
+// Render ONE pattern from a custom user-edited spec. Used by the Wix-style
+// pattern editor in the wallco.ai admin UI. Accepts:
+//   GET  /api/ai-coordinates/render?motif=stripe&style=even&pitch_in=6&colors=#000,#fff&tile_in=24
+//   POST /api/ai-coordinates/render  body: { motif, style, pitch_in, colors:[], tile_in, pinstripe? }
+app.use('/api/ai-coordinates/render', express.json({ limit: '8kb' }));
+function handleRender(req, res) {
+  try {
+    const src = (req.method === 'POST' ? req.body : req.query) || {};
+    const colors = Array.isArray(src.colors)
+      ? src.colors
+      : String(src.colors || '').split(',').map(s => s.trim()).filter(Boolean);
+    const spec = {
+      motif: String(src.motif || 'stripe').slice(0, 20),
+      style: String(src.style || 'even').slice(0, 24),
+      pitch_in: parseFloat(src.pitch_in) || 6,
+      colors: colors.filter(h => /^#?[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/.test(h))
+                    .map(h => h.startsWith('#') ? h : '#' + h)
+                    .slice(0, 5),
+      pinstripe: src.pinstripe || undefined,
+      label: src.label ? String(src.label).slice(0, 120) : undefined
+    };
+    const tile_in = parseFloat(src.tile_in) || 24;
+    const rendered = renderCustom(spec, tile_in);
+    res.set('Cache-Control', 'no-store');
+    res.json({ ok: true, ...rendered });
+  } catch (e) {
+    console.error('ai-coordinates/render error', e);
+    res.status(500).json({ ok: false, error: e.message });
+  }
+}
+app.get('/api/ai-coordinates/render', handleRender);
+app.post('/api/ai-coordinates/render', handleRender);
+
 app.listen(PORT, () => {
   console.log(`dw-pairs-well listening on :${PORT}`);
 });

← c7d0bfc CORS_ORIGIN: add wallco.ai (+ www variants, local dev) — wal  ·  back to Dw Pairs Well  ·  preview.html: surface attempted key in fetch-failure UI + es 386eea8 →