← 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
M data/vision-cache.jsonM ecosystem.config.jsM scripts/generator_tick.jsA scripts/rate_design.jsM server.jsA tests/integration/admin-gate.spec.jsA tests/integration/age-themes-render.spec.jsA tests/integration/csv-export.spec.jsA tests/integration/pairings-api.spec.jsA tests/unit/apca.test.jsA tests/unit/bands.test.js
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')} · ${design.category.charAt(0).toUpperCase()+design.category.slice(1)} · ${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,'<') +'</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 →