← back to Ventura Corridor

public/headlines.html

273 lines

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Ventura Boulevard · the wall of voices</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<script>(function(){try{var t=localStorage.getItem('vc-theme');document.documentElement.dataset.theme=(t==='light'||t==='dark')?t:'dark';}catch(e){document.documentElement.dataset.theme='dark';}})();</script>
<link rel="stylesheet" href="/theme.css">
<script src="/theme.js" defer></script>
<style>
  :root {
    --noir: #0a0a0c; --noir-rise: #131316; --ink: #f0ece2;
    --ink-mute: #888475; --metal: #b89968; --metal-glow: #d4b683;
    --rule: #2a2622;
    --serif: 'Cormorant Garamond', 'Times New Roman', serif;
    --sans: 'Inter', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
    --mono: 'JetBrains Mono', ui-monospace, monospace;
  }
  @import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,500;0,600;1,300;1,400;1,500&family=Inter:wght@200;300;400;600&display=swap');
  html, body { margin: 0; background: var(--noir); color: var(--ink); font-family: var(--sans); font-weight: 300; }
  * { box-sizing: border-box; }
  header {
    position: sticky; top: 0; z-index: 10;
    background: linear-gradient(180deg, rgba(10,10,12,.97) 70%, rgba(10,10,12,0));
    padding: 22px 40px 16px; border-bottom: 1px solid var(--rule);
    display: flex; justify-content: space-between; align-items: baseline; gap: 24px; flex-wrap: wrap;
  }
  header h1 { font-family: var(--serif); font-weight: 400; font-size: 28px; letter-spacing: -0.01em; margin: 0; }
  header h1 em { font-style: italic; color: var(--metal); }
  .sub { font-size: 11px; letter-spacing: .2em; text-transform: uppercase; color: var(--ink-mute); margin-top: 4px; }
  nav.tabs { display: flex; gap: 4px; flex-wrap: wrap; }
  nav.tabs a { font-size: 10px; letter-spacing: .26em; text-transform: uppercase;
    color: var(--ink-mute); text-decoration: none; padding: 6px 12px; border: 1px solid var(--rule); }
  nav.tabs a.active, nav.tabs a:hover { color: var(--metal); border-color: var(--metal); }

  .hero {
    padding: 80px 40px 64px; text-align: center;
    background: radial-gradient(ellipse at center top, rgba(184,153,104,0.08), transparent 70%);
    border-bottom: 1px solid var(--rule);
  }
  .hero h2 { font-family: var(--serif); font-weight: 300; font-style: italic; font-size: 56px; letter-spacing: -0.02em; margin: 0 0 14px; line-height: 1.05; }
  .hero h2 em { color: var(--metal); font-style: normal; }
  .hero p { color: var(--ink-mute); max-width: 640px; margin: 0 auto; font-family: var(--serif); font-style: italic; font-size: 16px; line-height: 1.55; }

  .controls { padding: 16px 40px; border-bottom: 1px solid var(--rule); display: flex; gap: 24px; align-items: center; flex-wrap: wrap; }
  .controls label { font-size: 10px; letter-spacing: .26em; text-transform: uppercase; color: var(--ink-mute); cursor: pointer; }
  .controls .pill {
    font-size: 10px; letter-spacing: .26em; text-transform: uppercase;
    padding: 6px 12px; border: 1px solid var(--rule); background: transparent; color: var(--ink-mute);
    cursor: pointer; transition: all 200ms ease;
  }
  .controls .pill.on { color: var(--metal); border-color: var(--metal); background: rgba(184,153,104,0.06); }
  .controls .pill:hover { color: var(--metal-glow); border-color: var(--metal-glow); }
  .controls input[type=search] {
    background: var(--noir-rise); border: 1px solid var(--rule); padding: 8px 14px;
    color: var(--ink); font-family: var(--sans); font-weight: 300; font-size: 13px;
    min-width: 240px;
  }
  .controls input[type=search]:focus { border-color: var(--metal); outline: none; }
  .controls .ct { margin-left: auto; font-size: 10px; letter-spacing: .26em; text-transform: uppercase; color: var(--ink-mute); }
  .controls .ct b { color: var(--metal); font-family: var(--serif); font-style: italic; font-size: 18px; font-weight: 400; letter-spacing: 0; margin-right: 8px; font-variant-numeric: tabular-nums; }

  .mural { padding: 60px 0 100px; }
  .voice {
    padding: 28px 40px; border-bottom: 1px solid var(--rule);
    display: grid; grid-template-columns: 220px 1fr 200px; gap: 32px; align-items: baseline;
    transition: background 200ms ease;
  }
  .voice:hover { background: var(--noir-rise); }
  .voice .who { color: var(--ink-mute); }
  .voice .who .nm { font-size: 11px; letter-spacing: .22em; text-transform: uppercase; color: var(--ink); margin-bottom: 4px; line-height: 1.3; }
  .voice .who .meta { font-family: var(--mono); font-size: 9px; letter-spacing: .15em; color: var(--ink-mute); }
  .voice .quote {
    font-family: var(--serif); font-weight: 300;
    font-size: 36px; line-height: 1.15; letter-spacing: -0.01em;
    color: var(--ink);
    word-break: break-word; hyphens: auto;
  }
  .voice .quote.title-derived { font-style: italic; color: var(--ink-mute); font-size: 28px; }
  .voice .quote.title-derived::before { content: '"'; color: var(--metal); }
  .voice .quote.title-derived::after { content: '"'; color: var(--metal); }
  .voice .quote.h1-derived em { color: var(--metal); font-style: italic; }
  .voice .src {
    text-align: right;
    font-family: var(--mono); font-size: 9px; letter-spacing: .2em; text-transform: uppercase; color: var(--ink-mute);
  }
  .voice .vtag {
    display: inline-block; font-size: 8px; letter-spacing: .26em; text-transform: uppercase;
    padding: 2px 6px; margin-bottom: 6px; border: 1px solid var(--rule); color: var(--ink-mute);
  }
  .voice.v-1 .vtag { border-color: var(--rule); color: var(--ink-mute); }
  .voice.v-2 .vtag { border-color: var(--rule-hot); color: var(--ink-mute); }
  .voice.v-3 .vtag { border-color: var(--metal); color: var(--metal); }
  .voice.v-4 .vtag { border-color: var(--metal-glow); color: var(--metal-glow); background: rgba(212,182,131,0.08); }
  .voice.v-1 .quote { color: var(--ink-mute); }
  .voice.v-4 .quote { color: var(--metal-glow); }
  .voice .src a { color: var(--metal); text-decoration: none; border-bottom: 1px solid transparent; }
  .voice .src a:hover { border-bottom-color: var(--metal); }
  .voice.steve { background: rgba(184,153,104,0.05); border-bottom: 1px solid var(--metal); }
  .voice.steve .quote::before { content: 'YOU · '; color: var(--metal); font-family: var(--sans); font-style: normal; font-size: 10px; letter-spacing: .26em; vertical-align: middle; padding-right: 12px; }

  /* Big-quote treatment: every 8th voice gets larger and centered */
  .voice.feature {
    display: block; text-align: center; padding: 60px 40px;
    background: linear-gradient(180deg, rgba(184,153,104,0.04), transparent 70%);
  }
  .voice.feature .quote { font-size: 64px; max-width: 900px; margin: 0 auto; line-height: 1.05; }
  .voice.feature .quote.title-derived { font-size: 44px; }
  .voice.feature .who { margin-bottom: 22px; }
  .voice.feature .who .nm { color: var(--metal); }
  .voice.feature .src { margin-top: 22px; }

  @media (max-width: 720px) {
    .voice { grid-template-columns: 1fr; padding: 22px 20px; gap: 12px; }
    .voice .quote { font-size: 24px; }
    .voice.feature .quote { font-size: 36px; }
    .voice .src { text-align: left; }
  }

  footer {
    padding: 24px 40px 40px; border-top: 1px solid var(--rule);
    font-family: var(--serif); font-style: italic; color: var(--ink-mute); font-size: 12px; text-align: center;
  }
  .empty { padding: 80px 32px; text-align: center; font-family: var(--serif); font-style: italic; color: var(--ink-mute); font-size: 18px; }
</style>
</head>
<body>

<header>
  <div>
    <h1>Ventura <em>Boulevard</em> · the wall of voices</h1>
    <div class="sub">every business's primary headline, sorted west-to-east</div>
  </div>
  <nav class="tabs" aria-label="Surface navigation">
    <a href="/">2D map</a>
    <a href="/3d.html">3D corridor</a>
    <a href="/timeline.html">Timeline</a>
    <a href="/pantone.html">Pantone</a>
    <a href="/headlines.html" class="active" aria-current="page">Voices</a>
    <a href="/tongues.html">Tongues</a>
    <a href="/chains.html">Chains</a>
    <a href="/atlas.html">Atlas</a>
    <a href="/eulogy.html">Eulogy</a>
    <span class="theme-toggle-host" style="display:inline-flex;align-items:center;margin-left:6px"></span>
  </nav>
</header>

<section class="hero">
  <h2>What does the boulevard <em>say</em>?</h2>
  <p>Each line is the primary headline scraped from one corridor business's homepage. Some declare. Some apologize. Some are stuck on default WordPress. Together, they are how Ventura speaks for itself.</p>
</section>

<section id="dict" style="padding:18px 40px;border-bottom:1px solid var(--rule);display:none">
  <div style="display:flex;align-items:baseline;justify-content:space-between;gap:24px;flex-wrap:wrap">
    <h3 style="font-family:var(--serif);font-style:italic;font-weight:400;font-size:18px;margin:0;color:var(--ink)">The boulevard's <em style="color:var(--metal)">vocabulary</em></h3>
    <div id="dict-meta" style="font-family:var(--mono);font-size:9px;letter-spacing:.18em;text-transform:uppercase;color:var(--ink-mute)">—</div>
  </div>
  <div id="dict-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:8px 24px;margin-top:14px"></div>
</section>

<div class="controls">
  <input type="search" id="q" placeholder="search headlines…" autocomplete="off">
  <span class="pill on" data-src="all">All sources</span>
  <span class="pill" data-src="h1">From &lt;h1&gt;</span>
  <span class="pill" data-src="title">From &lt;title&gt;</span>
  <span style="border-left:1px solid var(--rule);padding-left:24px;font-size:9px;letter-spacing:.26em;color:var(--ink-mute)">VOICE</span>
  <span class="pill voice on" data-voice="all">All</span>
  <span class="pill voice" data-voice="4">Exceptional</span>
  <span class="pill voice" data-voice="3">Sharp</span>
  <span class="pill voice" data-voice="2">Functional</span>
  <span class="pill voice" data-voice="1">Template</span>
  <span class="ct"><b id="ct">—</b> voices</span>
</div>

<section class="mural" id="mural"></section>

<footer>Verbatim text from each site's first &lt;h1&gt; or &lt;title&gt;. Voices unedited — a corridor in its own words.</footer>

<script>
const DW_ID = 17463;
let DATA = [];
let SRC_FILTER = 'all';
let VOICE_FILTER = 'all';
const VOICE_LABEL = { 1: 'TEMPLATE', 2: 'FUNCTIONAL', 3: 'SHARP', 4: 'EXCEPTIONAL' };

function escapeHtml(s) {
  return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}

function hostOnly(url) {
  if (!url) return '';
  try { return new URL(/^https?:/.test(url) ? url : 'https://' + url).hostname.replace(/^www\./, ''); }
  catch { return ''; }
}

function render() {
  const q = (document.getElementById('q').value || '').trim().toLowerCase();
  let rows = DATA;
  if (SRC_FILTER !== 'all') rows = rows.filter(r => r.headline_source === SRC_FILTER);
  if (VOICE_FILTER !== 'all') rows = rows.filter(r => String(r.headline_voice) === VOICE_FILTER);
  if (q) rows = rows.filter(r => {
    const hay = (r.headline + ' ' + r.name + ' ' + (r.city || '')).toLowerCase();
    return hay.includes(q);
  });
  document.getElementById('ct').textContent = rows.length;
  const mural = document.getElementById('mural');
  if (!rows.length) { mural.innerHTML = '<div class="empty">no voices match.</div>'; return; }
  mural.innerHTML = rows.map((r, i) => {
    const isFeature = i > 0 && i % 8 === 0;
    const isYou = String(r.id) === String(DW_ID);
    const cls = ['voice'];
    if (isFeature) cls.push('feature');
    if (isYou) cls.push('steve');
    if (r.headline_voice) cls.push('v-' + r.headline_voice);
    const qcls = r.headline_source === 'title' ? 'title-derived' : 'h1-derived';
    const host = hostOnly(r.website);
    const vtag = r.headline_voice ? `<div class="vtag">${VOICE_LABEL[r.headline_voice]}</div>` : '';
    return `
      <div class="${cls.join(' ')}">
        <div class="who">
          ${vtag}
          <div class="nm">${escapeHtml(r.name)}</div>
          <div class="meta">${escapeHtml(r.category || '')} · ${escapeHtml(r.city || '')}</div>
        </div>
        <div class="quote ${qcls}">${escapeHtml(r.headline)}</div>
        <div class="src">
          <span style="display:block;margin-bottom:4px">via ${escapeHtml(r.headline_source.replace('_', ' '))}</span>
          ${host ? `<a href="${r.website}" target="_blank" rel="noopener">${escapeHtml(host)} ↗</a>` : ''}
        </div>
      </div>`;
  }).join('');
}

// Two pill groups: source vs voice. Each group is exclusive within itself.
document.querySelectorAll('.controls .pill').forEach(p => {
  p.addEventListener('click', () => {
    const isVoice = p.classList.contains('voice');
    const groupSel = isVoice ? '.controls .pill.voice' : '.controls .pill:not(.voice)';
    document.querySelectorAll(groupSel).forEach(x => x.classList.remove('on'));
    p.classList.add('on');
    if (isVoice) VOICE_FILTER = p.dataset.voice;
    else SRC_FILTER = p.dataset.src;
    render();
  });
});
document.getElementById('q').addEventListener('input', () => { clearTimeout(window._t); window._t = setTimeout(render, 120); });

// Vocabulary panel
fetch('/api/dictionary').then(r => r.json()).then(d => {
  if (!d.bigrams || !d.bigrams.length) return;
  const sec = document.getElementById('dict');
  const grid = document.getElementById('dict-grid');
  document.getElementById('dict-meta').textContent = `${d.total_headlines} headlines · top phrases`;
  const topBigrams = d.bigrams.slice(0, 18);
  grid.innerHTML = topBigrams.map(b =>
    `<div style="display:flex;align-items:baseline;gap:10px;padding:6px 0;border-bottom:1px dotted var(--rule)">
      <span style="font-family:var(--serif);font-style:italic;font-size:18px;color:var(--metal);font-variant-numeric:tabular-nums;min-width:32px">${b.count}</span>
      <span style="font-size:13px;color:var(--ink);text-transform:lowercase">${escapeHtml(b.phrase)}</span>
    </div>`
  ).join('');
  sec.style.display = '';
}).catch(()=>{});

fetch('/api/headlines').then(r => r.json()).then(d => {
  DATA = d.rows || [];
  render();
}).catch(e => {
  document.getElementById('mural').innerHTML = '<div class="empty">couldn\\'t load voices: ' + escapeHtml(e.message) + '</div>';
});
</script>
</body>
</html>