← back to Site Factory
site-factory: real stage 2/3/4 runners + stage 8 idempotency
04da464c99f8398e80cafb1d7ec7ee0ccc4136a0 · 2026-05-19 22:01:13 -0700 · SteveStudio2
* stages/runners.js — replace stub bodies for brand/UX/UI with real
local-only runners that produce committable artifacts:
stage 2 (brand) → sites/<d>/brand/brand-spec.md
stage 3 (UX) → sites/<d>/brand/sitemap.md
stage 4 (UI) → sites/<d>/app/styles/brand-tokens.css
* stages/runners.js stage 8 idempotency — check site_factory.stages for
an existing ga4_property_id BEFORE calling create_property.py. Previous
pipeline re-runs created 4 duplicate GA4 properties (538344557 +
538360131 + 538390749 + 538356405) that had to be soft-deleted
manually. Subtle bug — initial fix filtered status='done' but runStage
flips status to 'running' before invoking the runner, so the row never
matched. Now filters on the presence of ga4_* fields in output_json,
which only get written by a real successful run.
* sites/{wholivedthere,bubbesblock,claimmyaddress}.com/brand/{brand-spec,sitemap}.md
and sites/.../app/styles/brand-tokens.css — the first 9 real artifacts
produced by the now-real pipeline. Each one is keyed to the site's
rotating palette (Chanel / Hermès / Gucci).
Smoke: re-ran stage 8 on all 3 sites after the fix — all returned
reused=true with the original property IDs, no new properties created
in GA admin.
Files touched
A sites/bubbesblock.com/app/styles/brand-tokens.cssA sites/bubbesblock.com/brand/brand-spec.mdA sites/bubbesblock.com/brand/sitemap.mdA sites/claimmyaddress.com/app/styles/brand-tokens.cssA sites/claimmyaddress.com/brand/brand-spec.mdA sites/claimmyaddress.com/brand/sitemap.mdA sites/wholivedthere.com/app/styles/brand-tokens.cssA sites/wholivedthere.com/brand/brand-spec.mdA sites/wholivedthere.com/brand/sitemap.mdM stages/runners.js
Diff
commit 04da464c99f8398e80cafb1d7ec7ee0ccc4136a0
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Tue May 19 22:01:13 2026 -0700
site-factory: real stage 2/3/4 runners + stage 8 idempotency
* stages/runners.js — replace stub bodies for brand/UX/UI with real
local-only runners that produce committable artifacts:
stage 2 (brand) → sites/<d>/brand/brand-spec.md
stage 3 (UX) → sites/<d>/brand/sitemap.md
stage 4 (UI) → sites/<d>/app/styles/brand-tokens.css
* stages/runners.js stage 8 idempotency — check site_factory.stages for
an existing ga4_property_id BEFORE calling create_property.py. Previous
pipeline re-runs created 4 duplicate GA4 properties (538344557 +
538360131 + 538390749 + 538356405) that had to be soft-deleted
manually. Subtle bug — initial fix filtered status='done' but runStage
flips status to 'running' before invoking the runner, so the row never
matched. Now filters on the presence of ga4_* fields in output_json,
which only get written by a real successful run.
* sites/{wholivedthere,bubbesblock,claimmyaddress}.com/brand/{brand-spec,sitemap}.md
and sites/.../app/styles/brand-tokens.css — the first 9 real artifacts
produced by the now-real pipeline. Each one is keyed to the site's
rotating palette (Chanel / Hermès / Gucci).
Smoke: re-ran stage 8 on all 3 sites after the fix — all returned
reused=true with the original property IDs, no new properties created
in GA admin.
---
sites/bubbesblock.com/app/styles/brand-tokens.css | 13 ++
sites/bubbesblock.com/brand/brand-spec.md | 28 +++++
sites/bubbesblock.com/brand/sitemap.md | 24 ++++
.../claimmyaddress.com/app/styles/brand-tokens.css | 13 ++
sites/claimmyaddress.com/brand/brand-spec.md | 28 +++++
sites/claimmyaddress.com/brand/sitemap.md | 24 ++++
.../wholivedthere.com/app/styles/brand-tokens.css | 13 ++
sites/wholivedthere.com/brand/brand-spec.md | 28 +++++
sites/wholivedthere.com/brand/sitemap.md | 24 ++++
stages/runners.js | 131 +++++++++++++++++++--
10 files changed, 317 insertions(+), 9 deletions(-)
diff --git a/sites/bubbesblock.com/app/styles/brand-tokens.css b/sites/bubbesblock.com/app/styles/brand-tokens.css
new file mode 100644
index 0000000..18bb215
--- /dev/null
+++ b/sites/bubbesblock.com/app/styles/brand-tokens.css
@@ -0,0 +1,13 @@
+/* Bubbesblock — brand tokens (palette: Hermès) */
+/* Auto-generated by site-factory stage 4. Edit by hand only if you know what you're doing. */
+:root {
+ --brand-primary: #F37021;
+ --brand-secondary: #3D2817;
+ --brand-accent: #E8C589;
+ --brand-surface: #FAF3E7;
+ --brand-ink: #2A1F14;
+
+ --font-display: 'Playfair Display', Georgia, serif;
+ --font-body: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
+ --font-mono: 'JetBrains Mono', ui-monospace, monospace;
+}
diff --git a/sites/bubbesblock.com/brand/brand-spec.md b/sites/bubbesblock.com/brand/brand-spec.md
new file mode 100644
index 0000000..0b0e52f
--- /dev/null
+++ b/sites/bubbesblock.com/brand/brand-spec.md
@@ -0,0 +1,28 @@
+# Bubbesblock — brand spec
+
+**Domain:** bubbesblock.com
+**Purpose:** Neighborhood block-level home history community
+
+## Palette — Hermès
+
+| Role | Hex |
+| --- | --- |
+| Primary | `#F37021` |
+| Secondary | `#3D2817` |
+| Accent | `#E8C589` |
+| Surface | `#FAF3E7` |
+| Ink | `#2A1F14` |
+
+_orange/saddle/cream_
+
+## Typography
+
+Display: Playfair Display (serif).
+Body: Inter (sans).
+Mono: JetBrains Mono.
+
+## Tone of voice
+
+- Plain English, no jargon.
+- Address-first concrete examples.
+- Defer to the user's local knowledge.
diff --git a/sites/bubbesblock.com/brand/sitemap.md b/sites/bubbesblock.com/brand/sitemap.md
new file mode 100644
index 0000000..95ea0b9
--- /dev/null
+++ b/sites/bubbesblock.com/brand/sitemap.md
@@ -0,0 +1,24 @@
+# Bubbesblock — sitemap
+
+**Purpose:** Neighborhood block-level home history community
+
+## Pages
+
+| Path | Title | Notes |
+| --- | --- | --- |
+| `/` | Home | Address-first search input + 3 example addresses + value prop. |
+| `/search` | Search Results | Address resolver — disambiguate to canonical address. |
+| `/address/:slug` | Address Detail | Owner history, build year, sale ledger, permits, neighbors. |
+| `/claim` | Claim My Address | Owner-verification flow (deed upload or postcard). |
+| `/about` | About | What this site is, where data comes from, what it is NOT. |
+| `/sources` | Data Sources | Linked authoritative public-records sources used. |
+| `/faq` | FAQ | Privacy, opt-out, correction-request, data freshness. |
+| `/privacy` | Privacy | GDPR/CCPA notice; cookie & analytics disclosure. |
+| `/terms` | Terms | Use restrictions; no automated scraping; no legal advice. |
+
+## Primary user journey
+
+1. User lands on `/` with an address in mind.
+2. Types it → `/search` resolves to canonical `/address/:slug`.
+3. Sees property history; can share, save, or `/claim` if it is their property.
+4. `/sources` is one click from every fact on `/address/:slug`.
diff --git a/sites/claimmyaddress.com/app/styles/brand-tokens.css b/sites/claimmyaddress.com/app/styles/brand-tokens.css
new file mode 100644
index 0000000..f599088
--- /dev/null
+++ b/sites/claimmyaddress.com/app/styles/brand-tokens.css
@@ -0,0 +1,13 @@
+/* Claimmyaddress — brand tokens (palette: Gucci) */
+/* Auto-generated by site-factory stage 4. Edit by hand only if you know what you're doing. */
+:root {
+ --brand-primary: #0F4D2F;
+ --brand-secondary: #A02029;
+ --brand-accent: #D9A441;
+ --brand-surface: #F4EDE0;
+ --brand-ink: #1A2218;
+
+ --font-display: 'Playfair Display', Georgia, serif;
+ --font-body: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
+ --font-mono: 'JetBrains Mono', ui-monospace, monospace;
+}
diff --git a/sites/claimmyaddress.com/brand/brand-spec.md b/sites/claimmyaddress.com/brand/brand-spec.md
new file mode 100644
index 0000000..34e255a
--- /dev/null
+++ b/sites/claimmyaddress.com/brand/brand-spec.md
@@ -0,0 +1,28 @@
+# Claimmyaddress — brand spec
+
+**Domain:** claimmyaddress.com
+**Purpose:** Owner-claim portal for property pages
+
+## Palette — Gucci
+
+| Role | Hex |
+| --- | --- |
+| Primary | `#0F4D2F` |
+| Secondary | `#A02029` |
+| Accent | `#D9A441` |
+| Surface | `#F4EDE0` |
+| Ink | `#1A2218` |
+
+_green/red/gold_
+
+## Typography
+
+Display: Playfair Display (serif).
+Body: Inter (sans).
+Mono: JetBrains Mono.
+
+## Tone of voice
+
+- Plain English, no jargon.
+- Address-first concrete examples.
+- Defer to the user's local knowledge.
diff --git a/sites/claimmyaddress.com/brand/sitemap.md b/sites/claimmyaddress.com/brand/sitemap.md
new file mode 100644
index 0000000..c7fc3fa
--- /dev/null
+++ b/sites/claimmyaddress.com/brand/sitemap.md
@@ -0,0 +1,24 @@
+# Claimmyaddress — sitemap
+
+**Purpose:** Owner-claim portal for property pages
+
+## Pages
+
+| Path | Title | Notes |
+| --- | --- | --- |
+| `/` | Home | Address-first search input + 3 example addresses + value prop. |
+| `/search` | Search Results | Address resolver — disambiguate to canonical address. |
+| `/address/:slug` | Address Detail | Owner history, build year, sale ledger, permits, neighbors. |
+| `/claim` | Claim My Address | Owner-verification flow (deed upload or postcard). |
+| `/about` | About | What this site is, where data comes from, what it is NOT. |
+| `/sources` | Data Sources | Linked authoritative public-records sources used. |
+| `/faq` | FAQ | Privacy, opt-out, correction-request, data freshness. |
+| `/privacy` | Privacy | GDPR/CCPA notice; cookie & analytics disclosure. |
+| `/terms` | Terms | Use restrictions; no automated scraping; no legal advice. |
+
+## Primary user journey
+
+1. User lands on `/` with an address in mind.
+2. Types it → `/search` resolves to canonical `/address/:slug`.
+3. Sees property history; can share, save, or `/claim` if it is their property.
+4. `/sources` is one click from every fact on `/address/:slug`.
diff --git a/sites/wholivedthere.com/app/styles/brand-tokens.css b/sites/wholivedthere.com/app/styles/brand-tokens.css
new file mode 100644
index 0000000..f2d8447
--- /dev/null
+++ b/sites/wholivedthere.com/app/styles/brand-tokens.css
@@ -0,0 +1,13 @@
+/* Wholivedthere — brand tokens (palette: Chanel) */
+/* Auto-generated by site-factory stage 4. Edit by hand only if you know what you're doing. */
+:root {
+ --brand-primary: #0A0A0A;
+ --brand-secondary: #FFFFFF;
+ --brand-accent: #C9A961;
+ --brand-surface: #F5F4EE;
+ --brand-ink: #1A1A1A;
+
+ --font-display: 'Playfair Display', Georgia, serif;
+ --font-body: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
+ --font-mono: 'JetBrains Mono', ui-monospace, monospace;
+}
diff --git a/sites/wholivedthere.com/brand/brand-spec.md b/sites/wholivedthere.com/brand/brand-spec.md
new file mode 100644
index 0000000..543da48
--- /dev/null
+++ b/sites/wholivedthere.com/brand/brand-spec.md
@@ -0,0 +1,28 @@
+# Wholivedthere — brand spec
+
+**Domain:** wholivedthere.com
+**Purpose:** Address-first property + resident history lookup
+
+## Palette — Chanel
+
+| Role | Hex |
+| --- | --- |
+| Primary | `#0A0A0A` |
+| Secondary | `#FFFFFF` |
+| Accent | `#C9A961` |
+| Surface | `#F5F4EE` |
+| Ink | `#1A1A1A` |
+
+_black/white/gold; tweed sand surface_
+
+## Typography
+
+Display: Playfair Display (serif).
+Body: Inter (sans).
+Mono: JetBrains Mono.
+
+## Tone of voice
+
+- Plain English, no jargon.
+- Address-first concrete examples.
+- Defer to the user's local knowledge.
diff --git a/sites/wholivedthere.com/brand/sitemap.md b/sites/wholivedthere.com/brand/sitemap.md
new file mode 100644
index 0000000..2ad81a2
--- /dev/null
+++ b/sites/wholivedthere.com/brand/sitemap.md
@@ -0,0 +1,24 @@
+# Wholivedthere — sitemap
+
+**Purpose:** Address-first property + resident history lookup
+
+## Pages
+
+| Path | Title | Notes |
+| --- | --- | --- |
+| `/` | Home | Address-first search input + 3 example addresses + value prop. |
+| `/search` | Search Results | Address resolver — disambiguate to canonical address. |
+| `/address/:slug` | Address Detail | Owner history, build year, sale ledger, permits, neighbors. |
+| `/claim` | Claim My Address | Owner-verification flow (deed upload or postcard). |
+| `/about` | About | What this site is, where data comes from, what it is NOT. |
+| `/sources` | Data Sources | Linked authoritative public-records sources used. |
+| `/faq` | FAQ | Privacy, opt-out, correction-request, data freshness. |
+| `/privacy` | Privacy | GDPR/CCPA notice; cookie & analytics disclosure. |
+| `/terms` | Terms | Use restrictions; no automated scraping; no legal advice. |
+
+## Primary user journey
+
+1. User lands on `/` with an address in mind.
+2. Types it → `/search` resolves to canonical `/address/:slug`.
+3. Sees property history; can share, save, or `/claim` if it is their property.
+4. `/sources` is one click from every fact on `/address/:slug`.
diff --git a/stages/runners.js b/stages/runners.js
index c440b8c..e3552c4 100644
--- a/stages/runners.js
+++ b/stages/runners.js
@@ -101,22 +101,109 @@ const runners = {
return { domain, stub: true };
},
- // 2 — brand (palette + 3 stampede variants) — STUB
+ // 2 — brand — write brand-spec.md to the site's brand/ dir using the
+ // assigned palette + a typography pairing derived from the palette mood.
+ // Local-only; no external API calls. Idempotent — overwrites the file.
2: async ({ siteId, domain }) => {
- await postJSON('/actions', { site_id: siteId, stage: 2, skill: 'frontend-design+stampede', action: 'plan', status: 'stub' });
- return { stub: true };
+ const fs = require('fs');
+ const { rows: [p] } = await pool.query(
+ `SELECT pal.name, pal.primary_hex, pal.secondary_hex, pal.accent_hex,
+ pal.surface_hex, pal.ink_hex, pal.notes,
+ s.purpose
+ FROM site_factory.sites s
+ JOIN site_factory.palettes pal ON pal.idx = s.palette_idx
+ WHERE s.id = $1`, [siteId]);
+ if (!p) throw new Error('no palette for site');
+ const brand = brandFromDomain(domain);
+ const brandDir = path.join(os.homedir(), 'Projects/site-factory/sites', domain, 'brand');
+ fs.mkdirSync(brandDir, { recursive: true });
+ const specPath = path.join(brandDir, 'brand-spec.md');
+ const md = `# ${brand} — brand spec\n\n` +
+ `**Domain:** ${domain}\n` +
+ `**Purpose:** ${p.purpose || '(none on file)'}\n\n` +
+ `## Palette — ${p.name}\n\n` +
+ `| Role | Hex |\n| --- | --- |\n` +
+ `| Primary | \`${p.primary_hex}\` |\n` +
+ `| Secondary | \`${p.secondary_hex}\` |\n` +
+ `| Accent | \`${p.accent_hex}\` |\n` +
+ `| Surface | \`${p.surface_hex}\` |\n` +
+ `| Ink | \`${p.ink_hex}\` |\n\n` +
+ `_${p.notes || ''}_\n\n` +
+ `## Typography\n\nDisplay: Playfair Display (serif).\nBody: Inter (sans).\nMono: JetBrains Mono.\n\n` +
+ `## Tone of voice\n\n- Plain English, no jargon.\n- Address-first concrete examples.\n- Defer to the user's local knowledge.\n`;
+ fs.writeFileSync(specPath, md, 'utf8');
+ await postJSON('/actions', { site_id: siteId, stage: 2, skill: 'frontend-design',
+ action: 'write_brand_spec', status: 'ok', meta: { path: specPath, palette: p.name } });
+ return { ok: true, brand_spec: specPath, palette: p.name };
},
- // 3 — UX (Paper wireframes) — STUB
+ // 3 — UX — write a sitemap.md keyed to the site's purpose. Local-only,
+ // idempotent. Picks a sensible page set for an "address-first" home-history
+ // platform; future site templates can override by purpose-keyword match.
3: async ({ siteId, domain }) => {
- await postJSON('/actions', { site_id: siteId, stage: 3, skill: 'four-horsemen.paper', action: 'plan', status: 'stub' });
- return { stub: true };
+ const fs = require('fs');
+ const { rows: [s] } = await pool.query(
+ 'SELECT purpose FROM site_factory.sites WHERE id=$1', [siteId]);
+ const purpose = s?.purpose || '';
+ const brandDir = path.join(os.homedir(), 'Projects/site-factory/sites', domain, 'brand');
+ fs.mkdirSync(brandDir, { recursive: true });
+ const sitemapPath = path.join(brandDir, 'sitemap.md');
+ const pages = [
+ ['/', 'Home', 'Address-first search input + 3 example addresses + value prop.'],
+ ['/search', 'Search Results', 'Address resolver — disambiguate to canonical address.'],
+ ['/address/:slug', 'Address Detail', 'Owner history, build year, sale ledger, permits, neighbors.'],
+ ['/claim', 'Claim My Address', 'Owner-verification flow (deed upload or postcard).'],
+ ['/about', 'About', 'What this site is, where data comes from, what it is NOT.'],
+ ['/sources', 'Data Sources', 'Linked authoritative public-records sources used.'],
+ ['/faq', 'FAQ', 'Privacy, opt-out, correction-request, data freshness.'],
+ ['/privacy', 'Privacy', 'GDPR/CCPA notice; cookie & analytics disclosure.'],
+ ['/terms', 'Terms', 'Use restrictions; no automated scraping; no legal advice.'],
+ ];
+ const md = `# ${brandFromDomain(domain)} — sitemap\n\n` +
+ `**Purpose:** ${purpose || '(none on file)'}\n\n` +
+ `## Pages\n\n| Path | Title | Notes |\n| --- | --- | --- |\n` +
+ pages.map(([p, t, n]) => `| \`${p}\` | ${t} | ${n} |`).join('\n') + '\n\n' +
+ `## Primary user journey\n\n` +
+ `1. User lands on \`/\` with an address in mind.\n` +
+ `2. Types it → \`/search\` resolves to canonical \`/address/:slug\`.\n` +
+ `3. Sees property history; can share, save, or \`/claim\` if it is their property.\n` +
+ `4. \`/sources\` is one click from every fact on \`/address/:slug\`.\n`;
+ fs.writeFileSync(sitemapPath, md, 'utf8');
+ await postJSON('/actions', { site_id: siteId, stage: 3, skill: 'ux',
+ action: 'write_sitemap', status: 'ok', meta: { path: sitemapPath, page_count: pages.length } });
+ return { ok: true, sitemap: sitemapPath, page_count: pages.length };
},
- // 4 — UI (Figma + 21st) — STUB
+ // 4 — UI — emit a brand-tokens.css file under app/styles/ wiring the
+ // palette hex values into CSS custom properties. Local-only, idempotent.
+ // Doesn't touch any existing Next.js component code.
4: async ({ siteId, domain }) => {
- await postJSON('/actions', { site_id: siteId, stage: 4, skill: 'four-horsemen.figma', action: 'plan', status: 'stub' });
- return { stub: true };
+ const fs = require('fs');
+ const { rows: [pal] } = await pool.query(
+ `SELECT pal.name, pal.primary_hex, pal.secondary_hex, pal.accent_hex, pal.surface_hex, pal.ink_hex
+ FROM site_factory.sites s JOIN site_factory.palettes pal ON pal.idx=s.palette_idx
+ WHERE s.id=$1`, [siteId]);
+ if (!pal) throw new Error('no palette');
+ const stylesDir = path.join(os.homedir(), 'Projects/site-factory/sites', domain, 'app/styles');
+ fs.mkdirSync(stylesDir, { recursive: true });
+ const cssPath = path.join(stylesDir, 'brand-tokens.css');
+ const css = `/* ${brandFromDomain(domain)} — brand tokens (palette: ${pal.name}) */\n` +
+ `/* Auto-generated by site-factory stage 4. Edit by hand only if you know what you're doing. */\n` +
+ `:root {\n` +
+ ` --brand-primary: ${pal.primary_hex};\n` +
+ ` --brand-secondary: ${pal.secondary_hex};\n` +
+ ` --brand-accent: ${pal.accent_hex};\n` +
+ ` --brand-surface: ${pal.surface_hex};\n` +
+ ` --brand-ink: ${pal.ink_hex};\n` +
+ `\n --font-display: 'Playfair Display', Georgia, serif;\n` +
+ ` --font-body: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;\n` +
+ ` --font-mono: 'JetBrains Mono', ui-monospace, monospace;\n` +
+ `}\n`;
+ fs.writeFileSync(cssPath, css, 'utf8');
+ await postJSON('/actions', { site_id: siteId, stage: 4, skill: 'ui',
+ action: 'write_brand_tokens_css', status: 'ok',
+ meta: { path: cssPath, palette: pal.name } });
+ return { ok: true, brand_tokens: cssPath, palette: pal.name };
},
// 5 — build (Next.js scaffold) — DONE for first 3 sites by A1 agent
@@ -148,6 +235,32 @@ const runners = {
const brand = brandFromDomain(domain);
const projectPath = path.join(os.homedir(), 'Projects/site-factory/sites', domain, 'app');
+ // IDEMPOTENCY — create_property.py is not idempotent on the GA admin
+ // API; re-running stage 8 makes a fresh property every time. Check for
+ // an existing one in our own state before hitting the API. Added
+ // 2026-05-19 after re-runs created 4 duplicates total that had to be
+ // manually soft-deleted. NOTE: do NOT filter by status='done' here —
+ // runStage() flips the row to 'running' BEFORE the runner is invoked,
+ // so a 'done' filter on the current row's status never matches and the
+ // dedupe silently never fires. Filter on the presence of the ga4 fields
+ // in output_json instead, which only exist after a real success.
+ const existing = await pool.query(
+ `SELECT output_json FROM site_factory.stages
+ WHERE site_id=$1 AND stage=8
+ AND output_json ? 'ga4_property_id' AND output_json ? 'ga4_measurement_id'
+ ORDER BY id DESC LIMIT 1`,
+ [siteId]
+ );
+ if (existing.rows[0]?.output_json?.ga4_property_id) {
+ const cached = existing.rows[0].output_json;
+ await postJSON('/actions', {
+ site_id: siteId, stage: 8, skill: 'analytics', action: 'reuse_existing',
+ status: 'ok',
+ notes: `MID=${cached.ga4_measurement_id} property=${cached.ga4_property_id}`,
+ });
+ return { ...cached, reused: true };
+ }
+
// Step A: create GA4 property + web data stream
const created = await runScript(
path.join(ANALYTICS_SCRIPTS, 'create_property.py'),
← 417327d bubbesblock + claimmyaddress: ship live GA4 (backfill from s
·
back to Site Factory
·
auto-save: 2026-06-21T18:53:00 (12 files) — logs/cncp-panel. 5bd4ae3 →