← back to Dw Universe
initial scaffold (gitify-all 2026-05-06)
d109d7589cb07b0ff35786252389d0895aaa6677 · 2026-05-06 10:25:17 -0700 · Steve Abrams
Files touched
A .gitignoreA CHANGELOG.mdA data/niche-neighbors.jsonA data/niche-poems.jsonA data/pair-rules.jsonA package-lock.jsonA package.jsonA public/favicon.svgA public/index.htmlA scripts/apply-design-fixes.jsA scripts/gen-niche-poems.jsA server.js
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 => ({'<':'<','>':'>','&':'&','"':'"',"'":'''}[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 →