# Template Design Neutralization — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Strip baked-in Substrate brand identity from all six starter template zips so the design-system overlay is the single source of look, and retire the `templates:sync-design` command.

**Architecture:** A one-time Python script rewrites each zip in `storage/app/private/templates/`, replacing `src/index.css` with one canonical neutral baseline, stripping the hardcoded Substrate font block from `index.html` (leaving an empty `<!-- design-system-fonts -->` marker), and swapping the Substrate section in `KNOWLEDGE.md` for a system-agnostic note. The Go build-time overlay (`ApplyDesignOverlay`) is unchanged — it still fully overwrites `index.css` and injects the chosen system's fonts on every real build; the neutral baseline is only the no-overlay fallback. Git is the safety net (zips are tracked), so no manual backup.

**Tech Stack:** Python 3 (`zipfile`, `re`) for the one-off bake; Laravel (delete a console command); the Go builder only for smoke verification.

Reference spec: `specs/2026-06-03-template-design-neutralization-design.md`

---

## File Structure

- **Throwaway (NOT committed):** `/tmp/neutralize_templates.py` — the one-time bake script (deleted in the final task).
- **Modified (committed):** `storage/app/private/templates/{default,ecommerce,dashboard,cms,landing,portfolio}-template.zip` — six zips, each with rewritten `src/index.css`, `index.html`, `KNOWLEDGE.md`.
- **Deleted (committed):** `app/Console/Commands/SyncTemplateDesign.php`.

No DB changes, no design-system-zip changes, no Go changes.

---

## Task 1: Write the bake script

**Files:**
- Create: `/tmp/neutralize_templates.py`

- [ ] **Step 1: Write the script**

Create `/tmp/neutralize_templates.py` with exactly this content:

```python
#!/usr/bin/env python3
"""One-time: neutralize the brand identity baked into the starter template zips.
Rewrites src/index.css, index.html and KNOWLEDGE.md inside each *-template.zip.
Throwaway — not committed. Re-runnable (idempotent)."""
import io, re, sys, zipfile
from pathlib import Path

TEMPLATES_DIR = Path("/Users/noriellecruz/Web/webby/storage/app/private/templates")
ZIPS = ["default", "ecommerce", "dashboard", "cms", "landing", "portfolio"]

NEUTRAL_INDEX_CSS = '''@import "tailwindcss";

@custom-variant dark (&:is(.dark *));

/* ============================================================
   Neutral baseline — no brand identity.
   The project's visual identity (color, typography, radius,
   elevation) is supplied by an installed DESIGN SYSTEM, applied
   at build time. This baseline only needs to be valid and render
   acceptably if a template is ever viewed without an overlay.
   ============================================================ */

:root {
  --background: 0 0% 100%;
  --foreground: 0 0% 9%;
  --card: 0 0% 100%;
  --card-foreground: 0 0% 9%;
  --popover: 0 0% 100%;
  --popover-foreground: 0 0% 9%;
  --secondary: 0 0% 96.1%;
  --secondary-foreground: 0 0% 9%;
  --muted: 0 0% 96.1%;
  --muted-foreground: 0 0% 45.1%;
  --accent: 0 0% 96.1%;
  --accent-foreground: 0 0% 9%;
  --border: 0 0% 89.8%;
  --input: 0 0% 89.8%;
  --destructive: 0 72% 51%;
  --destructive-foreground: 0 0% 98%;

  --primary: 0 0% 9%;
  --primary-foreground: 0 0% 98%;
  --ring: 0 0% 9%;

  --radius: 0.5rem;

  --chart-1: 0 0% 25%;
  --chart-2: 0 0% 40%;
  --chart-3: 0 0% 55%;
  --chart-4: 0 0% 70%;
  --chart-5: 0 0% 85%;

  --sidebar: 0 0% 98%;
  --sidebar-foreground: 0 0% 9%;
  --sidebar-primary: 0 0% 9%;
  --sidebar-primary-foreground: 0 0% 98%;
  --sidebar-accent: 0 0% 96.1%;
  --sidebar-accent-foreground: 0 0% 9%;
  --sidebar-border: 0 0% 89.8%;
  --sidebar-ring: 0 0% 9%;

  --shadow-color: 0 0% 0%;
}

.dark {
  --background: 0 0% 9%;
  --foreground: 0 0% 98%;
  --card: 0 0% 11%;
  --card-foreground: 0 0% 98%;
  --popover: 0 0% 11%;
  --popover-foreground: 0 0% 98%;
  --secondary: 0 0% 16%;
  --secondary-foreground: 0 0% 98%;
  --muted: 0 0% 16%;
  --muted-foreground: 0 0% 64%;
  --accent: 0 0% 18%;
  --accent-foreground: 0 0% 98%;
  --border: 0 0% 20%;
  --input: 0 0% 22%;
  --destructive: 0 62% 50%;
  --destructive-foreground: 0 0% 98%;

  --primary: 0 0% 98%;
  --primary-foreground: 0 0% 9%;
  --ring: 0 0% 83%;

  --chart-1: 0 0% 80%;
  --chart-2: 0 0% 65%;
  --chart-3: 0 0% 50%;
  --chart-4: 0 0% 38%;
  --chart-5: 0 0% 28%;

  --sidebar: 0 0% 11%;
  --sidebar-foreground: 0 0% 98%;
  --sidebar-primary: 0 0% 98%;
  --sidebar-primary-foreground: 0 0% 9%;
  --sidebar-accent: 0 0% 18%;
  --sidebar-accent-foreground: 0 0% 98%;
  --sidebar-border: 0 0% 20%;
  --sidebar-ring: 0 0% 83%;

  --shadow-color: 0 0% 0%;
}

@theme inline {
  --color-background: hsl(var(--background));
  --color-foreground: hsl(var(--foreground));
  --color-card: hsl(var(--card));
  --color-card-foreground: hsl(var(--card-foreground));
  --color-popover: hsl(var(--popover));
  --color-popover-foreground: hsl(var(--popover-foreground));
  --color-primary: hsl(var(--primary));
  --color-primary-foreground: hsl(var(--primary-foreground));
  --color-secondary: hsl(var(--secondary));
  --color-secondary-foreground: hsl(var(--secondary-foreground));
  --color-muted: hsl(var(--muted));
  --color-muted-foreground: hsl(var(--muted-foreground));
  --color-accent: hsl(var(--accent));
  --color-accent-foreground: hsl(var(--accent-foreground));
  --color-destructive: hsl(var(--destructive));
  --color-destructive-foreground: hsl(var(--destructive-foreground));
  --color-border: hsl(var(--border));
  --color-input: hsl(var(--input));
  --color-ring: hsl(var(--ring));
  --color-chart-1: hsl(var(--chart-1));
  --color-chart-2: hsl(var(--chart-2));
  --color-chart-3: hsl(var(--chart-3));
  --color-chart-4: hsl(var(--chart-4));
  --color-chart-5: hsl(var(--chart-5));
  --color-sidebar: hsl(var(--sidebar));
  --color-sidebar-foreground: hsl(var(--sidebar-foreground));
  --color-sidebar-primary: hsl(var(--sidebar-primary));
  --color-sidebar-primary-foreground: hsl(var(--sidebar-primary-foreground));
  --color-sidebar-accent: hsl(var(--sidebar-accent));
  --color-sidebar-accent-foreground: hsl(var(--sidebar-accent-foreground));
  --color-sidebar-border: hsl(var(--sidebar-border));
  --color-sidebar-ring: hsl(var(--sidebar-ring));

  /* Typography — neutral system stacks (a design system overrides these) */
  --font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  --font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;

  /* Shape */
  --radius-sm: calc(var(--radius) - 4px);
  --radius-md: calc(var(--radius) - 2px);
  --radius-lg: var(--radius);
  --radius-xl: calc(var(--radius) + 4px);

  /* Elevation scale */
  --shadow-xs: 0 1px 2px hsl(var(--shadow-color) / 0.05);
  --shadow-sm: 0 1px 2px hsl(var(--shadow-color) / 0.06), 0 1px 3px hsl(var(--shadow-color) / 0.05);
  --shadow-md: 0 2px 4px hsl(var(--shadow-color) / 0.05), 0 4px 10px hsl(var(--shadow-color) / 0.07);
  --shadow-lg: 0 4px 10px hsl(var(--shadow-color) / 0.06), 0 14px 28px hsl(var(--shadow-color) / 0.09);
  --shadow-xl: 0 10px 20px hsl(var(--shadow-color) / 0.08), 0 28px 56px hsl(var(--shadow-color) / 0.12);

  --animate-accordion-down: accordion-down 0.2s ease-out;
  --animate-accordion-up: accordion-up 0.2s ease-out;

  @keyframes accordion-down {
    from { height: 0; }
    to { height: var(--radix-accordion-content-height); }
  }

  @keyframes accordion-up {
    from { height: var(--radix-accordion-content-height); }
    to { height: 0; }
  }
}

* {
  border-color: hsl(var(--border));
}

body {
  background-color: hsl(var(--background));
  color: hsl(var(--foreground));
  font-family: var(--font-sans);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-rendering: optimizeLegibility;
  min-height: 100vh;
}

h1, h2, h3, h4, h5, h6 {
  font-weight: 600;
  letter-spacing: -0.02em;
  text-wrap: balance;
}
h1 { letter-spacing: -0.028em; line-height: 1.08; }
h2 { letter-spacing: -0.022em; line-height: 1.16; }
h3 { letter-spacing: -0.016em; line-height: 1.28; }

html:not(.preload) *,
html:not(.preload) *::before,
html:not(.preload) *::after {
  transition-property: color, background-color, border-color, outline-color;
  transition-timing-function: ease;
  transition-duration: 150ms;
}
'''

NEUTRAL_KNOWLEDGE_SECTION = '''## Theme & styling
This template ships a **neutral baseline only**. The project's visual identity —
colors, typography, radius, and elevation — is provided by an installed
**design system**, applied automatically at build time.

- Build with the **semantic tokens**, never hardcoded values: surfaces use
  `bg-background` / `bg-card` / `bg-muted`, text uses `text-foreground` /
  `text-muted-foreground`, actions use `bg-primary` + `text-primary-foreground`,
  borders use `border-border`. Typography uses `font-sans` / `font-serif`.
- Never hardcode brand colors (e.g. `bg-indigo-600`) or font families — that
  bypasses the design system and will look wrong once a system is applied.
- Functional status colors (e.g. green for success/in-stock, red for errors)
  are acceptable where they convey meaning, not brand.
'''

# Match any Google Fonts <link> + the "type system" comment line.
FONT_LINK_RE = re.compile(
    r'[ \t]*<link[^>]*fonts\.g(?:oogleapis|static)\.com[^>]*>[ \t]*\r?\n', re.I)
FONT_COMMENT_RE = re.compile(r'[ \t]*<!--[^>]*type system[^>]*-->[ \t]*\r?\n', re.I)
MARKER = '    <!-- design-system-fonts -->\n    <!-- /design-system-fonts -->\n'

def fix_index_html(html: str) -> str:
    html = FONT_COMMENT_RE.sub('', html)
    html = FONT_LINK_RE.sub('', html)
    if 'design-system-fonts' not in html:
        html = re.sub(r'(\s*</head>)', '\n' + MARKER + r'\1', html, count=1)
    return html

def fix_knowledge(md: str) -> str:
    # Replace the "## Theme & styling..." section up to the next top-level "## ".
    lines = md.splitlines(keepends=True)
    out, i, n = [], 0, len(lines)
    while i < n:
        if re.match(r'^## Theme & styling', lines[i]):
            out.append(NEUTRAL_KNOWLEDGE_SECTION if not NEUTRAL_KNOWLEDGE_SECTION.endswith('\n') else NEUTRAL_KNOWLEDGE_SECTION)
            i += 1
            while i < n and not lines[i].startswith('## '):
                i += 1
            continue
        out.append(lines[i]); i += 1
    return ''.join(out)

def process(name: str):
    path = TEMPLATES_DIR / f'{name}-template.zip'
    src = zipfile.ZipFile(path, 'r')
    buf = io.BytesIO()
    dst = zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED)
    touched = {'index.css': False, 'index.html': False, 'KNOWLEDGE.md': False}
    for item in src.infolist():
        data = src.read(item.filename)
        fn = item.filename
        if fn.endswith('src/index.css'):
            data = NEUTRAL_INDEX_CSS.encode(); touched['index.css'] = True
        elif fn.endswith('index.html') and 'src/' not in fn:
            data = fix_index_html(data.decode()).encode(); touched['index.html'] = True
        elif fn.endswith('KNOWLEDGE.md'):
            data = fix_knowledge(data.decode()).encode(); touched['KNOWLEDGE.md'] = True
        dst.writestr(item, data)
    src.close(); dst.close()
    path.write_bytes(buf.getvalue())
    print(f'{name}: {touched}')

if __name__ == '__main__':
    for z in ZIPS:
        process(z)
    print('done')
```

- [ ] **Step 2: Syntax-check the script (verify it parses, do NOT run yet)**

Run: `python3 -m py_compile /tmp/neutralize_templates.py && echo OK`
Expected: `OK`

---

## Task 2: Bake the neutral baseline into all six zips

**Files:**
- Modify: all six `storage/app/private/templates/*-template.zip`

- [ ] **Step 1: Confirm clean git state for the zips (git is the rollback path)**

Run: `cd /Users/noriellecruz/Web/webby && git status --short storage/app/private/templates/`
Expected: no output (zips unmodified before the bake).

- [ ] **Step 2: Run the bake**

Run: `python3 /tmp/neutralize_templates.py`
Expected: six lines like `default: {'index.css': True, 'index.html': True, 'KNOWLEDGE.md': True}` then `done`. Every value must be `True` — a `False` means an expected file wasn't found in that zip; stop and investigate before continuing.

---

## Task 3: Verify no brand residue remains (the core test)

**Files:** read-only checks against the six zips.

- [ ] **Step 1: Assert no Substrate/brand strings survive in any zip**

Run:
```bash
cd /tmp && rm -rf neu_check && mkdir neu_check && cd neu_check
SRC=/Users/noriellecruz/Web/webby/storage/app/private/templates
for t in default ecommerce dashboard cms landing portfolio; do
  unzip -o -q "$SRC/$t-template.zip" -d "$t"
  hits=$(grep -rilE "substrate|geist|newsreader|gradient-hero|fonts\.googleapis\.com|fonts\.gstatic\.com" "$t" 2>/dev/null | grep -v node_modules)
  echo "$t: ${hits:-CLEAN}"
done
```
Expected: every line ends in `CLEAN`. Any file listed is residue — fix the script's matchers and re-run Task 2.

- [ ] **Step 2: Assert the neutral marker + neutral CSS are present**

Run:
```bash
cd /tmp/neu_check
for t in default ecommerce dashboard cms landing portfolio; do
  m=$(grep -c "design-system-fonts" "$t/index.html")
  c=$(grep -c "Neutral baseline — no brand identity" "$t/src/index.css")
  k=$(grep -c "neutral baseline only" "$t/KNOWLEDGE.md")
  echo "$t: marker=$m neutralcss=$c knowledge=$k"
done
```
Expected: every line `marker=2 neutralcss=1 knowledge=1`.

---

## Task 4: Verify the neutral templates build standalone

**Files:** read-only build check against extracted templates.

- [ ] **Step 1: Build the `default` template standalone (validates the neutral CSS compiles)**

Run:
```bash
cd /tmp/neu_check/default && npm install --no-audit --no-fund --silent && npm run build
```
Expected: build succeeds (`vite build` finishes, `dist/` produced) with no CSS/Tailwind errors.

- [ ] **Step 2: Build the `ecommerce` template standalone (most components/pages)**

Run:
```bash
cd /tmp/neu_check/ecommerce && npm install --no-audit --no-fund --silent && npm run build
```
Expected: build succeeds. (If either build fails on a missing CSS variable, a token was dropped from the neutral baseline — compare against the `@theme inline` mapping in the spec and fix the script.)

---

## Task 5: Verify the build-time overlay still wins (regression test)

Confirms a real build replaces the neutral baseline with the chosen design system, with no neutral/Substrate residue. Requires the stack running (Laravel `:8000`, builder `:8846`, Reverb `:8892`) and a working AI provider, exactly as in the prior E2E session.

**Files:** none (runtime verification).

- [ ] **Step 1: Create a build pinned to a NON-neutral system (Carbon, id 10) on a neutralized template**

In an authenticated browser session (admin) run, or via tinker create a project with `template_id` (any non-default, e.g. 6=portfolio), `design_system_id=10`, `design_accent='cyan'`, then open the project editor URL so the build auto-starts. Wait for `build_status=completed`.

- [ ] **Step 2: Assert the overlay produced Carbon, not neutral**

Run (replace `<WS>` with the project/workspace UUID):
```bash
WS=/Users/noriellecruz/Web/webby-builder/storage/workspaces/<WS>
echo "fonts:"; grep -oE "family=[A-Za-z+]+" "$WS/index.html" | sort -u
echo "neutral residue (expect 0):"; grep -c "Neutral baseline — no brand identity" "$WS/src/index.css"
```
Expected: fonts are Carbon's (`Space+Grotesk`, `JetBrains+Mono`) and **only** those (no `Geist`/`Newsreader`); neutral residue count is `0`. This proves the overlay fully replaced the neutral baseline.

---

## Task 6: Retire the sync-design command

**Files:**
- Delete: `app/Console/Commands/SyncTemplateDesign.php`

- [ ] **Step 1: Delete the command**

Run: `cd /Users/noriellecruz/Web/webby && git rm app/Console/Commands/SyncTemplateDesign.php`
Expected: `rm 'app/Console/Commands/SyncTemplateDesign.php'`.

- [ ] **Step 2: Assert no references remain**

Run: `grep -rln "templates:sync-design\|SyncTemplateDesign" app/ routes/ database/ config/ 2>/dev/null || echo "NO REFERENCES"`
Expected: `NO REFERENCES`.

- [ ] **Step 3: Confirm the app still boots (command no longer registered, no broken references)**

Run: `php artisan list 2>&1 | grep -c "sync-design"`
Expected: `0` (and the command exits without error).

---

## Task 7: Commit and clean up

**Files:**
- Commit: the six zips + the command deletion.
- Delete: `/tmp/neutralize_templates.py`, `/tmp/neu_check`.

- [ ] **Step 1: Stage the zips and the deletion**

Run:
```bash
cd /Users/noriellecruz/Web/webby
git add storage/app/private/templates/*-template.zip app/Console/Commands/SyncTemplateDesign.php
git status --short
```
Expected: six `M storage/...-template.zip` lines and one `D app/Console/Commands/SyncTemplateDesign.php`. Nothing else.

- [ ] **Step 2: Commit**

Run:
```bash
git commit -m "$(cat <<'EOF'
feat(templates): neutralize baked design; retire templates:sync-design

Replace the Substrate identity baked into all six starter template zips with
one neutral baseline (grayscale + system fonts) so the design-system overlay is
the single source of look. Strips Substrate font links from index.html (leaving
the design-system-fonts marker), and the Substrate section in KNOWLEDGE.md
becomes a system-agnostic note. Removes the now-unused SyncTemplateDesign
command. The overlay still fully replaces index.css/fonts on every real build;
the neutral baseline is only the no-overlay fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF
)"
git log --oneline -1
```
Expected: commit created.

- [ ] **Step 3: Remove throwaway artifacts**

Run: `rm -f /tmp/neutralize_templates.py && rm -rf /tmp/neu_check && echo cleaned`
Expected: `cleaned`.

---

## Self-Review (completed by plan author)

- **Spec coverage:** index.css neutralization (Task 1-2), index.html font strip + marker (Task 1-2), KNOWLEDGE.md note (Task 1-2), retire command (Task 6), commit zips (Task 7), all four test types — standalone build (Task 4), overlay-wins (Task 5), residue grep + no-refs (Task 3, 6), fallback validity = the standalone build is the fallback render (Task 4). Seeder-unaffected is implicitly safe (zip contents only; metadata untouched) and re-verifiable via the prior session's seeder run. Covered.
- **Placeholder scan:** none — full script, full CSS, full KNOWLEDGE text, exact commands/expected output. `<WS>` in Task 5 is an explicit runtime substitution, not a placeholder.
- **Type/name consistency:** `NEUTRAL_INDEX_CSS`, `NEUTRAL_KNOWLEDGE_SECTION`, `fix_index_html`, `fix_knowledge`, `process` referenced consistently; marker string matches the Go overlay's `<!-- design-system-fonts -->` exactly; the residue matchers (Task 3) mirror the strings the script removes.
