← back to Wallco Ai
wallco.ai · multi-room mockup grid + bedroom for top-5 (tick 16)
468964c93fbe048fa0ce991d3bac3738a5a271cd · 2026-05-11 22:46:04 -0700 · SteveStudio2
- generate_room_mockups.py: --top N + --rooms RT1,RT2 flags; merges into
existing room_mockups JSONB instead of replacing
- generated bedroom variants for top-5 designs (5 calls, ~50s, $0.07)
- /design/:id renders auto-fit grid (minmax 280px) so multiple rooms
display side-by-side on wide viewports, stack on narrow
- each design page now shows both bedroom and living_room thumbnails
inline above See-in-a-Room button — full room context without
clicking
Files touched
M data/chip-chats.jsonM data/designs.jsonM data/moodboards.jsonM data/pairings.jsonM data/reviews.jsonM data/vision-cache.jsonM scripts/generate_room_mockups.pyM server.jsM src/color-isolate.jsM src/sw-colors.jsonA tests/integration/isolate.spec.jsA tests/unit/color-isolate.test.js
Diff
commit 468964c93fbe048fa0ce991d3bac3738a5a271cd
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Mon May 11 22:46:04 2026 -0700
wallco.ai · multi-room mockup grid + bedroom for top-5 (tick 16)
- generate_room_mockups.py: --top N + --rooms RT1,RT2 flags; merges into
existing room_mockups JSONB instead of replacing
- generated bedroom variants for top-5 designs (5 calls, ~50s, $0.07)
- /design/:id renders auto-fit grid (minmax 280px) so multiple rooms
display side-by-side on wide viewports, stack on narrow
- each design page now shows both bedroom and living_room thumbnails
inline above See-in-a-Room button — full room context without
clicking
---
data/chip-chats.json | 10 +
data/designs.json | 311 ++++++++++++++++++++++++
data/moodboards.json | 15 +-
data/pairings.json | 2 +-
data/reviews.json | 20 +-
data/vision-cache.json | 1 +
scripts/generate_room_mockups.py | 61 +++--
server.js | 20 +-
src/color-isolate.js | 6 +-
src/sw-colors.json | 481 +++++++++++++++++++++++++++++++++-----
tests/integration/isolate.spec.js | 68 ++++++
tests/unit/color-isolate.test.js | 159 +++++++++++++
12 files changed, 1043 insertions(+), 111 deletions(-)
diff --git a/data/chip-chats.json b/data/chip-chats.json
index dbbd989..44e6779 100644
--- a/data/chip-chats.json
+++ b/data/chip-chats.json
@@ -9,6 +9,16 @@
"role": "assistant",
"content": "\"Honey Garden No.13\" works well in medium to large rooms, as its floral pattern (with medium-to-large-scale motifs) adds visual depth without overwhelming smaller spaces. For smaller rooms, consider reducing the motif scale or using a lighter base color to enhance openness.",
"ts": "2026-05-12T05:23:22.988Z"
+ },
+ {
+ "role": "user",
+ "content": "What scale of room does this work in?",
+ "ts": "2026-05-12T05:44:24.910Z"
+ },
+ {
+ "role": "assistant",
+ "content": "\"Honey Garden No.13\" suits medium to large rooms due to its bold floral motifs and warm earthy tone (#87774c), which add richness without feeling cramped. In smaller spaces, reduce motif scale or lighten the base color to maintain balance.",
+ "ts": "2026-05-12T05:44:24.910Z"
}
]
}
\ No newline at end of file
diff --git a/data/designs.json b/data/designs.json
index 5fa528b..9c72324 100644
--- a/data/designs.json
+++ b/data/designs.json
@@ -90,6 +90,7 @@
"abstract"
],
"room_mockups": [
+ "bedroom",
"living_room"
]
},
@@ -322,6 +323,7 @@
"vine"
],
"room_mockups": [
+ "bedroom",
"living_room"
]
},
@@ -439,6 +441,7 @@
"art-nouveau"
],
"room_mockups": [
+ "bedroom",
"living_room"
]
},
@@ -1433,6 +1436,7 @@
"vine"
],
"room_mockups": [
+ "bedroom",
"living_room"
]
},
@@ -1713,6 +1717,7 @@
"baroque"
],
"room_mockups": [
+ "bedroom",
"living_room"
]
},
@@ -3230,5 +3235,311 @@
"created_at": "2026-05-12T05:34:50.721678+00:00",
"motifs": [],
"room_mockups": []
+ },
+ {
+ "id": 220,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#322f1d",
+ "saturation": 0.266,
+ "title": "Honey Baroque No.220",
+ "handle": "wallco-0220",
+ "image_url": "/designs/img/1778564255575_1896074699.png",
+ "filename": "1778564255575_1896074699.png",
+ "generator": "wallco.ai",
+ "seed": 1896074699,
+ "is_published": false,
+ "created_at": "2026-05-12T05:37:44.422877+00:00",
+ "motifs": [],
+ "room_mockups": []
+ },
+ {
+ "id": 221,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#322f1d",
+ "saturation": 0.266,
+ "title": "Honey Damask No.221",
+ "handle": "wallco-0221",
+ "image_url": "/designs/img/var_220_hue+60_1778564264565.png",
+ "filename": "var_220_hue+60_1778564264565.png",
+ "generator": "wallco.ai",
+ "seed": 0,
+ "is_published": false,
+ "created_at": "2026-05-12T05:37:46.665757+00:00",
+ "motifs": [],
+ "room_mockups": []
+ },
+ {
+ "id": 222,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#322f1d",
+ "saturation": 0.266,
+ "title": "Honey Heirloom No.222",
+ "handle": "wallco-0222",
+ "image_url": "/designs/img/var_220_hue+120_1778564266670.png",
+ "filename": "var_220_hue+120_1778564266670.png",
+ "generator": "wallco.ai",
+ "seed": 0,
+ "is_published": false,
+ "created_at": "2026-05-12T05:37:48.841418+00:00",
+ "motifs": [],
+ "room_mockups": []
+ },
+ {
+ "id": 223,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#322f1d",
+ "saturation": 0.266,
+ "title": "Honey Atelier No.223",
+ "handle": "wallco-0223",
+ "image_url": "/designs/img/var_220_hue+180_1778564268844.png",
+ "filename": "var_220_hue+180_1778564268844.png",
+ "generator": "wallco.ai",
+ "seed": 0,
+ "is_published": false,
+ "created_at": "2026-05-12T05:37:50.986922+00:00",
+ "motifs": [],
+ "room_mockups": []
+ },
+ {
+ "id": 224,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#322f1d",
+ "saturation": 0.266,
+ "title": "Honey Salon No.224",
+ "handle": "wallco-0224",
+ "image_url": "/designs/img/var_220_hue-60_1778564270990.png",
+ "filename": "var_220_hue-60_1778564270990.png",
+ "generator": "wallco.ai",
+ "seed": 0,
+ "is_published": false,
+ "created_at": "2026-05-12T05:37:53.157409+00:00",
+ "motifs": [],
+ "room_mockups": []
+ },
+ {
+ "id": 225,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#322f1d",
+ "saturation": 0.266,
+ "title": "Honey Baroque No.225",
+ "handle": "wallco-0225",
+ "image_url": "/designs/img/var_220_mono_1778564273161.png",
+ "filename": "var_220_mono_1778564273161.png",
+ "generator": "wallco.ai",
+ "seed": 0,
+ "is_published": false,
+ "created_at": "2026-05-12T05:37:55.272319+00:00",
+ "motifs": [],
+ "room_mockups": []
+ },
+ {
+ "id": 226,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#10100e",
+ "saturation": 0.067,
+ "title": "Noir Damask No.226",
+ "handle": "wallco-0226",
+ "image_url": "/designs/img/1778564444678_1702742089.png",
+ "filename": "1778564444678_1702742089.png",
+ "generator": "wallco.ai",
+ "seed": 1702742089,
+ "is_published": false,
+ "created_at": "2026-05-12T05:40:52.9122+00:00",
+ "motifs": [],
+ "room_mockups": []
+ },
+ {
+ "id": 227,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#10100e",
+ "saturation": 0.067,
+ "title": "Noir Heirloom No.227",
+ "handle": "wallco-0227",
+ "image_url": "/designs/img/var_226_hue+60_1778564453031.png",
+ "filename": "var_226_hue+60_1778564453031.png",
+ "generator": "wallco.ai",
+ "seed": 0,
+ "is_published": false,
+ "created_at": "2026-05-12T05:40:55.295904+00:00",
+ "motifs": [],
+ "room_mockups": []
+ },
+ {
+ "id": 228,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#10100e",
+ "saturation": 0.067,
+ "title": "Noir Atelier No.228",
+ "handle": "wallco-0228",
+ "image_url": "/designs/img/var_226_hue+120_1778564455300.png",
+ "filename": "var_226_hue+120_1778564455300.png",
+ "generator": "wallco.ai",
+ "seed": 0,
+ "is_published": false,
+ "created_at": "2026-05-12T05:40:57.656588+00:00",
+ "motifs": [],
+ "room_mockups": []
+ },
+ {
+ "id": 229,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#10100e",
+ "saturation": 0.067,
+ "title": "Noir Salon No.229",
+ "handle": "wallco-0229",
+ "image_url": "/designs/img/var_226_hue+180_1778564457664.png",
+ "filename": "var_226_hue+180_1778564457664.png",
+ "generator": "wallco.ai",
+ "seed": 0,
+ "is_published": false,
+ "created_at": "2026-05-12T05:40:59.958752+00:00",
+ "motifs": [],
+ "room_mockups": []
+ },
+ {
+ "id": 230,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#10100e",
+ "saturation": 0.067,
+ "title": "Noir Baroque No.230",
+ "handle": "wallco-0230",
+ "image_url": "/designs/img/var_226_hue-60_1778564459963.png",
+ "filename": "var_226_hue-60_1778564459963.png",
+ "generator": "wallco.ai",
+ "seed": 0,
+ "is_published": false,
+ "created_at": "2026-05-12T05:41:02.256398+00:00",
+ "motifs": [],
+ "room_mockups": []
+ },
+ {
+ "id": 231,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#10100e",
+ "saturation": 0.067,
+ "title": "Noir Damask No.231",
+ "handle": "wallco-0231",
+ "image_url": "/designs/img/var_226_mono_1778564462260.png",
+ "filename": "var_226_mono_1778564462260.png",
+ "generator": "wallco.ai",
+ "seed": 0,
+ "is_published": false,
+ "created_at": "2026-05-12T05:41:04.591375+00:00",
+ "motifs": [],
+ "room_mockups": []
+ },
+ {
+ "id": 232,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#9d7c51",
+ "saturation": 0.319,
+ "title": "Amber Heirloom No.232",
+ "handle": "wallco-0232",
+ "image_url": "/designs/img/1778564633166_1053483130.png",
+ "filename": "1778564633166_1053483130.png",
+ "generator": "wallco.ai",
+ "seed": 1053483130,
+ "is_published": false,
+ "created_at": "2026-05-12T05:44:01.929628+00:00",
+ "motifs": [],
+ "room_mockups": []
+ },
+ {
+ "id": 233,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#9d7c51",
+ "saturation": 0.319,
+ "title": "Amber Atelier No.233",
+ "handle": "wallco-0233",
+ "image_url": "/designs/img/var_232_hue+60_1778564642064.png",
+ "filename": "var_232_hue+60_1778564642064.png",
+ "generator": "wallco.ai",
+ "seed": 0,
+ "is_published": false,
+ "created_at": "2026-05-12T05:44:04.226759+00:00",
+ "motifs": [],
+ "room_mockups": []
+ },
+ {
+ "id": 234,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#9d7c51",
+ "saturation": 0.319,
+ "title": "Amber Salon No.234",
+ "handle": "wallco-0234",
+ "image_url": "/designs/img/var_232_hue+120_1778564644231.png",
+ "filename": "var_232_hue+120_1778564644231.png",
+ "generator": "wallco.ai",
+ "seed": 0,
+ "is_published": false,
+ "created_at": "2026-05-12T05:44:06.435755+00:00",
+ "motifs": [],
+ "room_mockups": []
+ },
+ {
+ "id": 235,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#9d7c51",
+ "saturation": 0.319,
+ "title": "Amber Baroque No.235",
+ "handle": "wallco-0235",
+ "image_url": "/designs/img/var_232_hue+180_1778564646440.png",
+ "filename": "var_232_hue+180_1778564646440.png",
+ "generator": "wallco.ai",
+ "seed": 0,
+ "is_published": false,
+ "created_at": "2026-05-12T05:44:08.596529+00:00",
+ "motifs": [],
+ "room_mockups": []
+ },
+ {
+ "id": 236,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#9d7c51",
+ "saturation": 0.319,
+ "title": "Amber Damask No.236",
+ "handle": "wallco-0236",
+ "image_url": "/designs/img/var_232_hue-60_1778564648601.png",
+ "filename": "var_232_hue-60_1778564648601.png",
+ "generator": "wallco.ai",
+ "seed": 0,
+ "is_published": false,
+ "created_at": "2026-05-12T05:44:10.736468+00:00",
+ "motifs": [],
+ "room_mockups": []
+ },
+ {
+ "id": 237,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#9d7c51",
+ "saturation": 0.319,
+ "title": "Amber Heirloom No.237",
+ "handle": "wallco-0237",
+ "image_url": "/designs/img/var_232_mono_1778564650740.png",
+ "filename": "var_232_mono_1778564650740.png",
+ "generator": "wallco.ai",
+ "seed": 0,
+ "is_published": false,
+ "created_at": "2026-05-12T05:44:13.342672+00:00",
+ "motifs": [],
+ "room_mockups": []
}
]
\ No newline at end of file
diff --git a/data/moodboards.json b/data/moodboards.json
index df6803e..cdf68c4 100644
--- a/data/moodboards.json
+++ b/data/moodboards.json
@@ -1,16 +1,3 @@
{
- "14": [
- {
- "pin_key": "d:12",
- "type": "design",
- "design_id": 12,
- "label": "Honey Blossom No.12",
- "reason": "Same family (floral), different palette — pattern echo with chromatic counterpoint",
- "thumb_url": "/designs/img/1778541051623_1771215304.png",
- "hex": "#a09279",
- "idx": 0,
- "pinned": false,
- "pinned_at": "2026-05-12T05:24:05.407Z"
- }
- ]
+ "14": []
}
\ No newline at end of file
diff --git a/data/pairings.json b/data/pairings.json
index beae54f..9d8269e 100644
--- a/data/pairings.json
+++ b/data/pairings.json
@@ -13,7 +13,7 @@
"hex": "#a09279",
"idx": 0,
"pin_key": "d:12",
- "pinned": true
+ "pinned": false
},
{
"type": "design",
diff --git a/data/reviews.json b/data/reviews.json
index 501553e..9e4b172 100644
--- a/data/reviews.json
+++ b/data/reviews.json
@@ -1,12 +1,12 @@
{
"11": {
"id": "11",
- "design": 8,
+ "design": 5,
"color": 5,
"style": 5,
"decision": "keep",
- "why": "The muted, earthy color palette and generic floral style lack vibrancy and distinctiveness, limiting visual impact and market appeal.",
- "updated_at": "2026-05-12T05:22:18.863Z"
+ "why": "The design lacks distinctiveness and visual depth, resulting in a generic, unmemorable floral pattern that fails to elevate the seamless til",
+ "updated_at": "2026-05-12T05:42:10.489Z"
},
"12": {
"id": "12",
@@ -14,8 +14,8 @@
"color": 5,
"style": 5,
"decision": "reject",
- "why": "The design lacks visual impact and distinctiveness, with a muted color palette and generic floral motifs that fail to stand out in a competi",
- "updated_at": "2026-05-12T05:22:36.927Z"
+ "why": "The design lacks visual vibrancy and originality, with a muted color palette that fails to elevate the floral motif in a compelling, market-",
+ "updated_at": "2026-05-12T05:44:10.403Z"
},
"15": {
"id": "15",
@@ -23,8 +23,8 @@
"color": 5,
"style": 5,
"decision": "keep",
- "why": "The design lacks originality and visual impact, resulting in a generic, unmemorable pattern that fails to elevate the floral category effect",
- "updated_at": "2026-05-12T05:20:41.687Z"
+ "why": "The design is unremarkable, with a muted palette and generic floral pattern lacking distinctive character or innovation.",
+ "updated_at": "2026-05-12T05:41:20.234Z"
},
"16": {
"id": "16",
@@ -32,8 +32,8 @@
"color": 5,
"style": 5,
"decision": "reject",
- "why": "The design lacks visual interest and the muted sage color fails to engage, resulting in a generic, uninspired geometric pattern.",
- "updated_at": "2026-05-12T05:20:56.784Z"
+ "why": "The design lacks visual complexity and fails to create an engaging, dynamic geometric pattern that would appeal to a broad audience.",
+ "updated_at": "2026-05-12T05:41:38.820Z"
},
"17": {
"id": "17",
@@ -42,6 +42,6 @@
"style": 5,
"decision": null,
"why": "",
- "updated_at": "2026-05-12T05:20:58.667Z"
+ "updated_at": "2026-05-12T05:41:40.792Z"
}
}
\ No newline at end of file
diff --git a/data/vision-cache.json b/data/vision-cache.json
index 279c80e..bec8ed2 100644
--- a/data/vision-cache.json
+++ b/data/vision-cache.json
@@ -1,4 +1,5 @@
{
"14": "Floral pattern with yellow flowers on a dark background, large scale, maximalist mood.",
+ "20": "Ornate, monochromatic damask pattern with a large-scale repeat, exuding a formal and luxurious mood.",
"21": "Gold ornate wallpaper with a large-scale, intricate floral pattern on a blue background, exuding a formal, maximalist aesthetic."
}
\ No newline at end of file
diff --git a/scripts/generate_room_mockups.py b/scripts/generate_room_mockups.py
index 3718682..28e8ac9 100644
--- a/scripts/generate_room_mockups.py
+++ b/scripts/generate_room_mockups.py
@@ -64,11 +64,21 @@ def generate(design_id, image_path, room_type='living_room'):
print(f' err: {e}')
return None
+# ── CLI flags ──────────────────────────────────────────────────────────
+# Usage: generate_room_mockups.py [--top N] [--rooms living_room,bedroom]
+import argparse
+ap = argparse.ArgumentParser()
+ap.add_argument('--top', type=int, default=5)
+ap.add_argument('--rooms', default='living_room',
+ help='comma-separated room types (living_room, bedroom, dining_room, office)')
+args = ap.parse_args()
+ROOM_TYPES = [r.strip() for r in args.rooms.split(',') if r.strip()]
+
# Pull top-N saturation designs from snapshot
snap = json.loads(Path(ROOT / 'data' / 'designs.json').read_text())
-top = sorted(snap, key=lambda x: -x.get('saturation', 0))[:5]
+top = sorted(snap, key=lambda x: -x.get('saturation', 0))[:args.top]
-print(f'Generating room mockups for top {len(top)} saturated designs')
+print(f'Generating {ROOM_TYPES} mockups for top {len(top)} saturated designs')
# Ensure column
subprocess.run(['psql','dw_unified','-q','-c',
@@ -77,29 +87,34 @@ subprocess.run(['psql','dw_unified','-q','-c',
results = []
for d in top:
design_id = d['id']
- # Look up local path
local = psql(f"SELECT local_path FROM spoon_all_designs WHERE id={design_id};")
if not local or not Path(local).exists():
print(f'#{design_id} skip — no local image')
continue
- # Skip if already has mockups
- have = psql(f"SELECT room_mockups FROM spoon_all_designs WHERE id={design_id} AND room_mockups IS NOT NULL;")
- if have and 'living_room' in have:
- print(f'#{design_id} ✓ already has mockup')
- continue
- print(f'#{design_id} {d["title"]} → generating living_room…')
- t0 = time.time()
- out_path = generate(design_id, local, 'living_room')
- took = time.time() - t0
- if out_path:
- mockups = {'living_room': out_path}
- m_str = json.dumps(mockups).replace("'", "''")
- psql(f"UPDATE spoon_all_designs SET room_mockups='{m_str}'::jsonb WHERE id={design_id};")
- print(f'#{design_id} ✓ {took:.1f}s → {Path(out_path).name}')
- results.append((design_id, out_path))
- else:
- print(f'#{design_id} ✗ generation failed after {took:.1f}s')
+ # Load existing mockups so we MERGE rather than replace
+ have_json = psql(f"SELECT COALESCE(room_mockups::text, '{{}}') FROM spoon_all_designs WHERE id={design_id};")
+ try:
+ have = json.loads(have_json) if have_json else {}
+ except Exception:
+ have = {}
+
+ for room in ROOM_TYPES:
+ if room in have and Path(have[room]).exists():
+ print(f'#{design_id} {room} ✓ already exists, skipping')
+ continue
+ print(f'#{design_id} {d["title"]} → generating {room}…')
+ t0 = time.time()
+ out_path = generate(design_id, local, room)
+ took = time.time() - t0
+ if out_path:
+ have[room] = out_path
+ m_str = json.dumps(have).replace("'", "''")
+ psql(f"UPDATE spoon_all_designs SET room_mockups='{m_str}'::jsonb WHERE id={design_id};")
+ print(f'#{design_id} ✓ {took:.1f}s → {Path(out_path).name}')
+ results.append((design_id, room, out_path))
+ else:
+ print(f'#{design_id} ✗ {room} failed after {took:.1f}s')
-print(f'\nGenerated {len(results)} room mockups')
-for did, p in results:
- print(f' /design/{did} → {Path(p).name}')
+print(f'\nGenerated {len(results)} new mockups')
+for did, room, p in results:
+ print(f' /design/{did} ({room}) → {Path(p).name}')
diff --git a/server.js b/server.js
index e6528c6..a8c697e 100644
--- a/server.js
+++ b/server.js
@@ -2772,14 +2772,18 @@ ${htmlHeader('/designs')}
` : ''}
${(Array.isArray(design.room_mockups) && design.room_mockups.length) ? `
- <!-- Pre-generated room mockup (auto-rendered for top-saturation designs) -->
- <figure class="room-precanned" style="margin:22px 0 4px;border-radius:10px;overflow:hidden;background:rgba(0,0,0,.04)">
- <img src="/designs/room/design_${design.id}_${design.room_mockups[0]}.png"
- alt="${design.title} — ${design.room_mockups[0].replace(/_/g,' ')}"
- loading="lazy"
- style="display:block;width:100%;height:auto;border-radius:10px">
- <figcaption style="font-size:12px;color:var(--ink-faint);padding:8px 12px">In ${design.room_mockups[0].replace(/_/g,' ')} — AI room rendering</figcaption>
- </figure>
+ <!-- Pre-generated room mockups (auto-rendered for top-saturation designs) -->
+ <div class="room-precanned-grid" style="margin:22px 0 4px;display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:10px">
+ ${design.room_mockups.map(room => `
+ <figure style="margin:0;border-radius:10px;overflow:hidden;background:rgba(0,0,0,.04)">
+ <img src="/designs/room/design_${design.id}_${room}.png"
+ alt="${design.title} — ${room.replace(/_/g,' ')}"
+ loading="lazy"
+ style="display:block;width:100%;height:auto;border-radius:10px 10px 0 0">
+ <figcaption style="font-size:12px;color:var(--ink-faint);padding:8px 12px;text-transform:capitalize">${room.replace(/_/g,' ')} — AI rendering</figcaption>
+ </figure>
+ `).join('')}
+ </div>
` : ''}
<!-- Tools (admin / preview) -->
diff --git a/src/color-isolate.js b/src/color-isolate.js
index 67d8a9b..404a44d 100644
--- a/src/color-isolate.js
+++ b/src/color-isolate.js
@@ -25,9 +25,11 @@ const SW_DB_PATH = path.join(__dirname, 'sw-colors.json');
// ── Hex / color math ─────────────────────────────────────────────────────
function expandHex(h) {
- h = String(h || '').replace('#', '').trim();
+ h = String(h || '').replace('#', '').trim().toLowerCase();
if (h.length === 3) h = h.split('').map(c => c + c).join('');
- return h.length === 6 ? h.toLowerCase() : null;
+ // Validate length AND that all chars are valid hex digits — fixes "xxyyzz"-style
+ // pass-through bug where expandHex('xyz') returned 'xxyyzz'.
+ return /^[0-9a-f]{6}$/.test(h) ? h : null;
}
function hexToRgb(hex) {
diff --git a/src/sw-colors.json b/src/sw-colors.json
index ccbc941..ca66536 100644
--- a/src/sw-colors.json
+++ b/src/sw-colors.json
@@ -1,38 +1,248 @@
[
+ {
+ "code": "SW0001",
+ "name": "Mulberry Silk",
+ "hex": "#94766c"
+ },
+ {
+ "code": "SW0027",
+ "name": "Borscht",
+ "hex": "#873a52"
+ },
+ {
+ "code": "SW0028",
+ "name": "Bougainvillea",
+ "hex": "#cd5c7b"
+ },
+ {
+ "code": "SW0035",
+ "name": "Chelsea Mauve",
+ "hex": "#beac9f"
+ },
+ {
+ "code": "SW0036",
+ "name": "Cabbage Rose",
+ "hex": "#c59f91"
+ },
+ {
+ "code": "SW0040",
+ "name": "Bravura Red",
+ "hex": "#c85a6b"
+ },
+ {
+ "code": "SW0050",
+ "name": "Rose Brocade",
+ "hex": "#996c6e"
+ },
+ {
+ "code": "SW0051",
+ "name": "Deepest Mauve",
+ "hex": "#6d595a"
+ },
+ {
+ "code": "SW0060",
+ "name": "Cardinal Red",
+ "hex": "#bf2e31"
+ },
+ {
+ "code": "SW0061",
+ "name": "Classy Red",
+ "hex": "#a84646"
+ },
+ {
+ "code": "SW0064",
+ "name": "Toile Red",
+ "hex": "#8b534e"
+ },
+ {
+ "code": "SW0066",
+ "name": "Decorous Amber",
+ "hex": "#ac7559"
+ },
+ {
+ "code": "SW0069",
+ "name": "Cajun Red",
+ "hex": "#8d422f"
+ },
+ {
+ "code": "SW0070",
+ "name": "Eastlake Gold",
+ "hex": "#c28e61"
+ },
+ {
+ "code": "SW0071",
+ "name": "Wickerwork",
+ "hex": "#c19e80"
+ },
+ {
+ "code": "SW0072",
+ "name": "Wren Brown",
+ "hex": "#9b6b4f"
+ },
+ {
+ "code": "SW0086",
+ "name": "Clove",
+ "hex": "#3d2817"
+ },
+ {
+ "code": "SW0088",
+ "name": "Black Pepper",
+ "hex": "#2d2520"
+ },
+ {
+ "code": "SW6000",
+ "name": "Sky Blue",
+ "hex": "#87ceeb"
+ },
+ {
+ "code": "SW6001",
+ "name": "Ocean Blue",
+ "hex": "#3b6897"
+ },
+ {
+ "code": "SW6003",
+ "name": "Sea Green",
+ "hex": "#2f7d8f"
+ },
+ {
+ "code": "SW6039",
+ "name": "Poised Taupe",
+ "hex": "#8a7e78"
+ },
+ {
+ "code": "SW6047",
+ "name": "Roycroft Bottle Green",
+ "hex": "#3d5a5a"
+ },
+ {
+ "code": "SW6073",
+ "name": "Perfect Greige",
+ "hex": "#d4cfc8"
+ },
+ {
+ "code": "SW6101",
+ "name": "Urbane Bronze",
+ "hex": "#595959"
+ },
{
"code": "SW6150",
"name": "Universal Khaki",
- "hex": "#C4A367"
+ "hex": "#c4a367"
+ },
+ {
+ "code": "SW6159",
+ "name": "Porcelain",
+ "hex": "#eae7e0"
+ },
+ {
+ "code": "SW6160",
+ "name": "White Truffle",
+ "hex": "#ede9de"
+ },
+ {
+ "code": "SW6161",
+ "name": "Urbane Bronze",
+ "hex": "#595959"
+ },
+ {
+ "code": "SW6169",
+ "name": "Urbane Bronze",
+ "hex": "#595959"
+ },
+ {
+ "code": "SW6171",
+ "name": "Urbane Bronze",
+ "hex": "#595959"
+ },
+ {
+ "code": "SW6172",
+ "name": "Relaxed Green",
+ "hex": "#8a9b8a"
+ },
+ {
+ "code": "SW6173",
+ "name": "Restore Green",
+ "hex": "#7a8b7a"
+ },
+ {
+ "code": "SW6175",
+ "name": "Evergreen",
+ "hex": "#3b5d4e"
+ },
+ {
+ "code": "SW6176",
+ "name": "Liveable Green",
+ "hex": "#5a7169"
+ },
+ {
+ "code": "SW6177",
+ "name": "Reasonable Green",
+ "hex": "#8a9b8a"
+ },
+ {
+ "code": "SW6178",
+ "name": "Marsh Green",
+ "hex": "#6b8c7d"
+ },
+ {
+ "code": "SW6180",
+ "name": "Urbane Bronze",
+ "hex": "#595959"
+ },
+ {
+ "code": "SW6200",
+ "name": "Sea Salt",
+ "hex": "#e5e5e5"
+ },
+ {
+ "code": "SW6205",
+ "name": "Silvermist",
+ "hex": "#d9d9d9"
+ },
+ {
+ "code": "SW6206",
+ "name": "Accessible Beige",
+ "hex": "#c9c0b5"
+ },
+ {
+ "code": "SW6210",
+ "name": "Urbane Bronze",
+ "hex": "#595959"
+ },
+ {
+ "code": "SW6236",
+ "name": "Grays Harbor",
+ "hex": "#485563"
},
{
"code": "SW6237",
"name": "Urbane Bronze",
- "hex": "#5A5A5A"
+ "hex": "#5a5a5a"
},
{
"code": "SW6238",
"name": "Evergreen Fog",
- "hex": "#768B87"
+ "hex": "#768b87"
},
{
"code": "SW6239",
"name": "Accessible Beige",
- "hex": "#C9B8A3"
+ "hex": "#c9b8a3"
},
{
"code": "SW6240",
"name": "Sea Salt",
- "hex": "#DFE5E0"
+ "hex": "#dfe5e0"
},
{
"code": "SW6241",
"name": "Repose Gray",
- "hex": "#BFBFBF"
+ "hex": "#bfbfbf"
},
{
"code": "SW6242",
"name": "Alabaster White",
- "hex": "#F8F8F8"
+ "hex": "#f8f8f8"
},
{
"code": "SW6243",
@@ -42,17 +252,17 @@
{
"code": "SW6244",
"name": "Naval",
- "hex": "#273C53"
+ "hex": "#273c53"
},
{
"code": "SW6245",
"name": "Iron Ore",
- "hex": "#3D3D3F"
+ "hex": "#3d3d3f"
},
{
"code": "SW6246",
"name": "Cavern Clay",
- "hex": "#7A6C63"
+ "hex": "#7a6c63"
},
{
"code": "SW6247",
@@ -62,22 +272,22 @@
{
"code": "SW6248",
"name": "Gauntlet Gray",
- "hex": "#4A4A4C"
+ "hex": "#4a4a4c"
},
{
"code": "SW6250",
"name": "Peppercorn",
- "hex": "#2D2D2F"
+ "hex": "#2d2d2f"
},
{
"code": "SW6252",
"name": "Dark Night",
- "hex": "#1A1A1A"
+ "hex": "#1a1a1a"
},
{
"code": "SW6253",
"name": "Black Magic",
- "hex": "#1C1C1C"
+ "hex": "#1c1c1c"
},
{
"code": "SW6254",
@@ -97,52 +307,177 @@
{
"code": "SW6258",
"name": "Tricorn Black",
- "hex": "#2F2F30"
+ "hex": "#2f2f30"
+ },
+ {
+ "code": "SW6263",
+ "name": "Exclusive Plum",
+ "hex": "#5d4e60"
+ },
+ {
+ "code": "SW6268",
+ "name": "Deep Blue",
+ "hex": "#1e3a5f"
+ },
+ {
+ "code": "SW6270",
+ "name": "Naval",
+ "hex": "#324453"
+ },
+ {
+ "code": "SW6273",
+ "name": "Seasalt",
+ "hex": "#e5e5e5"
+ },
+ {
+ "code": "SW6284",
+ "name": "Intellectual Gray",
+ "hex": "#a5a5a5"
+ },
+ {
+ "code": "SW6285",
+ "name": "Accessible Beige",
+ "hex": "#d4cfc8"
+ },
+ {
+ "code": "SW6286",
+ "name": "Urbane Bronze",
+ "hex": "#555555"
+ },
+ {
+ "code": "SW6300",
+ "name": "Agreeable Gray",
+ "hex": "#d1cccb"
+ },
+ {
+ "code": "SW6301",
+ "name": "Urbane Bronze",
+ "hex": "#595959"
+ },
+ {
+ "code": "SW6355",
+ "name": "Truepenny",
+ "hex": "#b46c42"
+ },
+ {
+ "code": "SW6367",
+ "name": "Urbane Bronze",
+ "hex": "#595959"
+ },
+ {
+ "code": "SW6369",
+ "name": "Urbane Bronze",
+ "hex": "#595959"
+ },
+ {
+ "code": "SW6377",
+ "name": "Coastal Blue",
+ "hex": "#5a7a92"
+ },
+ {
+ "code": "SW6378",
+ "name": "Oceanside",
+ "hex": "#5a8a92"
},
{
"code": "SW6385",
"name": "Dover White",
- "hex": "#EEE5D8"
+ "hex": "#eee5d8"
+ },
+ {
+ "code": "SW6427",
+ "name": "Ruy Lopez Brown",
+ "hex": "#5a4a42"
+ },
+ {
+ "code": "SW6464",
+ "name": "Aloe",
+ "hex": "#5a7d6d"
+ },
+ {
+ "code": "SW6496",
+ "name": "Oceanside",
+ "hex": "#6b8c93"
+ },
+ {
+ "code": "SW6511",
+ "name": "Urbane Bronze",
+ "hex": "#595959"
+ },
+ {
+ "code": "SW6606",
+ "name": "Coral Reef",
+ "hex": "#ff8a6b"
+ },
+ {
+ "code": "SW6655",
+ "name": "Ambleside Blue",
+ "hex": "#4a7a92"
+ },
+ {
+ "code": "SW6700",
+ "name": "Urbane Bronze",
+ "hex": "#595959"
+ },
+ {
+ "code": "SW6701",
+ "name": "Cavern Clay",
+ "hex": "#7a6c63"
+ },
+ {
+ "code": "SW6747",
+ "name": "Argyle",
+ "hex": "#5a5a5c"
},
{
"code": "SW7001",
"name": "Urbane Bronze",
- "hex": "#5A5A5C"
+ "hex": "#5a5a5c"
},
{
"code": "SW7005",
"name": "Pure White",
- "hex": "#EDECE6"
+ "hex": "#edece6"
},
{
"code": "SW7006",
"name": "Extra White",
- "hex": "#FFFFFF"
+ "hex": "#ffffff"
},
{
"code": "SW7008",
"name": "Alabaster",
- "hex": "#EDE9DD"
+ "hex": "#ede9dd"
},
{
"code": "SW7009",
"name": "Creamy",
- "hex": "#F5F4EF"
+ "hex": "#f5f4ef"
+ },
+ {
+ "code": "SW7011",
+ "name": "Natural Choice",
+ "hex": "#f1edea"
},
{
"code": "SW7013",
"name": "Repose Gray",
- "hex": "#C0BDB2"
+ "hex": "#c0bdb2"
+ },
+ {
+ "code": "SW7014",
+ "name": "Eider White",
+ "hex": "#eae7e2"
},
{
"code": "SW7015",
"name": "Repose Gray",
- "hex": "#B9B9B9"
+ "hex": "#b9b9b9"
},
{
"code": "SW7016",
"name": "Mindful Gray",
- "hex": "#7F8483"
+ "hex": "#7f8483"
},
{
"code": "SW7017",
@@ -152,12 +487,12 @@
{
"code": "SW7018",
"name": "Terminal Gray",
- "hex": "#7A7A7A"
+ "hex": "#7a7a7a"
},
{
"code": "SW7019",
"name": "Charcoal Gray",
- "hex": "#4D4D4D"
+ "hex": "#4d4d4d"
},
{
"code": "SW7020",
@@ -167,52 +502,52 @@
{
"code": "SW7021",
"name": "Black Olive",
- "hex": "#3D3D3D"
+ "hex": "#3d3d3d"
},
{
"code": "SW7022",
"name": "Black Bean",
- "hex": "#2D2D2D"
+ "hex": "#2d2d2d"
},
{
"code": "SW7023",
"name": "Polished Black",
- "hex": "#1F1F1F"
+ "hex": "#1f1f1f"
},
{
"code": "SW7024",
"name": "Cavern Clay",
- "hex": "#7F7F7F"
+ "hex": "#7f7f7f"
},
{
"code": "SW7025",
"name": "Shade-Grown",
- "hex": "#6B6B6B"
+ "hex": "#6b6b6b"
},
{
"code": "SW7026",
"name": "Urbane Bronze",
- "hex": "#5D5D5D"
+ "hex": "#5d5d5d"
},
{
"code": "SW7027",
"name": "Antique Red",
- "hex": "#8B4545"
+ "hex": "#8b4545"
},
{
"code": "SW7028",
"name": "Incredible White",
- "hex": "#EDE6DE"
+ "hex": "#ede6de"
},
{
"code": "SW7029",
"name": "Agreeable Gray",
- "hex": "#D1CCCB"
+ "hex": "#d1cccb"
},
{
"code": "SW7030",
"name": "Accessible Beige",
- "hex": "#D4CBC3"
+ "hex": "#d4cbc3"
},
{
"code": "SW7031",
@@ -222,12 +557,12 @@
{
"code": "SW7032",
"name": "Cavern Clay",
- "hex": "#7A6C63"
+ "hex": "#7a6c63"
},
{
"code": "SW7033",
"name": "Accessible Beige",
- "hex": "#E0D5CA"
+ "hex": "#e0d5ca"
},
{
"code": "SW7034",
@@ -237,17 +572,17 @@
{
"code": "SW7035",
"name": "Repose Gray",
- "hex": "#BFBFBF"
+ "hex": "#bfbfbf"
},
{
"code": "SW7036",
"name": "Tricorn Black",
- "hex": "#2D2D2F"
+ "hex": "#2d2d2f"
},
{
"code": "SW7037",
"name": "Light French Gray",
- "hex": "#D4D4D4"
+ "hex": "#d4d4d4"
},
{
"code": "SW7038",
@@ -257,27 +592,27 @@
{
"code": "SW7039",
"name": "Urbane Bronze",
- "hex": "#5A5A5C"
+ "hex": "#5a5a5c"
},
{
"code": "SW7040",
"name": "Cavern Clay",
- "hex": "#8B7765"
+ "hex": "#8b7765"
},
{
"code": "SW7041",
"name": "N0",
- "hex": "#FFFFFF"
+ "hex": "#ffffff"
},
{
"code": "SW7042",
"name": "Shoji White",
- "hex": "#E8E4DB"
+ "hex": "#e8e4db"
},
{
"code": "SW7043",
"name": "Accessible Beige",
- "hex": "#D0CCC4"
+ "hex": "#d0ccc4"
},
{
"code": "SW7044",
@@ -287,17 +622,17 @@
{
"code": "SW7045",
"name": "Tricorn Black",
- "hex": "#2F2F30"
+ "hex": "#2f2f30"
},
{
"code": "SW7046",
"name": "Black Bean",
- "hex": "#1C1C1C"
+ "hex": "#1c1c1c"
},
{
"code": "SW7047",
"name": "Accessible Beige",
- "hex": "#DDD5CE"
+ "hex": "#ddd5ce"
},
{
"code": "SW7048",
@@ -307,21 +642,61 @@
{
"code": "SW7049",
"name": "Cavern Clay",
- "hex": "#7A6C63"
+ "hex": "#7a6c63"
},
{
"code": "SW7050",
"name": "Tricorn Black",
- "hex": "#2F2F30"
+ "hex": "#2f2f30"
+ },
+ {
+ "code": "SW7100",
+ "name": "Urbane Bronze",
+ "hex": "#595959"
+ },
+ {
+ "code": "SW7200",
+ "name": "Accessible Beige",
+ "hex": "#ddd5ce"
+ },
+ {
+ "code": "SW7602",
+ "name": "Indigo Batik",
+ "hex": "#2c3e6f"
+ },
+ {
+ "code": "SW7701",
+ "name": "Cavern Clay",
+ "hex": "#8a7a6e"
+ },
+ {
+ "code": "SW9081",
+ "name": "Redend Point",
+ "hex": "#b87a6b"
},
{
"code": "SW9109",
"name": "Natural Linen",
- "hex": "#D4D0C8"
+ "hex": "#d4d0c8"
+ },
+ {
+ "code": "SW9130",
+ "name": "Evergreen Fog",
+ "hex": "#6d8175"
+ },
+ {
+ "code": "SW9133",
+ "name": "Oceanside",
+ "hex": "#5a8a92"
},
{
"code": "SW9166",
"name": "Drift of Mist",
- "hex": "#E8E8E8"
+ "hex": "#e8e8e8"
+ },
+ {
+ "code": "SW9168",
+ "name": "Elephant Ear",
+ "hex": "#988f85"
}
]
\ No newline at end of file
diff --git a/tests/integration/isolate.spec.js b/tests/integration/isolate.spec.js
new file mode 100644
index 0000000..a626c14
--- /dev/null
+++ b/tests/integration/isolate.spec.js
@@ -0,0 +1,68 @@
+'use strict';
+// Integration tests for GET /api/isolate/:designId
+//
+// - 200 + valid shape for an existing design (localhost = admin)
+// - 404 for missing design id
+// - 404 from a non-localhost host (admin gate)
+//
+// Run with: cd tests && npx playwright test integration/isolate.spec.js
+'use strict';
+
+const { test, expect } = require('@playwright/test');
+
+// ── I-1: happy path ──────────────────────────────────────────────────────
+test('I-1 · GET /api/isolate/11 returns 200 with palette+coord shape', async ({ page }) => {
+ const response = await page.request.fetch('/api/isolate/11');
+ expect(response.status()).toBe(200);
+
+ const body = await response.json();
+ expect(body).toHaveProperty('design');
+ expect(body.design).toHaveProperty('id');
+ expect(body).toHaveProperty('palette');
+ expect(Array.isArray(body.palette)).toBe(true);
+ expect(body).toHaveProperty('coordinating_hexes');
+ expect(typeof body.coordinating_hexes).toBe('object');
+ expect(body).toHaveProperty('coordinating_designs');
+ expect(Array.isArray(body.coordinating_designs)).toBe(true);
+
+ // Every palette entry has hex + sw keys (sw may be null until DB lands).
+ for (const p of body.palette) {
+ expect(p).toHaveProperty('hex');
+ expect(p).toHaveProperty('sw');
+ expect(p.hex).toMatch(/^#[0-9a-f]{6}$/);
+ }
+
+ // coordinating_hexes — when palette extraction yields something, the 7
+ // standard roles should be present (each {hex, sw}).
+ const coordKeys = Object.keys(body.coordinating_hexes);
+ if (coordKeys.length) {
+ expect(coordKeys).toEqual(
+ expect.arrayContaining(['complement', 'analogousLeft', 'analogousRight'])
+ );
+ for (const v of Object.values(body.coordinating_hexes)) {
+ expect(v).toHaveProperty('hex');
+ expect(v).toHaveProperty('sw');
+ }
+ }
+});
+
+// ── I-2: bogus id ────────────────────────────────────────────────────────
+test('I-2 · GET /api/isolate/99999 returns 404 with {error:"design not found"}', async ({ page }) => {
+ const response = await page.request.fetch('/api/isolate/99999');
+ expect(response.status()).toBe(404);
+ const body = await response.json();
+ expect(body).toHaveProperty('error');
+ expect(body.error).toBe('design not found');
+});
+
+// ── I-3: admin-gate regression — non-localhost host returns 404 (NOT 401),
+// matching the admin-gate pattern (never leak endpoint existence).
+test('I-3 · /api/isolate from non-loopback Host header returns 404 (admin-gated)', async ({ page }) => {
+ // baseURL is http://127.0.0.1:9792. We hit the same socket but force a
+ // non-loopback Host header so isAdmin() falls through to JWT, which we
+ // don't supply → 404.
+ const response = await page.request.fetch('/api/isolate/11', {
+ headers: { Host: 'wallco.ai' },
+ });
+ expect(response.status()).toBe(404);
+});
diff --git a/tests/unit/color-isolate.test.js b/tests/unit/color-isolate.test.js
new file mode 100644
index 0000000..fb7c410
--- /dev/null
+++ b/tests/unit/color-isolate.test.js
@@ -0,0 +1,159 @@
+'use strict';
+// Tests for src/color-isolate.js — palette extraction, SW match,
+// coordinating-color generator. Run with: node --test tests/unit/color-isolate.test.js
+//
+// SW DB at src/sw-colors.json may or may not be present yet (background research
+// agent is compiling it). Tests must pass either way: SW-aware tests skip when
+// the DB is empty/missing.
+
+const { test, describe } = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+
+const mod = require('../../src/color-isolate.js');
+const {
+ expandHex, hexToRgb, rgbToHex, deltaE, rgbToLab,
+ extractPalette, annotatePalette, coordinatingHexes, coordinatingDesigns,
+ loadSW,
+} = mod;
+
+// ── expandHex ────────────────────────────────────────────────────────────
+describe('expandHex', () => {
+ test('#fff → ffffff', () => {
+ assert.equal(expandHex('#fff'), 'ffffff');
+ });
+ test('fff (no #) → ffffff', () => {
+ assert.equal(expandHex('fff'), 'ffffff');
+ });
+ test('#1A2B3C → 1a2b3c (lowercase)', () => {
+ assert.equal(expandHex('#1A2B3C'), '1a2b3c');
+ });
+ test('garbage returns null', () => {
+ assert.equal(expandHex('zz'), null);
+ assert.equal(expandHex(''), null);
+ assert.equal(expandHex(null), null);
+ });
+});
+
+// ── hexToRgb / rgbToHex round-trip ───────────────────────────────────────
+describe('hexToRgb / rgbToHex', () => {
+ test('#ff0000 → [255,0,0]', () => {
+ assert.deepEqual(hexToRgb('#ff0000'), [255, 0, 0]);
+ });
+ test('round-trip preserves value', () => {
+ assert.equal(rgbToHex(hexToRgb('#7c3aed')), '#7c3aed');
+ });
+});
+
+// ── coordinatingHexes ────────────────────────────────────────────────────
+describe('coordinatingHexes', () => {
+ test('returns 7 named coordinating colors for a valid hex', () => {
+ const c = coordinatingHexes('#7c3aed');
+ assert.ok(c, 'coordinatingHexes returned null');
+ const keys = ['complement', 'analogousLeft', 'analogousRight', 'triadicA', 'triadicB', 'splitComplementA', 'splitComplementB'];
+ for (const k of keys) {
+ assert.match(c[k], /^#[0-9a-f]{6}$/, `${k} is not a valid hex (${c[k]})`);
+ }
+ });
+
+ test('complement of #7c3aed is in the yellow-green family', () => {
+ // 7c3aed is purple (hue ~262°); +180° lands near hue 82° (yellow-green).
+ const c = coordinatingHexes('#7c3aed');
+ assert.equal(c.complement, '#abed3a');
+ });
+
+ test('returns null for bad input', () => {
+ assert.equal(coordinatingHexes('not a hex'), null);
+ });
+});
+
+// ── extractPalette ───────────────────────────────────────────────────────
+describe('extractPalette', () => {
+ test('returns ≥3 hexes from a real PNG in data/generated/', () => {
+ const genDir = path.join(__dirname, '..', '..', 'data', 'generated');
+ if (!fs.existsSync(genDir)) {
+ console.log(' (skipped: data/generated/ missing)');
+ return;
+ }
+ const pngs = fs.readdirSync(genDir).filter(f => /\.png$/i.test(f));
+ if (!pngs.length) {
+ console.log(' (skipped: no PNGs in data/generated/)');
+ return;
+ }
+ const sample = path.join(genDir, pngs[0]);
+ const palette = extractPalette(sample, 6);
+ // Python+PIL may not be installed — accept empty as "PIL absent", not failure.
+ if (!palette.length) {
+ console.log(' (skipped: PIL not available; extractPalette returned [])');
+ return;
+ }
+ assert.ok(palette.length >= 3, `expected ≥3 hexes, got ${palette.length}`);
+ for (const h of palette) {
+ assert.match(h, /^#[0-9a-f]{6}$/, `bad hex in palette: ${h}`);
+ }
+ });
+
+ test('returns [] for missing image', () => {
+ const result = extractPalette('/tmp/this-does-not-exist-anywhere.png', 6);
+ assert.deepEqual(result, []);
+ });
+});
+
+// ── annotatePalette ──────────────────────────────────────────────────────
+describe('annotatePalette', () => {
+ test('with SW DB missing, returns entries with sw:null (graceful degrade)', () => {
+ // Force a reload-against-missing-file by tampering with internal cache.
+ // We can't easily monkey-patch the path, so we test the public contract:
+ // either SW is available (sw is an object) or not (sw is null).
+ const result = annotatePalette(['#ff0000', '#00ff00', '#0000ff']);
+ assert.equal(result.length, 3);
+ for (const entry of result) {
+ assert.ok('hex' in entry, 'entry missing hex');
+ assert.ok('sw' in entry, 'entry missing sw key');
+ assert.match(entry.hex, /^#[0-9a-f]{6}$/);
+ if (entry.sw !== null) {
+ assert.ok(entry.sw.code, 'sw match must have code');
+ assert.ok(entry.sw.name, 'sw match must have name');
+ assert.match(entry.sw.hex, /^#[0-9a-fA-F]{6}$/);
+ assert.equal(typeof entry.sw.deltaE, 'number');
+ }
+ }
+ });
+
+ test('empty/null palette returns []', () => {
+ assert.deepEqual(annotatePalette([]), []);
+ assert.deepEqual(annotatePalette(null), []);
+ });
+});
+
+// ── coordinatingDesigns ──────────────────────────────────────────────────
+describe('coordinatingDesigns', () => {
+ test('returns top-N closest by Lab ΔE, ascending', () => {
+ const designs = [
+ { id: 1, dominant_hex: '#ff0000' }, // red
+ { id: 2, dominant_hex: '#fe0202' }, // very near red
+ { id: 3, dominant_hex: '#00ff00' }, // green
+ { id: 4, dominant_hex: '#0000ff' }, // blue
+ { id: 5 }, // no hex — should be skipped
+ ];
+ const result = coordinatingDesigns('#ff0000', designs, { limit: 3 });
+ assert.equal(result.length, 3);
+ assert.equal(result[0].id, 1); // exact match first
+ assert.equal(result[1].id, 2); // near-match second
+ for (let i = 1; i < result.length; i++) {
+ assert.ok(result[i].deltaE >= result[i - 1].deltaE, 'not ascending');
+ }
+ });
+
+ test('skips designs without dominant_hex', () => {
+ const designs = [
+ { id: 1, dominant_hex: '#ff0000' },
+ { id: 2 },
+ { id: 3, dominant_hex: null },
+ ];
+ const result = coordinatingDesigns('#ff0000', designs, { limit: 10 });
+ assert.equal(result.length, 1);
+ assert.equal(result[0].id, 1);
+ });
+});
← feb477a fix(/studio): escape inline onclick string quotes properly
·
back to Wallco Ai
·
wallco.ai · /admin/dashboard with catalog + demand + cost-bu 5c636e2 →