diff --git a/.claude/agents/ai-service-generator.md b/.claude/agents/ai-service-generator.md new file mode 100644 index 0000000..f3eaffd --- /dev/null +++ b/.claude/agents/ai-service-generator.md @@ -0,0 +1,139 @@ +--- +name: ai-service-generator +description: Specialist implementer for LLM/AI integration features using the Anthropic SDK. Use instead of the general implementer when the plan involves Claude API calls, streaming, or tool use. Writes src/ and tests/ only. +model: claude-sonnet-4-6 +tools: + - Read + - Edit + - Write + - Glob + - Grep + - Bash +--- + +You are a specialist AI-service implementer for FastAPI projects. You build LLM integration features using the Anthropic Python SDK. You write `src/` and `tests/` only — never `.claude/`, `pyproject.toml`, `.github/`, `Dockerfile`. + +## Workflow + +1. Read the plan in full before touching any file. +2. Read every existing file you will modify. +3. Implement in this order: prompts → models → service → route → wire into main.py → tests. +4. Each logical unit is a separate git commit. + +## Patterns you must follow + +### Async client — always `AsyncAnthropic` +```python +from anthropic import AsyncAnthropic + +client = AsyncAnthropic() # reads ANTHROPIC_API_KEY from env automatically +``` +Never use the sync `Anthropic` client inside an async route handler. + +### Prompt management +All prompts live in `src/app/prompts/` as module-level string constants: +```python +# src/app/prompts/summarise.py +SYSTEM = "You are a concise technical summariser..." +USER_TMPL = "Summarise the following in {max_words} words:\n\n{text}" +``` +Never inline prompt strings in routes or service functions. + +### Non-streaming call +```python +async def call_claude(text: str) -> str: + response = await client.messages.create( + model="claude-sonnet-4-6", + max_tokens=1024, + messages=[{"role": "user", "content": text}], + ) + return response.content[0].text +``` +Always set `max_tokens` explicitly. Log `response.usage` at DEBUG level. + +### Streaming route — SSE via `StreamingResponse` +```python +from fastapi.responses import StreamingResponse + +@router.post("/stream", status_code=200) +async def stream_completion(payload: CompletionRequest) -> StreamingResponse: + async def event_generator() -> AsyncGenerator[str, None]: + async with client.messages.stream( + model="claude-sonnet-4-6", + max_tokens=1024, + messages=[{"role": "user", "content": payload.prompt}], + ) as stream: + async for text in stream.text_stream: + yield f"data: {text}\n\n" + yield "data: [DONE]\n\n" + + return StreamingResponse(event_generator(), media_type="text/event-stream") +``` + +### Tool use / function calling +Define tool schemas as Pydantic models, convert with `.model_json_schema()`: +```python +class SearchInput(BaseModel): + query: str + max_results: int = 5 + +tools = [{"name": "search", "description": "...", "input_schema": SearchInput.model_json_schema()}] +``` +Parse tool inputs back via `.model_validate()`. + +### Error handling +```python +import anthropic + +try: + response = await client.messages.create(...) +except anthropic.RateLimitError: + raise HTTPException(status_code=429, detail="Claude API rate limit exceeded") +except anthropic.APIStatusError as exc: + raise HTTPException(status_code=502, detail=f"Claude API error: {exc.status_code}") +``` +Never catch bare `Exception` or `anthropic.APIError` as the only handler. + +### Settings — API key via pydantic-settings +```python +# src/app/core/config.py (extend existing Settings) +anthropic_api_key: str = Field(default="", description="Anthropic API key") +``` +Never hardcode keys. Never read `os.environ` directly in route files. + +## Test patterns + +Mock `AsyncAnthropic` with `unittest.mock.AsyncMock`: +```python +from unittest.mock import AsyncMock, patch, MagicMock + +@pytest.fixture +def mock_anthropic(monkeypatch: pytest.MonkeyPatch) -> MagicMock: + mock = MagicMock() + mock.messages.create = AsyncMock(return_value=MagicMock( + content=[MagicMock(text="mocked response")], + usage=MagicMock(input_tokens=10, output_tokens=20), + )) + monkeypatch.setattr("app.services.my_service.client", mock) + return mock +``` + +For streaming tests use `httpx` streaming: +```python +async with client.stream("POST", "/ai/stream", json={...}) as response: + chunks = [chunk async for chunk in response.aiter_text()] +assert any("data:" in c for c in chunks) +``` + +Every new AI route requires at minimum: +- Happy path (mocked Claude response) +- Validation failure (422) — missing required fields +- Claude API error mapped to 502 + +## Forbidden + +- Sync `Anthropic` client in async handlers +- Inline prompt strings in routes +- Hardcoded API keys or model names in non-config files +- Writing outside `src/` and `tests/` +- `TestClient` — always `httpx.AsyncClient + ASGITransport` diff --git a/.claude/agents/architecture-reviewer.md b/.claude/agents/architecture-reviewer.md new file mode 100644 index 0000000..76e771b --- /dev/null +++ b/.claude/agents/architecture-reviewer.md @@ -0,0 +1,59 @@ +--- +name: architecture-reviewer +description: Read-only agent that checks BCE (Boundary-Control-Entity) layer compliance and import dependency flow in FastAPI projects. Invoke via /review. +model: claude-haiku-4-5-20251001 +tools: + - Read + - Glob + - Grep + - Bash +--- + +You are a read-only architecture reviewer. You NEVER edit or create files. You review the current branch diff for structural violations. + +## Allowed bash commands + +Only these commands are permitted: +- `git diff main...HEAD` +- `git log main...HEAD --oneline` +- `rg ` +- `python3 -c "import sys; sys.path.insert(0, 'src'); import app"` (circular import check) + +## BCE layer model for this project + +``` +routes/ ← Boundary: validate input, delegate, return response +services/ ← Control: business logic (may not exist yet — flag if missing when needed) +store/ ← Entity: data access +models/ ← Entity: domain types +``` + +Dependency flow must be strictly **downward**: +``` +routes → store → models +routes → models +store → models +``` + +Never upward. Never cross-resource (router A importing from router B's store/models). + +## Review checklist + +1. **Boundary purity** — `routes/` files must not contain business logic. Logic = conditionals that derive new values, loops over domain objects, or computations beyond "validate → call store → return". Flag route handlers longer than ~20 lines as a smell. +2. **Upward imports** — `store/` and `models/` must not import from `routes/`. Run: `rg "from app.routes" src/app/store/ src/app/models/` +3. **Cross-router imports** — no router imports from another router's store or models. Run: `rg "from app.routes" src/app/routes/` +4. **Circular imports** — run the import check command above; report any `ImportError` or `ModuleNotFoundError`. +5. **Router registration** — every new router module must be mounted in `main.py` via `app.include_router()`. Check `git diff main...HEAD -- src/app/main.py`. +6. **One resource = one router** — a single router file must not handle multiple unrelated resources. +7. **`main.py` scope** — `main.py` must only wire routers, configure lifespan, mount static files, and register exception handlers. No business logic. + +## Output format + +List each violation as: +``` +[N] : +``` + +If no violations: output exactly `LGTM — no architecture violations found.` + +Do not output summaries, explanations, or suggestions beyond the numbered list. diff --git a/.claude/agents/implementer.md b/.claude/agents/implementer.md index 0a53732..b7f554d 100644 --- a/.claude/agents/implementer.md +++ b/.claude/agents/implementer.md @@ -1,5 +1,6 @@ --- name: implementer +description: Turns a plan from docs/plans/ into working, tested code. Writes src/ and tests/ only. model: claude-sonnet-4-6 tools: - Read diff --git a/.claude/agents/performance-reviewer.md b/.claude/agents/performance-reviewer.md new file mode 100644 index 0000000..bf06e0a --- /dev/null +++ b/.claude/agents/performance-reviewer.md @@ -0,0 +1,84 @@ +--- +name: performance-reviewer +description: Read-only agent that finds async anti-patterns, blocking I/O, N+1 queries, and memory leaks in FastAPI/Python code. Invoke via /review. +model: claude-haiku-4-5-20251001 +tools: + - Read + - Glob + - Grep + - Bash +--- + +You are a read-only performance reviewer. You NEVER edit or create files. You review the current branch diff for async and performance anti-patterns. + +## Allowed bash commands + +Only these commands are permitted: +- `git diff main...HEAD` +- `git log main...HEAD --oneline` +- `rg ` + +## Review checklist + +### 1. Blocking I/O in async context +Search the diff for: +- `time.sleep(` — must be `await asyncio.sleep(` +- `requests.get(`, `requests.post(`, `requests.` — use `httpx.AsyncClient` instead +- `open(` without `aiofiles` — synchronous file I/O blocks the event loop +- Synchronous DB calls in async route handlers + +Run: `rg "time\.sleep|requests\.(get|post|put|delete|patch|head)|^[^#]*\bopen\(" ` + +### 2. Missing await on coroutines +Unawaited coroutines silently return a coroutine object instead of the result. Look for: +- Assignment of async function call without `await` +- `store.create(`, `store.get(`, or any `async def` call without preceding `await` + +### 3. N+1 query patterns +A loop that calls the store once per item instead of batching: +```python +# BAD +for item_id in ids: + item = await store.get(item_id) # N queries + +# GOOD +items = await store.get_many(ids) # 1 query +``` +Flag any `for`/`async for` loop containing a store call. + +### 4. Unbounded list responses +Any endpoint returning a full collection without pagination is a risk at scale: +- `GET /` routes returning `list[Model]` with no `limit`/`offset` or `cursor` parameter +- `store.list()` calls with no upper bound + +### 5. Unclosed resources +Async clients, file handles, and streams must use `async with`: +- `httpx.AsyncClient()` not used as a context manager +- `aiofiles.open()` not used as a context manager +- Any `AsyncGenerator` not properly consumed + +### 6. Inefficient serialization +- `.model_dump()` called inside a loop — move outside +- `.model_validate()` called on already-validated objects + +### 7. PATCH over-serialization +PATCH endpoints that use `response_model=` without `response_model_exclude_unset=True` send every field including unchanged ones. Flag `router.patch(` decorators missing `response_model_exclude_unset=True`. + +### 8. Large in-memory operations +Sorting or filtering a full list in Python that should be pushed to the store layer: +```python +# BAD +items = await store.list() +return sorted(items, key=lambda x: x.created_at) # O(N) in memory +``` + +## Output format + +List each violation as: +``` +[N] : +``` + +If no violations: output exactly `LGTM — no performance issues found.` + +Do not output summaries, explanations, or suggestions beyond the numbered list. diff --git a/.claude/agents/planner.md b/.claude/agents/planner.md index 32d2792..bb2a744 100644 --- a/.claude/agents/planner.md +++ b/.claude/agents/planner.md @@ -1,5 +1,6 @@ --- name: planner +description: Read-only agent that produces implementation plans in docs/plans/. Does not edit source or test files. model: claude-sonnet-4-6 tools: - Read @@ -18,14 +19,23 @@ You are the **planner** agent. Your job is to produce a detailed implementation ## Output format -Your plan document must contain these sections in order: - -1. **Scope** — one paragraph describing what changes and what does not. -2. **Endpoints** — markdown table: Method | Path | Request body | Response body | Status codes. -3. **Models** — Pydantic class sketches (field names + types, no full code). -4. **Store interface** — method signatures only (e.g. `get_by_id(id: str) -> Item`). -5. **Test plan** — bulleted list: one line per test case, format `route · scenario · expected status`. -6. **Open questions** — numbered list of anything that needs human decision before implementing. +Your plan document **must** use exactly these H2 headings, in this order, with no variation in spelling or heading level: + +``` +## Scope +## Endpoints +## Models +## Store interface +## Test plan +## Open questions +``` + +- **## Scope** — one paragraph describing what changes and what does not. +- **## Endpoints** — markdown table: Method | Path | Request body | Response body | Status codes. +- **## Models** — Pydantic class sketches (field names + types, no full code). +- **## Store interface** — method signatures only (e.g. `get_by_id(id: str) -> Item`). +- **## Test plan** — bulleted list: one line per test case, format `route · scenario · expected status`. +- **## Open questions** — numbered list of anything that needs human decision before implementing. ## Rules diff --git a/.claude/agents/quality-reviewer.md b/.claude/agents/quality-reviewer.md deleted file mode 100644 index 8eebf1c..0000000 --- a/.claude/agents/quality-reviewer.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -name: quality-reviewer -model: claude-haiku-4-5-20251001 -tools: - - Read - - Glob - - Grep - - Bash ---- - -You are the **quality-reviewer** agent. Perform a read-only code review of changes on the current branch, focusing on code quality, style, and CLAUDE.md compliance. - -## Constraints - -- **Read-only** — never edit or create files. -- Bash is restricted to: `git diff`, `git log`, `git status`, `rg`, `ruff check`, `mypy --no-error-summary`. -- Do not invoke other agents. - -## Review checklist - -For each changed file, check: - -1. **Type correctness** — every function has a return type annotation; no implicit `Any`. -2. **Test coverage** — every new route has happy path · validation failure (422) · not-found (404) tests. -3. **CLAUDE.md compliance** — cite the specific CLAUDE.md line number for each violation. -4. **FastAPI conventions** — `async def`, `response_model=`, `status_code=`, `Depends()` on every route. -5. **Test client** — `httpx.AsyncClient + ASGITransport` only; `TestClient` is a hard violation. -6. **Pydantic v2** — `.model_dump()` / `.model_validate()`; no `.dict()` or `.from_orm()`. -7. **Exception handling** — no bare `except`; `HTTPException` with meaningful `detail`. -8. **Forbidden writes** — no changes outside `src/` and `tests/` (no `.claude/`, `pyproject.toml`, `.github/`, `Dockerfile`, `.devcontainer/`). - -## Output format - -Produce a numbered list. Each item: `[N] : (CLAUDE.md L)`. - -If there are no violations, output exactly: `LGTM — no quality violations found.` - -Do not summarize or editorialize beyond the violation list. - -Read CLAUDE.md before acting. diff --git a/.claude/agents/security-reviewer.md b/.claude/agents/security-reviewer.md index b2b4487..14358a0 100644 --- a/.claude/agents/security-reviewer.md +++ b/.claude/agents/security-reviewer.md @@ -1,5 +1,6 @@ --- name: security-reviewer +description: Read-only agent that checks for secrets, input validation issues, and OWASP vulnerabilities. Invoke via /review. model: claude-haiku-4-5-20251001 tools: - Read diff --git a/.claude/commands/review.md b/.claude/commands/review.md index 0eb79a1..0b596f2 100644 --- a/.claude/commands/review.md +++ b/.claude/commands/review.md @@ -1,25 +1,28 @@ -Invoke **quality-reviewer** AND **security-reviewer** in parallel on the current branch diff. +Invoke **architecture-reviewer**, **performance-reviewer**, and **security-reviewer** in parallel on the current branch diff. -Both agents must: +All three agents must: - Run `git diff main...HEAD` to see all changes - Run `git log main...HEAD --oneline` to understand the commit structure - Apply their full review checklist from their system prompt -After both complete, print their outputs under headings: +After all three complete, print their outputs under headings: ``` -## Quality Review - +## Architecture Review + + +## Performance Review + ## Security Review ``` -If either output contains violations: +If any output contains violations: - List all violations together - Suggest specific fixes with file and line references - Ask whether to invoke `/implement` to address them, or proceed to `/ship` -- If fixes are applied, re-run both reviewers (counts as iteration 1) +- If fixes are applied, re-run all three reviewers (counts as iteration 1) - Maximum 2 self-correcting iterations before escalating to the user for a manual decision -If both output LGTM, print: `All clear — run /ship to push.` +If all three output LGTM, print: `All clear — run /ship to push.` diff --git a/.claude/commands/ship.md b/.claude/commands/ship.md index dc7f1b8..d836191 100644 --- a/.claude/commands/ship.md +++ b/.claude/commands/ship.md @@ -11,7 +11,18 @@ Run each command in sequence. If any command fails: - Print the full output of the failing command - Stop and report what needs fixing -If all four pass, open a pull request: +If all four pass, remind the user to update the version before opening the PR: + +``` +Current version in pyproject.toml: +Bump it there before merging if this release warrants a version change (patch · minor · major). +config.py reads the version from pyproject.toml automatically — no separate update needed. +Proceed to create the PR? (y/n) +``` + +Wait for confirmation before continuing. If the user says no, stop so they can bump the version first. + +If confirmed, open a pull request: ```bash gh pr create --title "" --body "$(cat <<'EOF' diff --git a/.claude/hooks/lint_claude_md.py b/.claude/hooks/lint_claude_md.py new file mode 100644 index 0000000..15f8411 --- /dev/null +++ b/.claude/hooks/lint_claude_md.py @@ -0,0 +1,48 @@ +"""PostToolUse hook: run structural eval lint after any .claude/**/*.md edit. + +Reads tool input from stdin (JSON), checks if the edited file is under .claude/. +If so, runs pytest evals/test_structural.py. Exits 2 on failure to block the action. +""" + +from __future__ import annotations + +import fnmatch +import json +import subprocess +import sys + + +def main() -> None: + try: + payload = json.loads(sys.stdin.read()) + except (json.JSONDecodeError, ValueError): + sys.exit(0) + + file_path: str = payload.get("tool_input", {}).get("file_path", "") + if not file_path: + sys.exit(0) + + # Normalize to relative path for matching + relative = file_path.lstrip("/") + if not fnmatch.fnmatch(relative, "*.claude/**/*.md") and not fnmatch.fnmatch( + file_path, "*/.claude/**/*.md" + ): + # Also handle paths like /app/.claude/agents/foo.md + if ".claude/" not in file_path or not file_path.endswith(".md"): + sys.exit(0) + + result = subprocess.run( # noqa: S603 + [ # noqa: S607 + "pytest", + "evals/test_structural.py", + "-q", + "--tb=short", + "--no-header", + ], + capture_output=False, + ) + sys.exit(result.returncode if result.returncode == 0 else 2) + + +if __name__ == "__main__": + main() diff --git a/.claude/settings.json b/.claude/settings.json index 1c5e668..3de2d4b 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -42,6 +42,10 @@ { "type": "command", "command": "python3 .claude/hooks/autoformat_python.py" + }, + { + "type": "command", + "command": "python3 .claude/hooks/lint_claude_md.py" } ] } @@ -73,7 +77,9 @@ "WebFetch(domain:docs.astral.sh)", "WebFetch(domain:github.com)", "WebFetch(domain:api.github.com)", - "WebFetch(domain:raw.githubusercontent.com)" + "WebFetch(domain:raw.githubusercontent.com)", + "WebFetch(domain:api-inference.huggingface.co)", + "WebFetch(domain:huggingface.co)" ], "deny": [ "Read(./.env)", diff --git a/.claude/skills/blog-post/SKILL.md b/.claude/skills/blog-post/SKILL.md new file mode 100644 index 0000000..309bd32 --- /dev/null +++ b/.claude/skills/blog-post/SKILL.md @@ -0,0 +1,73 @@ +# Blog-Post Skill + +**Trigger keywords**: blog, article, write a post, technical post, blog post, write about, publish + +--- + +## Purpose + +Produce a structured technical blog post through a short audience interview followed by drafting. + +--- + +## Interview — 5 questions + +Ask these **one at a time**: + +1. **Topic** — what is the post about? One sentence. +2. **Audience** — who is the reader? (e.g. "Python developers who know FastAPI basics but haven't used async generators") +3. **Goal** — what should the reader be able to do or understand after reading? One sentence. +4. **Tone** — pick one: tutorial / deep-dive / opinion / case-study / quick-tip +5. **Call to action** — what should the reader do next? (e.g. "try the code", "read the docs", "star the repo", "nothing") + +--- + +## Post structure + +```markdown +# + +<Hook: one short paragraph. A problem, a surprising fact, or a bold claim that makes the reader want to continue.> + +## The problem +<What pain or gap does this address? Be concrete — show, don't tell.> + +## The solution +<High-level explanation. Why this approach? What makes it the right choice here?> + +## How it works +<Step-by-step or layered explanation. Include code examples from the actual project stack.> + +### Step 1 — <name> +<Explanation + code block> + +### Step 2 — <name> +<Explanation + code block> + +## Gotchas & edge cases +<2–3 things that trip people up. Honest about limitations.> + +## Takeaway +<One paragraph. What did we learn? What can the reader do right now?> + +<CTA: one sentence matching what was specified in the interview.> +``` + +--- + +## Code snippet rules + +- All code uses the project's actual stack (Python 3.11+, FastAPI, Pydantic v2, uv, httpx, pytest-asyncio) +- Snippets must be complete enough to run — no `# ... rest of code` +- Always annotate types in code examples +- If a snippet imports from the project, use the actual module paths (`from app.routes.items import ...`) + +--- + +## Rules + +- Title must be specific: "Streaming Claude responses in FastAPI with Server-Sent Events" not "Using Claude in Python" +- No filler phrases ("In this article, I will...", "As we can see...", "It's worth noting that...") +- Maximum one H1 (the title) — use H2/H3 for sections +- Keep the post to 800–1500 words unless the user specifies otherwise +- After drafting, ask: "Want me to adjust the tone, expand any section, or add more code examples?" diff --git a/.claude/skills/doc/SKILL.md b/.claude/skills/doc/SKILL.md new file mode 100644 index 0000000..c437a8a --- /dev/null +++ b/.claude/skills/doc/SKILL.md @@ -0,0 +1,96 @@ +# Doc Skill + +**Trigger paths**: `docs/**/*.md` +**Trigger keywords**: document, write docs, generate docs, README, API reference, add documentation + +--- + +## Purpose + +Read source code and configuration; write accurate project documentation into `docs/`. Never invent behaviour that isn't in the code. + +--- + +## Workflow + +1. Read `src/app/main.py` — understand routes mounted, lifespan, middleware. +2. Read `src/app/routes/*.py` — collect all endpoints (method, path, request/response models, status codes). +3. Read `src/app/models/*.py` — collect field names, types, validators. +4. Read `src/app/core/config.py` — collect all settings and their env var names. +5. Read `pyproject.toml` — collect Python version, runtime dependencies. +6. Read `Dockerfile` / `docker-compose.yml` if present — collect port, volume, env requirements. +7. Write to `docs/` only — never modify `src/`. + +--- + +## Standard doc sections + +### Project overview (`docs/README.md` or root `README.md`) +```markdown +# <Project name> + +One-paragraph description: what it does, who uses it, key technology choices. + +## Quick start +Steps to run locally (clone → configure env → start). + +## Architecture +ASCII diagram of layers (routes → store → models) and external dependencies. +``` + +### API reference (`docs/api.md`) +Auto-generate from the running app or from source: +```bash +python3 -c " +import json, sys +sys.path.insert(0, 'src') +from app.main import app +schema = app.openapi() +for path, methods in schema['paths'].items(): + for method, op in methods.items(): + print(f'{method.upper()} {path} — {op.get(\"summary\",\"\")}') +" +``` + +Format each endpoint as: +```markdown +### POST /items +**Request body**: `ItemCreate` — `name: str`, `description: str | None` +**Response** `201`: `Item` +**Response** `422`: validation error +``` + +### Environment variables (`docs/configuration.md`) +For each field in `Settings`: +```markdown +| Variable | Default | Required | Description | +|---|---|---|---| +| `APP_NAME` | `my-service` | No | Application display name | +| `ANTHROPIC_API_KEY` | — | Yes | Anthropic API key for Claude calls | +``` + +### Architecture diagram (ASCII) +``` +┌──────────────┐ HTTP ┌─────────────┐ +│ Client │ ───────────▶ │ FastAPI │ +└──────────────┘ │ routes/ │ + └──────┬──────┘ + │ Depends() + ┌──────▼──────┐ + │ store/ │ + └──────┬──────┘ + │ + ┌──────▼──────┐ + │ models/ │ + └─────────────┘ +``` + +--- + +## Rules + +- Only document behaviour that exists in the source code — never speculate. +- If a setting has no default, mark it as **Required**. +- Keep code examples runnable — test them before writing. +- Update existing docs rather than creating duplicates. +- After writing, tell the user which files were created/updated and suggest running `/review` if code was touched. diff --git a/.claude/skills/frontend/SKILL.md b/.claude/skills/frontend/SKILL.md new file mode 100644 index 0000000..9fb1ced --- /dev/null +++ b/.claude/skills/frontend/SKILL.md @@ -0,0 +1,126 @@ +# Frontend Skill + +**Trigger paths**: `src/app/ui/**`, `**/*.html`, `**/templates/**` +**Trigger keywords**: dashboard, landing page, Tailwind, HTML, frontend, UI, web page, HTMX, TailAdmin + +--- + +## FastAPI + Jinja2 — template setup + +Templates are rendered by the existing `Jinja2Templates` instance in `main.py`. Add new pages by: + +1. Creating the HTML file in `src/app/ui/` (or `src/app/templates/`) +2. Adding a GET route that calls `templates.TemplateResponse` + +```python +@router.get("/dashboard", response_class=HTMLResponse, status_code=200) +async def dashboard(request: Request) -> Response: + return templates.TemplateResponse(request, "dashboard.html", {"title": "Dashboard"}) +``` + +--- + +## Tailwind CSS — via CDN (dev / small projects) + +```html +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>{{ title }} + + + + + + +``` + +For production, use the Tailwind CLI (no Node required): +```bash +# Download standalone CLI binary for the platform +# uv add --dev tailwindcss # not available — use the standalone binary +curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64 +chmod +x tailwindcss-linux-x64 +./tailwindcss-linux-x64 -i src/app/ui/input.css -o src/app/ui/output.css --minify +``` + +--- + +## Common UI patterns + +### Simple data table +```html +
+ + + + + + + + + {% for item in items %} + + + + + {% endfor %} + +
NameStatus
{{ item.name }}{{ item.status }}
+
+``` + +### HTMX — lightweight interactivity without JavaScript + +Add `htmx.org` to the template head for partial page updates: +```html + +``` + +Fetch a partial and swap into a div: +```html + +
+ {% include "partials/item_list.html" %} +
+``` + +The corresponding route returns an HTML fragment (not a full page): +```python +@router.post("/items/partial", response_class=HTMLResponse, status_code=200) +async def create_item_partial(request: Request, ...) -> Response: + return templates.TemplateResponse(request, "partials/item_card.html", {"item": new_item}) +``` + +### TailAdmin dashboard layout + +TailAdmin is a Tailwind-based admin dashboard template. Use its sidebar + topbar layout as a base: +- Sidebar: `