[object Object]

← back to Wallco Ai

feat: admin design ratings + RLHF-lite retry on /design/:id

8e98a62fb031ea30cfb4e15e7aa2db07279958a1 · 2026-05-11 22:01:06 -0700 · Steve Abrams

Three-part 1-10 admin rating (color / style / composition) that feeds the
generator on retry. Admin can override prior ratings — every rating is a
new row so the override stack is auditable.

Schema:
- design_ratings (PG): design_id, color/style/composition 1-10,
  spacing 0-100, invert bool, notes, retry_count, child_design_id,
  rated_by, created_at. Indexed on (design_id, created_at DESC).

Endpoints (all admin-gated by hostname=127.0.0.1/localhost OR ?review=1):
- POST /api/design/:id/rate    — append a new rating row
- GET  /api/design/:id/ratings — full history (override stack on top)
- POST /api/design/:id/retry   — pulls latest rating, builds prompt nudges
  from scores (low=corrective, high=preserve), respects spacing slider
  (tighter ⟷ wider) + invert flag, spawns generate_designs.js detached
  with the corrected prompt. Returns the nudges + final_prompt so admin
  sees what the LLM is being told.

Nudge logic (RLHF-lite):
- color  ≤ 4: 'refined palette, tonal harmony, no clashing hues'
- color  ≥ 8: 'preserve color palette tightly'
- style  ≤ 4: 'cohesive single-era style statement, commit to one tradition'
- style  ≥ 8: 'preserve stylistic direction'
- comp   ≤ 4: 'rebalance composition, clearer focal point, deliberate negative space'
- comp   ≥ 8: 'preserve composition'
- spacing ≤35: 'tighter element packing, denser repeat'
- spacing ≥65: 'more breathing room between motifs, generous spacing'
- invert:     'invert dominant and recessive palette roles'

UI on /design/:id (admin-only block):
- Three 1-10 sliders for color/style/composition (default 7), live readouts
- Spacing slider 0-100 with 'tighter ⟷ wider' label, default 50
- Invert palette checkbox
- Notes textarea
- 'Save rating' (no retry) + 'Save + try again' (saves then queues retry)
- Status line shows result + the nudges the model received
- Expandable rating-history with override stack, retry counts, notes

Infra fix:
- ecosystem.config.js had PORT=9905 but selfadhesivewallpaper already binds
  127.0.0.1:9905. Per the canonical 'pm2 wallco-ai-viewer :9792' memory rule,
  switched back to :9792. The two-listener-on-same-port silent failure mode
  is exactly the trap the 'check ports before claiming' rule warns about.

Smoke-tested live: rate / list / retry / admin-gate all green. HTML page
contains all 5 expected DOM hooks.

Files touched

Diff

commit 8e98a62fb031ea30cfb4e15e7aa2db07279958a1
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon May 11 22:01:06 2026 -0700

    feat: admin design ratings + RLHF-lite retry on /design/:id
    
    Three-part 1-10 admin rating (color / style / composition) that feeds the
    generator on retry. Admin can override prior ratings — every rating is a
    new row so the override stack is auditable.
    
    Schema:
    - design_ratings (PG): design_id, color/style/composition 1-10,
      spacing 0-100, invert bool, notes, retry_count, child_design_id,
      rated_by, created_at. Indexed on (design_id, created_at DESC).
    
    Endpoints (all admin-gated by hostname=127.0.0.1/localhost OR ?review=1):
    - POST /api/design/:id/rate    — append a new rating row
    - GET  /api/design/:id/ratings — full history (override stack on top)
    - POST /api/design/:id/retry   — pulls latest rating, builds prompt nudges
      from scores (low=corrective, high=preserve), respects spacing slider
      (tighter ⟷ wider) + invert flag, spawns generate_designs.js detached
      with the corrected prompt. Returns the nudges + final_prompt so admin
      sees what the LLM is being told.
    
    Nudge logic (RLHF-lite):
    - color  ≤ 4: 'refined palette, tonal harmony, no clashing hues'
    - color  ≥ 8: 'preserve color palette tightly'
    - style  ≤ 4: 'cohesive single-era style statement, commit to one tradition'
    - style  ≥ 8: 'preserve stylistic direction'
    - comp   ≤ 4: 'rebalance composition, clearer focal point, deliberate negative space'
    - comp   ≥ 8: 'preserve composition'
    - spacing ≤35: 'tighter element packing, denser repeat'
    - spacing ≥65: 'more breathing room between motifs, generous spacing'
    - invert:     'invert dominant and recessive palette roles'
    
    UI on /design/:id (admin-only block):
    - Three 1-10 sliders for color/style/composition (default 7), live readouts
    - Spacing slider 0-100 with 'tighter ⟷ wider' label, default 50
    - Invert palette checkbox
    - Notes textarea
    - 'Save rating' (no retry) + 'Save + try again' (saves then queues retry)
    - Status line shows result + the nudges the model received
    - Expandable rating-history with override stack, retry counts, notes
    
    Infra fix:
    - ecosystem.config.js had PORT=9905 but selfadhesivewallpaper already binds
      127.0.0.1:9905. Per the canonical 'pm2 wallco-ai-viewer :9792' memory rule,
      switched back to :9792. The two-listener-on-same-port silent failure mode
      is exactly the trap the 'check ports before claiming' rule warns about.
    
    Smoke-tested live: rate / list / retry / admin-gate all green. HTML page
    contains all 5 expected DOM hooks.
---
 data/vision-cache.json                      |   1 +
 ecosystem.config.js                         |   2 +-
 scripts/generator_tick.js                   |  16 ++
 scripts/rate_design.js                      | 136 +++++++++++++
 server.js                                   | 283 +++++++++++++++++++++++++++-
 tests/integration/admin-gate.spec.js        |  77 ++++++++
 tests/integration/age-themes-render.spec.js |  65 +++++++
 tests/integration/csv-export.spec.js        | 131 +++++++++++++
 tests/integration/pairings-api.spec.js      |  71 +++++++
 tests/unit/apca.test.js                     | 220 +++++++++++++++++++++
 tests/unit/bands.test.js                    | 216 +++++++++++++++++++++
 11 files changed, 1209 insertions(+), 9 deletions(-)

diff --git a/data/vision-cache.json b/data/vision-cache.json
index 26cee94..f7fc5ef 100644
--- a/data/vision-cache.json
+++ b/data/vision-cache.json
@@ -1,4 +1,5 @@
 {
+  "11": "Floral pattern with large pink peonies on a white background; formal, maximalist style with a repeating pattern.",
   "14": "Floral pattern with gold flowers on a dark background, large-scale floral motif, formal mood.",
   "21": "Ornate, maximalist, gold on blue, large repeat, formal."
 }
\ No newline at end of file
diff --git a/ecosystem.config.js b/ecosystem.config.js
index 038a20a..dd6a260 100644
--- a/ecosystem.config.js
+++ b/ecosystem.config.js
@@ -5,7 +5,7 @@ module.exports = {
     cwd: __dirname,
     env: {
       NODE_ENV: 'production',
-      PORT: '9905',
+      PORT: '9792',
     },
     max_memory_restart: '500M',
     error_file: 'logs/pm2.err.log',
diff --git a/scripts/generator_tick.js b/scripts/generator_tick.js
index 3656d86..73b5684 100644
--- a/scripts/generator_tick.js
+++ b/scripts/generator_tick.js
@@ -146,6 +146,22 @@ function main() {
 
   finishRun(runId, true, designId, null);
   bumpRecipe(recipe.id);
+
+  // ── Auto-rate the freshly-generated design ─────────────────────────────
+  // Two-lens AI critique (graphic-designer + interior-designer) via Gemini Vision.
+  // Detached so the cron doesn't block waiting for ~5-10s of vision calls.
+  if (designId) {
+    try {
+      const { spawn } = require('child_process');
+      const rater = spawn('node',
+        [path.join(__dirname, 'rate_design.js'), '--id', String(designId)],
+        { detached: true, stdio: 'ignore', env: process.env });
+      rater.unref();
+    } catch (e) {
+      console.log(JSON.stringify({ event: 'rate_spawn_err', err: e.message }));
+    }
+  }
+
   console.log(JSON.stringify({
     event: 'tick_done', run_id: runId, design_id: designId,
   }));
diff --git a/scripts/rate_design.js b/scripts/rate_design.js
new file mode 100644
index 0000000..a36ce8b
--- /dev/null
+++ b/scripts/rate_design.js
@@ -0,0 +1,136 @@
+#!/usr/bin/env node
+/**
+ * AI rate a design using two lenses:
+ *   1. Graphic Designer (typography/composition/hierarchy/balance/repeat fitness)
+ *   2. Interior Designer (luxury-room fit/lighting/scale/material hierarchy)
+ *
+ * Each lens returns a 1-5 score + one-paragraph critique via Gemini Vision.
+ * Combined score = average. Persisted to spoon_all_designs columns:
+ *   gd_score, gd_critique, id_score, id_critique, combined_score,
+ *   rating_summary, rated_at.
+ *
+ * Usage:
+ *   node scripts/rate_design.js --id 152
+ *   node scripts/rate_design.js --since "10 minutes"   # rate everything new
+ */
+'use strict';
+require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
+const fs = require('fs');
+const path = require('path');
+const { execSync } = require('child_process');
+
+const DB = 'dw_unified';
+const PSQL_CMD = (process.platform === 'linux')
+  ? `sudo -n -u postgres psql ${DB} -At -q`
+  : `psql ${DB} -At -q`;
+
+const KEY = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY;
+if (!KEY) { console.error('GEMINI_API_KEY missing'); process.exit(2); }
+
+function psql(sql) {
+  return execSync(PSQL_CMD, { input: sql, encoding: 'utf8' }).trim();
+}
+function sqlStr(v) {
+  if (v == null) return 'NULL';
+  return "'" + String(v).replace(/'/g, "''") + "'";
+}
+
+const GD_PROMPT = `You are a senior GRAPHIC DESIGNER reviewing a seamless wallpaper pattern.
+Judge ONLY on:
+  • composition + balance (does the eye flow / no awkward dead zones)
+  • motif scale + density (right scale for repeat / not too busy / not too sparse)
+  • color harmony (palette coherent, not muddy)
+  • repeat fitness (will edges tile? are seams visible?)
+  • visual hierarchy (clear primary / secondary / background)
+Return STRICT JSON only:
+{
+  "score": <1-5 integer>,
+  "critique": "<2-3 sentences, frank, designer voice, no fluff>",
+  "strengths": ["<1>","<2>"],
+  "weaknesses": ["<1>","<2>"]
+}`;
+
+const ID_PROMPT = `You are a luxury INTERIOR DESIGNER reviewing a wallpaper pattern
+for a $1,500/yard project (Hermès / Soho House / hotel-suite level). Judge ONLY on:
+  • luxury-interior fit (specifiable for high-end residential or boutique hotel)
+  • room scale (which rooms / which lighting / how the eye reads)
+  • material hierarchy (looks like an investment piece or a knock-off)
+  • mood appropriateness (formal vs casual vs maximalist vs minimalist match)
+  • would I actually specify this for a client paying full retail
+Return STRICT JSON only:
+{
+  "score": <1-5 integer>,
+  "critique": "<2-3 sentences, designer voice, would-specify-or-not framing>",
+  "room_fit": ["<best room>", "<2nd>"],
+  "lighting": "<best lighting>",
+  "scale": "<small/medium/large/mural>"
+}`;
+
+async function gemini(prompt, imageB64) {
+  const r = await fetch(
+    `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${KEY}`,
+    {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        contents: [{
+          parts: [
+            { inlineData: { mimeType: 'image/png', data: imageB64 } },
+            { text: prompt }
+          ]
+        }],
+        generationConfig: { temperature: 0.2, maxOutputTokens: 500, responseMimeType: 'application/json' }
+      })
+    }
+  );
+  if (!r.ok) throw new Error(`gemini ${r.status}: ${(await r.text()).slice(0, 200)}`);
+  const j = await r.json();
+  const t = j.candidates?.[0]?.content?.parts?.[0]?.text || '';
+  return JSON.parse(t.replace(/^```json\s*|\s*```$/g, ''));
+}
+
+async function rateOne(id) {
+  const row = psql(`SELECT row_to_json(t) FROM (SELECT id, local_path, prompt, category, dominant_hex FROM spoon_all_designs WHERE id=${id}) t;`);
+  if (!row) { console.error(`#${id}: not in DB`); return null; }
+  const d = JSON.parse(row);
+  if (!d.local_path || !fs.existsSync(d.local_path)) {
+    console.error(`#${id}: file missing ${d.local_path}`); return null;
+  }
+  const b64 = fs.readFileSync(d.local_path).toString('base64');
+  const t0 = Date.now();
+  let gd, ide;
+  try {
+    [gd, ide] = await Promise.all([ gemini(GD_PROMPT, b64), gemini(ID_PROMPT, b64) ]);
+  } catch (e) {
+    console.error(`#${id}: gemini error:`, e.message); return null;
+  }
+  const combined = ((gd.score + ide.score) / 2).toFixed(2);
+  const summary = `★ ${combined}/5 — GD:${gd.score} ID:${ide.score} · ${gd.critique?.split('.')[0] || ''}.`;
+  psql(`UPDATE spoon_all_designs SET
+    gd_score=${gd.score}, gd_critique=${sqlStr(gd.critique)},
+    id_score=${ide.score}, id_critique=${sqlStr(ide.critique)},
+    combined_score=${combined}, rating_summary=${sqlStr(summary)},
+    rated_at=now() WHERE id=${id};`);
+  const elapsed = ((Date.now() - t0) / 1000).toFixed(2);
+  console.log(JSON.stringify({
+    event: 'rated', id, gd: gd.score, id_score: ide.score, combined,
+    elapsed_s: parseFloat(elapsed), summary: summary.slice(0, 120)
+  }));
+  return { gd, ide, combined };
+}
+
+async function rateRecent() {
+  const ids = psql(`SELECT id FROM spoon_all_designs WHERE rated_at IS NULL AND local_path IS NOT NULL ORDER BY id DESC LIMIT 20;`).split('\n').filter(Boolean);
+  for (const id of ids) await rateOne(parseInt(id, 10));
+}
+
+async function main() {
+  const argv = process.argv.slice(2);
+  const idArg = argv.indexOf('--id');
+  if (idArg >= 0) {
+    await rateOne(parseInt(argv[idArg + 1], 10));
+  } else {
+    await rateRecent();
+  }
+}
+main().catch(e => { console.error('FATAL:', e); process.exit(1); });
diff --git a/server.js b/server.js
index 04c52ef..9ef9d36 100644
--- a/server.js
+++ b/server.js
@@ -1441,10 +1441,17 @@ function renderDesignNotFound(req, idForCanonical) {
       </div>
     </a>`).join('');
   const safePath = String(req.params.id || '').replace(/[<>"'&]/g, '').slice(0, 60);
+  // Use the first recovery thumb as the og:image so social shares of a 404 still
+  // render a real wallpaper preview (Canva-lens fix from the debate panel).
+  // Falls back to /og-default.png if no published designs are loaded yet.
+  const ogPick = (pickups[0] && pickups[0].image_url)
+    ? (pickups[0].image_url.startsWith('http') ? pickups[0].image_url : `https://wallco.ai${pickups[0].image_url}`)
+    : '';
   return `${htmlHead({
     title: 'Design not found — wallco.ai',
     description: 'The requested design isn’t in our archive. Browse the live collection.',
-    canonical: `https://wallco.ai/design/${idForCanonical || safePath}`
+    canonical: `https://wallco.ai/design/${idForCanonical || safePath}`,
+    ogImage: ogPick
   })}
 <style>
   .nf {
@@ -1463,12 +1470,12 @@ function renderDesignNotFound(req, idForCanonical) {
   .nf-numeral {
     font-family: var(--serif);
     font-style: italic;
-    font-weight: 300;
+    font-weight: 400;
     font-size: clamp(140px, 22vw, 280px);
     line-height: 0.85;
-    letter-spacing: -0.04em;
+    letter-spacing: -0.06em;
     color: var(--gold);
-    opacity: 0.85;
+    opacity: 1;
     margin-bottom: 24px;
     user-select: none;
   }
@@ -1500,10 +1507,8 @@ function renderDesignNotFound(req, idForCanonical) {
     font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
     text-transform: none;
     letter-spacing: 0;
-    padding: 2px 8px;
-    background: var(--card-bg);
-    border: 1px solid var(--line);
-    border-radius: 3px;
+    padding: 2px 0 3px;
+    border-bottom: 1px dotted var(--line);
     color: var(--ink-soft);
   }
   .nf-body {
@@ -1606,6 +1611,135 @@ ${req.query.figcap === '1' ? '<script src="https://mcp.figma.com/mcp/html-to-des
 }
 
 // ── DESIGN DETAIL
+// ── design_ratings: admin scoring + RLHF-lite for the generator
+// One row per (design, version) so we can see how the LLM improved over retries.
+function ensureDesignRatingsTable() {
+  try {
+    psqlQuery(`CREATE TABLE IF NOT EXISTS design_ratings (
+      id BIGSERIAL PRIMARY KEY,
+      design_id BIGINT NOT NULL,
+      color_score INT NOT NULL CHECK (color_score BETWEEN 1 AND 10),
+      style_score INT NOT NULL CHECK (style_score BETWEEN 1 AND 10),
+      composition_score INT NOT NULL CHECK (composition_score BETWEEN 1 AND 10),
+      spacing INT,                  -- 0..100 (50 = neutral)
+      invert BOOLEAN DEFAULT FALSE,
+      notes TEXT,
+      rated_by TEXT DEFAULT 'admin',
+      retry_count INT DEFAULT 0,    -- bumped when this rating triggers a retry
+      child_design_id BIGINT,       -- set when retry produces a new design
+      created_at TIMESTAMP DEFAULT NOW()
+    );`);
+    psqlQuery(`CREATE INDEX IF NOT EXISTS design_ratings_design_idx ON design_ratings(design_id, created_at DESC);`);
+  } catch (e) { console.error('[ratings] table init failed:', e.message); }
+}
+ensureDesignRatingsTable();
+
+function adminRatingGate(req, res) {
+  const isAdmin = req.hostname === '127.0.0.1' || req.hostname === 'localhost' || req.query.review === '1';
+  if (!isAdmin) { res.status(403).json({ error: 'admin only' }); return false; }
+  return true;
+}
+
+// POST /api/design/:id/rate  { color, style, composition, spacing?, invert?, notes? }
+app.post('/api/design/:id/rate', express.json({ limit: '128kb' }), (req, res) => {
+  if (!adminRatingGate(req, res)) return;
+  const id = parseInt(req.params.id, 10);
+  const { color, style, composition, spacing, invert, notes } = req.body || {};
+  const c = Number(color), s = Number(style), m = Number(composition);
+  if (![c, s, m].every(n => Number.isFinite(n) && n >= 1 && n <= 10)) {
+    return res.status(400).json({ error: 'color/style/composition must each be 1..10' });
+  }
+  const sp = Number.isFinite(Number(spacing)) ? Math.max(0, Math.min(100, Number(spacing))) : 50;
+  try {
+    const out = psqlQuery(`INSERT INTO design_ratings
+      (design_id, color_score, style_score, composition_score, spacing, invert, notes)
+      VALUES (${id}, ${c}, ${s}, ${m}, ${sp}, ${invert ? 'TRUE' : 'FALSE'}, ${pgEsc(notes || null)})
+      RETURNING id;`);
+    res.json({ ok: true, rating_id: parseInt(out, 10) });
+  } catch (e) {
+    res.status(500).json({ error: e.message });
+  }
+});
+
+// GET /api/design/:id/ratings — full history (admin reads override stack)
+app.get('/api/design/:id/ratings', (req, res) => {
+  const id = parseInt(req.params.id, 10);
+  try {
+    const raw = psqlQuery(`SELECT COALESCE(json_agg(t ORDER BY t.created_at DESC),'[]'::json) FROM (
+      SELECT id, color_score, style_score, composition_score, spacing, invert, notes,
+             rated_by, retry_count, child_design_id, created_at
+      FROM design_ratings WHERE design_id = ${id}) t;`);
+    res.json({ ratings: JSON.parse(raw || '[]') });
+  } catch (e) {
+    res.status(500).json({ error: e.message });
+  }
+});
+
+// Build prompt nudges from the rating — the LLM uses these on retry.
+function nudgesFromRating(r) {
+  const out = [];
+  if (r.color_score <= 4)      out.push('refined palette, tonal harmony, no clashing hues');
+  else if (r.color_score >= 8) out.push('preserve color palette tightly');
+  if (r.style_score <= 4)      out.push('cohesive single-era style statement, commit to one tradition');
+  else if (r.style_score >= 8) out.push('preserve stylistic direction');
+  if (r.composition_score <= 4) out.push('rebalance composition, clearer focal point, deliberate negative space');
+  else if (r.composition_score >= 8) out.push('preserve composition');
+  const sp = Number(r.spacing ?? 50);
+  if (sp <= 35)      out.push('tighter element packing, denser repeat');
+  else if (sp >= 65) out.push('more breathing room between motifs, generous spacing');
+  if (r.invert) out.push('invert dominant and recessive palette roles');
+  return out;
+}
+
+// POST /api/design/:id/retry — kicks a fresh generation using the latest
+// rating as RLHF-lite feedback. The new design lands as a normal row in
+// spoon_all_designs; we link parent→child via design_ratings.child_design_id.
+app.post('/api/design/:id/retry', express.json({ limit: '32kb' }), (req, res) => {
+  if (!adminRatingGate(req, res)) return;
+  const id = parseInt(req.params.id, 10);
+  try {
+    const raw = psqlQuery(`SELECT row_to_json(t) FROM (
+      SELECT id, color_score, style_score, composition_score, spacing, invert, notes
+      FROM design_ratings WHERE design_id = ${id} ORDER BY created_at DESC LIMIT 1) t;`);
+    if (!raw) return res.status(400).json({ error: 'rate the design first' });
+    const r = JSON.parse(raw);
+
+    // pull parent's category + (a representative) prompt seed
+    const parentRow = psqlQuery(`SELECT row_to_json(t) FROM (
+      SELECT category, prompt FROM spoon_all_designs WHERE id = ${id}) t;`);
+    const parent = parentRow ? JSON.parse(parentRow) : {};
+    const baseCategory = parent.category || 'mixed';
+    const nudges = nudgesFromRating(r);
+    const basePrompt = parent.prompt || `${baseCategory} seamless wallpaper repeat`;
+    const finalPrompt = `${basePrompt}. Corrections: ${nudges.join('; ')}.`;
+
+    psqlQuery(`UPDATE design_ratings SET retry_count = retry_count + 1
+               WHERE id = ${r.id};`);
+
+    const { spawn } = require('child_process');
+    const p = spawn('node', [
+      path.join(__dirname, 'scripts', 'generate_designs.js'),
+      '--n', '1',
+      '--kind', 'seamless_tile',
+      '--category', baseCategory,
+      '--prompts', finalPrompt,
+    ], { cwd: __dirname, env: { ...process.env, GEN_BACKEND: process.env.GEN_BACKEND || 'replicate' },
+         detached: true, stdio: 'ignore' });
+    p.unref();
+
+    res.json({
+      ok: true,
+      retry_started: true,
+      base_category: baseCategory,
+      nudges,
+      final_prompt: finalPrompt,
+      note: 'New design will land in /designs in 30-90s. Refresh after.',
+    });
+  } catch (e) {
+    res.status(500).json({ error: e.message });
+  }
+});
+
 app.get('/design/:id', (req, res) => {
   const id = parseInt(req.params.id, 10);
   if (!Number.isFinite(id) || id < 1) {
@@ -1935,6 +2069,36 @@ ${htmlHeader('/designs')}
         Pattern No.${String(design.id).padStart(4,'0')} &middot; ${design.category.charAt(0).toUpperCase()+design.category.slice(1)} &middot; ${design.kind === 'seamless_tile' ? 'Seamless Tile' : design.kind}
       </div>
 
+      <!-- AI Suggested Rating (graphic designer + interior designer) -->
+      <div id="ai-rating-card" style="display:none;margin:14px 0;padding:14px 16px;background:#1a1816;color:#e8e2d6;border-radius:8px;border:1px solid #2a2a2a;font-size:13px">
+        <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
+          <strong style="font-family:'Cormorant Garamond',serif;font-weight:400;font-size:16px;color:#d2b15c">AI Suggested Rating</strong>
+          <span id="rating-combined" style="font-size:18px;color:#d2b15c"></span>
+        </div>
+        <div id="rating-summary" style="color:#bba;font-size:12px;line-height:1.55"></div>
+        <div style="margin-top:10px;display:grid;grid-template-columns:1fr 1fr;gap:10px;font-size:11px">
+          <div><div style="text-transform:uppercase;letter-spacing:.08em;color:#888">Graphic designer</div><div id="rating-gd-score" style="font-size:14px;color:#fff"></div><div id="rating-gd-critique" style="color:#aaa;margin-top:4px"></div></div>
+          <div><div style="text-transform:uppercase;letter-spacing:.08em;color:#888">Interior designer</div><div id="rating-id-score" style="font-size:14px;color:#fff"></div><div id="rating-id-critique" style="color:#aaa;margin-top:4px"></div></div>
+        </div>
+      </div>
+      <script>
+      (async function(){
+        try {
+          var r = await fetch('/api/design/${design.id}/rating');
+          if (!r.ok) return;
+          var j = await r.json();
+          if (!j.combined_score) return;
+          document.getElementById('ai-rating-card').style.display = 'block';
+          document.getElementById('rating-combined').textContent = '★ ' + j.combined_score + ' / 5';
+          document.getElementById('rating-summary').textContent = j.rating_summary || '';
+          document.getElementById('rating-gd-score').textContent = (j.gd_score || '?') + ' / 5';
+          document.getElementById('rating-gd-critique').textContent = j.gd_critique || '';
+          document.getElementById('rating-id-score').textContent = (j.id_score || '?') + ' / 5';
+          document.getElementById('rating-id-critique').textContent = j.id_critique || '';
+        } catch(e) {}
+      })();
+      </script>
+
       <div class="detail-palette">
         <span class="swatch-main" style="background:${design.dominant_hex}" title="Dominant ${design.dominant_hex}"></span>
         <span class="hex-label">${design.dominant_hex}</span>
@@ -2098,6 +2262,83 @@ ${htmlHeader('/designs')}
       })();
       </script>
 
+      ${isAdmin ? `
+      <!-- Admin: rate this design (RLHF-lite for the generator) -->
+      <div id="rate-block" style="margin-top:28px;padding:18px;border:1px solid var(--line);border-radius:10px;background:rgba(0,0,0,.02)">
+        <h3 style="font-family:var(--serif);font-weight:300;font-size:18px;margin:0 0 4px">Rate this design</h3>
+        <p style="font-size:11px;color:var(--ink-faint);margin:0 0 14px;letter-spacing:.04em">Admin-only · color · style · composition · 1-10 · feeds the next retry</p>
+        <div class="rate-rows" style="display:grid;grid-template-columns:120px 1fr 36px;gap:10px;align-items:center;font-size:13px">
+          <label for="r-color">Color</label>
+          <input id="r-color" type="range" min="1" max="10" step="1" value="7">
+          <span id="r-color-v">7</span>
+          <label for="r-style">Style</label>
+          <input id="r-style" type="range" min="1" max="10" step="1" value="7">
+          <span id="r-style-v">7</span>
+          <label for="r-comp">Composition</label>
+          <input id="r-comp" type="range" min="1" max="10" step="1" value="7">
+          <span id="r-comp-v">7</span>
+          <label for="r-space">Spacing<br><small style="color:var(--ink-faint)">tighter ⟷ wider</small></label>
+          <input id="r-space" type="range" min="0" max="100" step="5" value="50">
+          <span id="r-space-v">50</span>
+          <label for="r-invert">Invert palette</label>
+          <label style="display:flex;align-items:center;gap:8px;font-size:12px;color:var(--ink-soft)"><input id="r-invert" type="checkbox"> swap dominant/recessive</label>
+          <span></span>
+        </div>
+        <textarea id="r-notes" placeholder="Notes for the model (optional)" style="width:100%;margin-top:10px;padding:8px;border:1px solid var(--line);border-radius:6px;font-family:inherit;font-size:13px;min-height:44px"></textarea>
+        <div style="display:flex;gap:8px;margin-top:10px;flex-wrap:wrap">
+          <button type="button" class="btn-outline" id="btn-rate-save">Save rating</button>
+          <button type="button" class="btn-outline" id="btn-rate-retry">Save + try again</button>
+          <span id="rate-status" style="font-size:12px;color:var(--ink-faint);align-self:center"></span>
+        </div>
+        <details id="rate-history" style="margin-top:14px">
+          <summary style="cursor:pointer;font-size:12px;color:var(--ink-soft)">Rating history (overrides on top)</summary>
+          <div id="rate-history-list" style="margin-top:8px;font-size:12px;color:var(--ink-soft)">Loading…</div>
+        </details>
+      </div>
+      <script>
+      (function(){
+        var id = ${design.id};
+        function vof(x){ return document.getElementById(x).value; }
+        function bind(slider, out){ var s=document.getElementById(slider), o=document.getElementById(out); s.addEventListener('input',function(){o.textContent=s.value;}); }
+        bind('r-color','r-color-v'); bind('r-style','r-style-v'); bind('r-comp','r-comp-v'); bind('r-space','r-space-v');
+        function payload(){return{color:+vof('r-color'),style:+vof('r-style'),composition:+vof('r-comp'),spacing:+vof('r-space'),invert:document.getElementById('r-invert').checked,notes:vof('r-notes')||null};}
+        function setStatus(t,isErr){var s=document.getElementById('rate-status');s.textContent=t;s.style.color=isErr?'#b00020':'var(--ink-faint)';}
+        async function post(url, body){var r=await fetch(url+'?review=1',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});return r.json();}
+        document.getElementById('btn-rate-save').addEventListener('click', async function(){
+          setStatus('Saving…');
+          var j = await post('/api/design/'+id+'/rate', payload());
+          setStatus(j.ok ? 'Saved rating #' + j.rating_id : ('Error: ' + (j.error||'?')), !j.ok);
+          if (j.ok) loadHistory();
+        });
+        document.getElementById('btn-rate-retry').addEventListener('click', async function(){
+          setStatus('Saving + queueing retry…');
+          var j = await post('/api/design/'+id+'/rate', payload());
+          if (!j.ok) { setStatus('Error: '+(j.error||'?'), true); return; }
+          var k = await post('/api/design/'+id+'/retry', {});
+          setStatus(k.ok ? ('Retry queued — nudges: ' + (k.nudges||[]).join(' · ')) : ('Retry error: '+(k.error||'?')), !k.ok);
+          loadHistory();
+        });
+        async function loadHistory(){
+          try {
+            var r = await fetch('/api/design/'+id+'/ratings?review=1').then(r=>r.json());
+            var list = document.getElementById('rate-history-list');
+            if (!r.ratings || !r.ratings.length) { list.textContent = 'No ratings yet.'; return; }
+            list.innerHTML = r.ratings.map(function(x){
+              var when = (x.created_at || '').replace('T',' ').slice(0,16);
+              return '<div style="padding:6px 0;border-top:1px dashed var(--line)">'+
+                when + ' — C:' + x.color_score + ' S:' + x.style_score + ' Co:' + x.composition_score +
+                ' space:' + x.spacing + (x.invert ? ' invert' : '') +
+                (x.retry_count > 0 ? ' · ' + x.retry_count + ' retr' + (x.retry_count===1?'y':'ies') : '') +
+                (x.notes ? '<div style="color:var(--ink-faint)">'+ (x.notes||'').replace(/</g,'&lt;') +'</div>' : '') +
+                '</div>';
+            }).join('');
+          } catch(e) {}
+        }
+        loadHistory();
+      })();
+      </script>
+      ` : ''}
+
       <!-- Tools (admin / preview) -->
       <div class="tool-group" style="margin-top:18px;display:flex;flex-wrap:wrap;gap:10px">
         <button type="button" class="btn-outline" id="btn-room"        data-id="${design.id}">See in a Room</button>
@@ -5125,6 +5366,32 @@ app.post('/api/design/:id/bake',
   }
 );
 
+// ── GET /api/design/:id/rating — AI suggested rating
+app.get('/api/design/:id/rating', (req, res) => {
+  const id = parseInt(req.params.id, 10);
+  if (!Number.isFinite(id) || id < 1) return res.status(404).json({});
+  try {
+    const row = psqlQuery(`SELECT row_to_json(t) FROM (SELECT gd_score, gd_critique,
+      id_score, id_critique, combined_score, rating_summary, rated_at
+      FROM spoon_all_designs WHERE id=${id}) t;`);
+    if (!row) return res.json({});
+    res.json(JSON.parse(row));
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+// ── POST /api/design/:id/ai-rate — fire the AI rater now
+//    (renamed from /rate to avoid colliding with the human-rating endpoint
+//     at line ~1640 that's gated to admin)
+app.post('/api/design/:id/ai-rate', (req, res) => {
+  const id = parseInt(req.params.id, 10);
+  if (!Number.isFinite(id) || id < 1) return res.status(404).json({ ok: false });
+  const proc = spawn('node',
+    [path.join(__dirname, 'scripts', 'rate_design.js'), '--id', String(id)],
+    { detached: true, stdio: 'ignore', env: process.env });
+  proc.unref();
+  res.json({ ok: true, queued: id });
+});
+
 // ── HEALTH
 app.get('/health', (_req, res) => {
   res.json({ ok: true, site: SITE, count: DESIGNS.length, port: PORT });
diff --git a/tests/integration/admin-gate.spec.js b/tests/integration/admin-gate.spec.js
new file mode 100644
index 0000000..fb5412d
--- /dev/null
+++ b/tests/integration/admin-gate.spec.js
@@ -0,0 +1,77 @@
+/**
+ * Spec A — Admin-gate access control for /age-themes and /moodboard.
+ *
+ * Both routes return 404 when the request comes from a non-localhost hostname
+ * and ?review=1 is absent. They return 200 (with content) when ?review=1 is
+ * present, because the server trusts that query param as the admin signal.
+ *
+ * Host-header spoofing: Playwright cannot override the Host header for
+ * requests it makes through page.goto() — the browser always sends
+ * 'Host: 127.0.0.1' regardless. We instead use page.evaluate() with
+ * fetch() directly, which lets us set arbitrary headers. The server checks
+ * req.hostname (derived from Host header), so this correctly exercises the
+ * gate.
+ *
+ * NOTE: When using fetch via page.evaluate, the URL must be absolute
+ * (including origin) because the page context is about:blank initially.
+ */
+'use strict';
+const { test, expect } = require('@playwright/test');
+
+const BASE = 'http://127.0.0.1:9792';
+
+// Dismiss the wallco.ai age-prompt overlay before any navigation.
+test.beforeEach(async ({ page }) => {
+  await page.addInitScript(() => {
+    try { localStorage.setItem('wallco-age-skipped', '1'); } catch {}
+  });
+});
+
+// ── /age-themes ───────────────────────────────────────────────────────────────
+
+test('A-1 · /age-themes without ?review=1 and Host: wallco.ai returns 404', async ({ page }) => {
+  // Navigate to about:blank so we have a stable context for fetch.
+  await page.goto('about:blank');
+
+  const status = await page.evaluate(async (base) => {
+    const res = await fetch(base + '/age-themes', {
+      headers: { Host: 'wallco.ai' },
+      redirect: 'follow',
+    });
+    return res.status;
+  }, BASE);
+
+  expect(status).toBe(404);
+});
+
+test('A-2 · /age-themes?review=1 returns 200 with page content', async ({ page }) => {
+  const response = await page.goto(`${BASE}/age-themes?review=1`, { waitUntil: 'domcontentloaded' });
+  expect(response.status()).toBe(200);
+
+  // Page must contain at least one h1 or a band-row element.
+  const hasContent = await page.evaluate(() =>
+    document.querySelectorAll('h1, section.band-row').length > 0
+  );
+  expect(hasContent).toBe(true);
+});
+
+// ── /moodboard ────────────────────────────────────────────────────────────────
+
+test('A-3 · /moodboard without ?review=1 and Host: wallco.ai returns 404', async ({ page }) => {
+  await page.goto('about:blank');
+
+  const status = await page.evaluate(async (base) => {
+    const res = await fetch(base + '/moodboard', {
+      headers: { Host: 'wallco.ai' },
+      redirect: 'follow',
+    });
+    return res.status;
+  }, BASE);
+
+  expect(status).toBe(404);
+});
+
+test('A-4 · /moodboard?review=1 returns 200', async ({ page }) => {
+  const response = await page.goto(`${BASE}/moodboard?review=1`, { waitUntil: 'domcontentloaded' });
+  expect(response.status()).toBe(200);
+});
diff --git a/tests/integration/age-themes-render.spec.js b/tests/integration/age-themes-render.spec.js
new file mode 100644
index 0000000..7f6ffea
--- /dev/null
+++ b/tests/integration/age-themes-render.spec.js
@@ -0,0 +1,65 @@
+/**
+ * Spec C — /age-themes?review=1 DOM rendering verification.
+ *
+ * Verifies the structural invariants of the age-themes page:
+ *   - Exactly 8 <section class="band-row"> elements (one per band in bands.json)
+ *   - Each band renders two theme swatches (CURRENT + BEST), each with exactly
+ *     3 .theme-apca .lc badges (text / accent / btn) → 6 per band → 48 total
+ *   - The credits banner contains "v9.0.0" (the version from bands.json)
+ *   - The credits banner contains "src/bands.json" (the canonical path callout)
+ *
+ * None of these checks require Ollama — the page is fully server-side rendered
+ * from src/bands.json.
+ */
+'use strict';
+const { test, expect } = require('@playwright/test');
+
+// Dismiss the wallco.ai age-prompt overlay before any navigation.
+test.beforeEach(async ({ page }) => {
+  await page.addInitScript(() => {
+    try { localStorage.setItem('wallco-age-skipped', '1'); } catch {}
+  });
+  await page.goto('/age-themes?review=1', { waitUntil: 'domcontentloaded' });
+  // Wait for the first band section to be visible before each test.
+  await expect(page.locator('section.band-row').first()).toBeVisible({ timeout: 10_000 });
+});
+
+// ── C-1: exactly 8 band-row sections ─────────────────────────────────────────
+
+test('C-1 · page has exactly 8 band-row sections', async ({ page }) => {
+  const bands = page.locator('section.band-row');
+  await expect(bands).toHaveCount(8);
+});
+
+// ── C-2: APCA badge count ─────────────────────────────────────────────────────
+
+test('C-2 · each band-row has exactly 6 APCA Lc badges (3 per swatch × 2 swatches)', async ({ page }) => {
+  const sections = page.locator('section.band-row');
+  const count = await sections.count();
+  expect(count).toBe(8);
+
+  for (let i = 0; i < count; i++) {
+    const section = sections.nth(i);
+    const badges = section.locator('.theme-apca .lc');
+    await expect(badges).toHaveCount(6);
+  }
+});
+
+test('C-3 · total APCA Lc badges across all bands is 48', async ({ page }) => {
+  const allBadges = page.locator('section.band-row .theme-apca .lc');
+  await expect(allBadges).toHaveCount(48);
+});
+
+// ── C-4: credits banner content ───────────────────────────────────────────────
+
+test('C-4 · credits banner contains version string "v9.0.0"', async ({ page }) => {
+  const creditsText = await page.locator('.credits').textContent();
+  expect(creditsText).toContain('v9.0.0');
+});
+
+test('C-5 · credits banner contains canonical path "src/bands.json"', async ({ page }) => {
+  // The rendered HTML contains ~/Projects/wallco-ai/src/bands.json in a <code>
+  // element. The path string is what we pin here.
+  const creditsText = await page.locator('.credits').textContent();
+  expect(creditsText).toContain('src/bands.json');
+});
diff --git a/tests/integration/csv-export.spec.js b/tests/integration/csv-export.spec.js
new file mode 100644
index 0000000..5bd6c4e
--- /dev/null
+++ b/tests/integration/csv-export.spec.js
@@ -0,0 +1,131 @@
+/**
+ * Spec B — CSV export endpoints: /api/export/keep.csv and /api/export/all.csv.
+ *
+ * These endpoints are pure I/O — they read reviews.json and return a CSV.
+ * No Ollama involved. The pretest reset (npm run reset-state in tests/) wipes
+ * reviews.json to {} before the parent test suite runs, so when these specs
+ * run the file is either empty ({}) or has state from earlier tests in the
+ * same suite run (the existing Ollama specs run before integration/ in the
+ * default testDir order).
+ *
+ * To guarantee a known-empty state for the "header-only body" assertions we
+ * reset reviews.json inline before those specific tests using fs.writeFileSync.
+ * The other assertions (200, correct Content-Type, header columns) hold
+ * regardless of reviews.json contents.
+ *
+ * Column order (from src/review.js lines 475 and 509):
+ *   design_id, title, category, kind, dominant_hex, score_design, score_color,
+ *   score_style, verdict, why, image_url, pinned_count, pinned_summary, reviewed_at
+ */
+'use strict';
+const { test, expect } = require('@playwright/test');
+const fs   = require('node:fs');
+const path = require('node:path');
+
+const BASE         = 'http://127.0.0.1:9792';
+const REVIEWS_FILE = path.join(__dirname, '..', '..', 'data', 'reviews.json');
+
+const EXPECTED_HEADER = 'design_id,title,category,kind,dominant_hex,score_design,score_color,score_style,verdict,why,image_url,pinned_count,pinned_summary,reviewed_at';
+
+// ── helpers ───────────────────────────────────────────────────────────────────
+
+/** Reset reviews.json to empty object — inline state control. */
+function emptyReviews() {
+  fs.writeFileSync(REVIEWS_FILE, '{}');
+}
+
+// ── /api/export/keep.csv ──────────────────────────────────────────────────────
+
+test('B-1 · keep.csv returns 200 with text/csv content-type', async ({ page }) => {
+  await page.goto('about:blank');
+
+  const result = await page.evaluate(async (base) => {
+    const res = await fetch(base + '/api/export/keep.csv');
+    return {
+      status:       res.status,
+      contentType:  res.headers.get('content-type'),
+      disposition:  res.headers.get('content-disposition'),
+    };
+  }, BASE);
+
+  expect(result.status).toBe(200);
+  expect(result.contentType).toContain('text/csv');
+  expect(result.disposition).toMatch(/^attachment;\s*filename="wallco-keep-/);
+});
+
+test('B-2 · keep.csv first line is exactly the expected column header', async ({ page }) => {
+  await page.goto('about:blank');
+
+  const body = await page.evaluate(async (base) => {
+    const res = await fetch(base + '/api/export/keep.csv');
+    return res.text();
+  }, BASE);
+
+  const firstLine = body.split('\n')[0];
+  expect(firstLine).toBe(EXPECTED_HEADER);
+});
+
+test('B-3 · keep.csv has exactly 1 line (header only) when reviews.json is empty', async ({ page }) => {
+  emptyReviews();
+  await page.goto('about:blank');
+
+  const body = await page.evaluate(async (base) => {
+    const res = await fetch(base + '/api/export/keep.csv');
+    return res.text();
+  }, BASE);
+
+  // The server appends a trailing newline; strip it, then count lines.
+  const lines = body.replace(/\n$/, '').split('\n');
+  expect(lines).toHaveLength(1);
+  expect(lines[0]).toBe(EXPECTED_HEADER);
+});
+
+// ── /api/export/all.csv ───────────────────────────────────────────────────────
+
+test('B-4 · all.csv returns 200 with text/csv content-type', async ({ page }) => {
+  await page.goto('about:blank');
+
+  const result = await page.evaluate(async (base) => {
+    const res = await fetch(base + '/api/export/all.csv');
+    return {
+      status:      res.status,
+      contentType: res.headers.get('content-type'),
+      disposition: res.headers.get('content-disposition'),
+    };
+  }, BASE);
+
+  expect(result.status).toBe(200);
+  expect(result.contentType).toContain('text/csv');
+  expect(result.disposition).toMatch(/^attachment;\s*filename="wallco-all-/);
+});
+
+test('B-5 · all.csv first line is exactly the expected column header', async ({ page }) => {
+  await page.goto('about:blank');
+
+  const body = await page.evaluate(async (base) => {
+    const res = await fetch(base + '/api/export/all.csv');
+    return res.text();
+  }, BASE);
+
+  const firstLine = body.split('\n')[0];
+  expect(firstLine).toBe(EXPECTED_HEADER);
+});
+
+test('B-6 · keep.csv and all.csv have identical header rows (regression guard)', async ({ page }) => {
+  await page.goto('about:blank');
+
+  const [keepBody, allBody] = await page.evaluate(async (base) => {
+    const [kr, ar] = await Promise.all([
+      fetch(base + '/api/export/keep.csv'),
+      fetch(base + '/api/export/all.csv'),
+    ]);
+    return [await kr.text(), await ar.text()];
+  }, BASE);
+
+  const keepHeader = keepBody.split('\n')[0];
+  const allHeader  = allBody.split('\n')[0];
+
+  expect(keepHeader).toBe(EXPECTED_HEADER);
+  expect(allHeader).toBe(EXPECTED_HEADER);
+  expect(keepHeader).toBe(allHeader);
+});
diff --git a/tests/integration/pairings-api.spec.js b/tests/integration/pairings-api.spec.js
new file mode 100644
index 0000000..2c768c5
--- /dev/null
+++ b/tests/integration/pairings-api.spec.js
@@ -0,0 +1,71 @@
+/**
+ * Spec D — Pairings + moodboard + reviews API fast-path tests.
+ *
+ * These test the JSON API layer directly without any Ollama involvement:
+ *   - /api/pairings/99999  (bogus ID) → 404 { error: "design not found" }
+ *   - POST /api/moodboard/99999/pin without pin_key → 400
+ *   - /api/reviews/all → 200 valid JSON object
+ *
+ * All requests are issued via page.evaluate fetch() so that we can inspect
+ * status codes and JSON bodies without going through Playwright's
+ * page.request helper (which has slightly different error semantics).
+ */
+'use strict';
+const { test, expect } = require('@playwright/test');
+
+const BASE = 'http://127.0.0.1:9792';
+
+test.beforeEach(async ({ page }) => {
+  // Stable context for fetch calls.
+  await page.goto('about:blank');
+});
+
+// ── D-1: pairings — bogus ID ──────────────────────────────────────────────────
+
+test('D-1 · GET /api/pairings/99999 returns 404 with {error:"design not found"}', async ({ page }) => {
+  const result = await page.evaluate(async (base) => {
+    const res = await fetch(base + '/api/pairings/99999');
+    const body = await res.json();
+    return { status: res.status, body };
+  }, BASE);
+
+  expect(result.status).toBe(404);
+  expect(result.body).toHaveProperty('error');
+  expect(result.body.error).toBe('design not found');
+});
+
+// ── D-2: moodboard pin — missing pin_key ─────────────────────────────────────
+
+test('D-2 · POST /api/moodboard/99999/pin without pin_key returns 400', async ({ page }) => {
+  const result = await page.evaluate(async (base) => {
+    const res = await fetch(base + '/api/moodboard/99999/pin', {
+      method:  'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body:    JSON.stringify({}),   // intentionally omit pin_key
+    });
+    const body = await res.json();
+    return { status: res.status, body };
+  }, BASE);
+
+  expect(result.status).toBe(400);
+  expect(result.body).toHaveProperty('error');
+});
+
+// ── D-3: reviews/all — valid JSON object ──────────────────────────────────────
+
+test('D-3 · GET /api/reviews/all returns 200 with a valid JSON object', async ({ page }) => {
+  const result = await page.evaluate(async (base) => {
+    const res = await fetch(base + '/api/reviews/all');
+    const body = await res.json();
+    return {
+      status:   res.status,
+      bodyType: typeof body,
+      isArray:  Array.isArray(body),
+    };
+  }, BASE);
+
+  expect(result.status).toBe(200);
+  // reviews/all returns an object keyed by design ID, never an array.
+  expect(result.bodyType).toBe('object');
+  expect(result.isArray).toBe(false);
+});
diff --git a/tests/unit/apca.test.js b/tests/unit/apca.test.js
new file mode 100644
index 0000000..72abb8f
--- /dev/null
+++ b/tests/unit/apca.test.js
@@ -0,0 +1,220 @@
+'use strict';
+// Tests for src/apca.js  (Myndex SACAM 0.1.9 self-implementation)
+// Run with: node --test tests/unit/apca.test.js
+
+const { test, describe } = require('node:test');
+const assert = require('node:assert/strict');
+const { apcaLc, classifyLc, expand, hexToRgb, sRGBtoY } =
+  require('../../src/apca.js');
+
+// ---------------------------------------------------------------------------
+// helpers
+// ---------------------------------------------------------------------------
+function assertInRange(actual, min, max, label) {
+  assert.ok(
+    actual >= min && actual <= max,
+    `${label}: expected ${actual} to be in [${min}, ${max}]`
+  );
+}
+
+// ---------------------------------------------------------------------------
+// expand()
+// ---------------------------------------------------------------------------
+describe('expand', () => {
+  test('3-char shorthand expands correctly', () => {
+    assert.equal(expand('fff'), 'ffffff');
+    assert.equal(expand('000'), '000000');
+    assert.equal(expand('abc'), 'aabbcc');
+  });
+
+  test('strips leading # then expands 3-char', () => {
+    assert.equal(expand('#fff'), 'ffffff');
+    assert.equal(expand('#000'), '000000');
+  });
+
+  test('6-char hex passes through unchanged', () => {
+    assert.equal(expand('1a2b3c'), '1a2b3c');
+    assert.equal(expand('ff0000'), 'ff0000');
+  });
+
+  test('strips leading # from 6-char hex', () => {
+    assert.equal(expand('#ff0000'), 'ff0000');
+    assert.equal(expand('#1a2b3c'), '1a2b3c');
+  });
+});
+
+// ---------------------------------------------------------------------------
+// hexToRgb()
+// ---------------------------------------------------------------------------
+describe('hexToRgb', () => {
+  test('#ff0000 returns [255, 0, 0]', () => {
+    assert.deepEqual(hexToRgb('#ff0000'), [255, 0, 0]);
+  });
+
+  test('#ffffff returns [255, 255, 255]', () => {
+    assert.deepEqual(hexToRgb('#ffffff'), [255, 255, 255]);
+  });
+
+  test('#000000 returns [0, 0, 0]', () => {
+    assert.deepEqual(hexToRgb('#000000'), [0, 0, 0]);
+  });
+
+  test('3-char #fff returns [255, 255, 255]', () => {
+    assert.deepEqual(hexToRgb('#fff'), [255, 255, 255]);
+  });
+
+  test('3-char #000 returns [0, 0, 0]', () => {
+    assert.deepEqual(hexToRgb('#000'), [0, 0, 0]);
+  });
+});
+
+// ---------------------------------------------------------------------------
+// sRGBtoY()
+// ---------------------------------------------------------------------------
+describe('sRGBtoY', () => {
+  test('white [255,255,255] returns luminance ~1.0', () => {
+    const y = sRGBtoY([255, 255, 255]);
+    assertInRange(y, 0.999, 1.001, 'white luminance');
+  });
+
+  test('black [0,0,0] returns luminance 0.0', () => {
+    assert.equal(sRGBtoY([0, 0, 0]), 0);
+  });
+});
+
+// ---------------------------------------------------------------------------
+// apcaLc() — textbook values
+// ---------------------------------------------------------------------------
+describe('apcaLc textbook values', () => {
+  test('black-on-white is Lc 106 (±2)', () => {
+    const lc = apcaLc('#000000', '#ffffff');
+    assertInRange(lc, 104, 108, 'black-on-white Lc');
+  });
+
+  test('white-on-black is Lc -108 (±2)', () => {
+    const lc = apcaLc('#ffffff', '#000000');
+    assertInRange(lc, -110, -106, 'white-on-black Lc');
+  });
+});
+
+// ---------------------------------------------------------------------------
+// apcaLc() — 3-char hex regression (bug fixed today)
+// ---------------------------------------------------------------------------
+describe('apcaLc 3-char hex regression', () => {
+  test('#fff on #000 does NOT return NaN', () => {
+    assert.ok(!isNaN(apcaLc('#fff', '#000')), 'apcaLc(#fff, #000) returned NaN');
+  });
+
+  test('#000 on #fff does NOT return NaN', () => {
+    assert.ok(!isNaN(apcaLc('#000', '#fff')), 'apcaLc(#000, #fff) returned NaN');
+  });
+
+  test('#fff on #000 returns a finite number', () => {
+    assert.ok(isFinite(apcaLc('#fff', '#000')));
+  });
+
+  test('#000 on #fff matches 6-char equivalent', () => {
+    assert.equal(apcaLc('#000', '#fff'), apcaLc('#000000', '#ffffff'));
+  });
+
+  test('#fff on #000 matches 6-char equivalent', () => {
+    assert.equal(apcaLc('#fff', '#000'), apcaLc('#ffffff', '#000000'));
+  });
+});
+
+// ---------------------------------------------------------------------------
+// apcaLc() — no leading # (document: works, same as with #)
+// ---------------------------------------------------------------------------
+describe('apcaLc no-leading-hash', () => {
+  test('fff on 000 (no #) works and equals 3-char with # version', () => {
+    const noHash = apcaLc('fff', '000');
+    assert.ok(!isNaN(noHash), 'no-hash returned NaN');
+    assert.equal(noHash, apcaLc('#fff', '#000'));
+  });
+});
+
+// ---------------------------------------------------------------------------
+// apcaLc() — LoConThresh: same colour returns 0
+// ---------------------------------------------------------------------------
+describe('apcaLc low-contrast threshold', () => {
+  test('#888 on #888 returns 0 (LoConThresh)', () => {
+    assert.equal(apcaLc('#888', '#888'), 0);
+  });
+
+  test('#ffffff on #ffffff returns 0', () => {
+    assert.equal(apcaLc('#ffffff', '#ffffff'), 0);
+  });
+});
+
+// ---------------------------------------------------------------------------
+// apcaLc() — site-specific claimed values
+// ---------------------------------------------------------------------------
+describe('apcaLc site-claimed values', () => {
+  test('tween indigo-500 #6366f1 on amber #fef3c7 is Lc 61–65 (site claims 63)', () => {
+    const lc = apcaLc('#6366f1', '#fef3c7');
+    assertInRange(lc, 61, 65, 'tween indigo-500 on amber');
+  });
+
+  test('tween indigo-700 #4338ca on amber #fef3c7 is Lc 77–81 (site claims 79)', () => {
+    const lc = apcaLc('#4338ca', '#fef3c7');
+    assertInRange(lc, 77, 81, 'tween indigo-700 on amber');
+  });
+
+  test('adult fg #e6eef8 on panel #161a26 is Lc -90 to -100 (site claims -95)', () => {
+    const lc = apcaLc('#e6eef8', '#161a26');
+    assertInRange(lc, -100, -90, 'adult fg on panel');
+  });
+});
+
+// ---------------------------------------------------------------------------
+// classifyLc()
+// ---------------------------------------------------------------------------
+describe('classifyLc', () => {
+  test('Lc 106 returns "aaa"', () => {
+    assert.equal(classifyLc(106), 'aaa');
+  });
+
+  test('Lc 75 returns "aaa" (boundary)', () => {
+    assert.equal(classifyLc(75), 'aaa');
+  });
+
+  test('Lc 60 returns "aa"', () => {
+    assert.equal(classifyLc(60), 'aa');
+  });
+
+  test('Lc 74 returns "aa" (just below aaa)', () => {
+    assert.equal(classifyLc(74), 'aa');
+  });
+
+  test('Lc 45 returns "ui"', () => {
+    assert.equal(classifyLc(45), 'ui');
+  });
+
+  test('Lc 59 returns "ui" (just below aa)', () => {
+    assert.equal(classifyLc(59), 'ui');
+  });
+
+  test('Lc 40 returns "fail"', () => {
+    assert.equal(classifyLc(40), 'fail');
+  });
+
+  test('Lc 0 returns "fail"', () => {
+    assert.equal(classifyLc(0), 'fail');
+  });
+
+  test('negative Lc uses absolute value: -108 returns "aaa"', () => {
+    assert.equal(classifyLc(-108), 'aaa');
+  });
+
+  test('negative Lc uses absolute value: -60 returns "aa"', () => {
+    assert.equal(classifyLc(-60), 'aa');
+  });
+
+  test('negative Lc uses absolute value: -45 returns "ui"', () => {
+    assert.equal(classifyLc(-45), 'ui');
+  });
+
+  test('negative Lc uses absolute value: -40 returns "fail"', () => {
+    assert.equal(classifyLc(-40), 'fail');
+  });
+});
diff --git a/tests/unit/bands.test.js b/tests/unit/bands.test.js
new file mode 100644
index 0000000..afc1dc0
--- /dev/null
+++ b/tests/unit/bands.test.js
@@ -0,0 +1,216 @@
+'use strict';
+// Tests for src/bands.json (canonical age-adaptive band data, v9)
+// Run with: node --test tests/unit/bands.test.js
+
+const { test, describe } = require('node:test');
+const assert = require('node:assert/strict');
+const { execSync } = require('node:child_process');
+const path = require('node:path');
+const fs = require('node:fs');
+
+// ---------------------------------------------------------------------------
+// Paths
+// ---------------------------------------------------------------------------
+const CANONICAL_PATH = path.resolve(__dirname, '../../src/bands.json');
+const SKILL_PATH = path.resolve(
+  require('node:os').homedir(),
+  '.claude/skills/age-adaptive-theme/bands.json'
+);
+
+// ---------------------------------------------------------------------------
+// Load once — all tests share the same parsed object
+// ---------------------------------------------------------------------------
+let bands;
+
+describe('bands.json — load and shape', () => {
+  test('file exists and is valid JSON', () => {
+    const raw = fs.readFileSync(CANONICAL_PATH, 'utf8');
+    bands = JSON.parse(raw); // throws if invalid
+    assert.ok(bands, 'parsed object is truthy');
+  });
+
+  test('has version field matching semver pattern', () => {
+    assert.match(bands.version, /^\d+\.\d+\.\d+$/);
+  });
+
+  test('has validated_at field matching YYYY-MM-DD', () => {
+    assert.match(bands.validated_at, /^\d{4}-\d{2}-\d{2}$/);
+  });
+});
+
+// ---------------------------------------------------------------------------
+// tokens object
+// ---------------------------------------------------------------------------
+describe('bands.json — tokens', () => {
+  test('tokens object exists', () => {
+    assert.ok(bands.tokens && typeof bands.tokens === 'object');
+  });
+
+  test('font_line_height_default is a positive number', () => {
+    assert.ok(typeof bands.tokens.font_line_height_default === 'number');
+    assert.ok(bands.tokens.font_line_height_default > 0);
+  });
+
+  test('btn_min_height_default is a positive number', () => {
+    assert.ok(typeof bands.tokens.btn_min_height_default === 'number');
+    assert.ok(bands.tokens.btn_min_height_default > 0);
+  });
+});
+
+// ---------------------------------------------------------------------------
+// bands array shape
+// ---------------------------------------------------------------------------
+describe('bands.json — array count and ordering', () => {
+  test('bands array has exactly 8 entries', () => {
+    assert.ok(Array.isArray(bands.bands));
+    assert.equal(bands.bands.length, 8);
+  });
+
+  test('band IDs are exactly the expected 8 in order', () => {
+    const expected = ['toddler', 'kid', 'tween', 'teen', 'adult', 'mature', 'senior', 'elder'];
+    const actual = bands.bands.map(b => b.id);
+    assert.deepEqual(actual, expected);
+  });
+});
+
+// ---------------------------------------------------------------------------
+// Per-band required fields
+// ---------------------------------------------------------------------------
+const REQUIRED_TOP = ['id', 'age', 'label', 'current', 'best', 'swap', 'reasoning', 'mood', 'anchor', 'sample'];
+const REQUIRED_PALETTE = ['bg', 'panel', 'fg', 'accent', 'border', 'font', 'body', 'h'];
+const HEX_RE = /^#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/;
+
+function assertValidHex(value, label) {
+  assert.ok(
+    HEX_RE.test(value),
+    `${label}: "${value}" is not a valid hex colour`
+  );
+}
+
+// Bands are loaded synchronously above; if the JSON is missing (CI edge case)
+// these subtests will simply fail clearly rather than crash with undefined.
+describe('bands.json — per-band field validation', () => {
+  // Re-read to ensure `bands` variable is populated even if test ordering shifts
+  const data = JSON.parse(fs.readFileSync(CANONICAL_PATH, 'utf8'));
+
+  for (const band of data.bands) {
+    describe(`band: ${band.id}`, () => {
+      test('has all required top-level fields', () => {
+        for (const field of REQUIRED_TOP) {
+          assert.ok(
+            Object.prototype.hasOwnProperty.call(band, field),
+            `missing field: ${field}`
+          );
+        }
+      });
+
+      test('current has all required palette fields', () => {
+        for (const field of REQUIRED_PALETTE) {
+          assert.ok(
+            Object.prototype.hasOwnProperty.call(band.current, field),
+            `current missing: ${field}`
+          );
+        }
+      });
+
+      test('best has all required palette fields', () => {
+        for (const field of REQUIRED_PALETTE) {
+          assert.ok(
+            Object.prototype.hasOwnProperty.call(band.best, field),
+            `best missing: ${field}`
+          );
+        }
+      });
+
+      test('current colour values are valid hex', () => {
+        for (const field of ['bg', 'panel', 'fg', 'accent', 'border']) {
+          assertValidHex(band.current[field], `current.${field} (${band.id})`);
+        }
+      });
+
+      test('best colour values are valid hex', () => {
+        for (const field of ['bg', 'panel', 'fg', 'accent', 'border']) {
+          assertValidHex(band.best[field], `best.${field} (${band.id})`);
+        }
+      });
+
+      test('current.body is a positive integer', () => {
+        assert.ok(Number.isInteger(band.current.body) && band.current.body > 0,
+          `current.body = ${band.current.body}`);
+      });
+
+      test('current.h is a positive integer', () => {
+        assert.ok(Number.isInteger(band.current.h) && band.current.h > 0,
+          `current.h = ${band.current.h}`);
+      });
+
+      test('best.body is a positive integer', () => {
+        assert.ok(Number.isInteger(band.best.body) && band.best.body > 0,
+          `best.body = ${band.best.body}`);
+      });
+
+      test('best.h is a positive integer', () => {
+        assert.ok(Number.isInteger(band.best.h) && band.best.h > 0,
+          `best.h = ${band.best.h}`);
+      });
+    });
+  }
+});
+
+// ---------------------------------------------------------------------------
+// Mirror byte-equality check vs skill copy
+// ---------------------------------------------------------------------------
+describe('bands.json — mirror check vs skill copy', () => {
+  test('skill copy exists', () => {
+    assert.ok(fs.existsSync(SKILL_PATH), `skill bands.json not found at ${SKILL_PATH}`);
+  });
+
+  test('exactly 2 differing lines (one per side of the _source_of_truth_note change)', () => {
+    // diff exits with code 1 when files differ; execSync would throw — catch it
+    let diffOutput = '';
+    try {
+      diffOutput = execSync(
+        `diff "${CANONICAL_PATH}" "${SKILL_PATH}"`,
+        { encoding: 'utf8' }
+      );
+    } catch (err) {
+      // err.stdout contains the diff output when exit code is 1
+      diffOutput = err.stdout || '';
+    }
+
+    // Count lines starting with < or > (changed content lines, not @@ markers)
+    const changedLines = diffOutput
+      .split('\n')
+      .filter(line => /^[<>]/.test(line));
+
+    assert.equal(
+      changedLines.length,
+      2,
+      `Expected exactly 2 differing lines (1 per file for _source_of_truth_note),` +
+      ` got ${changedLines.length}.\nDiff:\n${diffOutput}`
+    );
+  });
+
+  test('the single differing field is _source_of_truth_note', () => {
+    let diffOutput = '';
+    try {
+      diffOutput = execSync(
+        `diff "${CANONICAL_PATH}" "${SKILL_PATH}"`,
+        { encoding: 'utf8' }
+      );
+    } catch (err) {
+      diffOutput = err.stdout || '';
+    }
+
+    const changedLines = diffOutput
+      .split('\n')
+      .filter(line => /^[<>]/.test(line));
+
+    for (const line of changedLines) {
+      assert.ok(
+        line.includes('_source_of_truth_note'),
+        `Unexpected diff line (not _source_of_truth_note):\n${line}`
+      );
+    }
+  });
+});

← 81b7308 polish(modals): proper slide-in via translateX (was display:  ·  back to Wallco Ai  ·  404 v2 (debate consensus): numeral tightening + strip pill + 3a43f9b →