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.
57 KiB
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.cssintoapp/web/static/style-custom.cssand deletestyle.css. One stylesheet, one:roottoken block. - Introduce canonical primitives (
.btnfamily,.search-input,.filter-bar,.page-header,.data-table,.empty-state,.toast,.app-nav-linkunified 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.jsfor 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 rawstyle.csslink 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 41–58 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 4–7 | "Tasks 3, 4, 6, 7" | Primitives — buttons, form controls, page-header, table/empty/toast/tab/stat |
| Tasks 8–15 | "Tasks 8–12" | 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 |
| 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: nostyle.cssreference, no deprecated class names in templates, single:rootblock, all canonical primitives defined.
Modified (CSS):
app/web/static/style-custom.css— absorbsstyle.css, adds new primitive sections, defines legacy aliases.
Modified (Jinja chrome):
app/web/templates/base.html— dropsstyle.csslink.app/web/templates/base_login.html— dropsstyle.csslink.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>toapp.js.
Deleted:
app/web/static/style.css— content folded intostyle-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 autobefore 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.cssalready (e.g. duplicatebodyrule 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
:rootin 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-googleis still used by login pages — keep tokens).- Any color literals like
#1a73e8that style.css used as--primary— drop them; style-custom's#0073D1is 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 3–4; remove once primitives cover them.
===================================================== */
Drop every rule from style.css that maps to a primitive we'll create in Tasks 3–4 (don't move .btn-primary etc. — Task 3 owns the button family).
- Step 4: Drop the
style.csslink 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 3–7 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-triggerblocks, around line 2124–2220 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.jsfrombase.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-tableblock.
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-stateprimitive.
/* =====================================================
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
.toastprimitive + 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 8–12'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.htmlapp/web/templates/admin_access.htmlapp/web/templates/admin_groups.htmlapp/web/templates/admin_marketplaces.htmlapp/web/templates/admin_scheduler_runs.htmlapp/web/templates/admin_sessions.htmlapp/web/templates/admin_store_submissions.htmlapp/web/templates/admin_tables.htmlapp/web/templates/admin_tokens.htmlapp/web/templates/admin_usage.htmlapp/web/templates/admin_users.htmlapp/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-statemarkup 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.htmlapp/web/templates/admin_server_config.htmlapp/web/templates/admin_session_detail.htmlapp/web/templates/admin_store_submission_detail.htmlapp/web/templates/admin_user_detail.htmlapp/web/templates/admin_workspace_prompt.htmlapp/web/templates/admin/news_editor.html
Apply the same recipe as Task 8 (steps 1–7 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-inputprimitive to style-custom.css matching.search-inputbaseline 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.htmlapp/web/templates/marketplace.htmlapp/web/templates/marketplace_guide.htmlapp/web/templates/marketplace_item_detail.htmlapp/web/templates/marketplace_plugin_detail.htmlapp/web/templates/store_edit.htmlapp/web/templates/store_examples.htmlapp/web/templates/store_upload.html
Special considerations:
- Catalog has pill-shaped filters inside a
.filters-card(per audit atstore_listing.html:54-77pattern). Replace.filters-card .pillwith.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--herowith.page-header__eyebrow(defined in Task 6). marketplace.htmlhas tabbed sub-views (?tab=flea,?tab=my). Tabs render as a sibling row to the hero — define.tab-strip+.tab-strip__itemin 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.htmlapp/web/templates/login_email.htmlapp/web/templates/login_magic_link.htmlapp/web/templates/login_magic_link_sent.htmlapp/web/templates/password_setup.htmlapp/web/templates/password_reset.htmlapp/web/templates/setup.htmlapp/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 3–4.
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--stickymodifier.
.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:rootblock.
: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":- Bump
pyproject.tomlto the next patch version. - Rename
## [Unreleased]→## [X.Y.Z] — 2026-MM-DD, add new empty[Unreleased]. - Commit
release: X.Y.Zon the branch. - Push, enable auto-merge.
- Bump
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, 8–12 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. ✅