← 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 => ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;',"'":'&#39;'}[c])); }
function setQ(v) { qEl.value = v; qEl.dispatchEvent(new Event('input')); }

// === Slow-Reveal Bookmark ===
// Each visit increments a counter, and milestone visits (3, 7, 14, 30) unlock
// a deeper editorial layer that only returning visitors see.
// Surfaced as an italic Playfair line under the hero, briefly visible on the milestone visit.
const SR_KEY = 'dw-visits';
const SR_LAST_KEY = 'dw-last-visit';
const REVEAL_TIERS = [
  // visit-count → editorial line (revealed on first visit AT or after that count)
  { at: 3,  line: 'You came back. We thought you might.' },
  { at: 7,  line: 'Seven visits. The catalog is starting to remember you.' },
  { at: 14, line: 'Two weeks of looking. You are circling something specific.' },
  { at: 30, line: 'A month in. Whatever room you are decorating, it is already half-furnished.' },
  { at: 60, line: 'You have been here longer than most clients. Try requesting a memo.' },
  { at: 100, line: 'One hundred visits. We know your taste better than you do by now.' }
];
function slowRevealTick() {
  let visits = parseInt(localStorage.getItem(SR_KEY) || '0', 10);
  visits += 1;
  localStorage.setItem(SR_KEY, String(visits));
  if (location.search) return;
  const tier = REVEAL_TIERS.slice().reverse().find(t => visits >= t.at && visits < t.at + 1);
  if (!tier) return;
  // P1: dedupe same-day reveals so a hard refresh doesn't redundantly fire
  const today = new Date().toISOString().slice(0, 10);
  const lastShown = localStorage.getItem(SR_LAST_KEY) || '';
  if (lastShown === `${tier.at}|${today}`) return;
  localStorage.setItem(SR_LAST_KEY, `${tier.at}|${today}`);
  // Insert under the hero, fade in after a beat, fade out after 12s
  setTimeout(() => {
    const lede = document.querySelector('header p.lead');
    if (!lede) return;
    const el = document.createElement('div');
    el.style.cssText = 'font-family: "Playfair Display", serif; font-style: italic; color: var(--gold); font-size: 18px; line-height: 1.5; margin-top: 16px; opacity: 0; transition: opacity 1.6s ease; max-width: 56ch';
    el.textContent = tier.line;
    lede.after(el);
    requestAnimationFrame(() => el.style.opacity = '1');
    setTimeout(() => { el.style.opacity = '0'; setTimeout(() => el.remove(), 1800); }, 12000);
  }, 2400);
}
slowRevealTick();

// === Velvet Rope ===
// First-visit ritual: a single-line riddle gates the catalog.
// Three letters typed -> unlock. Skippable via "skip the door".
// Records the user's "first whisper" in localStorage as a sentimental artifact.
const VR_KEY = 'dw-velvet-entered';
const VR_WHISPER_KEY = 'dw-first-whisper';
function velvetMaybeShow() {
  if (localStorage.getItem(VR_KEY)) return;
  // Skip if loaded with a query (deeplink, search, shared passport) — preserve the link's intent
  if (location.search) { localStorage.setItem(VR_KEY, '1'); return; }
  const v = document.getElementById('velvet');
  v.setAttribute('aria-hidden', 'false');
  v.classList.add('show');
  document.body.style.overflow = 'hidden';
  const inp = document.getElementById('velvetInput');
  const hint = document.getElementById('velvetHint');
  setTimeout(() => inp.focus(), 1500);

  // P2: auto-skip after 8s idle. The riddle is charming once; on a hurry it's friction.
  // Reset the timer on any keypress so genuine players don't get yanked through.
  let idleT = setTimeout(() => { if (!velvetUnlocked) velvetSkip(); }, 8000);
  inp.addEventListener('input', () => { clearTimeout(idleT); idleT = setTimeout(() => { if (!velvetUnlocked) velvetSkip(); }, 12000); });
  inp.addEventListener('input', () => {
    if (inp.value.trim().length >= 3) {
      hint.textContent = 'press enter — or just keep typing';
      hint.classList.add('ready');
    } else {
      hint.classList.remove('ready');
      hint.textContent = 'type three letters to enter';
    }
  });
  inp.addEventListener('keydown', (e) => {
    if (e.key === 'Enter' && inp.value.trim().length >= 3) {
      velvetUnlock(inp.value.trim());
    }
  });
  // also auto-unlock at 12+ chars (typed past the threshold without pressing enter)
  inp.addEventListener('input', () => {
    if (inp.value.trim().length >= 12) {
      setTimeout(() => velvetUnlock(inp.value.trim()), 800);
    }
  }, { once: false });
}
let velvetUnlocked = false;
function velvetUnlock(whisper) {
  if (velvetUnlocked) return;  // P0: prevent double-fire (Enter + 12-char auto)
  velvetUnlocked = true;
  if (whisper) localStorage.setItem(VR_WHISPER_KEY, whisper.slice(0, 200));
  localStorage.setItem(VR_KEY, '1');
  const v = document.getElementById('velvet');
  v.classList.add('dismiss');
  document.body.style.overflow = '';
  setTimeout(() => v.classList.remove('show', 'dismiss'), 1500);
  startOracle();  // P1: Oracle now starts ONLY after gate is open
}
function velvetSkip() {
  if (velvetUnlocked) return;
  velvetUnlocked = true;
  localStorage.setItem(VR_KEY, '1');
  const v = document.getElementById('velvet');
  v.classList.add('dismiss');
  document.body.style.overflow = '';
  setTimeout(() => v.classList.remove('show', 'dismiss'), 1500);
  startOracle();
}
velvetMaybeShow();
// If gate was dismissed previously (returning user) the maybeShow returns early → start oracle now
if (!document.getElementById('velvet').classList.contains('show')) startOracle();

// === Sample Passport ===
// localStorage-backed record of niches the user has visited (clicked into).
// Each niche click stamps the passport; the pill in the header shows the count.
// Shareable via ?passport=slug1,slug2 URL param (read-only mode for someone else's set).
const PP_KEY = 'dw-passport-v1';
function loadPassport() { try { return JSON.parse(localStorage.getItem(PP_KEY) || '{}'); } catch (e) { return {}; } }
function savePassport(p) { localStorage.setItem(PP_KEY, JSON.stringify(p)); refreshPassportPill(); }
function stampPassport(slug) {
  if (!slug || ppReadOnly) return;
  const p = loadPassport();
  if (!p[slug]) p[slug] = new Date().toISOString();
  savePassport(p);
}
function refreshPassportPill() {
  const n = Object.keys(loadPassport()).length;
  const c = document.getElementById('ppCount');
  if (c) c.textContent = `Passport · ${n}`;
}
let ppReadOnly = false;
let ppViewing = null; // {slugs:[], from:'shared'}

(function initPassportFromURL() {
  const params = new URLSearchParams(location.search);
  const shared = params.get('passport');
  if (shared) {
    const slugs = shared.split(',').filter(Boolean).slice(0, 50);
    ppViewing = { slugs, from: 'shared' };
    ppReadOnly = true;
  }
  refreshPassportPill();
})();

function openPassport() {
  const overlay = document.getElementById('passportOverlay');
  const grid = document.getElementById('ppGrid');
  const sub = document.getElementById('ppSub');
  // build grid using the niches data already fetched for the constellation
  fetch('api/sites').then(r => r.json()).then(j => {
    const niches = j.niches;
    const slugs = Object.keys(niches);
    const visited = ppReadOnly && ppViewing ? new Set(ppViewing.slugs) : new Set(Object.keys(loadPassport()));
    const visitedDates = ppReadOnly ? {} : loadPassport();
    sub.textContent = ppReadOnly
      ? `${visited.size} of ${slugs.length} · viewing a shared passport`
      : `${visited.size} of ${slugs.length} niches stamped`;
    grid.innerHTML = slugs.sort((a, b) => (niches[a].label || a).localeCompare(niches[b].label || b)).map(slug => {
      const n = niches[slug];
      const isVisited = visited.has(slug);
      const ts = visitedDates[slug] ? new Date(visitedDates[slug]).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '';
      const cat = n.cat;
      const colorVar = `var(--c-${cat})`;
      // simple seal: filled circle with category color, plus a small inner mark for visited
      const sealSvg = `
        <svg class="seal-svg" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" style="color: ${colorVar}">
          <circle cx="50" cy="50" r="42" fill="currentColor" fill-opacity="${isVisited ? 0.32 : 0.06}" stroke="currentColor" stroke-opacity="${isVisited ? 1 : 0.5}" stroke-width="2"/>
          ${isVisited ? `<path d="M30 52 L44 66 L72 36" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/>` : ''}
        </svg>`;
      return `<div class="stamp${isVisited ? ' visited' : ''}" onclick="window.open('https://${n.domain}','_blank','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>