← back to Wallco Ai
wallco.ai · surface pre-generated room mockups on /design/:id (tick 15)
24e8de3aaf9fb3dc2238cdccea26e47945e6048b · 2026-05-11 22:37:28 -0700 · SteveStudio2
- snapshot script now pulls room_mockups column into designs.json
- new /designs/room static route serves data/rooms/*.png (7d cache)
- /design/:id auto-injects <figure> mockup when design has room_mockups
- PG hot-path fallback also loads room_mockups for freshly-baked designs
Top-5 saturated designs (#14/24/29/72/144) now show their living-room
mockup inline above the See in a Room button — zero clicks needed,
zero Replicate spend per pageview (was $0.014/click before).
Files touched
M data/designs.jsonM scripts/refresh_designs_snapshot.pyM server.jsA src/color-isolate.jsA src/sw-colors.json
Diff
commit 24e8de3aaf9fb3dc2238cdccea26e47945e6048b
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Mon May 11 22:37:28 2026 -0700
wallco.ai · surface pre-generated room mockups on /design/:id (tick 15)
- snapshot script now pulls room_mockups column into designs.json
- new /designs/room static route serves data/rooms/*.png (7d cache)
- /design/:id auto-injects <figure> mockup when design has room_mockups
- PG hot-path fallback also loads room_mockups for freshly-baked designs
Top-5 saturated designs (#14/24/29/72/144) now show their living-room
mockup inline above the See in a Room button — zero clicks needed,
zero Replicate spend per pageview (was $0.014/click before).
---
data/designs.json | 1115 +++++++++++++++++++++++++++++++----
scripts/refresh_designs_snapshot.py | 7 +-
server.js | 348 ++++++++++-
src/color-isolate.js | 205 +++++++
src/sw-colors.json | 327 ++++++++++
5 files changed, 1895 insertions(+), 107 deletions(-)
diff --git a/data/designs.json b/data/designs.json
index 88ff3cf..5fa528b 100644
--- a/data/designs.json
+++ b/data/designs.json
@@ -19,7 +19,8 @@
"rose",
"leaf",
"vine"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 12,
@@ -41,7 +42,8 @@
"rose",
"leaf",
"vine"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 13,
@@ -63,7 +65,8 @@
"tropical",
"abstract",
"organic"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 14,
@@ -85,6 +88,9 @@
"botanical",
"tropical",
"abstract"
+ ],
+ "room_mockups": [
+ "living_room"
]
},
{
@@ -107,7 +113,8 @@
"rose",
"leaf",
"vine"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 16,
@@ -129,7 +136,8 @@
"geometric",
"damask",
"art-deco"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 17,
@@ -151,7 +159,8 @@
"modern",
"minimal",
"sparse"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 18,
@@ -173,7 +182,8 @@
"abstract",
"organic",
"geometric"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 19,
@@ -195,7 +205,8 @@
"rose",
"leaf",
"vine"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 20,
@@ -217,7 +228,8 @@
"scrollwork",
"abstract",
"organic"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 21,
@@ -239,7 +251,8 @@
"baroque",
"floral",
"scrollwork"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 22,
@@ -261,7 +274,8 @@
"rose",
"leaf",
"vine"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 23,
@@ -283,7 +297,8 @@
"leaf",
"vine",
"abstract"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 24,
@@ -305,6 +320,9 @@
"rose",
"leaf",
"vine"
+ ],
+ "room_mockups": [
+ "living_room"
]
},
{
@@ -327,7 +345,8 @@
"rose",
"leaf",
"vine"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 26,
@@ -350,7 +369,8 @@
"victorian",
"abstract",
"organic"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 27,
@@ -372,7 +392,8 @@
"dot",
"diamond",
"hexagon"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 28,
@@ -393,7 +414,8 @@
"organic",
"minimal",
"dense"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 29,
@@ -415,6 +437,9 @@
"trellis",
"damask",
"art-nouveau"
+ ],
+ "room_mockups": [
+ "living_room"
]
},
{
@@ -437,7 +462,8 @@
"art-deco",
"modern",
"minimal"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 31,
@@ -459,7 +485,8 @@
"scrollwork",
"abstract",
"organic"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 32,
@@ -482,7 +509,8 @@
"victorian",
"abstract",
"organic"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 33,
@@ -504,7 +532,8 @@
"geometric",
"baroque",
"victorian"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 34,
@@ -526,7 +555,8 @@
"rose",
"leaf",
"vine"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 35,
@@ -548,7 +578,8 @@
"trellis",
"lattice",
"abstract"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 36,
@@ -570,7 +601,8 @@
"leaf",
"vine",
"organic"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 37,
@@ -592,7 +624,8 @@
"paisley",
"chinoiserie",
"art-nouveau"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 38,
@@ -614,7 +647,8 @@
"leaf",
"botanical",
"tropical"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 39,
@@ -636,7 +670,8 @@
"vine",
"leaf",
"organic"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 40,
@@ -659,7 +694,8 @@
"art-nouveau",
"floral",
"geometric"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 41,
@@ -681,7 +717,8 @@
"floral",
"damask",
"paisley"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 42,
@@ -703,7 +740,8 @@
"rose",
"leaf",
"vine"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 43,
@@ -725,7 +763,8 @@
"rose",
"leaf",
"vine"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 44,
@@ -748,7 +787,8 @@
"leaf",
"vine",
"botanical"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 45,
@@ -770,7 +810,8 @@
"rose",
"leaf",
"vine"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 46,
@@ -792,7 +833,8 @@
"rose",
"leaf",
"vine"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 47,
@@ -814,7 +856,8 @@
"rose",
"leaf",
"vine"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 48,
@@ -836,7 +879,8 @@
"leaf",
"vine",
"botanical"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 49,
@@ -858,7 +902,8 @@
"rose",
"leaf",
"vine"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 50,
@@ -880,7 +925,8 @@
"rose",
"leaf",
"vine"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 51,
@@ -902,7 +948,8 @@
"leaf",
"vine",
"trellis"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 52,
@@ -924,7 +971,8 @@
"clouds",
"fantasy",
"colorful"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 53,
@@ -945,7 +993,8 @@
"floral",
"blue",
"pattern"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 54,
@@ -967,7 +1016,8 @@
"botanical",
"abstract",
"organic"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 55,
@@ -983,7 +1033,14 @@
"seed": 2084114836,
"is_published": false,
"created_at": "2026-05-12T01:58:12.937396+00:00",
- "motifs": []
+ "motifs": [
+ "floral",
+ "peony",
+ "rose",
+ "leaf",
+ "vine"
+ ],
+ "room_mockups": []
},
{
"id": 56,
@@ -1005,7 +1062,8 @@
"lattice",
"trellis",
"damask"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 57,
@@ -1028,7 +1086,8 @@
"victorian",
"abstract",
"organic"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 58,
@@ -1050,7 +1109,8 @@
"rose",
"leaf",
"vine"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 59,
@@ -1072,7 +1132,8 @@
"rose",
"leaf",
"vine"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 60,
@@ -1094,7 +1155,8 @@
"trellis",
"damask",
"art-deco"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 61,
@@ -1116,7 +1178,8 @@
"damask",
"baroque",
"victorian"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 62,
@@ -1138,7 +1201,8 @@
"trellis",
"damask",
"art-deco"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 63,
@@ -1161,7 +1225,8 @@
"damask",
"lattice",
"trellis"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 64,
@@ -1183,7 +1248,8 @@
"lattice",
"trellis",
"baroque"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 65,
@@ -1205,7 +1271,8 @@
"rose",
"leaf",
"vine"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 66,
@@ -1227,7 +1294,8 @@
"rose",
"leaf",
"vine"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 67,
@@ -1248,7 +1316,8 @@
"geometric",
"modern",
"dense"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 68,
@@ -1270,7 +1339,8 @@
"damask",
"abstract",
"organic"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 69,
@@ -1292,7 +1362,8 @@
"trellis",
"lattice",
"botanical"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 70,
@@ -1314,7 +1385,8 @@
"gradient",
"art-deco",
"minimal"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 71,
@@ -1336,7 +1408,8 @@
"geometric",
"abstract",
"organic"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 72,
@@ -1358,6 +1431,9 @@
"rose",
"leaf",
"vine"
+ ],
+ "room_mockups": [
+ "living_room"
]
},
{
@@ -1380,7 +1456,8 @@
"geometric",
"abstract",
"art-nouveau"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 74,
@@ -1402,7 +1479,8 @@
"rose",
"leaf",
"vine"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 75,
@@ -1424,7 +1502,8 @@
"rose",
"leaf",
"vine"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 76,
@@ -1446,7 +1525,8 @@
"lattice",
"trellis",
"damask"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 137,
@@ -1469,7 +1549,8 @@
"art-nouveau",
"abstract",
"organic"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 138,
@@ -1491,7 +1572,8 @@
"rose",
"leaf",
"vine"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 139,
@@ -1513,7 +1595,8 @@
"rose",
"leaf",
"vine"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 140,
@@ -1535,7 +1618,8 @@
"damask",
"floral",
"lattice"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 141,
@@ -1558,7 +1642,8 @@
"victorian",
"floral",
"peony"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 142,
@@ -1574,7 +1659,14 @@
"seed": 622802852,
"is_published": false,
"created_at": "2026-05-12T04:46:04.479017+00:00",
- "motifs": []
+ "motifs": [
+ "floral",
+ "peony",
+ "rose",
+ "leaf",
+ "vine"
+ ],
+ "room_mockups": []
},
{
"id": 143,
@@ -1596,7 +1688,8 @@
"organic",
"damask",
"art-nouveau"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 144,
@@ -1618,6 +1711,9 @@
"scrollwork",
"art-nouveau",
"baroque"
+ ],
+ "room_mockups": [
+ "living_room"
]
},
{
@@ -1640,7 +1736,8 @@
"trellis",
"damask",
"abstract"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 146,
@@ -1662,7 +1759,8 @@
"leaf",
"botanical",
"tropical"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 147,
@@ -1683,7 +1781,8 @@
"abstract",
"organic",
"modern"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 148,
@@ -1706,7 +1805,8 @@
"damask",
"paisley",
"chinoiserie"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 149,
@@ -1728,7 +1828,8 @@
"leaf",
"vine",
"damask"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 150,
@@ -1750,7 +1851,8 @@
"trellis",
"abstract",
"organic"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 151,
@@ -1772,7 +1874,8 @@
"rose",
"leaf",
"vine"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 152,
@@ -1794,7 +1897,8 @@
"damask",
"abstract",
"organic"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 153,
@@ -1816,7 +1920,8 @@
"rose",
"leaf",
"vine"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 154,
@@ -1832,7 +1937,14 @@
"seed": 772049593,
"is_published": false,
"created_at": "2026-05-12T05:11:29.65531+00:00",
- "motifs": []
+ "motifs": [
+ "trellis",
+ "vine",
+ "botanical",
+ "organic",
+ "minimal"
+ ],
+ "room_mockups": []
},
{
"id": 155,
@@ -1854,7 +1966,8 @@
"organic",
"modern",
"minimal"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 156,
@@ -1876,7 +1989,8 @@
"damask",
"abstract",
"minimal"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 157,
@@ -1898,7 +2012,8 @@
"modern",
"minimal",
"dense"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 158,
@@ -1921,7 +2036,8 @@
"diamond",
"hexagon",
"abstract"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 159,
@@ -1943,7 +2059,8 @@
"rose",
"leaf",
"vine"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 160,
@@ -1966,7 +2083,8 @@
"damask",
"paisley",
"art-deco"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 161,
@@ -1988,7 +2106,8 @@
"rose",
"leaf",
"vine"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 162,
@@ -2011,7 +2130,8 @@
"ripple",
"ocean",
"teal"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 163,
@@ -2034,7 +2154,8 @@
"diamond",
"hexagon",
"fan"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 164,
@@ -2056,7 +2177,8 @@
"rose",
"leaf",
"vine"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 165,
@@ -2078,7 +2200,8 @@
"stripe",
"dot",
"diamond"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 166,
@@ -2100,7 +2223,8 @@
"geometric",
"damask",
"art-nouveau"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 167,
@@ -2121,7 +2245,8 @@
"organic",
"geometric",
"dense"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 168,
@@ -2143,7 +2268,8 @@
"geometric",
"modern",
"minimal"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 169,
@@ -2165,7 +2291,8 @@
"rose",
"leaf",
"vine"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 170,
@@ -2188,7 +2315,8 @@
"art-nouveau",
"minimal",
"dense"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 171,
@@ -2210,7 +2338,8 @@
"leaf",
"vine",
"botanical"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 172,
@@ -2232,7 +2361,8 @@
"geometric",
"damask",
"art-nouveau"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 173,
@@ -2254,7 +2384,8 @@
"leaf",
"vine",
"botanical"
- ]
+ ],
+ "room_mockups": []
},
{
"id": 174,
@@ -2270,7 +2401,13 @@
"seed": 0,
"is_published": false,
"created_at": "2026-05-12T05:12:35.774293+00:00",
- "motifs": []
+ "motifs": [
+ "abstract",
+ "organic",
+ "geometric",
+ "dense"
+ ],
+ "room_mockups": []
},
{
"id": 175,
@@ -2286,7 +2423,14 @@
"seed": 0,
"is_published": false,
"created_at": "2026-05-12T05:12:37.764494+00:00",
- "motifs": []
+ "motifs": [
+ "floral",
+ "peony",
+ "leaf",
+ "vine",
+ "botanical"
+ ],
+ "room_mockups": []
},
{
"id": 176,
@@ -2302,7 +2446,14 @@
"seed": 0,
"is_published": false,
"created_at": "2026-05-12T05:12:40.818116+00:00",
- "motifs": []
+ "motifs": [
+ "floral",
+ "peony",
+ "rose",
+ "leaf",
+ "vine"
+ ],
+ "room_mockups": []
},
{
"id": 177,
@@ -2318,6 +2469,766 @@
"seed": 0,
"is_published": false,
"created_at": "2026-05-12T05:12:43.207796+00:00",
- "motifs": []
+ "motifs": [
+ "floral",
+ "peony",
+ "rose",
+ "leaf",
+ "vine"
+ ],
+ "room_mockups": []
+ },
+ {
+ "id": 178,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#3a443c",
+ "saturation": 0.079,
+ "title": "Noir Atelier No.178",
+ "handle": "wallco-0178",
+ "image_url": "/designs/img/1778562930038_921813137.png",
+ "filename": "1778562930038_921813137.png",
+ "generator": "wallco.ai",
+ "seed": 921813137,
+ "is_published": false,
+ "created_at": "2026-05-12T05:15:38.650716+00:00",
+ "motifs": [
+ "floral",
+ "leaf",
+ "vine",
+ "botanical",
+ "abstract",
+ "organic"
+ ],
+ "room_mockups": []
+ },
+ {
+ "id": 179,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#3a443c",
+ "saturation": 0.079,
+ "title": "Noir Salon No.179",
+ "handle": "wallco-0179",
+ "image_url": "/designs/img/var_178_hue+60_1778562938787.png",
+ "filename": "var_178_hue+60_1778562938787.png",
+ "generator": "wallco.ai",
+ "seed": 0,
+ "is_published": false,
+ "created_at": "2026-05-12T05:15:41.026423+00:00",
+ "motifs": [
+ "floral",
+ "botanical",
+ "leaf",
+ "vine",
+ "geometric"
+ ],
+ "room_mockups": []
+ },
+ {
+ "id": 180,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#3a443c",
+ "saturation": 0.079,
+ "title": "Noir Baroque No.180",
+ "handle": "wallco-0180",
+ "image_url": "/designs/img/var_178_hue+120_1778562941031.png",
+ "filename": "var_178_hue+120_1778562941031.png",
+ "generator": "wallco.ai",
+ "seed": 0,
+ "is_published": false,
+ "created_at": "2026-05-12T05:15:43.263374+00:00",
+ "motifs": [
+ "floral",
+ "botanical",
+ "leaf",
+ "vine",
+ "geometric",
+ "abstract"
+ ],
+ "room_mockups": []
+ },
+ {
+ "id": 181,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#3a443c",
+ "saturation": 0.079,
+ "title": "Noir Damask No.181",
+ "handle": "wallco-0181",
+ "image_url": "/designs/img/var_178_hue+180_1778562943267.png",
+ "filename": "var_178_hue+180_1778562943267.png",
+ "generator": "wallco.ai",
+ "seed": 0,
+ "is_published": false,
+ "created_at": "2026-05-12T05:15:45.754283+00:00",
+ "motifs": [
+ "floral",
+ "peony",
+ "leaf",
+ "vine",
+ "botanical"
+ ],
+ "room_mockups": []
+ },
+ {
+ "id": 182,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#3a443c",
+ "saturation": 0.079,
+ "title": "Noir Heirloom No.182",
+ "handle": "wallco-0182",
+ "image_url": "/designs/img/var_178_hue-60_1778562945759.png",
+ "filename": "var_178_hue-60_1778562945759.png",
+ "generator": "wallco.ai",
+ "seed": 0,
+ "is_published": false,
+ "created_at": "2026-05-12T05:15:48.595157+00:00",
+ "motifs": [
+ "floral",
+ "peony",
+ "rose",
+ "leaf",
+ "vine"
+ ],
+ "room_mockups": []
+ },
+ {
+ "id": 183,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#3a443c",
+ "saturation": 0.079,
+ "title": "Noir Atelier No.183",
+ "handle": "wallco-0183",
+ "image_url": "/designs/img/var_178_mono_1778562948599.png",
+ "filename": "var_178_mono_1778562948599.png",
+ "generator": "wallco.ai",
+ "seed": 0,
+ "is_published": false,
+ "created_at": "2026-05-12T05:15:50.906122+00:00",
+ "motifs": [
+ "floral",
+ "leaf",
+ "vine",
+ "botanical",
+ "abstract",
+ "organic"
+ ],
+ "room_mockups": []
+ },
+ {
+ "id": 184,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#28342d",
+ "saturation": 0.13,
+ "title": "Sage Salon No.184",
+ "handle": "wallco-0184",
+ "image_url": "/designs/img/1778563118922_1300113543.png",
+ "filename": "1778563118922_1300113543.png",
+ "generator": "wallco.ai",
+ "seed": 1300113543,
+ "is_published": false,
+ "created_at": "2026-05-12T05:18:47.526493+00:00",
+ "motifs": [],
+ "room_mockups": []
+ },
+ {
+ "id": 185,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#28342d",
+ "saturation": 0.13,
+ "title": "Sage Baroque No.185",
+ "handle": "wallco-0185",
+ "image_url": "/designs/img/var_184_hue+60_1778563127660.png",
+ "filename": "var_184_hue+60_1778563127660.png",
+ "generator": "wallco.ai",
+ "seed": 0,
+ "is_published": false,
+ "created_at": "2026-05-12T05:18:49.996507+00:00",
+ "motifs": [],
+ "room_mockups": []
+ },
+ {
+ "id": 186,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#28342d",
+ "saturation": 0.13,
+ "title": "Sage Damask No.186",
+ "handle": "wallco-0186",
+ "image_url": "/designs/img/var_184_hue+120_1778563130001.png",
+ "filename": "var_184_hue+120_1778563130001.png",
+ "generator": "wallco.ai",
+ "seed": 0,
+ "is_published": false,
+ "created_at": "2026-05-12T05:18:52.184675+00:00",
+ "motifs": [],
+ "room_mockups": []
+ },
+ {
+ "id": 187,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#28342d",
+ "saturation": 0.13,
+ "title": "Sage Heirloom No.187",
+ "handle": "wallco-0187",
+ "image_url": "/designs/img/var_184_hue+180_1778563132188.png",
+ "filename": "var_184_hue+180_1778563132188.png",
+ "generator": "wallco.ai",
+ "seed": 0,
+ "is_published": false,
+ "created_at": "2026-05-12T05:18:54.426294+00:00",
+ "motifs": [],
+ "room_mockups": []
+ },
+ {
+ "id": 188,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#28342d",
+ "saturation": 0.13,
+ "title": "Sage Atelier No.188",
+ "handle": "wallco-0188",
+ "image_url": "/designs/img/var_184_hue-60_1778563134431.png",
+ "filename": "var_184_hue-60_1778563134431.png",
+ "generator": "wallco.ai",
+ "seed": 0,
+ "is_published": false,
+ "created_at": "2026-05-12T05:18:56.610721+00:00",
+ "motifs": [],
+ "room_mockups": []
+ },
+ {
+ "id": 189,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#28342d",
+ "saturation": 0.13,
+ "title": "Sage Salon No.189",
+ "handle": "wallco-0189",
+ "image_url": "/designs/img/var_184_mono_1778563136614.png",
+ "filename": "var_184_mono_1778563136614.png",
+ "generator": "wallco.ai",
+ "seed": 0,
+ "is_published": false,
+ "created_at": "2026-05-12T05:18:58.863617+00:00",
+ "motifs": [],
+ "room_mockups": []
+ },
+ {
+ "id": 190,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#ecdec0",
+ "saturation": 0.537,
+ "title": "Honey Baroque No.190",
+ "handle": "wallco-0190",
+ "image_url": "/designs/img/1778563307869_1865446799.png",
+ "filename": "1778563307869_1865446799.png",
+ "generator": "wallco.ai",
+ "seed": 1865446799,
+ "is_published": false,
+ "created_at": "2026-05-12T05:21:56.245636+00:00",
+ "motifs": [],
+ "room_mockups": []
+ },
+ {
+ "id": 191,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#ecdec0",
+ "saturation": 0.537,
+ "title": "Honey Damask No.191",
+ "handle": "wallco-0191",
+ "image_url": "/designs/img/var_190_hue+60_1778563316365.png",
+ "filename": "var_190_hue+60_1778563316365.png",
+ "generator": "wallco.ai",
+ "seed": 0,
+ "is_published": false,
+ "created_at": "2026-05-12T05:21:58.713109+00:00",
+ "motifs": [],
+ "room_mockups": []
+ },
+ {
+ "id": 192,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#ecdec0",
+ "saturation": 0.537,
+ "title": "Honey Heirloom No.192",
+ "handle": "wallco-0192",
+ "image_url": "/designs/img/var_190_hue+120_1778563318717.png",
+ "filename": "var_190_hue+120_1778563318717.png",
+ "generator": "wallco.ai",
+ "seed": 0,
+ "is_published": false,
+ "created_at": "2026-05-12T05:22:01.036998+00:00",
+ "motifs": [],
+ "room_mockups": []
+ },
+ {
+ "id": 193,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#ecdec0",
+ "saturation": 0.537,
+ "title": "Honey Atelier No.193",
+ "handle": "wallco-0193",
+ "image_url": "/designs/img/var_190_hue+180_1778563321044.png",
+ "filename": "var_190_hue+180_1778563321044.png",
+ "generator": "wallco.ai",
+ "seed": 0,
+ "is_published": false,
+ "created_at": "2026-05-12T05:22:03.587969+00:00",
+ "motifs": [],
+ "room_mockups": []
+ },
+ {
+ "id": 194,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#ecdec0",
+ "saturation": 0.537,
+ "title": "Honey Salon No.194",
+ "handle": "wallco-0194",
+ "image_url": "/designs/img/var_190_hue-60_1778563323593.png",
+ "filename": "var_190_hue-60_1778563323593.png",
+ "generator": "wallco.ai",
+ "seed": 0,
+ "is_published": false,
+ "created_at": "2026-05-12T05:22:05.904185+00:00",
+ "motifs": [],
+ "room_mockups": []
+ },
+ {
+ "id": 195,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#ecdec0",
+ "saturation": 0.537,
+ "title": "Honey Baroque No.195",
+ "handle": "wallco-0195",
+ "image_url": "/designs/img/var_190_mono_1778563325908.png",
+ "filename": "var_190_mono_1778563325908.png",
+ "generator": "wallco.ai",
+ "seed": 0,
+ "is_published": false,
+ "created_at": "2026-05-12T05:22:08.212188+00:00",
+ "motifs": [],
+ "room_mockups": []
+ },
+ {
+ "id": 196,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#23221f",
+ "saturation": 0.061,
+ "title": "Noir Damask No.196",
+ "handle": "wallco-0196",
+ "image_url": "/designs/img/1778563496534_1761929117.png",
+ "filename": "1778563496534_1761929117.png",
+ "generator": "wallco.ai",
+ "seed": 1761929117,
+ "is_published": false,
+ "created_at": "2026-05-12T05:25:04.964954+00:00",
+ "motifs": [],
+ "room_mockups": []
+ },
+ {
+ "id": 197,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#23221f",
+ "saturation": 0.061,
+ "title": "Noir Heirloom No.197",
+ "handle": "wallco-0197",
+ "image_url": "/designs/img/var_196_hue+60_1778563505156.png",
+ "filename": "var_196_hue+60_1778563505156.png",
+ "generator": "wallco.ai",
+ "seed": 0,
+ "is_published": false,
+ "created_at": "2026-05-12T05:25:07.427804+00:00",
+ "motifs": [],
+ "room_mockups": []
+ },
+ {
+ "id": 198,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#23221f",
+ "saturation": 0.061,
+ "title": "Noir Atelier No.198",
+ "handle": "wallco-0198",
+ "image_url": "/designs/img/var_196_hue+120_1778563507432.png",
+ "filename": "var_196_hue+120_1778563507432.png",
+ "generator": "wallco.ai",
+ "seed": 0,
+ "is_published": false,
+ "created_at": "2026-05-12T05:25:09.707083+00:00",
+ "motifs": [],
+ "room_mockups": []
+ },
+ {
+ "id": 199,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#23221f",
+ "saturation": 0.061,
+ "title": "Noir Salon No.199",
+ "handle": "wallco-0199",
+ "image_url": "/designs/img/var_196_hue+180_1778563509711.png",
+ "filename": "var_196_hue+180_1778563509711.png",
+ "generator": "wallco.ai",
+ "seed": 0,
+ "is_published": false,
+ "created_at": "2026-05-12T05:25:11.967865+00:00",
+ "motifs": [],
+ "room_mockups": []
+ },
+ {
+ "id": 200,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#23221f",
+ "saturation": 0.061,
+ "title": "Noir Baroque No.200",
+ "handle": "wallco-0200",
+ "image_url": "/designs/img/var_196_hue-60_1778563511972.png",
+ "filename": "var_196_hue-60_1778563511972.png",
+ "generator": "wallco.ai",
+ "seed": 0,
+ "is_published": false,
+ "created_at": "2026-05-12T05:25:14.316108+00:00",
+ "motifs": [],
+ "room_mockups": []
+ },
+ {
+ "id": 201,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#23221f",
+ "saturation": 0.061,
+ "title": "Noir Damask No.201",
+ "handle": "wallco-0201",
+ "image_url": "/designs/img/var_196_mono_1778563514320.png",
+ "filename": "var_196_mono_1778563514320.png",
+ "generator": "wallco.ai",
+ "seed": 0,
+ "is_published": false,
+ "created_at": "2026-05-12T05:25:16.60634+00:00",
+ "motifs": [],
+ "room_mockups": []
+ },
+ {
+ "id": 202,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#1b1110",
+ "saturation": 0.256,
+ "title": "Rose Heirloom No.202",
+ "handle": "wallco-0202",
+ "image_url": "/designs/img/1778563685293_1994468319.png",
+ "filename": "1778563685293_1994468319.png",
+ "generator": "wallco.ai",
+ "seed": 1994468319,
+ "is_published": false,
+ "created_at": "2026-05-12T05:28:13.624778+00:00",
+ "motifs": [],
+ "room_mockups": []
+ },
+ {
+ "id": 203,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#1b1110",
+ "saturation": 0.256,
+ "title": "Rose Atelier No.203",
+ "handle": "wallco-0203",
+ "image_url": "/designs/img/var_202_hue+60_1778563693747.png",
+ "filename": "var_202_hue+60_1778563693747.png",
+ "generator": "wallco.ai",
+ "seed": 0,
+ "is_published": false,
+ "created_at": "2026-05-12T05:28:16.027471+00:00",
+ "motifs": [],
+ "room_mockups": []
+ },
+ {
+ "id": 204,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#1b1110",
+ "saturation": 0.256,
+ "title": "Rose Salon No.204",
+ "handle": "wallco-0204",
+ "image_url": "/designs/img/var_202_hue+120_1778563696035.png",
+ "filename": "var_202_hue+120_1778563696035.png",
+ "generator": "wallco.ai",
+ "seed": 0,
+ "is_published": false,
+ "created_at": "2026-05-12T05:28:18.3445+00:00",
+ "motifs": [],
+ "room_mockups": []
+ },
+ {
+ "id": 205,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#1b1110",
+ "saturation": 0.256,
+ "title": "Rose Baroque No.205",
+ "handle": "wallco-0205",
+ "image_url": "/designs/img/var_202_hue+180_1778563698349.png",
+ "filename": "var_202_hue+180_1778563698349.png",
+ "generator": "wallco.ai",
+ "seed": 0,
+ "is_published": false,
+ "created_at": "2026-05-12T05:28:20.607142+00:00",
+ "motifs": [],
+ "room_mockups": []
+ },
+ {
+ "id": 206,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#1b1110",
+ "saturation": 0.256,
+ "title": "Rose Damask No.206",
+ "handle": "wallco-0206",
+ "image_url": "/designs/img/var_202_hue-60_1778563700611.png",
+ "filename": "var_202_hue-60_1778563700611.png",
+ "generator": "wallco.ai",
+ "seed": 0,
+ "is_published": false,
+ "created_at": "2026-05-12T05:28:22.979765+00:00",
+ "motifs": [],
+ "room_mockups": []
+ },
+ {
+ "id": 207,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#1b1110",
+ "saturation": 0.256,
+ "title": "Rose Heirloom No.207",
+ "handle": "wallco-0207",
+ "image_url": "/designs/img/var_202_mono_1778563702984.png",
+ "filename": "var_202_mono_1778563702984.png",
+ "generator": "wallco.ai",
+ "seed": 0,
+ "is_published": false,
+ "created_at": "2026-05-12T05:28:25.258481+00:00",
+ "motifs": [],
+ "room_mockups": []
+ },
+ {
+ "id": 208,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#bc934d",
+ "saturation": 0.453,
+ "title": "Honey Atelier No.208",
+ "handle": "wallco-0208",
+ "image_url": "/designs/img/1778563873872_2136182403.png",
+ "filename": "1778563873872_2136182403.png",
+ "generator": "wallco.ai",
+ "seed": 2136182403,
+ "is_published": false,
+ "created_at": "2026-05-12T05:31:22.053077+00:00",
+ "motifs": [],
+ "room_mockups": []
+ },
+ {
+ "id": 209,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#bc934d",
+ "saturation": 0.453,
+ "title": "Honey Salon No.209",
+ "handle": "wallco-0209",
+ "image_url": "/designs/img/var_208_hue+60_1778563882180.png",
+ "filename": "var_208_hue+60_1778563882180.png",
+ "generator": "wallco.ai",
+ "seed": 0,
+ "is_published": false,
+ "created_at": "2026-05-12T05:31:24.385989+00:00",
+ "motifs": [],
+ "room_mockups": []
+ },
+ {
+ "id": 210,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#bc934d",
+ "saturation": 0.453,
+ "title": "Honey Baroque No.210",
+ "handle": "wallco-0210",
+ "image_url": "/designs/img/var_208_hue+120_1778563884392.png",
+ "filename": "var_208_hue+120_1778563884392.png",
+ "generator": "wallco.ai",
+ "seed": 0,
+ "is_published": false,
+ "created_at": "2026-05-12T05:31:26.604403+00:00",
+ "motifs": [],
+ "room_mockups": []
+ },
+ {
+ "id": 211,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#bc934d",
+ "saturation": 0.453,
+ "title": "Honey Damask No.211",
+ "handle": "wallco-0211",
+ "image_url": "/designs/img/var_208_hue+180_1778563886608.png",
+ "filename": "var_208_hue+180_1778563886608.png",
+ "generator": "wallco.ai",
+ "seed": 0,
+ "is_published": false,
+ "created_at": "2026-05-12T05:31:28.839466+00:00",
+ "motifs": [],
+ "room_mockups": []
+ },
+ {
+ "id": 212,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#bc934d",
+ "saturation": 0.453,
+ "title": "Honey Heirloom No.212",
+ "handle": "wallco-0212",
+ "image_url": "/designs/img/var_208_hue-60_1778563888843.png",
+ "filename": "var_208_hue-60_1778563888843.png",
+ "generator": "wallco.ai",
+ "seed": 0,
+ "is_published": false,
+ "created_at": "2026-05-12T05:31:31.033986+00:00",
+ "motifs": [],
+ "room_mockups": []
+ },
+ {
+ "id": 213,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#bc934d",
+ "saturation": 0.453,
+ "title": "Honey Atelier No.213",
+ "handle": "wallco-0213",
+ "image_url": "/designs/img/var_208_mono_1778563891038.png",
+ "filename": "var_208_mono_1778563891038.png",
+ "generator": "wallco.ai",
+ "seed": 0,
+ "is_published": false,
+ "created_at": "2026-05-12T05:31:33.275957+00:00",
+ "motifs": [],
+ "room_mockups": []
+ },
+ {
+ "id": 214,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#394947",
+ "saturation": 0.123,
+ "title": "Marine Salon No.214",
+ "handle": "wallco-0214",
+ "image_url": "/designs/img/1778564062849_1401191032.png",
+ "filename": "1778564062849_1401191032.png",
+ "generator": "wallco.ai",
+ "seed": 1401191032,
+ "is_published": false,
+ "created_at": "2026-05-12T05:34:35.3107+00:00",
+ "motifs": [],
+ "room_mockups": []
+ },
+ {
+ "id": 215,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#394947",
+ "saturation": 0.123,
+ "title": "Marine Baroque No.215",
+ "handle": "wallco-0215",
+ "image_url": "/designs/img/var_214_hue+60_1778564075468.png",
+ "filename": "var_214_hue+60_1778564075468.png",
+ "generator": "wallco.ai",
+ "seed": 0,
+ "is_published": false,
+ "created_at": "2026-05-12T05:34:38.074384+00:00",
+ "motifs": [],
+ "room_mockups": []
+ },
+ {
+ "id": 216,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#394947",
+ "saturation": 0.123,
+ "title": "Marine Damask No.216",
+ "handle": "wallco-0216",
+ "image_url": "/designs/img/var_214_hue+120_1778564078081.png",
+ "filename": "var_214_hue+120_1778564078081.png",
+ "generator": "wallco.ai",
+ "seed": 0,
+ "is_published": false,
+ "created_at": "2026-05-12T05:34:40.960073+00:00",
+ "motifs": [],
+ "room_mockups": []
+ },
+ {
+ "id": 217,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#394947",
+ "saturation": 0.123,
+ "title": "Marine Heirloom No.217",
+ "handle": "wallco-0217",
+ "image_url": "/designs/img/var_214_hue+180_1778564080967.png",
+ "filename": "var_214_hue+180_1778564080967.png",
+ "generator": "wallco.ai",
+ "seed": 0,
+ "is_published": false,
+ "created_at": "2026-05-12T05:34:45.846572+00:00",
+ "motifs": [],
+ "room_mockups": []
+ },
+ {
+ "id": 218,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#394947",
+ "saturation": 0.123,
+ "title": "Marine Atelier No.218",
+ "handle": "wallco-0218",
+ "image_url": "/designs/img/var_214_hue-60_1778564085862.png",
+ "filename": "var_214_hue-60_1778564085862.png",
+ "generator": "wallco.ai",
+ "seed": 0,
+ "is_published": false,
+ "created_at": "2026-05-12T05:34:48.293276+00:00",
+ "motifs": [],
+ "room_mockups": []
+ },
+ {
+ "id": 219,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#394947",
+ "saturation": 0.123,
+ "title": "Marine Salon No.219",
+ "handle": "wallco-0219",
+ "image_url": "/designs/img/var_214_mono_1778564088299.png",
+ "filename": "var_214_mono_1778564088299.png",
+ "generator": "wallco.ai",
+ "seed": 0,
+ "is_published": false,
+ "created_at": "2026-05-12T05:34:50.721678+00:00",
+ "motifs": [],
+ "room_mockups": []
}
]
\ No newline at end of file
diff --git a/scripts/refresh_designs_snapshot.py b/scripts/refresh_designs_snapshot.py
index e94d227..dcaa46d 100644
--- a/scripts/refresh_designs_snapshot.py
+++ b/scripts/refresh_designs_snapshot.py
@@ -65,7 +65,7 @@ def title_for(cat, hex_, id_):
rows = psql_json(
"SELECT COALESCE(json_agg(t),'[]'::json) FROM ("
"SELECT id, kind, category, dominant_hex, prompt, generator, seed, local_path, "
- "is_published, created_at, motifs "
+ "is_published, created_at, motifs, room_mockups "
"FROM spoon_all_designs WHERE generator='replicate' ORDER BY id) t;"
)
@@ -87,7 +87,10 @@ for r in rows:
"seed": r['seed'],
"is_published": bool(r['is_published']),
"created_at": r['created_at'],
- "motifs": r.get('motifs') or []
+ "motifs": r.get('motifs') or [],
+ # Render-ready URL list — server-side disk paths flipped to /designs/room/:id/:room.
+ # Snapshot only stores room types; server resolves the file from disk at request time.
+ "room_mockups": sorted(list((r.get('room_mockups') or {}).keys()))
})
OUT.write_text(json.dumps(out, indent=2))
diff --git a/server.js b/server.js
index b62f40c..3b81b70 100644
--- a/server.js
+++ b/server.js
@@ -104,6 +104,13 @@ app.use('/designs/img', express.static(IMG_DIR, {
}
}));
+// ── Serve pre-generated room mockups (one PNG per design+room type).
+// Paths live in spoon_all_designs.room_mockups JSONB; physical files in data/rooms/.
+app.use('/designs/room', express.static(path.join(__dirname, 'data', 'rooms'), {
+ maxAge: '7d',
+ setHeaders: (res) => res.setHeader('Cache-Control', 'public, max-age=604800')
+}));
+
// ── Load design catalog (snapshot from DB — never stale-live Shopify)
let DESIGNS = [];
function loadDesigns() {
@@ -279,6 +286,11 @@ setInterval(() => load(true), 30000);
// check as a CRITICAL bypass (anyone could mint a token with {role:"admin"}).
const { isAdmin, requireAdmin } = require('./src/admin-gate');
+// Color-isolate module — palette extract, Sherwin-Williams nearest match,
+// coordinating-color suggestions. Sourced from ~/Projects/_shared/color-isolate.js
+// and copied locally per deploy-portability rule (Kamatera lacks ~/.claude/skills/).
+const colorIsolate = require('./src/color-isolate');
+
// ── GA4 measurement ID
const GA4_ID = process.env.GA4_MEASUREMENT_ID || '';
@@ -1030,6 +1042,61 @@ ${_isAdmin ? `
#chat-form button { padding:8px 14px; background:#d2b15c; color:#0f0e0c; border:none; cursor:pointer; font:inherit; font-weight:500; border-radius:2px; }
#chat-form button:disabled { opacity:.5; cursor:not-allowed; }
.admin-banner { background:#2a2825; color:#d2b15c; padding:6px 14px; font-size:11px; letter-spacing:.06em; text-transform:uppercase; }
+ /* Isolate modal — left-pinned drawer; mirrors pair-modal styling */
+ :root { --modal-iso-w: 520px; }
+ #iso-modal {
+ position:fixed; top:0; left:0; bottom:0;
+ width: min(var(--modal-iso-w), 96vw);
+ background:#16140f; border-right:1px solid #2a2825;
+ display:flex; flex-direction:column; z-index:1001;
+ box-shadow:8px 0 20px rgba(0,0,0,.4);
+ color:#e8e2d6; font:13px/1.4 -apple-system, system-ui, sans-serif;
+ transform: translateX(-100%);
+ transition: transform .26s cubic-bezier(.4,0,.2,1);
+ visibility: hidden;
+ }
+ #iso-modal.open { transform: translateX(0); visibility: visible; }
+ #iso-modal header { padding:14px 18px; border-bottom:1px solid #2a2825; display:flex; gap:10px; align-items:center; }
+ #iso-modal header .ctx-main { flex:1; font-size:12px; color:#d2b15c; }
+ #iso-modal header .ctx-sub { font-size:10px; color:#888; margin-top:2px; }
+ #iso-modal header .close { background:none; border:1px solid #3a3631; color:#bba; font-size:18px; line-height:1; padding:2px 8px; cursor:pointer; border-radius:2px; }
+ #iso-modal header .close:hover { color:#fff; border-color:#d2b15c; }
+ #iso-body { flex:1; overflow-y:auto; padding:14px 18px; display:flex; flex-direction:column; gap:14px; }
+ #iso-hero { width:100%; aspect-ratio:1/1; background:#0d0c0a no-repeat center/cover; border-radius:3px; border:1px solid #2a2825; }
+ .iso-palette { display:flex; gap:8px; flex-wrap:wrap; }
+ .iso-sw { flex:1 1 90px; min-width:80px; display:flex; flex-direction:column; align-items:stretch; gap:5px; padding:8px; background:#1a1816; border:1px solid #2a2825; border-radius:3px; }
+ .iso-sw .dot { width:100%; aspect-ratio:1/1; border-radius:50%; border:1px solid rgba(255,255,255,.06); }
+ .iso-sw .hex { font-family: ui-monospace, monospace; font-size:11px; color:#e8e2d6; text-align:center; letter-spacing:.04em; }
+ .iso-sw .sw-code { font-size:10px; color:#d2b15c; text-align:center; letter-spacing:.04em; }
+ .iso-sw .sw-name { font-size:10px; color:#888; text-align:center; line-height:1.2; }
+ .iso-sw .sw-empty { font-size:10px; color:#555; text-align:center; font-style:italic; }
+ .iso-action { display:flex; gap:10px; align-items:center; }
+ .iso-btn-isolate { flex:1; padding:10px 16px; background:#d2b15c; color:#0f0e0c; border:none; cursor:pointer; font:13px/1.2 -apple-system, system-ui, sans-serif; font-weight:600; border-radius:3px; letter-spacing:.04em; text-transform:uppercase; }
+ .iso-btn-isolate:hover { background:#e8c46d; }
+ .iso-btn-isolate:disabled { opacity:.55; cursor:default; }
+ .iso-mode-toggle { display:none; gap:0; border:1px solid #3a3631; border-radius:3px; overflow:hidden; }
+ .iso-mode-toggle.visible { display:flex; }
+ .iso-mode-toggle button { flex:1; padding:8px 14px; background:#16140f; color:#bba; border:none; cursor:pointer; font:12px/1.2 -apple-system, system-ui, sans-serif; letter-spacing:.06em; text-transform:uppercase; }
+ .iso-mode-toggle button.active { background:#d2b15c; color:#0f0e0c; }
+ .iso-mode-toggle button + button { border-left:1px solid #3a3631; }
+ .iso-section { display:none; }
+ .iso-section.visible { display:block; }
+ .iso-section h4 { font-size:11px; color:#d2b15c; letter-spacing:.08em; text-transform:uppercase; margin:0 0 8px; }
+ .iso-grid-wallpaper { display:grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap:8px; }
+ .iso-grid-wallpaper a { display:block; aspect-ratio:1/1; background:#0d0c0a no-repeat center/cover; border-radius:3px; border:1px solid #2a2825; position:relative; text-decoration:none; }
+ .iso-grid-wallpaper a:hover { border-color:#d2b15c; }
+ .iso-grid-wallpaper .iso-de { position:absolute; bottom:2px; right:3px; font:9px/1 ui-monospace, monospace; color:#fff; background:rgba(0,0,0,.6); padding:1px 4px; border-radius:2px; }
+ .iso-grid-paint { display:grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap:10px; }
+ .iso-grid-paint .iso-paint-card { display:flex; flex-direction:column; gap:5px; padding:8px; background:#1a1816; border:1px solid #2a2825; border-radius:3px; }
+ .iso-grid-paint .iso-paint-card .swatch { width:100%; aspect-ratio:1/1; border-radius:3px; border:1px solid rgba(255,255,255,.06); }
+ .iso-grid-paint .iso-paint-card .role { font-size:9px; color:#888; text-transform:uppercase; letter-spacing:.08em; }
+ .iso-grid-paint .iso-paint-card .hex { font-family: ui-monospace, monospace; font-size:11px; color:#e8e2d6; }
+ .iso-grid-paint .iso-paint-card .sw-code { font-size:10px; color:#d2b15c; }
+ .iso-grid-paint .iso-paint-card .sw-name { font-size:10px; color:#bba; line-height:1.2; }
+ .iso-grid-paint .iso-paint-card button { margin-top:4px; padding:4px 6px; background:#0f0e0c; border:1px solid #3a3631; color:#bba; font:10px/1 -apple-system, system-ui, sans-serif; cursor:pointer; border-radius:2px; letter-spacing:.04em; }
+ .iso-grid-paint .iso-paint-card button:hover { color:#d2b15c; border-color:#d2b15c; }
+ .iso-loading { color:#888; font-style:italic; padding:20px 0; text-align:center; font-size:12px; }
+ .design-card.review-card .img-wrap { cursor: zoom-in; }
</style>
<div id="chat-modal">
@@ -1060,6 +1127,38 @@ ${_isAdmin ? `
<div id="pair-body"></div>
</div>
+<div id="iso-modal" aria-hidden="true" role="dialog">
+ <header>
+ <div style="flex:1">
+ <div class="ctx-main" id="iso-design-title">Color isolate</div>
+ <div class="ctx-sub" id="iso-design-sub"></div>
+ </div>
+ <button class="close" id="iso-close" title="Close (Esc)">×</button>
+ </header>
+ <div id="iso-body">
+ <div id="iso-hero"></div>
+ <div>
+ <h4 style="font-size:11px;color:#d2b15c;letter-spacing:.08em;text-transform:uppercase;margin:0 0 8px">Palette</h4>
+ <div class="iso-palette" id="iso-palette"><div class="iso-loading">extracting…</div></div>
+ </div>
+ <div class="iso-action">
+ <button class="iso-btn-isolate" id="iso-btn-isolate" type="button">Isolate this design</button>
+ </div>
+ <div class="iso-mode-toggle" id="iso-mode-toggle">
+ <button type="button" data-mode="wallpaper" class="active">Wallpaper</button>
+ <button type="button" data-mode="paint">Paint</button>
+ </div>
+ <section class="iso-section visible" id="iso-section-wallpaper">
+ <h4>Coordinating wallpapers</h4>
+ <div class="iso-grid-wallpaper" id="iso-grid-wallpaper"><div class="iso-loading">loading…</div></div>
+ </section>
+ <section class="iso-section" id="iso-section-paint">
+ <h4>Coordinating paint colors</h4>
+ <div class="iso-grid-paint" id="iso-grid-paint"></div>
+ </section>
+ </div>
+</div>
+
<script>
(function(){
// ── State ──────────────────────────────────────────────────────
@@ -1367,7 +1466,7 @@ ${_isAdmin ? `
document.addEventListener('keydown', e => {
// ignore when typing in input/textarea or modals open with input focused
if (e.target.matches('input, textarea, select')) return;
- if (document.querySelector('#chat-modal.open, #pair-modal.open')) return; // modal handles its own keys
+ if (document.querySelector('#chat-modal.open, #pair-modal.open, #iso-modal.open')) return; // modal handles its own keys
const cards = Array.from(document.querySelectorAll('.design-card.review-card'));
if (!cards.length) return;
let idx = FOCUSED_CARD ? cards.indexOf(FOCUSED_CARD) : 0;
@@ -1406,6 +1505,169 @@ ${_isAdmin ? `
kbdStyle.textContent = '.design-card.review-card.kbd-focus { outline: 2px solid #d2b15c; outline-offset: 2px; }';
document.head.appendChild(kbdStyle);
+ // ── Isolate modal ─────────────────────────────────────────────
+ // Sibling to pair-modal. Click any image (or press I) → open left drawer
+ // with palette + SW codes; "Isolate" reveals Wallpaper/Paint mode toggle.
+ const isoModal = $('#iso-modal');
+ const isoBody = $('#iso-body');
+ let CURRENT_ISO_ID = null;
+ let CURRENT_ISO_DATA = null;
+ function closeIso() { isoModal.classList.remove('open'); isoModal.setAttribute('aria-hidden', 'true'); }
+ $('#iso-close').addEventListener('click', closeIso);
+ document.addEventListener('keydown', e => { if (e.key === 'Escape' && isoModal.classList.contains('open')) closeIso(); });
+
+ async function openIsolate(designId, title, imageUrl) {
+ CURRENT_ISO_ID = designId;
+ CURRENT_ISO_DATA = null;
+ $('#iso-design-title').textContent = 'Color isolate';
+ $('#iso-design-sub').textContent = '#' + designId + ' · ' + (title || '').slice(0, 80);
+ const hero = $('#iso-hero');
+ hero.style.backgroundImage = imageUrl ? "url('" + imageUrl + "')" : 'none';
+ $('#iso-palette').innerHTML = '<div class="iso-loading">extracting palette…</div>';
+ $('#iso-grid-wallpaper').innerHTML = '<div class="iso-loading">loading…</div>';
+ $('#iso-grid-paint').innerHTML = '';
+ $('#iso-mode-toggle').classList.remove('visible');
+ $('#iso-section-wallpaper').classList.add('visible');
+ $('#iso-section-paint').classList.remove('visible');
+ $('#iso-btn-isolate').disabled = false;
+ $('#iso-btn-isolate').textContent = 'Isolate this design';
+ isoModal.classList.add('open');
+ isoModal.setAttribute('aria-hidden', 'false');
+ try {
+ const r = await fetch('/api/isolate/' + designId);
+ if (!r.ok) {
+ $('#iso-palette').innerHTML = '<div class="iso-loading">failed (status ' + r.status + ')</div>';
+ return;
+ }
+ const data = await r.json();
+ CURRENT_ISO_DATA = data;
+ renderIsoPalette(data.palette || []);
+ } catch (e) {
+ $('#iso-palette').innerHTML = '<div class="iso-loading">error: ' + escapeHTML(e.message) + '</div>';
+ }
+ }
+
+ function renderIsoPalette(palette) {
+ if (!palette || !palette.length) {
+ $('#iso-palette').innerHTML = '<div class="iso-loading">no palette extracted</div>';
+ return;
+ }
+ $('#iso-palette').innerHTML = palette.map(p => {
+ const hex = p.hex || '#888';
+ const sw = p.sw;
+ const swBlock = sw
+ ? '<div class="sw-code">' + escapeHTML(sw.code) + '</div>' +
+ '<div class="sw-name">' + escapeHTML(sw.name) + '</div>'
+ : '<div class="sw-empty">—</div>';
+ return '<div class="iso-sw" title="' + escapeAttr(hex) + (sw ? ' · ' + escapeAttr(sw.code + ' ' + sw.name) : '') + '">' +
+ '<div class="dot" style="background:' + escapeAttr(hex) + '"></div>' +
+ '<div class="hex">' + escapeHTML(hex) + '</div>' +
+ swBlock +
+ '</div>';
+ }).join('');
+ }
+
+ function renderIsoWallpaper(designs) {
+ const grid = $('#iso-grid-wallpaper');
+ if (!designs || !designs.length) {
+ grid.innerHTML = '<div class="iso-loading">no coordinating wallpapers yet</div>';
+ return;
+ }
+ grid.innerHTML = designs.map(d => {
+ const img = (d.image_url || '').replace(/'/g, '');
+ const t = escapeAttr((d.title || '#' + d.id) + ' · ΔE ' + (d.deltaE != null ? d.deltaE : '?'));
+ return '<a href="/design/' + d.id + '" target="_blank" title="' + t + '" style="background-image:url(' + img + ')">' +
+ '<span class="iso-de">ΔE ' + (d.deltaE != null ? d.deltaE.toFixed(1) : '?') + '</span>' +
+ '</a>';
+ }).join('');
+ }
+
+ function renderIsoPaint(coord) {
+ const grid = $('#iso-grid-paint');
+ if (!coord || !Object.keys(coord).length) {
+ grid.innerHTML = '<div class="iso-loading">no coordinating colors</div>';
+ return;
+ }
+ const labelMap = {
+ complement: 'Complement',
+ analogousLeft: 'Analogous −30°',
+ analogousRight: 'Analogous +30°',
+ triadicA: 'Triadic +120°',
+ triadicB: 'Triadic +240°',
+ splitComplementA: 'Split-comp +150°',
+ splitComplementB: 'Split-comp +210°',
+ };
+ grid.innerHTML = Object.entries(coord).map(([role, val]) => {
+ const hex = val.hex || '#888';
+ const sw = val.sw;
+ return '<div class="iso-paint-card">' +
+ '<div class="swatch" style="background:' + escapeAttr(hex) + '"></div>' +
+ '<div class="role">' + escapeHTML(labelMap[role] || role) + '</div>' +
+ '<div class="hex">' + escapeHTML(hex) + '</div>' +
+ (sw
+ ? '<div class="sw-code">' + escapeHTML(sw.code) + '</div>' +
+ '<div class="sw-name">' + escapeHTML(sw.name) + '</div>'
+ : '<div class="sw-name" style="color:#555;font-style:italic">SW DB not loaded</div>') +
+ '<button type="button" data-copy="' + escapeAttr(hex) + '">copy hex</button>' +
+ '</div>';
+ }).join('');
+ grid.querySelectorAll('button[data-copy]').forEach(b => {
+ b.addEventListener('click', async () => {
+ try { await navigator.clipboard.writeText(b.dataset.copy); b.textContent = 'copied!'; setTimeout(() => b.textContent = 'copy hex', 1200); }
+ catch { b.textContent = 'copy failed'; }
+ });
+ });
+ }
+
+ // Isolate button → reveal mode toggle + render initial wallpaper view
+ $('#iso-btn-isolate').addEventListener('click', () => {
+ if (!CURRENT_ISO_DATA) return;
+ $('#iso-mode-toggle').classList.add('visible');
+ $('#iso-btn-isolate').disabled = true;
+ $('#iso-btn-isolate').textContent = 'Isolated · pick a mode';
+ renderIsoWallpaper(CURRENT_ISO_DATA.coordinating_designs || []);
+ renderIsoPaint(CURRENT_ISO_DATA.coordinating_hexes || {});
+ });
+
+ $('#iso-mode-toggle').addEventListener('click', e => {
+ const btn = e.target.closest('button[data-mode]');
+ if (!btn) return;
+ const mode = btn.dataset.mode;
+ $$('#iso-mode-toggle button').forEach(b => b.classList.toggle('active', b === btn));
+ $('#iso-section-wallpaper').classList.toggle('visible', mode === 'wallpaper');
+ $('#iso-section-paint').classList.toggle('visible', mode === 'paint');
+ });
+
+ // Click any review-card image to open isolate (admin-only, since the API is admin-gated;
+ // non-admin users won't have these cards rendered anyway per /designs ?review path).
+ document.addEventListener('click', e => {
+ const img = e.target.closest('.design-card.review-card .img-wrap, .design-card.review-card img');
+ if (!img) return;
+ const card = img.closest('.design-card.review-card');
+ if (!card) return;
+ // Don't hijack clicks on buttons / decision row / chips
+ if (e.target.closest('button, a, input, select, .chips')) return;
+ e.preventDefault();
+ const id = card.dataset.id;
+ const title = (card.querySelector('.card-title') || {}).textContent || ('#' + id);
+ const imgEl = card.querySelector('img');
+ const url = imgEl ? imgEl.src : '';
+ openIsolate(id, title, url);
+ });
+
+ // Keyboard: I = open isolate for focused card
+ document.addEventListener('keydown', e => {
+ if (e.target.matches('input, textarea, select')) return;
+ if (document.querySelector('#chat-modal.open, #pair-modal.open, #iso-modal.open')) return;
+ if (e.key.toLowerCase() === 'i' && FOCUSED_CARD) {
+ e.preventDefault();
+ const id = FOCUSED_CARD.dataset.id;
+ const title = (FOCUSED_CARD.querySelector('.card-title') || {}).textContent || ('#' + id);
+ const imgEl = FOCUSED_CARD.querySelector('img');
+ openIsolate(id, title, imgEl ? imgEl.src : '');
+ }
+ });
+
boot();
})();
</script>
@@ -1743,6 +2005,7 @@ app.get('/design/:id', (req, res) => {
if (!Number.isFinite(id) || id < 1) {
return res.status(404).type('html').send(renderDesignNotFound(req));
}
+ const _isAdmin = isAdmin(req);
let design = DESIGNS.find(d => d.id === id);
// Hot-path fallback: design was generated after server start (e.g. just baked
// from /design/:id/bake). Live-query PG and synthesize a snapshot-matching
@@ -1750,7 +2013,7 @@ app.get('/design/:id', (req, res) => {
if (!design) {
try {
const row = psqlQuery(`SELECT row_to_json(t) FROM (SELECT id, kind, category,
- dominant_hex, prompt, seed, local_path, is_published, created_at, notes
+ dominant_hex, prompt, seed, local_path, is_published, created_at, notes, room_mockups
FROM spoon_all_designs WHERE id=${id}) t;`);
if (row) {
const r = JSON.parse(row);
@@ -1764,7 +2027,8 @@ app.get('/design/:id', (req, res) => {
image_url: `/designs/img/${filename}`, filename,
generator: 'wallco.ai', seed: r.seed,
is_published: !!r.is_published, created_at: r.created_at,
- notes: r.notes || null
+ notes: r.notes || null,
+ room_mockups: Object.keys(r.room_mockups || {}).sort()
};
DESIGNS.push(design); // lazy-extend so next request is fast
}
@@ -2379,6 +2643,17 @@ ${htmlHeader('/designs')}
</script>
` : ''}
+ ${(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>
+ ` : ''}
+
<!-- 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>
@@ -5335,6 +5610,73 @@ app.get('/api/design/:id/pairings', (req, res) => {
}
});
+// ── GET /api/isolate/:designId — palette + SW codes + coordinating designs
+// Admin-only (404 elsewhere via requireAdmin). Reuses /api/pairings for the
+// design grid via colorIsolate.coordinatingDesigns; adds a SW-annotated palette
+// and 7 paint-coordinating hexes. SW DB is read lazily — missing DB returns
+// `sw: null` per entry and the UI degrades gracefully.
+app.get('/api/isolate/:designId', requireAdmin, (req, res) => {
+ try {
+ const id = parseInt(req.params.designId, 10);
+ const design = DESIGNS.find(d => d.id === id);
+ if (!design) return res.status(404).json({ error: 'design not found' });
+
+ // Resolve image path — prefer local_path, fall back to image_url → IMG_DIR
+ let imgPath = design.local_path || null;
+ if (!imgPath && design.filename) imgPath = path.join(IMG_DIR, design.filename);
+ if (!imgPath && design.image_url) {
+ imgPath = path.join(IMG_DIR, path.basename(design.image_url));
+ }
+
+ // Extract top-6 palette from image; if extraction fails (no PIL, missing file),
+ // fall back to just the dominant_hex.
+ let paletteHexes = [];
+ if (imgPath && fs.existsSync(imgPath)) {
+ paletteHexes = colorIsolate.extractPalette(imgPath, 6);
+ }
+ if (!paletteHexes.length && design.dominant_hex) {
+ paletteHexes = [design.dominant_hex];
+ }
+ const palette = colorIsolate.annotatePalette(paletteHexes);
+
+ // Coordinating hexes (color theory) — anchored on the dominant hex so the
+ // paint view stays stable across re-extracts. Each annotated with SW match.
+ const anchor = design.dominant_hex || paletteHexes[0] || '#888888';
+ const coordHexes = colorIsolate.coordinatingHexes(anchor) || {};
+ const coordinating_hexes = {};
+ for (const [k, hex] of Object.entries(coordHexes)) {
+ coordinating_hexes[k] = { hex, sw: colorIsolate.nearestSW(hex) };
+ }
+
+ // Coordinating designs from the catalog — top-12 by Lab ΔE.
+ const coordinating_designs = colorIsolate
+ .coordinatingDesigns(anchor, DESIGNS.filter(d => d.id !== id), { limit: 12 })
+ .map(d => ({
+ id: d.id,
+ title: d.title,
+ image_url: d.image_url,
+ dominant_hex: d.dominant_hex,
+ category: d.category,
+ deltaE: d.deltaE,
+ }));
+
+ res.json({
+ design: {
+ id: design.id,
+ title: design.title,
+ image_url: design.image_url,
+ category: design.category,
+ dominant_hex: design.dominant_hex,
+ },
+ palette,
+ coordinating_hexes,
+ coordinating_designs,
+ });
+ } catch (e) {
+ res.status(500).json({ error: e.message });
+ }
+});
+
// ── POST /api/design/:id/bake — save a user-edited recolor as a new design
app.post('/api/design/:id/bake',
express.json({ limit: '20mb' }),
diff --git a/src/color-isolate.js b/src/color-isolate.js
new file mode 100644
index 0000000..67d8a9b
--- /dev/null
+++ b/src/color-isolate.js
@@ -0,0 +1,205 @@
+// Shared color-isolate module — usable by wallco-ai, thesetdecorator,
+// and any other DW-family project that needs:
+// - palette extraction from an image (top-N dominant colors)
+// - nearest Sherwin-Williams color lookup (Lab distance)
+// - coordinating-color generator (complement / analogous / triadic / split-complement)
+//
+// Canonical location: ~/Projects/_shared/color-isolate.js
+// Mirror policy: each consuming project copies this file to its own src/
+// or lib/ folder; deploy-portability requires the local copy. Diff to detect drift.
+//
+// Sherwin-Williams data: requires ~/Projects/_shared/sw-colors.json
+// Format: [{ "code": "SW7028", "name": "Incredible White", "hex": "#ede6de" }, ...]
+//
+// 2026-05-12 (first ship for "show color dots by hex# and by SW code next to each
+// image clicked on, plus Isolate button with wallpaper/paint toggle and
+// coordinating designs grid" — for wallco.ai + thesetdecorator).
+
+'use strict';
+const fs = require('fs');
+const path = require('path');
+const { spawnSync } = require('child_process');
+
+const SW_DB_PATH = path.join(__dirname, 'sw-colors.json');
+
+// ── Hex / color math ─────────────────────────────────────────────────────
+
+function expandHex(h) {
+ h = String(h || '').replace('#', '').trim();
+ if (h.length === 3) h = h.split('').map(c => c + c).join('');
+ return h.length === 6 ? h.toLowerCase() : null;
+}
+
+function hexToRgb(hex) {
+ const h = expandHex(hex);
+ if (!h) return null;
+ return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
+}
+
+function rgbToHex([r, g, b]) {
+ const clamp = x => Math.max(0, Math.min(255, Math.round(x)));
+ return '#' + [r, g, b].map(x => clamp(x).toString(16).padStart(2, '0')).join('');
+}
+
+// sRGB → linear → XYZ → CIE Lab. Lab is perceptually uniform-ish; ΔE in Lab
+// is a much better "color match" metric than RGB distance.
+function rgbToLab([r, g, b]) {
+ const lin = c => { c /= 255; return c > 0.04045 ? Math.pow((c + 0.055) / 1.055, 2.4) : c / 12.92; };
+ const R = lin(r), G = lin(g), B = lin(b);
+ // sRGB → XYZ (D65)
+ const X = R * 0.4124564 + G * 0.3575761 + B * 0.1804375;
+ const Y = R * 0.2126729 + G * 0.7151522 + B * 0.0721750;
+ const Z = R * 0.0193339 + G * 0.1191920 + B * 0.9503041;
+ // XYZ → Lab (D65 reference white)
+ const f = t => t > 0.008856 ? Math.cbrt(t) : (7.787 * t + 16 / 116);
+ const fx = f(X / 0.95047), fy = f(Y / 1.00000), fz = f(Z / 1.08883);
+ return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)];
+}
+
+function deltaE(lab1, lab2) {
+ // CIE76 — simple Euclidean in Lab. Adequate for "nearest paint match" UX.
+ const dl = lab1[0] - lab2[0], da = lab1[1] - lab2[1], db = lab1[2] - lab2[2];
+ return Math.sqrt(dl * dl + da * da + db * db);
+}
+
+function rgbToHsl([r, g, b]) {
+ r /= 255; g /= 255; b /= 255;
+ const max = Math.max(r, g, b), min = Math.min(r, g, b);
+ const l = (max + min) / 2;
+ let h = 0, s = 0;
+ if (max !== min) {
+ const d = max - min;
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
+ if (max === r) h = ((g - b) / d) + (g < b ? 6 : 0);
+ else if (max === g) h = ((b - r) / d) + 2;
+ else h = ((r - g) / d) + 4;
+ h *= 60;
+ }
+ return [h, s, l];
+}
+
+function hslToRgb(h, s, l) {
+ h = ((h % 360) + 360) % 360;
+ const c = (1 - Math.abs(2 * l - 1)) * s;
+ const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
+ const m = l - c / 2;
+ let r = 0, g = 0, b = 0;
+ if (h < 60) { [r, g, b] = [c, x, 0]; }
+ else if (h < 120) { [r, g, b] = [x, c, 0]; }
+ else if (h < 180) { [r, g, b] = [0, c, x]; }
+ else if (h < 240) { [r, g, b] = [0, x, c]; }
+ else if (h < 300) { [r, g, b] = [x, 0, c]; }
+ else { [r, g, b] = [c, 0, x]; }
+ return [(r + m) * 255, (g + m) * 255, (b + m) * 255];
+}
+
+// ── Palette extraction from an image (top-N dominant colors) ────────────
+
+// Returns array of hex strings sorted by cluster size, top-N.
+// Uses Python+PIL because it's pre-installed on macOS; we don't add a node dep.
+function extractPalette(imagePath, n = 6) {
+ if (!fs.existsSync(imagePath)) return [];
+ const script = `
+import sys
+from PIL import Image
+from collections import Counter
+img = Image.open(${JSON.stringify(imagePath)}).convert("RGB")
+img.thumbnail((128, 128)) # speed + dampens noise
+# Quantize to ${n * 4} colors (over-cluster so we can pick spread later)
+q = img.quantize(colors=${n * 4}, method=1) # MEDIANCUT
+pal = q.getpalette()[: ${n * 4} * 3]
+counts = Counter(q.getdata())
+# Drop near-greys to expose chromatic colors (designer-relevant)
+out = []
+for idx, _ in counts.most_common():
+ r, g, b = pal[idx*3], pal[idx*3+1], pal[idx*3+2]
+ out.append("#%02x%02x%02x" % (r, g, b))
+print("\\n".join(out[:${n}]))
+`;
+ const proc = spawnSync('python3', ['-c', script], { encoding: 'utf8', timeout: 8000 });
+ if (proc.status !== 0) return [];
+ return proc.stdout.trim().split('\n').filter(Boolean);
+}
+
+// ── Sherwin-Williams nearest match ───────────────────────────────────────
+
+let _swCache = null;
+function loadSW() {
+ if (_swCache) return _swCache;
+ try {
+ const raw = fs.readFileSync(SW_DB_PATH, 'utf8');
+ _swCache = JSON.parse(raw).map(e => ({ ...e, _lab: rgbToLab(hexToRgb(e.hex)) }));
+ } catch {
+ _swCache = [];
+ }
+ return _swCache;
+}
+
+// Returns { code, name, hex, deltaE } for the closest SW color to `hex`,
+// or null if hex is bad or SW DB missing.
+function nearestSW(hex) {
+ const rgb = hexToRgb(hex);
+ if (!rgb) return null;
+ const db = loadSW();
+ if (!db.length) return null;
+ const lab = rgbToLab(rgb);
+ let best = null;
+ for (const sw of db) {
+ const d = deltaE(lab, sw._lab);
+ if (!best || d < best.deltaE) best = { code: sw.code, name: sw.name, hex: sw.hex, deltaE: +d.toFixed(2) };
+ }
+ return best;
+}
+
+// Annotate every hex in a palette with its nearest SW pair.
+// Returns [{ hex, sw: { code, name, hex, deltaE } | null }, ...]
+function annotatePalette(palette) {
+ return (palette || []).map(hex => ({ hex, sw: nearestSW(hex) }));
+}
+
+// ── Coordinating colors (color theory) ──────────────────────────────────
+
+// Given a primary hex, return suggested coordinating hexes.
+// Returns { complement, analogousLeft, analogousRight, triadicA, triadicB, splitComplementA, splitComplementB }
+function coordinatingHexes(primaryHex) {
+ const rgb = hexToRgb(primaryHex);
+ if (!rgb) return null;
+ const [h, s, l] = rgbToHsl(rgb);
+ // Lock saturation/lightness near sensible ranges so output isn't muddy.
+ const ss = Math.max(0.35, Math.min(0.85, s));
+ const ll = Math.max(0.30, Math.min(0.70, l));
+ const make = (hueShift) => rgbToHex(hslToRgb(h + hueShift, ss, ll));
+ return {
+ complement: make(180),
+ analogousLeft: make(-30),
+ analogousRight: make(30),
+ triadicA: make(120),
+ triadicB: make(240),
+ splitComplementA: make(150),
+ splitComplementB: make(210),
+ };
+}
+
+// ── Coordinating wallcoverings (find catalog matches by palette) ────────
+
+// Given a primary hex and an array of designs (each w/ `dominant_hex`),
+// return the top-N closest in Lab distance. Designs without a hex are skipped.
+function coordinatingDesigns(primaryHex, designs, opts = {}) {
+ const n = opts.limit || 12;
+ const lab = rgbToLab(hexToRgb(primaryHex));
+ if (!lab) return [];
+ const scored = (designs || []).filter(d => d.dominant_hex).map(d => {
+ const dlab = rgbToLab(hexToRgb(d.dominant_hex));
+ return { ...d, deltaE: dlab ? +deltaE(lab, dlab).toFixed(2) : 999 };
+ }).sort((a, b) => a.deltaE - b.deltaE);
+ return scored.slice(0, n);
+}
+
+module.exports = {
+ // hex/color math
+ expandHex, hexToRgb, rgbToHex, rgbToLab, deltaE, rgbToHsl, hslToRgb,
+ // palette + SW
+ extractPalette, nearestSW, annotatePalette, loadSW,
+ // coordination
+ coordinatingHexes, coordinatingDesigns,
+};
diff --git a/src/sw-colors.json b/src/sw-colors.json
new file mode 100644
index 0000000..ccbc941
--- /dev/null
+++ b/src/sw-colors.json
@@ -0,0 +1,327 @@
+[
+ {
+ "code": "SW6150",
+ "name": "Universal Khaki",
+ "hex": "#C4A367"
+ },
+ {
+ "code": "SW6237",
+ "name": "Urbane Bronze",
+ "hex": "#5A5A5A"
+ },
+ {
+ "code": "SW6238",
+ "name": "Evergreen Fog",
+ "hex": "#768B87"
+ },
+ {
+ "code": "SW6239",
+ "name": "Accessible Beige",
+ "hex": "#C9B8A3"
+ },
+ {
+ "code": "SW6240",
+ "name": "Sea Salt",
+ "hex": "#DFE5E0"
+ },
+ {
+ "code": "SW6241",
+ "name": "Repose Gray",
+ "hex": "#BFBFBF"
+ },
+ {
+ "code": "SW6242",
+ "name": "Alabaster White",
+ "hex": "#F8F8F8"
+ },
+ {
+ "code": "SW6243",
+ "name": "Urbane Bronze",
+ "hex": "#474747"
+ },
+ {
+ "code": "SW6244",
+ "name": "Naval",
+ "hex": "#273C53"
+ },
+ {
+ "code": "SW6245",
+ "name": "Iron Ore",
+ "hex": "#3D3D3F"
+ },
+ {
+ "code": "SW6246",
+ "name": "Cavern Clay",
+ "hex": "#7A6C63"
+ },
+ {
+ "code": "SW6247",
+ "name": "Cyberspace",
+ "hex": "#252525"
+ },
+ {
+ "code": "SW6248",
+ "name": "Gauntlet Gray",
+ "hex": "#4A4A4C"
+ },
+ {
+ "code": "SW6250",
+ "name": "Peppercorn",
+ "hex": "#2D2D2F"
+ },
+ {
+ "code": "SW6252",
+ "name": "Dark Night",
+ "hex": "#1A1A1A"
+ },
+ {
+ "code": "SW6253",
+ "name": "Black Magic",
+ "hex": "#1C1C1C"
+ },
+ {
+ "code": "SW6254",
+ "name": "Raven Black",
+ "hex": "#101010"
+ },
+ {
+ "code": "SW6255",
+ "name": "Total Black",
+ "hex": "#000000"
+ },
+ {
+ "code": "SW6257",
+ "name": "Urbane Bronze",
+ "hex": "#595959"
+ },
+ {
+ "code": "SW6258",
+ "name": "Tricorn Black",
+ "hex": "#2F2F30"
+ },
+ {
+ "code": "SW6385",
+ "name": "Dover White",
+ "hex": "#EEE5D8"
+ },
+ {
+ "code": "SW7001",
+ "name": "Urbane Bronze",
+ "hex": "#5A5A5C"
+ },
+ {
+ "code": "SW7005",
+ "name": "Pure White",
+ "hex": "#EDECE6"
+ },
+ {
+ "code": "SW7006",
+ "name": "Extra White",
+ "hex": "#FFFFFF"
+ },
+ {
+ "code": "SW7008",
+ "name": "Alabaster",
+ "hex": "#EDE9DD"
+ },
+ {
+ "code": "SW7009",
+ "name": "Creamy",
+ "hex": "#F5F4EF"
+ },
+ {
+ "code": "SW7013",
+ "name": "Repose Gray",
+ "hex": "#C0BDB2"
+ },
+ {
+ "code": "SW7015",
+ "name": "Repose Gray",
+ "hex": "#B9B9B9"
+ },
+ {
+ "code": "SW7016",
+ "name": "Mindful Gray",
+ "hex": "#7F8483"
+ },
+ {
+ "code": "SW7017",
+ "name": "Urbane Bronze",
+ "hex": "#505050"
+ },
+ {
+ "code": "SW7018",
+ "name": "Terminal Gray",
+ "hex": "#7A7A7A"
+ },
+ {
+ "code": "SW7019",
+ "name": "Charcoal Gray",
+ "hex": "#4D4D4D"
+ },
+ {
+ "code": "SW7020",
+ "name": "Repose Gray",
+ "hex": "#999999"
+ },
+ {
+ "code": "SW7021",
+ "name": "Black Olive",
+ "hex": "#3D3D3D"
+ },
+ {
+ "code": "SW7022",
+ "name": "Black Bean",
+ "hex": "#2D2D2D"
+ },
+ {
+ "code": "SW7023",
+ "name": "Polished Black",
+ "hex": "#1F1F1F"
+ },
+ {
+ "code": "SW7024",
+ "name": "Cavern Clay",
+ "hex": "#7F7F7F"
+ },
+ {
+ "code": "SW7025",
+ "name": "Shade-Grown",
+ "hex": "#6B6B6B"
+ },
+ {
+ "code": "SW7026",
+ "name": "Urbane Bronze",
+ "hex": "#5D5D5D"
+ },
+ {
+ "code": "SW7027",
+ "name": "Antique Red",
+ "hex": "#8B4545"
+ },
+ {
+ "code": "SW7028",
+ "name": "Incredible White",
+ "hex": "#EDE6DE"
+ },
+ {
+ "code": "SW7029",
+ "name": "Agreeable Gray",
+ "hex": "#D1CCCB"
+ },
+ {
+ "code": "SW7030",
+ "name": "Accessible Beige",
+ "hex": "#D4CBC3"
+ },
+ {
+ "code": "SW7031",
+ "name": "Urbane Bronze",
+ "hex": "#595959"
+ },
+ {
+ "code": "SW7032",
+ "name": "Cavern Clay",
+ "hex": "#7A6C63"
+ },
+ {
+ "code": "SW7033",
+ "name": "Accessible Beige",
+ "hex": "#E0D5CA"
+ },
+ {
+ "code": "SW7034",
+ "name": "Urbane Bronze",
+ "hex": "#575757"
+ },
+ {
+ "code": "SW7035",
+ "name": "Repose Gray",
+ "hex": "#BFBFBF"
+ },
+ {
+ "code": "SW7036",
+ "name": "Tricorn Black",
+ "hex": "#2D2D2F"
+ },
+ {
+ "code": "SW7037",
+ "name": "Light French Gray",
+ "hex": "#D4D4D4"
+ },
+ {
+ "code": "SW7038",
+ "name": "Silhouette",
+ "hex": "#828282"
+ },
+ {
+ "code": "SW7039",
+ "name": "Urbane Bronze",
+ "hex": "#5A5A5C"
+ },
+ {
+ "code": "SW7040",
+ "name": "Cavern Clay",
+ "hex": "#8B7765"
+ },
+ {
+ "code": "SW7041",
+ "name": "N0",
+ "hex": "#FFFFFF"
+ },
+ {
+ "code": "SW7042",
+ "name": "Shoji White",
+ "hex": "#E8E4DB"
+ },
+ {
+ "code": "SW7043",
+ "name": "Accessible Beige",
+ "hex": "#D0CCC4"
+ },
+ {
+ "code": "SW7044",
+ "name": "Urbane Bronze",
+ "hex": "#555555"
+ },
+ {
+ "code": "SW7045",
+ "name": "Tricorn Black",
+ "hex": "#2F2F30"
+ },
+ {
+ "code": "SW7046",
+ "name": "Black Bean",
+ "hex": "#1C1C1C"
+ },
+ {
+ "code": "SW7047",
+ "name": "Accessible Beige",
+ "hex": "#DDD5CE"
+ },
+ {
+ "code": "SW7048",
+ "name": "Urbane Bronze",
+ "hex": "#595959"
+ },
+ {
+ "code": "SW7049",
+ "name": "Cavern Clay",
+ "hex": "#7A6C63"
+ },
+ {
+ "code": "SW7050",
+ "name": "Tricorn Black",
+ "hex": "#2F2F30"
+ },
+ {
+ "code": "SW9109",
+ "name": "Natural Linen",
+ "hex": "#D4D0C8"
+ },
+ {
+ "code": "SW9166",
+ "name": "Drift of Mist",
+ "hex": "#E8E8E8"
+ }
+]
\ No newline at end of file
← d1fefad fix(site.css): bump wordmark weights per debate consensus
·
back to Wallco Ai
·
fix(/studio): escape inline onclick string quotes properly feb477a →