# Standalone Pages → base.html Framework Migration Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. One PR continuation (`zs/design-pass`). **Goal:** Migrate the 5 templates that currently ship their own ``, ``, `` scaffold to extend `base.html`. After this lands, every page that includes `_app_header.html` shares ONE rendering pipeline — same font load, same theme include, same script load, same nav. The class of bug that surfaced today (dropdown JS dead on `/catalog`, `/admin/tables`, `/corporate-memory` because ` ``` ### Target structure (after) ```jinja {% extends "base.html" %} {% block title %}Foo - {{ config.INSTANCE_NAME }}{% endblock %} {% block head_extra %} {% endblock %} {% block layout %} {# Use `layout` (not `content`) when the page renders its OWN top-level wrapper (e.g. dashboard.html does
). Use `content` when the page is happy inside base.html's
. #}
{# page-specific markup, verbatim (minus the _app_header include — base.html includes it already). The attribute, if any, moves to a {% block body_attrs %}data-x="…"{% endblock %} #}
{% endblock %} {% block scripts %} {% endblock %} ``` ### Deletions per page - `` + `` + `` (base.html provides) - `` + `` (base.html provides) - ``, `` (base.html provides) - Font preconnect block — `base.html` doesn't ship it today, so this is a small **behavior change**: pages will lose the explicit Inter preconnect. Mitigation: add the preconnect once to base.html's `{% block head_extra %}` parent (or to `base.html` itself above the stylesheet link). See Step 1. - `` if any (base.html provides) - `{% include '_theme.html' %}` (base.html provides) - `` opening tag (base.html provides; attrs go to `{% block body_attrs %}`) - `{% include '_app_header.html' %}` at start of body (base.html includes it) - `` + closing tags ### Preserved per page - `` text → `{% block title %}` - All inline `<style>` content → `{% block head_extra %}<style>...</style>{% endblock %}` - All page markup → `{% block content %}` or `{% block layout %}` - All inline `<script>` content → `{% block scripts %}<script>...</script>{% endblock %}` - Page-specific JS variable usage (e.g. `data-source-type` on body) → `{% block body_attrs %}` --- ## Task 0: Setup + verify base.html blocks + add body_attrs slot **Files:** - Modify: `app/web/templates/base.html` - [ ] **Step 1: Verify dashboard.html / home_*.html already extend base.html.** ```bash grep -l "extends.*base\.html" app/web/templates/*.html ``` Expected output includes `dashboard.html`, `home_onboarded.html`, `home_not_onboarded.html`. Confirms our scope is exactly the 5 standalones, not more. - [ ] **Step 2: Add `{% block body_attrs %}` to base.html.** The `admin_tables.html` template currently renders `<body data-source-type="{{ data_source_type }}">`; its inline JS reads that attribute. We must preserve the attribute. Read `base.html`'s `<body>` line, then change it from: ```html <body> ``` to: ```html <body {% block body_attrs %}{% endblock %}> ``` The default empty block keeps non-admin_tables pages unchanged. - [ ] **Step 3: Add Inter font preconnect to base.html.** Currently `base.html` ships only the stylesheet + `_theme.html` include. The 4 standalone pages that ship their own font preconnect (catalog, corporate_memory*, install) would lose the optimization after migration. Add to `base.html` `<head>` right BEFORE the stylesheet link: ```html {% if not config.THEME_FONT_URL %} <link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> {% endif %} ``` This change benefits ALL base.html consumers — admin pages currently rely on the system Inter being present. With this addition, base.html-extended pages always have the canonical font loaded. - [ ] **Step 4: Confirm test pass + render check before any per-page migration.** ```bash .venv/bin/python -m pytest tests/test_web_ui.py tests/test_web_home_page.py tests/test_design_system_contract.py -q ``` Expected: green. Captures the baseline before migrations begin. - [ ] **Step 5: Commit.** ```bash git add app/web/templates/base.html git commit -m "feat(base): body_attrs block + Inter font preconnect Adds a body_attrs Jinja block (default empty) so pages that extend base.html can carry their own <body> attributes — admin_tables.html needs data-source-type for its JS reading. Hoists the Inter font preconnect + stylesheet link into base.html's <head> so every page that extends base gets the same font load. The 5 standalone pages about to be migrated each had this block inline; centralising it here means future changes (e.g. self-hosting the font) land in one place." ``` --- ## Task 1: Migrate install.html (smallest pilot) **Why install first**: smallest of the 5 (1097 lines), simplest layout, no admin gating, well-contained. Validates the recipe before tackling the big templates. **Files:** - Modify: `app/web/templates/install.html` - [ ] **Step 1: Read full install.html.** ```bash wc -l app/web/templates/install.html sed -n '1,30p' app/web/templates/install.html # head (lines 1-30) sed -n '640,660p' app/web/templates/install.html # </head> + <body> boundary sed -n '935,945p' app/web/templates/install.html # <script> start area tail -5 app/web/templates/install.html ``` Note the exact boundaries. Note any `<body … attribute>` (install.html: none). - [ ] **Step 2: Convert via Python script (post-review revisions applied).** ```python import pathlib, re def migrate(filename: str) -> dict: """Convert a standalone Jinja template to extend base.html. Captures, in order: - <title> - Per-page <link> / <script src> / <style> inside <head> → head_extra (top) - Inline <style> blocks (with attributes preserved) → head_extra (after links) - <body> attributes → body_attrs - Body markup with the leading _app_header.html / _theme.html includes stripped and all inline scripts pulled out → layout block contents - All <script> blocks inside body (inline OR src, attributes preserved verbatim) → scripts block, in order Drops: - <!DOCTYPE>, <html>, </html> - <head>, </head> - <meta charset>, <meta viewport> - Duplicate <link rel="stylesheet" href=".../style-custom.css"> (base.html already ships one) - </body> """ path = pathlib.Path(f"app/web/templates/{filename}") text = path.read_text(encoding="utf-8") # 1) <title> title_m = re.search(r"<title>(.*?)", text, re.DOTALL) title = title_m.group(1).strip() if title_m else filename # 2) Split from head_m = re.search(r"]*>(.+?)", text, re.DOTALL) body_m = re.search(r"]*)>(.+?)", text, re.DOTALL) if not head_m or not body_m: raise RuntimeError(f"missing or in {filename}") head = head_m.group(1) body_attrs = body_m.group(1).strip() body = body_m.group(2) # 3) Inside : collect head-level , ", re.IGNORECASE | re.DOTALL)), ("style", re.compile(r"]*?>.*?", re.IGNORECASE | re.DOTALL)), ] # Concatenate matches found in head, in their original source position. matches = [] for kind, pat in HEAD_PATTERNS: for m in pat.finditer(head): matches.append((m.start(), kind, m.group(0))) matches.sort() for _, kind, raw in matches: # Skip duplicate style-custom.css link (base.html already provides). if kind == "link" and "style-custom.css" in raw: continue # Skip — # we keep the inline preconnect block per page to preserve current # behaviour; do NOT dedupe against base.html in this pass. head_assets.append(raw) head_extra = "\n".join(head_assets) # 4) Inside : pull out every ", re.IGNORECASE | re.DOTALL) for m in SCRIPT_BODY_RE.finditer(body): script_blocks.append(m.group(0)) body_no_scripts = SCRIPT_BODY_RE.sub("", body) # Strip leading _app_header.html include (with optional surrounding # whitespace and HTML comments). body_no_header = re.sub( r"^\s*(?:\s*)*\{%\s*include\s+['\"]_app_header\.html['\"]\s*%\}\s*", "", body_no_scripts, count=1 ) # 5) Compose output. layout_indent = " " out = ['{% extends "base.html" %}', "", f"{{% block title %}}{title}{{% endblock %}}"] if body_attrs: out += ["", f"{{% block body_attrs %}}{body_attrs}{{% endblock %}}"] if head_extra.strip(): out += ["", "{% block head_extra %}", head_extra, "{% endblock %}"] out += [ "", "{% block layout %}", "{% include '_app_header.html' %}", body_no_header.rstrip(), "{% endblock %}", ] if script_blocks: out += ["", "{% block scripts %}"] out += script_blocks out += ["{% endblock %}"] out.append("") new_text = "\n".join(out) path.write_text(new_text, encoding="utf-8") return { "name": filename, "old_lines": len(text.splitlines()), "new_lines": len(new_text.splitlines()), "head_assets": len(head_assets), "script_blocks": len(script_blocks), "body_attrs": bool(body_attrs), } result = migrate("install.html") print(result) ``` - [ ] **Step 3: Decide content vs layout block.** `install.html`'s top-level wrapper after the header — read it. If it's just standard content, `{% block content %}` is right (base.html wraps it in `
`). If it has its own `
` or full-bleed elements, switch to `{% block layout %}` (which overrides base.html's container entirely) and manually re-add the `_app_header.html` include and `
` wrapper. **Default to `content`; switch only if the page looks broken in browser.** - [ ] **Step 4: Smoke test in dev server.** ```bash LOCAL_DEV_MODE=1 DATA_DIR=/tmp/agnes-design-pass-data .venv/bin/uvicorn app.main:app --port 8765 > /tmp/uv-install.log 2>&1 & sleep 4 agent-browser open http://localhost:8765/install --wait-until networkidle agent-browser screenshot /tmp/install-after.png --full # Click Admin dropdown agent-browser snapshot -i | grep -E "Admin|Hide" # Verify install page's own behavior (whatever it does — copy buttons, accordions, etc.) pkill -f "uvicorn.*8765" ``` Compare `install-after.png` against the baseline from `/tmp/design-pass-baseline/`. - [ ] **Step 5: Run tests + commit.** ```bash .venv/bin/python -m pytest tests/ -k "install or web_ui" -q git add app/web/templates/install.html git commit -m "refactor(install): extend base.html instead of standalone Pilots the 5-page standalone→framework migration. install.html now inherits , , font preconnect, theme include, app-header, and the app.js script tag from base.html. Page-specific styles and scripts kept verbatim inside head_extra + scripts blocks. -1097 line template becomes ~+200 less (head/body scaffolding deleted)." ``` --- ## Task 2: Migrate corporate_memory.html **Files:** `app/web/templates/corporate_memory.html` Same recipe as Task 1. Note: page is admin-gated (`{% if session.user %}` checks in markup). The wrapping logic isn't part of the migration — base.html's `_app_header.html` already handles the auth check. - [ ] **Step 1–5: Apply Task-1 recipe verbatim.** Commit as `refactor(memory): extend base.html`. - [ ] **Step 6: Verify memory-page-specific JS** (knowledge filter, voting, sync status) still works on `/corporate-memory`. Browser test: click a knowledge item, click upvote, click filter pill. --- ## Task 3: Migrate corporate_memory_admin.html **Files:** `app/web/templates/corporate_memory_admin.html` Same recipe. Admin-only curation page; modals + accordion behavior to verify. - [ ] Apply recipe + commit `refactor(memory-admin): extend base.html`. - [ ] Browser test: open a knowledge item modal, edit, save (don't commit DB; just verify modal opens and closes). --- ## Task 4: Migrate catalog.html **Files:** `app/web/templates/catalog.html` The biggest of the four "memory + catalog + install" group (2524 lines). Has source-cards, accordion, profiler overlay, two inline `