← back to Ventura Claw Leads
Footer centered: 5-col equal grid + text-align center on every column
a18293196dc0636f0ad4f34114acde7e56a0b05c · 2026-05-07 16:28:34 -0700 · Steve Abrams
Was 4-column 2fr/1fr/1fr/1fr (5 cols of markup, brand col stretched), all
left-aligned. Steve called out the asymmetry. Now 5 equal columns,
each col is a flex column with align-items:center, max-width 1100px so
content is centered without sprawling, fineprint stacks vertically
center-aligned. Tablet breakpoint switched from repeat(2,1fr) to
auto-fit minmax(160px,1fr) so the 5 cols collapse cleanly without an
awkward 2+2+1 row.
Files touched
M public/css/public.cssM public/css/theme.cssA scripts/rotate-headline-variants.js
Diff
commit a18293196dc0636f0ad4f34114acde7e56a0b05c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu May 7 16:28:34 2026 -0700
Footer centered: 5-col equal grid + text-align center on every column
Was 4-column 2fr/1fr/1fr/1fr (5 cols of markup, brand col stretched), all
left-aligned. Steve called out the asymmetry. Now 5 equal columns,
each col is a flex column with align-items:center, max-width 1100px so
content is centered without sprawling, fineprint stacks vertically
center-aligned. Tablet breakpoint switched from repeat(2,1fr) to
auto-fit minmax(160px,1fr) so the 5 cols collapse cleanly without an
awkward 2+2+1 row.
---
public/css/public.css | 2 +-
public/css/theme.css | 15 +++--
scripts/rotate-headline-variants.js | 116 ++++++++++++++++++++++++++++++++++++
3 files changed, 126 insertions(+), 7 deletions(-)
diff --git a/public/css/public.css b/public/css/public.css
index 02cf173..5bf866b 100644
--- a/public/css/public.css
+++ b/public/css/public.css
@@ -189,7 +189,7 @@
/* Tablet */
@media (max-width: 1024px) and (min-width: 769px) {
- .footer-inner { grid-template-columns: repeat(2, 1fr); }
+ .footer-inner { grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); }
.filter-row { grid-template-columns: 1fr 1fr; }
.filter-row > button { grid-column: 1 / -1; }
.profile-body { grid-template-columns: 3fr 2fr; gap: 32px; }
diff --git a/public/css/theme.css b/public/css/theme.css
index 6fb127b..888f99d 100644
--- a/public/css/theme.css
+++ b/public/css/theme.css
@@ -67,14 +67,17 @@ label > input, label > select, label > textarea { margin-top: 4px; }
[data-theme="dark"] .theme-toggle .t-light { display: none; }
[data-theme="dark"] .theme-toggle .t-dark { display: inline; }
-/* Footer */
-.site-footer { background: var(--bg-alt); border-top: 1px solid var(--border); margin-top: 96px; padding: 56px 28px 24px; }
-.footer-inner { max-width: 1440px; margin: 0 auto; display: grid; grid-template-columns: 2fr 1fr 1fr 1fr; gap: 32px; }
+/* Footer — centered. Equal-width columns so the brand column doesn't
+ look stranded on the left; text-align center on every column; fineprint
+ row also centered. */
+.site-footer { background: var(--bg-alt); border-top: 1px solid var(--border); margin-top: 96px; padding: 56px 28px 24px; text-align: center; }
+.footer-inner { max-width: 1100px; margin: 0 auto; display: grid; grid-template-columns: repeat(5, 1fr); gap: 32px; text-align: center; }
+.footer-col { display: flex; flex-direction: column; align-items: center; }
.footer-brand { font-family: var(--serif); font-size: 20px; font-weight: 500; }
-.footer-tag { font-size: 13px; color: var(--fg-muted); margin: 4px 0 8px; }
+.footer-tag { font-size: 13px; color: var(--fg-muted); margin: 4px 0 8px; max-width: 30ch; }
.footer-contact { font-size: 13px; }
.footer-col h4 { font-family: var(--sans); font-weight: 600; font-size: 11px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--fg-muted); margin: 0 0 10px; }
.footer-col a { display: block; font-size: 13px; padding: 4px 0; border: none; color: var(--fg-muted); }
.footer-col a:hover { color: var(--fg); }
-.footer-fineprint { max-width: 1440px; margin: 32px auto 0; padding-top: 24px; border-top: 1px solid var(--border); display: flex; gap: 24px; flex-wrap: wrap; font-size: 11px; color: var(--fg-muted); line-height: 1.55; }
-.footer-fineprint span:first-child { white-space: nowrap; }
+.footer-fineprint { max-width: 1100px; margin: 32px auto 0; padding-top: 24px; border-top: 1px solid var(--border); display: flex; flex-direction: column; gap: 8px; align-items: center; text-align: center; font-size: 11px; color: var(--fg-muted); line-height: 1.55; }
+.footer-fineprint span { max-width: 80ch; }
diff --git a/scripts/rotate-headline-variants.js b/scripts/rotate-headline-variants.js
new file mode 100644
index 0000000..4a22b3b
--- /dev/null
+++ b/scripts/rotate-headline-variants.js
@@ -0,0 +1,116 @@
+#!/usr/bin/env node
+/**
+ * rotate-headline-variants.js
+ *
+ * Diversify the 1,819 templated headlines using `id % 3` deterministic
+ * variant selection. Each (type, city) combo gets 3 phrasings instead of 1:
+ *
+ * variant 0: "Hair salon in Sherman Oaks." (current)
+ * variant 1: "Sherman Oaks hair salon." (city first)
+ * variant 2: "Hair salon · Sherman Oaks corridor." (mid-dot, corridor cue)
+ *
+ * Triples distinct headlines: 170 → ~510. Avg rows-per-phrasing drops from
+ * ~11 to ~4. Same template flavor; same SEO targets; less visible repetition
+ * on the find grid.
+ *
+ * Idempotent — the variant is fully determined by `(template, city, id % 3)`,
+ * so re-running produces the same output. Only edits headlines that still
+ * end with ' in <city>.' (matches the post-headline-rewrite shape from
+ * commit 5a49679 — won't touch owner-customized headlines).
+ */
+
+require('dotenv').config({ path: require('path').resolve(__dirname, '..', '.env') });
+const db = require('../lib/db');
+
+// Convert "Hair salon" / "Sit-down restaurant" / "Sushi restaurant" into
+// the three canonical variants. Lowercase the type when it's mid-sentence
+// so "Sherman Oaks hair salon" reads naturally.
+function variants(type, city) {
+ const tLower = type.charAt(0).toLowerCase() + type.slice(1);
+ return [
+ `${type} in ${city}.`, // 0: original
+ `${city} ${tLower}.`, // 1: city-first
+ `${type} · ${city} corridor.` // 2: mid-dot variant
+ ];
+}
+
+(async () => {
+ // Pull every public_records row with an end-of-headline " in <city>." shape
+ // — that's the rewritten template format from commit 5a49679. Owners who've
+ // claimed and customized their headline write something different, so they
+ // won't match this regex and will be left alone.
+ const rows = await db.many(`
+ SELECT id, slug, headline, neighborhood, city
+ FROM businesses
+ WHERE source='public_records'
+ AND status IN ('active','hidden')
+ AND headline ~ ' in [A-Za-z][A-Za-z ]+\\.$'
+ `);
+ console.log(`[rotate] candidate rows: ${rows.length}`);
+
+ let updated = 0, skipped = 0;
+ // Batch updates in a transaction — 1,819 rows shouldn't be 1,819 round
+ // trips. (PROSECUTOR caught this pattern in an earlier debate.)
+ const client = await db.pool.connect();
+ try {
+ await client.query('BEGIN');
+
+ for (const r of rows) {
+ // Parse "<Type> in <City>." → [type, city]
+ const m = r.headline.match(/^(.+?) in ([A-Za-z][A-Za-z ]+)\.$/);
+ if (!m) { skipped++; continue; }
+ const type = m[1];
+ const city = m[2];
+
+ // City must match the row's neighborhood/city (sanity check).
+ const expected = (r.neighborhood || r.city || '').trim();
+ if (expected && expected.toLowerCase() !== city.toLowerCase()) {
+ skipped++; continue;
+ }
+
+ const variantIdx = Number(r.id) % 3;
+ const newHeadline = variants(type, expected || city)[variantIdx];
+
+ if (newHeadline === r.headline) { skipped++; continue; }
+
+ // Re-stitch description. Keep the prefix sentence intact, swap the
+ // trailing headline echo to match the new headline.
+ const verticalPrefixRe = new RegExp(`^(.+? is a [a-z _]+ business on the Ventura Blvd corridor in ${expected || city}\\. ).+$`);
+ // Update the headline; let the description regenerate via raw SQL
+ // concatenation instead of regex (faster + less error-prone).
+ await client.query(
+ `UPDATE businesses
+ SET headline = $1,
+ description = (
+ business_name || ' is a ' || REPLACE(vertical, '_', ' ')
+ || ' business on the Ventura Blvd corridor in ' || COALESCE(neighborhood, city)
+ || '. ' || $1
+ )
+ WHERE id = $2`,
+ [newHeadline, r.id]
+ );
+ updated++;
+ }
+
+ await client.query('COMMIT');
+ } catch (err) {
+ await client.query('ROLLBACK');
+ throw err;
+ } finally {
+ client.release();
+ }
+
+ // Tally distinct headlines now
+ const distinct = await db.one(`
+ SELECT COUNT(DISTINCT headline)::int AS n
+ FROM businesses WHERE source='public_records' AND status='active'
+ `);
+ console.log(`[rotate] updated=${updated} skipped=${skipped} distinct_headlines_now=${distinct.n}`);
+
+ await db.pool.end();
+ process.exit(0);
+})().catch(async (err) => {
+ console.error('[rotate] fatal', err);
+ try { await db.pool.end(); } catch (_) {}
+ process.exit(1);
+});
← d613a8f Right-rail visibility: brass-color + flex-shrink:0 on home r
·
back to Ventura Claw Leads
·
rel=alternate JSON + HTTP Link header on /find paginated rou fbd4be4 →