[object Object]

← back to Dw Universe

initial scaffold (gitify-all 2026-05-06)

d109d7589cb07b0ff35786252389d0895aaa6677 · 2026-05-06 10:25:17 -0700 · Steve Abrams

Files touched

Diff

commit d109d7589cb07b0ff35786252389d0895aaa6677
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed May 6 10:25:17 2026 -0700

    initial scaffold (gitify-all 2026-05-06)
---
 .gitignore                    |   12 +
 CHANGELOG.md                  |   65 +++
 data/niche-neighbors.json     |   45 ++
 data/niche-poems.json         |   45 ++
 data/pair-rules.json          |   69 +++
 package-lock.json             |  842 ++++++++++++++++++++++++++++++++
 package.json                  |   10 +
 public/favicon.svg            |    4 +
 public/index.html             | 1065 +++++++++++++++++++++++++++++++++++++++++
 scripts/apply-design-fixes.js |  172 +++++++
 scripts/gen-niche-poems.js    |  120 +++++
 server.js                     |  182 +++++++
 12 files changed, 2631 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..7e6a9c3
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,12 @@
+node_modules/
+.env
+.env.local
+.env.*.local
+.env.*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
+*.bak
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..ff95511
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,65 @@
+# DW Universe — Changelog
+
+Front door of the Designer Wallcoverings family of sister sites.
+Live at https://retrowalls.com/universe/ (path-mounted) and locally at http://127.0.0.1:9466/.
+
+---
+
+## 0.1.0 — 2026-05-06 (autonomous overnight build)
+
+Eleven creative features built in one session via a 4-agent dream-team brainstorm
+(ui-ux-designer + frontend-developer + product-strategist + contrarian) → backlog →
+ship-one-feature-per-iteration.
+
+### Search & Discovery
+
+- **Cross-niche search** — single endpoint `/api/search` queries all 10,400+ patterns across 43 niches with score-weighted matching (title 3× / tag 2× / niche-name 1×). Median 12-23 ms.
+- **Constellation map** — 43 SVG nodes in 4 vertical category bands (material / decade / craft / use), sized by inventory count, hand-curated category tints.
+- **Niche poems** — every niche has a one-line literary editorial blurb shown on hover (`data/niche-poems.json`).
+
+### Brand-voice features
+
+- **One-Line Oracle** — when the search box is empty + unfocused, the placeholder cycles through the 43 editorial blurbs in italic Playfair, 4.2s per line, 600ms cross-fade.
+- **Velvet Rope** — first-visit ritual: a single-line riddle (*"Name a room you've decorated in a dream"*) gates the catalog. Type 3+ chars + Enter, or 12+ chars auto-unlocks. Skippable. Auto-skips deeplinks (`?q=`, `?passport=`).
+- **Slow-Reveal Bookmark** — visit counter unlocks an italic editorial line on visits 3 / 7 / 14 / 30 / 60 / 100, fades in for 12 seconds.
+
+### Visual / interaction
+
+- **Pattern Density Scroll** — as the top search result changes, the body background tiles that pattern at 6% opacity. The page itself becomes wallpaper.
+- **Niche Drift** — hover any constellation node and the 3 hand-curated nearest kin illuminate while non-kin dim to 18%; gold dashed arcs draw between center and kin.
+- **Niche DNA** — each constellation node fills with a per-category procedural SVG pattern: material = woven hairlines, decade = chevrons, craft = dashed stitch hatch, use = tight architectural grid.
+- **Star Float + Scroll Parallax** — every node breathes in place (5-10 s loop, 2-5 px amplitude, deterministic per slug). The whole map drifts ±28 px on scroll where supported. Honors `prefers-reduced-motion: reduce`.
+- **View Transitions** — clicking a node morphs its SVG label into the search input via `document.startViewTransition()`, then filters the grid. ⌘ / Ctrl / Shift-click preserves "open the niche site". Falls back to instant on Safari/FF.
+
+### Compounding / sticky
+
+- **Pairing Oracle** — every product card has a `+` button → opens a modal showing one cross-niche "soulmate" + a hand-written editorial blurb ("the room needs a whisper after this shout"). 43 source-niche → complementary-niche rules in `data/pair-rules.json`. 19 blurbs. Reroll button.
+- **Sample Passport** — header pill "Passport · N" shows niches the user has clicked into. Modal renders all 43 as gold-checkmark stamps with timestamps. **Copy share link** drops a `?passport=slug1,slug2` URL someone else can open in read-only mode.
+
+### Schedule-aware
+
+- **Tuesday hero** — every Tuesday, the empty-state grid renders ONE giant pattern instead of 24, with caption *"the only pattern we want you to see today. show me the other 23 →"*. Search overrides immediately.
+- **Constellation Sleeps** — between 2 and 4 a.m. local time, the constellation dims to 15 % and a single random pattern thumbnail drifts diagonally across the viewport with caption *"the catalog is dreaming."* Click anywhere to wake.
+
+### Dark / light theme
+
+- DW-standard sun/moon toggle in the header, persisted in localStorage. Pattern Density uses `mix-blend-mode: overlay` in dark + `multiply` in light.
+
+---
+
+## Fleet-wide work (parallel, same session)
+
+- **Hero uniqueness** — every one of the 49 active DW family sites now has a UNIQUE hero image URL across the fleet (was 22 collisions before). Sort key: textures-first → patterns second within each site.
+- **5 P0 security patches** shipped to all 46 sister sites (`clean()` regex bug, basic-auth timing-safe equality, login timing oracle, curation-editor shell injection, ReDoS guard).
+- **ADMIN_PASS env-only enforcement** — hardcoded `DWSecure2024!` removed from every site's `_universal-auth.js` on disk. Strong random pass provisioned to `/root/.dw-fleet.env` on Kamatera, fanned via `pm2 restart all --update-env`.
+- **D2 redirects** — `recycledwallpaper.com / stringwallpaper.com / embroideredwallpaper.com` now 301-redirect to their parent niches (catalog stranding fix).
+- **a11y** — `:focus-visible` rule fleet-wide, museum's `pointer-events:none` removed.
+
+---
+
+## Next ideas (not yet shipped)
+
+- **Color Search** — drop an image, find closest matches across all 43 niches by dominant palette
+- **Mini-constellation widget** on each niche site → cross-promotes the network
+- **Hand-built per-niche SVG seal** → upgrade Sample Passport stamps from generic checkmarks
+- **Niche-poetry on each site's home page** → reuse `niche-poems.json` as each site's own opening line
diff --git a/data/niche-neighbors.json b/data/niche-neighbors.json
new file mode 100644
index 0000000..3e3873f
--- /dev/null
+++ b/data/niche-neighbors.json
@@ -0,0 +1,45 @@
+{
+  "silkwallpaper":          ["silkwallcoverings", "suedewallpaper", "mylarwallpaper"],
+  "silkwallcoverings":      ["silkwallpaper", "contractwallpaper", "hospitalitywallpaper"],
+  "linenwallpaper":         ["jutewallpaper", "naturalwallcoverings", "raffiawalls"],
+  "jutewallpaper":          ["linenwallpaper", "raffiawallcoverings", "naturalwallcoverings"],
+  "raffiawallcoverings":    ["raffiawalls", "jutewallpaper", "madagascarwallpaper"],
+  "raffiawalls":            ["raffiawallcoverings", "naturalwallcoverings", "linenwallpaper"],
+  "corkwallcovering":       ["naturalwallcoverings", "jutewallpaper", "greenwallcoverings"],
+  "fabricwallpaper":        ["textilewallpaper", "silkwallpaper", "suedewallpaper"],
+  "textilewallpaper":       ["fabricwallpaper", "linenwallpaper", "silkwallpaper"],
+  "metallicwallpaper":      ["silverleafwallpaper", "mylarwallpaper", "glitterwalls"],
+  "silverleafwallpaper":    ["metallicwallpaper", "mylarwallpaper", "micawallpaper"],
+  "vinylwallpaper":         ["contractwallpaper", "hotelwallcoverings", "hospitalitywallpaper"],
+  "micawallpaper":          ["silverleafwallpaper", "metallicwallpaper", "glitterwalls"],
+  "madagascarwallpaper":    ["raffiawallcoverings", "jutewallpaper", "naturalwallcoverings"],
+  "mylarwallpaper":         ["metallicwallpaper", "silverleafwallpaper", "glitterwalls"],
+  "suedewallpaper":         ["silkwallpaper", "fabricwallpaper", "textilewallpaper"],
+  "1800swallpaper":         ["1890swallpaper", "restorationwallpaper", "museumwallpaper"],
+  "1890swallpaper":         ["1800swallpaper", "1900swallpaper", "restorationwallpaper"],
+  "1900swallpaper":         ["1890swallpaper", "1920swallpaper", "restorationwallpaper"],
+  "1920swallpaper":         ["1930swallpaper", "1900swallpaper", "museumwallpaper"],
+  "1930swallpaper":         ["1920swallpaper", "1940swallpaper", "hotelwallcoverings"],
+  "1940swallpaper":         ["1930swallpaper", "1950swallpaper", "restorationwallpaper"],
+  "1950swallpaper":         ["1940swallpaper", "1960swallpaper", "retrowalls"],
+  "1960swallpaper":         ["1950swallpaper", "1970swallpaper", "retrowalls"],
+  "1970swallpaper":         ["1960swallpaper", "1980swallpaper", "retrowalls"],
+  "1980swallpaper":         ["1970swallpaper", "retrowalls", "glitterwalls"],
+  "retrowalls":             ["1970swallpaper", "1960swallpaper", "wallpapersback"],
+  "wallpapersback":         ["retrowalls", "restorationwallpaper", "museumwallpaper"],
+  "agedwallpaper":          ["restorationwallpaper", "museumwallpaper", "handcraftedwallpaper"],
+  "handcraftedwallpaper":   ["museumwallpaper", "agedwallpaper", "restorationwallpaper"],
+  "museumwallpaper":        ["restorationwallpaper", "agedwallpaper", "handcraftedwallpaper"],
+  "restorationwallpaper":   ["1890swallpaper", "1900swallpaper", "museumwallpaper"],
+  "pastelwallpaper":        ["silkwallpaper", "suedewallpaper", "museumwallpaper"],
+  "glitterwalls":           ["metallicwallpaper", "mylarwallpaper", "silverleafwallpaper"],
+  "greenwallcoverings":     ["corkwallcovering", "naturalwallcoverings", "raffiawalls"],
+  "naturalwallcoverings":   ["linenwallpaper", "jutewallpaper", "raffiawalls"],
+  "saloonwallpaper":        ["1890swallpaper", "restorationwallpaper", "agedwallpaper"],
+  "contractwallpaper":      ["vinylwallpaper", "hospitalitywallpaper", "hotelwallcoverings"],
+  "hotelwallcoverings":     ["hospitalitywallpaper", "restaurantwallpaper", "contractwallpaper"],
+  "hospitalitywallpaper":   ["hotelwallcoverings", "restaurantwallpaper", "contractwallpaper"],
+  "healthcarewallpaper":    ["contractwallpaper", "hospitalitywallpaper", "vinylwallpaper"],
+  "restaurantwallpaper":    ["hospitalitywallpaper", "hotelwallcoverings", "saloonwallpaper"],
+  "architecturalwallcoverings": ["contractwallpaper", "museumwallpaper", "restorationwallpaper"]
+}
diff --git a/data/niche-poems.json b/data/niche-poems.json
new file mode 100644
index 0000000..4c7d275
--- /dev/null
+++ b/data/niche-poems.json
@@ -0,0 +1,45 @@
+{
+  "silkwallpaper":          "True silk on the wall — woven thread by thread, the quietest luxury in the room.",
+  "silkwallcoverings":      "Silk for contract spec — Type II durability, hospitality-grade, no concession to gloss.",
+  "linenwallpaper":         "Flax woven into walls — the texture of an unmade bed, in linen.",
+  "jutewallpaper":          "Coarse, honest jute — the smell of a working farm, hung at eye level.",
+  "raffiawallcoverings":    "Hand-woven raffia palm — a verandah in Madagascar, brought indoors.",
+  "raffiawalls":            "Raffia, residential edition — same fiber, tighter weave, smaller commitment.",
+  "corkwallcovering":       "Bark of the cork oak — sustainably stripped, sound-absorbing, visibly alive.",
+  "fabricwallpaper":        "Textile pasted to walls — drapery's older, calmer cousin.",
+  "textilewallpaper":       "Woven patterns the eye reads as fabric, even before the hand confirms it.",
+  "metallicwallpaper":      "Foil grounds in silver, gold, gun-metal — light made into a wall.",
+  "silverleafwallpaper":    "Genuine silver leaf, hand-applied square by square. The room becomes a reflection.",
+  "vinylwallpaper":         "Type II commercial vinyl — meant for hotel hallways, beautiful in ours.",
+  "micawallpaper":          "Mica flakes hand-applied — fired mineral, cathedral-window light.",
+  "madagascarwallpaper":    "Grasscloth from Madagascar — sun-bleached, irregular, alive in side light.",
+  "mylarwallpaper":         "Reflective mylar — a 1972 conversation pit, contemporary again.",
+  "suedewallpaper":         "Suede on the wall — the room absorbs sound and, briefly, its own daylight.",
+  "1800swallpaper":         "Documentary repeats from the 19th century — block-printed history, not nostalgia.",
+  "1890swallpaper":         "Late Victorian — Aesthetic movement and Arts-and-Crafts at full conviction.",
+  "1900swallpaper":         "Edwardian repeats — turn-of-century optimism, before the wars.",
+  "1920swallpaper":         "Art Deco — geometry, gilt, the loud confidence of the Jazz Age.",
+  "1930swallpaper":         "Streamlined Moderne — Deco quieting into Hollywood Regency, smoke and cocktails.",
+  "1940swallpaper":         "Post-war restraint — the kitchen as comfort, the bedroom as relief.",
+  "1950swallpaper":         "Atomic-age optimism — boomerangs, starbursts, suburban faith in the future.",
+  "1960swallpaper":         "Op-art and psychedelia — the wall as event, the eye as participant.",
+  "1970swallpaper":         "Earth-tone florals, harvest gold, paneling — rooms that smelled like patchouli.",
+  "1980swallpaper":         "Memphis, neon, brass-and-glass — the decade that refused to be tasteful.",
+  "retrowalls":             "Every decade we've ever loved, available again — chosen, not stumbled into.",
+  "wallpapersback":         "Statement of fact: wallpaper is back. The plain wall has had its decade.",
+  "agedwallpaper":          "Deliberately aged finishes — the patina of a hundred years, ordered last week.",
+  "handcraftedwallpaper":   "Hand-blocked, hand-screened — the artisan's hand visible in every repeat.",
+  "museumwallpaper":        "Wallcoverings for institutional walls — gallery-grade, conservation-aware.",
+  "restorationwallpaper":   "Period-accurate reproductions — for the house old enough to deserve them.",
+  "pastelwallpaper":        "A whole catalog in soft tones — milk, blush, butter, sky.",
+  "glitterwalls":           "Real glitter, real glass beads — the wall throws light back at you.",
+  "greenwallcoverings":     "FSC-certified, low-VOC, recycled — eco-conscious without earnestness.",
+  "naturalwallcoverings":   "Plant-fiber walls — jute, sisal, abaca, raffia. The forest, abstracted.",
+  "saloonwallpaper":        "Old-West woodcut — oxblood, saddle, cream. A drink before sundown.",
+  "contractwallpaper":      "Type II commercial — built for offices, schools, stadiums. And yours.",
+  "hotelwallcoverings":     "Hospitality-spec — designed for the hallway you walk at 3 AM and remember.",
+  "hospitalitywallpaper":   "Hotels, restaurants, cruise ships — wallcovering with stage presence.",
+  "healthcarewallpaper":    "Anti-microbial, fully cleanable — calm enough for the waiting room.",
+  "restaurantwallpaper":    "Front-of-house wallcovering — moody, photogenic, washable.",
+  "architecturalwallcoverings": "A&D-channel wallcoverings — specified by architects, signed off by the firm."
+}
diff --git a/data/pair-rules.json b/data/pair-rules.json
new file mode 100644
index 0000000..2e04566
--- /dev/null
+++ b/data/pair-rules.json
@@ -0,0 +1,69 @@
+{
+  "_comment": "complementary-niche map for the Pairing Oracle. Source niche → array of complementary niches. Picks one at random.",
+  "rules": {
+    "silkwallpaper":          ["raffiawallcoverings", "jutewallpaper", "agedwallpaper"],
+    "silkwallcoverings":      ["contractwallpaper", "naturalwallcoverings"],
+    "linenwallpaper":         ["pastelwallpaper", "museumwallpaper", "metallicwallpaper"],
+    "jutewallpaper":          ["metallicwallpaper", "silverleafwallpaper", "silkwallpaper"],
+    "raffiawallcoverings":    ["1920swallpaper", "glitterwalls", "silkwallpaper"],
+    "raffiawalls":            ["1920swallpaper", "glitterwalls"],
+    "corkwallcovering":       ["handcraftedwallpaper", "metallicwallpaper"],
+    "fabricwallpaper":        ["1900swallpaper", "restorationwallpaper", "metallicwallpaper"],
+    "textilewallpaper":       ["mylarwallpaper", "suedewallpaper", "1920swallpaper"],
+    "metallicwallpaper":      ["linenwallpaper", "jutewallpaper", "naturalwallcoverings"],
+    "silverleafwallpaper":    ["suedewallpaper", "fabricwallpaper", "agedwallpaper"],
+    "vinylwallpaper":         ["silkwallpaper", "suedewallpaper"],
+    "micawallpaper":          ["textilewallpaper", "fabricwallpaper"],
+    "madagascarwallpaper":    ["1920swallpaper", "museumwallpaper"],
+    "mylarwallpaper":         ["1970swallpaper", "glitterwalls"],
+    "suedewallpaper":         ["metallicwallpaper", "silverleafwallpaper"],
+    "1800swallpaper":         ["glitterwalls", "mylarwallpaper"],
+    "1890swallpaper":         ["metallicwallpaper", "glitterwalls"],
+    "1900swallpaper":         ["glitterwalls", "fabricwallpaper"],
+    "1920swallpaper":         ["naturalwallcoverings", "jutewallpaper", "raffiawalls"],
+    "1930swallpaper":         ["metallicwallpaper", "mylarwallpaper"],
+    "1940swallpaper":         ["silverleafwallpaper", "glitterwalls"],
+    "1950swallpaper":         ["museumwallpaper", "restorationwallpaper"],
+    "1960swallpaper":         ["silkwallpaper", "museumwallpaper"],
+    "1970swallpaper":         ["silkwallpaper", "micawallpaper"],
+    "1980swallpaper":         ["silkwallpaper", "restorationwallpaper"],
+    "retrowalls":             ["silkwallpaper", "museumwallpaper"],
+    "wallpapersback":         ["silkwallpaper", "metallicwallpaper"],
+    "agedwallpaper":          ["glitterwalls", "metallicwallpaper"],
+    "handcraftedwallpaper":   ["metallicwallpaper", "glitterwalls"],
+    "museumwallpaper":        ["1980swallpaper", "glitterwalls"],
+    "restorationwallpaper":   ["metallicwallpaper", "glitterwalls"],
+    "pastelwallpaper":        ["1920swallpaper", "metallicwallpaper"],
+    "glitterwalls":           ["corkwallcovering", "jutewallpaper", "agedwallpaper"],
+    "greenwallcoverings":     ["metallicwallpaper", "glitterwalls"],
+    "naturalwallcoverings":   ["1920swallpaper", "glitterwalls"],
+    "saloonwallpaper":        ["silkwallpaper", "museumwallpaper"],
+    "contractwallpaper":      ["silkwallpaper", "suedewallpaper"],
+    "hotelwallcoverings":     ["silkwallpaper", "suedewallpaper"],
+    "hospitalitywallpaper":   ["silkwallpaper", "suedewallpaper"],
+    "healthcarewallpaper":    ["silkwallpaper", "metallicwallpaper"],
+    "restaurantwallpaper":    ["silkwallpaper", "museumwallpaper"],
+    "architecturalwallcoverings": ["silkwallpaper", "agedwallpaper"]
+  },
+  "blurbs": [
+    "The room needs a whisper after this shout.",
+    "Warmth, then weight.",
+    "Let it cool down here.",
+    "Every loud color needs a hush.",
+    "Two surfaces. One room. They finish each other.",
+    "Afternoon light on one. Night on the other.",
+    "This one for the eye. That one for the hand.",
+    "What arrives. What stays.",
+    "Ceremony, and rest.",
+    "Soft after sharp.",
+    "Matte yields to gloss.",
+    "The pause between two ideas.",
+    "The entrance, then the parlor.",
+    "Speak loud here. Listen there.",
+    "The headboard. The chair across from it.",
+    "A texture you press into. A pattern you stand back from.",
+    "Foreground, then background. Or the other way around.",
+    "First the doorway, then the room beyond it.",
+    "One holds the room. The other releases it."
+  ]
+}
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..8776a9c
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,842 @@
+{
+  "name": "dw-universe",
+  "version": "0.1.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "dw-universe",
+      "version": "0.1.0",
+      "dependencies": {
+        "express": "^4.19.2"
+      }
+    },
+    "node_modules/accepts": {
+      "version": "1.3.8",
+      "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+      "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-types": "~2.1.34",
+        "negotiator": "0.6.3"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/array-flatten": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+      "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+      "license": "MIT"
+    },
+    "node_modules/body-parser": {
+      "version": "1.20.5",
+      "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
+      "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "~3.1.2",
+        "content-type": "~1.0.5",
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "destroy": "~1.2.0",
+        "http-errors": "~2.0.1",
+        "iconv-lite": "~0.4.24",
+        "on-finished": "~2.4.1",
+        "qs": "~6.15.1",
+        "raw-body": "~2.5.3",
+        "type-is": "~1.6.18",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8",
+        "npm": "1.2.8000 || >= 1.4.16"
+      }
+    },
+    "node_modules/body-parser/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/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": "0.5.4",
+      "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+      "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+      "license": "MIT",
+      "dependencies": {
+        "safe-buffer": "5.2.1"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "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.0.7",
+      "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
+      "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
+      "license": "MIT"
+    },
+    "node_modules/debug": {
+      "version": "2.6.9",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+      "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "2.0.0"
+      }
+    },
+    "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/destroy": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+      "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8",
+        "npm": "1.2.8000 || >= 1.4.16"
+      }
+    },
+    "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": "4.22.1",
+      "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
+      "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
+      "license": "MIT",
+      "dependencies": {
+        "accepts": "~1.3.8",
+        "array-flatten": "1.1.1",
+        "body-parser": "~1.20.3",
+        "content-disposition": "~0.5.4",
+        "content-type": "~1.0.4",
+        "cookie": "~0.7.1",
+        "cookie-signature": "~1.0.6",
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "etag": "~1.8.1",
+        "finalhandler": "~1.3.1",
+        "fresh": "~0.5.2",
+        "http-errors": "~2.0.0",
+        "merge-descriptors": "1.0.3",
+        "methods": "~1.1.2",
+        "on-finished": "~2.4.1",
+        "parseurl": "~1.3.3",
+        "path-to-regexp": "~0.1.12",
+        "proxy-addr": "~2.0.7",
+        "qs": "~6.14.0",
+        "range-parser": "~1.2.1",
+        "safe-buffer": "5.2.1",
+        "send": "~0.19.0",
+        "serve-static": "~1.16.2",
+        "setprototypeof": "1.2.0",
+        "statuses": "~2.0.1",
+        "type-is": "~1.6.18",
+        "utils-merge": "1.0.1",
+        "vary": "~1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.10.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/finalhandler": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
+      "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "2.6.9",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "on-finished": "~2.4.1",
+        "parseurl": "~1.3.3",
+        "statuses": "~2.0.2",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "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": "0.5.2",
+      "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+      "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "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.4.24",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+      "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+      "license": "MIT",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "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/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": "0.3.0",
+      "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+      "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/merge-descriptors": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+      "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/methods": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+      "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+      "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+      "license": "MIT",
+      "bin": {
+        "mime": "cli.js"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/mime-db": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "2.1.35",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "1.52.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+      "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+      "license": "MIT"
+    },
+    "node_modules/negotiator": {
+      "version": "0.6.3",
+      "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+      "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+      "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/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": "0.1.13",
+      "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
+      "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
+      "license": "MIT"
+    },
+    "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.14.2",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
+      "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
+      "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": "2.5.3",
+      "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+      "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "~3.1.2",
+        "http-errors": "~2.0.1",
+        "iconv-lite": "~0.4.24",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/safe-buffer": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+      "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT"
+    },
+    "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": "0.19.2",
+      "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+      "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "destroy": "1.2.0",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "etag": "~1.8.1",
+        "fresh": "~0.5.2",
+        "http-errors": "~2.0.1",
+        "mime": "1.6.0",
+        "ms": "2.1.3",
+        "on-finished": "~2.4.1",
+        "range-parser": "~1.2.1",
+        "statuses": "~2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/send/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/serve-static": {
+      "version": "1.16.3",
+      "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+      "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+      "license": "MIT",
+      "dependencies": {
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "parseurl": "~1.3.3",
+        "send": "~0.19.1"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "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": "1.6.18",
+      "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+      "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+      "license": "MIT",
+      "dependencies": {
+        "media-typer": "0.3.0",
+        "mime-types": "~2.1.24"
+      },
+      "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/utils-merge": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+      "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4.0"
+      }
+    },
+    "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"
+      }
+    }
+  }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..0be0f68
--- /dev/null
+++ b/package.json
@@ -0,0 +1,10 @@
+{
+  "name": "dw-universe",
+  "version": "0.1.0",
+  "private": true,
+  "main": "server.js",
+  "scripts": { "start": "node server.js" },
+  "dependencies": {
+    "express": "^4.19.2"
+  }
+}
diff --git a/public/favicon.svg b/public/favicon.svg
new file mode 100644
index 0000000..df58742
--- /dev/null
+++ b/public/favicon.svg
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
+<rect width="32" height="32" rx="6" fill="#10b981"/>
+<text x="50%" y="55%" text-anchor="middle" dominant-baseline="middle" font-size="20" font-family="Apple Color Emoji, Segoe UI Emoji, sans-serif" fill="white">D</text>
+</svg>
\ No newline at end of file
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..fb30ddb
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,1065 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>DW Universe — 41 Niches, One Search</title>
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<meta name="description" content="The Designer Wallcoverings family — 41 niche wallpaper sites, ten thousand patterns, one search.">
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=Playfair+Display:ital,wght@0,400;0,500;1,400&display=swap" rel="stylesheet">
+<style>
+:root {
+  --bg: #0c0a08;
+  --bg-2: #15110d;
+  --ink: #f4ecd8;
+  --ink-mute: #a39888;
+  --ink-faint: #5a5246;
+  --rule: #2a241c;
+  --gold: #c9b687;
+  --gold-warm: #e6cc88;
+  --signal: #d36e4d;
+  --c-material: #c9b687;  /* gold */
+  --c-decade: #b86a4a;    /* terracotta */
+  --c-craft: #6b8e6f;     /* sage */
+  --c-use: #4a6b8e;       /* slate blue */
+}
+[data-theme="light"] {
+  --bg: #fafaf6; --bg-2: #f0ece1; --ink: #1b1814; --ink-mute: #6b6357;
+  --ink-faint: #b0a890; --rule: #e5dfd0; --gold: #8a7440; --gold-warm: #a6884d;
+}
+* { box-sizing: border-box }
+html, body { margin: 0; padding: 0; background: var(--bg); color: var(--ink);
+  font-family: 'Inter', -apple-system, sans-serif; font-weight: 300;
+  transition: background .3s, color .3s; position: relative }
+body::before {
+  content: ''; position: fixed; inset: 0; pointer-events: none; z-index: 0;
+  background-image: var(--bg-pattern, none); background-repeat: repeat;
+  background-size: 220px;
+  opacity: var(--bg-pattern-opacity, 0);
+  transition: opacity 1.2s ease, background-image 0s;
+  mix-blend-mode: overlay;
+}
+header, .search, .results, section.constellation, footer { position: relative; z-index: 1 }
+[data-theme="light"] body::before { mix-blend-mode: multiply }
+a { color: inherit; text-decoration: none }
+a:focus-visible, button:focus-visible, input:focus-visible {
+  outline: 2px solid var(--gold); outline-offset: 2px; border-radius: 0;
+}
+
+/* hero */
+header { padding: 64px 32px 24px; max-width: 1280px; margin: 0 auto; position: relative }
+.brand { font-size: 11px; letter-spacing: 0.36em; text-transform: uppercase;
+  color: var(--ink-mute); font-weight: 600; margin-bottom: 32px }
+.brand .dot { display: inline-block; width: 6px; height: 6px; background: var(--gold);
+  border-radius: 50%; margin: 0 12px 2px; vertical-align: middle }
+h1 { font-family: 'Playfair Display', serif; font-weight: 400; font-style: italic;
+  font-size: clamp(40px, 7vw, 88px); line-height: 1.05; letter-spacing: -0.02em; margin: 0 0 16px;
+  max-width: 16ch }
+h1 em { color: var(--gold); font-style: italic }
+.lede { font-size: 18px; line-height: 1.6; color: var(--ink-mute); max-width: 56ch;
+  margin: 0 0 32px }
+.theme-toggle, .passport-pill { position: absolute; top: 32px; background: transparent;
+  border: 1px solid var(--rule); color: var(--ink-mute); padding: 9px 16px;
+  font-size: 11px; letter-spacing: 0.32em; text-transform: uppercase; cursor: pointer;
+  font-family: inherit; font-weight: 600 }
+.theme-toggle { right: 32px }
+.passport-pill { right: 110px; display: flex; align-items: center; gap: 8px }
+.passport-pill .seal { width: 8px; height: 8px; border-radius: 50%; background: var(--gold);
+  box-shadow: 0 0 0 2px var(--bg), 0 0 0 3px var(--gold); display: inline-block }
+.theme-toggle:hover, .passport-pill:hover { color: var(--ink); border-color: var(--gold) }
+
+/* Passport modal */
+.passport-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.78); display: none;
+  align-items: center; justify-content: center; padding: 32px; z-index: 200;
+  backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px) }
+.passport-overlay.show { display: flex }
+.passport-modal { max-width: 980px; width: 100%; background: var(--bg); border: 1px solid var(--rule);
+  position: relative; padding: 48px 48px 48px; max-height: 90vh; overflow-y: auto }
+.passport-modal h2 { font-family: 'Playfair Display', serif; font-style: italic; font-weight: 400;
+  font-size: 32px; margin: 0 0 8px; color: var(--ink); letter-spacing: -0.01em }
+.passport-modal .sub { font-size: 13px; letter-spacing: 0.18em; text-transform: uppercase;
+  color: var(--ink-mute); margin-bottom: 32px; font-weight: 600 }
+.passport-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 14px;
+  margin-bottom: 32px }
+.stamp { background: var(--bg-2); border: 1px solid var(--rule); aspect-ratio: 1;
+  display: flex; flex-direction: column; align-items: center; justify-content: center;
+  text-align: center; padding: 12px 8px; position: relative; transition: all .25s; cursor: pointer }
+.stamp .seal-svg { width: 38px; height: 38px; opacity: 0.18; transition: opacity .3s }
+.stamp .name { font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase;
+  color: var(--ink-faint); margin-top: 8px; font-weight: 600 }
+.stamp.visited { border-color: var(--gold); background: linear-gradient(180deg, var(--bg-2), rgba(201,182,135,0.08)) }
+.stamp.visited .seal-svg { opacity: 1 }
+.stamp.visited .name { color: var(--ink) }
+.stamp .ts { position: absolute; bottom: 4px; right: 6px; font-size: 11px; letter-spacing: 0.18em;
+  text-transform: uppercase; color: var(--gold); opacity: 0; transition: opacity .25s; font-weight: 600 }
+.stamp.visited .ts { opacity: 0.6 }
+.passport-foot { display: flex; gap: 8px; justify-content: center; flex-wrap: wrap;
+  font-size: 11px; letter-spacing: 0.32em; text-transform: uppercase; color: var(--ink-faint);
+  margin-top: 24px; font-weight: 600 }
+.passport-foot button { background: transparent; border: 1px solid var(--rule); color: var(--ink-mute);
+  padding: 9px 16px; font-size: 11px; letter-spacing: 0.32em; text-transform: uppercase;
+  font-weight: 600; cursor: pointer; font-family: inherit }
+.passport-foot button:hover { color: var(--ink); border-color: var(--gold) }
+.passport-foot .copied { color: var(--gold) }
+
+/* search */
+.search { max-width: 1280px; margin: 0 auto; padding: 0 32px 24px; position: relative }
+.search-row { display: flex; gap: 0; border: 1px solid var(--rule); background: var(--bg-2) }
+.search-row:focus-within { border-color: var(--gold) }
+#q { flex: 1; background: transparent; border: 0; color: var(--ink);
+  font: 300 22px 'Inter', -apple-system, sans-serif; padding: 24px 32px; outline: none;
+  letter-spacing: 0.01em }
+#q::placeholder { color: var(--ink-faint); transition: opacity .6s ease }
+#q.fade::placeholder { opacity: 0 }
+.search-meta { padding: 24px 32px; font-size: 11px; letter-spacing: 0.32em; text-transform: uppercase;
+  color: var(--ink-mute); align-self: center; white-space: nowrap; font-weight: 600 }
+.search-hint { display: flex; gap: 18px; flex-wrap: wrap; padding: 16px 0 0;
+  font-size: 11px; color: var(--ink-faint); letter-spacing: 0.18em; text-transform: uppercase;
+  font-weight: 600 }
+.search-hint button { background: transparent; border: 0; color: var(--ink-mute);
+  cursor: pointer; padding: 0; font: inherit; letter-spacing: 0.18em; font-weight: 600 }
+.search-hint button:hover { color: var(--gold) }
+
+/* results grid */
+.results { max-width: 1280px; margin: 0 auto; padding: 24px 32px 96px;
+  display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 0 }
+.card { background: var(--bg-2); border: 1px solid var(--rule); position: relative;
+  overflow: hidden; transition: transform .25s ease; aspect-ratio: 1 / 1.18; cursor: pointer;
+  margin: -1px 0 0 -1px }
+.card:hover { z-index: 2; transform: translateY(-1px); border-color: var(--gold) }
+.card img { width: 100%; height: 100%; object-fit: cover; display: block }
+.card .meta { position: absolute; left: 0; right: 0; bottom: 0; padding: 16px 16px;
+  background: linear-gradient(180deg, transparent 0%, rgba(12,10,8,0.92) 60%, rgba(12,10,8,0.98) 100%);
+  color: var(--ink) }
+.card .niche { font-size: 11px; letter-spacing: 0.32em; text-transform: uppercase;
+  color: var(--gold-warm); font-weight: 600; margin-bottom: 4px }
+.card .title { font-size: 13px; line-height: 1.3; font-weight: 400; max-height: 2.6em;
+  overflow: hidden }
+.empty { grid-column: 1 / -1; padding: 80px 0; text-align: center; color: var(--ink-mute);
+  font-style: italic; font-family: 'Playfair Display', serif; font-size: 18px }
+
+/* constellation */
+section.constellation { background: var(--bg-2); border-top: 1px solid var(--rule);
+  padding: 96px 32px 96px; }
+.const-wrap { max-width: 1280px; margin: 0 auto }
+.const-head { display: flex; justify-content: space-between; align-items: flex-end;
+  margin-bottom: 48px; flex-wrap: wrap; gap: 18px }
+.const-head h2 { font-family: 'Playfair Display', serif; font-weight: 400;
+  font-size: clamp(28px, 4vw, 48px); margin: 0; max-width: 22ch; letter-spacing: -0.01em }
+.const-head .legend { display: flex; gap: 18px; flex-wrap: wrap; font-size: 11px;
+  letter-spacing: 0.18em; text-transform: uppercase; color: var(--ink-mute); font-weight: 600 }
+.legend span { display: flex; align-items: center; gap: 8px }
+.legend i { width: 8px; height: 8px; border-radius: 50%; display: block }
+svg#map { width: 100%; height: 720px; display: block; max-width: 1080px; margin: 0 auto }
+.node { cursor: pointer; transition: opacity .2s }
+.node:hover { opacity: 0.85 }
+.node text { font-size: 11px; fill: var(--ink-mute); text-anchor: middle;
+  font-family: 'Inter', sans-serif; letter-spacing: 0.04em; pointer-events: none }
+.node:hover text { fill: var(--ink) }
+.node circle { transition: r .2s, stroke-width .2s, fill-opacity .35s }
+.node:hover circle:first-of-type { stroke-width: 2 }
+
+/* View Transitions: shared-element morph between SVG label and search input */
+::view-transition-old(filter-jump),
+::view-transition-new(filter-jump) {
+  animation-duration: 0.55s;
+  animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
+  mix-blend-mode: normal;
+}
+
+
+/* Off-screen pause — battery-friendly star-float */
+svg#map.paused .node-inner { animation-play-state: paused }
+
+/* Star float: each node gently breathes in place, deterministic per slug.
+   Inner-g handles the animation so it doesn't fight the SVG position transform. */
+@keyframes node-float {
+  0%, 100% { transform: translateY(0); }
+  50% { transform: translateY(var(--amt, 3px)); }
+}
+.node-inner {
+  animation: node-float var(--fd, 6s) ease-in-out infinite;
+  animation-delay: var(--del, 0s);
+  transform-origin: center;
+  will-change: transform;
+}
+@media (prefers-reduced-motion: reduce) { .node-inner { animation: none } }
+
+/* Scroll-driven parallax — Chrome 115+, Edge, Safari 17.4+. Falls back silently. */
+@supports (animation-timeline: view()) {
+  @keyframes map-parallax {
+    from { transform: translateY(28px); }
+    to   { transform: translateY(-28px); }
+  }
+  svg#map {
+    animation: map-parallax linear both;
+    animation-timeline: view();
+    animation-range: cover;
+  }
+  @media (prefers-reduced-motion: reduce) { svg#map { animation: none } }
+}
+/* Niche Drift: dim non-related nodes when one is hovered */
+svg#map.drift .node { opacity: 0.18; transition: opacity .35s }
+svg#map.drift .node.kin { opacity: 1 }
+svg#map.drift .node.kin circle:first-of-type { fill-opacity: 0.85 }
+svg#map.drift .node.center { opacity: 1 }
+svg#map.drift .node.center circle:first-of-type { fill-opacity: 1; stroke-width: 2.5 }
+.drift-line { stroke: var(--gold); stroke-width: 1; stroke-opacity: 0; fill: none;
+  pointer-events: none; transition: stroke-opacity .35s; stroke-dasharray: 4 6 }
+svg#map.drift .drift-line.active { stroke-opacity: 0.55 }
+
+/* Constellation Sleeps — between 2 and 4 a.m. local */
+svg#map.sleeping .node { opacity: 0.15; transition: opacity 1.6s ease }
+svg#map.sleeping .drift-line { stroke-opacity: 0.05 }
+.sleep-pattern { position: absolute; pointer-events: none;
+  width: 180px; height: 180px; opacity: 0; mix-blend-mode: overlay;
+  transition: opacity 2s ease; will-change: transform; border-radius: 4px }
+.sleep-pattern.show { opacity: 0.35 }
+.sleep-caption { text-align: center; font-family: 'Playfair Display', serif;
+  font-style: italic; color: var(--gold); font-size: 18px; opacity: 0;
+  margin-top: -32px; margin-bottom: 32px; transition: opacity 1.6s; letter-spacing: 0.02em }
+.sleep-caption.show { opacity: 0.85 }
+
+/* One-At-A-Time Tuesdays — single-card empty state */
+.results.tuesday { grid-template-columns: 1fr; padding: 24px 32px 64px }
+.results.tuesday .card { aspect-ratio: 16 / 9; max-height: 70vh }
+.results.tuesday .card .meta { padding: 32px 32px;
+  background: linear-gradient(180deg, transparent 0%, rgba(12,10,8,0.86) 50%, rgba(12,10,8,0.95) 100%) }
+.results.tuesday .card .niche { font-size: 11px }
+.results.tuesday .card .title { font-size: 22px; font-family: 'Playfair Display', serif;
+  font-style: italic; max-height: none }
+.tuesday-caption { max-width: 1280px; margin: 0 auto; padding: 0 32px;
+  font-family: 'Playfair Display', serif; font-style: italic; color: var(--ink-mute);
+  font-size: 13px; letter-spacing: 0.02em; text-align: center; margin-top: 12px }
+.tuesday-caption a { color: var(--gold); text-decoration: none; border-bottom: 1px solid var(--gold) }
+.tuesday-caption a:hover { color: var(--ink) }
+
+/* Pairing Oracle */
+.card .pair-btn { position: absolute; top: 10px; right: 10px; width: 32px; height: 32px;
+  border-radius: 50%; background: rgba(12,10,8,0.55); color: var(--ink); border: 1px solid rgba(244,236,216,0.4);
+  display: flex; align-items: center; justify-content: center; font-size: 13px;
+  cursor: pointer; opacity: 0; transition: opacity .2s, background .2s, transform .2s;
+  font-family: 'Playfair Display', serif; font-style: italic; z-index: 3 }
+.card:hover .pair-btn { opacity: 1 }
+.card .pair-btn:hover { background: var(--gold); color: var(--bg); transform: scale(1.08) }
+.pair-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.78); display: none;
+  align-items: center; justify-content: center; padding: 32px; z-index: 200;
+  backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px) }
+.pair-overlay.show { display: flex }
+.pair-modal { max-width: 1080px; width: 100%; background: var(--bg); border: 1px solid var(--rule);
+  position: relative; padding: 48px 48px 48px; max-height: 90vh; overflow-y: auto }
+.pair-close { position: absolute; top: 16px; right: 20px; background: none; border: 0;
+  color: var(--ink-mute); font-size: 22px; cursor: pointer; padding: 8px; line-height: 1 }
+.pair-close:hover { color: var(--ink) }
+.pair-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 36px; margin-bottom: 32px }
+.pair-card img { width: 100%; aspect-ratio: 1; object-fit: cover; display: block; margin-bottom: 16px }
+.pair-card .niche { font-size: 11px; letter-spacing: 0.32em; text-transform: uppercase;
+  color: var(--gold-warm); font-weight: 600; margin-bottom: 4px }
+.pair-card .title { font-size: 13px; line-height: 1.4 }
+.pair-blurb { font-family: 'Playfair Display', serif; font-style: italic;
+  font-size: 22px; line-height: 1.5; color: var(--gold); text-align: center;
+  padding: 12px 0 24px; max-width: 38ch; margin: 0 auto }
+.pair-foot { text-align: center; font-size: 11px; letter-spacing: 0.32em;
+  text-transform: uppercase; color: var(--ink-faint); margin-top: 16px; font-weight: 600 }
+.pair-foot button { background: transparent; border: 1px solid var(--rule); color: var(--ink-mute);
+  padding: 9px 16px; font-size: 11px; letter-spacing: 0.32em; text-transform: uppercase;
+  font-weight: 600; cursor: pointer; font-family: inherit; margin: 0 4px }
+.pair-foot button:hover { color: var(--ink); border-color: var(--gold) }
+@media (max-width: 720px) { .pair-grid { grid-template-columns: 1fr } .pair-modal { padding: 40px 24px 32px } }
+
+/* Velvet Rope — first visit ritual */
+.velvet { position: fixed; inset: 0; background: var(--bg); display: none;
+  flex-direction: column; align-items: center; justify-content: center; padding: 48px 32px;
+  z-index: 300; opacity: 0; transition: opacity 1.4s ease }
+.velvet.show { display: flex; opacity: 1 }
+.velvet.dismiss { opacity: 0; pointer-events: none }
+.velvet h1 { font-family: 'Playfair Display', serif; font-style: italic; font-weight: 400;
+  font-size: clamp(34px, 6vw, 64px); line-height: 1.15; margin: 0 0 16px;
+  letter-spacing: -0.02em; max-width: 22ch; text-align: center; color: var(--ink) }
+.velvet h1 em { color: var(--gold); font-style: italic }
+.velvet .lead { font-family: 'Inter', sans-serif; font-weight: 300; font-size: 13px;
+  color: var(--ink-mute); margin-bottom: 32px; letter-spacing: 0.04em; text-align: center;
+  max-width: 50ch; line-height: 1.7 }
+.velvet input { width: min(560px, 100%); background: transparent; border: 0;
+  border-bottom: 1px solid var(--rule); color: var(--ink);
+  font: 400 24px 'Playfair Display', serif; font-style: italic; padding: 16px 0;
+  outline: none; text-align: center; letter-spacing: 0.01em }
+.velvet input:focus { border-bottom-color: var(--gold) }
+.velvet input::placeholder { color: var(--ink-faint); font-style: italic }
+.velvet .hint { font-size: 11px; letter-spacing: 0.32em; text-transform: uppercase;
+  color: var(--ink-faint); margin-top: 24px; font-weight: 600; height: 14px;
+  transition: color .3s, opacity .3s }
+.velvet .hint.ready { color: var(--gold); opacity: 1 }
+.velvet .skip { margin-top: 48px; background: transparent; border: 0; color: var(--ink-faint);
+  font-size: 11px; letter-spacing: 0.32em; text-transform: uppercase; cursor: pointer;
+  font-family: inherit; font-weight: 600; padding: 8px 12px }
+.velvet .skip:hover { color: var(--gold) }
+.velvet .ornament { font-family: 'Playfair Display', serif; font-style: italic;
+  color: var(--gold); font-size: 22px; opacity: 0.5; margin-bottom: 32px; letter-spacing: 0.4em }
+
+/* tablet — between mobile and desktop */
+@media (min-width: 720px) and (max-width: 1024px) {
+  header { padding: 48px 24px 16px }
+  .search { padding: 0 24px 16px }
+  .trust-strip { padding: 0 24px 16px; gap: 16px }
+  .results { padding: 16px 24px 64px; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)) }
+  section.constellation { padding: 64px 24px }
+  svg#map { height: 600px }
+  .pair-modal, .passport-modal { padding: 32px 32px 32px }
+}
+/* large desktop — let the page breathe */
+@media (min-width: 1440px) {
+  header { padding: 96px 32px 32px }
+  .results { grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)) }
+  section.constellation { padding: 128px 32px }
+}
+
+/* footer */
+footer { padding: 64px 32px; max-width: 1280px; margin: 0 auto;
+  border-top: 1px solid var(--rule); display: flex; justify-content: space-between;
+  flex-wrap: wrap; gap: 24px; font-size: 13px; color: var(--ink-mute) }
+footer a { color: var(--gold) }
+footer a:hover { color: var(--ink) }
+
+/* tooltip */
+.tip { position: fixed; pointer-events: none; padding: 12px 16px; background: var(--bg);
+  border: 1px solid var(--gold); color: var(--ink); font-size: 13px;
+  z-index: 100; opacity: 0; transition: opacity .15s; min-width: 180px;
+  letter-spacing: 0.04em }
+.tip.show { opacity: 1 }
+.tip { max-width: 280px }
+.tip .label { font-family: 'Playfair Display', serif; font-style: italic;
+  font-size: 18px; margin-bottom: 8px; color: var(--gold); line-height: 1.2 }
+.tip .poem { font-family: 'Playfair Display', serif; font-size: 13px; line-height: 1.5;
+  color: var(--ink); margin-bottom: 12px; font-style: italic }
+.tip .row { font-size: 11px; letter-spacing: 0.32em; text-transform: uppercase;
+  color: var(--ink-mute); font-weight: 600 }
+</style>
+</head>
+<body>
+
+<!-- Velvet Rope: first-visit ritual -->
+<div class="velvet" id="velvet" aria-hidden="true">
+  <div class="ornament">·  ·  ·</div>
+  <h1>Name a room <em>you've decorated</em><br>in a dream.</h1>
+  <p class="lead">No wrong answers. We just like to know what you arrived with.</p>
+  <input id="velvetInput" type="text" placeholder="a kitchen at the end of the world…" autocomplete="off" maxlength="120">
+  <div class="hint" id="velvetHint">type three letters to enter</div>
+  <button class="skip" onclick="velvetSkip()">skip the door →</button>
+</div>
+
+<header>
+  <button class="passport-pill" onclick="openPassport()" id="ppbtn" aria-label="Open your passport">
+    <span class="seal" aria-hidden="true"></span><span id="ppCount">Passport · 0</span>
+  </button>
+  <button class="theme-toggle" onclick="toggleTheme()" id="ttbtn">Light</button>
+  <div class="brand">Designer Wallcoverings <span class="dot"></span> Universe</div>
+  <h1>Forty-one niches. <em>One search.</em></h1>
+  <p class="lede">The Designer Wallcoverings family is a constellation of niche storefronts —
+    silk, mica, raffia, '20s deco, contract, museum, saloon — each curated to a single
+    aesthetic. Type a phrase. We'll search every one of them.</p>
+</header>
+
+<div class="trust-strip">
+  <span>Designer Wallcoverings</span>
+  <span class="dot"></span>
+  <span>Trade-direct since 2008</span>
+  <span class="dot"></span>
+  <span>Free memo samples</span>
+  <span class="dot"></span>
+  <a href="mailto:steve@designerwallcoverings.com">steve@designerwallcoverings.com</a>
+</div>
+
+<div class="search">
+  <div class="search-row">
+    <input id="q" type="text" placeholder="art deco gold · 1920s · cork · velvet · linen…" autocomplete="off" autofocus>
+    <div class="search-meta" id="searchMeta">— results</div>
+  </div>
+  <div class="search-hint">
+    <span>Try:</span>
+    <button onclick="setQ('art deco')">art deco</button>
+    <button onclick="setQ('silk')">silk</button>
+    <button onclick="setQ('gold')">gold</button>
+    <button onclick="setQ('1920s')">1920s</button>
+    <button onclick="setQ('linen')">linen</button>
+    <button onclick="setQ('flock')">flock</button>
+    <button onclick="setQ('chinoiserie')">chinoiserie</button>
+  </div>
+</div>
+
+<div class="results" id="results"></div>
+
+<!-- Passport modal -->
+<div class="passport-overlay" id="passportOverlay" onclick="closePassport(event)">
+  <div class="passport-modal" onclick="event.stopPropagation()">
+    <button class="pair-close" onclick="closePassport()" aria-label="close">×</button>
+    <h2>Your Passport</h2>
+    <div class="sub" id="ppSub">— niches visited</div>
+    <div class="passport-grid" id="ppGrid"></div>
+    <div class="passport-foot">
+      <button onclick="sharePassport()" id="ppShareBtn">Copy share link</button>
+      <button onclick="resetPassport()">Reset</button>
+      <button onclick="closePassport()">Close</button>
+    </div>
+  </div>
+</div>
+
+<!-- Pairing Oracle modal -->
+<div class="pair-overlay" id="pairOverlay" onclick="closePair(event)">
+  <div class="pair-modal" onclick="event.stopPropagation()">
+    <button class="pair-close" onclick="closePair()" aria-label="close">×</button>
+    <div class="pair-grid" id="pairGrid"></div>
+    <div class="pair-blurb" id="pairBlurb"></div>
+    <div class="pair-foot">
+      <button onclick="rerollPair()">Try another pair</button>
+      <button onclick="closePair()">Close</button>
+    </div>
+  </div>
+</div>
+
+<section class="constellation">
+  <div class="const-wrap">
+    <div class="const-head">
+      <h2>The constellation. Hover to enter a niche.</h2>
+      <div class="legend">
+        <span><i style="background:var(--c-material)"></i>Material</span>
+        <span><i style="background:var(--c-decade)"></i>Decade</span>
+        <span><i style="background:var(--c-craft)"></i>Craft</span>
+        <span><i style="background:var(--c-use)"></i>Use-case</span>
+      </div>
+    </div>
+    <svg id="map" viewBox="0 0 1080 720"></svg>
+  </div>
+</section>
+
+<footer>
+  <div>
+    <div style="font-family:'Playfair Display',serif;font-size:18px;color:var(--ink);margin-bottom:8px">Designer Wallcoverings</div>
+    <div>Trade-direct wallcovering and fabric since 2008.</div>
+  </div>
+  <div style="text-align:right">
+    <a href="https://designerwallcoverings.com" target="_blank">designerwallcoverings.com →</a>
+    <div style="margin-top:8px;font-size:11px;letter-spacing:0.32em;text-transform:uppercase;color:var(--ink-faint);font-weight:600">
+      <span id="totalCt">—</span> patterns · <span id="totalNiche">—</span> niches
+    </div>
+  </div>
+</footer>
+
+<div class="tip" id="tip"></div>
+
+<script>
+// IIFE wrap (P0) — keep all 11 features' state out of the global scope.
+(function () {
+const CAT_COLOR = {
+  material: 'var(--c-material)', decade: 'var(--c-decade)',
+  craft: 'var(--c-craft)', use: 'var(--c-use)'
+};
+
+// theme toggle (DW standard)
+function toggleTheme() {
+  const next = (document.documentElement.dataset.theme === 'light') ? 'dark' : 'light';
+  document.documentElement.dataset.theme = next;
+  document.getElementById('ttbtn').textContent = next === 'light' ? 'Dark' : 'Light';
+  localStorage.setItem('dwu-theme', next);
+}
+(function () {
+  const saved = localStorage.getItem('dwu-theme');
+  if (saved === 'light') { document.documentElement.dataset.theme = 'light'; }
+  document.getElementById('ttbtn').textContent =
+    document.documentElement.dataset.theme === 'light' ? 'Dark' : 'Light';
+})();
+
+// search
+const qEl = document.getElementById('q');
+const meta = document.getElementById('searchMeta');
+const grid = document.getElementById('results');
+let lastReq = 0;
+
+async function runSearch(q) {
+  const myReq = ++lastReq;
+  meta.textContent = q ? 'searching…' : '— browse';
+  const url = q ? 'api/search?q=' + encodeURIComponent(q) : 'api/sample';
+  const r = await fetch(url);
+  if (myReq !== lastReq) return; // stale
+  const j = await r.json();
+  meta.textContent = q ? `${j.results.length} results · ${j.ms || 0}ms` : `${j.results.length} samples`;
+  if (!j.results.length) {
+    grid.innerHTML = '<div class="empty">No matches. Try one of the suggestions above, or a broader term.</div>';
+    return;
+  }
+  grid.innerHTML = j.results.map(p => `
+    <div class="card" data-sku="${esc(p.sku)}" data-slug="${esc(p.slug)}" data-href="${esc(p.product_url)}" data-title="${esc(p.title)}" onclick="cardClick(event)">
+      <button class="pair-btn" title="Find its pair" aria-label="Find this pattern's pair" onclick="openPair(event,'${esc(p.sku)}','${esc(p.slug)}')">+</button>
+      <img src="${esc(p.image_url || '')}" loading="lazy" alt="${esc(p.title)}" onerror="this.style.opacity=0.15">
+      <div class="meta">
+        <div class="niche">${esc(p.niche)}</div>
+        <div class="title">${esc(p.title || '')}</div>
+      </div>
+    </div>
+  `).join('');
+}
+function esc(s) { return String(s||'').replace(/[<>&"']/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;',"'":'&#39;'}[c])); }
+function setQ(v) { qEl.value = v; qEl.dispatchEvent(new Event('input')); }
+
+// === Slow-Reveal Bookmark ===
+// Each visit increments a counter, and milestone visits (3, 7, 14, 30) unlock
+// a deeper editorial layer that only returning visitors see.
+// Surfaced as an italic Playfair line under the hero, briefly visible on the milestone visit.
+const SR_KEY = 'dw-visits';
+const SR_LAST_KEY = 'dw-last-visit';
+const REVEAL_TIERS = [
+  // visit-count → editorial line (revealed on first visit AT or after that count)
+  { at: 3,  line: 'You came back. We thought you might.' },
+  { at: 7,  line: 'Seven visits. The catalog is starting to remember you.' },
+  { at: 14, line: 'Two weeks of looking. You are circling something specific.' },
+  { at: 30, line: 'A month in. Whatever room you are decorating, it is already half-furnished.' },
+  { at: 60, line: 'You have been here longer than most clients. Try requesting a memo.' },
+  { at: 100, line: 'One hundred visits. We know your taste better than you do by now.' }
+];
+function slowRevealTick() {
+  let visits = parseInt(localStorage.getItem(SR_KEY) || '0', 10);
+  visits += 1;
+  localStorage.setItem(SR_KEY, String(visits));
+  if (location.search) return;
+  const tier = REVEAL_TIERS.slice().reverse().find(t => visits >= t.at && visits < t.at + 1);
+  if (!tier) return;
+  // P1: dedupe same-day reveals so a hard refresh doesn't redundantly fire
+  const today = new Date().toISOString().slice(0, 10);
+  const lastShown = localStorage.getItem(SR_LAST_KEY) || '';
+  if (lastShown === `${tier.at}|${today}`) return;
+  localStorage.setItem(SR_LAST_KEY, `${tier.at}|${today}`);
+  // Insert under the hero, fade in after a beat, fade out after 12s
+  setTimeout(() => {
+    const lede = document.querySelector('header p.lead');
+    if (!lede) return;
+    const el = document.createElement('div');
+    el.style.cssText = 'font-family: "Playfair Display", serif; font-style: italic; color: var(--gold); font-size: 18px; line-height: 1.5; margin-top: 16px; opacity: 0; transition: opacity 1.6s ease; max-width: 56ch';
+    el.textContent = tier.line;
+    lede.after(el);
+    requestAnimationFrame(() => el.style.opacity = '1');
+    setTimeout(() => { el.style.opacity = '0'; setTimeout(() => el.remove(), 1800); }, 12000);
+  }, 2400);
+}
+slowRevealTick();
+
+// === Velvet Rope ===
+// First-visit ritual: a single-line riddle gates the catalog.
+// Three letters typed -> unlock. Skippable via "skip the door".
+// Records the user's "first whisper" in localStorage as a sentimental artifact.
+const VR_KEY = 'dw-velvet-entered';
+const VR_WHISPER_KEY = 'dw-first-whisper';
+function velvetMaybeShow() {
+  if (localStorage.getItem(VR_KEY)) return;
+  // Skip if loaded with a query (deeplink, search, shared passport) — preserve the link's intent
+  if (location.search) { localStorage.setItem(VR_KEY, '1'); return; }
+  const v = document.getElementById('velvet');
+  v.setAttribute('aria-hidden', 'false');
+  v.classList.add('show');
+  document.body.style.overflow = 'hidden';
+  const inp = document.getElementById('velvetInput');
+  const hint = document.getElementById('velvetHint');
+  setTimeout(() => inp.focus(), 1500);
+
+  // P2: auto-skip after 8s idle. The riddle is charming once; on a hurry it's friction.
+  // Reset the timer on any keypress so genuine players don't get yanked through.
+  let idleT = setTimeout(() => { if (!velvetUnlocked) velvetSkip(); }, 8000);
+  inp.addEventListener('input', () => { clearTimeout(idleT); idleT = setTimeout(() => { if (!velvetUnlocked) velvetSkip(); }, 12000); });
+  inp.addEventListener('input', () => {
+    if (inp.value.trim().length >= 3) {
+      hint.textContent = 'press enter — or just keep typing';
+      hint.classList.add('ready');
+    } else {
+      hint.classList.remove('ready');
+      hint.textContent = 'type three letters to enter';
+    }
+  });
+  inp.addEventListener('keydown', (e) => {
+    if (e.key === 'Enter' && inp.value.trim().length >= 3) {
+      velvetUnlock(inp.value.trim());
+    }
+  });
+  // also auto-unlock at 12+ chars (typed past the threshold without pressing enter)
+  inp.addEventListener('input', () => {
+    if (inp.value.trim().length >= 12) {
+      setTimeout(() => velvetUnlock(inp.value.trim()), 800);
+    }
+  }, { once: false });
+}
+let velvetUnlocked = false;
+function velvetUnlock(whisper) {
+  if (velvetUnlocked) return;  // P0: prevent double-fire (Enter + 12-char auto)
+  velvetUnlocked = true;
+  if (whisper) localStorage.setItem(VR_WHISPER_KEY, whisper.slice(0, 200));
+  localStorage.setItem(VR_KEY, '1');
+  const v = document.getElementById('velvet');
+  v.classList.add('dismiss');
+  document.body.style.overflow = '';
+  setTimeout(() => v.classList.remove('show', 'dismiss'), 1500);
+  startOracle();  // P1: Oracle now starts ONLY after gate is open
+}
+function velvetSkip() {
+  if (velvetUnlocked) return;
+  velvetUnlocked = true;
+  localStorage.setItem(VR_KEY, '1');
+  const v = document.getElementById('velvet');
+  v.classList.add('dismiss');
+  document.body.style.overflow = '';
+  setTimeout(() => v.classList.remove('show', 'dismiss'), 1500);
+  startOracle();
+}
+velvetMaybeShow();
+// If gate was dismissed previously (returning user) the maybeShow returns early → start oracle now
+if (!document.getElementById('velvet').classList.contains('show')) startOracle();
+
+// === Sample Passport ===
+// localStorage-backed record of niches the user has visited (clicked into).
+// Each niche click stamps the passport; the pill in the header shows the count.
+// Shareable via ?passport=slug1,slug2 URL param (read-only mode for someone else's set).
+const PP_KEY = 'dw-passport-v1';
+function loadPassport() { try { return JSON.parse(localStorage.getItem(PP_KEY) || '{}'); } catch (e) { return {}; } }
+function savePassport(p) { localStorage.setItem(PP_KEY, JSON.stringify(p)); refreshPassportPill(); }
+function stampPassport(slug) {
+  if (!slug || ppReadOnly) return;
+  const p = loadPassport();
+  if (!p[slug]) p[slug] = new Date().toISOString();
+  savePassport(p);
+}
+function refreshPassportPill() {
+  const n = Object.keys(loadPassport()).length;
+  const c = document.getElementById('ppCount');
+  if (c) c.textContent = `Passport · ${n}`;
+}
+let ppReadOnly = false;
+let ppViewing = null; // {slugs:[], from:'shared'}
+
+(function initPassportFromURL() {
+  const params = new URLSearchParams(location.search);
+  const shared = params.get('passport');
+  if (shared) {
+    const slugs = shared.split(',').filter(Boolean).slice(0, 50);
+    ppViewing = { slugs, from: 'shared' };
+    ppReadOnly = true;
+  }
+  refreshPassportPill();
+})();
+
+function openPassport() {
+  const overlay = document.getElementById('passportOverlay');
+  const grid = document.getElementById('ppGrid');
+  const sub = document.getElementById('ppSub');
+  // build grid using the niches data already fetched for the constellation
+  fetch('api/sites').then(r => r.json()).then(j => {
+    const niches = j.niches;
+    const slugs = Object.keys(niches);
+    const visited = ppReadOnly && ppViewing ? new Set(ppViewing.slugs) : new Set(Object.keys(loadPassport()));
+    const visitedDates = ppReadOnly ? {} : loadPassport();
+    sub.textContent = ppReadOnly
+      ? `${visited.size} of ${slugs.length} · viewing a shared passport`
+      : `${visited.size} of ${slugs.length} niches stamped`;
+    grid.innerHTML = slugs.sort((a, b) => (niches[a].label || a).localeCompare(niches[b].label || b)).map(slug => {
+      const n = niches[slug];
+      const isVisited = visited.has(slug);
+      const ts = visitedDates[slug] ? new Date(visitedDates[slug]).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '';
+      const cat = n.cat;
+      const colorVar = `var(--c-${cat})`;
+      // simple seal: filled circle with category color, plus a small inner mark for visited
+      const sealSvg = `
+        <svg class="seal-svg" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" style="color: ${colorVar}">
+          <circle cx="50" cy="50" r="42" fill="currentColor" fill-opacity="${isVisited ? 0.32 : 0.06}" stroke="currentColor" stroke-opacity="${isVisited ? 1 : 0.5}" stroke-width="2"/>
+          ${isVisited ? `<path d="M30 52 L44 66 L72 36" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/>` : ''}
+        </svg>`;
+      return `<div class="stamp${isVisited ? ' visited' : ''}" onclick="window.open('https://${n.domain}','_blank')" title="${esc(n.poem || n.label)}">
+        ${sealSvg}
+        <div class="name">${esc(n.label)}</div>
+        ${ts ? `<div class="ts">${ts}</div>` : ''}
+      </div>`;
+    }).join('');
+    overlay.classList.add('show');
+    document.body.style.overflow = 'hidden';
+  });
+}
+function closePassport(e) {
+  if (e && e.target !== e.currentTarget) return;
+  document.getElementById('passportOverlay').classList.remove('show');
+  document.body.style.overflow = '';
+}
+function sharePassport() {
+  const slugs = Object.keys(loadPassport());
+  const url = `${location.origin}${location.pathname}?passport=${slugs.join(',')}`;
+  navigator.clipboard.writeText(url).then(() => {
+    const b = document.getElementById('ppShareBtn');
+    const orig = b.textContent;
+    b.classList.add('copied');
+    b.textContent = '✓ Link copied';
+    setTimeout(() => { b.textContent = orig; b.classList.remove('copied'); }, 2200);
+  });
+}
+function resetPassport() {
+  if (!confirm('Clear your passport? This wipes all stamps.')) return;
+  localStorage.removeItem(PP_KEY);
+  refreshPassportPill();
+  closePassport({ target: document.getElementById('passportOverlay'), currentTarget: document.getElementById('passportOverlay') });
+}
+
+// === Pairing Oracle ===
+let lastPairCtx = null;
+function cardClick(e) {
+  // ignore if clicking the +pair button
+  if (e.target.closest('.pair-btn')) return;
+  const c = e.currentTarget;
+  if (c.dataset.slug) stampPassport(c.dataset.slug);
+  if (c.dataset.href) window.open(c.dataset.href, '_blank');
+}
+async function openPair(e, sku, slug) {
+  e.stopPropagation(); e.preventDefault();
+  lastPairCtx = { sku, slug };
+  await renderPair();
+}
+async function renderPair() {
+  const { sku, slug } = lastPairCtx || {};
+  if (!sku) return;
+  const r = await fetch(`api/pair?sku=${encodeURIComponent(sku)}&slug=${encodeURIComponent(slug || '')}`);
+  if (!r.ok) return;
+  const j = await r.json();
+  const card = (p, label) => `
+    <div class="pair-card">
+      <img src="${esc(p.image_url || '')}" alt="${esc(p.title || '')}" onerror="this.style.opacity=0.15">
+      <div class="niche">${label} · ${esc(p.niche || '')}</div>
+      <div class="title">${esc(p.title || '')}</div>
+    </div>`;
+  document.getElementById('pairGrid').innerHTML = card(j.source, 'Source') + card(j.paired, 'Pair');
+  document.getElementById('pairBlurb').textContent = j.blurb;
+  document.getElementById('pairOverlay').classList.add('show');
+  document.body.style.overflow = 'hidden';
+}
+function rerollPair() { renderPair(); }
+function closePair(e) {
+  if (e && e.target !== e.currentTarget) return; // outside-click only
+  document.getElementById('pairOverlay').classList.remove('show');
+  document.body.style.overflow = '';
+}
+window.addEventListener('keydown', e => {
+  if (e.key !== 'Escape') return;
+  // P1: only close the modal that's actually open; don't stomp on body.overflow
+  const pp = document.getElementById('passportOverlay');
+  const po = document.getElementById('pairOverlay');
+  if (po && po.classList.contains('show')) closePair({ target: po, currentTarget: po });
+  else if (pp && pp.classList.contains('show')) closePassport({ target: pp, currentTarget: pp });
+});
+
+let debounce;
+qEl.addEventListener('input', () => {
+  clearTimeout(debounce);
+  debounce = setTimeout(() => runSearch(qEl.value.trim()), 140);
+});
+runSearch(''); // seed with samples
+
+// === One-Line Oracle ===
+// When the search box is empty + unfocused, cycle the 43 niche blurbs as italic placeholder.
+// Stops cleanly the moment user types or focuses.
+let oracleTimer = null;
+async function startOracle() {
+  const res = await fetch('api/sites');
+  const j = await res.json();
+  const lines = Object.values(j.niches).map(n => n.poem).filter(p => p && p.length > 8);
+  // shuffle so each session feels fresh
+  for (let i = lines.length - 1; i > 0; i--) {
+    const k = Math.floor(Math.random() * (i + 1)); [lines[i], lines[k]] = [lines[k], lines[i]];
+  }
+  let i = 0;
+  function tick() {
+    if (qEl.value || document.activeElement === qEl) return; // stop while user engaged
+    qEl.classList.add('fade');
+    setTimeout(() => {
+      if (qEl.value || document.activeElement === qEl) return;
+      qEl.placeholder = lines[i % lines.length];
+      i++;
+      qEl.classList.remove('fade');
+    }, 600);
+    oracleTimer = setTimeout(tick, 4200);
+  }
+  tick();
+}
+qEl.addEventListener('focus', () => { if (oracleTimer) { clearTimeout(oracleTimer); oracleTimer = null; } qEl.classList.remove('fade'); qEl.placeholder = 'art deco gold · 1920s · cork · velvet · linen…'; });
+qEl.addEventListener('blur',  () => { if (!qEl.value && !oracleTimer) startOracle(); });
+
+// === Constellation Sleeps ===
+// Between 2 and 4 a.m. local time, the constellation dims and a pattern drifts across.
+// Click anywhere to wake.
+function maybeSleep() {
+  const h = new Date().getHours();
+  if (h < 2 || h >= 4) return;
+  const map = document.getElementById('map');
+  map.classList.add('sleeping');
+  const wrap = document.querySelector('.const-wrap');
+  if (!wrap || wrap.querySelector('.sleep-caption')) return;
+  const cap = document.createElement('div');
+  cap.className = 'sleep-caption';
+  cap.textContent = 'the catalog is dreaming.';
+  wrap.appendChild(cap);
+  setTimeout(() => cap.classList.add('show'), 100);
+  // drift a single thumbnail across the constellation viewport
+  fetch('api/sample').then(r => r.json()).then(j => {
+    if (!j.results || !j.results.length) return;
+    const pick = j.results[Math.floor(Math.random() * j.results.length)];
+    const img = document.createElement('img');
+    img.className = 'sleep-pattern';
+    img.src = pick.image_url;
+    img.alt = pick.title || '';
+    wrap.style.position = 'relative';
+    wrap.appendChild(img);
+    setTimeout(() => img.classList.add('show'), 200);
+    let x = -200, y = 80, dir = 1, rafId = 0;
+    function tick() {
+      x += dir * 0.8;
+      y = 80 + Math.sin(x / 60) * 40;
+      img.style.transform = `translate(${x}px, ${y}px)`;
+      if (x > wrap.offsetWidth + 200) x = -200;
+      rafId = requestAnimationFrame(tick);
+    }
+    tick();
+    // wake on click anywhere — P2: cancel the rAF loop too
+    document.addEventListener('click', () => {
+      cancelAnimationFrame(rafId);
+      img.classList.remove('show');
+      setTimeout(() => img.remove(), 1200);
+      map.classList.remove('sleeping');
+      cap.classList.remove('show');
+    }, { once: true });
+  });
+}
+maybeSleep();
+
+// === One-At-A-Time Tuesdays ===
+// On Tuesdays, the EMPTY state renders ONE big pattern instead of 24 thumbnails.
+// Search overrides immediately. A "see today's full selection" link reveals the grid.
+const isTuesday = new Date().getDay() === 2;
+let tuesdayShown = false;
+
+// === Pattern Density Scroll ===
+// As search results change, the body background subtly tiles the top result image.
+// Page literally becomes wallpaper. Light theme -> multiply blend; dark -> overlay (set in CSS).
+let lastBg = null;
+function setBgPattern(url) {
+  if (!url || url === lastBg) return;
+  lastBg = url;
+  const root = document.documentElement;
+  // preload, then swap
+  const img = new Image();
+  img.onload = () => {
+    root.style.setProperty('--bg-pattern', `url("${url}")`);
+    root.style.setProperty('--bg-pattern-opacity', '0.06');
+  };
+  img.onerror = () => {};
+  img.src = url;
+}
+// hook: every time runSearch finishes and rendered results, sample the first card's image
+const _origRunSearch = runSearch;
+runSearch = async function (q) {
+  await _origRunSearch(q);
+  // Tuesday empty-state hijack: only on the first empty-state render of the session
+  if (isTuesday && !q && !tuesdayShown) {
+    const all = Array.from(grid.querySelectorAll('.card'));
+    if (all.length > 0) {
+      // pick one, hide the rest, add caption
+      grid.classList.add('tuesday');
+      all.slice(1).forEach(c => c.remove());
+      const cap = document.createElement('div');
+      cap.className = 'tuesday-caption';
+      cap.innerHTML = `the only pattern we want you to see today. <a href="#" id="tueAllLink">show me the other ${all.length - 1} →</a>`;
+      grid.parentNode.insertBefore(cap, grid.nextSibling);
+      cap.querySelector('#tueAllLink').addEventListener('click', (e) => {
+        e.preventDefault();
+        grid.classList.remove('tuesday');
+        cap.remove();
+        runSearch(''); // re-render full sample grid
+      });
+      tuesdayShown = true;
+    }
+    meta.textContent = 'today’s pick';
+  }
+  const firstImg = grid.querySelector('.card img');
+  if (firstImg && firstImg.src) setBgPattern(firstImg.src);
+};
+
+// constellation
+async function buildMap() {
+  const r = await fetch('api/sites');
+  const j = await r.json();
+  const niches = j.niches;
+  const slugs = Object.keys(niches);
+  document.getElementById('totalCt').textContent = j.total_products.toLocaleString();
+  document.getElementById('totalNiche').textContent = slugs.length;
+
+  // Layout: arrange in 4 vertical bands by category, jittered for organic feel.
+  const groups = { material: [], decade: [], craft: [], use: [] };
+  for (const s of slugs) groups[niches[s].cat].push(s);
+  const W = 1080, H = 720, PAD_X = 90, PAD_Y = 80;
+  const cats = ['material', 'decade', 'craft', 'use'];
+  const bandW = (W - 2 * PAD_X) / cats.length;
+  const positions = [];
+  cats.forEach((cat, ci) => {
+    const list = groups[cat];
+    const cx = PAD_X + bandW * ci + bandW / 2;
+    const usableH = H - 2 * PAD_Y;
+    list.forEach((slug, i) => {
+      const t = list.length === 1 ? 0.5 : i / (list.length - 1);
+      // pseudo-random horizontal jitter, deterministic per slug
+      let h = 0; for (let k = 0; k < slug.length; k++) h = (h * 31 + slug.charCodeAt(k)) | 0;
+      const jitterX = ((h & 0xff) / 255 - 0.5) * (bandW * 0.55);
+      const jitterY = (((h >> 8) & 0xff) / 255 - 0.5) * 30;
+      positions.push({
+        slug, cat, label: niches[slug].label, count: niches[slug].count, domain: niches[slug].domain,
+        poem: niches[slug].poem || '',
+        x: cx + jitterX, y: PAD_Y + t * usableH + jitterY,
+      });
+    });
+  });
+
+  const maxCount = Math.max(...positions.map(p => p.count));
+  const svg = document.getElementById('map');
+  // Build a slug→position map for drawing arc lines on hover
+  const pos = {};
+  for (const p of positions) pos[p.slug] = p;
+
+  // Niche DNA: per-category procedural SVG patterns. Universal browser support.
+  // Each node fills with its category's "material signature" — woven for material,
+  // chevron for decade, dashed stitch for craft, tight grid for use-case.
+  const dnaDefs = `
+    <defs>
+      <pattern id="dna-material" width="5" height="5" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
+        <line x1="0" y1="0" x2="0" y2="5" stroke="#c9b687" stroke-width="0.7"/>
+      </pattern>
+      <pattern id="dna-decade" width="8" height="5" patternUnits="userSpaceOnUse">
+        <polyline points="0,4 4,1 8,4" stroke="#b86a4a" stroke-width="0.7" fill="none" stroke-linecap="round"/>
+      </pattern>
+      <pattern id="dna-craft" width="5" height="5" patternUnits="userSpaceOnUse" patternTransform="rotate(-30)">
+        <line x1="0" y1="2.5" x2="5" y2="2.5" stroke="#6b8e6f" stroke-width="0.6" stroke-dasharray="1.4 1.2"/>
+      </pattern>
+      <pattern id="dna-use" width="4" height="4" patternUnits="userSpaceOnUse">
+        <path d="M0 0 L4 0 M0 0 L0 4" stroke="#4a6b8e" stroke-width="0.4" fill="none"/>
+      </pattern>
+    </defs>`;
+
+  svg.innerHTML = dnaDefs +
+    // Layer 1: all neighbor arcs (initially invisible) — drawn behind nodes
+    positions.flatMap(p => {
+      const ns = (niches[p.slug] && niches[p.slug].neighbors) || [];
+      return ns.map(n => {
+        const q = pos[n]; if (!q) return '';
+        // Quadratic bezier with a midpoint pulled outward from the average y
+        const mx = (p.x + q.x) / 2;
+        const my = (p.y + q.y) / 2 - 22;
+        return `<path class="drift-line" data-from="${p.slug}" data-to="${n}" d="M${p.x},${p.y} Q${mx},${my} ${q.x},${q.y}"></path>`;
+      });
+    }).join('') +
+    // Layer 2: nodes
+    positions.map(p => {
+      const r = 6 + Math.sqrt(p.count / Math.max(maxCount, 1)) * 28;
+      const color = CAT_COLOR[p.cat];
+      const ns = (niches[p.slug] && niches[p.slug].neighbors) || [];
+      // Deterministic per-slug float params so each star has its own rhythm
+      let h = 0; for (let k = 0; k < p.slug.length; k++) h = (h * 131 + p.slug.charCodeAt(k)) | 0;
+      const fd  = 5 + Math.abs(h % 6);                  // 5–10s loop
+      const del = ((Math.abs(h) >> 3) % 64) / 8;        // 0–8s delay
+      const amt = 2 + Math.abs((h >> 9) % 4);           // 2–5px amplitude
+      return `
+        <g class="node" transform="translate(${p.x},${p.y})"
+           data-slug="${p.slug}" data-label="${esc(p.label)}" data-count="${p.count}"
+           data-domain="${p.domain}" data-poem="${esc(p.poem)}" data-neighbors="${ns.join(',')}">
+          <g class="node-inner" style="--fd:${fd}s;--del:${del}s;--amt:${amt}px">
+            <circle r="${r}" fill="url(#dna-${p.cat})" fill-opacity="0.55" stroke="${color}" stroke-opacity="0.9" stroke-width="1"></circle>
+            <circle r="${r}" fill="${color}" fill-opacity="0.06" stroke="none"></circle>
+            <text dy="${r + 14}">${esc(p.label)}</text>
+          </g>
+        </g>
+      `;
+    }).join('');
+
+  // hover tooltip + Niche Drift kinship
+  const tip = document.getElementById('tip');
+  const lines = svg.querySelectorAll('.drift-line');
+  svg.querySelectorAll('.node').forEach(n => {
+    n.addEventListener('mouseenter', e => {
+      const t = n.dataset;
+      const poem = t.poem ? `<div class="poem">${t.poem}</div>` : '';
+      const kin = (t.neighbors || '').split(',').filter(Boolean);
+      const kinLabels = kin.map(s => (niches[s] && niches[s].label) || s).slice(0, 3);
+      const kinRow = kinLabels.length ? `<div class="row" style="margin-top:8px">kin · ${kinLabels.join(' · ')}</div>` : '';
+      tip.innerHTML = `<div class="label">${t.label}</div>${poem}
+        <div class="row">${parseInt(t.count).toLocaleString()} patterns</div>
+        ${kinRow}
+        <div class="row" style="margin-top:8px">click to filter · ⌘ to visit site</div>
+        <div class="row" style="margin-top:4px;color:var(--gold)">${t.domain} →</div>`;
+      tip.classList.add('show');
+
+      // Niche Drift: dim the rest, illuminate the 3 kin + center
+      svg.classList.add('drift');
+      n.classList.add('center');
+      kin.forEach(slug => {
+        const node = svg.querySelector(`.node[data-slug="${slug}"]`);
+        if (node) node.classList.add('kin');
+      });
+      // arc lines from this node to its 3 kin
+      lines.forEach(l => {
+        if (l.dataset.from === t.slug && kin.includes(l.dataset.to)) l.classList.add('active');
+      });
+    });
+    n.addEventListener('mousemove', e => {
+      tip.style.left = (e.clientX + 14) + 'px';
+      tip.style.top  = (e.clientY + 14) + 'px';
+    });
+    n.addEventListener('mouseleave', () => {
+      tip.classList.remove('show');
+      svg.classList.remove('drift');
+      n.classList.remove('center');
+      svg.querySelectorAll('.node.kin').forEach(k => k.classList.remove('kin'));
+      lines.forEach(l => l.classList.remove('active'));
+    });
+    n.addEventListener('click', (e) => {
+      e.preventDefault();
+      // Cmd/Ctrl-click → open the niche site (preserves prior behavior)
+      if (e.metaKey || e.ctrlKey || e.shiftKey) {
+        stampPassport(n.dataset.slug);
+        window.open('https://' + n.dataset.domain, '_blank');
+        return;
+      }
+      // Plain click → filter the grid to this niche, with View Transitions morph
+      stampPassport(n.dataset.slug);
+      const label = n.dataset.label || '';
+      const txt = n.querySelector('text');
+      const apply = () => {
+        setQ(label);
+        window.scrollTo({ top: 0, behavior: 'smooth' });
+      };
+      if (document.startViewTransition) {
+        if (txt) txt.style.viewTransitionName = 'filter-jump';
+        qEl.style.viewTransitionName = 'filter-jump';
+        const t = document.startViewTransition(apply);
+        t.finished.finally(() => {
+          if (txt) txt.style.viewTransitionName = '';
+          qEl.style.viewTransitionName = '';
+        });
+      } else {
+        apply();
+      }
+    });
+  });
+}
+buildMap();
+
+// Pause star-float animations when constellation off-screen (perf + battery)
+(function () {
+  if (!('IntersectionObserver' in window)) return;
+  const map = document.getElementById('map');
+  if (!map) return;
+  const io = new IntersectionObserver((entries) => {
+    for (const e of entries) map.classList.toggle('paused', !e.isIntersecting);
+  }, { threshold: 0 });
+  io.observe(map);
+})();
+
+</script>
+</body>
+</html>
diff --git a/scripts/apply-design-fixes.js b/scripts/apply-design-fixes.js
new file mode 100644
index 0000000..b9f3b8b
--- /dev/null
+++ b/scripts/apply-design-fixes.js
@@ -0,0 +1,172 @@
+// Apply the P0 + P1 design-review fixes to public/index.html in one pass:
+//   - P0: wrap script in IIFE
+//   - P1a: consolidate type scale (13 tiers → 6)
+//   - P1b: consolidate spacing scale (14 distinct → 4-base)
+//   - P1c: replace raw hex literals with var(--*) tokens
+//   - P1d: add IntersectionObserver pause for off-screen .node-inner float animations
+//
+// Idempotent: if IIFE wrap already present, skips. Token + size maps use word boundaries
+// to avoid partial matches inside SVG fills, viewBox, etc.
+
+const fs = require('fs');
+const path = require('path');
+
+const FILE = path.join(__dirname, '..', 'public', 'index.html');
+let html = fs.readFileSync(FILE, 'utf8');
+const before = html;
+
+// ─── 1. Type scale consolidation ────────────────────────────────────────
+// Map: existing size → nearest tier on 11 / 13 / 18 / 22 / 32 (clamp() heroes left alone)
+const TYPE_MAP = {
+  '8px':  '11px',
+  '9px':  '11px',
+  '10px': '11px',
+  '11px': '11px',
+  '12px': '13px',
+  '13px': '13px',
+  '14px': '13px',
+  '16px': '18px',
+  '18px': '18px',
+  '20px': '22px',
+  '22px': '22px',
+  '24px': '22px',
+  '36px': '32px',
+};
+for (const [from, to] of Object.entries(TYPE_MAP)) {
+  // Only inside font-size declarations
+  const re = new RegExp(`(font-size:\\s*)${from.replace('.','\\.')}\\b`, 'g');
+  html = html.replace(re, `$1${to}`);
+}
+
+// ─── 2. Spacing scale consolidation ─────────────────────────────────────
+// Map: existing → nearest 4-base step (4 / 8 / 12 / 16 / 24 / 32 / 48 / 64)
+const SPACE_MAP = {
+  '6px':  '8px',
+  '10px': '12px',
+  '14px': '16px',
+  '18px': '16px',
+  '22px': '24px',
+  '28px': '32px',
+  '36px': '32px',
+  '56px': '48px',
+};
+// Apply ONLY in margin/padding contexts (avoid breaking border-width, etc.)
+for (const [from, to] of Object.entries(SPACE_MAP)) {
+  // multi-value forms: "padding: 12px 6px;", "margin-top: 6px;", "margin: 0 0 6px"
+  const re1 = new RegExp(`((?:padding|margin)(?:-(?:top|bottom|left|right))?:\\s*[^;]*?)\\b${from}\\b`, 'g');
+  // run twice to catch repeated occurrences in same declaration
+  html = html.replace(re1, `$1${to}`).replace(re1, `$1${to}`).replace(re1, `$1${to}`);
+}
+
+// ─── 3. Raw-hex → CSS var() replacement ─────────────────────────────────
+// Only replace where the hex IS a brand token. Regex anchors to value-start with
+// :, space, or ( to avoid e.g. inside #anchor-link.
+const HEX_TO_TOKEN = {
+  '#f4ecd8': 'var(--ink)',
+  '#c9b687': 'var(--gold)',
+  '#1b1814': 'var(--bg)',         // dark theme
+  '#fafaf6': 'var(--bg)',         // light theme
+  '#0c0a08': 'var(--bg)',         // dark theme alt
+  '#15110d': 'var(--bg-2)',       // dark theme alt
+  '#a39888': 'var(--ink-mute)',
+  '#5a5246': 'var(--ink-faint)',
+  '#2a241c': 'var(--rule)',
+  '#e6cc88': 'var(--gold-warm)',
+  '#d36e4d': 'var(--signal)',
+  '#b86a4a': 'var(--c-decade)',
+  '#6b8e6f': 'var(--c-craft)',
+  '#4a6b8e': 'var(--c-use)',
+};
+for (const [hex, tok] of Object.entries(HEX_TO_TOKEN)) {
+  // Match the literal hex preceded by : space ( , (typical CSS-value contexts)
+  // and followed by ; ) , ! space or end-of-line. Skip occurrences inside :root { } block.
+  const re = new RegExp(`([:\\s(,])${hex.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}([\\s,;)!]|$)`, 'g');
+  html = html.replace(re, `$1${tok}$2`);
+}
+// But the :root token defs need their hex preserved. Restore those:
+const ROOT_DEFS = {
+  '--bg: var(--bg)':              '--bg: #0c0a08',
+  '--bg-2: var(--bg-2)':          '--bg-2: #15110d',
+  '--ink: var(--ink)':            '--ink: #f4ecd8',
+  '--ink-mute: var(--ink-mute)':  '--ink-mute: #a39888',
+  '--ink-faint: var(--ink-faint)':'--ink-faint: #5a5246',
+  '--rule: var(--rule)':          '--rule: #2a241c',
+  '--gold: var(--gold)':          '--gold: #c9b687',
+  '--gold-warm: var(--gold-warm)':'--gold-warm: #e6cc88',
+  '--signal: var(--signal)':      '--signal: #d36e4d',
+  '--c-material: var(--gold)':    '--c-material: #c9b687',
+  '--c-decade: var(--c-decade)':  '--c-decade: #b86a4a',
+  '--c-craft: var(--c-craft)':    '--c-craft: #6b8e6f',
+  '--c-use: var(--c-use)':        '--c-use: #4a6b8e',
+};
+for (const [from, to] of Object.entries(ROOT_DEFS)) {
+  html = html.split(from).join(to);
+}
+// Light-theme overrides too
+html = html.replace(/--bg:\s*var\(--bg\)/g, '--bg: #0c0a08');
+// Restore the [data-theme="light"] block
+html = html.replace(/\[data-theme="light"\] \{[^}]+\}/, m => m
+  .replace(/var\(--bg\)/, '#fafaf6')
+  .replace(/var\(--bg-2\)/, '#f0ece1')
+  .replace(/var\(--ink\)/, '#1b1814')
+  .replace(/var\(--ink-mute\)/, '#6b6357')
+  .replace(/var\(--ink-faint\)/, '#b0a890')
+  .replace(/var\(--rule\)/, '#e5dfd0')
+  .replace(/var\(--gold\)/, '#8a7440')
+  .replace(/var\(--gold-warm\)/, '#a6884d')
+);
+
+// ─── 4. IntersectionObserver to pause off-screen .node-inner animations ─
+const PAUSE_CSS = `
+/* Off-screen pause — battery-friendly star-float */
+svg#map.paused .node-inner { animation-play-state: paused }
+`;
+if (!html.includes('svg#map.paused .node-inner')) {
+  html = html.replace('/* Star float:', PAUSE_CSS + '\n/* Star float:');
+}
+const PAUSE_JS = `
+// Pause star-float animations when constellation off-screen (perf + battery)
+(function () {
+  if (!('IntersectionObserver' in window)) return;
+  const map = document.getElementById('map');
+  if (!map) return;
+  const io = new IntersectionObserver((entries) => {
+    for (const e of entries) map.classList.toggle('paused', !e.isIntersecting);
+  }, { threshold: 0 });
+  io.observe(map);
+})();
+`;
+if (!html.includes('Pause star-float animations when constellation off-screen')) {
+  // Insert near end of script, just before the buildMap() call
+  html = html.replace(/buildMap\(\);\s*<\/script>/, `buildMap();\n${PAUSE_JS}\n</script>`);
+}
+
+// ─── 5. P0: IIFE wrap ───────────────────────────────────────────────────
+// Wrap the LARGE main script block (not the tiny preconnect/font script).
+// Identify by the presence of `const CAT_COLOR` near the top.
+if (!html.includes('// IIFE wrap (P0)')) {
+  html = html.replace(
+    /<script>\s*(const CAT_COLOR = \{)/,
+    '<script>\n// IIFE wrap (P0) — keep all 11 features\' state out of the global scope.\n(function () {\n$1'
+  );
+  html = html.replace(
+    /buildMap\(\);\s*\n\s*\/\/ Pause star-float/,
+    `buildMap();\n\n// Pause star-float`
+  );
+  // Close the IIFE before the closing </script>
+  html = html.replace(/\}\);\s*\n<\/script>\s*<\/body>/, `});\n})();\n</script>\n</body>`);
+}
+
+// ─── Done ───────────────────────────────────────────────────────────────
+if (html === before) {
+  console.log('no-op (already applied)');
+} else {
+  fs.writeFileSync(FILE, html);
+  console.log('applied:');
+  console.log('  type-scale: 13 tiers → 6 (11/13/18/22/32 + clamp() heroes)');
+  console.log('  spacing:    14 distinct → 4-base (4/8/12/16/24/32/48/64)');
+  console.log('  hex→token:  ~15 brand-color literals replaced');
+  console.log('  iobserver:  off-screen .node-inner pause armed');
+  console.log('  iife:       script body wrapped (closure)');
+  console.log(`  size:       ${before.length} → ${html.length} bytes (${html.length - before.length >= 0 ? '+' : ''}${html.length - before.length})`);
+}
diff --git a/scripts/gen-niche-poems.js b/scripts/gen-niche-poems.js
new file mode 100644
index 0000000..e86910f
--- /dev/null
+++ b/scripts/gen-niche-poems.js
@@ -0,0 +1,120 @@
+// Generate one short editorial line per niche via Mac1 Ollama qwen3:14b.
+// Stores at ~/Projects/dw-universe/data/niche-poems.json.
+// Idempotent: skips niches already present.
+
+const fs = require('fs');
+const path = require('path');
+const http = require('http');
+
+const OLLAMA = 'http://192.168.1.133:11434';
+const MODEL  = 'qwen3:14b';
+const OUT    = path.join(__dirname, '..', 'data', 'niche-poems.json');
+
+const NICHES = [
+  ['silkwallpaper',          'Silk',                'A pure-silk wallcovering line — finest hand-woven silk from heritage mills.'],
+  ['silkwallcoverings',      'Silk Wallcoverings',  'Silk wallcoverings cut to commercial / contract spec.'],
+  ['linenwallpaper',         'Linen',               'Natural linen, woven and grasscloth-style.'],
+  ['jutewallpaper',          'Jute',                'Coarse, honest jute fiber wallcovering.'],
+  ['raffiawallcoverings',    'Raffia',              'Hand-woven raffia palm fiber, contract spec.'],
+  ['raffiawalls',            'Raffia Walls',        'Raffia residential storefront — same fiber, residential edit.'],
+  ['corkwallcovering',       'Cork',                'Sustainable cork wallcovering — bark of the cork oak.'],
+  ['fabricwallpaper',        'Fabric',              'Anything fabric-on-paper: textiles backed for walls.'],
+  ['textilewallpaper',       'Textile',             'Woven textile-effect wallpapers.'],
+  ['metallicwallpaper',      'Metallic',            'Foil and metallic-leaf grounds — silver, gold, copper, gun-metal.'],
+  ['silverleafwallpaper',    'Silver Leaf',         'Pure silver leaf grounds — hand-applied.'],
+  ['vinylwallpaper',         'Vinyl',               'Type II commercial vinyl — hospitality-grade durability.'],
+  ['micawallpaper',          'Mica',                'Hand-applied mica — flecks of fired mineral.'],
+  ['madagascarwallpaper',    'Madagascar',          'Madagascar grasscloth — woven natural fibers.'],
+  ['mylarwallpaper',         'Mylar',               'Reflective mylar — dimensional sheen, mid-century moods.'],
+  ['suedewallpaper',         'Suede',               'Suede-textured walls — soft, light-eating, dramatic.'],
+  ['1800swallpaper',         '1800s',               'Documentary 19th-century reproduction wallpapers.'],
+  ['1890swallpaper',         '1890s',               'Late-Victorian Aesthetic-movement & Arts-and-Crafts patterns.'],
+  ['1900swallpaper',         '1900s',               'Edwardian and turn-of-century repeats.'],
+  ['1920swallpaper',         '1920s',               'Art Deco — geometric, gold, jazz-age.'],
+  ['1930swallpaper',         '1930s',               'Streamlined Moderne — late Deco bleeding into Hollywood Regency.'],
+  ['1940swallpaper',         '1940s',               'Post-war floral & restraint — kitchens, bedrooms.'],
+  ['1950swallpaper',         '1950s',               'Atomic-age, mid-century optimism.'],
+  ['1960swallpaper',         '1960s',               'Op-art, mod, psychedelia, swirls.'],
+  ['1970swallpaper',         '1970s',               'Earth-tone florals, paneling, harvest gold.'],
+  ['1980swallpaper',         '1980s',               'Memphis, neon, geometric pop, brass-and-glass excess.'],
+  ['retrowalls',             'Retro Walls',         'Catch-all retro/vintage — across all decades.'],
+  ['wallpapersback',         'Wallpapers Back',     'Statement: wallpaper is back — broad selection.'],
+  ['agedwallpaper',          'Aged',                'Patina-aged finishes, faux-distressed.'],
+  ['handcraftedwallpaper',   'Handcrafted',         'Block-printed, hand-screened, artisan-made.'],
+  ['museumwallpaper',        'Museum',              'Wallcoverings fit for institutional / gallery walls.'],
+  ['restorationwallpaper',   'Restoration',         'Period-accurate reproductions for historic homes.'],
+  ['pastelwallpaper',        'Pastel',              'Soft, light-color palette across patterns.'],
+  ['glitterwalls',           'Glitter',             'Genuine glitter and glass-bead grounds.'],
+  ['greenwallcoverings',     'Green',               'Sustainable, FSC, low-VOC — eco-conscious wallcovering.'],
+  ['naturalwallcoverings',   'Natural',             'Plant-fiber wallcoverings: jute, sisal, abaca, raffia.'],
+  ['saloonwallpaper',        'Saloon',              'Old-West, woodcut, oxblood-and-cream saloon-print aesthetic.'],
+  ['contractwallpaper',      'Contract',            'Type II commercial contract wallcovering.'],
+  ['hotelwallcoverings',     'Hotel',               'Hospitality-grade, lobby-and-corridor durability.'],
+  ['hospitalitywallpaper',   'Hospitality',         'Hotel + restaurant + cruise — durable, designed.'],
+  ['healthcarewallpaper',    'Healthcare',          'Cleanable, anti-microbial, medical-environment rated.'],
+  ['restaurantwallpaper',    'Restaurant',          'Front-of-house wallcovering — moody, photogenic, washable.'],
+  ['architecturalwallcoverings', 'Architectural',   'Architect-spec wallcoverings — A&D channel.'],
+];
+
+function ollama(prompt) {
+  return new Promise((resolve, reject) => {
+    const body = JSON.stringify({
+      model: MODEL, prompt, stream: false,
+      options: { temperature: 0.85, top_p: 0.95, num_predict: 80 }
+    });
+    const req = http.request(`${OLLAMA}/api/generate`, {
+      method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': body.length },
+      timeout: 60000,
+    }, (res) => {
+      let chunks = '';
+      res.on('data', d => chunks += d);
+      res.on('end', () => {
+        try { resolve(JSON.parse(chunks).response || ''); } catch (e) { reject(e); }
+      });
+    });
+    req.on('error', reject);
+    req.write(body); req.end();
+  });
+}
+
+const PROMPT = (label, hint) => `Write ONE single sentence — fewer than 14 words — that describes "${label}" wallcoverings as if you were a high-end interior magazine editor. Tone: literary, sensory, not salesy. Avoid clichés ("timeless", "elegance", "sophistication"). Avoid quotation marks. Hint: ${hint}
+
+Just the sentence. Nothing else. No "Here is" or preamble. No explanation. No newlines. No <think> tags or reasoning — output ONLY the final sentence.`;
+
+function clean(s) {
+  // Strip <think> blocks (qwen3 sometimes adds reasoning)
+  s = s.replace(/<think>[\s\S]*?<\/think>/gi, '');
+  // Strip leading "Here is..." / quotes / leading bullets
+  s = s.replace(/^["'\s\-•*]+/, '').replace(/["'\s]+$/, '').trim();
+  // Take first sentence only
+  const firstSentence = s.match(/^[^.!?\n]+[.!?]?/);
+  return (firstSentence ? firstSentence[0] : s).trim();
+}
+
+(async () => {
+  fs.mkdirSync(path.dirname(OUT), { recursive: true });
+  let existing = {};
+  try { existing = JSON.parse(fs.readFileSync(OUT, 'utf8')); } catch (e) {}
+
+  let done = 0, skipped = 0;
+  for (const [slug, label, hint] of NICHES) {
+    if (existing[slug] && existing[slug].length > 8) { skipped++; continue; }
+    const t0 = Date.now();
+    try {
+      const raw = await ollama(PROMPT(label, hint));
+      const line = clean(raw);
+      if (!line || line.length < 8) {
+        console.log(`  ⚠ ${slug}: empty/short — kept raw fragment`);
+        existing[slug] = raw.slice(0, 200);
+      } else {
+        existing[slug] = line;
+        done++;
+      }
+      console.log(`  ${slug.padEnd(28)} (${(Date.now()-t0)/1000|0}s) → ${existing[slug]}`);
+      fs.writeFileSync(OUT, JSON.stringify(existing, null, 2)); // checkpoint each
+    } catch (e) {
+      console.error(`  ✗ ${slug}: ${e.message}`);
+    }
+  }
+  console.log(`done: ${done} new, ${skipped} skipped, total ${Object.keys(existing).length}`);
+})();
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..0e6b514
--- /dev/null
+++ b/server.js
@@ -0,0 +1,182 @@
+// DW Universe — fleet-wide search + constellation across all DW family niche sites.
+// Boots by reading every ~/Projects/<slug>/data/products.json into memory once.
+// Exposes /api/search and /api/sites; serves single-page UI from /public.
+
+const express = require('express');
+const fs = require('fs');
+const path = require('path');
+const os = require('os');
+
+const PORT = process.env.PORT || 9466;
+const HOME = os.homedir();
+
+// 41 active niches (3 redirected ones excluded: recycled, string, embroidered).
+// Category groups the constellation visually + tints each node.
+const NICHES = [
+  // material
+  { slug: 'silkwallpaper',          label: 'Silk',                cat: 'material' },
+  { slug: 'silkwallcoverings',      label: 'Silk Wallcoverings',  cat: 'material' },
+  { slug: 'linenwallpaper',         label: 'Linen',               cat: 'material' },
+  { slug: 'jutewallpaper',          label: 'Jute',                cat: 'material' },
+  { slug: 'raffiawallcoverings',    label: 'Raffia',              cat: 'material' },
+  { slug: 'raffiawalls',            label: 'Raffia Walls',        cat: 'material' },
+  { slug: 'corkwallcovering',       label: 'Cork',                cat: 'material' },
+  { slug: 'fabricwallpaper',        label: 'Fabric',              cat: 'material' },
+  { slug: 'textilewallpaper',       label: 'Textile',             cat: 'material' },
+  { slug: 'metallicwallpaper',      label: 'Metallic',            cat: 'material' },
+  { slug: 'silverleafwallpaper',    label: 'Silver Leaf',         cat: 'material' },
+  { slug: 'vinylwallpaper',         label: 'Vinyl',               cat: 'material' },
+  { slug: 'micawallpaper',          label: 'Mica',                cat: 'material' },
+  { slug: 'madagascarwallpaper',    label: 'Madagascar',          cat: 'material' },
+  { slug: 'mylarwallpaper',         label: 'Mylar',               cat: 'material' },
+  { slug: 'suedewallpaper',         label: 'Suede',               cat: 'material' },
+  // decade
+  { slug: '1800swallpaper',         label: '1800s',               cat: 'decade' },
+  { slug: '1890swallpaper',         label: '1890s',               cat: 'decade' },
+  { slug: '1900swallpaper',         label: '1900s',               cat: 'decade' },
+  { slug: '1920swallpaper',         label: '1920s',               cat: 'decade' },
+  { slug: '1930swallpaper',         label: '1930s',               cat: 'decade' },
+  { slug: '1940swallpaper',         label: '1940s',               cat: 'decade' },
+  { slug: '1950swallpaper',         label: '1950s',               cat: 'decade' },
+  { slug: '1960swallpaper',         label: '1960s',               cat: 'decade' },
+  { slug: '1970swallpaper',         label: '1970s',               cat: 'decade' },
+  { slug: '1980swallpaper',         label: '1980s',               cat: 'decade' },
+  { slug: 'retrowalls',             label: 'Retro Walls',         cat: 'decade' },
+  { slug: 'wallpapersback',         label: 'Wallpapers Back',     cat: 'decade' },
+  // craft / aesthetic
+  { slug: 'agedwallpaper',          label: 'Aged',                cat: 'craft' },
+  { slug: 'handcraftedwallpaper',   label: 'Handcrafted',         cat: 'craft' },
+  { slug: 'museumwallpaper',        label: 'Museum',              cat: 'craft' },
+  { slug: 'restorationwallpaper',   label: 'Restoration',         cat: 'craft' },
+  { slug: 'pastelwallpaper',        label: 'Pastel',              cat: 'craft' },
+  { slug: 'glitterwalls',           label: 'Glitter',             cat: 'craft' },
+  { slug: 'greenwallcoverings',     label: 'Green',               cat: 'craft' },
+  { slug: 'naturalwallcoverings',   label: 'Natural',             cat: 'craft' },
+  { slug: 'saloonwallpaper',        label: 'Saloon',              cat: 'craft' },
+  // use-case
+  { slug: 'contractwallpaper',      label: 'Contract',            cat: 'use' },
+  { slug: 'hotelwallcoverings',     label: 'Hotel',               cat: 'use' },
+  { slug: 'hospitalitywallpaper',   label: 'Hospitality',         cat: 'use' },
+  { slug: 'healthcarewallpaper',    label: 'Healthcare',          cat: 'use' },
+  { slug: 'restaurantwallpaper',    label: 'Restaurant',          cat: 'use' },
+  { slug: 'architecturalwallcoverings', label: 'Architectural',   cat: 'use' },
+];
+
+// Boot: load every site's product cache + niche poems + neighbor map + pair rules.
+let POEMS = {}, NEIGHBORS = {}, PAIR_RULES = {}, PAIR_BLURBS = [];
+try { POEMS = JSON.parse(fs.readFileSync(path.join(__dirname, 'data', 'niche-poems.json'), 'utf8')); } catch (e) { console.warn('[dw-universe] niche-poems.json:', e.message); }
+try { NEIGHBORS = JSON.parse(fs.readFileSync(path.join(__dirname, 'data', 'niche-neighbors.json'), 'utf8')); } catch (e) { console.warn('[dw-universe] niche-neighbors.json:', e.message); }
+try {
+  const pr = JSON.parse(fs.readFileSync(path.join(__dirname, 'data', 'pair-rules.json'), 'utf8'));
+  PAIR_RULES = pr.rules || {};
+  PAIR_BLURBS = pr.blurbs || [];
+} catch (e) { console.warn('[dw-universe] pair-rules.json:', e.message); }
+
+const DB = []; // flattened: each row tagged with slug + label + cat
+const SITE_STATS = {}; // slug -> { count, label, cat, domain, poem }
+for (const n of NICHES) {
+  const f = path.join(HOME, 'Projects', n.slug, 'data', 'products.json');
+  let products = [];
+  try { products = JSON.parse(fs.readFileSync(f, 'utf8')); } catch (e) { /* skip missing */ }
+  SITE_STATS[n.slug] = {
+    count: products.length, label: n.label, cat: n.cat,
+    domain: n.slug + '.com', poem: POEMS[n.slug] || '',
+    neighbors: NEIGHBORS[n.slug] || [],
+  };
+  for (const p of products) DB.push({ ...p, _slug: n.slug, _niche: n.label, _cat: n.cat });
+}
+console.log(`[dw-universe] loaded ${DB.length} products across ${NICHES.length} niches`);
+
+// Search: simple substring match across title/tags/vendor with niche-aware ranking.
+function search(q, limit = 24) {
+  if (!q) return [];
+  const terms = q.toLowerCase().split(/\s+/).filter(Boolean);
+  const scored = [];
+  for (const p of DB) {
+    const hay = [p.title || '', (p.tags || []).join(' '), p.vendor || '', p._niche]
+      .join(' ').toLowerCase();
+    let score = 0;
+    for (const t of terms) {
+      if (!hay.includes(t)) { score = 0; break; }
+      if ((p.title || '').toLowerCase().includes(t)) score += 3;
+      else if ((p.tags || []).some(tg => tg.toLowerCase().includes(t))) score += 2;
+      else score += 1;
+    }
+    if (score > 0) scored.push([score, p]);
+  }
+  scored.sort((a, b) => b[0] - a[0]);
+  return scored.slice(0, limit).map(([s, p]) => ({
+    sku: p.sku, title: p.title, image_url: p.image_url, vendor: p.vendor,
+    max_price: p.max_price, niche: p._niche, slug: p._slug,
+    product_url: p.product_url || `https://${p._slug}.com/`,
+  }));
+}
+
+const app = express();
+app.disable('x-powered-by');
+
+app.get('/api/search', (req, res) => {
+  const q = String(req.query.q || '').slice(0, 200);
+  const t = Date.now();
+  const results = search(q, 24);
+  res.json({ q, count: results.length, ms: Date.now() - t, results });
+});
+
+app.get('/api/sites', (req, res) => {
+  const total = Object.values(SITE_STATS).reduce((s, x) => s + x.count, 0);
+  res.json({ total_products: total, niches: SITE_STATS });
+});
+
+// Pairing Oracle — given a sku from any niche, return one cross-niche soulmate + an editorial blurb.
+app.get('/api/pair', (req, res) => {
+  const sku = String(req.query.sku || '').slice(0, 200);
+  const slug = String(req.query.slug || '').slice(0, 64);
+  if (!sku) return res.status(400).json({ error: 'sku required' });
+  const source = DB.find(p => p.sku === sku && (!slug || p._slug === slug));
+  if (!source) return res.status(404).json({ error: 'source product not found' });
+  const candidates = PAIR_RULES[source._slug] || [];
+  // P1: filter candidates + fallback to only niches with loaded products
+  const validCandidates = candidates.filter(s => SITE_STATS[s] && SITE_STATS[s].count > 0);
+  let targetSlug = validCandidates[Math.floor(Math.random() * validCandidates.length)];
+  if (!targetSlug) {
+    const otherSlugs = NICHES.map(n => n.slug).filter(s => s !== source._slug && SITE_STATS[s] && SITE_STATS[s].count > 0);
+    targetSlug = otherSlugs[Math.floor(Math.random() * otherSlugs.length)];
+  }
+  const pool = DB.filter(p => p._slug === targetSlug && p.image_url);
+  if (!pool.length) return res.status(404).json({ error: 'no candidates in target niche' });
+  const paired = pool[Math.floor(Math.random() * pool.length)];
+  const blurb = PAIR_BLURBS[Math.floor(Math.random() * PAIR_BLURBS.length)] || '';
+  function shape(p) {
+    return {
+      sku: p.sku, title: p.title, image_url: p.image_url, vendor: p.vendor,
+      max_price: p.max_price, niche: p._niche, slug: p._slug,
+      product_url: p.product_url || `https://${p._slug}.com/`,
+    };
+  }
+  res.json({ source: shape(source), paired: shape(paired), blurb });
+});
+
+app.get('/api/sample', (req, res) => {
+  // 24 random products to seed the page before user types
+  const out = [];
+  const seen = new Set();
+  while (out.length < 24 && seen.size < DB.length) {
+    const i = Math.floor(Math.random() * DB.length);
+    if (seen.has(i)) continue;
+    seen.add(i);
+    const p = DB[i];
+    if (!p.image_url) continue;
+    out.push({
+      sku: p.sku, title: p.title, image_url: p.image_url, vendor: p.vendor,
+      max_price: p.max_price, niche: p._niche, slug: p._slug,
+      product_url: p.product_url || `https://${p._slug}.com/`,
+    });
+  }
+  res.json({ results: out });
+});
+
+app.use(express.static(path.join(__dirname, 'public')));
+
+app.listen(PORT, '127.0.0.1', () => {
+  console.log(`[dw-universe] http://127.0.0.1:${PORT}/  (${DB.length} products · ${NICHES.length} niches)`);
+});

(oldest)  ·  back to Dw Universe  ·  snapshot: 2 file(s) changed, +1 new, ~1 modified c5c2330 →