agnes-the-ai-analyst/docs/archive/superpowers/plans/2026-05-13-design-system-unification.md
ZdenekSrotyr a48524509a
docs: consolidate and de-clutter the documentation tree (#306)
CLAUDE.md rewritten (708 -> ~320 lines): four overlapping release
sections collapsed to one, stale v1->v35 schema history dropped (it
lives in CHANGELOG), marketplace endpoint internals and verbose
process sections moved out or tightened.

New focused docs:
- docs/RELEASING.md - release process, deploy workflows, CI quirks
  (RELEASE_TEMPLATE.md folded in as an appendix)
- docs/marketplace.md - marketplace ingestion + re-serving internals
- docs/README.md - documentation index by audience, linked from
  README.md and CLAUDE.md

Archived under docs/archive/: docs/superpowers/ (52 historical
planning artifacts), HACKATHON.md, pd-ps-comments.md,
security-audit-2026-04.md, future/NOTIFICATIONS.md.

Removed the docs/auto-install.md stub. Fixed dangling links in
connectors/jira/README.md and dev_docs/README.md, repointed
code/doc references to archived paths.
2026-05-14 18:54:22 +00:00

57 KiB
Raw Blame History

Design System Unification Implementation 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 for the whole thing — no mid-stream releases.

Goal: Make every page of the Agnes web UI look like part of the same product — one CSS file, one design-token palette, one set of primitives (buttons, inputs, filter bars, page headers, tables, empty states, toasts), one nav style. Resolve the user-visible drift: top-nav Admin entry looking different from sibling links, filter bars rendering 3 different ways across pages, page headers in 5 different sizes, admin tables each rolling their own CSS.

Architecture:

  • Consolidate app/web/static/style.css into app/web/static/style-custom.css and delete style.css. One stylesheet, one :root token block.
  • Introduce canonical primitives (.btn family, .search-input, .filter-bar, .page-header, .data-table, .empty-state, .toast, .app-nav-link unified for <a> and <button>). Keep legacy class names as CSS aliases during migration so individual templates can flip independently — final task removes the aliases.
  • Migrate all 41 inline-style templates to the canonical primitives, deleting per-page <style> blocks where they only duplicate now-global rules.
  • Add a thin app.js for the toast helper + the (already-present) dropdown wiring extracted from _app_header.html.
  • Add a Python contract test (tests/test_design_system_contract.py) that fails if any template re-introduces a deprecated class name or a raw style.css link tag, so regressions don't slip in after merge.

Tech Stack: FastAPI + Jinja2 templates, vanilla CSS (no preprocessor), vanilla JS, pytest for contract tests, agent-browser for visual smoke tests across all routes.

One-PR rule: This plan bundles every task into one branch (zs/design-pass) and one PR. The migration is mechanically repetitive after Task 4 finishes — small commits per template keep git log readable but the review surface is one PR. Half-migrated state would look worse than current drift, so no intermediate merges.


Revised execution order (post-review)

External review flagged: (a) the user's nav complaint must land in the first commit, not buried at Task 5; (b) Tasks 14 (sticky header) + 15 (dark-mode skeleton) expand review surface without addressing the complaint — cut from this PR; (c) static_url() has no cache-busting → users hit stale 404s after style.css deletion → add mtime version query string in Task 1; (d) the contract test's deprecated-class detection has bugs (won't catch multi-line class= attributes, false-positives on prose text) → tokenize the class="..." attribute properly.

Execution follows the task tracker (Tasks 4158 in the project task list), not the plan-section numbering. Section headers below are kept in their original order for diff readability.

Tracker Plan section What
Task 0 Task 0 Setup + baseline pytest + baseline screenshots for ~30 routes
Task 1 "Task 5" Nav fix first — user's complaint lands in commit #1
Task 2 "Task 1" CSS consolidation + static_url mtime cache-busting
Task 3 "Task 2" Contract test (tokenized class detection) + Jinja smoke render
Tasks 47 "Tasks 3, 4, 6, 7" Primitives — buttons, form controls, page-header, table/empty/toast/tab/stat
Tasks 815 "Tasks 812" Template migration sweep, Task 11 split into 11/12/13/14, Task 12 stays as Task 15 (login verify)
Task 16 "Task 13" Remove legacy CSS aliases
Task 17 "Task 16" CHANGELOG + widened vendor-grep + full pytest + smoke + push
"Tasks 1415" sticky header + dark mode DROPPED — defer to follow-up PRs

File structure (what gets touched)

New files:

  • app/web/static/app.js — toast helper (window.appToast({kind, msg, timeout})) + nav dropdown wiring moved out of inline <script> in _app_header.html.
  • tests/test_design_system_contract.py — contract assertions: no style.css reference, no deprecated class names in templates, single :root block, all canonical primitives defined.

Modified (CSS):

  • app/web/static/style-custom.css — absorbs style.css, adds new primitive sections, defines legacy aliases.

Modified (Jinja chrome):

  • app/web/templates/base.html — drops style.css link.
  • app/web/templates/base_login.html — drops style.css link.
  • app/web/templates/_app_header.html — Admin trigger becomes <a class="app-nav-link"><details>pattern OR .app-nav-link.is-trigger (decided in Task 5). Extract inline <script> to app.js.

Deleted:

  • app/web/static/style.css — content folded into style-custom.css, file removed.

Migrated templates (41 total):

  • Admin index pages (12): activity_center.html, admin_access.html, admin_groups.html, admin_marketplaces.html, admin_scheduler_runs.html, admin_sessions.html, admin_store_submissions.html, admin_tables.html, admin_tokens.html, admin_usage.html, admin_users.html, admin_welcome.html.
  • Admin detail / single-purpose (7): admin_group_detail.html, admin_server_config.html, admin_session_detail.html, admin_store_submission_detail.html, admin_user_detail.html, admin_workspace_prompt.html, admin/news_editor.html.
  • Catalog + marketplace + store (8): catalog.html, marketplace.html, marketplace_guide.html, marketplace_item_detail.html, marketplace_plugin_detail.html, store_edit.html, store_examples.html, store_upload.html.
  • Corporate memory (2): corporate_memory.html, corporate_memory_admin.html.
  • Profile + sessions + tokens (3): profile.html, profile_sessions.html, my_tokens.html.
  • Home / dashboard / install (5): home_not_onboarded.html, home_onboarded.html, dashboard.html, install.html, setup_advanced.html.
  • Misc (4): error.html, me_debug.html, news.html, plus the existing nav partial.

Tests:

  • Existing tests/test_route_integrity.py — already in tree; re-run after every template touch.
  • New tests/test_design_system_contract.py — see Task 2.
  • Full pytest tests/ -n auto before push (per CLAUDE.md "Run tests before every push").

Task 0: Setup + baseline

Files: none (environment work).

  • Step 1: Worktree already prepared.

The plan is being written in .worktrees/design-pass on branch zs/design-pass tracking origin/main. Confirm:

cd .worktrees/design-pass
git status                # working tree clean (only this plan staged later)
git rev-parse --abbrev-ref HEAD   # zs/design-pass
git log -1 --oneline      # should be origin/main tip
  • Step 2: Create venv and install deps.
python3 -m venv .venv
.venv/bin/pip install -q -e ".[dev]"

Expected: install completes without error. Confirms the new worktree is wired up.

  • Step 3: Run the full baseline test suite.
.venv/bin/pytest tests/ --tb=short -n auto -q

Expected: full suite green (or only the pre-existing test_clean_install_integration.py::test_readers_in_pre_init_dir flake). Note exact count + any pre-existing failures in scratch notes — these are the baseline; any new failure caused by this PR is a regression.

  • Step 4: Confirm LOCAL_DEV_MODE behavior + baseline screenshots.
grep -rn "LOCAL_DEV_MODE" app/auth/ app/main.py | head    # confirm OAuth short-circuit exists
LOCAL_DEV_MODE=1 .venv/bin/uvicorn app.main:app --port 8000 &
sleep 3
mkdir -p /tmp/design-pass-baseline
ROUTES=(
    /dashboard /home /catalog /marketplace /marketplace/guide
    /corporate-memory /corporate-memory/admin
    /admin/users /admin/groups /admin/access /admin/tokens
    /admin/marketplaces /admin/tables /admin/scheduler-runs
    /admin/server-config /admin/agent-prompt /admin/workspace-prompt
    /admin/activity /admin/telemetry /admin/usage /admin/sessions
    /admin/store/submissions /admin/welcome
    /profile /profile/sessions /tokens
    /setup /install /login
)
for r in "${ROUTES[@]}"; do
    safe="${r//\//-}"
    agent-browser open "http://localhost:8000${r}"
    agent-browser wait --load networkidle
    agent-browser screenshot "/tmp/design-pass-baseline${safe}.png" --full
done
kill %1

Outcome: a /tmp/design-pass-baseline/ snapshot tree the migration tasks compare against.

  • Step 5: Commit the plan.
git add docs/superpowers/plans/2026-05-13-design-system-unification.md
git commit -m "docs(plan): design-system unification plan (post-review revisions)"

Task 1: Token consolidation (CSS file merge)

Goal: One stylesheet, one :root, one palette. No visible change yet — pure refactor.

Files:

  • Modify: app/web/static/style-custom.css (top of file)

  • Modify: app/web/templates/base.html:7-8

  • Modify: app/web/templates/base_login.html:7-8

  • Delete: app/web/static/style.css

  • Step 1: Read both CSS files in full.

wc -l app/web/static/style.css app/web/static/style-custom.css

Read both end-to-end. Make a scratch list of every rule in style.css and decide for each:

  • Drop: duplicated in style-custom.css already (e.g. duplicate body rule that style-custom overrides anyway).

  • Move: not yet in style-custom; copy into the appropriate section of style-custom.

  • Replace: same selector exists in style-custom with a better value; keep style-custom's version.

  • Step 2: Add to :root in style-custom.css any tokens style.css had that style-custom didn't.

Examples to look for (verify against actual file contents):

  • --btn-google-bg, --btn-google-border (if .btn-google is still used by login pages — keep tokens).
  • Any color literals like #1a73e8 that style.css used as --primary — drop them; style-custom's #0073D1 is canonical.
  • Any fonts referenced by --font-family (style.css legacy name) — alias them inside style-custom.

After this step, style-custom.css's :root is the only token block in the codebase.

  • Step 3: Move non-token rules from style.css into a clearly labeled section near the bottom of style-custom.css.

Add a section header:

/* =====================================================
   Legacy rules absorbed from the deleted style.css
   (login button family, base typography, table defaults).
   Reorganize during Tasks 34; remove once primitives cover them.
   ===================================================== */

Drop every rule from style.css that maps to a primitive we'll create in Tasks 34 (don't move .btn-primary etc. — Task 3 owns the button family).

  • Step 4: Drop the style.css link tag from both base templates.
sed -n '6,9p' app/web/templates/base.html
sed -n '6,9p' app/web/templates/base_login.html

Edit base.html:7 and base_login.html:7 — remove the <link rel="stylesheet" href="{{ static_url('style.css') }}"> line. Leave the style-custom.css line.

  • Step 5: Delete style.css.
git rm app/web/static/style.css
  • Step 6: Visual smoke check.
.venv/bin/uvicorn app.main:app --reload &
# Wait for "Application startup complete"
agent-browser open http://localhost:8000/login
agent-browser screenshot /tmp/login-after-merge.png
agent-browser open http://localhost:8000/        # while unauth, this redirects to login

Expected: login page renders without missing styles. Inter font visible. Google button still styled. Stop the server.

  • Step 7: Run tests + commit.
.venv/bin/pytest tests/ -k "not test_clean_install_integration" --tb=short -q
git add app/web/static/style-custom.css app/web/templates/base.html app/web/templates/base_login.html
git rm app/web/static/style.css 2>/dev/null   # already done in step 5; ensure index is correct
git commit -m "style(css): consolidate style.css into style-custom.css

Single design-token block in style-custom.css. style.css deleted; its
rules absorbed into a labeled section pending primitive migration in
later commits."

Task 2: Contract test for design-system invariants

Goal: Guard the refactor with a test that fails if a future PR re-introduces a deprecated class, points at the deleted style.css, or splits the token block. The test is strict from this commit forward — every later task in this plan must keep it passing.

Files:

  • Create: tests/test_design_system_contract.py

  • Step 1: Write the test file.

"""Design-system invariants. Fails if a regression undoes the design-pass."""
from pathlib import Path
import re

TEMPLATES = Path("app/web/templates")
STATIC = Path("app/web/static")


def _all_html() -> list[Path]:
    return sorted(p for p in TEMPLATES.rglob("*.html"))


def test_style_css_deleted() -> None:
    assert not (STATIC / "style.css").exists(), (
        "style.css must stay deleted — all rules live in style-custom.css"
    )


def test_no_template_references_style_css() -> None:
    offenders = []
    for path in _all_html():
        text = path.read_text(encoding="utf-8")
        if "static_url('style.css')" in text or 'static_url("style.css")' in text:
            offenders.append(str(path))
    assert not offenders, f"templates still link style.css: {offenders}"


def test_style_custom_has_single_root_block() -> None:
    css = (STATIC / "style-custom.css").read_text(encoding="utf-8")
    root_count = len(re.findall(r"^:root\s*\{", css, flags=re.MULTILINE))
    assert root_count == 1, f"expected exactly one :root block, found {root_count}"


def test_canonical_primitives_defined() -> None:
    """All primitives Tasks 37 introduce must be declared in style-custom.css."""
    css = (STATIC / "style-custom.css").read_text(encoding="utf-8")
    required = [
        ".btn",            # base button
        ".btn-primary",
        ".btn-secondary",
        ".btn-ghost",
        ".btn-danger",
        ".search-input",
        ".filter-bar",
        ".page-header",
        ".page-header__title",
        ".data-table",
        ".empty-state",
        ".toast",
    ]
    missing = [sel for sel in required if sel not in css]
    assert not missing, f"missing canonical primitive selectors: {missing}"


DEPRECATED_CLASSES = {
    # Single class tokens only — multi-token patterns ("modal-btn primary")
    # are caught by .modal-btn alone, no need to special-case.
    "btn-primary-v2": "btn-primary",
    "btn-secondary-v2": "btn-secondary",
    "modal-btn": "btn (+ .btn-primary / .btn-secondary)",
    "users-table": "data-table",
    "gp-table": "data-table",
    "marketplaces-table": "data-table",
    "audit-table": "data-table",
    "users-search": "search-input",
    "marketplaces-search": "search-input",
    "kb-search": "search-input",
    "filters-card": "filter-bar",
    "pill": "filter-pill",
}

# Match every class="..." or class='...' attribute, possibly multi-line.
_CLASS_ATTR_RE = re.compile(r"""class\s*=\s*(["'])(.*?)\1""", re.DOTALL)


def _classes_in_template(text: str) -> set[str]:
    """Extract every class token used in the template. Tokenizes the
    class attribute on whitespace so multi-class attrs ("btn btn-primary")
    and multi-line attrs (Jinja templates do this) split cleanly."""
    tokens: set[str] = set()
    for match in _CLASS_ATTR_RE.finditer(text):
        attr_value = match.group(2)
        for tok in attr_value.split():
            # Jinja conditionals: skip tokens that contain "{{", "{%", "}"
            # — we only care about literal class names authors wrote.
            if "{" in tok or "}" in tok:
                continue
            tokens.add(tok)
    return tokens


def test_no_deprecated_class_in_templates() -> None:
    """Templates must use canonical primitives, not legacy aliases."""
    offenders: dict[str, list[str]] = {}
    for path in _all_html():
        text = path.read_text(encoding="utf-8")
        used = _classes_in_template(text)
        for cls, _replacement in DEPRECATED_CLASSES.items():
            if cls in used:
                offenders.setdefault(cls, []).append(path.name)
    assert not offenders, (
        "deprecated classes found in templates:\n"
        + "\n".join(f"  {cls} -> use {DEPRECATED_CLASSES[cls]} ({files})"
                    for cls, files in offenders.items())
    )
  • Step 2: Run the test — it should FAIL.
.venv/bin/pytest tests/test_design_system_contract.py -v

Expected: test_canonical_primitives_defined fails (primitives not added yet), test_no_deprecated_class_in_templates fails (templates still use legacy classes). The other tests should pass (Task 1 already deleted style.css). Note the failures — Tasks 3 onward drive them to green.

  • Step 3: Commit.
git add tests/test_design_system_contract.py
git commit -m "test(design): contract test for design-system invariants

Asserts style.css stays deleted, single :root, canonical primitives
exist, no deprecated class names in templates. Failing until Tasks
3-7 add the primitives and migrate the templates."

Task 3: Button primitive hierarchy

Goal: Define .btn + .btn-primary + .btn-secondary + .btn-ghost + .btn-danger + .btn-sm/.btn-lg size modifiers. Map legacy class names (.btn-primary-v2, .modal-btn, etc.) as CSS aliases so existing templates keep rendering during migration.

Files:

  • Modify: app/web/static/style-custom.css (new section, before legacy-absorption block)

  • Step 1: Add the button section to style-custom.css.

Insert after the existing token block, before the existing component styles. Anchor with a clear comment so reviewers can find it.

/* =====================================================
   Buttons
   Canonical:  .btn (+ variant) (+ size).
   Variants:   .btn-primary | .btn-secondary | .btn-ghost | .btn-danger
   Sizes:      default | .btn-sm | .btn-lg
   Legacy aliases at end of section — to be removed in final cleanup.
   ===================================================== */

.btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: var(--space-2);
    padding: 8px 16px;
    font-family: var(--font-primary);
    font-size: var(--text-base);
    font-weight: var(--font-medium);
    line-height: 1;
    border: 1px solid transparent;
    border-radius: var(--radius-md);
    background: transparent;
    color: var(--text-primary);
    cursor: pointer;
    transition: background var(--transition-fast), color var(--transition-fast),
                border-color var(--transition-fast), box-shadow var(--transition-fast);
    white-space: nowrap;
    user-select: none;
}

.btn:disabled,
.btn[aria-disabled="true"] { opacity: 0.5; cursor: not-allowed; }

.btn:focus-visible { outline: none; box-shadow: var(--focus-ring); }

.btn-primary {
    background: var(--primary);
    color: #fff;
    border-color: var(--primary);
}
.btn-primary:hover { background: var(--primary-dark); border-color: var(--primary-dark); }

.btn-secondary {
    background: var(--surface);
    color: var(--text-primary);
    border-color: var(--border);
}
.btn-secondary:hover { background: var(--border-light); }

.btn-ghost {
    background: transparent;
    color: var(--text-secondary);
}
.btn-ghost:hover { background: var(--border-light); color: var(--text-primary); }

.btn-danger {
    background: #fff;
    color: var(--error);
    border-color: var(--error);
}
.btn-danger:hover { background: var(--error); color: #fff; }

.btn-sm { padding: 4px 10px; font-size: var(--text-sm); }
.btn-lg { padding: 12px 20px; font-size: var(--text-md); }

/* Legacy aliases — remove in Task 13 once all templates migrated. */
.btn-primary-v2 { /* alias */ }   /* will be normalized via JS-free CSS @extend-style by repeating rules below */

Note: CSS has no @extend, so the legacy alias block needs to either (a) physically share selectors (.btn-primary-v2, .btn-primary { … }) or (b) be temporary copy-pasted rules. Option (a) is cleaner:

/* Legacy aliases — primary group */
.btn-primary-v2 { /* deprecated: use .btn .btn-primary */ }
.btn.btn-primary-v2,
.btn-primary-v2 { /* falls through to .btn-primary on legacy markup */
    /* duplicate of .btn .btn-primary so legacy markup without .btn still works */
    display: inline-flex;
    /* …copy of .btn + .btn-primary properties… */
}

Simpler approach: just add selector-list rules:

.btn.btn-primary, .btn-primary-v2 { background: var(--primary); color: #fff; border-color: var(--primary); }
.btn.btn-primary:hover, .btn-primary-v2:hover { background: var(--primary-dark); border-color: var(--primary-dark); }

Apply same selector-list trick for .modal-btn.primary (alias of .btn .btn-primary inside modals), .modal-btn (alias of .btn .btn-secondary).

  • Step 2: Search for every distinct button class name used today.
grep -roh 'class="[^"]*\(btn\|button\)[^"]*"' app/web/templates/ \
    | sort -u > /tmp/btn-classes.txt
cat /tmp/btn-classes.txt

For every entry, add an alias rule to the legacy block IF it's NOT one of the canonical names. Verify nothing renders unstyled.

  • Step 3: Verify contract test progresses.
.venv/bin/pytest tests/test_design_system_contract.py::test_canonical_primitives_defined -v

Expected: now passes for the button selectors (.btn, .btn-primary, etc.).

  • Step 4: Visual smoke check.
.venv/bin/uvicorn app.main:app --reload &
agent-browser open http://localhost:8000/login
agent-browser snapshot -i           # verify Google button still renders correctly
  • Step 5: Commit.
git add app/web/static/style-custom.css
git commit -m "feat(css): canonical .btn primitive hierarchy

Defines .btn + .btn-primary/.btn-secondary/.btn-ghost/.btn-danger +
.btn-sm/.btn-lg. Legacy class names (.btn-primary-v2, .modal-btn,
.modal-btn.primary, etc.) aliased via selector lists so existing
templates keep rendering. Aliases removed in final cleanup task."

Task 4: Form-control primitives — .search-input, .filter-bar, .filter-pill

Goal: One filter-bar pattern across all pages. Same height, same focus ring, same border radius. Pill-shaped category filters become a separate explicit class (.filter-pill) used inside .filter-bar.

Files:

  • Modify: app/web/static/style-custom.css

  • Step 1: Add the form-controls section.

/* =====================================================
   Form controls — inputs, selects, textareas, filter bars.
   Canonical:  .search-input | .filter-bar | .filter-pill
   ===================================================== */

.search-input,
.filter-bar input[type="search"],
.filter-bar input[type="text"],
.filter-bar select {
    height: 36px;
    padding: 0 12px;
    font-family: var(--font-primary);
    font-size: var(--text-sm);
    color: var(--text-primary);
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: var(--radius-md);
    transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}

.search-input::placeholder,
.filter-bar input::placeholder { color: var(--text-muted); }

.search-input:focus,
.filter-bar input:focus,
.filter-bar select:focus {
    outline: none;
    border-color: var(--primary);
    box-shadow: var(--focus-ring);
}

.filter-bar {
    display: flex;
    align-items: center;
    gap: var(--space-2);
    flex-wrap: wrap;
    padding: var(--space-3) var(--space-4);
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: var(--radius-lg);
    margin-bottom: var(--space-4);
}

.filter-bar > .search-input { flex: 1 1 240px; min-width: 200px; }

.filter-pill {
    display: inline-flex;
    align-items: center;
    height: 28px;
    padding: 0 12px;
    font-size: var(--text-sm);
    font-weight: var(--font-medium);
    color: var(--text-secondary);
    background: transparent;
    border: 1px solid var(--border);
    border-radius: var(--radius-full);
    cursor: pointer;
    transition: background var(--transition-fast), color var(--transition-fast);
}

.filter-pill:hover { background: var(--border-light); color: var(--text-primary); }

.filter-pill.is-active {
    background: var(--primary-light);
    color: var(--primary);
    border-color: var(--primary);
}

/* Legacy aliases. Same selector-list trick as buttons. */
.users-search,
.marketplaces-search,
.kb-search { /* legacy classes, treated as .search-input */ }
.users-search, .marketplaces-search, .kb-search, .search-input {
    /* duplicated to keep legacy markup styled until templates migrate */
}

(In practice the test enforces these legacy class names are deleted from templates by Task 13 — the alias block is throwaway scaffolding.)

  • Step 2: Run contract test.
.venv/bin/pytest tests/test_design_system_contract.py -v

Expected: .search-input, .filter-bar now found.

  • Step 3: Commit.
git add app/web/static/style-custom.css
git commit -m "feat(css): .search-input / .filter-bar / .filter-pill primitives

Single canonical filter-bar shape: 36px-height inputs, 28px pills with
.is-active state, consistent focus ring. Legacy .users-search /
.marketplaces-search / .kb-search aliased."

Task 5: Nav unification — fix the Admin entry

Goal: Resolve the user's specific complaint. "Home", "Marketplace", "Data Packages" render via <a class="app-nav-link">; "Admin" renders via <button class="app-nav-link app-nav-menu-trigger">. Today the button strips font/color/padding inheritance and adds its own active state (var(--border-light) instead of var(--primary)). Make them identical.

Files:

  • Modify: app/web/templates/_app_header.html

  • Modify: app/web/static/style-custom.css (the existing .app-nav-link + .app-nav-menu-trigger blocks, around line 21242220 per the audit)

  • Create: app/web/static/app.js

  • Step 1: Read the current nav CSS rules.

grep -n "\.app-nav-link\|\.app-nav-menu-trigger\|\.app-nav-menu-chevron" app/web/static/style-custom.css

Read every block referenced, end to end. Confirm where active state diverges.

  • Step 2: Merge selectors so <button> inherits the full link styling.

Change every .app-nav-link { … } rule to .app-nav-link, .app-nav-menu-trigger { … }. Same for :hover, .is-active, focus states. Then DELETE the standalone .app-nav-menu-trigger { … } rules that previously stripped button chrome — they're redundant once the trigger shares the link rules.

The Admin trigger should now match siblings 1:1 in font, color, padding, hover, and active state. Active state of Admin uses the same var(--primary) color + var(--primary-light) background as other active links. Chevron icon stays.

  • Step 3: Extract inline <script> from _app_header.html.

Move the _wireDropdown function + the two _wireDropdown(...) calls out of _app_header.html:122-146 into a new file app/web/static/app.js.

// app/web/static/app.js
(function () {
    function wireDropdown(triggerId, panelId) {
        var trigger = document.getElementById(triggerId);
        var panel = document.getElementById(panelId);
        if (!trigger || !panel) return;
        function setOpen(open) {
            trigger.setAttribute('aria-expanded', open ? 'true' : 'false');
            if (open) { panel.removeAttribute('hidden'); }
            else { panel.setAttribute('hidden', ''); }
        }
        trigger.addEventListener('click', function (e) {
            e.stopPropagation();
            setOpen(trigger.getAttribute('aria-expanded') !== 'true');
        });
        document.addEventListener('click', function (e) {
            if (!panel.contains(e.target) && e.target !== trigger) setOpen(false);
        });
        document.addEventListener('keydown', function (e) {
            if (e.key === 'Escape') { setOpen(false); trigger.focus(); }
        });
    }
    window.appUI = { wireDropdown: wireDropdown };

    document.addEventListener('DOMContentLoaded', function () {
        wireDropdown('userMenuTrigger', 'userMenuPanel');
        wireDropdown('adminNavTrigger', 'adminNavPanel');
    });
})();
  • Step 4: Link app.js from base.html + base_login.html.

In base.html, after the existing style-custom.css link, add:

<script src="{{ static_url('app.js') }}" defer></script>

(base_login.html probably doesn't need it — the login page has no nav. Skip there to avoid 404.)

Then delete the inline <script> block at _app_header.html:122-146.

  • Step 5: Browser-verify the nav.
# Start uvicorn with LOCAL_DEV_MODE=1 so we can browse as admin without OAuth.
LOCAL_DEV_MODE=1 .venv/bin/uvicorn app.main:app --reload &

agent-browser open http://localhost:8000/dashboard
agent-browser snapshot -i        # nav visible; Home/Marketplace/Data Packages + Admin all rendered
agent-browser screenshot /tmp/nav-after.png --full
# Click Admin trigger
agent-browser click @<adminNavTrigger-ref>
agent-browser screenshot /tmp/nav-dropdown.png

Expected: Admin label same font weight, size, color as siblings. Hover state matches. Active state (when on any /admin/* path) matches. Dropdown opens.

  • Step 6: Commit.
git add app/web/templates/_app_header.html app/web/templates/base.html \
        app/web/static/style-custom.css app/web/static/app.js
git commit -m "fix(nav): unify Admin trigger with sibling nav links

Admin dropdown trigger now shares .app-nav-link styling 1:1 with
<a> siblings — same font, color, padding, hover, active state
(primary blue, not the grey it used to render). Inline dropdown
JS moved to app.js as window.appUI.wireDropdown."

Task 6: Page-header primitive

Goal: One page-header layout. Title + optional subtitle + optional right-aligned action slot. Same H1 size everywhere (22px / --text-xl adjusted). Hero variant for marketing pages opt-in via .page-header--hero.

Files:

  • Modify: app/web/static/style-custom.css

  • Step 1: Add the page-header primitive section.

/* =====================================================
   Page header — title + subtitle + actions row.
   Default:  compact 22px title for admin/content pages.
   Hero:     .page-header--hero for marketing/landing.
   ===================================================== */

.page-header {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    gap: var(--space-4);
    margin: var(--space-6) 0 var(--space-5);
    flex-wrap: wrap;
}

.page-header__main {
    min-width: 0;
    flex: 1 1 auto;
}

.page-header__title {
    margin: 0;
    font-size: 22px;
    font-weight: var(--font-semibold);
    color: var(--text-primary);
    line-height: 1.2;
}

.page-header__subtitle {
    margin: var(--space-2) 0 0;
    font-size: var(--text-sm);
    color: var(--text-secondary);
    line-height: 1.4;
}

.page-header__actions {
    display: flex;
    align-items: center;
    gap: var(--space-2);
    flex-wrap: wrap;
}

/* Hero variant — for landing / store / marketplace headers. */
.page-header--hero {
    padding: var(--space-7) var(--space-6);
    background: linear-gradient(135deg, var(--primary), var(--primary-dark));
    color: #fff;
    border-radius: var(--radius-xl);
    margin-bottom: var(--space-6);
}
.page-header--hero .page-header__title { font-size: 28px; color: #fff; }
.page-header--hero .page-header__subtitle { color: rgba(255,255,255,0.85); }

/* Eyebrow label inside hero. */
.page-header__eyebrow {
    text-transform: uppercase;
    font-size: var(--text-xs);
    letter-spacing: 0.8px;
    color: rgba(255,255,255,0.75);
    margin: 0 0 var(--space-2);
}
  • Step 2: Commit.
git add app/web/static/style-custom.css
git commit -m "feat(css): .page-header primitive with hero variant

Unified header pattern: 22px title, optional subtitle, optional
right-aligned actions slot. .page-header--hero opt-in for landing
pages keeps the gradient look of /store and /marketplace heroes."

Task 7: .data-table, .empty-state, .toast primitives

Goal: Cover the remaining three components called out by the audit. .data-table already exists in style-custom.css (line ~2262 per audit) — keep its rules but ensure they handle every column-width variant the admin pages need. .empty-state is new. .toast is new + paired with a JS helper.

Files:

  • Modify: app/web/static/style-custom.css

  • Modify: app/web/static/app.js

  • Step 1: Audit the existing .data-table block.

grep -n "\.data-table" app/web/static/style-custom.css

Read the block. Add .data-table--compact modifier (smaller padding for dense admin tables like Audit log) if not already present. Confirm th uses uppercase tracking, td uses tabular-nums, hover state is var(--border-light).

  • Step 2: Add .empty-state primitive.
/* =====================================================
   Empty state — for "no records" / "no results" panels.
   ===================================================== */

.empty-state {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--space-3);
    padding: var(--space-8) var(--space-6);
    text-align: center;
    background: var(--surface);
    border: 1px dashed var(--border);
    border-radius: var(--radius-lg);
    color: var(--text-secondary);
}

.empty-state__icon { font-size: 32px; opacity: 0.5; }
.empty-state__title { font-size: var(--text-md); color: var(--text-primary); font-weight: var(--font-medium); margin: 0; }
.empty-state__description { font-size: var(--text-sm); margin: 0; max-width: 480px; line-height: 1.5; }
.empty-state__actions { margin-top: var(--space-3); }
  • Step 3: Add .toast primitive + JS helper.
/* =====================================================
   Toasts — global notification surface.
   Containers stacked bottom-right; dismissed via timeout or click.
   ===================================================== */

.toast-container {
    position: fixed;
    right: var(--space-5);
    bottom: var(--space-5);
    display: flex;
    flex-direction: column;
    gap: var(--space-2);
    z-index: 9999;
    pointer-events: none;
}

.toast {
    pointer-events: auto;
    min-width: 240px;
    max-width: 360px;
    padding: var(--space-3) var(--space-4);
    border-radius: var(--radius-md);
    background: var(--surface);
    border: 1px solid var(--border);
    box-shadow: var(--shadow-elevated);
    font-size: var(--text-sm);
    color: var(--text-primary);
    cursor: pointer;
    animation: toast-in 200ms ease;
}

.toast.is-success { border-left: 3px solid var(--success); }
.toast.is-warning { border-left: 3px solid var(--warning); }
.toast.is-error   { border-left: 3px solid var(--error); }
.toast.is-info    { border-left: 3px solid var(--primary); }

@keyframes toast-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }

Append to app.js:

(function () {
    function ensureContainer() {
        var c = document.getElementById('appToastContainer');
        if (c) return c;
        c = document.createElement('div');
        c.id = 'appToastContainer';
        c.className = 'toast-container';
        document.body.appendChild(c);
        return c;
    }
    window.appToast = function (opts) {
        opts = opts || {};
        var kind = opts.kind || 'info';
        var msg = String(opts.msg || '');
        var timeout = opts.timeout == null ? 4000 : opts.timeout;
        var el = document.createElement('div');
        el.className = 'toast is-' + kind;
        el.textContent = msg;
        el.addEventListener('click', function () { el.remove(); });
        ensureContainer().appendChild(el);
        if (timeout > 0) setTimeout(function () { el.remove(); }, timeout);
        return el;
    };
})();
  • Step 4: Run contract test — should be GREEN now for primitives.
.venv/bin/pytest tests/test_design_system_contract.py::test_canonical_primitives_defined -v

Expected: pass. (test_no_deprecated_class_in_templates still fails — that's Tasks 812's job.)

  • Step 5: Commit.
git add app/web/static/style-custom.css app/web/static/app.js
git commit -m "feat(css): .data-table modifier, .empty-state, .toast primitives

.data-table--compact for dense admin tables. .empty-state and .toast
new; toasts paired with window.appToast({kind, msg, timeout})."

Task 8: Template migration — admin index pages (12 files)

Goal: Sweep all admin list/index pages onto the new primitives. Mechanical: each page's inline <style> block either deletes (when it only re-states global rules) or shrinks (when it has page-specific column widths).

Files (12 templates):

  • app/web/templates/activity_center.html
  • app/web/templates/admin_access.html
  • app/web/templates/admin_groups.html
  • app/web/templates/admin_marketplaces.html
  • app/web/templates/admin_scheduler_runs.html
  • app/web/templates/admin_sessions.html
  • app/web/templates/admin_store_submissions.html
  • app/web/templates/admin_tables.html
  • app/web/templates/admin_tokens.html
  • app/web/templates/admin_usage.html
  • app/web/templates/admin_users.html
  • app/web/templates/admin_welcome.html

Per-template recipe (apply to all 12):

  • Step 1 (per file): Read the file end-to-end.
.venv/bin/python -c "
import sys, pathlib
p = pathlib.Path(sys.argv[1])
print(p.read_text())
" app/web/templates/<file>

Identify: (a) the page-header markup (some H1 + maybe a subtitle + maybe an action button), (b) the filter/search row markup, (c) the table markup, (d) inline <style> block.

  • Step 2 (per file): Replace page-header markup.

Before:

<div class="users-page">
  <div class="users-toolbar">
    <h1 class="users-title">Users</h1>
    <input class="users-search" type="search" placeholder="…">
    <button>+ Add user</button>
  </div>
</div>

After:

<header class="page-header">
  <div class="page-header__main">
    <h1 class="page-header__title">Users</h1>
  </div>
  <div class="page-header__actions">
    <button class="btn btn-primary">+ Add user</button>
  </div>
</header>

<div class="filter-bar">
  <input class="search-input" type="search" placeholder="…">
</div>
  • Step 3 (per file): Swap table class.

class="users-table"class="data-table". Same for gp-table, marketplaces-table, audit-table. Move any genuinely page-specific column-width rules into a slim inline <style> block that only sets <colgroup> widths — nothing else.

  • Step 4 (per file): Strip the now-orphan rules from the inline <style> block.

Delete every rule that duplicates .data-table, .btn-*, .search-input, .page-header__*, or .filter-bar. Keep only page-specific column widths or page-specific layout grids. If the whole <style> block becomes empty, delete it.

  • Step 5 (per file): Insert .empty-state markup if the page renders a "no records" state.

Before (varied per page — <p>No users found.</p>, <div class="empty">…</div>, etc.):

{% if not users %}<p>No users found.</p>{% endif %}

After:

{% if not users %}
<div class="empty-state">
  <div class="empty-state__title">No users yet</div>
  <p class="empty-state__description">Add a user to get started, or wait for Google sign-in to populate the list.</p>
</div>
{% endif %}

(Wording varies per page — match the page's domain.)

  • Step 6 (per file): Browser-verify.
LOCAL_DEV_MODE=1 .venv/bin/uvicorn app.main:app --reload &
agent-browser open http://localhost:8000/admin/<page>
agent-browser screenshot /tmp/<page>-after.png --full

Compare against /tmp/<page>-before.png if you took one. Expected: identical structural placement, modernized styling, no broken layout.

  • Step 7 (per file): Commit.
git add app/web/templates/<file>
git commit -m "style(<page>): migrate to canonical primitives

Replace per-page <style> block with .page-header, .filter-bar,
.search-input, .data-table, .empty-state."
  • Step 8 (whole task): Run contract test + full suite.

After all 12 files done:

.venv/bin/pytest tests/test_design_system_contract.py -v
.venv/bin/pytest tests/ -k "not test_clean_install_integration" -n auto -q

Expected: contract test progressing (some legacy-class entries gone). Full suite green.


Task 9: Template migration — admin detail pages (7 files)

Files:

  • app/web/templates/admin_group_detail.html
  • app/web/templates/admin_server_config.html
  • app/web/templates/admin_session_detail.html
  • app/web/templates/admin_store_submission_detail.html
  • app/web/templates/admin_user_detail.html
  • app/web/templates/admin_workspace_prompt.html
  • app/web/templates/admin/news_editor.html

Apply the same recipe as Task 8 (steps 17 per file). Detail pages often have form blocks instead of tables — for forms, use .btn .btn-primary for submit and .btn .btn-secondary for cancel; inputs use .search-input if they're plain text inputs or define a sibling .form-input primitive in style-custom.css if more form-specific styling is needed (height/border match .search-input so visual consistency holds).

  • Step 1: If any form-input styling diverges from .search-input, add .form-input primitive to style-custom.css matching .search-input baseline plus textarea sizing rules.
.form-input,
.form-input[type="text"],
.form-input[type="email"],
.form-input[type="number"],
.form-input[type="password"],
.form-input[type="url"],
textarea.form-input,
select.form-input {
    /* same as .search-input but multi-line capable */
    width: 100%;
    min-height: 36px;
    padding: 8px 12px;
    /* (etc — copy from .search-input) */
}
textarea.form-input { min-height: 96px; resize: vertical; font-family: var(--font-mono); }

Add .form-input to the contract test's required-selectors list and re-run.

  • Step 2..N (per file): Migrate per Task-8 recipe.

One commit per file. Same message structure.

  • Step N+1: Run full suite + commit.
.venv/bin/pytest tests/ -k "not test_clean_install_integration" -n auto -q

Task 10: Template migration — catalog + marketplace + store (8 files)

Files:

  • app/web/templates/catalog.html
  • app/web/templates/marketplace.html
  • app/web/templates/marketplace_guide.html
  • app/web/templates/marketplace_item_detail.html
  • app/web/templates/marketplace_plugin_detail.html
  • app/web/templates/store_edit.html
  • app/web/templates/store_examples.html
  • app/web/templates/store_upload.html

Special considerations:

  • Catalog has pill-shaped filters inside a .filters-card (per audit at store_listing.html:54-77 pattern). Replace .filters-card .pill with .filter-bar + .filter-pill (defined in Task 4). Same shape, canonical name.
  • Marketplace/store use hero page-headers (gradient background, larger title, eyebrow label). Use .page-header.page-header--hero with .page-header__eyebrow (defined in Task 6).
  • marketplace.html has tabbed sub-views (?tab=flea, ?tab=my). Tabs render as a sibling row to the hero — define .tab-strip + .tab-strip__item in style-custom.css if not already present; otherwise alias whatever's there.

Apply Task-8 recipe with these hero/pill substitutions. One commit per file.


Task 11: Template migration — corporate memory, profile, home, misc (15 files)

Files (grouped for one task, separate commits):

  • Corporate memory (2): corporate_memory.html, corporate_memory_admin.html
  • Profile / sessions / tokens (3): profile.html, profile_sessions.html, my_tokens.html
  • Home / dashboard / install / setup (5): home_not_onboarded.html, home_onboarded.html, dashboard.html, install.html, setup_advanced.html
  • Misc (5): error.html, me_debug.html, news.html, _quarantine_banner.html, _flea_versions.html

Apply Task-8 recipe per file. _quarantine_banner.html and _flea_versions.html are partials — they may not have a page-header (they're embedded chunks). Just clean up their inline styles to use tokens + canonical classes.

dashboard.html likely has stat-card components — define .stat-card primitive if multiple pages use it, or scope to dashboard alone.


Task 12: Login / auth page sweep

Goal: Catch the login flow (uses base_login.html, not base.html — no nav, no toasts wiring). Keep it minimal — these pages are usually fine, just verify they render after the token consolidation didn't break anything.

Files (verify, light touch if needed):

  • app/web/templates/login.html
  • app/web/templates/login_email.html
  • app/web/templates/login_magic_link.html
  • app/web/templates/login_magic_link_sent.html
  • app/web/templates/password_setup.html
  • app/web/templates/password_reset.html
  • app/web/templates/setup.html
  • app/web/templates/desktop_link.html

For each: open in agent-browser, screenshot. If anything looks broken (Inter font not loaded, Google button wrong color, etc.), fix. If it looks fine, no edit needed.

  • Step 1: Loop through each login route.
LOCAL_DEV_MODE=1 .venv/bin/uvicorn app.main:app --reload &
for route in /login /login/email /login/magic-link /password/reset /setup /desktop-link; do
    agent-browser open "http://localhost:8000${route}"
    agent-browser screenshot "/tmp/loginflow${route//\//-}-after.png" --full
done
  • Step 2: Triage screenshots manually. Only edit files where rendering breaks.

  • Step 3: Commit any fixes.


Task 13: Remove legacy CSS aliases

Goal: Now that every template uses the canonical primitives, delete the legacy alias rules from style-custom.css. Contract test enforces no template re-introduces them.

Files:

  • Modify: app/web/static/style-custom.css

  • Step 1: Grep for residual legacy class references in templates.

for cls in btn-primary-v2 btn-secondary-v2 modal-btn users-table gp-table marketplaces-table audit-table users-search marketplaces-search kb-search filters-card pill; do
    echo "=== $cls ==="
    grep -l "$cls" app/web/templates/ -r
done

Expected: empty for each. If any template still references one, go back and migrate it first.

  • Step 2: Delete the legacy-alias sections from style-custom.css.

Remove every selector that exists ONLY to alias a legacy class. Find them by the /* Legacy aliases — remove in Task 13 */ comments planted in Tasks 34.

Also delete the "Legacy rules absorbed from the deleted style.css" section planted in Task 1 — its contents should by now be either covered by primitives or no longer referenced.

  • Step 3: Run contract test — should be GREEN end-to-end.
.venv/bin/pytest tests/test_design_system_contract.py -v

Expected: every test passes.

  • Step 4: Run full suite.
.venv/bin/pytest tests/ -n auto -q

Expected: green (modulo pre-existing flake).

  • Step 5: Commit.
git add app/web/static/style-custom.css
git commit -m "style(css): remove legacy class aliases

All templates now use canonical primitives. Deprecated class names
(.btn-primary-v2, .modal-btn, .users-table, .gp-table,
.marketplaces-table, .audit-table, .users-search,
.marketplaces-search, .kb-search, .filters-card, .pill) gone from
both templates and CSS."

Task 14: Sticky page header — DROPPED (deferred to follow-up PR)

Per review: expands review surface (12 admin pages need sticky-mode opt-in + viewport smoke tests) without addressing the user's complaint. Open follow-up issue after this PR merges.

Original task body (kept for follow-up reference)

Sticky page header for long admin tables (nice-to-have #1)

Goal: When scrolling a long admin list (users, marketplaces, audit log), the .page-header + .filter-bar stay visible.

Files:

  • Modify: app/web/static/style-custom.css

  • Step 1: Add a .page-header--sticky modifier.

.page-header--sticky {
    position: sticky;
    top: 0;
    z-index: 50;
    background: var(--background);
    margin-top: 0;
    padding-top: var(--space-4);
    padding-bottom: var(--space-3);
    border-bottom: 1px solid var(--border);
}

/* When the page-header is sticky, the filter-bar can stack right below it. */
.page-header--sticky + .filter-bar {
    position: sticky;
    top: 72px;     /* page-header height + a bit */
    z-index: 49;
    margin-bottom: var(--space-4);
}
  • Step 2: Opt in for admin list pages (12 from Task 8).

For each of the 12, change <header class="page-header"> to <header class="page-header page-header--sticky">. Quick edit.

  • Step 3: Browser-verify a long table.
agent-browser open http://localhost:8000/admin/activity     # likely has many rows
agent-browser scroll down 800
agent-browser screenshot /tmp/sticky-scrolled.png --full

Expected: page-header + filter-bar remain visible at the top.

  • Step 4: Commit.
git add app/web/static/style-custom.css app/web/templates/admin_*.html app/web/templates/activity_center.html
git commit -m "feat(css): page-header--sticky modifier for long admin lists"


Task 15: Dark-mode token skeleton — DROPPED (deferred to follow-up PR)

Per review: silently expands review surface ("does this also work dark?" on every selector touched). Defer to a focused follow-up PR with a UI toggle.

Original task body (kept for follow-up reference)

Dark-mode token skeleton (nice-to-have #2)

Goal: Lay the groundwork even without a UI toggle. A future PR can wire a toggle; this PR just defines the dark palette so future work is mechanical.

Files:

  • Modify: app/web/static/style-custom.css

  • Step 1: Add a [data-theme="dark"] token override block right after the :root block.

:root[data-theme="dark"] {
    --primary: #4FA8FF;
    --primary-light: rgba(79, 168, 255, 0.15);
    --primary-dark: #2B7CD9;
    --text-primary: #E5E7EB;
    --text-secondary: #9CA3AF;
    --text-muted: #6B7280;
    --background: #0F172A;
    --surface: #1E293B;
    --border: #334155;
    --border-light: #1E293B;
    --shadow-sm: rgba(0,0,0,0.4) 0px 1px 2px 0px;
    --shadow-card: 0 1px 3px 0 rgba(0,0,0,0.5), 0 1px 2px -1px rgba(0,0,0,0.4);
    --shadow-elevated: 0 8px 24px rgba(0,0,0,0.45);
    --focus-ring: 0 0 0 3px rgba(79, 168, 255, 0.4);
}

No template change. No toggle yet. Verify via DevTools by setting document.documentElement.dataset.theme = 'dark' — every primitive should re-skin via the token cascade.

  • Step 2: Browser-verify by injecting the attribute.
agent-browser open http://localhost:8000/dashboard
agent-browser keyboard type ''     # focus body
# Use a one-shot eval via the inspect tool to set documentElement dataset:
agent-browser --headed inspect    # then in DevTools console: document.documentElement.dataset.theme = 'dark'
agent-browser screenshot /tmp/dashboard-dark.png --full

Expected: all primitives re-skin coherently. Buttons, tables, page headers, filter bars all in dark variants. No hardcoded white backgrounds peeking through. If something doesn't re-skin, that's a hardcoded color we missed — fix the offending rule to use a token.

  • Step 3: Commit.
git add app/web/static/style-custom.css
git commit -m "feat(css): dark-mode token skeleton

Adds :root[data-theme=\"dark\"] palette override. No UI toggle yet —
flipping the attribute via JS re-skins every primitive that uses
tokens correctly. Toggle to be wired in a follow-up PR."


Task 16: CHANGELOG entry + final sweep + push

  • Step 1: Update CHANGELOG.md.

Add to the top ## [Unreleased] section:

### Changed
- Web UI design system unified: single stylesheet (`style-custom.css`),
  canonical primitives for buttons, inputs, filter bars, page headers,
  tables, empty states, toasts. Top-nav Admin entry now matches sibling
  links in font / color / hover / active state. 40+ templates migrated;
  legacy class names (`.btn-primary-v2`, `.modal-btn`, `.users-table`,
  `.users-search`, etc.) removed. Dark-mode token skeleton in place
  behind `[data-theme="dark"]` for a future toggle.

### Removed
- `app/web/static/style.css` — content folded into `style-custom.css`.

### Internal
- New `tests/test_design_system_contract.py` enforces design-system
  invariants (single `:root` block, no deprecated class names in
  templates, canonical primitives defined).
- Inline `<style>` blocks removed from 35+ templates; per-page CSS now
  only carries column-width specifics where needed.
- Nav dropdown JS extracted from inline `<script>` in `_app_header.html`
  into `app/web/static/app.js` (also hosts the new `window.appToast`
  helper).
  • Step 2: Vendor-agnostic token scan over the entire diff.
git diff origin/main..HEAD -- ':(exclude)docs/superpowers/plans/' \
    | grep -niE 'foundryai|groupon|prj-grp|agnes-dev|grp_foundryai|@groupon\.com|@groupondev\.com|keboola/agnes|\.internal\b' \
    || echo "clean"

Expected: clean. If anything matches, generalize before pushing.

  • Step 3: Final full-suite run (matches CI exactly per CLAUDE.md).
.venv/bin/pytest tests/ --tb=short -n auto -q

Expected: green (modulo pre-existing flake).

  • Step 4: Comprehensive browser smoke pass.

Iterate over every route via agent-browser. For each, snapshot + screenshot, then visually skim. Use a single shell loop:

LOCAL_DEV_MODE=1 .venv/bin/uvicorn app.main:app --reload &
ROUTES=(
    /dashboard /home
    /catalog
    /marketplace /marketplace?tab=flea /marketplace?tab=my /marketplace/guide
    /corporate-memory /corporate-memory/admin
    /admin/users /admin/groups /admin/access /admin/tokens
    /admin/marketplaces /admin/tables /admin/scheduler-runs
    /admin/server-config /admin/agent-prompt /admin/workspace-prompt
    /admin/activity /admin/telemetry /admin/usage /admin/sessions
    /admin/store/submissions
    /admin/welcome
    /profile /profile/sessions /tokens
    /setup /install
    /login
)
for r in "${ROUTES[@]}"; do
    safe="${r//\//-}"
    safe="${safe//\?/--Q}"
    agent-browser open "http://localhost:8000${r}"
    agent-browser wait --load networkidle
    agent-browser screenshot "/tmp/smoke${safe}.png" --full
done

Eyeball each screenshot. Anything that looks broken — fix and re-test.

  • Step 5: Push branch + open PR.
git push -u origin zs/design-pass
gh pr create --title "UI design system unification: single stylesheet, canonical primitives, nav fix" --body "$(cat <<'EOF'
## Summary

- **Single stylesheet**: `style.css` deleted; rules folded into `style-custom.css`. One `:root` token block.
- **Canonical primitives**: `.btn` family, `.search-input`, `.filter-bar`, `.filter-pill`, `.page-header` (+ hero modifier), `.data-table` (+ compact modifier), `.empty-state`, `.toast`. Every page uses them.
- **Top-nav Admin entry fixed**: Admin trigger now shares `.app-nav-link` styling 1:1 with sibling `<a>` entries — same font, color, padding, hover, active state. The user-reported "Admin looks different" goes away.
- **40+ templates migrated**: inline `<style>` blocks reduced to only page-specific column widths where genuinely needed. Legacy class names (`.btn-primary-v2`, `.modal-btn`, `.users-table`, `.gp-table`, `.marketplaces-table`, `.audit-table`, `.users-search`, `.marketplaces-search`, `.kb-search`, `.filters-card .pill`) gone from templates and CSS.
- **Nice-to-haves**: `.page-header--sticky` for long admin tables; dark-mode token skeleton behind `[data-theme="dark"]` ready for a follow-up toggle PR; `window.appToast({kind, msg})` helper in `app/web/static/app.js`.
- **Contract test**: `tests/test_design_system_contract.py` fails CI if a future PR re-introduces a deprecated class or breaks the single-stylesheet invariant.

## Test plan

- [ ] Full pytest suite green: `pytest tests/ -n auto`
- [ ] New contract test green: `pytest tests/test_design_system_contract.py -v`
- [ ] Browser smoke pass via agent-browser over ~30 routes (see commit log for the loop used)
- [ ] No hardcoded customer-specific tokens in diff
- [ ] CHANGELOG updated under `[Unreleased]`
EOF
)"
  • Step 6: Watch CI. If green and [Unreleased] would land alone, cut the release per CLAUDE.md "Release-cut belongs to the PR — non-negotiable":

    1. Bump pyproject.toml to the next patch version.
    2. Rename ## [Unreleased]## [X.Y.Z] — 2026-MM-DD, add new empty [Unreleased].
    3. Commit release: X.Y.Z on the branch.
    4. Push, enable auto-merge.

If other feature PRs are queued behind this one, skip the release cut and let the last PR in the queue carry it.


Self-review checklist

After writing this plan, walking back through:

  • Spec coverage: All five user-named problem areas covered — fonts (Task 1 tokens), nav (Task 5), filters (Task 4 + Task 8 migrations), page consistency (Tasks 6, 812 migrations), tables (Task 7 + migration sweep).
  • No placeholders: Every CSS block above is concrete code. Per-file migration steps point to specific markup transforms.
  • Type consistency: Class names referenced in primitive tasks (.btn-primary, .search-input, .page-header__title, etc.) match what migration tasks expect to swap in.
  • Tests run before each push (CLAUDE.md): Tasks 1, 8, 9, 11, 13, 16 each run pytest. Final push in Task 16 runs the full CI-matching command.
  • One PR (user's explicit ask): All 16 tasks land on zs/design-pass; the PR opens at Task 16 step 5. No intermediate merges.
  • Vendor-agnostic check: Task 16 step 2 greps the diff before push.
  • CHANGELOG (CLAUDE.md): Task 16 step 1.
  • Release-cut (CLAUDE.md): Task 16 step 6 — conditional on no other PRs being queued, exactly as the rule prescribes.