AgentSkillsCN

gts-frontend-dev

Jinja2 SSR 页面配合 HTMX 实现小型片段更新(如卡片大小或更小的组件),Alpine.js 用于客户端 UI 状态管理(开关、选项卡),Tailwind CSS 则负责样式设计。仅对复杂有状态组件(如 SignalChainBuilder)使用 React Island 技术。GTS 特定的模式。

SKILL.md
--- frontmatter
name: gts-frontend-dev
description: Jinja2 SSR pages with HTMX for small fragment updates (card-sized or smaller), Alpine.js for client-side UI state (toggles, tabs), and Tailwind CSS for styling. React islands only for complex stateful components (SignalChainBuilder). GTS-specific patterns.
context: fork

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 check

See .claude/rules/container-execution.md for full details.

Architecture Overview

code
┌─────────────────────────────────────────────────────────────┐
│                         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

LayerTechnologyPurpose
Static PagesAstro SSG (pre-built)Home, About, Login (nginx-served from dist/)
Dynamic PagesJinja2Server-rendered HTML pages
InteractivityHTMXServer-driven DOM updates
UI StateAlpine.jsTabs, toggles, local state
StylingTailwind CSSUtility-first, compiled at build time
Complex UIReactSignalChainBuilder island only

Runtime containers: db, backend, nginx (frontend container only for builds)

Directory Structure

code
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

code
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/)
python
# 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/*.css with 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

html
{% 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)

html
<!-- 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

python
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

python
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

html
<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

html
<div
  hx-get="/api/v1/html/items"
  hx-trigger="intersect once"
  hx-swap="innerHTML"
>
  <div class="animate-pulse">Loading...</div>
</div>

Search with Debounce

html
<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

html
<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

html
<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

html
<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

html
<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

html
<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

html
{% 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

html
<!-- 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

PatternExample
{page}gear-library
{component}shootout-card
{component}-{element}shootout-card-title
{component}-{action}-btnshootout-card-delete-btn
{component}-{field}-inputlogin-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:

html
<!-- 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

html
<!-- 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

  1. Edit astro/src/styles/global.css
  2. Run just build-astro (or use just watch-templates for auto-rebuild)
  3. Changes apply to both static (Astro) and dynamic (Jinja2) pages
  4. No backend restart needed - Jinja2 templates auto-reload from disk

Development Workflow

bash
# 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:

python
redirect = require_auth(user)
if redirect:
    return redirect

HTMX Auth Error Handling

Add to pages that make HTMX requests to protected endpoints:

html
{% 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

bash
# 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