← 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 <h1></span>
<span class="pill" data-src="title">From <title></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 <h1> or <title>. 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>