← back to Dw Universe
public/index.html
1068 lines
<!doctype html>
<html lang="en">
<head>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<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" rel="noopener noreferrer">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','noopener')" 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', 'noopener');
}
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', 'noopener');
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>