Merge Secondary + Outlined accent + Outlined accent + icon into Outlined, Card muted action into Card action, and Delete link into Link. Each merged variant uses colour modifiers instead of separate style guide rows.
758 lines
36 KiB
Markdown
758 lines
36 KiB
Markdown
# Claude Code Guidelines for Brewlog
|
|
|
|
## Project Overview
|
|
|
|
Brewlog is a self-hosted coffee logging platform built in Rust. It provides:
|
|
|
|
- HTTP server with web UI (Axum + Askama templates + Datastar)
|
|
- REST API for programmatic access
|
|
- CLI client for command-line operations
|
|
- SQLite database
|
|
|
|
## Build & Test Commands
|
|
|
|
```bash
|
|
cargo build # Build the project
|
|
cargo test # Run all tests
|
|
cargo clippy --allow-dirty --fix # Lint and auto-fix
|
|
cargo fmt # Format code
|
|
```
|
|
|
|
### Database Migrations
|
|
|
|
Create new migrations using sqlx:
|
|
|
|
```bash
|
|
sqlx migrate add <migration_name> # Creates migrations/NNNN_<migration_name>.sql
|
|
```
|
|
|
|
Migration files are plain SQL in the `migrations/` directory, numbered sequentially (e.g., `0008_remove_gear_notes.sql`).
|
|
|
|
## Workflow Requirements
|
|
|
|
**Before finishing any task**, always:
|
|
|
|
1. Run `cargo clippy --allow-dirty --fix && cargo fmt` to lint and format
|
|
2. Run `cargo build` to verify compilation
|
|
3. Run `cargo test` if changes affect testable code
|
|
4. Update `README.md` if the change adds/removes/renames CLI commands, environment variables, or user-facing features
|
|
5. Update `scripts/bootstrap-db.sh` if the change adds/removes/renames CLI commands, flags, or entity fields used by the bootstrap script
|
|
6. Provide a **draft commit message** using Conventional Commits format
|
|
|
|
Example commit message:
|
|
|
|
```
|
|
feat(gear): add category filtering to gear list
|
|
|
|
- Add GearFilter with optional category field
|
|
- Update repository to apply filter in SQL WHERE clause
|
|
- Add --category flag to CLI list-gear command
|
|
|
|
```
|
|
|
|
## Architecture
|
|
|
|
The codebase follows **Clean Architecture / Domain-Driven Design** with four layers:
|
|
|
|
```
|
|
src/
|
|
├── domain/ # Pure business logic, no external dependencies
|
|
│ ├── errors.rs # RepositoryError enum
|
|
│ ├── ids.rs # Typed ID wrappers (RoasterId, RoastId, BagId, BrewId, GearId, etc.)
|
|
│ ├── listing.rs # Pagination & sorting (SortKey, ListRequest, Page, PageSize)
|
|
│ ├── repositories.rs # Repository traits
|
|
│ └── {entity}.rs # Entity definitions (roasters, roasts, bags, brews, gear, etc.)
|
|
│
|
|
├── infrastructure/ # External integrations (database, HTTP clients, third-party APIs)
|
|
│ ├── repositories/ # SQL implementations of repository traits
|
|
│ ├── client/ # HTTP client for CLI
|
|
│ ├── ai/ # OpenRouter LLM integration for AI extraction
|
|
│ ├── foursquare.rs # Foursquare Places API for nearby cafe search
|
|
│ └── database.rs # Database pool abstraction
|
|
│
|
|
├── application/ # HTTP server, routes, middleware, services
|
|
│ ├── routes/ # Axum route handlers
|
|
│ ├── services/ # Entity services (create + timeline orchestration)
|
|
│ └── errors.rs # HTTP error mapping
|
|
│
|
|
└── presentation/ # User interfaces
|
|
├── cli/ # CLI commands and argument parsing
|
|
└── web/ # View models for templates
|
|
```
|
|
|
|
**Dependency flow**: `presentation → application → domain ← infrastructure`
|
|
|
|
## Gotchas
|
|
|
|
These are non-obvious footguns that will cause bugs if missed.
|
|
|
|
**1. `datastar-fetch` event bubbles through the DOM.** When a page has multiple forms with `data-on:datastar-fetch` handlers, each handler fires for events from *any* `@post`/`@get` in the same DOM tree. **Every handler must guard with its own in-progress signal**:
|
|
|
|
```html
|
|
<form data-on:submit="$_extracting = true; @post(...)"
|
|
data-on:datastar-fetch="if (!$_extracting) return;
|
|
if (evt.detail.type === 'finished') { $_extracting = false }
|
|
else if (evt.detail.type === 'error') { $_extracting = false; $_extractError = 'Failed.' }">
|
|
```
|
|
|
|
Only reset state on `finished` or `error`, never unconditionally.
|
|
|
|
**2. No `data-model` in Datastar v1** — it is silently ignored. Use `data-bind:_signal-name` for two-way binding.
|
|
|
|
**3. Signal patching requires JSON, not HTML.** Datastar only processes signal updates from `application/json` responses (via `render_signals_json()`), not from `data-signals` attributes in DOM-patched HTML fragments.
|
|
|
|
**4. List partial must be OUTSIDE the form section.** In page templates, the `{% include %}` for the list partial must be a **sibling** of the form `<section>`, not nested inside it. Placing it inside removes the flex gap between form and list.
|
|
|
|
**5. Table wrapper must be `<section>`, not `<div>`.** List partials wrap the table in `<section class="rounded-lg border bg-surface">`.
|
|
|
|
**6. Infinite scroll sentinel needs `md:hidden`.** The sentinel `<div class="infinite-scroll-sentinel h-4 md:hidden">` must include `md:hidden` to avoid unwanted height on desktop. Same applies when creating sentinels dynamically in JS.
|
|
|
|
**7. Use token-based text classes, never hardcoded `text-stone-*`.** Always use `text-text`, `text-text-secondary`, `text-text-muted` which adapt between light and dark themes.
|
|
|
|
**8. Static assets need explicit routes and cache headers.** All assets are embedded at compile time via `include_str!()`/`include_bytes!()` with explicit routes in `application/routes/app/mod.rs`. There is no `tower-http` static file serving. Every static asset handler must return a `cache-control: public, max-age=604800` header alongside `content-type`.
|
|
|
|
**9. CSP must be updated when adding external resources.** The `Content-Security-Policy` header is set in `application/routes/mod.rs`. If you add a new external script, stylesheet, font, or image source, update the corresponding CSP directive (`script-src`, `style-src`, `font-src`, `img-src`) or the browser will block it silently. Datastar requires `'unsafe-inline'` and `'unsafe-eval'` in `script-src`.
|
|
|
|
**10. Cookie `Secure` flag is on by default.** Session cookies are marked `Secure` unless `BREWLOG_INSECURE_COOKIES=true` is set. Local HTTP development needs this env var in `.env`. Do not use the old `BREWLOG_SECURE_COOKIES` variable — it no longer exists.
|
|
|
|
**11. URL fields must validate scheme server-side.** Any user-supplied URL field (e.g., roaster `homepage`) must reject non-`http(s)` schemes to prevent `javascript:` or `data:` XSS. Use the `is_valid_url_scheme()` helper in `domain/roasters.rs` as a reference pattern.
|
|
|
|
**12. Datastar create handlers must check referer for fragment targets.** When a `@post` creates an entity and returns a list fragment (e.g., `#brew-list`), that fragment only exists on the entity's data page. If the same `@post` can fire from other pages (homepage, timeline), check the `Referer` header and return a reload-script response for pages that lack the target element. See `create_brew` in `application/routes/api/brews.rs`.
|
|
|
|
## Backend Patterns
|
|
|
|
### SQLite Configuration
|
|
|
|
`infrastructure/database.rs` configures SQLite pragmas at connection time:
|
|
|
|
| Pragma | Value | Purpose |
|
|
|--------|-------|---------|
|
|
| `foreign_keys` | `ON` | Enforce FK constraints |
|
|
| `journal_mode` | `WAL` | Concurrent reads during writes |
|
|
| `synchronous` | `NORMAL` | Faster writes (safe with WAL) |
|
|
| `cache_size` | `-8000` | 8 MB page cache |
|
|
| `temp_store` | `MEMORY` | Temp tables in RAM |
|
|
| `busy_timeout` | `5000` | Wait up to 5s on lock contention |
|
|
|
|
When adding new pragmas, add them after the existing ones in `Database::connect()`. Connection pool is capped at 5 — appropriate for SQLite's single-writer model.
|
|
|
|
### HTTP Middleware Stack
|
|
|
|
The middleware stack in `application/routes/mod.rs` applies layers in this order (outermost first): request tracing, cookie parsing, body size limit, security headers (`X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `CSP`, `HSTS`), and gzip compression. When adding new middleware, place it in the `ServiceBuilder` chain at the appropriate position.
|
|
|
|
### Repository Pattern
|
|
|
|
All data access goes through trait-based repositories defined in `domain/repositories.rs`. SQL implementations live in `infrastructure/repositories/`, each using a private `Record` struct with a `to_domain()` method to convert database rows to domain entities. Use typed ID wrappers from `domain/ids.rs` (e.g., `RoastId`, `BagId`) — never raw `i64`.
|
|
|
|
### Service Layer
|
|
|
|
Services in `application/services/` encapsulate "create entity + record timeline event" as a single operation.
|
|
|
|
**When to use services vs repos:**
|
|
- **Services** — for `create()` (and `finish()` for bags). These record a timeline event after the insert.
|
|
- **Repos** — for `get()`, `list()`, `update()`, `delete()`. No side effects needed.
|
|
|
|
`AppState` holds both repos and services. Route handlers call `state.xxx_service.create()` for creation and `state.xxx_repo.get()` / `.list()` / etc. for reads and updates.
|
|
|
|
The `define_simple_service!` macro in `services/mod.rs` generates services for entities whose `to_timeline_event()` needs only `&self`. This covers `RoasterService`, `CafeService`, `GearService`.
|
|
|
|
Entities needing enrichment are hand-written:
|
|
|
|
| Service | Extra repos | Why |
|
|
|---------|-------------|-----|
|
|
| `RoastService` | `roaster_repo` | Needs roaster name/slug for timeline |
|
|
| `BagService` | `roast_repo`, `roaster_repo` | `create()` + `finish()`, needs roast+roaster for timeline |
|
|
| `BrewService` | — | `create()` enriches via `get_with_details()` for timeline + response |
|
|
| `CupService` | — | `create()` enriches via `get_with_details()` for timeline |
|
|
|
|
Timeline events are display-only (not data integrity), so they use fire-and-forget error handling:
|
|
|
|
```rust
|
|
if let Err(err) = self.timeline_repo.insert(entity.to_timeline_event()).await {
|
|
warn!(error = %err, id = %entity.id, "failed to record timeline event");
|
|
}
|
|
```
|
|
|
|
### Route Module Structure
|
|
|
|
Each list-bearing route module (roasters, roasts, bags, gear, brews) follows the same structure:
|
|
|
|
1. **Path constants** — `ENTITY_PAGE_PATH` (full page URL) and `ENTITY_FRAGMENT_PATH` (with `#entity-list` anchor)
|
|
2. **`load_entity_page()`** — calls `repo.list()` and builds view models via `build_page_view()` from `support.rs`
|
|
3. **`entity_page()`** — checks `is_datastar_request()`: returns fragment for Datastar, full page otherwise
|
|
4. **`render_entity_list_fragment()`** — returns just the list partial for Datastar replacement
|
|
|
|
Create handlers follow a three-way response pattern:
|
|
|
|
```rust
|
|
if is_datastar_request(&headers) {
|
|
render_fragment(...) // Datastar → updated list fragment
|
|
} else if matches!(source, PayloadSource::Form) {
|
|
Redirect::to(...) // Browser form → redirect
|
|
} else {
|
|
Json(entity) // API → JSON
|
|
}
|
|
```
|
|
|
|
### Macros Reference
|
|
|
|
All macros have doc comments with usage examples. Check the source files for full documentation.
|
|
|
|
| Macro | Location | Purpose |
|
|
|-------|----------|---------|
|
|
| `define_simple_service!` | `application/services/mod.rs` | Generate service struct with `create()` + timeline |
|
|
| `define_get_handler!` | `application/routes/api/macros.rs` | GET `/api/v1/:entity/:id` → JSON |
|
|
| `define_enriched_get_handler!` | `application/routes/api/macros.rs` | GET with joined related entities → JSON |
|
|
| `define_delete_handler!` | `application/routes/api/macros.rs` | DELETE → fragment for Datastar or 204 for API |
|
|
| `define_list_fragment_renderer!` | `application/routes/api/macros.rs` | Generate fragment renderer for a list page |
|
|
| `define_get_command!` | `presentation/cli/macros.rs` | CLI get-entity command |
|
|
| `define_delete_command!` | `presentation/cli/macros.rs` | CLI delete-entity command |
|
|
| `push_update_field!` | `infrastructure/repositories/macros.rs` | Build dynamic UPDATE queries with `QueryBuilder` |
|
|
|
|
### SQL & Queries
|
|
|
|
Use `QueryBuilder` for dynamic queries. For UPDATE, use `push_update_field!` (see macro docs). Each repository has an `order_clause()` method for sort query generation — use `order_clause` as the method name, not `sort_clause`.
|
|
|
|
### Error Handling & Logging
|
|
|
|
**Error types**: `RepositoryError` (domain), `AppError` (HTTP with status code mapping), `anyhow::Result` (CLI).
|
|
|
|
**Logging**: `tracing` + `tracing-subscriber` with `tower-http` `TraceLayer`. Configure via `RUST_LOG` (default `info`) and `RUST_LOG_FORMAT=json` for structured output.
|
|
|
|
**Error logging rules** — never silently discard errors:
|
|
|
|
1. **`map_err(|_| StatusCode::*)` patterns** — log the original error before mapping. Use `warn!` for client-caused failures, `error!` for server-side.
|
|
2. **`.ok()` / `.ok()?` patterns** — replace with explicit match that logs before returning `None`.
|
|
3. **Fire-and-forget (`let _ = ...`)** — use `if let Err(err) = ...` and log.
|
|
4. **Background tasks (`tokio::spawn`)** — log inside the spawned future.
|
|
|
|
**CRUD logging**: Every successful create/update/delete logs at `info!` with entity ID and key fields.
|
|
|
|
**Security logging**: Auth events (login, logout, token create/revoke, passkey delete) log at `info!` with user ID.
|
|
|
|
### Foursquare Integration
|
|
|
|
Nearby cafe search uses the Foursquare Places API. Set `BREWLOG_FOURSQUARE_API_KEY`. See `infrastructure/foursquare.rs` for the implementation and `tests/server/nearby_api.rs` for the `wiremock`-based test pattern.
|
|
|
|
## Datastar & Frontend
|
|
|
|
### Core Concepts
|
|
|
|
The web UI uses [Datastar](https://data-star.dev/) for reactive updates without full page reloads.
|
|
|
|
Key attributes:
|
|
|
|
| Attribute | Purpose | Example |
|
|
|-----------|---------|---------|
|
|
| `data-signals:_name="value"` | Declare local signal (underscore = not sent to server) | `data-signals:_show-form="false"` |
|
|
| `data-show="$_signal"` | Conditional visibility | `data-show="$_showForm"` |
|
|
| `data-bind:_signal-name` | Two-way binding to input value | `data-bind:_roaster-name` |
|
|
| `data-on:event="expr"` | Event handler | `data-on:submit="$_submitting = true; @post(...)"` |
|
|
| `data-ref="_name"` | DOM element reference | `data-ref="_form"` |
|
|
| `data-text="$_signal"` | Set text content from signal | `data-text="$_cafeName"` |
|
|
| `data-attr:attr="$_signal"` | Set attribute from signal | `data-attr:value="$_roastId"` |
|
|
| `@get/@post/@put/@delete` | HTTP actions with Datastar headers | `@post('/api/v1/roasters', {contentType: 'form'})` |
|
|
|
|
### Signal Naming
|
|
|
|
Signal names use **kebab-case** in HTML attributes and auto-convert to **camelCase** in JS expressions and JSON:
|
|
|
|
- HTML: `data-signals:_roaster-name="''"` or `data-bind:_roaster-name`
|
|
- JS expression: `$_roasterName`
|
|
- JSON key: `_roasterName`
|
|
|
|
**Naming conventions for common signals:**
|
|
|
|
| Signal | Purpose |
|
|
|--------|---------|
|
|
| `_extracting` | AI extraction in progress |
|
|
| `_submitting` | Form save/create in progress |
|
|
| `_extract-error` / `_error` | Error message |
|
|
| `_show-{thing}` | Boolean visibility toggle |
|
|
|
|
### Response Types
|
|
|
|
Two response formats exist for Datastar:
|
|
|
|
**HTML fragments** — for replacing DOM sections. Use `render_fragment(template, selector)` from `support.rs`, which sets `datastar-selector` and `datastar-mode: replace` headers.
|
|
|
|
**JSON signal patches** — for updating signal values (e.g., AI extraction filling form fields). Use `render_signals_json(&[("_signal-name", value)])` from `support.rs`. Signal keys are passed in kebab-case; the function converts to camelCase for the JSON response.
|
|
|
|
### Datastar vs JavaScript
|
|
|
|
**Use Datastar for:**
|
|
- Visibility toggling (`data-show` + signals)
|
|
- List CRUD — delete with `confirm() && @delete()`, create with `@post()` + fragment re-render
|
|
- Debounced search — `data-on:input__debounce.300ms` + `@get()` with `responseOverrides`
|
|
- AI extraction signal patching
|
|
- Multi-step wizards — step signals (`$_step`) with `data-show="$_step === N"`
|
|
- Searchable selection lists — `<searchable-select>` with `data-on:change`
|
|
|
|
**Use JavaScript for:**
|
|
- Browser APIs: WebAuthn, clipboard, geolocation, FileReader
|
|
- Infinite scroll (`IntersectionObserver` in `base.html`)
|
|
- Theme toggle — must run in `<head>` before DOM renders
|
|
- Any flow requiring `window.location.reload()` after completion
|
|
|
|
### AI Extraction Pattern
|
|
|
|
Pages with AI-powered form filling use a Datastar-native pattern. Extraction endpoints return JSON signal patches that Datastar merges into the signal store, and `data-bind` pushes values into form fields automatically.
|
|
|
|
Template structure:
|
|
|
|
```html
|
|
<section data-signals:_extracting="false" data-signals:_extract-error="''" data-signals:_submitting="false">
|
|
<!-- Extraction form -->
|
|
<form id="{id}-extract-form"
|
|
data-on:submit="$_extracting = true; $_extractError = ''; @post('{endpoint}', {contentType: 'form'})"
|
|
data-on:datastar-fetch="if (!$_extracting) return;
|
|
if (evt.detail.type === 'finished') { $_extracting = false }
|
|
else if (evt.detail.type === 'error') { $_extracting = false; $_extractError = 'Extraction failed.' }">
|
|
<input type="hidden" name="image" id="{id}-image" />
|
|
<div data-show="!$_extracting">
|
|
<brew-photo-capture target-input="{id}-image" target-form="{id}-extract-form" class="...">
|
|
Take Photo
|
|
</brew-photo-capture>
|
|
<input name="prompt" type="text" placeholder="Or describe..." />
|
|
<button type="submit">Go</button>
|
|
</div>
|
|
<div data-show="$_extracting" style="display:none"><!-- spinner --></div>
|
|
<p data-show="$_extractError" data-text="$_extractError" style="display:none" class="text-sm text-red-600"></p>
|
|
</form>
|
|
|
|
<!-- Main form with data-bind fields populated by extraction -->
|
|
<form data-on:submit="$_submitting = true; @post(...)">
|
|
<input name="name" data-bind:_roaster-name class="input-field" />
|
|
</form>
|
|
</section>
|
|
```
|
|
|
|
Server-side handler:
|
|
|
|
```rust
|
|
let signals = vec![
|
|
("_roaster-name", Value::String(result.name)),
|
|
("_roaster-country", Value::String(result.country)),
|
|
];
|
|
render_signals_json(&signals)
|
|
```
|
|
|
|
### Web Components
|
|
|
|
**`<brew-photo-capture>`** (`static/js/components/photo-capture.js`):
|
|
|
|
| Attribute | Purpose |
|
|
|-----------|---------|
|
|
| `target-input` | ID of hidden `<input>` that receives the data URL |
|
|
| `target-form` | ID of `<form>` to submit after reading the photo |
|
|
|
|
Clicking the element opens the camera/file picker, reads the file as a data URL, sets the target input, and submits the form.
|
|
|
|
**`<searchable-select>`** (`static/js/components/searchable-select.js`):
|
|
|
|
| Attribute | Purpose |
|
|
|-----------|---------|
|
|
| `name` | Name for hidden `<input>` in form submission |
|
|
| `placeholder` | Search input placeholder (default: "Type to search...") |
|
|
|
|
| Event | Detail |
|
|
|-------|--------|
|
|
| `change` | `{ value, display, data }` — fires on selection |
|
|
| `clear` | Fires when selection is cleared |
|
|
|
|
Place `<button>` children with `value` and `data-display` attributes. The component handles search filtering, hidden input, and selected-value display internally.
|
|
|
|
### FlexiblePayload
|
|
|
|
Handlers accept both JSON and form data via `FlexiblePayload<T>`. When form fields don't map 1:1 to the domain `New*` struct (e.g., a roaster name needing resolution to an ID), use a `*Submission` newtype that handles the conversion.
|
|
|
|
## Design System
|
|
|
|
### CSS Build
|
|
|
|
**Tailwind CSS v4** built via standalone CLI (no Node.js). `build.rs` runs `tailwindcss` automatically during `cargo build` — no manual step needed. Source: `static/css/input.css`. Output: `static/css/styles.css` (gitignored).
|
|
|
|
### Design Tokens
|
|
|
|
Colors are defined as CSS custom properties in `static/css/input.css` (`:root` for light, `[data-theme="dark"]` for dark) and mapped to Tailwind utilities via `@theme`. See `input.css` for exact colour values.
|
|
|
|
| Token | Tailwind classes |
|
|
|-------|-----------------|
|
|
| `--page` | `bg-page` |
|
|
| `--surface` | `bg-surface` |
|
|
| `--surface-alt` | `bg-surface-alt` |
|
|
| `--border` | default `border` / `divide-y` |
|
|
| `--accent` | `bg-accent`, `text-accent` |
|
|
| `--accent-hover` | `bg-accent-hover`, `hover:bg-accent-hover` |
|
|
| `--accent-subtle` | `bg-accent-subtle` |
|
|
| `--accent-text` | `text-accent-text` |
|
|
| `--text` | `text-text` |
|
|
| `--text-secondary` | `text-text-secondary` |
|
|
| `--text-muted` | `text-text-muted` |
|
|
|
|
Dark mode uses `[data-theme="dark"]` on `<html>`, set via a `<script>` in `<head>` that reads `localStorage` / `prefers-color-scheme` before body render. The `dark:` Tailwind prefix is available as an escape hatch.
|
|
|
|
### Component Classes
|
|
|
|
Defined in `input.css` — use these instead of ad-hoc utility combinations:
|
|
|
|
| Class | Use for |
|
|
|-------|---------|
|
|
| `.input-field` | All text/number/select inputs |
|
|
| `.btn-adjust` | +/- stepper buttons flanking a numeric input |
|
|
| `.sticky-submit` | Mobile-fixed / desktop-static submit bar |
|
|
| `.pill` + variant | Tags, status badges (always include `.pill` base) |
|
|
| `.tab` / `.tab-active` | Desktop tab buttons |
|
|
| `.tab-mobile` / `.tab-mobile-active` | Mobile tab dropdown items |
|
|
| `.responsive-table` | Tables that become card layout on mobile |
|
|
| `.scrollbar-hide` | Hide scrollbar on horizontal scroll containers |
|
|
| `.timeline-*` / `.tl-card` | Timeline page layout (line, node, heading, card) |
|
|
| `.text-2xs` | Micro text (0.65rem) — available for fine print |
|
|
| `.small-caps` | Font-variant small-caps — table headers, footer |
|
|
|
|
**Pill variants** (always pair with `.pill` base class):
|
|
|
|
| Variant | Use |
|
|
|---------|-----|
|
|
| `.pill-muted` | Categories, neutral status |
|
|
| `.pill-success` | Positive status ("Open") |
|
|
| `.pill-warning` | Warning status |
|
|
| `.pill-floral` through `.pill-vegetal` | Tasting note SCA wheel colours |
|
|
|
|
Do not override pill colour/border/padding with utilities.
|
|
|
|
### UI Patterns
|
|
|
|
#### Page Layout
|
|
|
|
Main container from `base.html`: `mx-auto flex w-full max-w-5xl flex-col gap-8 px-6 py-4 md:py-10`. All page sections are direct children spaced by `gap-8`.
|
|
|
|
Cards use `rounded-lg border bg-surface` — no shadows. Padding varies by context:
|
|
- `p-4` — compact cards (brew cards, bag cards, stat cards)
|
|
- `p-5` — form sections, account sections, timeline cards
|
|
- `p-6` — auth pages (login, register)
|
|
|
|
#### Typography
|
|
|
|
| Level | Classes | Use |
|
|
|-------|---------|-----|
|
|
| Page title | `text-3xl font-semibold` | `<h1>` on each page |
|
|
| Auth title | `text-2xl font-semibold text-accent` | Login/register headers |
|
|
| Section title | `text-lg font-semibold text-text` | `<h2>` for page sections |
|
|
| Subsection title | `text-base font-semibold text-text` | `<h3>` within cards |
|
|
| Form subsection | `text-sm font-semibold text-text` | `<h4>` grouping fields (e.g. "Coffee", "Grinder") |
|
|
| Form field label | `text-xs font-semibold text-text-muted uppercase tracking-wide` | `<h4>` / field group headings |
|
|
| Body | `text-sm text-text-secondary` | Description text |
|
|
| Muted | `text-xs text-text-muted` | Metadata, subtext |
|
|
| Micro | `text-2xs text-text-muted` | Fine print (available utility, currently unused) |
|
|
|
|
Page headers: `<header class="flex flex-col gap-2">` with `<h1>` + `<p class="max-w-2xl text-sm text-text-secondary">`.
|
|
|
|
**Font weight rules:**
|
|
- `font-bold` — stat values only (`text-lg font-bold text-text`)
|
|
- `font-semibold` — headings and primary action buttons
|
|
- `font-medium` — card action buttons, link-style actions, tab labels, table content, outlined/secondary buttons
|
|
|
|
**Small-caps pattern:** `text-xs uppercase tracking-wide` — used for form field labels, timeline kind labels. Table headers and the footer use the `.small-caps` utility class.
|
|
|
|
#### Icons
|
|
|
|
Entity-type icon mapping (consistent across all pages):
|
|
|
|
| Entity | Icon macro |
|
|
|--------|-----------|
|
|
| brew | `icons::beaker` |
|
|
| roast | `icons::coffee_bean` |
|
|
| roaster | `icons::fire` |
|
|
| bag | `icons::bag` |
|
|
| cup | `icons::cup` |
|
|
| cafe | `icons::location` |
|
|
| gear | `icons::grinder` |
|
|
|
|
**Size by context:**
|
|
|
|
| Size | Context |
|
|
|------|---------|
|
|
| `h-3 w-3` | Timeline card category labels (inline with `text-xs`) |
|
|
| `h-4 w-4` | Buttons with text, tab buttons, account page actions, list action links, form indicator icons |
|
|
| `h-5 w-5` | Nav icons, quick action buttons, loading spinners, homepage activity rows, timeline expand/collapse chevrons |
|
|
| `h-6 w-6` | Stat cards on homepage |
|
|
|
|
Always include `shrink-0` on icons inside flex containers to prevent shrinking.
|
|
|
|
When an icon appears alongside text in a button or label, use `inline-flex items-center gap-N` on the container:
|
|
- `gap-1` — compact inline labels (timeline categories)
|
|
- `gap-1.5` — card action buttons, tab buttons (mobile selected label)
|
|
- `gap-2` — standard buttons with icons
|
|
|
|
#### Buttons
|
|
|
|
| Variant | Classes | Use |
|
|
|---------|---------|-----|
|
|
| Primary | `inline-flex items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover` | Form submits (Save, Log Brew, Check In), New Backup |
|
|
| Outlined | `inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium transition hover:bg-surface-alt` | Bordered secondary actions. Colour variants: `text-text` for Cancel/Back, `text-accent hover:text-text` for actions (Restore, Reset, Sign Out, Delete, Revoke) |
|
|
| Card action | `inline-flex h-8 items-center justify-center gap-1.5 rounded-md border px-2 text-sm font-medium transition hover:bg-surface-alt` | Compact inline card buttons. Colour variants: `text-accent hover:text-accent-hover` for positive actions (Brew), `text-text-muted hover:text-text` for neutral (Close Bag) |
|
|
| Link | `inline-flex items-center gap-1 text-sm font-medium` | Borderless inline actions. Colour variants: `text-accent hover:text-accent-hover` for actions (View all, Brew Again, Homepage), `text-text-muted hover:text-red-600` for destructive (Delete) |
|
|
| Text-only | `text-xs text-text-muted hover:text-text` | Minimal buttons: Change, Back in summary bars |
|
|
| Nav icon | `rounded-md p-1.5 text-text-muted transition hover:text-text-secondary` | Theme toggle, user menu |
|
|
| Adjustment | `.btn-adjust` CSS class | +/- steppers |
|
|
|
|
**Sizing rules:**
|
|
- `w-full` for full-width CTAs (form submits)
|
|
- `py-3` for larger touch targets (login, register, check-in submit)
|
|
- `disabled:opacity-50 disabled:cursor-not-allowed` with `data-attr:disabled` for loading states
|
|
|
|
Submit buttons right-aligned: `<div class="flex items-center justify-end gap-2">`. Outlined comes first when paired with Primary.
|
|
|
|
#### Forms
|
|
|
|
Label + input pattern:
|
|
|
|
```html
|
|
<label class="flex flex-col gap-1 text-sm">
|
|
<span class="text-text">Field Name *</span>
|
|
<input type="text" name="field" required class="input-field" placeholder="Example" />
|
|
</label>
|
|
```
|
|
|
|
Mark required fields with `*`. Multi-column: `<div class="grid gap-4 sm:grid-cols-2">`. Form card wrapper: `<div class="rounded-lg border bg-surface p-5">` with `flex flex-col gap-4` (or `gap-6` with section headers).
|
|
|
|
Checkbox: `<label class="inline-flex items-center gap-2 text-sm cursor-pointer">` with `<input class="accent-orange-700" />`.
|
|
|
|
#### Feedback States
|
|
|
|
| Variant | Classes |
|
|
|---------|---------|
|
|
| Error text | `text-sm text-red-600` with `data-show`/`data-text` bound to error signal |
|
|
| Error alert | `rounded-md bg-red-100 border border-red-300 p-3 text-sm text-red-800` |
|
|
| Success alert | `rounded-md bg-green-100 border border-green-300 p-4 text-sm text-green-800` |
|
|
| Warning alert | `rounded-md bg-yellow-100 border border-yellow-300 p-3 text-sm text-yellow-800` |
|
|
| Info alert | `rounded-md bg-green-50 border border-green-200 px-3 py-2 text-sm text-green-800` |
|
|
| Loading spinner | `flex items-center gap-3 text-sm text-accent` with `{{ icons::spinner("h-5 w-5") }}` + `Saving…` |
|
|
|
|
Always pair loading spinners with `data-show` bound to an in-progress signal.
|
|
|
|
#### Empty & Prerequisite States
|
|
|
|
When a form requires a parent entity that doesn't exist yet, show a static message with `text-lg font-semibold text-text` heading and `text-sm text-text-secondary` body.
|
|
|
|
Homepage empty states use blurred placeholder cards with an overlay: `blur-[2px] select-none pointer-events-none` on the placeholder grid, `absolute inset-0 z-10 flex items-center justify-center` on the overlay with `text-lg font-semibold text-text-muted` label.
|
|
|
|
#### Selected Item Indicator
|
|
|
|
In multi-step flows, show: `rounded-lg border bg-surface px-4 py-3 flex items-center justify-between` — icon + name + muted subtext on left, text-only "Change" button (`text-xs text-text-muted hover:text-text`) on right.
|
|
|
|
#### Navigation
|
|
|
|
Active state: `text-accent font-medium`. Inactive: `text-text-muted hover:text-text transition`. Desktop nav: `hidden md:flex items-center gap-6`. Mobile nav: toggled via `data-show`.
|
|
|
|
#### Tabs
|
|
|
|
Tabs are used on `/data` and `/add` pages. Each tab button shows an entity-type icon + label.
|
|
|
|
Desktop: `<nav class="hidden md:flex gap-1.5">` with `.tab` buttons. Active state via `data-class:tab-active`.
|
|
|
|
Mobile: dropdown selector — trigger button shows current tab with chevron, options list uses `.tab-mobile` with `data-class:tab-mobile-active`.
|
|
|
|
Tab keys differ between pages: singular on `/add` (brew, roast, bag) vs plural on `/data` (brews, roasts, bags). Icon conditionals handle both: `tab.key == "brew" || tab.key == "brews"`.
|
|
|
|
#### Responsive Patterns
|
|
|
|
- Desktop-only: `hidden md:flex` or `hidden md:block`
|
|
- Mobile-only: `md:hidden`
|
|
- Pagination: `hidden md:flex` (desktop); infinite scroll (mobile)
|
|
|
|
#### Spacing
|
|
|
|
| Context | Gap |
|
|
|---------|-----|
|
|
| Page sections (main) | `gap-8` |
|
|
| Major form sections | `gap-6` |
|
|
| Related field groups | `gap-4` |
|
|
| Button groups | `gap-2` or `gap-3` |
|
|
| Icon + text (buttons) | `gap-2` |
|
|
| Icon + text (compact) | `gap-1` or `gap-1.5` |
|
|
| Label to input | `gap-1` |
|
|
| Navigation links | `gap-6` |
|
|
|
|
## Tables & Lists
|
|
|
|
### Page Template Structure
|
|
|
|
Each list page has form and list as **separate siblings** under `<main>` (which uses `flex flex-col gap-6`):
|
|
|
|
```html
|
|
<section data-signals:_show-form="false">
|
|
<header>...</header>
|
|
{% if is_authenticated %}
|
|
<div data-show="$_showForm" style="display: none"><!-- Form --></div>
|
|
{% endif %}
|
|
</section>
|
|
|
|
{% include "partials/lists/{entity}_list.html" %}
|
|
{% endblock %}
|
|
```
|
|
|
|
### List Partial Structure
|
|
|
|
List partials live in `templates/partials/lists/`. Each follows:
|
|
|
|
```html
|
|
{% import "partials/lists/table.html" as table %}
|
|
|
|
<div id="{entity}-list" class="mt-6" data-star-scope="{entity}">
|
|
{% if items.is_empty() && !navigator.has_search() %}
|
|
<div class="rounded-lg border border-dashed px-4 py-6 text-sm text-text-secondary">
|
|
<p class="text-center">No {entities} recorded yet.</p>
|
|
</div>
|
|
{% else %}
|
|
<section class="rounded-lg border bg-surface" ...>
|
|
{% call table::search_header(navigator, "#{entity}-list") %}
|
|
<table class="responsive-table ...">...</table>
|
|
{% if items.is_empty() %}
|
|
<div class="p-8 text-center text-text-muted">No {entities} match your search.</div>
|
|
{% endif %}
|
|
{% call table::pagination_header(items, navigator, "#{entity}-list") %}
|
|
{% if items.has_next() %}
|
|
<div class="infinite-scroll-sentinel h-4 md:hidden" aria-hidden="true"></div>
|
|
{% endif %}
|
|
</section>
|
|
{% endif %}
|
|
</div>
|
|
```
|
|
|
|
**Exception**: the bags partial uses a dual-section layout (open-bag cards + history table).
|
|
|
|
### Table Macros
|
|
|
|
Three macros in `templates/partials/lists/table.html`:
|
|
|
|
- **`search_header(navigator, target_selector)`** — search input with debounced `@get` + `history.pushState`
|
|
- **`pagination_header(items, navigator, target_selector)`** — prev/next, page count, rows-per-page; hidden on mobile via `pagination-controls hidden md:flex`
|
|
- **`sortable_header(label, key, navigator, target_selector)`** — clickable column header with sort arrows
|
|
|
|
### "Added" Column
|
|
|
|
Every table has a sortable "Added" column as its **first column** (sorted by `created-at`), styled with `text-xs font-medium text-text-secondary` to visually separate from content columns.
|
|
|
|
### Actions Column
|
|
|
|
When a row has multiple action buttons, wrap them in `<div class="inline-flex items-center gap-1">` inside the `<td>` to prevent vertical stacking.
|
|
|
|
### Responsive Table Pattern
|
|
|
|
Tables use the `responsive-table` CSS class (card layout on mobile, standard table on desktop).
|
|
|
|
**Desktop** — combine related fields with subtext:
|
|
|
|
```html
|
|
<td data-label="Coffee" class="px-4 py-3 whitespace-nowrap">
|
|
<div class="font-medium">{{ brew.roast_name }}</div>
|
|
<div class="hidden md:block text-xs text-text-muted">{{ brew.roaster_name }}</div>
|
|
</td>
|
|
```
|
|
|
|
**Mobile** — separate `<td>` for each sub-field:
|
|
|
|
```html
|
|
<td data-label="Roaster" class="px-4 py-3 whitespace-nowrap md:hidden">
|
|
{{ brew.roaster_name }}
|
|
</td>
|
|
```
|
|
|
|
### Pagination & Infinite Scroll
|
|
|
|
- **Desktop** (`md:+`): pagination controls via `pagination_header` macro
|
|
- **Mobile** (below `md:`): infinite scroll via `IntersectionObserver` in `base.html`, activated only on mobile via `matchMedia("(max-width: 767px)")`
|
|
|
|
### Search
|
|
|
|
Server-side via `q` query parameter. `ListQuery` extracts it, repos apply `LIKE` filtering. `ListNavigator` preserves the search term across pagination and sort URLs.
|
|
|
|
`ListNavigator` URL helpers: `page_href()` (full page), `fragment_page_href()` (with anchor), `sort_href()`.
|
|
|
|
## Code Style
|
|
|
|
### Rust
|
|
|
|
**Extract large closures** — if a closure in `.filter_map()`, `.map()`, etc. exceeds ~10 lines, extract into a named function.
|
|
|
|
**DRY repeated blocks** — when 3+ blocks follow the same structure differing only by a parameter, extract a helper.
|
|
|
|
**Prefer `match` over `if/else-if`** when branching on the same variable.
|
|
|
|
**Extract shared predicates** — if the same boolean condition appears in multiple functions, make it a named helper.
|
|
|
|
**Use generic helpers** — when the same decode/encode/match pattern appears 3+ times with different types.
|
|
|
|
### JavaScript
|
|
|
|
- **Never use `var`** — always `const` (default) or `let` (when reassignment needed)
|
|
- **Never use `function` declarations** — always arrow functions: `const fn = () => { ... }`
|
|
- **Always use template literals** for interpolation — never `+` concatenation
|
|
- Prefer `if`/`else` and `switch` over ternaries; never nest ternaries
|
|
- **Inline `onclick` + global arrow functions** for pages with imperative JS — do not use `DOMContentLoaded` + `addEventListener`
|
|
|
|
### Naming & Conventions
|
|
|
|
- **Method naming**: `order_clause()` for sort query builders (not `sort_clause`)
|
|
- **Imports**: Group by `super::`, then `crate::`, macros imported explicitly
|
|
- **SQL strings**: Use raw strings `r#"..."#` for multi-line queries
|
|
- **Tests**: Integration tests in `tests/cli/` and `tests/server/`. External APIs mocked with `wiremock`. See [Test Macros](#test-macros) below
|
|
- **Commits**: Conventional Commit format (`feat:`, `fix:`, `refactor:`, etc.)
|
|
- **Commit authorship**: Never add "Co-Authored-By" trailers
|
|
- **Commit signing**: Never use `--no-gpg-sign` — always allow default GPG signing
|
|
- **Committing**: Never commit unless explicitly prompted — provide a draft commit message instead
|
|
|
|
### Test Macros
|
|
|
|
Repeated test patterns are generated via macros in `tests/server/test_macros.rs` and `tests/cli/test_macros.rs`. Use these instead of hand-writing boilerplate tests.
|
|
|
|
**Server API tests** (`tests/server/test_macros.rs`):
|
|
|
|
`define_crud_tests!` — generates nonexistent-GET-404, nonexistent-DELETE-404, empty-list-200, and optionally malformed-JSON-400 and missing-fields-400:
|
|
|
|
```rust
|
|
use crate::test_macros::define_crud_tests;
|
|
define_crud_tests!(
|
|
entity: roaster, path: "/roasters", list_type: Roaster,
|
|
malformed_json: r#"{"name": "Test", "country": }"#,
|
|
missing_fields: r#"{"name": "Test Roasters"}"#
|
|
);
|
|
```
|
|
|
|
`define_datastar_entity_tests!` — generates list-with-fragment, list-without-full-page, and delete-with-fragment tests. Requires a setup function that creates an entity and returns its ID as `String`:
|
|
|
|
```rust
|
|
use crate::test_macros::define_datastar_entity_tests;
|
|
define_datastar_entity_tests!(
|
|
entity: roasters, type_param: "roasters", api_path: "/roasters",
|
|
list_element: r#"id="roaster-list""#, selector: "#roaster-list",
|
|
setup: create_roaster_entity
|
|
);
|
|
```
|
|
|
|
**CLI tests** (`tests/cli/test_macros.rs`):
|
|
|
|
`define_cli_auth_test!` — asserts a command fails without a token:
|
|
|
|
```rust
|
|
define_cli_auth_test!(test_add_roaster_requires_authentication,
|
|
&["roaster", "add", "--name", "Test", "--country", "UK"]);
|
|
```
|
|
|
|
`define_cli_list_test!` — asserts a list command succeeds without auth and returns a JSON array:
|
|
|
|
```rust
|
|
define_cli_list_test!(test_list_roasters_works_without_authentication,
|
|
&["roaster", "list"]);
|
|
```
|
|
|
|
**Helper generics**: `create_entity<P, R>()` in `tests/server/helpers.rs` (POST + auth + deserialize) and `create_entity_cli()` in `tests/cli/helpers.rs` (run + assert + parse ID) eliminate duplication in entity creation helpers.
|
|
|
|
## Communication Style
|
|
|
|
- Be direct and factual
|
|
- Analyse root causes before proposing solutions
|
|
- Prefer simple solutions over complex ones
|
|
- When proposing changes, explain the trade-offs
|