- Reorganise from single mega-section into layer-based structure (Backend, Datastar/Frontend, Design System, Tables/Lists) - Promote 8 critical gotchas into dedicated top-level section - Deduplicate signal naming (3x), route patterns (3x), form patterns - Replace code examples with macro reference table pointing to source - Trim design token table to name→class mapping (reference input.css) - Consolidate error/logging rules into single section - All patterns, rules, and conventions preserved
607 lines
27 KiB
Markdown
607 lines
27 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/PostgreSQL database support (feature-flagged)
|
|
|
|
## 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.** All assets are embedded at compile time via `include_str!()`/`include_bytes!()` with explicit routes in `application/routes/mod.rs`. There is no `tower-http` static file serving.
|
|
|
|
## Backend Patterns
|
|
|
|
### 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 (border, padding, rounded, focus ring) |
|
|
| `.btn-adjust` | +/- stepper buttons flanking a numeric input |
|
|
| `.pill.pill-accent` | Tasting notes, highlighted tags |
|
|
| `.pill.pill-muted` | Categories, kind labels, neutral status |
|
|
| `.pill.pill-success` | Positive status ("Open") |
|
|
|
|
Always include `.pill` base class with the variant. Do not override pill colour/border/padding with utilities.
|
|
|
|
Cards use `rounded-lg border bg-surface p-4` — no shadows.
|
|
|
|
### UI Patterns
|
|
|
|
#### Typography
|
|
|
|
| Level | Classes | Use |
|
|
|-------|---------|-----|
|
|
| Page title | `text-3xl font-semibold` | `<h1>` on each page |
|
|
| Section title | `text-lg font-semibold text-text` | `<h2>` / `<h3>` for cards |
|
|
| Subsection title | `text-base font-semibold text-text` | `<h3>` within cards |
|
|
| Form section label | `text-xs font-semibold text-text-muted uppercase tracking-wide` | `<h4>` grouping fields |
|
|
| Body | `text-sm text-text-secondary` | Description text |
|
|
| Muted | `text-xs text-text-muted` | Metadata, subtext |
|
|
| Accent title | `text-2xl font-semibold text-accent` | Login/register headers |
|
|
|
|
Page headers: `<header class="flex flex-col gap-2">` with `<h1>` + `<p class="max-w-2xl text-sm text-text-secondary">`.
|
|
|
|
#### Buttons
|
|
|
|
| Variant | Classes | Use |
|
|
|---------|---------|-----|
|
|
| Primary | `rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover` | Save, Submit, Check In |
|
|
| Secondary | `rounded-md border px-4 py-2 text-sm font-semibold text-text transition hover:border-accent hover:text-text` | Cancel, Back |
|
|
| Text-only | `text-xs text-text-muted hover:text-text` | Change, Back in summary bars |
|
|
| Icon-only | `inline-flex h-8 w-8 items-center justify-center rounded-md p-1 text-text-muted transition hover:text-red-600` | Delete, revoke |
|
|
| Link-style | `inline-flex items-center gap-1 text-sm font-medium text-accent hover:text-accent-hover` | Brew Again, View all |
|
|
| Adjustment | `.btn-adjust` CSS class | +/- steppers |
|
|
|
|
Submit buttons are right-aligned: `<div class="flex items-center justify-end gap-2">`. Secondary comes first when paired.
|
|
|
|
Use `w-full` for full-width CTAs, `py-3` for larger touch targets, `disabled:opacity-50` with `data-attr:disabled` for loading states.
|
|
|
|
#### 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 `{% call 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.
|
|
|
|
#### 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 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`.
|
|
|
|
#### 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 |
|
|
|---------|-----|
|
|
| Major form sections | `gap-6` |
|
|
| Related field groups | `gap-4` |
|
|
| Label to input | `gap-1` |
|
|
| Icon + text pairs | `gap-2` |
|
|
| Navigation links | `gap-6` |
|
|
| Button groups | `gap-2` |
|
|
| Page sections | `gap-8` |
|
|
|
|
## 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`
|
|
- **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
|
|
|
|
## 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
|