[object Object]

← back to Wallco Ai

wallco.ai v0.1: PG schema (pd_source_designs + spoon_all_designs) · Met/Wikimedia/Smithsonian crawler · palette extraction · design generator (stub backend) · viewer on :9792 · 10 seamless tiles + 33 PD source images

28534381f83a0c1985e3edf273a221ce50f416e5 · 2026-05-11 15:59:12 -0700 · Steve

Files touched

Diff

commit 28534381f83a0c1985e3edf273a221ce50f416e5
Author: Steve <steve@designerwallcoverings.com>
Date:   Mon May 11 15:59:12 2026 -0700

    wallco.ai v0.1: PG schema (pd_source_designs + spoon_all_designs) · Met/Wikimedia/Smithsonian crawler · palette extraction · design generator (stub backend) · viewer on :9792 · 10 seamless tiles + 33 PD source images
---
 .gitignore                   |   8 +
 db/migrations/001_schema.sql |  65 ++++
 ecosystem.config.js          |   9 +
 package-lock.json            | 840 +++++++++++++++++++++++++++++++++++++++++++
 package.json                 |  17 +
 scripts/crawl_pd.py          | 344 ++++++++++++++++++
 scripts/extract_palette.py   |  53 +++
 scripts/generate_designs.js  | 205 +++++++++++
 server.js                    | 217 +++++++++++
 9 files changed, 1758 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..09fc806
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+data/images/
diff --git a/db/migrations/001_schema.sql b/db/migrations/001_schema.sql
new file mode 100644
index 0000000..9c3707f
--- /dev/null
+++ b/db/migrations/001_schema.sql
@@ -0,0 +1,65 @@
+-- wallco.ai — PG schema (dw_unified)
+-- Two tables:
+--   pd_source_designs   — public-domain / CC0 pattern source corpus (Smithsonian, Wikimedia, LACMA, IA, V&A CC0)
+--   spoon_all_designs   — generated wallco.ai designs (AI-original + variants), tracks provenance
+
+CREATE TABLE IF NOT EXISTS pd_source_designs (
+  id                BIGSERIAL PRIMARY KEY,
+  source            TEXT NOT NULL,             -- 'smithsonian' | 'wikimedia' | 'lacma' | 'ia' | 'va_cc0'
+  source_id         TEXT NOT NULL,             -- ID at source (e.g. 'edanmdm:nmah_1234')
+  title             TEXT,
+  creator           TEXT,
+  date_created      TEXT,                      -- free-text; PD spans centuries, hard to normalize
+  license           TEXT NOT NULL,             -- 'CC0' | 'PD' | 'PDMark' (must be one of these for inclusion)
+  source_url        TEXT NOT NULL,             -- canonical source page
+  image_url         TEXT NOT NULL,             -- high-res image URL
+  local_path        TEXT,                      -- ~/Projects/wallco-ai/data/images/<source>/<id>.jpg once downloaded
+  width_px          INTEGER,
+  height_px         INTEGER,
+  bytes             BIGINT,
+  raw_metadata      JSONB,                     -- whatever the source API returned
+  tags              TEXT[],                    -- source-provided tags
+  motifs            TEXT[],                    -- LLM-extracted motifs (e.g. 'peony', 'scroll', 'damask')
+  dominant_hex      TEXT,
+  palette           JSONB,                     -- [{hex, pct}, ...]
+  category          TEXT,                      -- 'floral' | 'damask' | 'geometric' | 'art-deco' | etc.
+  ingested_at       TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+  UNIQUE (source, source_id)
+);
+CREATE INDEX IF NOT EXISTS idx_pd_source_designs_source     ON pd_source_designs(source);
+CREATE INDEX IF NOT EXISTS idx_pd_source_designs_category   ON pd_source_designs(category);
+CREATE INDEX IF NOT EXISTS idx_pd_source_designs_dominant   ON pd_source_designs(dominant_hex);
+CREATE INDEX IF NOT EXISTS idx_pd_source_designs_motifs     ON pd_source_designs USING GIN(motifs);
+CREATE INDEX IF NOT EXISTS idx_pd_source_designs_tags       ON pd_source_designs USING GIN(tags);
+
+
+CREATE TABLE IF NOT EXISTS spoon_all_designs (
+  id                BIGSERIAL PRIMARY KEY,
+  brand             TEXT NOT NULL DEFAULT 'wallco.ai',
+  kind              TEXT NOT NULL CHECK (kind IN ('seamless_tile','mural','mural_panel')),
+  width_in          NUMERIC(8,2),
+  height_in         NUMERIC(8,2),
+  panels            INTEGER,                   -- mural: how many panels; null for seamless tile
+  generator         TEXT,                      -- 'sdxl-text' | 'sdxl-img2img' | 'comfy' | 'replicate' | 'stub'
+  prompt            TEXT,                      -- text prompt used (always recorded for provenance)
+  negative_prompt   TEXT,
+  seed              BIGINT,
+  steps             INTEGER,
+  cfg_scale         NUMERIC(5,2),
+  pd_source_ids     BIGINT[],                  -- which pd_source_designs rows fed this generation (if any)
+  image_url         TEXT,                      -- public CDN/serve URL (when deployed)
+  local_path        TEXT,                      -- ~/Projects/wallco-ai/data/generated/<id>.png
+  dominant_hex      TEXT,
+  palette           JSONB,
+  motifs            TEXT[],                    -- LLM-extracted
+  tags              TEXT[],
+  category          TEXT,
+  is_published      BOOLEAN NOT NULL DEFAULT FALSE,
+  notes             TEXT,
+  created_at        TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+CREATE INDEX IF NOT EXISTS idx_spoon_all_kind       ON spoon_all_designs(kind);
+CREATE INDEX IF NOT EXISTS idx_spoon_all_category   ON spoon_all_designs(category);
+CREATE INDEX IF NOT EXISTS idx_spoon_all_motifs     ON spoon_all_designs USING GIN(motifs);
+CREATE INDEX IF NOT EXISTS idx_spoon_all_tags       ON spoon_all_designs USING GIN(tags);
+CREATE INDEX IF NOT EXISTS idx_spoon_all_published  ON spoon_all_designs(is_published);
diff --git a/ecosystem.config.js b/ecosystem.config.js
new file mode 100644
index 0000000..1bdba17
--- /dev/null
+++ b/ecosystem.config.js
@@ -0,0 +1,9 @@
+module.exports = {
+  apps: [{
+    name: 'wallco-ai-viewer',
+    cwd: '/Users/stevestudio2/Projects/wallco-ai',
+    script: 'server.js',
+    env: { PORT: 9792 },
+    max_memory_restart: '256M'
+  }]
+};
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..7eb5d1d
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,840 @@
+{
+  "name": "wallco-ai",
+  "version": "1.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "wallco-ai",
+      "version": "1.0.0",
+      "license": "ISC",
+      "dependencies": {
+        "dotenv": "^17.4.2",
+        "express": "^5.2.1"
+      }
+    },
+    "node_modules/accepts": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
+      "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-types": "^3.0.0",
+        "negotiator": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/body-parser": {
+      "version": "2.2.2",
+      "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
+      "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "^3.1.2",
+        "content-type": "^1.0.5",
+        "debug": "^4.4.3",
+        "http-errors": "^2.0.0",
+        "iconv-lite": "^0.7.0",
+        "on-finished": "^2.4.1",
+        "qs": "^6.14.1",
+        "raw-body": "^3.0.1",
+        "type-is": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/bytes": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+      "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/call-bound": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+      "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "get-intrinsic": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/content-disposition": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
+      "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/content-type": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+      "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie": {
+      "version": "0.7.2",
+      "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+      "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie-signature": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
+      "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.6.0"
+      }
+    },
+    "node_modules/debug": {
+      "version": "4.4.3",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/depd": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+      "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/dotenv": {
+      "version": "17.4.2",
+      "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
+      "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://dotenvx.com"
+      }
+    },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/ee-first": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+      "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+      "license": "MIT"
+    },
+    "node_modules/encodeurl": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+      "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+      "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/escape-html": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+      "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+      "license": "MIT"
+    },
+    "node_modules/etag": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+      "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/express": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
+      "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
+      "license": "MIT",
+      "dependencies": {
+        "accepts": "^2.0.0",
+        "body-parser": "^2.2.1",
+        "content-disposition": "^1.0.0",
+        "content-type": "^1.0.5",
+        "cookie": "^0.7.1",
+        "cookie-signature": "^1.2.1",
+        "debug": "^4.4.0",
+        "depd": "^2.0.0",
+        "encodeurl": "^2.0.0",
+        "escape-html": "^1.0.3",
+        "etag": "^1.8.1",
+        "finalhandler": "^2.1.0",
+        "fresh": "^2.0.0",
+        "http-errors": "^2.0.0",
+        "merge-descriptors": "^2.0.0",
+        "mime-types": "^3.0.0",
+        "on-finished": "^2.4.1",
+        "once": "^1.4.0",
+        "parseurl": "^1.3.3",
+        "proxy-addr": "^2.0.7",
+        "qs": "^6.14.0",
+        "range-parser": "^1.2.1",
+        "router": "^2.2.0",
+        "send": "^1.1.0",
+        "serve-static": "^2.2.0",
+        "statuses": "^2.0.1",
+        "type-is": "^2.0.1",
+        "vary": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/finalhandler": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
+      "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^4.4.0",
+        "encodeurl": "^2.0.0",
+        "escape-html": "^1.0.3",
+        "on-finished": "^2.4.1",
+        "parseurl": "^1.3.3",
+        "statuses": "^2.0.1"
+      },
+      "engines": {
+        "node": ">= 18.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/forwarded": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+      "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/fresh": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
+      "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
+      "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
+      "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/http-errors": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+      "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+      "license": "MIT",
+      "dependencies": {
+        "depd": "~2.0.0",
+        "inherits": "~2.0.4",
+        "setprototypeof": "~1.2.0",
+        "statuses": "~2.0.2",
+        "toidentifier": "~1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/iconv-lite": {
+      "version": "0.7.2",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
+      "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
+      "license": "MIT",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+      "license": "ISC"
+    },
+    "node_modules/ipaddr.js": {
+      "version": "1.9.1",
+      "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+      "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/is-promise": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
+      "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
+      "license": "MIT"
+    },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/media-typer": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
+      "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/merge-descriptors": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
+      "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/mime-db": {
+      "version": "1.54.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+      "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
+      "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "^1.54.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "license": "MIT"
+    },
+    "node_modules/negotiator": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
+      "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/object-inspect": {
+      "version": "1.13.4",
+      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+      "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/on-finished": {
+      "version": "2.4.1",
+      "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+      "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+      "license": "MIT",
+      "dependencies": {
+        "ee-first": "1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/once": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+      "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+      "license": "ISC",
+      "dependencies": {
+        "wrappy": "1"
+      }
+    },
+    "node_modules/parseurl": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+      "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/path-to-regexp": {
+      "version": "8.4.2",
+      "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
+      "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
+      "license": "MIT",
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/proxy-addr": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+      "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+      "license": "MIT",
+      "dependencies": {
+        "forwarded": "0.2.0",
+        "ipaddr.js": "1.9.1"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/qs": {
+      "version": "6.15.1",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
+      "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "side-channel": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=0.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/range-parser": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+      "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/raw-body": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
+      "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "~3.1.2",
+        "http-errors": "~2.0.1",
+        "iconv-lite": "~0.7.0",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/router": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
+      "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^4.4.0",
+        "depd": "^2.0.0",
+        "is-promise": "^4.0.0",
+        "parseurl": "^1.3.3",
+        "path-to-regexp": "^8.0.0"
+      },
+      "engines": {
+        "node": ">= 18"
+      }
+    },
+    "node_modules/safer-buffer": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+      "license": "MIT"
+    },
+    "node_modules/send": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
+      "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^4.4.3",
+        "encodeurl": "^2.0.0",
+        "escape-html": "^1.0.3",
+        "etag": "^1.8.1",
+        "fresh": "^2.0.0",
+        "http-errors": "^2.0.1",
+        "mime-types": "^3.0.2",
+        "ms": "^2.1.3",
+        "on-finished": "^2.4.1",
+        "range-parser": "^1.2.1",
+        "statuses": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/serve-static": {
+      "version": "2.2.1",
+      "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
+      "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
+      "license": "MIT",
+      "dependencies": {
+        "encodeurl": "^2.0.0",
+        "escape-html": "^1.0.3",
+        "parseurl": "^1.3.3",
+        "send": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/setprototypeof": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+      "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+      "license": "ISC"
+    },
+    "node_modules/side-channel": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+      "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.3",
+        "side-channel-list": "^1.0.0",
+        "side-channel-map": "^1.0.1",
+        "side-channel-weakmap": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-list": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+      "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.4"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-map": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+      "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-weakmap": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+      "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3",
+        "side-channel-map": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/statuses": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+      "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/toidentifier": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+      "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.6"
+      }
+    },
+    "node_modules/type-is": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
+      "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
+      "license": "MIT",
+      "dependencies": {
+        "content-type": "^1.0.5",
+        "media-typer": "^1.1.0",
+        "mime-types": "^3.0.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/unpipe": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+      "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/vary": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+      "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/wrappy": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+      "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+      "license": "ISC"
+    }
+  }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..c5f9381
--- /dev/null
+++ b/package.json
@@ -0,0 +1,17 @@
+{
+  "name": "wallco-ai",
+  "version": "1.0.0",
+  "main": "server.js",
+  "scripts": {
+    "test": "echo \"Error: no test specified\" && exit 1",
+    "start": "node server.js"
+  },
+  "keywords": [],
+  "author": "",
+  "license": "ISC",
+  "description": "",
+  "dependencies": {
+    "dotenv": "^17.4.2",
+    "express": "^5.2.1"
+  }
+}
diff --git a/scripts/crawl_pd.py b/scripts/crawl_pd.py
new file mode 100644
index 0000000..7ffedb9
--- /dev/null
+++ b/scripts/crawl_pd.py
@@ -0,0 +1,344 @@
+#!/usr/bin/env python3
+"""
+Multi-source public-domain pattern corpus crawler for wallco.ai.
+
+Sources:
+  - Wikimedia Commons (no key, polite 2 req/s)
+      Categories: Textile_patterns, Floral_patterns, Decorative_patterns,
+                  Geometric_patterns, Damask, Paisley_patterns, Toile, Brocade,
+                  Files_from_the_Smithsonian, Files_from_the_Cooper_Hewitt,_Smithsonian_Design_Museum
+  - Met Museum Open Access (no key, no advertised rate limit)
+      Filter: isPublicDomain=true, classification in our pattern set
+  - Smithsonian / Cooper Hewitt (DEMO_KEY 30/hr — used sparingly)
+      Query: unit_code=CHNDM AND wallpaper
+
+Writes to dw_unified.pd_source_designs. Idempotent on (source, source_id).
+Downloads images to ~/Projects/wallco-ai/data/images/<source>/<id>.<ext>.
+
+Run:
+  python3 scripts/crawl_pd.py --source wikimedia --limit 200
+  python3 scripts/crawl_pd.py --source met --limit 500
+  python3 scripts/crawl_pd.py --source smithsonian --limit 50
+"""
+import os, sys, json, time, argparse, urllib.request, ssl, subprocess
+from pathlib import Path
+from urllib.parse import quote
+
+ROOT = Path.home() / 'Projects' / 'wallco-ai'
+IMGROOT = ROOT / 'data' / 'images'
+LOG = ROOT / 'logs' / 'crawl_pd.log'
+IMGROOT.mkdir(parents=True, exist_ok=True)
+LOG.parent.mkdir(parents=True, exist_ok=True)
+
+CTX = ssl.create_default_context()
+
+WIKIMEDIA_CATEGORIES = [
+    'Textile_patterns', 'Floral_patterns', 'Decorative_patterns',
+    'Geometric_patterns', 'Damask', 'Paisley_patterns', 'Toile_de_Jouy',
+    'Brocade', 'Wallpaper_(material)', 'Embroidery_patterns'
+]
+
+MET_PATTERN_QUERIES = ['wallpaper', 'textile', 'damask', 'brocade', 'pattern', 'design', 'ornament']
+
+PD_LICENSES = {'CC0', 'PD', 'Public domain', 'Public domain mark', 'PD-old', 'PD-self', 'PD-Art', 'PDM'}
+
+
+def http_get_json(url, timeout=20):
+    req = urllib.request.Request(url, headers={'User-Agent': 'wallco.ai-crawler/0.1 (research; contact: steve@designerwallcoverings.com)'})
+    with urllib.request.urlopen(req, timeout=timeout, context=CTX) as r:
+        return json.loads(r.read().decode('utf-8'))
+
+
+def http_download(url, dest, timeout=60):
+    req = urllib.request.Request(url, headers={'User-Agent': 'wallco.ai-crawler/0.1'})
+    with urllib.request.urlopen(req, timeout=timeout, context=CTX) as r, open(dest, 'wb') as f:
+        f.write(r.read())
+
+
+def psql(sql):
+    r = subprocess.run(['psql', 'dw_unified', '-At', '-q'], input=sql, capture_output=True, text=True)
+    if r.returncode != 0:
+        raise RuntimeError(f"psql failed: {r.stderr}")
+    return r.stdout.strip()
+
+
+def esc(s):
+    if s is None: return 'NULL'
+    return "'" + str(s).replace("'", "''") + "'"
+
+
+def upsert(source, source_id, **fields):
+    cols = ['source', 'source_id'] + list(fields.keys())
+    vals_clean = [esc(source), esc(source_id)]
+    for k, v in fields.items():
+        if v is None:
+            vals_clean.append('NULL')
+        elif isinstance(v, list):
+            arr = "ARRAY[" + ",".join(esc(x) for x in v) + "]::text[]"
+            vals_clean.append(arr)
+        elif isinstance(v, dict):
+            vals_clean.append(f"{esc(json.dumps(v))}::jsonb")
+        elif isinstance(v, bool):
+            vals_clean.append('TRUE' if v else 'FALSE')
+        elif isinstance(v, (int, float)):
+            vals_clean.append(str(v))
+        else:
+            vals_clean.append(esc(v))
+    update_set = ", ".join(f"{k}=EXCLUDED.{k}" for k in fields.keys())
+    sql = f"""
+INSERT INTO pd_source_designs ({", ".join(cols)})
+VALUES ({", ".join(vals_clean)})
+ON CONFLICT (source, source_id) DO UPDATE SET {update_set}, ingested_at=NOW()
+RETURNING id;
+"""
+    return psql(sql)
+
+
+# -------- Wikimedia Commons --------
+
+def crawl_wikimedia(limit=None, polite_s=0.5):
+    saved = 0
+    skipped_license = 0
+    for cat in WIKIMEDIA_CATEGORIES:
+        cont = ''
+        while True:
+            url = f'https://commons.wikimedia.org/w/api.php?action=query&list=categorymembers&cmtitle=Category:{cat}&cmtype=file&cmlimit=200&format=json' + cont
+            try:
+                d = http_get_json(url)
+            except Exception as e:
+                print(f"  cat {cat} fetch err: {e}")
+                break
+            members = d.get('query', {}).get('categorymembers', []) or []
+            for m in members:
+                title = m['title']  # e.g. 'File:foo.jpg'
+                if not title.startswith('File:'):
+                    continue
+                # Fetch imageinfo
+                ii_url = f'https://commons.wikimedia.org/w/api.php?action=query&titles={quote(title)}&prop=imageinfo&iiprop=url|extmetadata|size|mime&format=json'
+                try:
+                    ii = http_get_json(ii_url)
+                except Exception as e:
+                    print(f"  imageinfo err for {title}: {e}")
+                    continue
+                pages = ii.get('query', {}).get('pages', {}) or {}
+                page = next(iter(pages.values()), None)
+                if not page: continue
+                infos = page.get('imageinfo') or []
+                if not infos: continue
+                info = infos[0]
+                exm = info.get('extmetadata', {}) or {}
+                license_short = (exm.get('LicenseShortName', {}) or {}).get('value', '')
+                if not any(lic.lower() in license_short.lower() for lic in PD_LICENSES):
+                    skipped_license += 1
+                    time.sleep(polite_s)
+                    continue
+                source_id = title
+                creator   = (exm.get('Artist', {}) or {}).get('value', '')
+                date_created = (exm.get('DateTimeOriginal', {}) or {}).get('value', '') or (exm.get('DateTime', {}) or {}).get('value', '')
+                desc = (exm.get('ImageDescription', {}) or {}).get('value', '')
+                cats = (exm.get('Categories', {}) or {}).get('value', '')
+                # strip HTML
+                import re
+                creator_clean = re.sub(r'<[^>]+>', '', creator).strip()[:250]
+                desc_clean = re.sub(r'<[^>]+>', '', desc).strip()
+                tags = [t.strip() for t in cats.split('|') if t.strip()][:30]
+
+                img_url = info.get('url')
+                width = info.get('width')
+                height = info.get('height')
+                bytes_ = info.get('size')
+
+                # Download
+                ext = (img_url or '').rsplit('.', 1)[-1].lower()[:5] or 'jpg'
+                dest_dir = IMGROOT / 'wikimedia'
+                dest_dir.mkdir(parents=True, exist_ok=True)
+                safe = title.replace('File:', '').replace(' ', '_').replace('/', '_')[:120]
+                dest = dest_dir / f'{safe}'
+                if not dest.exists() and img_url:
+                    try:
+                        http_download(img_url, dest)
+                    except Exception as e:
+                        print(f"  dl err {title}: {e}")
+                        time.sleep(polite_s)
+                        continue
+
+                row_id = upsert(
+                    'wikimedia',
+                    source_id,
+                    title=desc_clean[:300] or title.replace('File:', ''),
+                    creator=creator_clean,
+                    date_created=date_created[:60] if date_created else None,
+                    license=license_short or 'PD',
+                    source_url=f'https://commons.wikimedia.org/wiki/{quote(title)}',
+                    image_url=img_url,
+                    local_path=str(dest),
+                    width_px=width,
+                    height_px=height,
+                    bytes=bytes_,
+                    raw_metadata=exm,
+                    tags=tags,
+                    category=cat.replace('_', '-').lower()
+                )
+                saved += 1
+                if saved % 10 == 0:
+                    print(f"  [{saved}] wikimedia saved · cat={cat} · last={title[:60]}")
+                if limit and saved >= limit:
+                    print(f"  hit limit {limit}, stopping")
+                    return saved, skipped_license
+                time.sleep(polite_s)
+
+            cont_obj = d.get('continue')
+            if not cont_obj:
+                break
+            cont = '&cmcontinue=' + cont_obj['cmcontinue']
+    return saved, skipped_license
+
+
+# -------- Met Museum Open Access --------
+
+def crawl_met(limit=None, polite_s=0.4):
+    saved = 0
+    skipped_non_pd = 0
+    seen_ids = set()
+    for q in MET_PATTERN_QUERIES:
+        try:
+            ids_resp = http_get_json(f'https://collectionapi.metmuseum.org/public/collection/v1/search?q={quote(q)}&hasImages=true')
+        except Exception as e:
+            print(f"  met search err for {q}: {e}")
+            continue
+        ids = ids_resp.get('objectIDs') or []
+        print(f"  Met query '{q}' returned {len(ids)} candidates")
+        for oid in ids:
+            if oid in seen_ids: continue
+            seen_ids.add(oid)
+            try:
+                o = http_get_json(f'https://collectionapi.metmuseum.org/public/collection/v1/objects/{oid}')
+            except Exception as e:
+                print(f"  object {oid} err: {e}")
+                time.sleep(polite_s); continue
+            if not o.get('isPublicDomain'):
+                skipped_non_pd += 1
+                time.sleep(polite_s); continue
+            primary = o.get('primaryImage') or o.get('primaryImageSmall')
+            if not primary:
+                time.sleep(polite_s); continue
+            title = o.get('title') or f"Met #{oid}"
+            ext = primary.rsplit('.', 1)[-1].split('?')[0][:5] or 'jpg'
+            dest_dir = IMGROOT / 'met'
+            dest_dir.mkdir(parents=True, exist_ok=True)
+            dest = dest_dir / f'met_{oid}.{ext}'
+            if not dest.exists():
+                try:
+                    http_download(primary, dest)
+                except Exception as e:
+                    print(f"  met dl err {oid}: {e}")
+                    time.sleep(polite_s); continue
+            tags = [t.get('term') for t in (o.get('tags') or []) if t.get('term')]
+            row = upsert(
+                'met',
+                str(oid),
+                title=title[:300],
+                creator=o.get('artistDisplayName') or '',
+                date_created=o.get('objectDate') or None,
+                license='CC0',
+                source_url=o.get('objectURL') or f'https://www.metmuseum.org/art/collection/search/{oid}',
+                image_url=primary,
+                local_path=str(dest),
+                raw_metadata={k: o.get(k) for k in ('classification','medium','culture','department','dimensions','period','objectName')},
+                tags=tags,
+                category=(o.get('classification') or '').lower().replace(' ', '-') or 'pattern'
+            )
+            saved += 1
+            if saved % 5 == 0:
+                print(f"  [{saved}] met saved · oid={oid} · {title[:50]}")
+            if limit and saved >= limit:
+                return saved, skipped_non_pd
+            time.sleep(polite_s)
+    return saved, skipped_non_pd
+
+
+# -------- Smithsonian / Cooper Hewitt --------
+
+def crawl_smithsonian(limit=50, polite_s=2.5, api_key='DEMO_KEY'):
+    # DEMO_KEY = 30 req/hour. With polite_s=2.5 we do ~24 req/min — too fast.
+    # 30 req/hour = 1 every 120s. Stick to that or set a real api_key in .env.
+    # For initial seeding we just sample.
+    saved = 0
+    start = 0
+    while True:
+        url = f'https://api.si.edu/openaccess/api/v1.0/search?q=unit_code%3ACHNDM+AND+wallpaper&start={start}&rows=20&api_key={api_key}'
+        try:
+            d = http_get_json(url)
+        except Exception as e:
+            print(f"  si search err: {e}")
+            break
+        rows = (d.get('response') or {}).get('rows') or []
+        if not rows: break
+        for r in rows:
+            dnr = (r.get('content') or {}).get('descriptiveNonRepeating') or {}
+            om = dnr.get('online_media') or {}
+            media = om.get('media') or []
+            ind = (r.get('content') or {}).get('indexedStructured') or {}
+            for m in media[:5]:  # keep at most 5 images per record
+                usage = (m.get('usage') or {}).get('access')
+                if usage != 'CC0':
+                    continue
+                img_url = m.get('content') or m.get('thumbnail')
+                if not img_url: continue
+                source_id = f"{r.get('id')}|{m.get('idsId') or img_url[-40:]}"
+                dest_dir = IMGROOT / 'smithsonian'
+                dest_dir.mkdir(parents=True, exist_ok=True)
+                ext = 'jpg'
+                safe = source_id.replace('|','_').replace('/','_')[:120]
+                dest = dest_dir / f'{safe}.{ext}'
+                if not dest.exists():
+                    try:
+                        http_download(img_url, dest)
+                    except Exception as e:
+                        print(f"  si dl err {source_id}: {e}")
+                        continue
+                upsert(
+                    'smithsonian',
+                    source_id,
+                    title=(r.get('title') or '')[:300],
+                    creator=(', '.join((ind.get('name') or [])))[:250],
+                    date_created=(', '.join(ind.get('date') or []))[:60],
+                    license='CC0',
+                    source_url=f"https://www.si.edu/object/{r.get('id')}",
+                    image_url=img_url,
+                    local_path=str(dest),
+                    raw_metadata={k: ind.get(k) for k in ('object_type','topic','culture','place','date')},
+                    tags=(ind.get('topic') or [])[:20],
+                    category=(((ind.get('object_type') or [None])[0]) or 'wallpaper').lower().replace(' ', '-')
+                )
+                saved += 1
+                if saved % 5 == 0:
+                    print(f"  [{saved}] smithsonian saved · {r.get('title','')[:40]}")
+                if limit and saved >= limit:
+                    return saved
+                time.sleep(polite_s)
+        start += 20
+        if start >= 500: break
+    return saved
+
+
+def main():
+    ap = argparse.ArgumentParser()
+    ap.add_argument('--source', choices=['wikimedia', 'met', 'smithsonian', 'all'], default='all')
+    ap.add_argument('--limit', type=int, default=200, help='per-source cap')
+    args = ap.parse_args()
+    if args.source in ('wikimedia', 'all'):
+        n, sk = crawl_wikimedia(limit=args.limit)
+        print(f"WIKIMEDIA: saved {n}, skipped non-PD {sk}")
+    if args.source in ('met', 'all'):
+        n, sk = crawl_met(limit=args.limit)
+        print(f"MET: saved {n}, skipped non-PD {sk}")
+    if args.source in ('smithsonian', 'all'):
+        n = crawl_smithsonian(limit=min(args.limit, 50))
+        print(f"SMITHSONIAN: saved {n}")
+    print("\n--- DB counts ---")
+    print(psql("SELECT source, COUNT(*) FROM pd_source_designs GROUP BY source ORDER BY 1;"))
+
+
+if __name__ == '__main__':
+    main()
diff --git a/scripts/extract_palette.py b/scripts/extract_palette.py
new file mode 100644
index 0000000..40e7f19
--- /dev/null
+++ b/scripts/extract_palette.py
@@ -0,0 +1,53 @@
+#!/usr/bin/env python3
+"""Extract dominant_hex + 5-color palette for every pd_source_designs row
+with a local image but no palette yet. Reuses the SP-onboarding pattern."""
+import json, subprocess
+from pathlib import Path
+from PIL import Image
+from collections import Counter
+
+def dominant_palette(path, k=5):
+    img = Image.open(path).convert('RGB')
+    img.thumbnail((300, 300))
+    pal = img.quantize(colors=k, method=Image.Quantize.MEDIANCUT).convert('RGB')
+    pixels = list(pal.getdata())
+    cnt = Counter(pixels)
+    total = sum(cnt.values())
+    return [{'hex': '#{:02x}{:02x}{:02x}'.format(*rgb), 'pct': round(n/total*100, 2)} for rgb, n in cnt.most_common(k)]
+
+def fetchall_pending():
+    sql = "SELECT json_agg(t) FROM (SELECT id, local_path FROM pd_source_designs WHERE local_path IS NOT NULL AND palette IS NULL ORDER BY id) t;"
+    out = subprocess.run(['psql','dw_unified','-At','-q','-c',sql], capture_output=True, text=True, check=True).stdout.strip()
+    return json.loads(out) if out and out != '' else []
+
+def main():
+    rows = fetchall_pending()
+    print(f"{len(rows)} rows pending palette extraction")
+    updates = []
+    failed = 0
+    for i, r in enumerate(rows, 1):
+        p = Path(r['local_path'])
+        if not p.exists():
+            failed += 1; continue
+        try:
+            palette = dominant_palette(p)
+            dominant = palette[0]['hex']
+            updates.append((r['id'], dominant, palette))
+            if i % 10 == 0:
+                print(f"  [{i:>4}/{len(rows)}] {dominant} {p.name[:60]}")
+        except Exception as e:
+            failed += 1
+            print(f"  [{i:>4}/{len(rows)}] FAIL {p.name[:50]}: {e}")
+    fp = Path('/tmp/wallco_palette.sql')
+    with fp.open('w') as f:
+        f.write("BEGIN;\n")
+        for rid, dh, pal in updates:
+            pj = json.dumps(pal).replace("'", "''")
+            f.write(f"UPDATE pd_source_designs SET dominant_hex='{dh}', palette='{pj}'::jsonb WHERE id={rid};\n")
+        f.write("COMMIT;\n")
+    subprocess.run(['psql','dw_unified','-q','-f', str(fp)], check=True)
+    fp.unlink()
+    print(f"\nUpdated {len(updates)} · failed {failed}")
+
+if __name__ == '__main__':
+    main()
diff --git a/scripts/generate_designs.js b/scripts/generate_designs.js
new file mode 100644
index 0000000..7730d90
--- /dev/null
+++ b/scripts/generate_designs.js
@@ -0,0 +1,205 @@
+#!/usr/bin/env node
+/**
+ * Wallco.ai design generator — produces N seamless 24x24 tiles + mural panels
+ * and stores in dw_unified.spoon_all_designs.
+ *
+ * Generation backends (env-driven):
+ *   GEN_BACKEND=stub        — fast placeholder (1024×1024 noise tile). Default.
+ *   GEN_BACKEND=replicate   — Replicate SDXL via REPLICATE_API_TOKEN.
+ *   GEN_BACKEND=comfy       — local ComfyUI at COMFY_URL (e.g. http://127.0.0.1:8188).
+ *   GEN_BACKEND=ollama-sd   — Ollama with SD-capable model (not common; mostly LLMs).
+ *
+ * Stub mode lets us validate the schema + storage + viewer pipeline today
+ * without a real diffusion endpoint. Swap GEN_BACKEND once Steve has SDXL wired up.
+ *
+ * Usage:
+ *   node scripts/generate_designs.js --n 10 --kind seamless_tile --category floral
+ *   node scripts/generate_designs.js --n 1 --kind mural --width 108 --height 96 --panels 4
+ */
+require('dotenv').config({ path: require('path').join(__dirname,'..','.env') });
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+const { execSync, spawnSync } = require('child_process');
+
+const ROOT = path.join(__dirname, '..');
+const OUT = path.join(ROOT, 'data', 'generated');
+fs.mkdirSync(OUT, { recursive: true });
+
+const BACKEND = process.env.GEN_BACKEND || 'stub';
+
+function args() {
+  const a = process.argv.slice(2);
+  const o = { n: 10, kind: 'seamless_tile', width: 24, height: 24, panels: null, category: 'mixed', prompts: null };
+  for (let i = 0; i < a.length; i++) {
+    const v = a[i+1];
+    switch (a[i]) {
+      case '--n':       o.n = parseInt(v, 10); i++; break;
+      case '--kind':    o.kind = v; i++; break;
+      case '--width':   o.width = parseFloat(v); i++; break;
+      case '--height':  o.height = parseFloat(v); i++; break;
+      case '--panels':  o.panels = parseInt(v, 10); i++; break;
+      case '--category': o.category = v; i++; break;
+      case '--prompts': o.prompts = v.split('|'); i++; break;
+    }
+  }
+  return o;
+}
+
+const PROMPT_LIBRARY = {
+  floral:  [
+    'Art Nouveau peony repeat, sage and rose, watercolour on cream linen, hand-painted seamless tile',
+    'Botanical herbarium pressed-flower pattern, muted olive and ochre, archival paper texture',
+    'English chintz floral spray, navy blue ground, golden yellow blossoms, traditional damask host',
+    'Loose pastel azalea repeat, soft pinks and lavenders, hand-drawn watercolour, 1920s parlour wallpaper'
+  ],
+  geometric: [
+    'Art Deco fan motif repeat, brass and verdigris on jet black, gilded edge, 1925 ocean liner',
+    'Bauhaus colorblock grid, primary red blue yellow on bone white, geometric seamless tile',
+    'Mid-century atomic burst pattern, terracotta and teal, 1955 wallcovering',
+    'Moorish tile lattice, midnight blue and antique brass, seamless 24x24 tile'
+  ],
+  damask: [
+    'Classical Italian damask, oyster on champagne, hand-engraved feel, formal salon wallpaper',
+    'Modern reinterpreted damask, charcoal grey on slate ground, minimalist scale',
+    'Persian arabesque damask, gold thread on indigo silk feel, dimensional'
+  ],
+  mixed: [
+    'Hand-painted toile de Jouy, sage on cream linen, pastoral scene, fine line work',
+    'Block-printed Indian paisley repeat, indigo and madder on calico ground, hand-wood-block feel',
+    'Japanese asanoha geometric, persimmon and slate, washi paper texture'
+  ]
+};
+
+function pickPrompts(category, n) {
+  const pool = PROMPT_LIBRARY[category] || PROMPT_LIBRARY.mixed;
+  const all = [];
+  while (all.length < n) all.push(pool[all.length % pool.length]);
+  return all.slice(0, n);
+}
+
+// ---- backends -------------------------------------------------------------
+
+function genStub(prompt, seed, outPath) {
+  // Generate a deterministic-from-seed 'pattern' using ImageMagick-style HSL gradient via Python+Pillow.
+  // Encodes prompt hash into hue so the output is at least visually distinguishable.
+  const py = `
+import sys, hashlib, random
+from PIL import Image, ImageDraw, ImageFilter
+prompt = sys.argv[1]; seed = int(sys.argv[2]); out = sys.argv[3]
+random.seed(seed)
+h = int(hashlib.sha1(prompt.encode()).hexdigest()[:6], 16)
+hue = h % 360
+import colorsys
+W = H = 1024
+img = Image.new('RGB', (W, H))
+d = ImageDraw.Draw(img)
+def hsl_to_rgb(h, s, l):
+    r,g,b = colorsys.hls_to_rgb(h/360.0, l, s)
+    return (int(r*255), int(g*255), int(b*255))
+img.paste(hsl_to_rgb(hue, 0.55, 0.78), (0,0,W,H))
+# Draw motifs in a tileable grid for seamless feel
+random.seed(seed)
+for _ in range(60):
+    cx = random.randint(0, W); cy = random.randint(0, H)
+    r = random.randint(20, 90)
+    hh = (hue + random.randint(-30, 30)) % 360
+    fill = hsl_to_rgb(hh, 0.65, 0.62)
+    outline = hsl_to_rgb(hh, 0.85, 0.32)
+    d.ellipse((cx-r, cy-r, cx+r, cy+r), fill=fill, outline=outline, width=2)
+img = img.filter(ImageFilter.GaussianBlur(radius=2))
+img.save(out, 'PNG')
+print(out)
+`;
+  const r = spawnSync('python3', ['-c', py, prompt, String(seed), outPath], { encoding: 'utf8' });
+  if (r.status !== 0) throw new Error('stub gen failed: ' + r.stderr);
+  return outPath;
+}
+
+function genReplicate(prompt, _seed, _outPath) {
+  throw new Error('Replicate backend not configured — set REPLICATE_API_TOKEN and SDXL_MODEL_VERSION');
+}
+
+function genComfy() {
+  throw new Error('ComfyUI backend not configured — set COMFY_URL and a workflow JSON path');
+}
+
+function generate(prompt, seed, outPath) {
+  switch (BACKEND) {
+    case 'replicate': return genReplicate(prompt, seed, outPath);
+    case 'comfy':     return genComfy(prompt, seed, outPath);
+    default:          return genStub(prompt, seed, outPath);
+  }
+}
+
+// ---- persist --------------------------------------------------------------
+
+function psql(sql) {
+  const r = spawnSync('psql', ['dw_unified', '-At', '-q'], { input: sql, encoding: 'utf8' });
+  if (r.status !== 0) throw new Error(r.stderr);
+  return r.stdout.trim();
+}
+
+function persistDesign({ kind, prompt, seed, localPath, width, height, panels, category }) {
+  // Extract palette via Pillow
+  const py = `
+from PIL import Image
+from collections import Counter
+import json, sys
+img = Image.open(sys.argv[1]).convert('RGB')
+img.thumbnail((300,300))
+pal = img.quantize(colors=5, method=Image.Quantize.MEDIANCUT).convert('RGB')
+pixels = list(pal.getdata()); cnt = Counter(pixels); total = sum(cnt.values())
+palette = [{'hex':'#{:02x}{:02x}{:02x}'.format(*rgb), 'pct':round(n/total*100,2)} for rgb,n in cnt.most_common(5)]
+print(json.dumps(palette))
+`;
+  const r = spawnSync('python3', ['-c', py, localPath], { encoding: 'utf8' });
+  let palette = [], dominant = null;
+  try { palette = JSON.parse(r.stdout.trim()); dominant = palette[0]?.hex; } catch {}
+
+  const palJson = JSON.stringify(palette).replace(/'/g, "''");
+  const promptEsc = prompt.replace(/'/g, "''");
+  const sql = `
+INSERT INTO spoon_all_designs (kind, width_in, height_in, panels, generator, prompt, seed, dominant_hex, palette, local_path, category, is_published)
+VALUES (
+  '${kind}', ${width}, ${height}, ${panels == null ? 'NULL' : panels},
+  '${BACKEND}', '${promptEsc}', ${seed},
+  ${dominant ? "'" + dominant + "'" : 'NULL'},
+  '${palJson}'::jsonb,
+  '${localPath.replace(/'/g, "''")}',
+  '${category.replace(/'/g, "''")}',
+  FALSE
+) RETURNING id;`;
+  return psql(sql);
+}
+
+// ---- main -----------------------------------------------------------------
+
+function main() {
+  const opt = args();
+  console.log(`Generating ${opt.n} × ${opt.kind} (${opt.width}"×${opt.height}", category=${opt.category}, backend=${BACKEND})`);
+
+  const prompts = opt.prompts || pickPrompts(opt.category, opt.n);
+  const created = [];
+  for (let i = 0; i < opt.n; i++) {
+    const prompt = prompts[i];
+    const seed = crypto.randomInt(1, 2**31 - 1);
+    const filename = `${Date.now()}_${seed}.png`;
+    const outPath = path.join(OUT, filename);
+    try {
+      generate(prompt, seed, outPath);
+      const id = persistDesign({
+        kind: opt.kind, prompt, seed, localPath: outPath,
+        width: opt.width, height: opt.height, panels: opt.panels,
+        category: opt.category
+      });
+      console.log(`  ✓ #${id} · seed ${seed} · ${path.basename(outPath)}`);
+      created.push(id);
+    } catch (e) {
+      console.log(`  ✗ ${e.message}`);
+    }
+  }
+  console.log(`\nCreated ${created.length}/${opt.n} designs · IDs: ${created.join(',')}`);
+}
+
+main();
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..a61df85
--- /dev/null
+++ b/server.js
@@ -0,0 +1,217 @@
+#!/usr/bin/env node
+/**
+ * Wallco.ai local viewer — http://127.0.0.1:9792
+ *
+ * Two tabs:
+ *   /pd        — public-domain source corpus (Met / Wikimedia / Smithsonian)
+ *   /designs   — wallco.ai generated designs (spoon_all_designs)
+ * Standing rule: sort + density slider (localStorage-persisted).
+ */
+require('dotenv').config();
+const express = require('express');
+const path = require('path');
+const fs = require('fs');
+const { execSync } = require('child_process');
+
+const app = express();
+const PORT = process.env.PORT || 9792;
+const DATA_ROOT = path.join(__dirname, 'data');
+
+function psql(sql) {
+  return execSync(`psql dw_unified -At -q`, { input: sql, encoding: 'utf8' }).trim();
+}
+
+// Serve local image files (so the viewer can show downloaded PD images)
+app.use('/img', express.static(DATA_ROOT, { maxAge: '7d' }));
+
+app.get('/api/pd', (_req, res) => {
+  try {
+    const raw = psql(`SELECT COALESCE(json_agg(t), '[]'::json) FROM (
+      SELECT id, source, source_id, title, creator, date_created, license,
+             source_url, image_url, local_path, dominant_hex, palette,
+             category, tags, motifs
+      FROM pd_source_designs ORDER BY ingested_at DESC LIMIT 2000
+    ) t;`);
+    res.type('application/json').send(raw || '[]');
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+app.get('/api/designs', (_req, res) => {
+  try {
+    const raw = psql(`SELECT COALESCE(json_agg(t), '[]'::json) FROM (
+      SELECT id, kind, width_in, height_in, panels, generator, prompt,
+             seed, local_path, dominant_hex, palette, category, tags,
+             motifs, is_published, created_at
+      FROM spoon_all_designs ORDER BY created_at DESC LIMIT 2000
+    ) t;`);
+    res.type('application/json').send(raw || '[]');
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+app.get('/api/counts', (_req, res) => {
+  try {
+    const pd = psql(`SELECT json_agg(t) FROM (SELECT source, COUNT(*) FROM pd_source_designs GROUP BY source ORDER BY 1) t;`);
+    const designs = psql(`SELECT COUNT(*) FROM spoon_all_designs;`);
+    res.json({ pd: JSON.parse(pd || '[]'), designs: parseInt(designs || '0', 10) });
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+// Helper: convert local_path absolute → /img relative URL
+function imgUrl(localPath) {
+  if (!localPath) return null;
+  if (localPath.startsWith(DATA_ROOT)) return '/img/' + localPath.slice(DATA_ROOT.length + 1);
+  return null;
+}
+
+app.get('/', (_req, res) => {
+  res.type('html').send(`<!doctype html>
+<meta charset="utf-8">
+<title>wallco.ai — corpus + designs</title>
+<style>
+  :root { --cols: 5; }
+  *,*::before,*::after { box-sizing: border-box; }
+  body { margin:0; font:13px/1.4 -apple-system, system-ui, sans-serif; color:#1a1a1a; background:#0f0e0c; }
+  header { padding: 24px 32px; border-bottom: 1px solid #2a2825; background:#1a1816; color:#e8e2d6; display:flex; gap:16px; align-items:baseline; flex-wrap:wrap; }
+  header h1 { margin:0; font-weight:300; font-size:22px; letter-spacing:.5px; }
+  header .tag { font-size:12px; color:#aaa; }
+  nav a { color:#bba; text-decoration:none; margin-right:14px; font-size:12px; }
+  nav a.on { color:#fff; border-bottom:1px solid #d2b15c; }
+  .controls { margin-left:auto; display:flex; gap:16px; align-items:center; flex-wrap:wrap; }
+  .controls label { font-size:11px; color:#bba; text-transform:uppercase; letter-spacing:.08em; display:flex; gap:6px; align-items:center; }
+  .controls select, .controls input[type=range] { font:inherit; padding:4px 8px; border:1px solid #3a3631; background:#16140f; color:#fff; border-radius:2px; }
+  .controls input[type=range] { width:140px; }
+  main { padding: 24px 32px; }
+  .grid { display:grid; gap:18px; grid-template-columns: repeat(var(--cols), 1fr); }
+  .card { background:#1a1816; border:1px solid #2a2825; border-radius:2px; overflow:hidden; color:#ddd; }
+  .card .img { aspect-ratio: 1; background:#0d0c0a no-repeat center/cover; }
+  .card .meta { padding:10px 12px 14px; }
+  .card .title { font-size:12px; margin:0 0 4px; }
+  .card .sub { font-size:10px; color:#888; }
+  .card .dot { display:inline-block; width:10px; height:10px; border-radius:50%; border:1px solid #00000060; vertical-align:middle; margin-right:4px; }
+  .card .badge { display:inline-block; padding:1px 5px; font-size:9px; background:#2a2825; color:#d2b15c; border-radius:2px; margin-left:5px; text-transform:uppercase; letter-spacing:.05em; }
+  .empty { padding:48px; text-align:center; color:#666; }
+  a.card-link { color:inherit; text-decoration:none; display:block; }
+</style>
+<header>
+  <h1>wallco.ai</h1>
+  <span class="tag" id="count">…</span>
+  <nav>
+    <a href="#" id="tab-pd" class="on">Source corpus</a>
+    <a href="#" id="tab-designs">Generated designs</a>
+  </nav>
+  <div class="controls">
+    <label>Sort
+      <select id="sort">
+        <option value="recent">Recent</option>
+        <option value="color-wheel">Color Wheel</option>
+        <option value="light-dark">Light → Dark</option>
+        <option value="dark-light">Dark → Light</option>
+        <option value="source">Source</option>
+        <option value="title">Title A→Z</option>
+      </select>
+    </label>
+    <label>Density
+      <input type="range" id="density" min="3" max="9" step="1" value="5">
+    </label>
+  </div>
+</header>
+<main><div class="grid" id="grid"></div></main>
+<script>
+const DATA_ROOT = ${JSON.stringify(DATA_ROOT)};
+const sortSel = document.getElementById('sort');
+const dens = document.getElementById('density');
+const grid = document.getElementById('grid');
+const cnt = document.getElementById('count');
+sortSel.value = localStorage.getItem('wc.sort') || 'recent';
+dens.value = localStorage.getItem('wc.density') || 5;
+document.documentElement.style.setProperty('--cols', dens.value);
+
+let tab = localStorage.getItem('wc.tab') || 'pd';
+function setTab(t) {
+  tab = t;
+  document.getElementById('tab-pd').classList.toggle('on', t === 'pd');
+  document.getElementById('tab-designs').classList.toggle('on', t === 'designs');
+  localStorage.setItem('wc.tab', t);
+  load();
+}
+document.getElementById('tab-pd').addEventListener('click', e => { e.preventDefault(); setTab('pd'); });
+document.getElementById('tab-designs').addEventListener('click', e => { e.preventDefault(); setTab('designs'); });
+
+function hexToHsl(hex) {
+  if (!hex || hex.length < 4) return [0,0,0];
+  const m = hex.match(/^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
+  if (!m) return [0,0,0];
+  const r = parseInt(m[1],16)/255, g = parseInt(m[2],16)/255, b = parseInt(m[3],16)/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 imgPath(p) {
+  if (!p) return '';
+  if (p.startsWith(DATA_ROOT)) return '/img/' + p.slice(DATA_ROOT.length + 1);
+  return p;
+}
+
+function render(data) {
+  const sort = sortSel.value;
+  const sorted = [...data].sort((a,b) => {
+    if (sort === 'recent') return 0;
+    if (sort === 'source') return (a.source||a.generator||'').localeCompare(b.source||b.generator||'');
+    if (sort === 'title')  return (a.title||a.prompt||'').localeCompare(b.title||b.prompt||'');
+    const ha = hexToHsl(a.dominant_hex), hb = hexToHsl(b.dominant_hex);
+    if (sort === 'light-dark') return hb[2]-ha[2];
+    if (sort === 'dark-light') return ha[2]-hb[2];
+    if (sort === 'color-wheel') {
+      const isaA = ha[1] < 0.08, isaB = hb[1] < 0.08;
+      if (isaA !== isaB) return isaA ? -1 : 1;
+      if (isaA) return ha[2]-hb[2];
+      const bA = Math.floor(ha[0]/30), bB = Math.floor(hb[0]/30);
+      if (bA !== bB) return bA - bB;
+      return ha[2]-hb[2];
+    }
+    return 0;
+  });
+  cnt.textContent = sorted.length + (tab === 'pd' ? ' PD source images' : ' generated designs');
+  grid.innerHTML = sorted.map(p => {
+    const img = imgPath(p.local_path) || p.image_url || '';
+    const dot = p.dominant_hex ? \`<span class="dot" style="background:\${p.dominant_hex}" title="\${p.dominant_hex}"></span>\` : '';
+    const badge = (p.source || p.generator || '');
+    const title = p.title || p.prompt || ('#'+p.id);
+    const sub = p.creator || p.date_created || p.kind || '';
+    const link = p.source_url || '#';
+    return \`<a class="card-link" href="\${link}" target="_blank"><div class="card">
+      <div class="img" style="background-image:url('\${img}')"></div>
+      <div class="meta">
+        <div class="title">\${dot}\${title.slice(0,60)}<span class="badge">\${badge}</span></div>
+        <div class="sub">\${sub.slice(0,80)}</div>
+      </div>
+    </div></a>\`;
+  }).join('') || '<div class="empty">Empty — run scripts/crawl_pd.py or scripts/generate_designs.js</div>';
+}
+
+async function load() {
+  const r = await fetch(tab === 'pd' ? '/api/pd' : '/api/designs');
+  const data = await r.json();
+  render(data);
+}
+
+sortSel.addEventListener('change', () => { localStorage.setItem('wc.sort', sortSel.value); load(); });
+dens.addEventListener('input', () => {
+  document.documentElement.style.setProperty('--cols', dens.value);
+  localStorage.setItem('wc.density', dens.value);
+});
+
+setTab(tab);
+</script>`);
+});
+
+app.listen(PORT, '127.0.0.1', () => console.log(`wallco.ai viewer → http://127.0.0.1:${PORT}`));

(oldest)  ·  back to Wallco Ai  ·  wallco.ai: wire Replicate SDXL backend · inline-editor mount f5bdf9b →