Frontend Development
Activation: Jinja2, HTMX, Alpine.js, Tailwind, template, page, UI, frontend
CRITICAL: Pre-Bundled Architecture
Frontend is pre-bundled.
astro/dist/is committed to git. No Vite dev server at runtime.bash# Build frontend (outputs to git-tracked astro/dist/) just build-astro # Watch mode (run in separate terminal) just watch-templates # Commit both source and dist git add astro/src/ astro/dist/
Container-First Execution
NEVER run Node/pnpm commands directly on the host. Always use Docker:
bash# WRONG - will fail with Volta error pnpm lint # RIGHT - use Docker docker compose exec astro pnpm lint docker compose exec astro pnpm checkSee
.claude/rules/container-execution.mdfor full details.
Architecture Overview
┌─────────────────────────────────────────────────────────────┐
│ Nginx │
│ Static: /, /about, /login │ Dynamic: /library/*, /shootouts │
│ (from astro/dist/) │ (from FastAPI) │
└──────────────┬──────────────┴──────────────┬────────────────┘
│ │
▼ ▼
┌──────────────────────┐ ┌──────────────────────────────┐
│ astro/dist/ │ │ FastAPI Backend │
│ (Pre-built, git- │ │ │
│ tracked) │ │ ┌────────────────────────┐ │
└──────────────────────┘ │ │ Jinja2 Templates │ │
│ │ pages/ + fragments/ │ │
│ └───────────┬────────────┘ │
│ │ │
│ HTMX requests │
│ │ │
│ ┌───────────▼────────────┐ │
│ │ HTML Fragments │ │
│ │ /api/v1/html/* │ │
│ └────────────────────────┘ │
└──────────────────────────────┘
Stack
| Layer | Technology | Purpose |
|---|---|---|
| Static Pages | Astro SSG (pre-built) | Home, About, Login (nginx-served from dist/) |
| Dynamic Pages | Jinja2 | Server-rendered HTML pages |
| Interactivity | HTMX | Server-driven DOM updates |
| UI State | Alpine.js | Tabs, toggles, local state |
| Styling | Tailwind CSS | Utility-first, compiled at build time |
| Complex UI | React | SignalChainBuilder island only |
Runtime containers: db, backend, nginx (frontend container only for builds)
Directory Structure
astro/src/
├── pages/
│ ├── layouts/
│ │ └── base.astro # Builds to dist/layouts/base.html (Jinja2 wrapper)
│ ├── index.astro # Static pages (home, about, login)
│ └── ...
├── styles/
│ └── global.css # Design tokens (single source of truth)
├── components/
│ └── SignalChain/ # React island for drag-drop builder
└── islands/ # React island entry points
astro/dist/ # Build output (COMMITTED TO GIT)
├── layouts/
│ └── base.html # Jinja2 wrapper (auto-generated by Astro)
├── pages/ # Full page templates (extend layouts/base.html)
│ ├── gear_browse.html # /gear
│ ├── gear.html # /gear/{slug}
│ ├── shootout_detail.html
│ ├── shootout_create.html
│ └── library/
│ ├── my_gear.html
│ ├── shootouts.html
│ ├── chains.html
│ ├── chains_build.html # React island page
│ └── di-tracks.html
├── fragments/ # HTMX response templates
│ ├── gear/
│ ├── library/
│ └── shootouts/
├── partials/
│ ├── header.html # Nav, auth state, mobile menu
│ └── footer.html # Footer links
└── _astro/
└── *.css # Compiled CSS (includes design tokens)
Template Architecture
Jinja2 templates inherit from layouts/base.html which is built by Astro, not maintained in the backend.
How It Works
astro/src/pages/layouts/base.astro
↓ Astro build
astro/dist/layouts/base.html # Contains compiled CSS, Jinja2 blocks
↓ FastAPI ChoiceLoader
Jinja2 templates extend "layouts/base.html"
Template Loading
FastAPI uses astro/dist for all templates:
- •Jinja2 wrapper template and compiled CSS
- •Pages, fragments, and partials (built from
astro/src/pages/)
# backend/app/core/templates.py
templates.env.loader = ChoiceLoader([
FileSystemLoader(str(_FRONTEND_DIST_DIR)), # layouts/base.html
FileSystemLoader(str(_BACKEND_TEMPLATE_DIR)), # pages/, fragments/, partials/
])
Wrapper Template Features
The Astro-built layouts/base.html includes:
- •Pre-compiled CSS at
/_astro/*.csswith all design tokens - •HTMX and Alpine.js CDN scripts
- •WebSocket connection script for notifications
- •Jinja2 blocks:
title,description,head,content,scripts
Why This Architecture
- •Single source of truth: Design tokens defined once in
astro/src/styles/global.css - •Consistent styling: Static (Astro) and dynamic (Jinja2) pages use identical CSS
- •No CDN Tailwind: All styles are pre-compiled by Astro
Page Template Pattern
Full Page Template
{% extends "layouts/base.html" %}
{% block title %}Page Title{% endblock %}
{% block description %}Meta description{% endblock %}
{% block content %}
<div
data-testid="page-name"
class="container mx-auto px-4 py-8"
x-data="{ /* Alpine.js state */ }"
>
<!-- Static content -->
<h1 class="text-2xl font-bold mb-6">Page Title</h1>
<!-- HTMX container with loading skeleton -->
<div
id="content-container"
hx-get="/api/v1/html/content"
hx-trigger="load"
hx-swap="innerHTML"
>
<!-- Loading skeleton -->
<div class="animate-pulse">...</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// Page-specific JavaScript
</script>
{% endblock %}
Fragment Template (HTMX Response)
<!-- fragments/library/item_list.html -->
{% for item in items %}
<div
data-testid="item-card"
data-item-id="{{ item.id }}"
class="bg-[var(--color-bg-elevated)] rounded-lg p-4"
>
<h3 data-testid="item-card-title">{{ item.name }}</h3>
<button
data-testid="item-card-delete-btn"
hx-delete="/api/v1/html/items/{{ item.id }}"
hx-target="closest [data-testid='item-card']"
hx-swap="outerHTML"
hx-confirm="Delete this item?"
class="text-red-500 hover:text-red-400"
>
Delete
</button>
</div>
{% endfor %}
{% if has_more %}
<div
hx-get="/api/v1/html/items?page={{ page + 1 }}"
hx-trigger="revealed"
hx-swap="outerHTML"
>
Loading more...
</div>
{% endif %}
FastAPI Routes
Page Route Pattern
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from app.api.deps import CurrentUserOptional
from app.core.templates import templates
router = APIRouter(tags=["pages"])
def require_auth(user) -> RedirectResponse | None:
"""Return redirect to login if user is None."""
if user is None:
return RedirectResponse(url="/login", status_code=307)
return None
@router.get("/library/items", response_class=HTMLResponse, response_model=None)
async def items_page(
request: Request,
user: CurrentUserOptional,
) -> Response:
"""Protected page - requires authentication."""
redirect = require_auth(user)
if redirect:
return redirect
return templates.TemplateResponse(
request=request,
name="pages/library/items.html",
context={"title": "Items", "user": user},
)
HTML Fragment Endpoint Pattern
from fastapi import APIRouter, Request, Depends
from app.api.deps import CurrentUser
from app.core.templates import templates
from app.services.item_service import ItemService
router = APIRouter(prefix="/api/v1/html", tags=["html"])
@router.get("/library/items")
async def get_items_fragment(
request: Request,
user: CurrentUser,
page: int = 1,
item_service: ItemService = Depends(),
):
"""Return HTML fragment for HTMX."""
items = await item_service.get_user_items(user.id, page=page)
return templates.TemplateResponse(
request=request,
name="fragments/library/items.html",
context={
"items": items.items,
"page": page,
"has_more": items.has_more,
},
)
HTMX Patterns
Load Content on Page Load
<div id="items-container" hx-get="/api/v1/html/items" hx-trigger="load" hx-swap="innerHTML" > <div class="animate-pulse">Loading...</div> </div>
Lazy Load on Visibility
<div hx-get="/api/v1/html/items" hx-trigger="intersect once" hx-swap="innerHTML" > <div class="animate-pulse">Loading...</div> </div>
Search with Debounce
<input type="search" name="q" hx-get="/api/v1/html/search" hx-trigger="keyup changed delay:300ms" hx-target="#results" placeholder="Search..." /> <div id="results"></div>
Delete with Confirmation
<button
hx-delete="/api/v1/html/items/{{ item.id }}"
hx-target="closest [data-testid='item-card']"
hx-swap="outerHTML"
hx-confirm="Delete this item?"
>
Delete
</button>
Form Submission
<form hx-post="/api/v1/html/items" hx-target="#result" hx-swap="innerHTML" > <input name="name" required /> <button type="submit">Create</button> </form>
Alpine.js Patterns
Tab Switching
<div x-data="{ activeTab: 'tab1' }">
<nav class="border-b border-border">
<button
@click="activeTab = 'tab1'"
:class="activeTab === 'tab1' ? 'border-accent' : 'border-transparent'"
class="border-b-2 px-4 py-2"
>
Tab 1
</button>
<button
@click="activeTab = 'tab2'"
:class="activeTab === 'tab2' ? 'border-accent' : 'border-transparent'"
class="border-b-2 px-4 py-2"
>
Tab 2
</button>
</nav>
<div x-show="activeTab === 'tab1'" x-transition>
Tab 1 content
</div>
<div x-show="activeTab === 'tab2'" x-transition>
Tab 2 content
</div>
</div>
Expandable Section
<div x-data="{ expanded: false }">
<button @click="expanded = !expanded">
<span x-text="expanded ? 'Collapse' : 'Expand'"></span>
</button>
<div x-show="expanded" x-collapse>
Expandable content
</div>
</div>
Mobile Menu Toggle
<div x-data="{ mobileMenuOpen: false }">
<button @click="mobileMenuOpen = !mobileMenuOpen">
Menu
</button>
<nav x-show="mobileMenuOpen" @click.outside="mobileMenuOpen = false">
<!-- Menu items -->
</nav>
</div>
React Islands (SignalChainBuilder Only)
React is used ONLY for the SignalChainBuilder component due to its complex drag-drop requirements.
Loading React Island
{% extends "layouts/base.html" %}
{% block head %}
<link rel="stylesheet" href="/static/islands/guitar-tone-shootout-frontend.css" />
{% endblock %}
{% block content %}
<div id="signal-chain-builder" data-testid="signal-chain-builder">
<div class="animate-pulse">Loading builder...</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/static/islands/signal-chain-builder.js"></script>
<script>
window.SignalChainBuilder.mount('signal-chain-builder');
</script>
{% endblock %}
Testability Requirements
CRITICAL: All elements MUST have data-testid attributes for Playwright testing.
Required Attributes
<!-- Container -->
<div data-testid="page-name">
<!-- List items with entity IDs -->
<div data-testid="item-card" data-item-id="{{ item.id }}">
<!-- Action buttons -->
<button data-testid="item-card-delete-btn">
<!-- Form inputs -->
<input data-testid="form-name-input">
<!-- State exposure on containers -->
<div
data-testid="item-list"
data-loading="false"
data-empty="{{ 'true' if not items else 'false' }}"
>
Test ID Naming Convention
| Pattern | Example |
|---|---|
{page} | gear-library |
{component} | shootout-card |
{component}-{element} | shootout-card-title |
{component}-{action}-btn | shootout-card-delete-btn |
{component}-{field}-input | login-email-input |
tab-{name} | tab-browse, tab-my-gear |
Design System
Design tokens are defined in astro/src/styles/global.css and compiled by Astro.
Color Variables
Use CSS custom properties with Tailwind's arbitrary value syntax:
<!-- Backgrounds --> bg-[var(--color-bg-base)] /* Page background */ bg-[var(--color-bg-surface)] /* Card surface */ bg-[var(--color-bg-elevated)] /* Elevated elements */ <!-- Text --> text-[var(--color-text-primary)] /* Primary text */ text-[var(--color-text-secondary)] /* Secondary text */ text-[var(--color-text-muted)] /* Muted text */ <!-- Accents --> bg-[var(--color-accent-primary)] /* Primary accent */ border-[var(--color-border)] /* Borders */ <!-- Gear type colors --> bg-[var(--color-block-amp)] /* Amp blocks */ bg-[var(--color-block-pedal)] /* Pedal blocks */ bg-[var(--color-block-ir)] /* IR blocks */
Common Classes
<!-- Cards -->
<div class="bg-[var(--color-bg-elevated)] rounded-lg p-4 border border-[var(--color-border)]">
<!-- Buttons -->
<button class="px-4 py-2 bg-[var(--color-accent-primary)] text-white rounded-md
hover:opacity-90 transition-colors">
<!-- Form inputs -->
<input class="w-full px-3 py-2 bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-md
focus:ring-2 focus:ring-[var(--color-accent-primary)] focus:outline-none">
<!-- Loading skeletons -->
<div class="animate-pulse bg-[var(--color-bg-surface)] rounded h-4 w-full">
Updating Design Tokens
- •Edit
astro/src/styles/global.css - •Run
just build-astro(or usejust watch-templatesfor auto-rebuild) - •Changes apply to both static (Astro) and dynamic (Jinja2) pages
- •No backend restart needed - Jinja2 templates auto-reload from disk
Development Workflow
# One-time build just build-astro # Watch mode (run in separate terminal) - auto-rebuilds on file changes just watch-templates
The watch command monitors src/**/*.{astro,html,css,ts,tsx} and triggers astro build automatically. Backend picks up changes immediately due to Jinja2 auto_reload=True.
Auth Handling
Page-Level Auth Redirect
Protected pages redirect unauthenticated users to /login:
redirect = require_auth(user)
if redirect:
return redirect
HTMX Auth Error Handling
Add to pages that make HTMX requests to protected endpoints:
{% block scripts %}
<script>
document.body.addEventListener('htmx:responseError', (event) => {
if (event.detail?.xhr?.status === 401) {
window.location.href = `/login?next=${encodeURIComponent(window.location.pathname)}`;
}
});
</script>
{% endblock %}
Quality Commands
# Frontend checks docker compose exec astro pnpm lint docker compose exec astro pnpm check # Build frontend (backend auto-reloads templates) just build-astro # Watch mode (run in separate terminal) just watch-templates # Backend checks docker compose exec backend ruff check app/ docker compose exec backend mypy app/ # All checks just check
Related
- •Frontend Architecture - Architecture overview and route mapping
- •
.claude/skills/htmx/SKILL.md- Full HTMX patterns - •
.claude/skills/backend-dev/SKILL.md- FastAPI patterns - •
.claude/rules/frontend-standards.md- Testability rules